merge: fix/governance-v2-codex — three-workflow governance and Codex P0
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: 强制 Agent 文档同步与 ops 协作纪律;详情见 AGENTS.md
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent 文档同步(强制)
|
||||
|
||||
完整规范见 [AGENTS.md](../../AGENTS.md) 与 [PROJECT.md](../../PROJECT.md)。
|
||||
|
||||
## 开场必读
|
||||
|
||||
1. `AGENTS.md` → `PROJECT.md` → `.doc/ops/CURRENT.md` → 相关 short-term / handoff
|
||||
|
||||
## 红线
|
||||
|
||||
- 任何任务/调研/审计/修复/运维:**开始前**建或更新 `.doc/ops/short-term/` 或 `long-term/`(复制 templates)
|
||||
- **禁止**只改代码、真相只留在聊天
|
||||
- 里程碑更新 `CURRENT.md`;会话结束写 `handoffs/`
|
||||
- 验收报告进 `.doc/test-reports/` 并回链 checklist
|
||||
- 文档与代码同批或紧随 `docs(...)` 提交
|
||||
|
||||
## 状态
|
||||
|
||||
`planned | in_progress | blocked | done | cancelled`
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: Git push 凭证自主读取;禁止向用户索要 token;详情见 AGENTS.md §8
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git Secrets 自主(禁止索要)
|
||||
|
||||
完整规范见 [AGENTS.md](../../AGENTS.md) §8 与 [PROJECT.md](../../PROJECT.md) §7。
|
||||
|
||||
## 本仓约定
|
||||
|
||||
- 凭证:`${HOME}/.secrets/gitea`
|
||||
- 字段:`- ACCESS_TOKEN=`、`- URL_PREFIX=`
|
||||
- 推送:优先 `./scripts/git-push-dev.sh <branch>`
|
||||
|
||||
## 禁止
|
||||
|
||||
- 向用户索要或要求粘贴 token
|
||||
- 在聊天/文档/commit 中展示 secret
|
||||
- 写入 git config 或提交到仓库
|
||||
|
||||
## 缺失时
|
||||
|
||||
在 `.doc/ops` 记 blocker(路径 + 缺字段),提示用户本机补齐后重试;**仍不索要内容**。
|
||||
@@ -0,0 +1,312 @@
|
||||
# 项目全面审计 - 文档索引
|
||||
|
||||
> **HISTORICAL(2026-09-12 初审快照)**
|
||||
> **当前发布/缺口 SSOT** → [remediation-gap-matrix.md](./2026-09-12-remediation-gap-matrix.md)
|
||||
> **协作焦点** → [.doc/ops/CURRENT.md](./ops/CURRENT.md)
|
||||
> **Agent 工作方式** → [AGENTS.md](../AGENTS.md) · [PROJECT.md](../PROJECT.md)
|
||||
|
||||
**审计日期**: 2026-09-12
|
||||
**项目**: Chat-Agent-System
|
||||
**审计状态**: 历史索引(下方 5.7/10 为初审结论,勿作 live status)
|
||||
|
||||
---
|
||||
|
||||
## 核心文档(按阅读顺序推荐)
|
||||
|
||||
### 0️⃣ 当前状态(Live SSOT)
|
||||
|
||||
**文件**: `2026-09-12-remediation-gap-matrix.md`
|
||||
**协作**: `.doc/ops/CURRENT.md` · `AGENTS.md` · `PROJECT.md`
|
||||
**用途**: 发布缺口、Codex 再审计闭环证据、发布门禁勾选
|
||||
|
||||
---
|
||||
|
||||
### 1️⃣ 从这里开始:审计总结报告(历史)
|
||||
**文件**: `2026-09-12-audit-summary-report.md`
|
||||
**用途**: 项目整体现状、关键数字、风险评估
|
||||
**适合**: 项目经理、技术负责人、快速概览
|
||||
**阅读时间**: 15-20 分钟
|
||||
|
||||
**关键内容**:
|
||||
- 项目评分及各维度打分
|
||||
- 12 个已确认缺陷分布(critical/high/medium)
|
||||
- 未完成功能清单
|
||||
- 开发库数据污染现状(88 个测试定义)
|
||||
- 整改时间与资源规划
|
||||
- 立即行动建议
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 详细缺陷与整改:综合整改方案
|
||||
**文件**: `2026-09-12-comprehensive-audit-and-remediation-plan.md`
|
||||
**用途**: 12 个缺陷的详细分析 + 3 个新功能的整改方案
|
||||
**适合**: 架构师、资深开发工程师
|
||||
**阅读时间**: 45-60 分钟
|
||||
|
||||
**关键内容**:
|
||||
- BUG-01 到 BUG-12 的代码现场与后果分析
|
||||
- 5 个 high 缺陷的深层原因
|
||||
- 3 个 MISSING 新功能的实现设计
|
||||
- 3 个 Phase (A/B/C) 的整改计划
|
||||
- 风险预案与建议
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 可执行的任务拆分:任务清单与验收
|
||||
**文件**: `2026-09-12-remediation-tasks-and-acceptance-checklist.md`
|
||||
**用途**: 具体的代码修改指引、单元测试框架、验收标准
|
||||
**适合**: 开发工程师、QA、代码审查人
|
||||
**阅读时间**: 60-90 分钟
|
||||
|
||||
**关键内容**:
|
||||
- Task A1-A6:关键缺陷修复(15h)
|
||||
- 每个 Task 的修改文件、代码片段、测试用例
|
||||
- 验收标准与预计工作量
|
||||
- Task B1-B3:高优先级新功能(26h)
|
||||
- 受控终止 API 的完整实现
|
||||
- E2E 测试框架与运行命令
|
||||
- 项目上下文快照的数据模型与集成点
|
||||
- Task C1-C7:完善与发布(20h)
|
||||
- 全套验收检查表
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ 快速参考与速查:快速指南
|
||||
**文件**: `2026-09-12-quick-reference-guide.md`
|
||||
**用途**: 问题症状→原因→解决方案的快速诊断
|
||||
**适合**: 所有开发人员、值班人员
|
||||
**阅读时间**: 5-10 分钟(按需查阅)
|
||||
|
||||
**关键内容**:
|
||||
- 10 个常见症状的诊断矩阵
|
||||
- 代码热点地图(哪个文件有哪个 bug)
|
||||
- 整改优先级排序
|
||||
- 代码修改检查清单
|
||||
- 数据库迁移清单
|
||||
- 测试命令参考
|
||||
- API 变动汇总
|
||||
- 常见问题 Q&A
|
||||
|
||||
---
|
||||
|
||||
## 配套参考文档
|
||||
|
||||
### 业务需求原文
|
||||
**文件**: `2026-09-12-three-workflows-strict-governance-remediation.md`
|
||||
**用途**: 业务决策、验收标准的原始出处
|
||||
**适合**: 产品、架构、QA(需要理解业务背景)
|
||||
|
||||
**关键章节**:
|
||||
- 第 3 章:不可违反的业务规则
|
||||
- 第 7 章:验收标准(7.1-7.5)
|
||||
- 第 8-9 章:已确认决策与实施解释
|
||||
|
||||
---
|
||||
|
||||
## 快速导航
|
||||
|
||||
### 我是 PM/项目经理
|
||||
```
|
||||
1. 读 audit-summary-report.md 第一部分(总体评估)
|
||||
2. 重点关注第九章(建议行动计划)
|
||||
3. 查阅 quick-reference-guide.md 第三部分(优先级排序)
|
||||
4. 预估资源与时间
|
||||
```
|
||||
|
||||
### 我是后端开发
|
||||
```
|
||||
1. 读 quick-reference-guide.md(快速了解要改什么)
|
||||
2. 针对每个 task 查阅 remediation-tasks-and-acceptance-checklist.md
|
||||
3. 参考 comprehensive-audit-and-remediation-plan.md 理解原因
|
||||
4. 按照 task 中的"修改文件"和"代码片段"进行开发
|
||||
5. 执行"测试用例"部分验证修改
|
||||
```
|
||||
|
||||
### 我是 QA/测试工程师
|
||||
```
|
||||
1. 读 audit-summary-report.md 第十一章(验收标准清单)
|
||||
2. 查阅 quick-reference-guide.md 第五部分(检查清单)
|
||||
3. 针对每个 task 制定测试计划
|
||||
4. 执行 comprehensive-audit-and-remediation-plan.md 中的验收标准
|
||||
5. 特别关注 remediation-tasks 的 E2E 部分
|
||||
```
|
||||
|
||||
### 我是前端工程师
|
||||
```
|
||||
1. 读 quick-reference-guide.md 第三部分(了解哪些前端需改)
|
||||
2. 查看 BUG-09、BUG-10、BUG-12 的具体改动
|
||||
3. 关注 comprehensive-audit 中的前端相关缺陷
|
||||
4. 参考 remediation-tasks 的 API 变动部分
|
||||
```
|
||||
|
||||
### 我是架构师/技术负责人
|
||||
```
|
||||
1. 读 audit-summary-report.md(整体判定)
|
||||
2. 深入 comprehensive-audit-and-remediation-plan.md(详细分析)
|
||||
3. 关注第八章(架构建议)和第十二章(结语)
|
||||
4. 制定整改计划和资源分配
|
||||
```
|
||||
|
||||
### 我要处理现场问题
|
||||
```
|
||||
1. 查询 quick-reference-guide.md 第五部分(症状→原因→解决)
|
||||
2. 找到对应的 bug 号
|
||||
3. 查阅 comprehensive-audit 的完整分析
|
||||
4. 按照 remediation-tasks 中的修复步骤操作
|
||||
5. 若需临时绕过,参考 quick-reference-guide 的建议
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文档关系图
|
||||
|
||||
```
|
||||
入口文档
|
||||
↓
|
||||
audit-summary-report.md ← 总体评估与建议
|
||||
↓
|
||||
├→ comprehensive-audit-and-remediation-plan.md ← 详细缺陷分析
|
||||
│ ├─ BUG-01 到 BUG-12 详解
|
||||
│ ├─ MISSING-01 到 MISSING-04 详解
|
||||
│ └─ Phase A/B/C 整改计划
|
||||
│
|
||||
├→ remediation-tasks-and-acceptance-checklist.md ← 可执行任务
|
||||
│ ├─ Task A1-A6(关键缺陷)
|
||||
│ ├─ Task B1-B3(新功能)
|
||||
│ └─ 验收检查表
|
||||
│
|
||||
├→ quick-reference-guide.md ← 快速查询
|
||||
│ ├─ 症状诊断矩阵
|
||||
│ ├─ 代码热点地图
|
||||
│ ├─ 优先级排序
|
||||
│ └─ 常见问题 Q&A
|
||||
│
|
||||
└→ three-workflows-strict-governance-remediation.md ← 业务背景
|
||||
├─ 业务需求与决策
|
||||
├─ 验收标准
|
||||
└─ 来源证据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键指标速查
|
||||
|
||||
| 指标 | 数值 | 位置 |
|
||||
|---|---|---|
|
||||
| 项目完成度 | 70% | audit-summary-report.md:1.2 |
|
||||
| 总体评分 | 5.7/10 | audit-summary-report.md:1.1 |
|
||||
| 缺陷总数 | 12 个 | audit-summary-report.md:2.1 |
|
||||
| Critical 缺陷 | 3 个 | audit-summary-report.md:2.1 |
|
||||
| 整改工作量 | 61h | audit-summary-report.md:9.1 |
|
||||
| 单元测试覆盖率 | 85% | audit-summary-report.md:1.2 |
|
||||
| E2E 覆盖率 | 0% | audit-summary-report.md:1.2 |
|
||||
| 开发库污染定义 | 88 个 | audit-summary-report.md:4.1 |
|
||||
|
||||
---
|
||||
|
||||
## 立即行动清单
|
||||
|
||||
**今天需要做的事**(第一天):
|
||||
|
||||
```
|
||||
[ ] 技术负责人读 audit-summary-report.md(20 分钟)
|
||||
[ ] 架构师读 comprehensive-audit-and-remediation-plan.md(60 分钟)
|
||||
[ ] 后端 A 开发师启动 Task A1(2h)- 项目生命周期修复
|
||||
[ ] 后端 A 开发师启动 Task A4(2h)- 对话推进新服务
|
||||
[ ] QA 准备 E2E 测试框架(同步进行)
|
||||
[ ] 全队讨论确认整改计划(30 分钟)
|
||||
```
|
||||
|
||||
**本周目标**(5 天):
|
||||
|
||||
```
|
||||
Day 1: 完成 Phase A 关键缺陷修复(15h)
|
||||
Day 2-3: 启动 Phase B 新功能开发并行(26h)
|
||||
Day 4-5: Phase C 完善与发布准备(20h)
|
||||
+ 每晚运行完整测试套件验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见路径快查
|
||||
|
||||
**"完成工作流后项目仍然是 active"**
|
||||
→ quick-reference-guide.md 第二部分 症状 1
|
||||
→ comprehensive-audit 第二部分 BUG-01
|
||||
→ remediation-tasks 的 Task A1
|
||||
|
||||
**"对话推进不起作用"**
|
||||
→ quick-reference-guide.md 第二部分 症状 3
|
||||
→ comprehensive-audit 第二部分 BUG-04
|
||||
→ remediation-tasks 的 Task A4
|
||||
|
||||
**"禁用文档仍能被打开"**
|
||||
→ quick-reference-guide.md 第二部分 症状 2
|
||||
→ comprehensive-audit 第二部分 BUG-05
|
||||
→ remediation-tasks 的 Task A5
|
||||
|
||||
**"激活 token 可能被重复使用"**
|
||||
→ quick-reference-guide.md 第二部分 症状 6
|
||||
→ comprehensive-audit 第二部分 BUG-06
|
||||
→ remediation-tasks 的 Task A6
|
||||
|
||||
**"无法运行测试流程"**
|
||||
→ quick-reference-guide.md 第二部分 症状 7
|
||||
→ comprehensive-audit 第三部分 MISSING-06
|
||||
→ remediation-tasks 的 Phase B 开始
|
||||
|
||||
**"需要完整 E2E 验证"**
|
||||
→ comprehensive-audit 第三部分 MISSING-04
|
||||
→ remediation-tasks 的 Task B2
|
||||
|
||||
---
|
||||
|
||||
## 发布检查清单
|
||||
|
||||
**上线前必须确认**:
|
||||
|
||||
```
|
||||
整改完成度:
|
||||
□ Phase A 全部 PASS
|
||||
□ Phase B 全部 PASS
|
||||
□ Phase C 全部 PASS
|
||||
|
||||
测试覆盖:
|
||||
□ 单元测试覆盖率 ≥ 85%
|
||||
□ E2E 测试三工作流完整链路
|
||||
□ 并发测试通过
|
||||
□ 数据库迁移兼容性验证
|
||||
|
||||
业务验收:
|
||||
□ 验收标准 7.1-7.5 全部满足
|
||||
□ 不存在 critical 缺陷
|
||||
□ 安全性审计通过
|
||||
|
||||
运维就绪:
|
||||
□ 数据库备份与恢复方案
|
||||
□ 审计日志完整
|
||||
□ 监控告警配置
|
||||
□ 性能基准测试
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文档维护
|
||||
|
||||
这套审计文档为 v1.0,应定期更新:
|
||||
|
||||
- **每完成一个 task**: 更新对应 task 的状态
|
||||
- **每发现新缺陷**: 补充到 comprehensive-audit-and-remediation-plan.md
|
||||
- **每月**: 做一次整体进度评估和文档更新
|
||||
|
||||
---
|
||||
|
||||
**审计完毕**
|
||||
|
||||
**下一步**: 确认整改计划,进入实施阶段
|
||||
|
||||
**总体建议**: 立即启动 Phase A(关键缺陷),并行启动 E2E 测试框架,一周内完成所有整改。
|
||||
|
||||
---
|
||||
|
||||
*本索引最后更新于 2026-09-12 15:05 UTC*
|
||||
@@ -0,0 +1,440 @@
|
||||
# 项目全面审计总结报告
|
||||
|
||||
> **SUPERSEDED** — 历史初审快照。Live 状态见 [remediation-gap-matrix.md](./2026-09-12-remediation-gap-matrix.md) 与 [.doc/ops/CURRENT.md](./ops/CURRENT.md)。
|
||||
|
||||
**审计日期**: 2026-09-12
|
||||
**审计人**: 代码审计
|
||||
**项目**: Chat-Agent-System (项目工作流与对话智能体系统)
|
||||
**分支**: dev
|
||||
**状态**: 架构完整,核心功能 70% 完成,存在 12 个已识别缺陷
|
||||
|
||||
---
|
||||
|
||||
## 一、项目总体评估
|
||||
|
||||
### 1.1 系统架构评分
|
||||
|
||||
| 维度 | 评分 | 评语 |
|
||||
|---|---|---|
|
||||
| 架构设计 | 8/10 | 三工作流治理顶层决策清晰,状态机完备,符合业务需求 |
|
||||
| 功能完整度 | 5/10 | 核心框架 70%,关键模块(上下文快照、终止)0%,多个 bug 待修 |
|
||||
| 代码质量 | 6/10 | 单元测试覆盖 OK,缺失 E2E;对话-服务耦合需解 |
|
||||
| 数据一致性 | 4/10 | 项目生命周期转换不完整,禁用文档无显式标记 |
|
||||
| 安全性 | 5/10 | 权限框架存在,执行不一致;token 处理存在漏洞 |
|
||||
| 运维成熟度 | 6/10 | DB 迁移和测试隔离需完善 |
|
||||
|
||||
**综合评分**: **5.7/10** → **需立即整改**
|
||||
|
||||
---
|
||||
|
||||
### 1.2 关键指标
|
||||
|
||||
| 指标 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| 工作流定义总数 | 92 | 其中 88 个为测试/过期定义,三项正式 |
|
||||
| 项目实例数 | 79 | active 32, completed 47 |
|
||||
| 已确认缺陷 | 12 | 3 critical, 4 high, 5 medium |
|
||||
| 单元测试覆盖率 | 85% | 主要包:projectinstance, coordinator, workflowdef |
|
||||
| E2E 覆盖率 | 0% | **待补充** |
|
||||
| 代码行数 | ~15k | Go backend + ~8k web frontend |
|
||||
|
||||
---
|
||||
|
||||
## 二、缺陷分布与风险等级
|
||||
|
||||
### 2.1 严重缺陷(Critical)- 立即修复
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ BUG-01: 完成后项目生命周期未更新 [CRITICAL] │
|
||||
│ 风险: 项目永久锁死,用户无法配置下一流程 │
|
||||
│ 优先级: 🔴 P0 (2h) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ BUG-02: 推进强制批准文档 [CRITICAL] │
|
||||
│ 风险: 违反业务规则,数据不一致 │
|
||||
│ 优先级: 🔴 P0 (1h) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ BUG-04: 对话推进调用旧服务 [CRITICAL] │
|
||||
│ 风险: 绕过新权限检查,双轨运行 │
|
||||
│ 优先级: 🔴 P0 (2h) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 高优先级缺陷(High)- 本周修复
|
||||
|
||||
```
|
||||
BUG-03: 禁用文档无显式 skipped_disabled 标记
|
||||
BUG-05: 禁用文档拒绝逻辑不一致
|
||||
BUG-06: 激活 token 安全问题
|
||||
BUG-07: 项目完成后下一流程配置缺失
|
||||
```
|
||||
|
||||
### 2.3 中低优先级缺陷(Medium/Low)- 下周处理
|
||||
|
||||
```
|
||||
BUG-08: 测试数据库隔离不完全
|
||||
BUG-09: 版本冲突时前端自动重试
|
||||
BUG-10: 项目设置无审计视图
|
||||
BUG-11: 旧 API 未正式下线
|
||||
BUG-12: 前端名称不一致
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、未完成功能模块
|
||||
|
||||
### 3.1 缺失模块清单
|
||||
|
||||
| 模块 | 完成度 | 影响 | 优先级 |
|
||||
|---|---|---|---|
|
||||
| ProjectContextSnapshot | 0% | Phase 3 必需,两智能工作流血缘依赖 | 🔴 P1 |
|
||||
| WorkflowTermination | 10% | 实例可永久锁死 | 🔴 P1 |
|
||||
| DocumentAccessDecider | 50% | 拒绝逻辑分散,安全隐患 | 🔴 P1 |
|
||||
| E2E Tests (PG+PI) | 0% | 验收标准 7.5 要求 | 🔴 P1 |
|
||||
| 项目生命周期完整转换 | 40% | 完成后无法切换 | 🔴 P1 |
|
||||
|
||||
### 3.2 代码现状与缺陷位置
|
||||
|
||||
**问题集中地**:
|
||||
- `internal/projectinstance/stage_store.go:71-158` - 推进事务问题
|
||||
- `internal/coordinator/engine.go:672-811` - 对话推进耦合
|
||||
- `internal/projectinstance/service.go:119-135` - 生命周期不完整
|
||||
- `internal/projectinstance/stage_access.go:15-71` - 拒绝逻辑不一致
|
||||
- `web/src/components/workflow/ProjectWorkflowSwitcher.tsx` - 前端名称&自动重试
|
||||
|
||||
---
|
||||
|
||||
## 四、数据一致性风险分析
|
||||
|
||||
### 4.1 开发库现状快照
|
||||
|
||||
```
|
||||
发现问题:
|
||||
┌────────────────────────────────────────────┐
|
||||
│ workflow_definitions 92 个 │
|
||||
│ ├─ 正式三项 3 个 ✅ │
|
||||
│ ├─ 过期/历史定义 1 个 ❌ │
|
||||
│ └─ 测试污染 88 个 ❌❌❌ │
|
||||
│ │
|
||||
│ project_workflow_instances 79 个 │
|
||||
│ ├─ active 32 个 │
|
||||
│ ├─ completed 47 个 │
|
||||
│ └─ 问题: completed 项目 lifecycle 仍为 │
|
||||
│ active (47 个不一致) ❌ │
|
||||
│ │
|
||||
│ projects.lifecycle_status 分布 │
|
||||
│ ├─ active 97 个 │
|
||||
│ ├─ planning 51 个 │
|
||||
│ └─ completed 0 个 (不支持) │
|
||||
│ │
|
||||
│ 隔离问题: │
|
||||
│ 集成测试未设置 TEST_DATABASE_URL │
|
||||
│ → 默认连接本地 agent_system_dev │
|
||||
│ → 88 个定义污染 ❌❌ │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**风险评估**:
|
||||
- **数据污染**: 🔴 严重 - 无法清洁复现
|
||||
- **生命周期不一致**: 🔴 严重 - 47 个实例数据已破损
|
||||
- **隔离缺失**: 🟠 高 - CI/CD 不可信
|
||||
|
||||
---
|
||||
|
||||
## 五、安全性审计结论
|
||||
|
||||
### 5.1 权限检查
|
||||
|
||||
| 检查项 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 激活权限 | ✅ | 仅创建者可激活 |
|
||||
| 推进权限 | ✅ | 仅创建者可推进 |
|
||||
| 文档职责 | ✅ | 三权互斥、必填 |
|
||||
| 禁用文档拒绝 | ❌ | 不一致,存在旁路 |
|
||||
| Token 安全 | ❌ | 明文/重复消费风险 |
|
||||
| 跨项目访问 | ⚠️ | 框架存在但执行不一 |
|
||||
|
||||
### 5.2 威胁模型
|
||||
|
||||
**高风险场景**:
|
||||
|
||||
1. **禁用文档旁路**(CVSS 6.5)
|
||||
- 通过 SSE 或 Agent tool 绕过禁用检查
|
||||
- 修改方案: Task A5 `DocumentAccessDecider`
|
||||
|
||||
2. **Token 重放**(CVSS 7.2)
|
||||
- 激活 token 明文保存,可多次使用
|
||||
- 修改方案: Task A6 token hash + one-time
|
||||
|
||||
3. **对话权限绕过**(CVSS 7.8)
|
||||
- 对话推进绕过实例权限检查
|
||||
- 修改方案: Task A4 统一服务调用
|
||||
|
||||
---
|
||||
|
||||
## 六、性能与扩展性评估
|
||||
|
||||
| 方面 | 评估 | 建议 |
|
||||
|---|---|---|
|
||||
| 查询性能 | 单项查询 <100ms,列表查询 <500ms | 应补充 N+1 和批量查询测试 |
|
||||
| 并发处理 | 无明显 race condition 检测到 | 应补充压力测试 |
|
||||
| 大文档处理 | 上下文快照未实现 | 建议流式检索和分页 |
|
||||
| 缓存策略 | 定义缓存 OK,实例缓存缺失 | 应补充 Redis 集成 |
|
||||
|
||||
---
|
||||
|
||||
## 七、测试覆盖现状
|
||||
|
||||
### 7.1 单元测试
|
||||
|
||||
```
|
||||
✅ PASS 套件:
|
||||
- projectinstance: 45 tests, avg 0.1s/test
|
||||
- coordinator: 32 tests, avg 0.01s/test
|
||||
- workflowdef: 28 tests, avg 0.05s/test
|
||||
- server: 15 tests, avg 0.2s/test
|
||||
|
||||
总计: 120+ 单元测试
|
||||
|
||||
❌ 缺失:
|
||||
- ProjectLifecycleAfterCompletion
|
||||
- DisabledDocumentPersistence
|
||||
- AdvanceStageDoesNotModifyApproval
|
||||
- DialogueAdvanceStageUsesNewService
|
||||
- ActivationTokenSecurity
|
||||
- TerminationWorkflow (全部)
|
||||
- ProjectContextSnapshot (全部)
|
||||
```
|
||||
|
||||
### 7.2 集成测试
|
||||
|
||||
```
|
||||
✅ 可运行:
|
||||
- workflow_governance_pg_test.go
|
||||
- phase5_fault_injection_pg_test.go
|
||||
|
||||
❌ 问题:
|
||||
- 未设置 TEST_DATABASE_URL 时连接开发库
|
||||
- 测试定义发布后无清理
|
||||
- 并发测试不完整
|
||||
```
|
||||
|
||||
### 7.3 E2E 测试
|
||||
|
||||
```
|
||||
❌ 完全缺失:
|
||||
- 真实 PostgreSQL 端到端验证
|
||||
- PI Agent 集成测试
|
||||
- 三工作流完整链路
|
||||
- 项目生命周期转换
|
||||
|
||||
需补充:
|
||||
- setupE2ETestDB()
|
||||
- TestE2E_BizdevWorkflow_Complete
|
||||
- TestE2E_ProductManagerWorkflow_Standalone
|
||||
- TestE2E_TestEngineerWorkflow_WithoutSRS
|
||||
- TestE2E_ProjectTransitionToNextWorkflow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、架构建议
|
||||
|
||||
### 8.1 保留并强化
|
||||
|
||||
- ✅ 三工作流隔离模型 - 业务模型清晰
|
||||
- ✅ 实例冻结快照 - 运行权威保证
|
||||
- ✅ 激活/推进事务 - 数据一致性基础
|
||||
- ✅ 通知和对话耦合 - 用户体验友好
|
||||
|
||||
### 8.2 改进点
|
||||
|
||||
- 🔧 禁用文档持久化 - 明确 `skipped_disabled` 状态
|
||||
- 🔧 权限决策集中化 - `DocumentAccessDecider` 统一拒绝逻辑
|
||||
- 🔧 生命周期完整转换 - 完成/终止时更新项目状态
|
||||
- 🔧 项目上下文快照 - 支持多来源血缘追溯
|
||||
|
||||
### 8.3 新增模块
|
||||
|
||||
- 📦 项目上下文快照服务 - 两智能工作流共用
|
||||
- 📦 受控终止 API - 异常恢复能力
|
||||
- 📦 审计日志完整化 - 故障排查与合规
|
||||
|
||||
---
|
||||
|
||||
## 九、整改时间与资源规划
|
||||
|
||||
### 9.1 时间估算
|
||||
|
||||
```
|
||||
Phase A (关键缺陷) : 15h
|
||||
│
|
||||
├─ Task A1-A2 : 3h
|
||||
├─ Task A3 : 3h
|
||||
├─ Task A4-A5 : 6h
|
||||
└─ Task A6 : 3h
|
||||
|
||||
Phase B (新功能) : 26h
|
||||
│
|
||||
├─ Task B1 (终止) : 6h
|
||||
├─ Task B2 (E2E) : 8h
|
||||
└─ Task B3 (快照) : 12h
|
||||
|
||||
Phase C (完善) : 20h
|
||||
│
|
||||
├─ UI/文档/迁移 : 10h
|
||||
├─ 集成验证 : 6h
|
||||
└─ 发布准备 : 4h
|
||||
|
||||
总计: ~61 小时
|
||||
= 2.5 天(单人全职)
|
||||
= 5 天(双人并行)
|
||||
```
|
||||
|
||||
### 9.2 资源配置建议
|
||||
|
||||
```
|
||||
推荐配置: 2 人团队
|
||||
├─ 开发 A (后端):
|
||||
│ └─ Phase A (15h) + Phase B 部分 (8h)
|
||||
│
|
||||
├─ 开发 B (全栈):
|
||||
│ └─ Phase B 部分 (18h) + Phase C (20h)
|
||||
│
|
||||
└─ QA (并行):
|
||||
└─ E2E 测试框架、验收检查
|
||||
|
||||
里程碑:
|
||||
Day 1 (8h): Phase A 完成,单测 PASS
|
||||
Day 2 (8h): Phase B 进行中,E2E 框架启动
|
||||
Day 3 (8h): Phase B 完成,真实链路验证
|
||||
Day 4 (8h): Phase C 完善,发布准备
|
||||
Day 5 (5h): 最终验收,合并主分支
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、验收标准清单
|
||||
|
||||
### 10.1 功能验收(来自 `.doc/2026-09-12-three-workflows-strict-governance-remediation.md:417-459`)
|
||||
|
||||
**7.1 目录和定义**
|
||||
- [ ] 任意用户在项目选择页都只能看到 3 个工作流 ✅ (已实施)
|
||||
- [ ] 正式名称精确为"商务对接流程 / 智能产品经理流程 / 智能测试工程师流程" ✅
|
||||
- [ ] 定义快照精确为 7 / 1 / 1 ⚠️ (main.v2.json 缺 2 个)
|
||||
- [ ] 三个正式定义均没有流程级上游依赖 ✅
|
||||
|
||||
**7.2 选择和冻结**
|
||||
- [ ] 只有项目创建者可保存、激活和推进 ✅
|
||||
- [ ] live 实例存在时,UI、API 和对话三个入口均不能切换 ✅
|
||||
- [ ] 工作流完成或受控终止后,创建者可以配置下一实例 ❌ (BUG-01/B1)
|
||||
- [ ] 两个浏览器同时配置时只有一个成功 ⚠️ (需完整测试)
|
||||
|
||||
**7.3 文档开关和三权**
|
||||
- [ ] 启用文档缺任一职责、职责重复、责任人非 active 项目成员时不能激活 ✅
|
||||
- [ ] 禁用文档没有职责行、没有受控文档、没有运行按钮 ⚠️ (BUG-03)
|
||||
- [ ] 对禁用文档的编制、提交、审核、批准请求全部稳定返回 `DOC_TASK_DISABLED` ❌ (BUG-05)
|
||||
- [ ] 再启用只允许发生在 planning;激活后不能原地改变 ✅
|
||||
|
||||
**7.4 阶段门禁和提醒**
|
||||
- [ ] 当前阶段 N 个启用文档中任一未批准,创建者按钮和对话推进均被阻断 ✅
|
||||
- [ ] 全部批准后只产生一次首提醒;24 小时未处理才产生一次升级提醒 ✅
|
||||
- [ ] readiness 后文档被驳回或标 stale 时,旧推进请求失效 ⚠️ (需验证)
|
||||
- [ ] 推进事务不会修改任何文档审批状态 ❌ (BUG-02)
|
||||
- [ ] 最后一阶段确认后实例为 completed,项目立即可配置下一流程 ❌ (BUG-01)
|
||||
|
||||
**7.5 安全、恢复和真实链路**
|
||||
- [ ] 跨项目、非成员、非职责人、已退出成员全部有服务端负例 ✅
|
||||
- [ ] API、SSE、异步任务、断线恢复、搜索、导出、RAG、Agent tool 的判定一致 ❌ (BUG-05)
|
||||
- [ ] 服务重启后能恢复 live 实例、当前阶段、禁用状态、职责和 pending 推进请求 ✅
|
||||
- [ ] **使用真实 PostgreSQL 和真实 PI Agent 分别跑完商务 7 文档、产品 1 文档、测试 1 文档链路** ❌ (BUG-MISSING-04)
|
||||
- [ ] 项目从未运行产品经理流程且不存在 SRS 时,测试工程师仍可依据项目对话、文档和材料成功生成唯一测试方案 ❌ (BUG-MISSING-01)
|
||||
|
||||
---
|
||||
|
||||
## 十一、建议行动计划
|
||||
|
||||
### 11.1 立即行动(今天)
|
||||
|
||||
```
|
||||
1. 确认 BUG-02 现状 (grep "UPDATE.*approved")
|
||||
2. 启动 Task A1 修复(项目完成后生命周期)
|
||||
3. 启动 Task A4 修复(对话推进新服务)
|
||||
4. 启动数据库 00025 迁移设计(禁用文档标记)
|
||||
```
|
||||
|
||||
### 11.2 本周行动
|
||||
|
||||
```
|
||||
Day 1:
|
||||
✓ Phase A 关键缺陷修复(15h)
|
||||
✓ 单测全部 PASS
|
||||
|
||||
Day 2-3:
|
||||
✓ Task B1 受控终止 API(6h)
|
||||
✓ Task B2 E2E 测试框架(8h)
|
||||
✓ 商务流程完整链路验证
|
||||
|
||||
Day 4-5:
|
||||
✓ Task B3 项目上下文快照(12h)
|
||||
✓ Phase C 完善与发布(20h)
|
||||
✓ 最终验收清单确认
|
||||
```
|
||||
|
||||
### 11.3 后续维护
|
||||
|
||||
```
|
||||
生产上线前:
|
||||
□ 性能基准测试
|
||||
□ 压力测试
|
||||
□ 灾难恢复演练
|
||||
|
||||
生产上线后:
|
||||
□ 审计日志监控
|
||||
□ 每周数据一致性检查
|
||||
□ 用户反馈收集与迭代
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、结语
|
||||
|
||||
### 12.1 总体判定
|
||||
|
||||
**项目状态**: 🟠 **中等风险** → 需立即整改
|
||||
|
||||
**原因**:
|
||||
1. 架构设计合理,业务需求清晰
|
||||
2. 核心框架 70% 已实现,技术选型无问题
|
||||
3. **关键性缺陷 3 个**(项目生命周期、推进状态、对话服务)必须本周修复
|
||||
4. **新功能 3 个**(快照、终止、E2E)需并行开发
|
||||
5. **数据污染**(88 个测试定义)影响可信度
|
||||
|
||||
### 12.2 风险缓解
|
||||
|
||||
| 风险 | 缓解措施 | 优先级 |
|
||||
|---|---|---|
|
||||
| 项目永久锁死 | 立即修复 BUG-01 | 🔴 P0 |
|
||||
| 数据不一致 | 运行数据清理脚本 + DB 迁移 | 🔴 P0 |
|
||||
| 安全隐患 | Task A4-A6 权限统一 | 🟠 P1 |
|
||||
| E2E 缺失 | Task B2 补充完整链路 | 🟠 P1 |
|
||||
|
||||
### 12.3 建议
|
||||
|
||||
1. **立即启动 Phase A**(5 天内完成关键缺陷修复)
|
||||
2. **并行启动 Phase B**(E2E 测试框架必须本周启动)
|
||||
3. **建立自动化验收流程**(CI 应包含验收标准检查)
|
||||
4. **定期数据库审计**(周期性检查一致性)
|
||||
|
||||
---
|
||||
|
||||
**审计完毕**
|
||||
**下一步**: 技术评审确认整改计划,进入实施阶段
|
||||
|
||||
**相关文档**:
|
||||
- `.doc/2026-09-12-comprehensive-audit-and-remediation-plan.md` - 详细缺陷说明与整改方案
|
||||
- `.doc/2026-09-12-remediation-tasks-and-acceptance-checklist.md` - 可执行的任务拆分
|
||||
|
||||
---
|
||||
|
||||
*生成于 2026-09-12 14:50 UTC*
|
||||
@@ -0,0 +1,680 @@
|
||||
# 项目全面审计与整改方案
|
||||
|
||||
> **HISTORICAL** — 缺陷详解与 Phase 设计档案。执行状态以 [remediation-gap-matrix.md](./2026-09-12-remediation-gap-matrix.md) 为准。
|
||||
|
||||
**日期**:2026-09-12
|
||||
**编制**:代码审计
|
||||
**状态**:待执行
|
||||
**范围**:功能完整性、实现缺陷、潜在bug 及整改优先级
|
||||
|
||||
---
|
||||
|
||||
## 一、执行现状总结
|
||||
|
||||
### 1.1 已完成的工作
|
||||
|
||||
| 项目 | 完成度 | 说明 |
|
||||
|---|---|---|
|
||||
| 业务决策文档 | ✅ 100% | `.doc/2026-09-12-three-workflows-strict-governance-remediation.md` 完成,10章详细规划 |
|
||||
| 核心代码变更 | ✅ 70% | 8个变更集框架已实施:目录隔离、manifest、validator、激活冻结、阶段推进等 |
|
||||
| 数据库迁移 | ✅ 80% | 00015-00024 共 10 个迁移已应用,核心表结构完成 |
|
||||
| 单元测试 | ✅ 85% | Coordinator、ProjectInstance、WorkflowDef 单元测试PASS |
|
||||
| 前端治理 API | ✅ 90% | `/api/v1/workflows/catalog`、`/api/v1/projects/{id}/settings` 等已实现 |
|
||||
|
||||
### 1.2 未完成或不完整的工作
|
||||
|
||||
| 编号 | 项目 | 完成度 | 优先级 | 影响 |
|
||||
|---|---|---|---|---|
|
||||
| MISSING-01 | 项目上下文快照服务 | 0% | 🔴 **关键** | Phase 3 依赖,两个智能工作流血缘无法追溯 |
|
||||
| MISSING-02 | 受控终止工作流 API | 10% | 🔴 **关键** | 状态机不完整,live 实例可永久锁死项目 |
|
||||
| MISSING-03 | 禁用文档全入口拒绝 | 50% | 🔴 **高** | 不同入口拒绝逻辑不统一,可能绕过 |
|
||||
| MISSING-04 | 真实 E2E 测试(PG+PI Agent) | 0% | 🔴 **高** | 验收标准第 7.5 节要求,尚未运行 |
|
||||
| MISSING-05 | 项目完成后下一流程配置 | 40% | 🟠 **中** | 完成最后阶段后项目仍为 active,用户看不到"配置下一流程" |
|
||||
| MISSING-06 | 对话推进新服务切换 | 30% | 🟠 **中** | 旧 `bizdev.ExecuteStage` 仍在部分路径调用 |
|
||||
| MISSING-07 | 激活 token 安全处理 | 40% | 🟠 **中** | token 明文或消费分事务,存在泄露/重放风险 |
|
||||
| MISSING-08 | 测试数据库完全隔离 | 60% | 🟠 **中** | 部分集成测试仍默认连接开发库 |
|
||||
|
||||
---
|
||||
|
||||
## 二、现存与潜在 Bug 清单
|
||||
|
||||
### 2.1 严重缺陷(Severity: Critical)
|
||||
|
||||
#### BUG-01: 完成最后阶段后项目生命周期未更新
|
||||
|
||||
**文件**: `internal/projectinstance/stage_store.go:128-158`, `internal/projectinstance/service.go:119-135`
|
||||
**现象**: 执行 `AdvanceStage` 完成最后阶段后,实例状态变为 `completed`,但项目 `lifecycle_status` 仍为 `active`。
|
||||
|
||||
**代码现场**:
|
||||
```go
|
||||
// stage_store.go:AdvanceStage
|
||||
after, err := s.store.AdvanceStage(ctx, instanceID, in.StageKey, in.ExpectedConfigRevision)
|
||||
// ... 只更新实例,未更新项目
|
||||
```
|
||||
|
||||
**后果**:
|
||||
- 项目创建者无法看到"配置下一工作流"选项
|
||||
- 前端无法识别项目已完成,可能显示旧流程按钮
|
||||
- 项目实际生命周期与数据库记录不一致
|
||||
|
||||
**验收标准冲突**: 7.4 "最后一阶段确认后实例为 completed,项目立即可配置下一流程"
|
||||
|
||||
**整改方案**:
|
||||
1. `AdvanceStage` 在同一事务中检查是否为最后阶段
|
||||
2. 若是最后阶段,同时更新 `projects.lifecycle_status` 为 `planning`
|
||||
3. 清空 `projects.current_workflow_instance_id`(可选,视数据模型)
|
||||
4. 写审计事件 `ProjectWorkflowCompleted`
|
||||
|
||||
---
|
||||
|
||||
#### BUG-02: 推进事务内仍强制更新文档批准状态
|
||||
|
||||
**文件**: `internal/projectinstance/stage_store.go:71-158`
|
||||
**现象**: 执行阶段推进时,SQL 语句中有类似逻辑将启用任务的文档强制设置为 `approved`。
|
||||
|
||||
**代码痕迹**(需要验证):
|
||||
```sql
|
||||
-- 伪代码,实际位置在 AdvanceStage 事务内
|
||||
UPDATE project_doc_task_instances
|
||||
SET status = 'approved'
|
||||
WHERE instance_id = ? AND task_key = ? AND enabled = true;
|
||||
```
|
||||
|
||||
**后果**:
|
||||
- 违反业务规则 3.3:"推进事务不会修改任何文档审批状态"
|
||||
- 可能掩盖文档实际批准情况,导致数据不一致
|
||||
- 前端与后端对"哪些文档已批准"的理解可能分歧
|
||||
|
||||
**验收标准冲突**: 7.4 "推进事务不会修改任何文档审批状态"
|
||||
|
||||
**整改方案**:
|
||||
1. 确认 `stage_store.go:AdvanceStage` 中是否存在此 UPDATE 逻辑
|
||||
2. 若存在,删除该 UPDATE 语句
|
||||
3. `AdvanceStage` 只改变阶段状态和实例状态,不触及文档
|
||||
4. 补充单测: `TestAdvanceStage_DoesNotModifyDocumentApprovalStatus`
|
||||
|
||||
---
|
||||
|
||||
#### BUG-03: 禁用文档状态未标记为 `skipped_disabled`
|
||||
|
||||
**文件**: `internal/projectinstance/store.go:244-280` (duty 写入)
|
||||
**现象**: 禁用文档的持久化时不创建职责行,但在 `project_doc_task_instances` 中无明确的 `skipped_disabled` 状态标记。
|
||||
|
||||
**代码现场**:
|
||||
```go
|
||||
// store.go:SaveWorkflowPlan 或类似
|
||||
for _, doc := range docs {
|
||||
if !doc.Enabled {
|
||||
continue // 跳过,不写入
|
||||
}
|
||||
// 只写启用文档
|
||||
}
|
||||
```
|
||||
|
||||
**后果**:
|
||||
- 禁用文档没有显式的数据库记录,无法区分"从未创建"与"被禁用"
|
||||
- 运行时查询时无法快速定位哪些文档被禁用,只能通过与定义比对
|
||||
- 审计和恢复时无法追溯禁用历史
|
||||
|
||||
**验收标准冲突**: 3.2 "禁用后实例状态写为 `skipped_disabled`"
|
||||
|
||||
**整改方案**:
|
||||
1. `project_doc_task_instances` 表添加状态枚举支持 `skipped_disabled`
|
||||
2. 激活时为禁用文档创建记录,状态明确设为 `skipped_disabled`
|
||||
3. 查询、拒绝等所有入口统一检查此状态
|
||||
4. 补充测试: `TestDisabledDocumentPersistence` 和 `TestDisabledDocumentRejectionAllEntries`
|
||||
|
||||
---
|
||||
|
||||
#### BUG-04: 对话推进仍调用旧商务执行服务
|
||||
|
||||
**文件**: `internal/coordinator/engine.go:672-811`
|
||||
**现象**: 对话识别 `ADVANCE_STAGE` 意图后,调用 `bizdevSvc.ExecuteStage` 而非新的 `projectinstance.Service.AdvanceStage`。
|
||||
|
||||
**代码现场**:
|
||||
```go
|
||||
// engine.go:672-811 (AdvanceStage 路由)
|
||||
func (e *CoordinatorEngine) handleAdvanceStage(...) {
|
||||
// ...
|
||||
result, err := e.bizdevSvc.ExecuteStage(ctx, ...) // ❌ 旧服务
|
||||
// 应该调用 e.instSvc.AdvanceStage(...)
|
||||
}
|
||||
```
|
||||
|
||||
**后果**:
|
||||
- 对话推进绕过新的权限和门禁检查
|
||||
- 可能导致阶段推进状态不一致(新旧实例双轨运行)
|
||||
- 文档审批状态可能被旧 executor 强制改写
|
||||
|
||||
**验收标准冲突**: 3.4 "两种入口都调用同一个 `AdvanceStage` 应用服务"
|
||||
|
||||
**整改方案**:
|
||||
1. 修改 `CoordinatorEngine.handleAdvanceStage` 以调用新服务
|
||||
2. 删除对 `bizdevSvc.ExecuteStage` 的依赖(或标记 deprecated)
|
||||
3. 对话推进需要提取 `workflow_instance_id`(可从项目或上文获取)
|
||||
4. 补充端到端测试: `TestDialogueAdvanceStage_UsesNewService`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 高优先级缺陷(Severity: High)
|
||||
|
||||
#### BUG-05: 禁用文档的拒绝逻辑不一致
|
||||
|
||||
**文件**: 多处
|
||||
**现象**: 不同入口(API、SSE、Agent tool)对禁用文档的拒绝逻辑分散,缺少统一的"fail closed"决策器。
|
||||
|
||||
**问题点**:
|
||||
- `stage_access.go:15-71` 仅在 live 实例存在时排除禁用任务;无 live 实例时返回 unrestricted
|
||||
- 文档创建/编辑/提交 API 可能未检查禁用状态
|
||||
- SSE 推送可能无法过滤禁用文档的更新
|
||||
- Agent tool 访问文档时无一致的权限查询
|
||||
|
||||
**后果**:
|
||||
- 禁用文档在某些路径仍可被访问/修改
|
||||
- 数据安全与治理政策执行不力
|
||||
|
||||
**验收标准冲突**: 3.2 "禁用后…不能通过 task key、对话、SSE、Agent tool 或旧 API 旁路启动"
|
||||
|
||||
**整改方案**:
|
||||
1. 新增 `DocumentAccessDecider` 接口,统一决策逻辑:
|
||||
```go
|
||||
type DocumentAccessDecider interface {
|
||||
CanAccess(ctx context.Context, docTaskInstID int64, action string, userID int64) (bool, error)
|
||||
}
|
||||
```
|
||||
2. 实现检查流程:
|
||||
- 确认 instance 存在且为 live 或对应的 active/awaiting 状态
|
||||
- 检查 doc_task_instance 状态不为 `skipped_disabled`
|
||||
- 检查当前阶段包含该任务
|
||||
- 检查用户为对应职责人
|
||||
3. 在所有入口(API、SSE、异步任务、搜索、导出、RAG、Agent tool)使用此决策器
|
||||
4. 补充测试: `TestDocumentAccessDecider_AllEntries`
|
||||
|
||||
---
|
||||
|
||||
#### BUG-06: 激活 token 安全问题
|
||||
|
||||
**文件**: `internal/projectinstance/service.go` (激活相关代码)
|
||||
**现象**: 激活 token 可能存在以下问题之一:
|
||||
- 明文保存在数据库
|
||||
- 激活和消费分事务,中间失败时 token 泄露
|
||||
- 重复消费可能被允许
|
||||
|
||||
**后果**:
|
||||
- 攻击者可获得激活权限
|
||||
- 用户可在浏览器历史/日志中找到 token
|
||||
- 重放攻击可重复激活
|
||||
|
||||
**验收标准冲突**: 3.1 "激活时冻结…运行中不可静默修改" 的先决条件是原子激活
|
||||
|
||||
**整改方案**:
|
||||
1. 激活流程改为:
|
||||
- `CreateActivationPreview` 返回 challenge/nonce(不返回 token)
|
||||
- 用户确认后,`ActivateWorkflow` 生成 one-time token
|
||||
- token 仅存储 bcrypt hash(类似密码)
|
||||
- 在同一事务内消费 token、验证 hash、更新实例状态
|
||||
2. token 过期时间设为 5 分钟
|
||||
3. 补充测试: `TestActivationTokenSecurity`
|
||||
|
||||
---
|
||||
|
||||
#### BUG-07: 项目完成后项目生命周期转换逻辑缺失
|
||||
|
||||
**文件**: `internal/projectinstance/service.go`, `internal/projectworkflow/service.go`
|
||||
**现象**: 完成一个工作流后,项目无法自动进入可选择下一工作流的状态。
|
||||
|
||||
**代码现场**:
|
||||
```go
|
||||
// projectinstance/service.go:119-135
|
||||
// GetSettings 只允许 lifecycle == LifecyclePlanning 时编辑
|
||||
if lifecycle != LifecyclePlanning {
|
||||
return nil, ErrPlanFrozen // completed 实例的项目仍不能选择下一流程
|
||||
}
|
||||
```
|
||||
|
||||
**后果**:
|
||||
- 用户必须手工改数据库才能运行下一工作流
|
||||
- 用户体验与业务规则相悖
|
||||
|
||||
**验收标准冲突**: 3.1 "完成或受控终止后创建下一工作流实例"
|
||||
|
||||
**整改方案**:
|
||||
1. 新增 `CompleteWorkflowInstance` 应用服务:
|
||||
- 检查实例处于最后阶段且阶段就绪
|
||||
- 在事务内:标记实例为 `completed`,项目改为 `planning`,清空 live 指针
|
||||
2. `AdvanceStage` 检查当前阶段是否为最后阶段,是则调用此服务
|
||||
3. 补充测试: `TestProjectLifecycleAfterCompletion`
|
||||
|
||||
---
|
||||
|
||||
### 2.3 中优先级缺陷(Severity: Medium)
|
||||
|
||||
#### BUG-08: 测试数据库隔离不完全
|
||||
|
||||
**文件**: `internal/projectinstance/pg_integration_test.go:19-49`,`internal/server/workflow_governance_pg_test.go:26-61`
|
||||
**现象**: 集成测试未设置 `TEST_DATABASE_URL` 时默认连接本地开发库,导致测试数据污染。
|
||||
|
||||
**现场数据**:
|
||||
```
|
||||
开发库 workflow_definitions = 92 个
|
||||
其中 pg_* / test_* 命名 = 88 个(测试污染)
|
||||
```
|
||||
|
||||
**后果**:
|
||||
- 业务数据与测试数据混淆
|
||||
- 本地开发环境无法清洁复现
|
||||
- CI/CD 若复用开发库会产生不可预测的行为
|
||||
|
||||
**验收标准冲突**: 7.5 "测试结束后业务数据库中不残留 `pg_*`、`test_*` 定义"
|
||||
|
||||
**整改方案**:
|
||||
1. 所有 `*_test.go` 中强制要求 `TEST_DATABASE_URL`;未设置时 skip 或 fatal
|
||||
2. 测试前创建隔离 schema 或独立库,测试后清理
|
||||
3. CI 配置中显式设置 `TEST_DATABASE_URL` 指向临时数据库
|
||||
4. 补充 CI 脚本检查:测试结束后确认开发库中无 `pg_*`、`test_*` 定义
|
||||
|
||||
---
|
||||
|
||||
#### BUG-09: 版本冲突时前端自动重试
|
||||
|
||||
**文件**: `web/src/components/workflow/ProjectWorkflowSwitcher.tsx:38-76,173-236`
|
||||
**现象**: 当两个浏览器同时切换工作流时,第一个返回版本冲突,但前端自动取新版本再次提交,无需用户确认。
|
||||
|
||||
**代码现场**:
|
||||
```typescript
|
||||
// ProjectWorkflowSwitcher.tsx:173-236
|
||||
// 冲突时自动重试,而非展示冲突提示
|
||||
if (conflict) {
|
||||
fetchLatestAndRetry(); // ❌ 自动重试,用户不知情
|
||||
}
|
||||
```
|
||||
|
||||
**后果**:
|
||||
- 用户最新选择被覆盖
|
||||
- 可能激活非预期的工作流
|
||||
- 与业务规则冲突
|
||||
|
||||
**验收标准冲突**: 7.2 "两个浏览器同时配置或推进时…另一个收到版本冲突并重新确认"
|
||||
|
||||
**整改方案**:
|
||||
1. 冲突时展示弹窗:"您的选择与其他编辑冲突,请重新确认"
|
||||
2. 显示当前版本、冲突版本和新版本内容
|
||||
3. 用户确认后重新提交,而非自动重试
|
||||
4. 删除自动重试逻辑
|
||||
|
||||
---
|
||||
|
||||
#### BUG-10: 项目设置 UI 缺少完整只读审计视图
|
||||
|
||||
**文件**: `web/src/components/workflow/` 相关组件
|
||||
**现象**: 项目激活后,设置页面无法清晰显示冻结的配置内容(版本、hash、激活人/时间、所有 enabled/disabled、职责)。
|
||||
|
||||
**后果**:
|
||||
- 创建者不知道实际冻结的是什么
|
||||
- 无法自助诊断权限问题
|
||||
- 审计信息不可见
|
||||
|
||||
**验收标准冲突**: 7.2 "live 配置缺少完整只读审计视图"
|
||||
|
||||
**整改方案**:
|
||||
1. 新增 "工作流运行详情" 只读面板,显示:
|
||||
- 工作流版本、发布时间、hash
|
||||
- 激活人员、激活时间
|
||||
- 所有启用文档及三权职责
|
||||
- 所有禁用文档及禁用原因
|
||||
- 当前阶段进度
|
||||
2. 补充测试: `TestWorkflowAuditViewCompleteness`
|
||||
|
||||
---
|
||||
|
||||
#### BUG-11: 旧切换 API 未正式下线
|
||||
|
||||
**文件**: `internal/server/server.go:157-169`
|
||||
**现象**: 新项目实例 API 已实现,但旧 `/projects/{id}/active-workflow` 仍在暴露,未返回 410 Gone。
|
||||
|
||||
**代码现场**:
|
||||
```go
|
||||
// server.go:157-169
|
||||
mux.HandleFunc("POST /projects/{id}/active-workflow", s.handleActiveWorkflow) // ❌ 仍暴露
|
||||
```
|
||||
|
||||
**后次**:
|
||||
- 旧客户端可绕过新治理
|
||||
- 不能区分"不支持"与"已下线"
|
||||
|
||||
**验收标准冲突**: 5 "旧服务端路由…应返回 `410 WORKFLOW_SWITCH_API_RETIRED`"
|
||||
|
||||
**整改方案**:
|
||||
1. 将 `/projects/{id}/active-workflow` 路由改为返回 410 Gone 和迁移指引
|
||||
2. 添加 deprecation 日志
|
||||
3. 补充测试: `TestLegacyAPIReturns410`
|
||||
|
||||
---
|
||||
|
||||
### 2.4 低优先级缺陷(Severity: Low)
|
||||
|
||||
#### BUG-12: 前端名称不一致
|
||||
|
||||
**文件**: `web/src/components/workflow/ProjectWorkflowSwitcher.tsx:16-23`
|
||||
**现象**: 前端硬编码的三个工作流名称与后端正式名称不一致。
|
||||
|
||||
**后果**: 用户在不同页面看到不同名称,混淆认知
|
||||
|
||||
**整改**: 由后端 `GET /api/v1/workflows/catalog` 返回的正式名称驱动前端显示
|
||||
|
||||
---
|
||||
|
||||
## 三、未完成的功能模块详情
|
||||
|
||||
### 3.1 项目上下文快照服务(ProjectContextSnapshot)
|
||||
|
||||
**规划文档**: `.doc/2026-09-12-three-workflows-strict-governance-remediation.md:82-105`
|
||||
**当前状态**: 规划完整,实现 0%
|
||||
**关键要求**:
|
||||
|
||||
1. **五层输入源**:
|
||||
- 项目事实(项目名、客户、产品类型等)
|
||||
- 项目决策(里程碑、已关闭待确认项)
|
||||
- 项目文档(受控文档、共享文档、上传材料)
|
||||
- 项目对话(进入知识边界的所有对话)
|
||||
- 历史输出与知识依据
|
||||
|
||||
2. **不可变快照**:
|
||||
- `project_context_snapshots` 表:记录项目、任务、截止时间、汇总 hash、创建人、冻结状态
|
||||
- `project_context_snapshot_sources` 表:逐项记录 source type/id/version/hash、权限范围、权威等级、是否实际引用
|
||||
|
||||
3. **冲突优先级**:
|
||||
- 创建者确认事实/决策 > 已批准文档 > 上传材料 > 里程碑记录 > 普通对话 > 智能体总结
|
||||
|
||||
4. **血缘追溯**:
|
||||
- 任务输出必须带 source manifest
|
||||
- 产品经理:输出 SRS + 来源清单
|
||||
- 测试工程师:输出验证方案 + 需求/风险—测试项—来源追溯表
|
||||
|
||||
**现有基础**:
|
||||
- `internal/squad/service.go:35-55` 已支持 project、materials、prior_docs、knowledge_hits 等输入槽位
|
||||
- `internal/squad/workspace.go:262-408` 已有 context 索引建立能力
|
||||
- `internal/bizdev/service.go:250-294` 已有部分项目事实输入逻辑
|
||||
|
||||
**整改方案**:
|
||||
1. 新增 `internal/projectcontext/service.go`:
|
||||
- `BuildContextSnapshot(ctx, projectID, taskID)` -> `*ContextSnapshot`
|
||||
- 调用五层源检索、权限过滤、冲突合并、hash 计算
|
||||
2. 新增数据库表与迁移
|
||||
3. 产品经理与测试工程师都改用此服务
|
||||
4. 补充集成测试
|
||||
|
||||
**验收标准**: 7.5
|
||||
- 同一组项目输入分别启动产品经理和测试工程师时,两者快照可复现、来源清单一致
|
||||
- 产品经理/测试工程师输出中包含完整 source manifest 和被排除内容说明
|
||||
|
||||
---
|
||||
|
||||
### 3.2 受控终止工作流(WorkflowTermination)
|
||||
|
||||
**规划文档**: `.doc/2026-09-12-three-workflows-strict-governance-remediation.md:109-131` (状态机)
|
||||
**当前状态**: 状态表存在,API 和逻辑 0%
|
||||
**关键要求**:
|
||||
|
||||
1. **状态转换**:
|
||||
```
|
||||
active → termination_requested → terminated
|
||||
```
|
||||
|
||||
2. **操作约束**:
|
||||
- 仅创建者可申请
|
||||
- 需二次确认
|
||||
- 需填写原因
|
||||
- 处理或取消在途任务
|
||||
- 生成审计
|
||||
|
||||
3. **API**:
|
||||
- `POST /api/v1/workflow-instances/{id}/termination-preview` - 预览影响
|
||||
- `POST /api/v1/workflow-instances/{id}/terminate` - 执行终止
|
||||
|
||||
**整改方案**:
|
||||
1. 新增 `internal/projectinstance/terminate.go`:
|
||||
- `TerminationPreview(ctx, instanceID)` -> 未决任务、文档和通知清单
|
||||
- `TerminateWorkflow(ctx, instanceID, userID, reason)` -> 原子操作
|
||||
2. 事务内:
|
||||
- 检查操作者为创建者
|
||||
- 标记实例为 `terminated`
|
||||
- 取消或标记未决任务为 cancelled
|
||||
- 项目改为 `planning`,清空 live 指针
|
||||
- 写审计事件
|
||||
3. 补充 API 路由和端到端测试
|
||||
|
||||
**验收标准**: 7.3
|
||||
- 创建者可以选择"受控终止"而非永久锁死
|
||||
- terminated 状态的项目可创建下一实例
|
||||
- 审计记录包含终止原因
|
||||
|
||||
---
|
||||
|
||||
### 3.3 真实 E2E 测试(PostgreSQL + PI Agent)
|
||||
|
||||
**规划文档**: `.doc/2026-09-12-three-workflows-strict-governance-remediation.md:417-459` (验收标准)
|
||||
**当前状态**: 单元测试 PASS,E2E 0%
|
||||
**要求**:
|
||||
|
||||
1. 使用真实 PostgreSQL(不是内存 mock)
|
||||
2. 使用真实 PI Agent 容器
|
||||
3. 完整链路:
|
||||
- 创建项目 → 选择工作流 → 激活 → 推进各阶段 → 完成 → 配置下一工作流
|
||||
4. 三个工作流分别测试:
|
||||
- 商务流程(7 个文档)
|
||||
- 产品流程(SRS)
|
||||
- 测试流程(无 SRS 前置)
|
||||
|
||||
**现有基础**:
|
||||
- `internal/server/workflow_governance_piagent_pg_test.go` 已有 PG 集成框架
|
||||
- PI Agent 容器已在 `docker-compose.yml` 中定义
|
||||
|
||||
**整改方案**:
|
||||
1. 新增 `internal/server/e2e_workflow_test.go`:
|
||||
- `TestE2E_BizdevWorkflow_Complete` - 7 文档全流程
|
||||
- `TestE2E_ProductManagerWorkflow_Standalone` - 无前置依赖
|
||||
- `TestE2E_TestEngineerWorkflow_WithoutSRS` - 无 SRS 硬门禁
|
||||
- `TestE2E_ProjectTransitionToNextWorkflow` - 完成后切换
|
||||
2. 设置 `TEST_DATABASE_URL` 指向隔离库
|
||||
3. 启动 PI Agent 容器(或 mock 其接口)
|
||||
4. 逐步执行,记录结果
|
||||
|
||||
**验收标准**: 7.5 (完整链路列表)
|
||||
|
||||
---
|
||||
|
||||
## 四、整改优先级与时间估计
|
||||
|
||||
### Phase A:关键缺陷修复(优先处理)
|
||||
|
||||
| Bug | 预计工作量 | 风险 | 依赖 |
|
||||
|---|---|---|---|
|
||||
| BUG-01:项目完成后生命周期 | 2h | 低 | - |
|
||||
| BUG-02:推进不强制批准 | 1h | 中 | 需验证现状 |
|
||||
| BUG-03:禁用文档 skipped_disabled 标记 | 3h | 中 | DB 迁移 |
|
||||
| BUG-04:对话推进新服务切换 | 2h | 高 | 需联调 Coordinator |
|
||||
| BUG-05:禁用文档拒绝一致性 | 4h | 中 | 需 helper 函数 |
|
||||
| BUG-06:激活 token 安全 | 3h | 中 | 涉及 API 改动 |
|
||||
| **小计** | **15h** | | |
|
||||
|
||||
### Phase B:高优先级功能(并行)
|
||||
|
||||
| 功能 | 预计工作量 | 验收标准 | 依赖 |
|
||||
|---|---|---|---|
|
||||
| 受控终止(MISSING-02) | 6h | 7.3 | BUG-01 |
|
||||
| 真实 E2E 测试(MISSING-04) | 8h | 7.5 | Phase A + 容器 |
|
||||
| 项目上下文快照(MISSING-01) | 12h | 7.5 | DB 迁移 + 新服务 |
|
||||
| **小计** | **26h** | | |
|
||||
|
||||
### Phase C:完善与发布
|
||||
|
||||
| 项目 | 预计工作量 |
|
||||
|---|---|
|
||||
| 文档 UI 审计视图(BUG-10) | 3h |
|
||||
| 旧 API 下线(BUG-11) | 1h |
|
||||
| 前端名称一致性(BUG-12) | 1h |
|
||||
| 测试数据隔离完善(BUG-08) | 2h |
|
||||
| 数据迁移与兼容(MISSING-05) | 4h |
|
||||
| 对话对接(MISSING-06) | 3h |
|
||||
| 集成验证与发布 | 6h |
|
||||
| **小计** | **20h** |
|
||||
|
||||
**总计**: ~61 小时 ≈ **2.5 天(单人全职)**或 **5 天(双人)**
|
||||
|
||||
---
|
||||
|
||||
## 五、整改执行计划
|
||||
|
||||
### 5.1 Day 1: 关键缺陷修复
|
||||
|
||||
**Morning (4h)**:
|
||||
1. BUG-01 修复:AdvanceStage → 同事务更新项目生命周期
|
||||
- 修改 `stage_store.go` 和 `service.go`
|
||||
- 补充单测 `TestProjectLifecycleAfterCompletion`
|
||||
- 验证:完成最后阶段后项目改为 planning
|
||||
|
||||
2. BUG-02 修复:确认是否存在强制批准逻辑,若存在删除
|
||||
- grep 搜索"approved"强制更新
|
||||
- 删除该 SQL 行
|
||||
- 补充测试 `TestAdvanceStage_DoesNotModifyApprovalStatus`
|
||||
|
||||
**Afternoon (4h)**:
|
||||
3. BUG-04 修复:对话推进切换到新服务
|
||||
- 修改 `coordinator/engine.go` 的 `handleAdvanceStage`
|
||||
- 提取 workflow_instance_id(从 session 或项目)
|
||||
- 调用 `instSvc.AdvanceStage` 替代 `bizdevSvc.ExecuteStage`
|
||||
- 补充对话测试
|
||||
|
||||
4. BUG-06 开始:激活 token 安全重构
|
||||
- 设计 one-time token 与 hash 方案
|
||||
- 修改激活预览与激活 API
|
||||
|
||||
### 5.2 Day 2: 继续缺陷修复 + 开始新功能
|
||||
|
||||
**Morning (4h)**:
|
||||
5. BUG-03 修复:禁用文档 skipped_disabled 标记
|
||||
- 数据库迁移 00025:project_doc_task_instances 添加 skipped_disabled 状态
|
||||
- 修改激活逻辑为禁用文档写入 skipped_disabled 记录
|
||||
- 补充测试
|
||||
|
||||
6. BUG-05 开始:禁用文档拒绝一致性
|
||||
- 新增 `DocumentAccessDecider` 接口与实现
|
||||
- 集成到 API、SSE、异步任务等入口
|
||||
|
||||
**Afternoon (4h)**:
|
||||
7. 受控终止开发(MISSING-02)
|
||||
- 新增 `terminate.go` 服务
|
||||
- 实现终止预览和执行 API
|
||||
- 补充测试
|
||||
|
||||
### 5.3 Day 3: 新功能开发与集成测试
|
||||
|
||||
**Full Day (8h)**:
|
||||
8. 项目上下文快照初版(MISSING-01)
|
||||
- 新增 `projectcontext/service.go`
|
||||
- 实现快照构建与来源清单
|
||||
- 产品/测试工程师改用新服务
|
||||
|
||||
9. 真实 E2E 测试启动(MISSING-04)
|
||||
- 编写 E2E 测试框架
|
||||
- 三个工作流链路验证
|
||||
- 文档与验证报告
|
||||
|
||||
---
|
||||
|
||||
## 六、质量保证与验收检查表
|
||||
|
||||
### 6.1 单元测试
|
||||
|
||||
- [ ] 所有修复的 bug 都有对应负例测试
|
||||
- [ ] 新功能模块都有 50%+ 覆盖率
|
||||
- [ ] 关键路径(激活、推进、完成、终止)100% 覆盖
|
||||
- [ ] 权限边界(非创建者、已 active、禁用文档)有负例
|
||||
|
||||
### 6.2 集成测试
|
||||
|
||||
- [ ] PG 集成测试全部 PASS(TEST_DATABASE_URL 隔离库)
|
||||
- [ ] 测试后开发库无 `pg_*`、`test_*` 定义残留
|
||||
- [ ] 并发测试(两个浏览器同时操作)PASS
|
||||
- [ ] 数据库迁移向前向后兼容
|
||||
|
||||
### 6.3 真实 E2E
|
||||
|
||||
- [ ] 商务流程完整链路 PASS
|
||||
- [ ] 产品流程独立运行 PASS(无前置依赖)
|
||||
- [ ] 测试流程无 SRS 也能运行 PASS
|
||||
- [ ] 项目从完成切换到下一工作流 PASS
|
||||
- [ ] 受控终止后项目可重新配置 PASS
|
||||
|
||||
### 6.4 验收标准
|
||||
|
||||
- [ ] 目录仅暴露三项正式工作流
|
||||
- [ ] 禁用文档全入口(API、对话、SSE、Agent tool)拒绝
|
||||
- [ ] 推进事务不改文档审批状态
|
||||
- [ ] 项目完成后立即可配置下一工作流
|
||||
- [ ] 对话推进使用新实例服务
|
||||
- [ ] 激活 token 原子且安全
|
||||
- [ ] 旧 API 返回 410 Gone
|
||||
|
||||
---
|
||||
|
||||
## 七、风险预案
|
||||
|
||||
### Risk-1: 激活 token 改动影响现有流程
|
||||
|
||||
**应对**: 先保留双通道(新旧),shadow 运行,验证一致后下线旧通道
|
||||
|
||||
### Risk-2: 数据库迁移引入兼容性问题
|
||||
|
||||
**应对**: 新增迁移前备份数据库;迁移脚本包含回滚逻辑;测试向后兼容
|
||||
|
||||
### Risk-3: 对话推进改动影响用户体验
|
||||
|
||||
**应对**: 对话测试覆盖完整,先内测验证;保留原意图识别,改调用目标
|
||||
|
||||
---
|
||||
|
||||
## 八、文档与交付物清单
|
||||
|
||||
| 交付物 | 格式 | 目标受众 |
|
||||
|---|---|---|
|
||||
| 整改方案(本文档) | Markdown | 开发、QA、产品 |
|
||||
| 单元测试新增用例 | Go test | 开发 |
|
||||
| E2E 测试报告 | Markdown + 日志 | QA、产品 |
|
||||
| 数据库迁移脚本 | SQL | DBA、开发 |
|
||||
| API 文档更新 | OpenAPI / Markdown | 前端、集成方 |
|
||||
| 项目上下文快照设计文档 | Markdown | 架构评审 |
|
||||
| 发布说明 | Markdown | 用户、销售 |
|
||||
|
||||
---
|
||||
|
||||
## 九、总结与建议
|
||||
|
||||
### 当前项目整体评估
|
||||
|
||||
| 方面 | 评分 | 说明 |
|
||||
|---|---|---|
|
||||
| **架构与设计** | 8/10 | 三工作流治理顶层决策清晰,状态机设计合理 |
|
||||
| **实现完整度** | 5/10 | 核心框架 70%,关键功能(快照、终止)缺失,多个 bug |
|
||||
| **代码质量** | 6/10 | 单元测试 OK,缺失 E2E;对话-服务耦合待解 |
|
||||
| **数据一致性** | 4/10 | 项目生命周期转换不完整,禁用文档没有显式标记 |
|
||||
| **安全性** | 5/10 | 权限框架存在,执行不一致;token 处理存在漏洞 |
|
||||
| **运维成熟度** | 6/10 | DB 迁移和测试隔离需完善 |
|
||||
|
||||
### 建议
|
||||
|
||||
1. **优先完成 Phase A 缺陷修复**(2 天)以稳定当前功能
|
||||
2. **并行开发 Phase B 新功能**(3 天),特别是 E2E 测试以验证整体逻辑
|
||||
3. **建立自动化验收流程**:CI 应包括所有验收标准的检查
|
||||
4. **后续维护建议**:
|
||||
- 禁用项目选择非正式工作流的自动检查(CI 中)
|
||||
- 每次激活/推进/完成后都输出审计链(便于故障排查)
|
||||
- 项目完成前端页面加"确认完成工作流"的双确认卡
|
||||
- 定期数据库快照与清理脚本(防止垃圾数据)
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2026-09-12 14:45 UTC
|
||||
**下一步**: 待产品&技术评审确认,进入实施阶段
|
||||
@@ -0,0 +1,706 @@
|
||||
# Agent System 权限、设置与三类工作流完整实施方案
|
||||
|
||||
## 1. 执行结论
|
||||
|
||||
当前系统已经完成了从原型向受控工作流平台演进所需的多数“结构件”:数据库账户与会话、系统角色/权限表、项目成员、工作流定义及版本、模板版本、项目工作流实例、阶段实例、文档任务实例、三权职责、激活确认、阶段推进请求、通知 Outbox、任务运行持久化和文档血缘均已存在。现有 Go 短测、前端测试和生产构建也能通过。因此,本轮不应推倒重做,而应完成最后一段也是最关键的一段:**统一授权权威、把设置真正落到服务端、让已发布工作流版本成为运行时唯一事实源,并将商务对接、智能产品经理、智能测试经理发布为可配置、可冻结、可执行、可审计的正式工作流。**[^1][^2][^3]
|
||||
|
||||
但截至本次审计,系统还不能宣称“权限和设置已做全”,三个工作流也还不能按当前治理设计直接投入多人生产使用。核心原因如下:
|
||||
|
||||
1. **权限表存在,但策略执行不完整。** 部分项目查询、工作流历史和血缘查询缺少对象级授权;创建项目只校验登录,不校验 `project:create`;工作流管理 API 仍统一要求超级管理员,未使用已入库的细粒度权限;系统权限计算还忽略绑定作用域、角色状态和 `valid_from`。[^4][^5][^6]
|
||||
2. **阶段权限存在高风险放行路径。** 解析 live 实例发生数据库错误时会回退为“不限制”,多个列表、聊天、RAG/上下文过滤辅助函数也在错误时放行;拥有当前阶段任一文档职责的用户会获得该阶段全部文档任务权限,任务执行门禁还允许其运行任意 `task_key`。[^7][^8]
|
||||
3. **设置界面与服务端权威尚未闭环。** 浏览器仍把 Bearer token 和会话对象放入 `localStorage`,登出只删除本地状态;个人资料保存、个人偏好和部分权限画像仍是前端本地数据,已有活动会话 API 未在用户设置中形成闭环。[^9][^10]
|
||||
4. **三个工作流只是“登记过”,不是“完整接入”。** `main`、`product_manager`、`test_manager` 已作为发布种子存在,但都是单阶段包装;运行目录会把任意已发布版本压扁为第一阶段的第一个任务。商务对接真实的 V0.1/V0.5/V1.0 阶段仍硬编码在 BizDev 执行器中;产品经理和测试经理的五步 Agent 流程存在,但没有由版本化定义驱动。[^11][^12][^13]
|
||||
5. **项目实例与文档治理还未成为同一条链。** 规划校验没有检查必需阶段/必需任务是否被整个遗漏,也没有校验三权人员是否为有效项目成员;实例持久化把所有阶段 `position` 写成 0;执行器又会在请求未传三权时回退到固定用户 2/3,并把文档阶段固定为 `V0.5_feasibility`,没有消费冻结实例中的职责和活动阶段。[^14][^15]
|
||||
|
||||
因此建议把工作拆为三道发布闸门:
|
||||
|
||||
- **闸门 A:授权止血。** 先关闭跨项目读取、错误放行、任意任务键、身份/会话本地权威等 P0;完成全部 API/数据通路的策略矩阵。
|
||||
- **闸门 B:配置与运行时合流。** 扩展工作流 schema,建立 `executor_key` 注册表和全阶段解释器,让项目实例、DocGov、任务运行、对话/RAG、文档血缘都从同一冻结快照取值。
|
||||
- **闸门 C:发布三个正式版本。** 以新版本而不是覆盖旧数据的方式发布三个工作流,完成迁移、真实 PostgreSQL、跨用户负例和真实 PI Agent E2E 后再开放生产选择。
|
||||
|
||||
本方案遵循当前系统已经确定的治理原则:生产身份只来自服务端会话;系统角色、项目角色和单文档三权职责分离;已发布版本不可原地修改;项目激活时冻结完整快照;同一项目只允许一个 live 工作流;当前流程完成或受控终止后才能选择下一个;门禁满足只发提醒,阶段推进必须由项目创建人明确确认。[^1]
|
||||
|
||||
## 2. 本次研究范围与验证边界
|
||||
|
||||
### 2.1 已审阅范围
|
||||
|
||||
| 层 | 已审阅内容 |
|
||||
|---|---|
|
||||
| 数据库 | Goose `00014`–`00020`:成员、IAM、工作流/模板、项目实例、通知、任务运行 |
|
||||
| 后端 | `internal/auth`、`authz`、`workflowdef`、`projectinstance`、`projectworkflow`、`bizdev`、`squad`、`server` |
|
||||
| 前端 | 登录会话、RBAC 类型、用户设置、系统设置、项目设置、工作流切换/展示 |
|
||||
| 业务执行 | 商务对接真实阶段;产品经理/测试经理 Agent 角色、步骤、模板、质量门禁、前置依赖和血缘 |
|
||||
| 既有文档 | 2026-09-11 治理总方案、Phase 1/2 实施计划、当前 PG/CI/Soak/E2E 报告 |
|
||||
|
||||
### 2.2 本轮已执行验证
|
||||
|
||||
```text
|
||||
go test -short -timeout 8m \
|
||||
./internal/auth/... ./internal/authz/... ./internal/workflowdef/... \
|
||||
./internal/projectinstance/... ./internal/projectworkflow/... ./internal/server/...
|
||||
结果:全部通过
|
||||
|
||||
cd web && npm test
|
||||
结果:170 项 Node tests + 22 项 Vitest tests 全部通过
|
||||
|
||||
cd web && npm run build
|
||||
结果:TypeScript + Vite 构建通过;存在主 bundle 过大的非阻断警告
|
||||
```
|
||||
|
||||
这些结果证明当前分支能编译且现有回归未破坏,不证明跨租户授权、真实 PostgreSQL 事务、重启恢复和真实 PI Agent 长链路已满足本方案的发布门禁。当前测试报告中 PI Agent 全生命周期仍记录了 `/v1/run` 超时,且报告结尾又写“仍在运行”,应先修正文档状态,再以一次新的、带 commit SHA 和环境指纹的运行作为验收证据。[^16]
|
||||
|
||||
### 2.3 外部设计基线
|
||||
|
||||
授权采用“默认拒绝、每次请求验证、对象标识不可猜测越权、失败安全退出、记录审计”的基线;动态项目、阶段和文档条件用 RBAC + ReBAC + ABAC 组合,而不是只靠全局角色。OWASP 明确建议默认拒绝并对每次请求执行权限判断;NIST RBAC 模型把用户、角色、权限、操作和对象建模为独立关系,并支持职责分离约束。[^17][^18]
|
||||
|
||||
浏览器会话不再把认证 token 放在 `localStorage`。OWASP 指出 Web Storage 可被同源 JavaScript 读取,建议会话使用 `HttpOnly; Secure; SameSite` Cookie;Cookie 模式下同时补齐 CSRF 防护。[^19]
|
||||
|
||||
## 3. 当前系统能力盘点
|
||||
|
||||
### 3.1 可以直接保留并扩展的能力
|
||||
|
||||
| 能力 | 当前状态 | 本方案处理 |
|
||||
|---|---|---|
|
||||
| 统一 API 认证中间件 | 已存在 | 保留;切换到安全 Cookie/CSRF,并在授权失败时统一错误模型 |
|
||||
| 数据库账户、密码哈希、会话哈希 | 已存在 | 保留;增加密码/MFA/重置策略、会话前端闭环和安全事件 |
|
||||
| 系统角色/权限/绑定 | 已存在 | 纠正多角色、作用域、有效期、状态和管理审计语义 |
|
||||
| `project_members` | 已存在 | 增加 FK、项目业务角色多绑定、成员状态和职责候选资格 |
|
||||
| 工作流草稿/发布/退役 | 后端基础存在 | 增加复制草稿、完整 schema、细粒度授权、发布影响预览和 UI |
|
||||
| 模板 CAS/版本/扫描状态 | 基础存在 | 增加扫描 worker、隔离区、预览、发布/退役和引用影响分析 |
|
||||
| 项目规划/预览/确认/冻结 | 基础存在 | 修复完整性校验、原子 token、阶段顺序、受控变更和下一个工作流 |
|
||||
| 文档三权状态机 | 已存在 | 由实例职责驱动;加职责资格、再认证/签署策略和阶段门禁映射 |
|
||||
| Outbox/通知/阶段推进请求 | 已存在 | 完成用户偏好、渠道、升级、去重和创建人确认体验 |
|
||||
| 任务运行持久化/lease | 已存在 | 把任务与实例/阶段/任务定义绑定;细分 read/cancel/resume 权限 |
|
||||
| 文档血缘/stale | 已存在 | stale 成为阶段退出阻断条件,提供影响分析和重新基线闭环 |
|
||||
|
||||
### 3.2 当前成熟度判定
|
||||
|
||||
| 领域 | 代码骨架 | 服务端权威 | 前端闭环 | 生产验收 | 判定 |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| 认证与会话 | 高 | 中 | 低 | 中 | 需整改 |
|
||||
| 系统 RBAC | 中 | 低 | 中 | 低 | 发布阻断 |
|
||||
| 项目 ReBAC | 中 | 中 | 中 | 低 | 发布阻断 |
|
||||
| 阶段/文档 ABAC | 中 | 低 | 中 | 低 | 发布阻断 |
|
||||
| 用户设置 | 中 | 低 | 中 | 低 | 未做全 |
|
||||
| 系统设置 | 中 | 中 | 中 | 低 | 未做全 |
|
||||
| 项目设置 | 中高 | 中 | 中 | 低 | 关键断点 |
|
||||
| 工作流设计/版本 | 中高 | 中 | 低 | 低 | 关键断点 |
|
||||
| 三工作流执行器 | 高 | 低 | 中 | 低 | 尚未正式接入 |
|
||||
| 审计与通知 | 中 | 中 | 低 | 低 | 尚未闭环 |
|
||||
|
||||
## 4. 必须优先修复的问题清单
|
||||
|
||||
### 4.1 P0:在接入三个工作流前必须关闭
|
||||
|
||||
| ID | 问题 | 影响 | 整改与验收 |
|
||||
|---|---|---|---|
|
||||
| AUTH-001 | 工作流运行历史、文档血缘 handler 未调用项目对象授权 | 登录用户可能读取其他项目元数据/血缘 | 所有项目子路由统一经过资源加载 + `Authorize`;跨项目返回 404 或统一 403;加双用户 PG 负例 |
|
||||
| AUTH-002 | 阶段访问解析或过滤出错时放行 | DB/策略故障可扩大文档、聊天、RAG、Agent 上下文可见范围 | 生产环境 fail closed;只对明确标记的 legacy 项目执行迁移期策略,且有截止版本和指标 |
|
||||
| AUTH-003 | 工作流路由接受客户端覆盖 `task_key`;阶段成员可运行当前阶段任意任务 | 绕过工作流定义和文档职责 | 服务端只从 `workflow_instance_id + active_stage + task_instance_id` 推导 executor;拒绝不在快照中的任务 |
|
||||
| AUTH-004 | 项目规划可以整个遗漏必需阶段/任务 | 可激活空壳或删减受控交付物的计划 | 对定义中每个 non-optional stage 和 required/blocking task 做反向完整性校验 |
|
||||
| AUTH-005 | 三权用户未校验有效账户、项目成员和候选角色 | 可把无权或已停用用户写入审批链 | 激活前逐人校验 membership/status/eligibility/职责分离;激活后人员失效触发阻断事件 |
|
||||
| AUTH-006 | 项目列表 SQL 把 `creator_id=1`/NULL 项目视为公开,旧切换逻辑有固定用户/项目 ID 豁免 | 演示兼容规则泄漏到真实授权 | 删除 ID 特判;显式 `project_visibility`/legacy migration,不以 creator ID 表达公开 |
|
||||
| AUTH-007 | 项目创建接口未检查 `project:create` | 任意活动账户可立项 | handler/service 双层校验 `projects.create`,前端按钮仅作体验控制 |
|
||||
| AUTH-008 | 项目成员可读取/取消其他成员任务运行,未区分动作 | 破坏他人运行和审计归属 | `task_runs.view_own/view_project/cancel_own/cancel_any/resume` 分权;取消他人任务须负责人或创建人 |
|
||||
| AUTH-009 | 本地 token、仅本地 logout、失效 `/me` 后仍保留登录态 | XSS/撤销后前端假登录 | 改安全 Cookie;服务端 logout;`/me` 401 立即清空主体和缓存;权限加载失败敏感动作 fail closed |
|
||||
| WF-001 | DBCatalog 将完整版本压扁为第一阶段第一任务 | 发布多阶段定义也不会按定义运行 | 移除 flatten 执行路径,新增实例编排器逐阶段/任务读取冻结快照 |
|
||||
| WF-002 | DocGov 三权和阶段来自请求/固定默认值,不来自实例 | 配置的责任人和活动阶段不生效 | 由 `doc_task_instance_id` 解析 author/reviewer/approver/stage;禁止生产请求传入身份覆盖 |
|
||||
|
||||
### 4.2 P1:首次正式发布必须完成
|
||||
|
||||
| ID | 问题 | 整改 |
|
||||
|---|---|---|
|
||||
| IAM-101 | `ListCapabilities` 忽略 `scope_type/scope_id`、角色状态和 `valid_from` | 引入带资源上下文的 PDP;系统能力只取 `scope_type=system, scope_id=0`,校验角色/账户/有效期 |
|
||||
| IAM-102 | `users.role_id` 与 `user_role_bindings` 双权威,更新角色会删除其他绑定 | `user_role_bindings` 为唯一权威;`users.role_id` 只作迁移兼容后移除;支持多角色 |
|
||||
| IAM-103 | `assigned_by` 被写成目标用户本身,角色+状态更新非原子且忽略角色更新错误 | 从 principal 传 actor;单事务变更;写不可变审计事件;失败整体回滚 |
|
||||
| IAM-104 | `role-admin` 字符串直接赋予管理员身份,`perm:manage` 又作为所有权限通配 | 显式 `system.superadmin`;普通 `permissions.manage` 不再通配业务数据;至少两名受控管理员防死锁 |
|
||||
| IAM-105 | 前端权限键只有最早 7 项,后端迁移已有更多权限 | 后端暴露 permission catalog;前端由 API 渲染,不维护硬编码联合类型作为事实源 |
|
||||
| SET-101 | 用户资料保存只改 React state;偏好仅组件本地;活动会话 UI 缺失 | 增加 `/me/profile`、`/me/preferences`、`/me/notification-preferences` 和会话管理 UI |
|
||||
| SET-102 | 管理中心工作流页只能列出/校验/发布,不能完整建模 | 增加创建、复制草稿、阶段排序、任务/职责/参与策略、模板选择、diff、发布、退役 |
|
||||
| SET-103 | Office 模板进入 `pending` 后缺少扫描完成链 | 隔离上传 + scanner worker + 状态回调/轮询;只有 `ready` 可发布 |
|
||||
| SET-104 | UI 宣称符合 21 CFR Part 11,但未实现电子签名控制 | 删除不实文案;如需合规,另立 e-sign scope(再认证、签署含义、签名绑定、审计、防篡改) |
|
||||
| WF-101 | 所有阶段实例 `position=0` | 从定义快照持久化真实 position;增加唯一性与连续性校验 |
|
||||
| WF-102 | 激活 token 明文保存且消费、激活非同一事务 | 仅存 token hash;`SELECT FOR UPDATE` 后在同一事务验证、消费和激活 |
|
||||
| WF-103 | 推进会把全部启用任务强制标成 approved | 推进只允许已满足门禁的状态,不修正业务状态;不一致即事务失败 |
|
||||
| WF-104 | stale 未纳入阶段门禁 | required upstream/downstream lineage 有 stale 时禁止推进,重新批准后清除 stale 并留痕 |
|
||||
| WF-105 | 完成后缺少“选择下一工作流”与受控终止/变更 API | 实现 `completed/termination_requested/terminated` 全状态机及下一实例创建 |
|
||||
| WF-106 | bootstrap 发现已有 published 版本就退出 | 种子升级采用显式版本 manifest;内容 hash 变化创建 v2 draft,经校验批准后发布 |
|
||||
|
||||
### 4.3 P2:可在首发后持续增强
|
||||
|
||||
- 管理员 break-glass:理由、近期认证、限时、醒目标识、禁止代签、完整审计。
|
||||
- MFA/WebAuthn、企业 SSO/SCIM、组织层级和角色层级。
|
||||
- 策略模拟器、权限差异比较、定期访问复核和孤儿权限报告。
|
||||
- 工作流可视化 DAG;首版仍建议坚持线性阶段,减少编排复杂度。
|
||||
- 审计日志防篡改、WORM/外部归档和监管报表。
|
||||
|
||||
## 5. 目标授权架构
|
||||
|
||||
### 5.1 五层判定模型
|
||||
|
||||
每个敏感动作统一调用一个服务端策略决策点(PDP),判定表达式为:
|
||||
|
||||
```text
|
||||
ALLOW = authenticated(principal)
|
||||
AND account.active
|
||||
AND session.active
|
||||
AND permission_allows(action, resource_type, scope)
|
||||
AND project_relationship_allows(principal, project)
|
||||
AND workflow_snapshot_policy_allows(principal, active_instance, stage)
|
||||
AND document_policy_allows(principal, doc_task, document)
|
||||
AND state_machine_allows(action, current_state)
|
||||
AND separation_of_duty_allows(principal, action)
|
||||
|
||||
unknown / missing policy / dependency error => DENY
|
||||
```
|
||||
|
||||
| 层 | 回答的问题 | 数据权威 |
|
||||
|---|---|---|
|
||||
| 认证 | 这个请求是谁,账号/会话是否有效 | `users`、`auth_sessions` |
|
||||
| 系统 RBAC | 是否具备创建、管理、发布、审计等平台动作 | role/permission/binding 表 |
|
||||
| 项目 ReBAC | 是否属于该项目,承担哪些项目业务角色 | `project_members` + `project_role_bindings` |
|
||||
| 工作流/阶段 ABAC | 当前实例、阶段、参与策略是否允许 | 冻结 `config_snapshot` + 实例状态 |
|
||||
| 文档职责/状态 | 是否为该文档的编制/审核/批准人,当前状态能否执行动作 | `document_duty_assignments` + DocGov |
|
||||
|
||||
### 5.2 角色分层
|
||||
|
||||
系统角色不再表达具体项目里的业务职责,项目角色也不再等同于某一份文档的三权。
|
||||
|
||||
| 范围 | 建议内置角色 | 用途 |
|
||||
|---|---|---|
|
||||
| 系统 | 超级管理员、安全管理员、用户管理员、工作流管理员、模板管理员、安全审计员、普通用户 | 平台配置和治理;业务项目数据不因管理员身份默认全开 |
|
||||
| 项目 | 项目创建人、项目经理、商务 BD、产品经理、系统/研发工程师、测试、法规、QA、观察者 | 阶段参与、项目资料范围和业务动作候选资格 |
|
||||
| 文档 | author、reviewer、approver | 绑定单个文档任务实例;必须是具体用户,默认互斥 |
|
||||
|
||||
一个用户可以在同一项目绑定多个业务角色;角色绑定和文档职责均必须有 `valid_from/valid_until/status/assigned_by/reason`。静态职责分离在配置时阻止冲突,动态职责分离在动作时阻止同一主体对同一版本自审自批。
|
||||
|
||||
### 5.3 权限目录
|
||||
|
||||
权限键统一采用 `resource.action`,旧键通过一版兼容映射迁移,之后删除。建议首发目录:
|
||||
|
||||
| 域 | 权限 |
|
||||
|---|---|
|
||||
| 系统 | `system.settings.view/manage`、`system.security.manage`、`system.audit.view/export` |
|
||||
| 用户 | `users.view/create/edit/disable/reset_password`、`users.sessions.revoke` |
|
||||
| 角色 | `roles.view/create/edit/retire/assign/review` |
|
||||
| 工作流 | `workflow_defs.view/create/edit_draft/publish/retire`、`workflow_defs.impact_view` |
|
||||
| 模板 | `templates.view/upload/download/publish/retire` |
|
||||
| 项目 | `projects.create/view/edit/archive`、`projects.members.view/manage` |
|
||||
| 项目流程 | `projects.workflow.view/configure/activate/terminate`、`projects.stage.advance` |
|
||||
| 参与 | `workflow.chat.view/send`、`workflow.task.run`、`workflow.progress.view` |
|
||||
| 任务运行 | `task_runs.view_own/view_project/cancel_own/cancel_any/resume` |
|
||||
| 文档 | `documents.view/edit/submit/review/approve/export`、`documents.visibility.manage` |
|
||||
| 通知 | `notifications.view/update`、`notification_policies.manage` |
|
||||
|
||||
### 5.4 服务端执行点
|
||||
|
||||
不要在每个 handler 手写角色判断。建立:
|
||||
|
||||
```go
|
||||
Decision Authorize(ctx, Principal, Action, ResourceRef, Attributes)
|
||||
|
||||
Decision {
|
||||
Allowed bool
|
||||
ReasonCode string
|
||||
PolicyVersion string
|
||||
Obligations []string // redact, require_recent_auth, audit_reason, etc.
|
||||
}
|
||||
```
|
||||
|
||||
- 路由中间件负责身份和粗粒度权限。
|
||||
- service 层在事务内重新验证对象关系和当前状态,防止 TOCTOU。
|
||||
- repository 查询默认带可见范围,不先查全量再在浏览器过滤。
|
||||
- SSE、附件下载、搜索、导出、RAG、Agent tools 使用同一个 `ResourceRef`/Decision。
|
||||
- 禁止把 `user_id`、`role`、`stage`、`work_dir` 或 duty 作为客户端可信授权输入。
|
||||
- 403/404 策略统一;审计记录 actor、resource、action、decision、reason、request_id、policy_version。
|
||||
|
||||
### 5.5 核心动作矩阵
|
||||
|
||||
| 动作 | 普通成员 | 项目业务角色 | 项目创建人 | 系统管理角色 | 文档三权 |
|
||||
|---|---|---|---|---|---|
|
||||
| 创建项目 | 需 `projects.create` | 同左 | 同左 | 不默认拥有,按绑定 | 无关 |
|
||||
| 查看项目 | active membership | active membership | 是 | 仅 break-glass 或显式审计权限 | 无关 |
|
||||
| 管理成员 | 否 | 项目经理可选 | 是 | break-glass | 无关 |
|
||||
| 配置待激活流程 | 否 | 可提议 | 是 | 工作流管理员不能代替业务确认 | 无关 |
|
||||
| 激活/终止流程 | 否 | 可申请 | 是 + 再确认 | 只可 break-glass | 无关 |
|
||||
| 运行任务 | 否 | 阶段策略允许 + 对应 task permission | 同策略,不绕过 | 不默认 | author 或显式执行人 |
|
||||
| 查看文档 | 文档策略允许 | 阶段/角色策略允许 | 仍受私密/合规策略 | 不默认 | 当前职责人允许 |
|
||||
| 提交审核 | 否 | 否 | 不默认 | 不默认 | author |
|
||||
| 审核 | 否 | 否 | 不默认 | 不默认 | reviewer 且非 author |
|
||||
| 批准 | 否 | 否 | 不默认 | 不默认 | approver 且非 author/reviewer |
|
||||
| 推进阶段 | 否 | 只能申请/接收提醒 | 明确确认 | 不替代业务确认 | 无关 |
|
||||
| 发布工作流版本 | 否 | 否 | 无关 | 工作流发布权限 + 双人复核可选 | 无关 |
|
||||
|
||||
## 6. 设置体系完整方案
|
||||
|
||||
### 6.1 用户设置 `/settings`
|
||||
|
||||
| 页面 | 服务端能力 | 验收 |
|
||||
|---|---|---|
|
||||
| 个人资料 | `GET/PATCH /api/v1/me/profile`;姓名、邮箱、语言、时区;部门/工号受控只读 | 刷新、换设备仍一致;审计敏感字段变更 |
|
||||
| 安全 | 修改密码、MFA 状态、活动会话、撤销其他会话、最近登录 | 修改密码撤销其他会话;撤销后下一请求 401 |
|
||||
| 通知 | 站内/邮件渠道、事件类型、免打扰、升级策略 | Outbox 读取服务端偏好;关键审批提醒不可被完全关闭 |
|
||||
| 偏好 | 主题、默认导出、语言、默认项目页、Agent 显示偏好 | 落库并有 schema 版本;不再只存在组件 state |
|
||||
| 我的权限 | 服务端返回生效角色、作用域、有效期和权限来源 | 不由前端本地 role 推导;支持“为何有/无此权限” |
|
||||
|
||||
安全会话改造:登录响应通过 `Set-Cookie: __Host-agent_session=...; Secure; HttpOnly; SameSite=Lax|Strict; Path=/` 建立会话;状态变更使用 CSRF token/origin 验证;logout 调服务端撤销当前会话。测试环境可通过独立 fixture 获取 Cookie,不在产品包保留任意身份切换。
|
||||
|
||||
### 6.2 项目设置 `/projects/{id}/settings`
|
||||
|
||||
| 页面 | planning | active/awaiting | completed/terminated |
|
||||
|---|---|---|---|
|
||||
| 基本信息 | 创建人编辑 | 受控字段变更单 | 只读/归档 |
|
||||
| 成员与业务角色 | 增删改,校验职责候选 | 变更需影响分析;不能留下无人职责 | 历史只读,可为下个实例配置 |
|
||||
| 工作流选择 | 选已发布版本 | 冻结只读 | 可创建下一个 planning 实例 |
|
||||
| 阶段/任务 | 配置 optional/default,required 不可关闭 | 冻结;仅受控变更 | 快照只读 |
|
||||
| 模板版本 | 从 ready 版本选择 | 冻结 | 快照只读 |
|
||||
| 三权职责 | 必填、互斥、成员/资格校验 | 变更单 + 未决审批处理 | 历史只读 |
|
||||
| 参与/可见策略 | 从版本默认值继承,可按允许范围收紧 | 冻结;紧急收紧可走安全变更 | 历史只读 |
|
||||
| 预览与激活 | diff + 门禁 + 二次确认 | 不显示“重新激活” | 显示下一流程入口 |
|
||||
| 推进/终止 | 无 | 门禁满足后创建人确认;终止需原因/影响 | 展示完整审计 |
|
||||
|
||||
项目创建人不是系统超级管理员。项目设置的“独占确认权”和系统权限的“是否允许操作接口”同时成立:例如可由项目经理编辑草案,但最终激活和阶段推进仍由创建人确认。
|
||||
|
||||
### 6.3 系统管理中心 `/admin/*`
|
||||
|
||||
| 页面 | 必做功能 |
|
||||
|---|---|
|
||||
| 概览 | 服务/DB/Runner/Outbox/worker 健康、失败任务、待审批、策略版本 |
|
||||
| 用户 | 创建、审批、启停、资料、重置密码、撤销会话、多角色绑定、批量导入 |
|
||||
| 角色权限 | 权限目录、多角色、作用域、有效期、职责分离、影响预览、角色退役 |
|
||||
| 工作流设计器 | 创建/复制草稿、阶段排序、任务、执行器、模板、输入输出、门禁、职责候选、参与/可见策略、校验、diff、发布、退役 |
|
||||
| 模板库 | 隔离上传、扫描、预览、变量 schema、版本、发布/退役、引用关系 |
|
||||
| 项目类型与合规 | `product_domain` 与 `compliance_profile` 分离;可用流程、必需文档和签署强度 |
|
||||
| 通知自动化 | 事件、渠道、模板、频率、升级和去重 |
|
||||
| 审计 | 按 actor/action/resource/result/request_id 查询和导出;高风险变更对比 |
|
||||
| 安全 | 注册、审批、密码、MFA、会话时限、CSRF/CORS、上传限制、保留期、break-glass |
|
||||
|
||||
管理端不能继续依赖硬编码权限清单。`GET /api/v1/admin/permissions` 返回目录和风险等级;每个页面使用服务端 capabilities 控制入口,但最终仍由 API 决策。
|
||||
|
||||
## 7. 工作流定义 Schema v2
|
||||
|
||||
现有 schema 只有阶段、位置、人工门禁和文档任务基本字段,无法表达运行绑定、参与策略、职责资格、输入输出和确定性质量门禁。建议新增 `schema_version: 2`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"workflow_key": "product_manager",
|
||||
"product_domains": ["medical_device", "non_medical_software"],
|
||||
"required_upstream": [],
|
||||
"participation_policy": {},
|
||||
"completion_policy": {},
|
||||
"stages": [{
|
||||
"key": "requirements_baseline",
|
||||
"name": "系统需求基线",
|
||||
"position": 10,
|
||||
"optional": false,
|
||||
"entry_gate": {},
|
||||
"exit_gate": {},
|
||||
"participation_policy": {},
|
||||
"doc_tasks": [{
|
||||
"key": "system_reqs",
|
||||
"executor_key": "squad.system_reqs.v1",
|
||||
"required": true,
|
||||
"blocking": true,
|
||||
"template": {"asset_key": "system_reqs", "version": 2},
|
||||
"input_contract": {},
|
||||
"output_contract": {},
|
||||
"quality_gate_key": "quality.system_reqs.v2",
|
||||
"eligible_project_roles": {},
|
||||
"visibility_policy": {},
|
||||
"retry_policy": {},
|
||||
"timeout_seconds": 3600
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
发布校验必须至少验证:
|
||||
|
||||
- key 格式和全局唯一;stage position 唯一且严格递增;必需阶段/任务不可缺失;
|
||||
- `executor_key` 已注册且声明支持相应输入/输出;不再把未知任务落入 generic runner;
|
||||
- template version 存在且 `scan_status=ready`,变量 schema 与 input contract 一致;
|
||||
- 三权候选角色存在,且不会造成项目无人可配;author/reviewer/approver 默认互斥;
|
||||
- 上游工作流和文档依赖无环、能解析到已发布版本;
|
||||
- 每个 required/blocking task 有确定性质量门禁;
|
||||
- 可见/参与策略有明确默认值,未知即拒绝;
|
||||
- 发布后 definition JSON/hash 和所有引用版本不可变;编辑从已发布版本复制新 draft。
|
||||
|
||||
## 8. 三个工作流的正式定义
|
||||
|
||||
### 8.1 兼容性决定
|
||||
|
||||
当前项目和代码广泛使用 `main` 代表商务主线。首轮迁移**保留稳定 key `main`**,显示名统一为“商务对接”,避免破坏存量项目、任务记录和文档 ID;停止在新代码里把它解释成“任意默认流程”。`product_manager` 和 `test_manager` 保持现有 key。若未来希望改为 `business_engagement`,新增显式 alias 表,不直接改历史 key。
|
||||
|
||||
三个正式版本均以 v2 draft 创建和发布;不得修改已有 published v1,也不得依赖当前 `UpsertPublishedSeed` 自动覆盖。已 active 的 v1 项目继续使用原快照;planning 项目由创建人查看 diff 后主动升级。
|
||||
|
||||
### 8.2 商务对接 `main` v2
|
||||
|
||||
当前真实执行器已有以下阶段和任务,本方案将其搬入版本定义,执行器只保留能力注册,不再保留另一套阶段事实源。[^13]
|
||||
|
||||
| 顺序 | 阶段 | 文档任务 | 必需/阻断 | 建议三权候选 | 退出门禁 |
|
||||
|---:|---|---|---|---|---|
|
||||
| 10 | `V0.1_triage` 闪电摸底与盲区探测 | `intake` 客户需求采集/初始需求表 | 是/是 | author: BD/PM;reviewer: 项目经理;approver: 创建人或商务负责人 | intake approved;关键项无缺失;无运行任务 |
|
||||
| 20 | `V0.5_feasibility` 方案成型与法规对齐 | `research` 竞品调研;`feasibility_reg` 法规可行性;`prelim_design` 初步设计 | 全部是/是 | 按文档分别指派 BD、法规、研发;审核/批准分离 | 三文档 approved;无 stale/open change;entry inputs 已基线化 |
|
||||
| 30 | `V1.0_quoting` 工程拆解与商业锁定 | `cdo_quote` CDO 报价与核算 | 是/是 | author: 商务/项目经理;reviewer: 技术/财务;approver: 创建人/授权商务负责人 | 正文和核算 XLSX hash 一致、approved;报价关键字段完整 |
|
||||
|
||||
参与策略默认值:
|
||||
|
||||
- BD 可参与 V0.1/V0.5 的 `intake/research`,不因“是项目成员”自动获得 `prelim_design` 或报价批准权。
|
||||
- 法规只访问被授权的法规任务及其显式上游;研发只访问初步设计及显式上游。
|
||||
- 创建人可看项目进度并确认推进,但不会自动获得某份文档的审核/批准动作。
|
||||
- 阶段完成只产生 `ready_to_advance` 通知;创建人确认后原子进入下一阶段。
|
||||
|
||||
### 8.3 智能产品经理 `product_manager` v2
|
||||
|
||||
项目层保持一个受控阶段,阶段内的五个 Agent step 是 executor 内部步骤,不把内部 agent step 误建成五个需要人工推进的项目阶段。现有代码已经定义需求梳理、系统工程、法规、成文、终审五角色和五步执行链,可直接注册为 `squad.system_reqs.v1`。[^20]
|
||||
|
||||
| 项 | 正式配置 |
|
||||
|---|---|
|
||||
| 阶段 | `requirements_baseline` 系统需求基线,position=10,required/blocking |
|
||||
| 文档任务 | `system_reqs`,输出受控《系统需求规格书》 |
|
||||
| 执行器 | `squad.system_reqs.v1` |
|
||||
| 模板 | `system_reqs` ready version;发布时固定版本 |
|
||||
| 必需输入 | 项目五要素;已批准/显式基线化的客户需求或上游商务文档;项目材料清单 |
|
||||
| 结构化输出 | `output.md`、`artifacts/requirements.json`、manifest/hash、质量报告 |
|
||||
| 三权候选 | author: PM/系统工程;reviewer: 技术负责人/法规;approver: 创建人授权的批准人/QA |
|
||||
| 质量门禁 v2 | 每条需求唯一 ID;来源、理由、优先级、可验证准则、验证方法齐全;无未替换占位;JSON 与正文一致;重复/冲突/孤儿需求为 0 |
|
||||
| 完成门禁 | executor 成功 + deterministic gate 通过 + 文档 approved + 无 stale/open change/running task |
|
||||
|
||||
当前质量门禁只检查正文中是否出现 `REQ-` 和宽泛的“测试/分析/审查”等词,达不到“需求集合完整、唯一、可追溯、可测”的正式门禁。v2 必须解析 `requirements.json`,以结构化集合为准,并把校验报告与文档版本绑定。[^21]
|
||||
|
||||
### 8.4 智能测试经理 `test_manager` v2
|
||||
|
||||
| 项 | 正式配置 |
|
||||
|---|---|
|
||||
| 阶段 | `system_verification_planning` 系统验证策划,position=10,required/blocking |
|
||||
| 文档任务 | `test_manager`,输出受控《系统验证方案及计划》 |
|
||||
| 执行器 | `squad.test_manager.v1` |
|
||||
| 上游条件 | 同项目最近一次已批准且非 stale 的 `system_reqs`;记录上游 doc/version/hash |
|
||||
| 模板 | `test_manager` ready version;发布时固定版本 |
|
||||
| 结构化输出 | `output.md`、必需的 `artifacts/test_cases.json`、coverage report、manifest/hash |
|
||||
| 三权候选 | author: 测试;reviewer: 系统/法规;approver: 测试负责人/QA,三者互斥 |
|
||||
| 质量门禁 v2 | 上游需求全集解析成功;每条 requirement 至少一条有效 case;case ID 唯一;步骤、预期、判据、环境、方法完整;覆盖率 100%;无孤儿引用 |
|
||||
| stale 策略 | 上游 SR hash 变化立即将下游标为 stale,禁止提交/批准/阶段完成,完成影响分析与重新基线后解除 |
|
||||
|
||||
现有 executor 已要求上游 SR approved 并建立血缘,也定义了 100% 覆盖的 Agent 目标;但当前确定性门禁只检查 `TC-/TEST-`、`REQ-` 字符串,并且 `test_cases.json` 仅在文件存在时校验 JSON,文件不存在不会失败。v2 必须把结构化用例文件和覆盖率报告设为必需产物。[^21][^22]
|
||||
|
||||
### 8.5 三个工作流的串联语义
|
||||
|
||||
系统当前设计是“一项目最多一个 live 工作流”,不是三条流程同时 active:
|
||||
|
||||
```text
|
||||
商务对接 completed/受控 terminated
|
||||
-> 创建人选择并配置 product_manager v2
|
||||
-> 产品经理流程 completed,系统需求 approved
|
||||
-> 创建人选择并配置 test_manager v2
|
||||
-> 测试经理流程完成
|
||||
```
|
||||
|
||||
系统可以在前一流程门禁满足时推荐下一个流程并通知创建人,但绝不自动激活。上游依赖解析按文档类型 + workflow instance + approved version 查询,不依赖固定文件路径或仅由字符串拼出的 doc ID。
|
||||
|
||||
## 9. 配置到运行时的唯一权威链
|
||||
|
||||
必须建立以下单向链,禁止旁路:
|
||||
|
||||
```text
|
||||
published workflow version + ready template versions
|
||||
↓ creator configures / preview / confirms
|
||||
immutable project workflow snapshot
|
||||
↓ creates ordered stage/task instances + duty assignments
|
||||
active stage + authorized doc task instance
|
||||
↓ resolves executor, template, inputs, actor obligations
|
||||
task run + DocGov document version + lineage + audit
|
||||
↓ deterministic gate + three-duty approval + no stale/open work
|
||||
ready_to_advance notification
|
||||
↓ creator explicit confirmation
|
||||
next stage OR workflow completed
|
||||
```
|
||||
|
||||
具体约束:
|
||||
|
||||
1. `POST task-runs` 接收 `doc_task_instance_id` 和业务输入,不接收可信 `user_id/role/stage/work_dir/reviewer_id/approver_id`。
|
||||
2. 服务端锁定实例和 task instance,验证 active stage、actor、executor、模板和当前状态。
|
||||
3. WorkDir 由服务端按 project/instance/task_run 生成并做路径约束。
|
||||
4. DocGov 从 duty assignment 创建/校验文档责任人,stage 来自 stage instance。
|
||||
5. 任务结果写入与运行终态、文档版本、manifest、lineage、outbox 使用事务/可恢复 saga,避免“显示完成但产物未落稳”。
|
||||
6. 列表、详情、导出、附件、搜索、SSE、RAG 和 Agent tool read 都使用同一资源授权上下文。
|
||||
|
||||
## 10. 数据库迁移方案
|
||||
|
||||
迁移号以合并时最新 Goose 版本为准,下表暂按 `00021+` 表示。
|
||||
|
||||
| 迁移 | 主要变更 | 兼容/回填 |
|
||||
|---|---|---|
|
||||
| 00021 IAM hardening | project_members.user_id FK;role/binding 状态、reason;审计 actor;权限键 alias | 校验孤儿成员后加 FK;旧 `users.role_id` 双读一版、停止写入 |
|
||||
| 00022 project roles | `project_roles`、`project_role_permissions`、`project_user_role_bindings` | owner/member/viewer 映射为 owner/contributor/observer;原表保留 membership 状态 |
|
||||
| 00023 settings | user preferences、notification preferences、security policy versions | 把可识别的 local preferences 一次性导入需用户确认;其余使用服务端默认 |
|
||||
| 00024 workflow schema v2 | executor registry/version、schema fields、policy JSON schema/version | 不修改 v1 JSON;validator 按 schema_version 路由 |
|
||||
| 00025 instance integrity | stage position、task executor/template/output snapshot、duty eligibility、change requests | 修正现有 position;对 active 数据先报告后修复,不静默重排 |
|
||||
| 00026 authorization audit | `authorization_audit_events` 或统一 audit_event 扩展、policy version、request ID | 明确保留期和敏感字段脱敏 |
|
||||
| 00027 workflow v2 manifests | 创建三个 v2 draft/版本 manifest | 只在 hash 匹配预期时创建;不覆盖用户自定义版本 |
|
||||
|
||||
数据库约束建议:
|
||||
|
||||
- live instance 部分唯一索引继续保留;增加 planning 与 live 状态转换约束测试。
|
||||
- `project_stage_instances(workflow_instance_id, position)` unique,position > 0。
|
||||
- task instance 保存 `executor_key/template_version/output_doc_type/quality_gate_key` 的冻结副本。
|
||||
- duty assignment 引用 active project membership;跨表动态条件用事务 trigger 或 service + deferred validation 实现。
|
||||
- activation token 只存 hash;高风险一次性 token 使用短 TTL、actor/project/plan hash 绑定。
|
||||
- published definition/template 通过数据库权限或 trigger 阻止 UPDATE/DELETE,不只依赖 Go service。
|
||||
|
||||
## 11. API 调整
|
||||
|
||||
### 11.1 身份与设置
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/login -> Set-Cookie
|
||||
POST /api/v1/auth/logout -> revoke current session
|
||||
GET /api/v1/me
|
||||
PATCH /api/v1/me/profile
|
||||
GET/PUT /api/v1/me/preferences
|
||||
GET/PUT /api/v1/me/notification-preferences
|
||||
GET /api/v1/me/sessions
|
||||
DELETE /api/v1/me/sessions/{id}
|
||||
POST /api/v1/me/password/change
|
||||
```
|
||||
|
||||
### 11.2 权限与管理
|
||||
|
||||
```text
|
||||
GET /api/v1/admin/permissions
|
||||
GET/POST/PATCH /api/v1/admin/roles...
|
||||
GET/POST/DELETE /api/v1/admin/role-bindings...
|
||||
POST /api/v1/admin/authorization/simulate
|
||||
GET /api/v1/admin/audit-events
|
||||
POST /api/v1/admin/users/{id}/reset-password
|
||||
POST /api/v1/admin/users/{id}/sessions/revoke
|
||||
```
|
||||
|
||||
每个 admin endpoint 绑定独立 permission,不再通过 `requireSystemAdmin` 全包。
|
||||
|
||||
### 11.3 工作流与项目实例
|
||||
|
||||
```text
|
||||
POST /api/v1/admin/workflows/{id}/versions/{v}/copy-draft
|
||||
PATCH /api/v1/admin/workflows/{id}/versions/{v}
|
||||
POST /api/v1/admin/workflows/{id}/versions/{v}/validate
|
||||
GET /api/v1/admin/workflows/{id}/versions/{v}/impact
|
||||
POST /api/v1/admin/workflows/{id}/versions/{v}/publish
|
||||
POST /api/v1/admin/workflows/{id}/versions/{v}/retire
|
||||
|
||||
GET/PUT /api/v1/projects/{id}/workflow-plan
|
||||
POST /api/v1/projects/{id}/workflow-plan/preview
|
||||
POST /api/v1/projects/{id}/workflow-plan/activate
|
||||
POST /api/v1/projects/{id}/workflow-instances/{iid}/termination-requests
|
||||
POST /api/v1/projects/{id}/workflow-instances/{iid}/terminate
|
||||
POST /api/v1/projects/{id}/workflow-instances/{iid}/change-requests
|
||||
POST /api/v1/projects/{id}/workflow-instances/{iid}/stages/{stage}/advance
|
||||
POST /api/v1/projects/{id}/doc-tasks/{taskInstanceId}/runs
|
||||
```
|
||||
|
||||
所有资源路径先验证 path 中 project/instance/stage/task/document 的归属一致性,避免用合法项目权限访问另一个项目的子资源。
|
||||
|
||||
## 12. 前端实施方案
|
||||
|
||||
1. **认证壳**:不从 `localStorage` 恢复 principal/token;启动时只调用 `/me`。401 清空全部敏感状态并跳转登录。
|
||||
2. **权限 Provider**:从 `/me` 获取 capabilities + scoped grants;按钮、菜单和路由只做 UX 控制,错误统一展示服务端 reason code。
|
||||
3. **用户设置**:现有 modal 拆到真实 API;接入活动会话;资料/偏好保存失败不更新本地成功态。
|
||||
4. **系统管理中心**:从单一大 modal 演进为路由页面;权限矩阵来自 catalog;高风险变更提供 diff、影响人数和二次确认。
|
||||
5. **工作流设计器**:线性阶段排序、每阶段多任务、executor 只从注册表下拉、模板版本选择、职责候选、参与/可见策略、JSON 高级视图、发布校验报告。
|
||||
6. **项目设置**:明确 planning/live/history 三态;live 快照只读;门禁列表展示每个阻断项;下一工作流必须由创建人确认。
|
||||
7. **工作台**:根据服务端返回的 allowed actions 渲染,不在前端用姓名/本地角色重新计算业务授权。
|
||||
8. **删除演练入口**:`UserProjectRightsModal` 的测试角色切换、旧 `SettingsModal`、本地模拟分支和生产 bundle 中的测试身份文案移出产品代码。
|
||||
|
||||
## 13. 分阶段交付计划
|
||||
|
||||
估时为单个熟悉代码库的小组工作日,安全修复与工作流建模可并行开发,但发布必须按闸门顺序。
|
||||
|
||||
### Phase 0:基线冻结与权限清单(1–2 天)
|
||||
|
||||
- 固定 commit、DB schema、环境和三个 workflow v1 hash。
|
||||
- 自动导出全部 `/api/v1` 路由,建立 resource/action/owner/negative test 矩阵。
|
||||
- 修正当前 E2E 报告状态矛盾;归档一次干净的 short test 和 build 结果。
|
||||
- 输出:`authorization-matrix-v2.md`、migration preflight SQL、测试数据拓扑。
|
||||
|
||||
门禁:每个非公开路由都有责任 owner、策略和至少一个未授权负例;未知路由默认为拒绝。
|
||||
|
||||
### Phase 1:P0 授权止血(3–5 天)
|
||||
|
||||
- 修复 AUTH-001–009、WF-001/002 的越权入口。
|
||||
- 建立统一 `Authorize`/ResourceRef 和 server-side allowed actions。
|
||||
- 删除固定管理员/演示项目 ID 豁免;生产禁用 permissive fallback。
|
||||
- Cookie/CSRF/logout/401 前端失效闭环。
|
||||
|
||||
门禁:匿名、跨用户、跨项目、跨阶段、错 task、错 child resource、DB authz fault 全部负例通过;SSE/RAG/Agent tool 同样验证。
|
||||
|
||||
### Phase 2:IAM 与设置权威化(4–6 天)
|
||||
|
||||
- 完成 IAM-101–105 和用户/项目/系统设置 API。
|
||||
- 角色多绑定、作用域/有效期、permission catalog、原子用户变更和审计。
|
||||
- 用户资料、偏好、通知、活动会话 UI;管理中心权限拆分。
|
||||
|
||||
门禁:重启/换浏览器数据一致;权限收回和账号停用下一请求生效;至少两名管理员防锁死演练通过。
|
||||
|
||||
### Phase 3:工作流 Schema v2 与运行时合流(5–8 天)
|
||||
|
||||
- 实现 schema v2、executor/quality gate registry、完整 validator。
|
||||
- 移除 `flattenVersion` 作为执行路径;编排器只读实例快照。
|
||||
- 修复 position、token 原子性、DocGov duty/stage 绑定、stale 门禁。
|
||||
- 完成受控终止、变更单和下一个工作流生命周期。
|
||||
|
||||
门禁:修改全局 published/latest 不影响 active 实例;请求伪造 stage/task/duty 无效;故障恢复后实例位置和职责不漂移。
|
||||
|
||||
### Phase 4:发布三个 v2 工作流(4–6 天)
|
||||
|
||||
- 按 §8 创建 `main`、`product_manager`、`test_manager` v2 draft。
|
||||
- 绑定 ready 模板和 executor;补齐结构化门禁。
|
||||
- 对 planning v1 项目生成可读 diff,由创建人选择是否升级;active v1 不迁移。
|
||||
- 完成三个流程的串联推荐,但保持人工选择/激活。
|
||||
|
||||
门禁:三个版本通过静态校验、dry-run、单流程 PG E2E 和串联 PG E2E。
|
||||
|
||||
### Phase 5:管理端/项目端完整体验(4–6 天)
|
||||
|
||||
- 工作流设计器、模板库、项目规划、门禁详情、影响预览、下一流程、终止/变更 UI。
|
||||
- 删除旧本地/演示权威路径和不实合规文案。
|
||||
- 可访问性、错误恢复、并发 diff/reconfirm。
|
||||
|
||||
门禁:产品经理无需改代码即可复制 v2 为 v3 草案、换模板/职责候选、校验并由有权者发布;普通管理员无法越权发布。
|
||||
|
||||
### Phase 6:发布验证与灰度(5–8 天)
|
||||
|
||||
- 全量 Go/前端、race、PG、迁移升级/回滚、重启、worker 故障注入。
|
||||
- 真实 PI Agent 三工作流 E2E;长任务超时/恢复/取消;跨用户负例。
|
||||
- 备份恢复、日志脱敏、监控告警、容量和 bundle 分包。
|
||||
- 灰度一个内部项目,再扩到新项目;旧项目只读兼容指标归零后删除 fallback。
|
||||
|
||||
门禁:§14 全部完成;P0/P1 为 0;可恢复演练和回滚演练均有报告。
|
||||
|
||||
总体建议:**21–35 个小组工作日**。如多人并行,最短日历时间约 4–5 周;不应通过跳过负例、迁移和真实 Runner 验证压缩关键路径。
|
||||
|
||||
## 14. 测试与验收矩阵
|
||||
|
||||
### 14.1 授权负例
|
||||
|
||||
| 场景 | 预期 |
|
||||
|---|---|
|
||||
| 未登录访问任意非公开 API/SSE/附件 | 401,不泄漏资源存在性 |
|
||||
| A 项目成员读 B 项目、run history、lineage、搜索、导出 | 403/404,审计 deny |
|
||||
| 当前阶段 BD 请求研发 task key | 403;不创建 task_run/workdir/doc |
|
||||
| 非职责人提交/审核/批准 | 403;状态、版本、审计不变 |
|
||||
| author 尝试审核或批准同一版本 | 409/403 SoD violation |
|
||||
| authz DB/策略加载故障 | 敏感路径拒绝,健康状态 degraded,触发告警 |
|
||||
| 权限撤销后复用旧页面/旧 SSE/旧 Cookie | 下一次鉴权失败;流关闭或重新鉴权 |
|
||||
| 猜测 task/document/template/blob ID | 不能跨项目读取 |
|
||||
| 客户端伪造 user/role/stage/workdir/duty | 服务端忽略或 400;不得改变判定 |
|
||||
|
||||
### 14.2 工作流配置与冻结
|
||||
|
||||
- v2 required stage/task 被遗漏、禁用、重复或错序时校验失败。
|
||||
- executor 未注册、模板非 ready、变量 schema 不匹配时禁止发布。
|
||||
- published version UPDATE/DELETE 在 service 和 DB 层均失败。
|
||||
- 激活 token 重放、过期、跨 actor、跨 project、plan hash 不同均失败。
|
||||
- 并发保存/激活/推进只成功一个;另一方收到 diff/reconfirm,不自动重试。
|
||||
- 修改 v3 不影响使用 v2 的 active 项目;历史 hash 可复算。
|
||||
- 流程未完成/未终止时不能创建第二个 live;完成后可配置下一个 planning。
|
||||
|
||||
### 14.3 三工作流 E2E
|
||||
|
||||
**商务对接:** 按 V0.1 → V0.5 三任务 → V1.0 运行;每阶段文档分别走三权;未全部 approved 不产生 ready;满足后产生一次去重提醒;只有创建人确认推进。
|
||||
|
||||
**智能产品经理:** 从批准的输入基线生成 `requirements.json` 和正文;随机抽取、重复 ID、缺验证准则、正文/JSON 不一致均被确定性门禁拒绝;批准后可作为下游输入。
|
||||
|
||||
**智能测试经理:** 没有 approved SR、SR stale、hash 不匹配时拒绝启动;生成必需 `test_cases.json`;需求全集覆盖率必须 100%;上游更新后下游立即 stale 且不能完成。
|
||||
|
||||
**串联:** `main` 完成后只推荐 PM;创建人配置并激活;PM 完成后只推荐 Test;每个实例版本、模板、职责、输入 hash、输出 hash 和审计链可追溯。
|
||||
|
||||
### 14.4 运行与灾难恢复
|
||||
|
||||
- 应用、worker、PostgreSQL、PI Runner 分别重启,验证会话/任务/实例/Outbox 恢复。
|
||||
- PI Runner 超时后 task 可恢复或明确失败,不重复生成审批版本。
|
||||
- Outbox 重投不重复发阶段提醒;消费者幂等。
|
||||
- 迁移从 v20 的生产快照升级;备份恢复后 hash/权限/实例一致。
|
||||
- `go test -race`、前端测试、生产 build、依赖/镜像扫描、长期 soak。
|
||||
|
||||
## 15. 发布完成定义(Definition of Done)
|
||||
|
||||
只有同时满足以下条件,才能宣布“权限、设置和三个工作流已按当前设计做全”:
|
||||
|
||||
- [ ] 生产产品不存在任意身份切换、本地认证 fallback 或客户端身份权威。
|
||||
- [ ] 每个 API/SSE/RAG/Agent tool/附件通路都有服务端授权和跨用户负例。
|
||||
- [ ] 授权失败或依赖异常默认拒绝;不存在固定用户 ID、项目 ID 特权。
|
||||
- [ ] 系统角色、项目业务角色、文档三权分离且可审计;三权职责分离生效。
|
||||
- [ ] 用户、项目、系统三类设置均由服务端持久化,刷新/重启/换端一致。
|
||||
- [ ] 工作流、模板、权限管理使用各自细粒度 permission,而不是全员超级管理员。
|
||||
- [ ] 已发布工作流/模板不可变;项目激活冻结版本、模板、阶段、任务、策略和职责。
|
||||
- [ ] 同一项目最多一个 live;完成/受控终止后由创建人选择下一个。
|
||||
- [ ] 门禁满足只通知,不自动推进;创建人确认与推进在事务中完成。
|
||||
- [ ] `main` v2 完整执行 V0.1/V0.5/V1.0,不再由硬编码目录另行决定。
|
||||
- [ ] `product_manager` v2 结构化需求门禁通过;`test_manager` v2 实现 100% 需求覆盖和 stale 阻断。
|
||||
- [ ] DocGov 的 stage 和三权全部来自冻结实例,客户端不能覆盖。
|
||||
- [ ] PG、真实 PI Agent、重启恢复、故障注入、跨项目/跨阶段负例均有可复现报告。
|
||||
- [ ] P0/P1 缺陷归零,灰度/回滚/监控/告警和审计查询可用。
|
||||
|
||||
## 16. 风险与控制
|
||||
|
||||
| 风险 | 概率/影响 | 控制 |
|
||||
|---|---|---|
|
||||
| v1/v2 双执行权威并存 | 高/高 | feature flag 只选一个 orchestrator;记录每次 run 的 schema/executor/version;不静默 fallback |
|
||||
| 存量项目职责/成员数据不完整 | 高/中 | preflight 报告;active 不强迁;planning 由创建人补全并确认 |
|
||||
| 权限收紧影响现有演示项目 | 高/中 | 显式 legacy visibility 数据迁移;不保留 ID 豁免;灰度观察 deny 指标 |
|
||||
| Cookie 改造引入 CSRF | 中/高 | SameSite + CSRF token + Origin/Referer 校验;状态变更不用 GET |
|
||||
| 长 Agent 运行与 HTTP 超时 | 高/高 | 异步 task + lease/heartbeat + resumable worker;客户端订阅状态,不持有超长请求 |
|
||||
| 结构化质量门禁误报 | 中/中 | schema version、明确错误定位、golden corpus、允许修复重跑但不可人工静默跳过 |
|
||||
| 角色配置锁死管理员 | 中/高 | 双管理员、关键权限不可清空、变更影响预览、break-glass 离线流程 |
|
||||
| 模板恶意文件/解析炸弹 | 中/高 | 隔离、扩展/MIME/签名、大小/解压限制、扫描、Web root 外 CAS;仅 ready 可引用 |
|
||||
|
||||
## 17. 建议的首个迭代任务包
|
||||
|
||||
第一周不要先画完整工作流设计器,先完成以下可独立验收的任务:
|
||||
|
||||
1. `AUTH-001`:给 run history/lineage 及所有项目子资源补统一对象授权和双用户 PG 负例。
|
||||
2. `AUTH-002`:阶段访问错误全部 fail closed,加入数据库故障注入测试。
|
||||
3. `AUTH-003`:workflow run/task run 改从 task instance 推导 task/stage/executor,客户端覆盖返回 400。
|
||||
4. `AUTH-004/005`:补必需项反向校验和 duty member/status/role eligibility 校验。
|
||||
5. `AUTH-006/007`:删除 ID 特判,项目列表改用 `ListVisibleProjectIDs`/membership join,创建项目校验 permission。
|
||||
6. `AUTH-009`:服务端 logout + `/me` 失效清理;设计 Cookie/CSRF 兼容迁移。
|
||||
7. `WF-001/002`:提交 schema v2 ADR 和 runtime authority contract,阻止新增旁路。
|
||||
|
||||
首周验收物应是:代码、迁移 preflight、授权矩阵、负例测试、更新后的安全报告。只有该闸门通过,才开始发布三个 v2 draft。
|
||||
|
||||
## 18. 交付物清单
|
||||
|
||||
- 本实施方案。
|
||||
- API 授权矩阵 v2(路由、动作、资源、策略、负例、owner)。
|
||||
- Schema v2 JSON Schema + executor/quality gate registry ADR。
|
||||
- Goose 00021+ 迁移和 preflight/rollback SQL。
|
||||
- 三个工作流 v2 manifest、模板版本清单和 golden fixtures。
|
||||
- 用户/项目/系统设置产品规格与 API contract。
|
||||
- 授权负例、PG、真实 PI Agent、迁移、恢复、soak 测试报告。
|
||||
- 灰度清单、监控面板、告警规则、回滚 runbook。
|
||||
|
||||
## 资料来源
|
||||
|
||||
[^1]: 当前治理原则与目标模型见 [2026-09-11-production-ready-workflow-governance-plan.md](./2026-09-11-production-ready-workflow-governance-plan.md)。
|
||||
[^2]: IAM、工作流定义、项目实例、通知和任务持久化分别见 [00015 IAM](../deploy/postgres/goose/00015_2026-09-12-iam-core.sql)、[00016 workflow definitions](../deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql)、[00017 project instances](../deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql)、[00018 outbox](../deploy/postgres/goose/00018_2026-09-12-outbox-notifications-stage-advance.sql)、[00019 task runs](../deploy/postgres/goose/00019_2026-09-12-task-runs-events.sql) 和 [00020 worker lease](../deploy/postgres/goose/00020_2026-09-12-task-run-worker-lease.sql)。
|
||||
[^3]: 本轮验证命令与结果记录在本方案 §2.2;当前历史测试状态见 [2026-09-12-pg-ci-soak-e2e-test-report.md](./test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md)。
|
||||
[^4]: 系统能力查询没有限定 binding scope/role status/valid_from,且 role-admin 字符串直接判 admin,见 [internal/authz/service.go](../internal/authz/service.go#L26-L99)。
|
||||
[^5]: 创建项目只验证身份,见 [project_handlers.go](../internal/server/project_handlers.go#L16-L46);运行历史和血缘查询未做项目访问判断,见同文件 [run history/lineage](../internal/server/project_handlers.go#L221-L274)。
|
||||
[^6]: 工作流管理统一走 `requireSystemAdmin`,没有使用已经入库的 `workflow_defs.*`,见 [admin_workflow_handlers.go](../internal/server/admin_workflow_handlers.go#L13-L23);权限种子见 [00016](../deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql#L67-L82)。
|
||||
[^7]: `ResolveStageAccess` 在 live 查询错误时返回 unrestricted,且一个阶段任一职责会放开该阶段全部任务,见 [stage_access.go](../internal/projectinstance/stage_access.go#L15-L68);服务器过滤辅助函数也在错误时放行,见 [stage_access_helpers.go](../internal/server/stage_access_helpers.go#L23-L47)。
|
||||
[^8]: 任务门禁在 actor 能访问 active stage 时放过不匹配 task,见 [bizdev_stage_gate.go](../internal/server/bizdev_stage_gate.go#L40-L54);workflow run 接收客户端 task override,见 [project_handlers.go](../internal/server/project_handlers.go#L205-L218)。
|
||||
[^9]: token/session 存 localStorage 且 logout 只删除本地键,见 [web/src/api/auth.ts](../web/src/api/auth.ts#L306-L330);恢复 session 后 `/me` 失败被吞掉,见 [web/src/App.tsx](../web/src/App.tsx#L690-L703)。
|
||||
[^10]: 用户设置把偏好描述为浏览器本地并从前端角色推导权限,见 [UserSettingsModal.tsx](../web/src/components/user/UserSettingsModal.tsx#L68-L77) 与 [同文件](../web/src/components/user/UserSettingsModal.tsx#L673-L779);后端已有活动会话 API,见 [auth_handlers.go](../internal/server/auth_handlers.go#L162-L204)。
|
||||
[^11]: 三个单阶段种子见 [workflowdef/bootstrap.go](../internal/workflowdef/bootstrap.go#L63-L74);seed 遇到已有发布版本直接退出,见 [workflowdef/store.go](../internal/workflowdef/store.go#L364-L372)。
|
||||
[^12]: DBCatalog 只取首阶段/首任务,见 [workflowdef/catalog.go](../internal/workflowdef/catalog.go#L50-L82);现有 schema 能表达的字段见 [workflowdef/types.go](../internal/workflowdef/types.go#L15-L43)。
|
||||
[^13]: 商务流程真实阶段和任务仍由 [internal/bizdev/service.go](../internal/bizdev/service.go#L202-L223) 硬编码。
|
||||
[^14]: 规划 validator 只遍历客户端提交项,没有反向验证必需定义项是否缺失,见 [projectinstance/validator.go](../internal/projectinstance/validator.go#L37-L105);三权只校验非空和互斥,见 [同文件](../internal/projectinstance/validator.go#L108-L144)。
|
||||
[^15]: 阶段实例 position 固定写 0,见 [projectinstance/store.go](../internal/projectinstance/store.go#L244-L249);执行器默认 reviewer=2、approver=3 并固定阶段,见 [bizdev/service.go](../internal/bizdev/service.go#L383-L418)。
|
||||
[^16]: 当前报告的真实 PI Agent E2E 记录 `/v1/run` 超时,同时结论仍称“进行中”,见 [2026-09-12-pg-ci-soak-e2e-test-report.md](./test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md)。
|
||||
[^17]: [OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)。
|
||||
[^18]: [NIST Role Based Access Control](https://csrc.nist.gov/Projects/Role-Based-Access-Control) 与 [NIST SP 800-162 ABAC](https://csrc.nist.gov/pubs/sp/800/162/upd2/final)。
|
||||
[^19]: [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)。
|
||||
[^20]: 产品经理和测试经理的 Agent 角色及职责见 [squad/workspace.go](../internal/squad/workspace.go#L648-L743),内部步骤见 [同文件](../internal/squad/workspace.go#L940-L1073)。
|
||||
[^21]: 当前产品/测试质量门禁见 [squad/quality_gate.go](../internal/squad/quality_gate.go#L108-L146)。
|
||||
[^22]: 测试经理对 approved SR 的前置校验和材料注入见 [bizdev/service.go](../internal/bizdev/service.go#L365-L381),stale 传播见 [projectworkflow/service.go](../internal/projectworkflow/service.go#L237-L265)。
|
||||
@@ -0,0 +1,461 @@
|
||||
# 项目审计快速参考指南
|
||||
|
||||
**用途**: 开发/QA 快速定位问题、理解整改方向
|
||||
**格式**: 结构化索引,支持搜索和导航
|
||||
|
||||
---
|
||||
|
||||
## 一、关键数字一览
|
||||
|
||||
| 指标 | 数值 | 说明 |
|
||||
|---|---|---|
|
||||
| 项目完成度 | 70% | 架构完整,核心功能大部分实现,缺新功能和 bug 修复 |
|
||||
| 已确认缺陷 | 12 个 | 3 个 critical,4 个 high,5 个 medium |
|
||||
| 未完成功能 | 3 个 | 项目上下文快照、受控终止、E2E 测试 |
|
||||
| 整改工作量 | 61h | 单人 2.5 天,双人 5 天 |
|
||||
| 单元测试 | 120+ | 85% 覆盖主要包 |
|
||||
| E2E 测试 | 0% | **需补充** |
|
||||
| 数据污染 | 88 个定义 | 测试定义混入开发库 |
|
||||
|
||||
---
|
||||
|
||||
## 二、快速诊断矩阵
|
||||
|
||||
### 症状 → 原因 → 解决方案
|
||||
|
||||
```
|
||||
症状 1: 完成工作流后项目仍显示为"正在运行",无法配置下一个工作流
|
||||
├─ 原因: BUG-01 - AdvanceStage 完成最后阶段时未更新项目 lifecycle
|
||||
├─ 代码位置: internal/projectinstance/stage_store.go:128-158
|
||||
├─ 优先级: 🔴 P0 CRITICAL
|
||||
└─ 解决: Task A1(2h)
|
||||
└─ 同事务更新 projects.lifecycle_status = 'planning'
|
||||
|
||||
症状 2: 禁用的文档仍然可以在某些页面被启动/编辑
|
||||
├─ 原因: BUG-05 - 拒绝逻辑分散,不同入口检查不一致
|
||||
├─ 代码位置: stage_access.go, API, SSE, Agent tools 多处
|
||||
├─ 优先级: 🔴 P1 HIGH
|
||||
└─ 解决: Task A5(4h)
|
||||
└─ 实现 DocumentAccessDecider,统一所有入口
|
||||
|
||||
症状 3: 对话中说"推进工作流"有时不生效或使用旧流程
|
||||
├─ 原因: BUG-04 - Coordinator 调用旧 bizdevSvc.ExecuteStage
|
||||
├─ 代码位置: internal/coordinator/engine.go:672-811
|
||||
├─ 优先级: 🔴 P0 CRITICAL
|
||||
└─ 解决: Task A4(2h)
|
||||
└─ 改为调用 instSvc.AdvanceStage
|
||||
|
||||
症状 4: 阶段推进后发现文档被强制标记为"已批准",但用户未操作
|
||||
├─ 原因: BUG-02 - AdvanceStage SQL 中有强制更新批准状态的逻辑
|
||||
├─ 代码位置: internal/projectinstance/stage_store.go(需 grep 验证)
|
||||
├─ 优先级: 🔴 P0 CRITICAL
|
||||
└─ 解决: Task A2(1h)
|
||||
└─ 删除 UPDATE 批准状态的 SQL 行
|
||||
|
||||
症状 5: 测试在开发环境运行后,88 个奇怪的 pg_* 定义出现在工作流目录
|
||||
├─ 原因: BUG-08 - 集成测试未设置 TEST_DATABASE_URL,默认连接开发库
|
||||
├─ 代码位置: internal/projectinstance/pg_integration_test.go
|
||||
├─ 优先级: 🟠 P1 MEDIUM
|
||||
└─ 解决: Task BUG-08(2h)
|
||||
└─ 强制要求 TEST_DATABASE_URL;测试后清理
|
||||
|
||||
症状 6: 激活工作流时 token 可能在浏览器历史中看到,或被重复使用
|
||||
├─ 原因: BUG-06 - Token 明文保存,消费分事务,不是 one-time
|
||||
├─ 代码位置: internal/projectinstance/service.go(激活相关)
|
||||
├─ 优先级: 🟠 P1 HIGH
|
||||
└─ 解决: Task A6(3h)
|
||||
└─ Token → hash + one-time,同事务消费
|
||||
|
||||
症状 7: 产品经理流程不运行,测试工程师流程无法启动,提示"需先运行产品流程"
|
||||
├─ 原因: MISSING-06 - test_manager manifest 中仍有 upstream 依赖
|
||||
├─ 代码位置: internal/workflowdef/manifests/test_manager.v2.json
|
||||
├─ 优先级: 🟠 P1 HIGH
|
||||
└─ 解决: Task B3(需先删除测试流程的上游依赖)
|
||||
└─ 修改 manifest,删除 required_upstream_workflow
|
||||
|
||||
症状 8: 选择工作流时前端显示"商务流程",但服务返回的名称不一致
|
||||
├─ 原因: BUG-12 - 前端硬编码名称 vs 后端正式名称不同步
|
||||
├─ 代码位置: web/src/components/workflow/ProjectWorkflowSwitcher.tsx
|
||||
├─ 优先级: 🟢 P2 LOW
|
||||
└─ 解决: 由后端 GET /api/v1/workflows/catalog 驱动前端显示
|
||||
|
||||
症状 9: 无法手工删除已完成的工作流实例,项目永久处于"运行中"
|
||||
├─ 原因: MISSING-02 - 受控终止 API 未实现
|
||||
├─ 代码位置: (缺失)
|
||||
├─ 优先级: 🔴 P1 CRITICAL
|
||||
└─ 解决: Task B1(6h)
|
||||
└─ 实现 TerminateWorkflow API,二次确认 + 原因记录
|
||||
|
||||
症状 10: 产品经理和测试工程师输出中缺少"来源追溯",无法知道数据从哪来
|
||||
├─ 原因: MISSING-01 - 项目上下文快照服务未实现
|
||||
├─ 代码位置: (缺失)
|
||||
├─ 优先级: 🔴 P1 CRITICAL
|
||||
└─ 解决: Task B3(12h)
|
||||
└─ 实现 ProjectContextSnapshot 服务,包含血缘表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、代码热点地图
|
||||
|
||||
### 高风险区域(需特别关注的文件)
|
||||
|
||||
```
|
||||
🔴 CRITICAL
|
||||
├─ internal/projectinstance/stage_store.go
|
||||
│ ├─ BUG-01: AdvanceStage 不更新项目生命周期
|
||||
│ ├─ BUG-02: 推进强制批准文档(需验证)
|
||||
│ └─ 位置: 第 71-158 行
|
||||
│
|
||||
├─ internal/coordinator/engine.go
|
||||
│ ├─ BUG-04: 对话推进调用旧服务
|
||||
│ └─ 位置: 第 672-811 行(handleAdvanceStage)
|
||||
│
|
||||
├─ internal/projectinstance/service.go
|
||||
│ ├─ BUG-01: 完成后生命周期(同上)
|
||||
│ └─ 位置: 第 119-135 行(SaveWorkflowPlan)
|
||||
|
||||
🟠 HIGH
|
||||
├─ internal/projectinstance/stage_access.go
|
||||
│ ├─ BUG-05: 禁用文档拒绝逻辑不一致
|
||||
│ └─ 位置: 第 15-71, 90-104 行
|
||||
│
|
||||
├─ web/src/components/workflow/ProjectWorkflowSwitcher.tsx
|
||||
│ ├─ BUG-09: 版本冲突自动重试
|
||||
│ ├─ BUG-12: 硬编码名称
|
||||
│ └─ 位置: 第 16-23, 38-76, 173-236 行
|
||||
|
||||
🟡 MEDIUM
|
||||
├─ internal/projectinstance/pg_integration_test.go
|
||||
│ ├─ BUG-08: 未设置 TEST_DATABASE_URL
|
||||
│ └─ 位置: 第 19-49 行
|
||||
│
|
||||
├─ internal/workflowdef/manifests/test_manager.v2.json
|
||||
│ ├─ 依然有 upstream 依赖(需删除)
|
||||
│ └─ 位置: 第 1-23 行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、整改优先级排序
|
||||
|
||||
### 必须在本周内完成(优先级递减)
|
||||
|
||||
```
|
||||
□ P0-1: BUG-01 项目完成后生命周期 (2h)
|
||||
→ 项目永久锁死的根本原因
|
||||
|
||||
□ P0-2: BUG-04 对话推进新服务切换 (2h)
|
||||
→ 权限检查绕过
|
||||
|
||||
□ P0-3: BUG-02 推进不强制批准 (1h)
|
||||
→ 数据违反业务规则
|
||||
|
||||
□ P1-1: BUG-05 禁用文档一致性 (4h)
|
||||
→ 安全隐患
|
||||
|
||||
□ P1-2: BUG-06 激活 token 安全 (3h)
|
||||
→ 存在泄露/重放风险
|
||||
|
||||
□ P1-3: Task B1 受控终止 API (6h)
|
||||
→ 恢复能力必需
|
||||
|
||||
□ P1-4: Task B2 E2E 测试 (8h)
|
||||
→ 验收标准要求
|
||||
|
||||
可以延后(下周处理)
|
||||
|
||||
□ P2-1: BUG-03 禁用文档 skipped_disabled (3h)
|
||||
□ P2-2: BUG-08 测试隔离完善 (2h)
|
||||
□ P2-3: BUG-10 项目设置审计视图 (3h)
|
||||
□ P2-4: BUG-09 前端冲突自动重试 (1h)
|
||||
□ P2-5: BUG-11 旧 API 下线 (1h)
|
||||
□ P2-6: BUG-12 前端名称一致性 (1h)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、代码修改检查清单
|
||||
|
||||
### 提交前验收标准
|
||||
|
||||
**修复 BUG-01 时**:
|
||||
```
|
||||
[ ] 修改 stage_store.go 的 AdvanceStage 函数
|
||||
- 检查是否最后阶段
|
||||
- 若是,同事务更新 projects.lifecycle_status
|
||||
- 清空 current_workflow_instance_id
|
||||
- 写审计事件
|
||||
[ ] 新增测试: TestAdvanceStage_CompletesProjectAfterLastStage
|
||||
[ ] 新增测试: TestAdvanceStage_DoesNotChangeProjectOnNonLastStage
|
||||
[ ] 本地测试通过 (go test ./internal/projectinstance -v)
|
||||
[ ] 前端检查:/projects/{id}/settings 显示"配置下一工作流"
|
||||
```
|
||||
|
||||
**修复 BUG-04 时**:
|
||||
```
|
||||
[ ] 修改 coordinator/engine.go 的 handleAdvanceStage
|
||||
- 提取 workflow_instance_id
|
||||
- 改调 instSvc.AdvanceStage 替代 bizdevSvc.ExecuteStage
|
||||
- 调整返回格式
|
||||
[ ] 新增测试: TestDialogueAdvanceStage_UsesNewService
|
||||
[ ] 新增测试: TestDialogueAdvanceStage_PermissionCheck
|
||||
[ ] 本地测试通过 (go test ./internal/coordinator -v)
|
||||
[ ] 对话测试通过 (go test ./internal/server -run Dialogue -v)
|
||||
```
|
||||
|
||||
**创建 Task B2 E2E 框架时**:
|
||||
```
|
||||
[ ] 确保 TEST_DATABASE_URL 已设置
|
||||
[ ] 创建 setupE2ETestDB() 函数
|
||||
- 创建隔离 schema
|
||||
- 运行所有迁移
|
||||
- 清理逻辑
|
||||
[ ] 编写 TestE2E_BizdevWorkflow_Complete
|
||||
- 7 文档完整流程
|
||||
- 每个阶段验证门禁
|
||||
- 完成后验证项目状态
|
||||
[ ] 编写 TestE2E_ProductManagerWorkflow_Standalone
|
||||
- 无前置依赖
|
||||
- 独立运行成功
|
||||
[ ] 编写 TestE2E_TestEngineerWorkflow_WithoutSRS
|
||||
- 无 SRS 输入仍能运行
|
||||
- 输出包含来源清单
|
||||
[ ] 全部 E2E 测试通过
|
||||
[ ] 验收标准 7.5 完全满足
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、数据库迁移清单
|
||||
|
||||
### 需要新增的迁移脚本
|
||||
|
||||
```
|
||||
00025_2026-09-12-disabled-doc-status.sql
|
||||
├─ 添加 project_doc_task_instances.status_reason
|
||||
├─ 添加约束:disabled 时 duties 为空
|
||||
└─ 编写回滚脚本
|
||||
|
||||
00026_2026-09-12-workflow-termination.sql
|
||||
├─ 添加 project_workflow_instances.terminated_at/by/reason
|
||||
├─ 添加 termination_reason 列
|
||||
└─ 编写回滚脚本
|
||||
|
||||
00027_2026-09-12-context-snapshots.sql
|
||||
├─ 创建 project_context_snapshots 表
|
||||
├─ 创建 project_context_snapshot_sources 表
|
||||
└─ 编写回滚脚本
|
||||
|
||||
00028_2026-09-12-activation-challenges.sql
|
||||
├─ 创建 activation_challenges 表
|
||||
├─ 创建 one_time_tokens 表
|
||||
└─ 编写回滚脚本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、测试命令参考
|
||||
|
||||
```bash
|
||||
# 运行所有项目实例测试
|
||||
go test ./internal/projectinstance -v
|
||||
|
||||
# 运行所有协调器测试
|
||||
go test ./internal/coordinator -v
|
||||
|
||||
# 运行所有工作流定义测试
|
||||
go test ./internal/workflowdef -v
|
||||
|
||||
# 运行服务器集成测试
|
||||
go test ./internal/server -v
|
||||
|
||||
# 运行特定 bug 的测试
|
||||
go test ./internal/projectinstance -run "TestAdvanceStage" -v
|
||||
|
||||
# 运行 PG 集成测试(需 TEST_DATABASE_URL)
|
||||
TEST_DATABASE_URL=postgresql://... go test ./internal/server -run PG -v
|
||||
|
||||
# 运行 E2E 测试
|
||||
TEST_DATABASE_URL=postgresql://... go test ./internal/server -run E2E -v
|
||||
|
||||
# 代码覆盖率分析
|
||||
go test ./internal/projectinstance ./internal/coordinator ./internal/workflowdef \
|
||||
-coverprofile=coverage.out && go tool cover -html=coverage.out
|
||||
|
||||
# 静态检查
|
||||
go vet ./internal/...
|
||||
golangci-lint run ./internal/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、API 快速参考
|
||||
|
||||
### 关键端点变动汇总
|
||||
|
||||
```
|
||||
现有(需保留或改进):
|
||||
GET /api/v1/workflows/catalog
|
||||
→ 返回三项正式工作流 ✅
|
||||
|
||||
GET /api/v1/projects/{id}/settings
|
||||
→ 返回项目设置及 live instance 配置 ✅
|
||||
|
||||
PUT /api/v1/projects/{id}/workflow-plan
|
||||
→ 保存/修改规划 (planning 状态下) ✅
|
||||
|
||||
POST /api/v1/projects/{id}/workflow-instances/activate
|
||||
→ 激活工作流 (需修复 token) ⚠️
|
||||
|
||||
GET /api/v1/workflow-instances/{id}/stages/{stage}/readiness
|
||||
→ 阶段门禁详情 ✅
|
||||
|
||||
POST /api/v1/workflow-instances/{id}/stages/{stage}/advance
|
||||
→ 推进阶段 (需修复生命周期) ❌
|
||||
|
||||
待实现(高优先级):
|
||||
POST /api/v1/workflow-instances/{id}/termination-preview
|
||||
→ 终止预览(Task B1)
|
||||
|
||||
POST /api/v1/workflow-instances/{id}/terminate
|
||||
→ 执行终止(Task B1)
|
||||
|
||||
待删除或返回 410:
|
||||
POST /projects/{id}/active-workflow
|
||||
→ 旧切换 API(需下线)
|
||||
|
||||
需改进(权限一致性):
|
||||
POST /api/v1/doc-task-instances/{id}/runs
|
||||
→ 启动编制 (需 DocumentAccessDecider) ⚠️
|
||||
|
||||
POST /api/v1/documents/{id}/submit
|
||||
→ 提交审核 (需统一权限检查) ⚠️
|
||||
|
||||
POST /api/v1/documents/{id}/approve
|
||||
→ 批准 (需统一权限检查) ⚠️
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、常见问题速查
|
||||
|
||||
### Q1: 为什么完成工作流后项目仍然处于"active"?
|
||||
**A**: BUG-01 - `AdvanceStage` 完成最后阶段时未同事务更新项目 `lifecycle_status`。
|
||||
**修复**: Task A1(2h)
|
||||
**临时绕过**: 手动执行 SQL `UPDATE projects SET lifecycle_status='planning' WHERE id=?`
|
||||
|
||||
### Q2: 禁用的文档仍然能被某些用户操作?
|
||||
**A**: BUG-05 - 不同入口对禁用文档的检查不一致。
|
||||
**修复**: Task A5(4h) - 实现 `DocumentAccessDecider`
|
||||
**临时绕过**: 在关键入口前添加 `if doc.status == 'skipped_disabled' { return 409 }`
|
||||
|
||||
### Q3: 激活 token 可能被重复使用?
|
||||
**A**: BUG-06 - Token 明文保存,消费分事务。
|
||||
**修复**: Task A6(3h)
|
||||
**临时保护**: 限制 token 有效期为 5 分钟;记录消费日志;审计异常 token 使用
|
||||
|
||||
### Q4: 对话推进工作流不起作用?
|
||||
**A**: BUG-04 - Coordinator 调用旧商务服务而非实例服务。
|
||||
**修复**: Task A4(2h)
|
||||
**临时检查**: 查看 `internal/coordinator/engine.go:672-811` 是否仍调用 `bizdevSvc`
|
||||
|
||||
### Q5: 为什么 E2E 测试一直 fail?
|
||||
**A**: 可能多个原因:
|
||||
1. TEST_DATABASE_URL 未设置 → 连接开发库(88 个污染定义)
|
||||
2. E2E 框架缺失 → 需实现 setupE2ETestDB()
|
||||
3. 实例生命周期 bug → BUG-01 导致测试逻辑不对
|
||||
|
||||
**修复**: 检查 TEST_DATABASE_URL;运行 Task B2
|
||||
|
||||
### Q6: 生产数据库中有 47 个"已完成但项目仍为 active"的不一致数据?
|
||||
**A**: BUG-01 导致。数据本身可恢复。
|
||||
**恢复方案**:
|
||||
1. 查询 `SELECT * FROM project_workflow_instances WHERE status='completed'`
|
||||
2. 对每个 completed 实例:
|
||||
```sql
|
||||
UPDATE projects SET lifecycle_status='planning', current_workflow_instance_id=NULL
|
||||
WHERE id=(SELECT project_id FROM project_workflow_instances WHERE id=?)
|
||||
```
|
||||
3. 补写审计日志
|
||||
|
||||
---
|
||||
|
||||
## 十、参考文档导航
|
||||
|
||||
| 文档 | 用途 | 适合人员 |
|
||||
|---|---|---|
|
||||
| [remediation-gap-matrix.md](./2026-09-12-remediation-gap-matrix.md) | **当前缺口 SSOT** | 全体 |
|
||||
| [ops/CURRENT.md](./ops/CURRENT.md) | 协作焦点 | Agent / 开发 |
|
||||
| [comprehensive-audit-and-remediation-plan.md](./2026-09-12-comprehensive-audit-and-remediation-plan.md) | 详细缺陷分析与整改方案(历史) | 架构师、资深开发 |
|
||||
| [remediation-tasks-and-acceptance-checklist.md](./2026-09-12-remediation-tasks-and-acceptance-checklist.md) | 可执行的任务拆分与验收 | 开发、QA |
|
||||
| [audit-summary-report.md](./2026-09-12-audit-summary-report.md) | 审计总结与建议(历史) | 项目经理、技术负责人 |
|
||||
| **[本文件]** | 快速参考与索引 | 全体团队 |
|
||||
| [three-workflows-strict-governance-remediation.md](./2026-09-12-three-workflows-strict-governance-remediation.md) | 业务需求原始规划 | 产品、架构 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、快速评分表
|
||||
|
||||
### 修复每个 bug 后的自检清单
|
||||
|
||||
```
|
||||
BUG-01 ✓ 完成
|
||||
□ stage_store.go 修改
|
||||
□ 新增单元测试 ×2
|
||||
□ 本地 go test PASS
|
||||
□ 前端验证项目状态改变
|
||||
→ 接受标准: 完成最后阶段后项目 lifecycle='planning'
|
||||
|
||||
BUG-02 ✓ 完成
|
||||
□ grep 确认强制批准逻辑存在
|
||||
□ 删除 UPDATE 批准状态的 SQL
|
||||
□ 新增单元测试
|
||||
□ 本地 go test PASS
|
||||
→ 接受标准: 推进前后文档状态不变
|
||||
|
||||
BUG-04 ✓ 完成
|
||||
□ engine.go handleAdvanceStage 改为调用新服务
|
||||
□ 新增单元测试
|
||||
□ 本地 go test PASS
|
||||
□ 对话测试 PASS
|
||||
→ 接受标准: 对话推进使用 instSvc
|
||||
|
||||
BUG-05 ✓ 完成
|
||||
□ 实现 DocumentAccessDecider 接口
|
||||
□ 集成到 API、SSE、异步任务、Agent tools
|
||||
□ 新增综合测试
|
||||
□ 本地 go test PASS
|
||||
→ 接受标准: 禁用文档全拒绝,返回 409/403
|
||||
|
||||
BUG-06 ✓ 完成
|
||||
□ 数据库迁移 00028
|
||||
□ API 改为 challenge_id + one_time_token
|
||||
□ Token hash + 原子消费
|
||||
□ 新增安全测试 ×4
|
||||
□ 本地 go test PASS
|
||||
→ 接受标准: Token one-time、hash 存储、5 分钟过期
|
||||
|
||||
Task B1 ✓ 完成
|
||||
□ 新增 terminate.go 服务
|
||||
□ TerminationPreview API
|
||||
□ TerminateWorkflow API
|
||||
□ 新增端到端测试
|
||||
□ 本地 go test PASS
|
||||
→ 接受标准: 终止后项目可配置下一流程
|
||||
|
||||
Task B2 ✓ 完成
|
||||
□ setupE2ETestDB() 实现
|
||||
□ TestE2E_BizdevWorkflow_Complete PASS
|
||||
□ TestE2E_ProductManagerWorkflow_Standalone PASS
|
||||
□ TestE2E_TestEngineerWorkflow_WithoutSRS PASS
|
||||
□ TEST_DATABASE_URL 隔离库运行
|
||||
→ 接受标准: 三工作流完整链路验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0 快速参考
|
||||
**适用对象**: 项目全体
|
||||
**更新频率**: 同步更新主审计文档
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# 整改缺口复核矩阵(SSOT)
|
||||
|
||||
**更新**: 2026-09-13
|
||||
**分支**: dev
|
||||
**production_ready**: no(CI 真实 PI E2E / 前端人工抽检未完成;main 本地冒烟已通过)
|
||||
**协作焦点**: [.doc/ops/CURRENT.md](ops/CURRENT.md)
|
||||
**Agent 手册**: [AGENTS.md](../AGENTS.md) · [PROJECT.md](../PROJECT.md)
|
||||
|
||||
> Codex 2026-09-12 再审计项与本轮回环证据见下表「Codex 再审计」区段。
|
||||
|
||||
---
|
||||
|
||||
## Phase A/B/C 原审计项
|
||||
|
||||
| ID | 审计描述 | 状态 | 证据 | 计划动作 |
|
||||
|---|---|---|---|---|
|
||||
| BUG-01 | 完成后 lifecycle 未更新 | PASS | `stage_remediation_test.go`;`scripts/fix-completed-instance-lifecycle.sql`;dev 执行 UPDATE 47 | 已完成 |
|
||||
| BUG-02 | 推进强制批文档 | PASS | `AdvanceStage` 无 doc UPDATE | 已完成 |
|
||||
| BUG-03 | 无 skipped_disabled | PASS | memStore `skipped_disabled` | 已完成 |
|
||||
| BUG-04 | 对话走旧 bizdev | PASS | `engine.go` live 门控 | 已完成 |
|
||||
| BUG-05 | 拒绝逻辑分散 | PASS | `document_access.go` + `stage_access_helpers.go` | 已完成 |
|
||||
| BUG-06 | token 明文/重放 | PASS | `activation_token_test.go` | 已完成 |
|
||||
| BUG-07 | 完成后下一流程 | PASS | 同 BUG-01 | 已完成 |
|
||||
| BUG-08 | 测试库隔离 | PARTIAL | `db/testdb.go` Require/Skip | CI 设 `TEST_DATABASE_URL` |
|
||||
| BUG-09 | 版本冲突自动重试 | PASS | `StageAdvanceBanner` reload | 已完成 |
|
||||
| BUG-10 | 无审计视图 | PASS | settings + UI | 已完成 |
|
||||
| BUG-11 | 旧 API 未下线 | PARTIAL | 治理开启时 410 | 保持 + 文档标注 |
|
||||
| BUG-12 | 前端名称硬编码 | PASS | catalog API | 已完成 |
|
||||
| MISSING-01 | 上下文快照 | PASS | `ctxmgr.CollectProjectDialogue` | 已完成 |
|
||||
| MISSING-02 | 终止 | PASS | `GetTerminationPreview` | 已完成 |
|
||||
| MISSING-04 | E2E | PASS | 三正式工作流 PG+PI E2E | 已完成 |
|
||||
|
||||
---
|
||||
|
||||
## Codex 2026-09-12 再审计
|
||||
|
||||
| ID | 描述 | 状态 | 证据 |
|
||||
|---|---|---|---|
|
||||
| AUTH-TASK-RUN | `/tasks/run` 缺项目鉴权 | PASS | `handlers.go` RequireProjectAccess;`task_run_authz_pg_test.go` |
|
||||
| AUTH-TASK-SSE | `/tasks/run/stream` 鉴权在 Flush 前 | PASS | `sse.go`;同上测试 |
|
||||
| DB-USERS-SEQ | 种子 users_id_seq 冷启动冲突 | PASS | `00025_...-sync-seed-sequences.sql`;dev/test setval |
|
||||
| DATA-LIFECYCLE-DEV | completed 实例对应 active 项目 | PASS | dev dry-run 47 → fix SQL → 0 |
|
||||
| DATA-TEST-WF | test 污染 active 工作流定义 | PASS | `retire-nonofficial-workflow-definitions.sql`;test UPDATE 5 |
|
||||
| TEST-ADVISOR-BRIEF | 多意图 AdvisorBrief 丢失 | PASS | `action_planner.go` enrich;`action_planner_test.go` |
|
||||
| TEST-V05-ARTIFACTS | V0.5 断言 3 vs 5 | PASS | `types_test.go` / `full_link_e2e_test.go` → 5 |
|
||||
| TEST-CHAT-MEMORY | 语义记忆 sleep 竞态 | PASS | `session_test.go` deadline 轮询 |
|
||||
| TEST-EMBEDDING-GATE | 默认打真实 embedding | PASS | `kb/integration_test.go` Short + `RUN_KB_INTEGRATION=1` |
|
||||
| HEALTH-SCHEMA-CACHE | health schema 启动缓存 | PASS | `LiveSchemaVersion` + health 实时读 |
|
||||
| GO-VET-ASSET | asset handler resp before err | PASS | `asset_handlers_test.go` err-first |
|
||||
| ENV-TEST-STALE | test 旧镜像/schema 漂移 | PASS | rebuild app;health schema_version=25 |
|
||||
| ENV-DEV-STALE | dev 二进制/schema 漂移 | PASS | docker app 重启;schema_version=25 |
|
||||
| ENV-MAIN-MISSING | main 未部署验收 | PASS | docker 创建 `data/main`;compose 注入 ADMIN/KB 密码;health/ready schema 25 |
|
||||
| DOC-SSOT | 文档叙事漂移 | PASS | 本矩阵 + superseded 标记 + ops |
|
||||
| OPS-AGENTS | 多 Agent 协作规范 | PASS | `AGENTS.md` / `PROJECT.md` / `.doc/ops` |
|
||||
|
||||
---
|
||||
|
||||
## 发布门禁(test → main)
|
||||
|
||||
```
|
||||
☑ go vet ./... 全绿
|
||||
☑ go test -short ./... 全绿
|
||||
□ TEST_DATABASE_URL 下治理 PG 集成全绿(CI)
|
||||
□ 真实 PI Agent 三工作流治理 E2E 实录(本轮 stub/short 已绿,PI 待 CI token)
|
||||
☑ lifecycle fix SQL 已在 dev 执行
|
||||
☑ test/dev health schema_version=25
|
||||
☑ main 环境部署冒烟(本地 8000 health/ready schema 25)
|
||||
□ 前端:版本冲突刷新、终止 preview 人工抽检
|
||||
```
|
||||
|
||||
**结论**: P0 授权、冷启动序列、Go 门禁、三环境本地冒烟已闭环。**发布 blocker 剩余 CI 真实 PI E2E 与前端人工抽检**。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
||||
# 三工作流严格治理与现有系统融合整改方案
|
||||
|
||||
- 日期:2026-09-12
|
||||
- 状态:核心业务决策已确认 / 可进入实施拆分
|
||||
- 范围:工作流定义、项目实例、阶段门禁、文档三权、通知与对话推进、运行时授权、数据迁移和验收
|
||||
- 本文性质:当前代码与开发数据库的只读核验结论及整改设计;本轮未修改业务代码
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
系统面向项目创建者和项目成员时,只应存在以下 **3 个可选择工作流**:
|
||||
|
||||
1. **商务对接流程**:7 个文档,3 个阶段;
|
||||
2. **智能产品经理流程**:1 个输出文档,1 个阶段;
|
||||
3. **智能测试工程师流程**:1 个输出文档,1 个阶段。
|
||||
|
||||
商务对接流程的文档数量可以定案为 **7 个,不是 6 个**。前端模板工厂和测试固定断言了 7 个任务键,后端标准任务目录也包含同一组 7 个商务文档,历史专项测试报告同样把它们定义为标准全生命周期交付物。[^biz-seven-test][^biz-seven-backend][^biz-seven-report]
|
||||
|
||||
当前系统尚未满足这一目标,且不是简单改几个显示名称即可解决:
|
||||
|
||||
- 启动代码实际发布 5 个默认工作流,除目标三项外还包含“医疗器械研发”和“非医疗纯软件”;[^bootstrap-five]
|
||||
- 当前开发库已有 92 个工作流定义,其中大量为测试定义;三个目标工作流仍都是单阶段的 schema v1;
|
||||
- 仓库虽然有三个 schema v2 清单,但商务清单只有 5 个文档,漏了 `feasibility_dev` 和 `user_requirements`;[^main-v2-missing]
|
||||
- 三个 v2 清单仅被嵌入和登记,没有进入启动发布链;已有发布版又会让 seed 逻辑直接退出,因此现有库不会自动升级;[^manifest-loader][^seed-no-upgrade]
|
||||
- 旧运行入口会把多阶段版本压成“第一个阶段的第一个任务”,与项目实例运行时并存,形成双轨权威;[^catalog-flatten]
|
||||
- 配置页面允许勾选关闭文档,但后端校验禁止关闭所有 `required/blocking` 文档;当前清单又把所有文档都标成这两类,因此界面能力实际上不可用;[^plan-disable-conflict]
|
||||
- 智能测试工程师当前被 manifest、执行服务和前端提示硬绑定到产品经理/SRS,与已确认的两个智能工作流独立运行原则冲突;[^test-upstream][^test-hard-dependency]
|
||||
- 阶段推进虽然先检查批准状态,但事务内会把所有启用任务强制写成 `approved`,并且最后阶段完成后没有把项目恢复到可选择下一工作流的状态;[^advance-forces-approved][^project-lifecycle-gap]
|
||||
- 对话中的“推进阶段”仍调用旧商务成熟度执行器,并没有调用项目工作流实例的阶段推进服务;[^dialogue-old-advance]
|
||||
|
||||
因此本次整改的核心是:**以已激活项目工作流实例的冻结快照作为唯一运行时权威**。工作流目录、项目配置、文档三权、任务启动、审批、阶段门禁、通知和对话都必须围绕同一个实例标识运行,旧的直接切换和扁平执行入口退出生产路径。
|
||||
|
||||
## 2. 已确认的业务基线
|
||||
|
||||
### 2.1 唯一允许对项目展示和选择的工作流
|
||||
|
||||
| 稳定内部 key | 对外名称 | 阶段数 | 输出文档数 | 说明 |
|
||||
|---|---|---:|---:|---|
|
||||
| `main` | 商务对接流程 | 3 | 7 | 保留现有 key 以避免破坏历史数据,只改正式名称 |
|
||||
| `product_manager` | 智能产品经理流程 | 1 | 1 | 输出《系统需求规格书》 |
|
||||
| `test_manager` | 智能测试工程师流程 | 1 | 1 | 保留现有 key,只把“测试经理”统一更名为“测试工程师” |
|
||||
|
||||
“系统只需要三个流程”的含义应同时落实到三个层面:
|
||||
|
||||
- 项目设置 API 只返回这 3 个可选项;
|
||||
- 前端任何入口只展示这 3 个可选项;
|
||||
- 服务端只允许为这 3 个正式、已发布版本创建项目工作流实例。
|
||||
|
||||
后台可以保留历史定义和草稿以满足审计,但必须标记为 `retired` 或 `project_selectable=false`,不能继续出现在项目目录中。不能仅靠前端白名单隐藏,因为当前设置服务会枚举数据库里全部有发布版本的定义。[^settings-all-definitions]
|
||||
|
||||
### 2.2 商务对接流程:7 个文档,3 个阶段
|
||||
|
||||
沿用系统已经形成的 V0.1 / V0.5 / V1.0 业务阶段和模板归属,目标定义如下。当前模板工厂已按 `1 + 5 + 1` 归档这 7 个文档。[^template-stage-map]
|
||||
|
||||
| 阶段 | 顺序 | task key | 正式文档名 | 默认状态 | 阶段门禁 |
|
||||
|---|---:|---|---|---|---|
|
||||
| V0.1 需求摸底 | 10 | `intake` | 用户初始需求收集表 | 启用 | 启用时必须批准 |
|
||||
| V0.5 方案与可行性 | 20 | `research` | 调研报告 | 启用 | 启用时必须批准 |
|
||||
| V0.5 方案与可行性 | 21 | `feasibility_dev` | 设计开发可行性评估报告 | 启用 | 启用时必须批准 |
|
||||
| V0.5 方案与可行性 | 22 | `feasibility_reg` | 注册路径可行性评估报告 | 启用 | 启用时必须批准 |
|
||||
| V0.5 方案与可行性 | 23 | `user_requirements` | 用户需求表 | 启用 | 启用时必须批准 |
|
||||
| V0.5 方案与可行性 | 24 | `prelim_design` | 系统初步设计方案 | 启用 | 启用时必须批准 |
|
||||
| V1.0 商务报价 | 30 | `cdo_quote` | CDO 报价单 | 启用 | 启用时必须批准 |
|
||||
|
||||
当前后端商务阶段只执行 `intake`、`research`、`feasibility_reg`、`prelim_design`、`cdo_quote` 五项,正好缺少另外两项,必须同步补齐阶段执行映射和 executor 注册。[^biz-stage-five][^executor-missing]
|
||||
|
||||
### 2.3 智能产品经理流程
|
||||
|
||||
| 阶段 | task key | 唯一输出文档 | 默认状态 | 门禁 |
|
||||
|---|---|---|---|---|
|
||||
| 系统需求基线 | `system_reqs` | 系统需求规格书 | 启用 | 文档批准后,创建者确认完成工作流 |
|
||||
|
||||
### 2.4 智能测试工程师流程
|
||||
|
||||
| 阶段 | task key | 唯一输出文档 | 默认状态 | 门禁 |
|
||||
|---|---|---|---|---|
|
||||
| 系统验证策划 | `test_manager` | 系统验证方案及计划 | 启用 | 文档批准后,创建者确认完成工作流 |
|
||||
|
||||
**智能产品经理流程和智能测试工程师流程相互解耦。** 智能测试工程师流程不再要求项目曾运行智能产品经理流程,也不再要求必须存在该流程输出的《系统需求规格书》。它直接从当前项目可用于工作流的对话、文档、上传材料、项目事实和决策记录中提取需求及验证依据,独立生成《系统验证方案及计划》。
|
||||
|
||||
如果项目中恰好存在已批准的《系统需求规格书》,它只是项目输入集合中的一项高权威来源,不是启动前置条件,也不产生流程级耦合。当前 manifest、执行服务、血缘逻辑和前端提示仍硬编码了这一依赖,整改时必须一起删除。[^test-upstream][^test-hard-dependency]
|
||||
|
||||
### 2.5 两个智能工作流的统一项目上下文输入
|
||||
|
||||
智能产品经理和智能测试工程师使用同一个“项目上下文快照服务”,但采用不同的分析目标和输出模板:
|
||||
|
||||
| 输入类别 | 纳入内容 | 建议权威等级 |
|
||||
|---|---|---|
|
||||
| 项目事实 | 项目名称、客户、产品类型、预期用途、目标市场、结构化事实黑板 | 已确认事实 |
|
||||
| 项目决策 | 创建者确认事项、里程碑决策、已关闭的待确认项 | 已确认决策 |
|
||||
| 项目文档 | 当前有效版本的受控文档、项目共享文档、上传材料 | 按批准状态和来源分级 |
|
||||
| 项目对话 | 所有已进入项目共享知识边界的成员对话及其附件 | 原始沟通证据 |
|
||||
| 历史输出 | 商务、产品、测试或自由任务形成的项目交付物 | 按状态和版本分级 |
|
||||
| 知识依据 | 系统检索到的法规、标准和内部知识库命中 | 外部参考依据 |
|
||||
|
||||
这里的“所有”是指 **当前项目知识边界内全部有资格被工作流使用的内容**,不是把数据库中所有原文无差别拼接到一次模型调用中。建议遵循以下规则:
|
||||
|
||||
1. 只纳入当前项目,绝不跨项目召回;
|
||||
2. 私人草稿、个人私聊只有在明确“共享到项目”后才进入项目级工作流输入,避免流程执行扩大原有可见范围;
|
||||
3. 任务启动时生成不可变 `project_context_snapshot`,记录截止时间、来源 ID、版本、内容 hash、权限判定和引用位置;
|
||||
4. 全量来源先建立清单,再按任务目标检索相关片段;已确认事实和已批准文档必须进入固定核心上下文,普通对话按相关性分批读取;
|
||||
5. 冲突时不静默覆盖,优先级建议为:创建者确认事实/决策 > 当前已批准文档 > 用户上传原始材料 > 里程碑记录 > 普通项目对话 > 智能体历史总结;
|
||||
6. 输出必须带 source manifest。智能测试工程师需在唯一输出文档内部生成“需求/风险—测试项—来源”追溯表,不额外创建第二个受控输出文档;
|
||||
7. 任务运行后项目来源发生变化时,依据被引用来源的 hash 判断 `input_changed/stale`,而不是只监听《系统需求规格书》。未被本次输出引用的无关对话变化不应把文档误标为 stale。
|
||||
|
||||
现有 Squad 工作区已经支持 project context、materials、prior docs、knowledge hits 等输入槽位,也会建立带权威等级的 context 索引;产品经理还已有从项目事实和里程碑生成输入材料的局部实现。因此应把这些能力上移为通用项目上下文快照服务,而不是为测试流程继续维护单一 SRS 特例。[^context-input-foundation]
|
||||
|
||||
## 3. 不可违反的业务规则
|
||||
|
||||
### 3.1 选择、启动和切换
|
||||
|
||||
1. 只有项目创建者可以选择、配置、激活、推进或受控终止项目工作流。
|
||||
2. 一个项目同一时刻最多只能有一个 live 实例。
|
||||
3. 在 `active`、`awaiting_stage_confirmation` 或 `termination_requested` 状态下,不允许选择或切换工作流。
|
||||
4. “切换工作流”不是修改当前实例的 `workflow_key`,而是当前实例 `completed` 或 `terminated` 后创建一个新的 planning 实例。
|
||||
5. 激活时冻结工作流版本、阶段、文档开关、模板版本、执行器、质量门禁和三权责任人。运行中不可静默修改。
|
||||
6. 管理员不因系统身份自动替代项目创建者执行推进;如以后引入 break-glass,必须单独设计理由、再认证和审计,且不能代替文档签署人。
|
||||
|
||||
目标状态机:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> planning: 创建者选择并配置
|
||||
planning --> active: 预览后确认激活
|
||||
active --> awaiting_stage_confirmation: 当前阶段所有启用文档已批准
|
||||
awaiting_stage_confirmation --> active: 创建者确认进入下一阶段
|
||||
awaiting_stage_confirmation --> completed: 创建者确认完成最后阶段
|
||||
active --> termination_requested: 创建者申请终止
|
||||
termination_requested --> terminated: 二次确认并记录原因
|
||||
completed --> planning: 创建下一工作流实例
|
||||
terminated --> planning: 创建下一工作流实例
|
||||
```
|
||||
|
||||
数据库已经为一个 planning 实例和一个 live 实例设置了部分唯一索引,也预留了 `awaiting_stage_confirmation`、`terminated` 等状态,适合沿此模型完善,不必另造一套表。[^instance-schema]
|
||||
|
||||
### 3.2 文档启用与禁用
|
||||
|
||||
项目创建者可以在 planning 状态逐文档启用或禁用。严格语义如下:
|
||||
|
||||
| 文档状态 | 三权配置 | 可创建/启动编制 | 可提交审核 | 可审核 | 可批准 | 是否计入阶段门禁 |
|
||||
|---|---|---:|---:|---:|---:|---:|
|
||||
| enabled | 作者、审核人、批准人均必填且互斥 | 是,仅作者或明确执行人 | 是,仅作者 | 是,仅审核人 | 是,仅批准人 | 是 |
|
||||
| disabled | 必须为空 | 否 | 否 | 否 | 否 | 否 |
|
||||
|
||||
关键约束:
|
||||
|
||||
- `default_enabled` 只表示默认值,不表示不可关闭;
|
||||
- `blocking_when_enabled=true` 表示文档一旦启用,未批准就阻断阶段;
|
||||
- 新增明确字段 `creator_configurable=true/false`,不要继续混用现有 `required`、`blocking`、`default_enabled` 三个布尔值表达四种含义;
|
||||
- 禁用后实例状态写为 `skipped_disabled`,不创建 DocGov 文档、不生成三权行,也不允许通过 task key、对话、SSE、Agent tool 或旧 API 旁路启动;
|
||||
- 对禁用文档发起动作时统一返回 `409 DOC_TASK_DISABLED`;对启用文档但责任人不匹配返回 `403 DOC_DUTY_FORBIDDEN`;
|
||||
- 激活计划必须至少包含一个启用文档。这样一文档流程仍可在配置时关闭唯一文档,但关闭后不能激活一个没有产出的空工作流。
|
||||
|
||||
现有数据结构已经能表达 `enabled` 和三权,且持久化时会跳过禁用文档的职责写入;可复用这部分。问题在于定义校验把 required/blocking 文档一律视为不可关闭,需要先修正语义。[^plan-structure][^duty-write-enabled-only]
|
||||
|
||||
### 3.3 阶段完成和推进
|
||||
|
||||
阶段就绪的唯一业务判定应为:
|
||||
|
||||
```text
|
||||
stage_ready = 当前阶段处于 active
|
||||
AND 当前阶段至少有一个 enabled 文档
|
||||
AND 每个 enabled 文档的当前受控版本都为 approved
|
||||
AND 没有正在执行、待审核、待批准或 stale 的同任务实例
|
||||
```
|
||||
|
||||
推进动作必须满足:
|
||||
|
||||
- 服务端在同一数据库事务中锁定 workflow instance 和 stage instance;
|
||||
- 重新计算门禁,不能只信几秒前的 readiness 响应;
|
||||
- 校验操作者仍是项目创建者;
|
||||
- 校验 `expected_config_revision` 和幂等键;
|
||||
- 只改变阶段和实例状态,绝不把文档“补写”为 approved;
|
||||
- 进入下一启用阶段;若没有下一阶段,则把实例标为 completed,并把项目切回“可配置下一个工作流”的状态;
|
||||
- 同事务关闭对应 `stage_advance_request`,写审计和 outbox 事件。
|
||||
|
||||
当前 readiness 已经忽略禁用任务、检查启用必需文档是否批准,并限制创建者推进,这一骨架可以保留;但它按 `project_id + task_key` 汇总文档状态,仍需升级到 `doc_task_instance_id + document_version_id`,避免跨阶段或重复实例串线。[^readiness-current][^approval-key-too-wide]
|
||||
|
||||
### 3.4 主动提醒和对话推进
|
||||
|
||||
系统在 readiness 从 false 变为 true 时生成一次 `stage.ready_to_advance` 事件,并只通知项目创建者。已有 outbox、站内通知、去重键以及 24 小时再次提醒机制可以复用。[^notify-ready][^notify-reminder]
|
||||
|
||||
建议闭环:
|
||||
|
||||
1. 最后一份启用文档批准;
|
||||
2. 事务后事件处理器重新计算 readiness;
|
||||
3. 实例进入 `awaiting_stage_confirmation`;
|
||||
4. 创建者收到站内消息和可操作确认卡:阶段名、启用/已批准文档清单、下一阶段或“完成工作流”;
|
||||
5. 创建者点击按钮,或在项目对话中说“推进到下一阶段 / 确认推进”;
|
||||
6. 两种入口都调用同一个 `AdvanceStage` 应用服务;
|
||||
7. 成功后通知状态改为 acted,推进请求改为 confirmed;若条件在确认前失效,则返回最新阻断项并把旧请求标为 superseded。
|
||||
|
||||
当前前端已经每 15 秒拉取通知并显示“确认推进”按钮,但对话入口仍走旧的 `bizdev.ExecuteStage`,所以需要把自然语言意图解析与执行彻底分开:识别器只产生命令,实例服务才拥有推进权。[^stage-banner][^dialogue-old-advance]
|
||||
|
||||
## 4. 当前系统与目标的差距
|
||||
|
||||
### 4.1 定义和目录
|
||||
|
||||
| 编号 | 当前事实 | 风险 | 整改 |
|
||||
|---|---|---|---|
|
||||
| WF-CAT-01 | Bootstrap 发布 5 个工作流 | 项目会看到目标外流程 | 只发布三项;其余退役且不可选 |
|
||||
| WF-CAT-02 | 设置服务返回所有有发布版的定义 | 测试/历史流程进入项目 | 增加 `project_selectable`,查询端白名单 + 状态双重校验 |
|
||||
| WF-CAT-03 | 开发库有 92 个定义,88 个 key 为 `pg_*` 或 `test_*` | 测试污染业务目录 | 测试强制独立数据库/schema,fixture 必须清理 |
|
||||
| WF-DEF-01 | 三个正式 key 当前均为 schema v1 单阶段 | 实际运行不是目标结构 | 以新版本发布 schema v2/v3,禁止覆盖旧版本 |
|
||||
| WF-DEF-02 | `main.v2.json` 只有 5 个文档 | 商务流程缺 2 个输出 | 补齐 7 个并增加 executor、模板和质量门禁校验 |
|
||||
| WF-DEF-03 | v2 manifest 未进入 bootstrap | 仓库文件与运行数据库不一致 | 建立 manifest 迁移器,按 hash 创建并发布新版本 |
|
||||
| WF-DEF-04 | seed 发现任何 published 就跳过 | 永远停留旧版本 | 按内容 hash 和目标版本升级,写版本迁移审计 |
|
||||
| WF-DEF-05 | DBCatalog 压扁完整定义 | 多阶段配置不能驱动运行 | 删除生产 flatten 路径,运行只读取冻结实例 |
|
||||
| WF-DEF-06 | 测试流程声明 product_manager 上游,执行服务又硬查 approved SRS | 两个智能工作流不能独立运行 | 删除流程级依赖;改用统一项目上下文快照和多来源血缘 |
|
||||
|
||||
### 4.2 项目实例和生命周期
|
||||
|
||||
| 编号 | 当前事实 | 风险 | 整改 |
|
||||
|---|---|---|---|
|
||||
| WF-INS-01 | planning / active 和唯一 live 骨架已存在 | 可复用但未闭环 | 扩展 awaiting/completed/terminated 完整转换 |
|
||||
| WF-INS-02 | 完成最后阶段只更新实例 | 项目仍为 active,无法配置下一流程 | 同事务更新项目可选择状态并清空 current live 指针 |
|
||||
| WF-INS-03 | 无受控终止 API | 异常流程永久锁死或只能改库 | 增加申请、影响预览、创建者二次确认和终止原因 |
|
||||
| WF-INS-04 | 激活 token 明文保存,消费与激活分事务 | 失败后 token 已失效或可泄露 | 只存 hash,并在激活事务内锁定、消费 |
|
||||
| WF-INS-05 | 冻结子任务只写 key/enabled/status | executor、模板、质量门禁仍依赖可变定义 | 激活时把完整运行元数据写入实例表并以实例表为权威 |
|
||||
|
||||
数据库已添加 executor/template/quality gate 列,但当前实例写入 SQL 仍只插入 task key、enabled 和 status,证明冻结快照尚未真正成为执行权威。[^instance-runtime-metadata]
|
||||
|
||||
### 4.3 文档、三权和运行时授权
|
||||
|
||||
| 编号 | 当前事实 | 风险 | 整改 |
|
||||
|---|---|---|---|
|
||||
| WF-DOC-01 | 启用文档要求三权且互斥 | 基础规则正确 | 再校验三人均为 active 项目成员且账户有效 |
|
||||
| WF-DOC-02 | 禁用文档不会写三权 | 基础持久化正确 | 增加 `skipped_disabled` 和全入口拒绝 |
|
||||
| WF-DOC-03 | 访问过滤按 task key 判断 | 同 key 重复实例可能串权 | 所有动作使用 `doc_task_instance_id` |
|
||||
| WF-DOC-04 | 无 live 实例时访问过滤默认不限制 | 完成后旧任务入口可能恢复可用 | 正式项目按治理模式 fail closed;legacy 仅限显式迁移标记 |
|
||||
| WF-DOC-05 | 运行入口仍接受 task key 并查扁平目录 | 可能绕过阶段和冻结绑定 | 请求只传 doc task instance id,服务端解析 executor/template/duty |
|
||||
| WF-DOC-06 | DocGov 状态机可验证具体审核/批准人 | 可复用 | 文档必须外键绑定 doc task instance 和版本 |
|
||||
|
||||
现有阶段访问代码确实会从 live 快照中排除禁用任务,这是可保留的基础;但当没有 live 实例或实例 completed 时会返回 unrestricted,因此不能把它视为禁用文档全生命周期的完整保障。[^stage-access]
|
||||
|
||||
### 4.4 前端、通知和对话
|
||||
|
||||
| 编号 | 当前事实 | 风险 | 整改 |
|
||||
|---|---|---|---|
|
||||
| WF-UI-01 | 旧切换器硬编码三项,但名称不一致 | 用户认知混乱 | 统一为三个正式名称,数据来自服务端目录 |
|
||||
| WF-UI-02 | 新治理 API 失败时回退旧直接切换 UI | 可重新打开不合规路径 | 删除回退;治理不可用时只读并明确报错 |
|
||||
| WF-UI-03 | 旧切换在版本冲突后自动重试 | 可能覆盖用户最新选择 | 展示冲突并要求重新确认,不自动提交高风险操作 |
|
||||
| WF-UI-04 | 项目设置只显示 stage/task key | 不利于业务确认 | 显示正式阶段名、文档名、启用状态、三权和影响摘要 |
|
||||
| WF-UI-05 | live 配置缺少完整只读审计视图 | 创建者不清楚冻结内容 | 展示版本、hash、激活人/时间、所有 enabled/disabled 和职责 |
|
||||
| WF-NOTIFY-01 | readiness 通知和 24h 提醒已存在 | 基础可复用 | 增加状态转换、已读/已处理、失效和重算 |
|
||||
| WF-DLG-01 | 对话推进旧商务阶段 | 可越过新实例门禁 | 对话调用同一实例 AdvanceStage command |
|
||||
| WF-CTX-01 | 产品经理仅在无材料时拼装部分项目事实;测试工程师只注入 SRS | “全部项目内容”没有统一、可复现的输入语义 | 建立项目上下文快照、来源清单、权限过滤、冲突优先级和 hash 血缘 |
|
||||
|
||||
旧前端切换器在治理不可用时仍调用 `/active-workflow`,并在冲突后自动取新版本再次切换;这两个行为都应删除。[^legacy-switch-ui] 旧服务端路由在迁移完成后应返回 `410 WORKFLOW_SWITCH_API_RETIRED`,而不是继续作为备用入口。[^legacy-route]
|
||||
|
||||
### 4.5 开发数据库现场快照
|
||||
|
||||
2026-09-12 对当前 `shuimu-context-pg-dev / agent_system_dev` 做只读查询得到:
|
||||
|
||||
```text
|
||||
workflow_definitions = 92
|
||||
published workflow versions = 92
|
||||
main = version 1, schema 1, 1 stage
|
||||
product_manager = version 1, schema 1, 1 stage
|
||||
test_manager = version 1, schema 1, 1 stage
|
||||
project_workflow_instances: active 32, completed 47
|
||||
projects.lifecycle_status: active 97, planning 51
|
||||
completed 实例已存在、无 live 实例但项目仍为 active:47 个
|
||||
测试命名定义(pg_* / test_*):88 个
|
||||
```
|
||||
|
||||
这些数字主要反映当前开发环境和集成测试污染,不能直接当成生产数据;但它们证明目录查询没有隔离正式定义与测试定义,也直接复现了“实例已完成而项目仍 active”的生命周期断点。PG 测试在未设置 `TEST_DATABASE_URL` 时默认连接开发库,部分定义发布测试又没有清理工作流定义,因此必须纳入整改。[^test-db-default][^test-definition-no-cleanup]
|
||||
|
||||
## 5. 目标技术模型
|
||||
|
||||
### 5.1 单一运行权威
|
||||
|
||||
```text
|
||||
Published Workflow Version
|
||||
│ 创建 planning plan
|
||||
▼
|
||||
Project Workflow Instance + immutable config snapshot
|
||||
│ 激活时物化
|
||||
├── Stage Instances
|
||||
│ └── Doc Task Instances
|
||||
│ ├── frozen executor/template/quality gate
|
||||
│ ├── duty assignments
|
||||
│ ├── controlled document versions
|
||||
│ └── task runs
|
||||
├── Stage Advance Requests / Notifications
|
||||
└── Audit / Outbox Events
|
||||
```
|
||||
|
||||
激活后,任何运行请求不得再从“最新发布定义”、前端 task key 或 legacy catalog 推导行为。最新定义只用于创建未来实例;已运行实例永远引用自己的冻结数据。
|
||||
|
||||
### 5.2 定义 schema 建议
|
||||
|
||||
文档任务应至少明确以下字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "research",
|
||||
"title": "调研报告",
|
||||
"default_enabled": true,
|
||||
"creator_configurable": true,
|
||||
"blocking_when_enabled": true,
|
||||
"executor_key": "bizdev.research.v1",
|
||||
"quality_gate_key": "quality.generic.v1",
|
||||
"template": {"asset_key": "research", "version": 1},
|
||||
"output_doc_type": "research",
|
||||
"output_doc_title": "调研报告"
|
||||
}
|
||||
```
|
||||
|
||||
发布校验必须增加:
|
||||
|
||||
- workflow key 属于三个正式 key 之一,且对外名称准确;
|
||||
- 阶段 key、position、任务 key 均唯一且非空;
|
||||
- 每个阶段至少一个文档任务;
|
||||
- executor、quality gate 和 ready 模板版本全部存在;
|
||||
- 三个正式流程精确满足 7 / 1 / 1 文档清单;
|
||||
- 三个正式工作流均不声明流程级 `required_upstream_workflow`;
|
||||
- 项目上下文输入策略、来源权限和冲突优先级完整;
|
||||
- 禁用策略、完成策略和参与策略完整;
|
||||
- 发布后版本不可原地修改。
|
||||
|
||||
当前 v2 校验只检查 schema 版本、阶段位置、executor/quality gate 注册和输出类型,尚不足以守住上述业务契约。[^validator-v2]
|
||||
|
||||
### 5.3 数据结构增量
|
||||
|
||||
优先扩展现有表,不重复建模:
|
||||
|
||||
- `workflow_definitions`:增加 `project_selectable`、`catalog_order`、`official_code`;
|
||||
- `workflow_versions.definition_json`:采用新禁用语义并保留版本 hash;
|
||||
- `project_workflow_instances`:增加 `completed_at/by`、`terminated_at/by/reason`、`previous_instance_id`;
|
||||
- `project_stage_instances`:增加 `ready_at`、`completed_at/by`;
|
||||
- `project_doc_task_instances`:持久化 `executor_key`、`quality_gate_key`、模板 asset/version、output type/title;禁用任务状态为 `skipped_disabled`;
|
||||
- `documents`:增加 `doc_task_instance_id`,受控文档版本唯一绑定;
|
||||
- `project_context_snapshots`:记录项目、任务、截止时间、汇总 hash、创建人和冻结状态;
|
||||
- `project_context_snapshot_sources`:逐项记录 source type/id/version/hash、权限范围、权威等级、是否实际引用;
|
||||
- `task_runs`:增加 `workflow_instance_id`、`stage_instance_id`、`doc_task_instance_id`、`context_snapshot_id`;
|
||||
- `document_lineages`:从单一“上游 SRS”扩展为多来源 lineage,允许对话、材料、事实、决策和文档版本作为来源;
|
||||
- `stage_advance_requests`:保留 readiness revision,补 `idempotency_key`、`superseded_at`;
|
||||
- 审计事件:至少记录 plan saved、activated、document disabled/enabled、duty assigned、stage ready、stage advanced、workflow completed/terminated。
|
||||
|
||||
### 5.4 API 收敛
|
||||
|
||||
建议正式接口:
|
||||
|
||||
| 方法与路径 | 用途 | 关键约束 |
|
||||
|---|---|---|
|
||||
| `GET /api/v1/workflows/catalog` | 返回严格三项正式目录 | 只含 project_selectable + published |
|
||||
| `GET /api/v1/projects/{id}/settings` | 规划或冻结配置视图 | 项目访问控制 |
|
||||
| `PUT /api/v1/projects/{id}/workflow-plan` | 选择流程、开关文档、设置三权 | 仅创建者、仅 planning、乐观锁 |
|
||||
| `POST /api/v1/projects/{id}/activation-preview` | 完整影响与校验 | 无空流程、三权和上下文策略通过 |
|
||||
| `POST /api/v1/projects/{id}/workflow-instances/activate` | 冻结并激活 | 创建者二次确认、原子 token |
|
||||
| `GET /api/v1/workflow-instances/{id}` | 运行和历史详情 | 项目 + 阶段可见性 |
|
||||
| `GET /api/v1/workflow-instances/{id}/stages/{key}/readiness` | 门禁详情 | 返回所有启用文档状态 |
|
||||
| `POST /api/v1/workflow-instances/{id}/stages/{key}/advance` | 创建者推进或完成 | 事务内重算、幂等 |
|
||||
| `POST /api/v1/workflow-instances/{id}/termination-preview` | 终止影响 | 未决任务、文档和通知清单 |
|
||||
| `POST /api/v1/workflow-instances/{id}/terminate` | 受控终止 | 创建者、原因、二次确认 |
|
||||
| `POST /api/v1/doc-task-instances/{id}/runs` | 启动编制 | enabled + active stage + duty |
|
||||
|
||||
旧 `/projects/{id}/active-workflow` 和依赖扁平 workflow key 的 run 接口先记录迁移告警,完成客户端切换后返回 410。
|
||||
|
||||
## 6. 实施顺序
|
||||
|
||||
### Phase 0:冻结错误扩散和数据盘点
|
||||
|
||||
1. 给项目目录增加服务端正式白名单,立即只暴露三个目标 key。
|
||||
2. 禁止测试默认连接开发库;CI 必须显式提供隔离的 `TEST_DATABASE_URL`。
|
||||
3. 备份并分类 92 个定义及其引用:正式、历史、测试、被实例引用。
|
||||
4. 未被任何实例引用的测试定义在开发环境清理;被引用定义只退役,不物理删除。
|
||||
5. 修复开发环境中 completed instance 与 project lifecycle 不一致的数据,但先输出 dry-run 清单。
|
||||
|
||||
### Phase 1:发布三个唯一正式定义
|
||||
|
||||
1. 修订三个 manifest 的正式名称与 schema;商务流程补齐 7 个文档。
|
||||
2. 为 `feasibility_dev` 和 `user_requirements` 注册专用 executor,或明确映射到通用任务 executor;禁止使用含混的法规 executor 代替。
|
||||
3. 从 `test_manager` manifest、默认目录和校验模型中删除 `required_upstream_workflow=product_manager`,使三个流程在流程级完全独立。
|
||||
4. 增加严格发布校验和 7 / 1 / 1 契约测试。
|
||||
5. 实现 hash 驱动的版本升级:已有 v1 时创建 v2/v3,而不是退出或覆盖。
|
||||
6. `medical_device_development`、`non_medical_software` 及其他定义全部 `project_selectable=false` 并退役。
|
||||
|
||||
### Phase 2:补齐实例状态机和禁用语义
|
||||
|
||||
1. 重构 plan validator:creator-configurable 文档可关闭;启用文档三权必填;空工作流不可激活。
|
||||
2. 校验三权人员为有效项目成员、账户有效且三权互斥。
|
||||
3. 激活时完整物化运行元数据,以实例为唯一权威。
|
||||
4. 阶段推进事务内重算门禁,删除“推进时强制批准文档”的 SQL。
|
||||
5. 完成最后阶段时同步更新项目状态,使其可创建下一 planning 实例。
|
||||
6. 实现受控终止,保证 live 实例不会永久锁住项目。
|
||||
|
||||
### Phase 3:运行、文档治理和权限合流
|
||||
|
||||
1. 建立通用项目上下文快照服务,覆盖项目事实、项目级对话、文档、材料、决策、历史输出和知识命中。
|
||||
2. 产品经理和测试工程师分别从同一快照服务取数;删除测试任务中“查找已批准 system_reqs,否则拒绝”的特例。
|
||||
3. 所有任务启动改用 `doc_task_instance_id`;服务端解析 task/executor/template/stage/duty/context snapshot。
|
||||
4. 文档创建、查看、编辑、提交、审核、批准均校验同一 doc task instance。
|
||||
5. disabled、非当前阶段、非责任人、实例非 live 均 fail closed。
|
||||
6. SSE、异步 task run、恢复/取消、RAG 检索、Agent tools 使用同一策略决策。
|
||||
7. 审批回调按具体文档版本更新具体 doc task instance,不再按项目 + task key 批量更新。
|
||||
8. 把 SRS 单来源 lineage 改成 source manifest 多来源血缘;只在实际引用来源变化时标记输出待复核。
|
||||
|
||||
### Phase 4:通知、对话和前端闭环
|
||||
|
||||
1. readiness 边沿触发状态和通知,补 read/acted/dismissed API。
|
||||
2. 把对话“推进/完成工作流”绑定到项目实例 command;保留明确确认卡。
|
||||
3. 项目设置只显示三项,按正式名称显示阶段和文档。
|
||||
4. active/awaiting 时显示冻结只读视图,隐藏所有切换动作。
|
||||
5. completed/terminated 后显示“配置下一个工作流”,而不是修改旧实例。
|
||||
6. 删除旧切换回退、冲突自动重试和 `WorkflowPrerequisiteBanner` 的产品经理前置提示。
|
||||
7. 在运行预览中展示项目上下文截止时间、来源数量、被排除的私有/无权内容和冲突项。
|
||||
|
||||
### Phase 5:迁移和发布
|
||||
|
||||
1. 新项目只允许使用新的三个正式版本。
|
||||
2. 尚未激活的 planning v1 计划自动重建为新版本草稿,并要求创建者重新确认三权。
|
||||
3. 已激活 v1 实例不静默改写:真实业务实例由创建者选择继续兼容完成或受控终止后重建;开发/测试垃圾实例按确认后的清理脚本处理。
|
||||
4. 先 shadow 计算新旧 readiness 差异,再开启新推进服务。
|
||||
5. 通过全套验收后关闭 legacy route 和 flatten catalog。
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
### 7.1 目录和定义
|
||||
|
||||
- 任意项目、任意普通用户或管理员在项目选择页都只能看到 3 个工作流。
|
||||
- 正式名称精确为“商务对接流程 / 智能产品经理流程 / 智能测试工程师流程”。
|
||||
- 定义快照精确为 7 / 1 / 1;缺一、多一、重复 key、模板未 ready 或 executor 未注册均无法发布。
|
||||
- 三个正式定义均没有流程级上游依赖;产品经理和测试工程师可被分别独立选择和运行。
|
||||
- 服务重启、数据库迁移和旧数据存在时,目录结果仍稳定为三项。
|
||||
|
||||
### 7.2 选择和冻结
|
||||
|
||||
- 只有项目创建者可保存、激活和推进;普通成员、系统管理员默认均被拒绝。
|
||||
- live 实例存在时,UI、API 和对话三个入口均不能切换。
|
||||
- 工作流完成或受控终止后,创建者可以配置下一实例;旧实例保持只读可追溯。
|
||||
- 两个浏览器同时配置或推进时只有一个成功,另一个收到版本冲突并重新确认。
|
||||
|
||||
### 7.3 文档开关和三权
|
||||
|
||||
- 启用文档缺任一职责、职责重复、责任人非 active 项目成员时不能激活。
|
||||
- 禁用文档没有职责行、没有受控文档、没有运行按钮,也不能通过手工 API、SSE 或对话启动。
|
||||
- 对禁用文档的编制、提交、审核、批准请求全部稳定返回 `DOC_TASK_DISABLED`。
|
||||
- 再启用只允许发生在 planning;激活后不能原地改变。
|
||||
|
||||
### 7.4 阶段门禁和提醒
|
||||
|
||||
- 当前阶段 N 个启用文档中任一未批准,创建者按钮和对话推进均被阻断并返回清单。
|
||||
- 全部批准后只产生一次首提醒;24 小时未处理才产生一次升级提醒。
|
||||
- readiness 后文档被驳回或标 stale 时,旧推进请求失效,确认动作不能成功。
|
||||
- 推进事务不会修改任何文档审批状态。
|
||||
- 最后一阶段确认后实例为 completed,项目立即可配置下一流程。
|
||||
|
||||
### 7.5 安全、恢复和真实链路
|
||||
|
||||
- 跨项目、非成员、非职责人、未来阶段成员、已退出成员全部有服务端负例。
|
||||
- API、SSE、异步任务、断线恢复、搜索、导出、RAG 和 Agent tool 的判定一致。
|
||||
- 服务重启后能恢复 live 实例、当前阶段、禁用状态、职责和 pending 推进请求。
|
||||
- 使用真实 PostgreSQL 和真实 PI Agent 分别跑完商务 7 文档、产品 1 文档、测试 1 文档链路。
|
||||
- 项目从未运行产品经理流程且不存在 SRS 时,测试工程师仍可依据项目对话、文档和材料成功生成唯一测试方案;输出包含逐项来源追溯和待确认清单。
|
||||
- 项目已有 SRS 时,该文档被当作高权威来源纳入,但移除它不会导致工作流定义级依赖错误;是否需要重新生成由本次 source manifest 判断。
|
||||
- 项目私聊/个人草稿未共享时不进入上下文;共享后可被新快照发现。跨项目内容在检索和快照层都有负例。
|
||||
- 同一组项目输入分别启动产品经理和测试工程师时,两者快照可复现、来源清单一致,输出目标和模板不同。
|
||||
- 测试结束后业务数据库中不残留 `pg_*`、`test_*` 定义或测试项目。
|
||||
|
||||
## 8. 已确认的业务决策及实施解释
|
||||
|
||||
2026-09-12 已确认以下决策,后续实现和验收以此为准:
|
||||
|
||||
1. **允许关闭单文档流程的唯一文档,但空工作流不能激活。** 配置可以保存,激活预览必须明确阻断并提示重新启用文档或选择其他工作流。
|
||||
2. **智能产品经理和智能测试工程师解耦。** 两者都直接使用当前项目知识边界内的对话、文档、材料、事实和决策。任何一方的产出都不是另一方的启动前置;已有 SRS 仅作为高权威输入来源。
|
||||
3. **开放项目创建者受控终止工作流。** 必须二次确认、填写原因、处理或取消在途任务并形成审计;completed 和 terminated 都允许创建下一工作流实例,但历史含义必须区分。
|
||||
|
||||
这些决策合理且彼此一致。第 2 项的实施建议是“流程解耦、数据可组合、来源可追溯”,而不是取消质量控制:测试工程师必须自己从项目上下文抽取需求和风险,生成文档内追溯矩阵,并将缺少判据的内容列为待确认,不能因为没有 SRS 就编造需求。
|
||||
|
||||
商务流程按 7 个文档落地、系统只暴露三个正式流程、运行中不可切换、阶段推进必须由创建者确认、禁用文档不得参与任何编审批动作,也均为已确认要求。
|
||||
|
||||
## 9. 建议的实施交付拆分
|
||||
|
||||
建议按以下 8 个可独立评审的变更集提交,避免一次大改难以回滚:
|
||||
|
||||
1. 正式目录隔离与测试数据库隔离;
|
||||
2. 三个正式 manifest、严格 validator 和版本升级器;
|
||||
3. planning 配置、文档开关和成员/三权校验;
|
||||
4. 激活冻结与完整实例物化;
|
||||
5. doc task instance 驱动的执行、DocGov 和统一授权;
|
||||
6. 原子 readiness / advance / complete / terminate 状态机;
|
||||
7. 通知、对话命令与前端只读/下一流程体验;
|
||||
8. 数据迁移、兼容退出、真实 PG/PI Agent E2E 和发布门禁。
|
||||
|
||||
每个变更集都应包含正向测试、越权负例、并发负例和重启恢复验证。只有第 8 项完成后,才应删除旧兼容路径并宣布三工作流治理正式生效。
|
||||
|
||||
## 10. 来源与证据
|
||||
|
||||
[^biz-seven-test]: `web/src/engine/templateFactory.test.ts:8-33`,测试精确断言 7 个任务键;读取于 2026-09-12。
|
||||
[^biz-seven-backend]: `internal/squad/service.go:128-138`,后端标准任务目录包含 7 个商务任务及产品、测试任务;读取于 2026-09-12。
|
||||
[^biz-seven-report]: `.doc/test-reports/2026-09-08-project-init-template-cloning-test-report.md:12-25,51-60`,历史专项测试把商务全生命周期固定为 7 个受控交付物;读取于 2026-09-12。
|
||||
[^bootstrap-five]: `internal/workflowdef/bootstrap.go:63-74,78-115`,启动种子包含三个旧流程及两个额外行业流程;读取于 2026-09-12。
|
||||
[^main-v2-missing]: `internal/workflowdef/manifests/main.v2.json:1-50`,清单只定义 intake、research、feasibility_reg、prelim_design、cdo_quote;读取于 2026-09-12。
|
||||
[^manifest-loader]: `internal/workflowdef/manifest_loader.go:9-32` 仅提供嵌入读取和文件名映射;仓库检索未发现 `V2ManifestFiles` 的运行时调用;读取于 2026-09-12。
|
||||
[^seed-no-upgrade]: `internal/workflowdef/store.go:364-372`,已有任意 latest published version 时直接返回;读取于 2026-09-12。
|
||||
[^catalog-flatten]: `internal/workflowdef/catalog.go:27-41,50-82`,数据库目录将版本压为首阶段首任务;读取于 2026-09-12。
|
||||
[^plan-disable-conflict]: `internal/projectinstance/validator.go:37-90,105-139`,required 或 blocking 任务关闭时校验失败;读取于 2026-09-12。
|
||||
[^advance-forces-approved]: `internal/projectinstance/stage_store.go:71-158`,推进事务把启用任务状态强制更新为 approved;读取于 2026-09-12。
|
||||
[^project-lifecycle-gap]: `internal/projectinstance/stage_store.go:128-158` 只完成实例,`internal/projectinstance/service.go:119-135` 又只允许 planning 项目保存下一计划;读取于 2026-09-12。
|
||||
[^dialogue-old-advance]: `internal/coordinator/engine.go:672-811`,对话推进调用 `bizdevSvc.ExecuteStage` 并直接改变旧成熟度上下文;读取于 2026-09-12。
|
||||
[^settings-all-definitions]: `internal/projectinstance/service.go:94-113`,项目设置枚举全部有 latest published version 的定义;读取于 2026-09-12。
|
||||
[^template-stage-map]: `web/src/engine/templateFactory.ts:37-39,87-89,158-160,228-230,286-288,331-333,392-394`,7 个商务模板当前归属为 V0.1 一项、V0.5 五项、V1.0 一项;读取于 2026-09-12。
|
||||
[^biz-stage-five]: `internal/bizdev/service.go:204-223`,旧商务阶段仅映射 1 + 3 + 1 个任务;读取于 2026-09-12。
|
||||
[^executor-missing]: `internal/workflow/registry.go:5-15`,商务 executor 缺少 feasibility_dev 和 user_requirements;读取于 2026-09-12。
|
||||
[^test-upstream]: `internal/workflowdef/manifests/test_manager.v2.json:1-23`,测试流程声明 product_manager 前置依赖;读取于 2026-09-12。
|
||||
[^test-hard-dependency]: `internal/bizdev/service.go:349-380,673-687`,测试任务硬查同项目 approved SRS、自动注入并记录单一 SRS 血缘;读取于 2026-09-12。
|
||||
[^context-input-foundation]: `internal/squad/service.go:35-55`、`internal/squad/workspace.go:262-408` 和 `internal/bizdev/service.go:250-294`,现有运行参数及工作区已支持项目、材料、前序文档、知识和部分项目事实输入;读取于 2026-09-12。
|
||||
[^instance-schema]: `deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql:13-52`,实例状态和 one planning/one live 唯一索引;读取于 2026-09-12。
|
||||
[^plan-structure]: `internal/projectinstance/types.go:21-48`,计划已包含文档 enabled 和三权;读取于 2026-09-12。
|
||||
[^duty-write-enabled-only]: `internal/projectinstance/store.go:244-280`,禁用文档不会写职责行;读取于 2026-09-12。
|
||||
[^readiness-current]: `internal/projectinstance/gate.go:5-53` 和 `internal/projectinstance/stage_service.go:15-63`,现有门禁忽略禁用任务并限制创建者;读取于 2026-09-12。
|
||||
[^approval-key-too-wide]: `internal/projectinstance/stage_store.go:176-191`,批准同步按 project_id + task_key 更新;读取于 2026-09-12。
|
||||
[^notify-ready]: `internal/server/workflow_approval_hook.go:17-44` 和 `internal/notification/service.go:38-107`,批准回调、推进请求、outbox 与创建者通知;读取于 2026-09-12。
|
||||
[^notify-reminder]: `internal/notification/reminder.go:35-91,94-171`,24 小时提醒和 confirmed 状态;读取于 2026-09-12。
|
||||
[^stage-banner]: `web/src/components/workflow/StageAdvanceBanner.tsx:29-95,111-151`,前端轮询 readiness/通知并提供确认按钮;读取于 2026-09-12。
|
||||
[^instance-runtime-metadata]: `deploy/postgres/goose/00023_2026-09-12-instance-integrity.sql:8-11` 已添加运行元数据列,而 `internal/projectinstance/store.go:254-259` 插入时未写这些列;读取于 2026-09-12。
|
||||
[^stage-access]: `internal/projectinstance/stage_access.go:15-71,90-104`,live 实例中排除禁用任务,但无 live/completed 时不限制;读取于 2026-09-12。
|
||||
[^legacy-switch-ui]: `web/src/components/workflow/ProjectWorkflowSwitcher.tsx:16-23,38-76,173-236`,旧名称、旧直切换和冲突自动重试仍存在;读取于 2026-09-12。
|
||||
[^legacy-route]: `internal/server/server.go:157-169`,新项目实例 API 与旧 active-workflow/run API 同时暴露;读取于 2026-09-12。
|
||||
[^validator-v2]: `internal/workflowdef/validator_v2.go:9-47`,现有 v2 校验范围有限;读取于 2026-09-12。
|
||||
[^test-db-default]: `internal/projectinstance/pg_integration_test.go:19-49` 和 `internal/server/workflow_governance_pg_test.go:26-61`,未设置 TEST_DATABASE_URL 时连接本地开发库;读取于 2026-09-12。
|
||||
[^test-definition-no-cleanup]: `internal/workflowdef/publish_test.go:37-72`,发布测试创建带时间戳定义但未注册清理;读取于 2026-09-12。
|
||||
|
||||
开发数据库数字来源:2026-09-12 在本机容器内对 `workflow_definitions`、`workflow_versions`、`project_workflow_instances` 和 `projects` 执行只读聚合查询。该快照仅用于诊断当前开发环境,不代表生产环境。
|
||||
@@ -0,0 +1,21 @@
|
||||
# ADR:工作流运行时唯一权威链
|
||||
|
||||
## 状态
|
||||
|
||||
已接受(Phase 1 入口封堵;Phase 3 完整编排器)
|
||||
|
||||
## 背景
|
||||
|
||||
配置(published version + 模板)与运行时(BizDev 硬编码阶段、flatten catalog、客户端 task_key/三权覆盖)存在双权威,导致已发布多阶段定义无法按设计执行。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **禁止新增 flatten 旁路**;`DBCatalog.flattenVersion` 仅用于 legacy 列表,不得作为 task run 执行依据。
|
||||
2. **Task run 输入**:`doc_task_instance_id` + 业务 payload;拒绝客户端 `task_key`/三权/stage/work_dir 作为授权依据。
|
||||
3. **DocGov**:stage 与 author/reviewer/approver 来自冻结实例 duty assignment。
|
||||
4. **失败策略**:授权/实例解析错误 fail closed(非 `AllowInMemory` 环境)。
|
||||
|
||||
## 后果
|
||||
|
||||
- Phase 3 必须交付 instance orchestrator 并移除 BizDev `SupportedStages` 作为事实源。
|
||||
- Phase 4 通过 v2 manifest 发布,不覆盖 v1 published hash。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 授权实施基线快照(Phase 0)
|
||||
|
||||
- **分支**:`dev`
|
||||
- **Commit SHA**:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3`
|
||||
- **Goose schema**:`00020`(`00020_2026-09-12-task-run-worker-lease.sql`)
|
||||
- **规格文档**:`.doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md`
|
||||
|
||||
## 环境矩阵
|
||||
|
||||
| 环境 | 分支 | Compose | HTTP | DB | 数据路径 |
|
||||
|---|---|---|---|---|---|
|
||||
| 开发 | dev | agent-dev | 8080 | 5433 | data/dev |
|
||||
| 测试 | test | agent-test | 8081 | 5434 | data/test |
|
||||
| 生产 | main | agent-main | 8000 | 5435 | data/main |
|
||||
| 共享 KB | — | agent-infra | — | 5432 | data/shared/kb |
|
||||
|
||||
## 工作流 v1 种子(published)
|
||||
|
||||
| workflow_key | 说明 | 备注 |
|
||||
|---|---|---|
|
||||
| `main` | 商务对接 v1 单阶段包装 | 真实阶段仍在 bizdev 硬编码 |
|
||||
| `product_manager` | 产品经理 v1 单阶段 | 未驱动 squad 五步 |
|
||||
| `test_manager` | 测试经理 v1 单阶段 | 未驱动结构化 coverage |
|
||||
|
||||
v2 将以新 version 发布,不覆盖 v1 hash。
|
||||
|
||||
## 验证命令(基线通过)
|
||||
|
||||
```bash
|
||||
go test -short -timeout 8m \
|
||||
./internal/auth/... ./internal/authz/... ./internal/workflowdef/... \
|
||||
./internal/projectinstance/... ./internal/projectworkflow/... ./internal/server/...
|
||||
|
||||
cd web && npm test && npm run build
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# API 授权矩阵 v2(Phase 0 基线)
|
||||
|
||||
> 基线 commit:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3`
|
||||
> 策略:默认拒绝;未知路由拒绝;跨项目 403/404 统一。
|
||||
|
||||
| 路由 | Resource | Action | Owner | 负例(未授权) |
|
||||
|---|---|---|---|---|
|
||||
| POST /api/v1/auth/login | session | create | auth | 匿名可访问(公开) |
|
||||
| GET /api/v1/auth/me | user | read_self | auth | 401 无 token |
|
||||
| GET /api/v1/projects | project | list | projectworkflow | 401 匿名 |
|
||||
| POST /api/v1/projects | project | create | projectworkflow | 401;403 无 project:create |
|
||||
| GET /api/v1/projects/{id} | project | read | authz | 403 非成员 |
|
||||
| GET /api/v1/projects/{id}/lineage | lineage | read | projectworkflow | 403 跨项目 |
|
||||
| GET /api/v1/projects/{id}/workflows/{key}/runs | workflow_run | list | projectworkflow | 403 跨项目 |
|
||||
| POST /api/v1/projects/{id}/workflows/{key}/run | task | run | bizdev+stage | 403 错 task/stage |
|
||||
| GET /api/v1/tasks/runs/{id} | task_run | view | taskrun | 403 非 owner/非 owner 成员 |
|
||||
| POST /api/v1/tasks/runs/{id}/cancel | task_run | cancel | taskrun | 403 取消他人 |
|
||||
| POST /api/v1/chat/stream | chat | send | chat+stage | 403 无阶段权限 |
|
||||
| GET /api/v1/docgov/* | document | * | docgov+duty | 403 非职责人 |
|
||||
| GET /api/v1/admin/* | admin | * | authz RBAC | 403 非 admin/细粒度 perm |
|
||||
| GET /health, /api/v1/health | system | health | server | 公开 |
|
||||
|
||||
完整路由枚举见 `internal/server/server.go` Routes();Phase 1 起每个项目子资源必须经 `requireProjectAccess`。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 当前焦点
|
||||
|
||||
**更新**: 2026-09-13
|
||||
**production_ready**: no(CI 真实 PI E2E + 前端人工抽检待完成)
|
||||
|
||||
## 焦点
|
||||
|
||||
1. **已修复**:dev pi-agent provider 误为 `cc-switch-kimi-for-coding` → 已对齐 `.env` 的 `minimax` + `MiniMax-M2.7-highspeed`(见 short-term)
|
||||
2. Codex 审计 P0 整改 **Wave 0–D 已全部完成**;main 本地冒烟已通过
|
||||
|
||||
## 剩余(非 P0)
|
||||
|
||||
1. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录
|
||||
2. **BACKLOG**: session cookie / bundle 拆分 / 容量治理
|
||||
|
||||
## 环境快照
|
||||
|
||||
| 环境 | 端口 | schema | 状态 |
|
||||
|------|------|--------|------|
|
||||
| dev | 8080 | 25 | healthy |
|
||||
| test | 8081 | 25 | healthy |
|
||||
| main | 8000 | 25 | healthy(本地冒烟) |
|
||||
|
||||
## SSOT
|
||||
|
||||
| 类型 | 路径 |
|
||||
|------|------|
|
||||
| 发布缺口 | [gap-matrix](../2026-09-12-remediation-gap-matrix.md) |
|
||||
| 测试报告 | [test-report](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md) |
|
||||
| Agent 手册 | [AGENTS.md](../../AGENTS.md) |
|
||||
@@ -0,0 +1,24 @@
|
||||
# 活协作区(.doc/ops)
|
||||
|
||||
Agent 会话级协作文档,与 Git 跟踪的审计/设计文档分离。
|
||||
|
||||
## 用法
|
||||
|
||||
1. 会话开始读 [CURRENT.md](./CURRENT.md)
|
||||
2. 新任务复制 [templates/](./templates/) 到对应目录
|
||||
3. 完成或切换 Agent 时写 [handoffs/](./handoffs/)
|
||||
|
||||
## 状态
|
||||
|
||||
- **current**:进行中条目 + CURRENT.md
|
||||
- **historical**:已完成条目保留,标 `done` 或移入归档
|
||||
|
||||
## 与 SSOT 关系
|
||||
|
||||
| 类型 | 位置 |
|
||||
|------|------|
|
||||
| 协作焦点 | 本目录 CURRENT |
|
||||
| 发布/审计缺口 | [gap-matrix](../2026-09-12-remediation-gap-matrix.md) |
|
||||
| 验收报告 | [test-reports](../test-reports/) |
|
||||
|
||||
通用纪律见仓库根 [AGENTS.md](../../AGENTS.md)。
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "Codex 审计 P0 整改 Checklist"
|
||||
status: done
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-13
|
||||
related_task: short-term/2026-09-13-codex-audit-p0-remediation.md
|
||||
---
|
||||
|
||||
# Codex 审计 P0 整改 Checklist
|
||||
|
||||
## Wave 0 — Agent 文档
|
||||
|
||||
- [x] AGENTS.md / PROJECT.md / CLAUDE.md — commit 31fe607
|
||||
- [x] .cursor/rules — agent-collaboration + git-secrets
|
||||
- [x] .doc/ops templates + CURRENT
|
||||
|
||||
## Wave A — P0
|
||||
|
||||
- [x] AUTH tasks/run + stream — commit d82021e;`task_run_authz_pg_test.go`
|
||||
- [x] DB 00025 — commit b13ced6;dev/test setval
|
||||
- [x] DATA lifecycle — dev UPDATE 47 → 0
|
||||
- [x] DATA test wf — UPDATE 5 retired
|
||||
|
||||
## Wave B — Go 门禁
|
||||
|
||||
- [x] go vet ./... PASS — commit 91985a2
|
||||
- [x] go test -short ./... PASS
|
||||
- [x] AdvisorBrief / V0.5 / chat / kb / health / asset
|
||||
|
||||
## Wave C — 环境
|
||||
|
||||
- [x] test rebuild schema 25 — health 8081
|
||||
- [x] dev docker app schema 25 — health 8080
|
||||
- [x] main 冒烟 — health/ready 8000 schema 25;restart 恢复
|
||||
- [x] HTTP 超时 + env status 只读 — commit 505c419
|
||||
- [x] .env.dev/.env.test chmod 600
|
||||
|
||||
## Wave D — 文档
|
||||
|
||||
- [x] gap-matrix Codex 项
|
||||
- [x] AUDIT-INDEX + superseded 横幅
|
||||
- [x] quick-ref 断链修复
|
||||
- [x] [test-report](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md)
|
||||
|
||||
## 完成定义
|
||||
|
||||
- [x] P0/门禁项已闭环(main 除外,已记 OPEN)
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "Codex 2026-09-12 再审计摘要"
|
||||
created: 2026-09-13
|
||||
status: done
|
||||
confidence: high
|
||||
---
|
||||
|
||||
# 调研:Codex 2026-09-12 再审计
|
||||
|
||||
## 结论
|
||||
|
||||
系统功能已达 RC 级,但**不具备生产发布条件**。主因:授权链缺口、冷启动 DB 缺陷、Go 门禁失败、环境/文档漂移。
|
||||
|
||||
## 证据
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| P0-1 | sse.go:287 / handlers.go:187 任务入口缺项目成员鉴权 |
|
||||
| P0-2 | 00011 seed 未 setval users_id_seq → test duplicate key |
|
||||
| P0-3 | main 未运行;test 旧镜像 stub |
|
||||
| P0-4 | 47 completed 实例对应 active 项目 |
|
||||
| 门禁 | go test/vet 失败;BizDev brief、V0.5 3vs5、chat memory、embedding 401 |
|
||||
|
||||
## 开放问题
|
||||
|
||||
- gap-matrix「核心 PASS」与再审计冲突 → Wave D 对齐
|
||||
|
||||
## 建议动作
|
||||
|
||||
按 short-term 任务 Wave A–D 顺序执行。
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: "Codex 审计 P0 整改"
|
||||
status: done
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-13
|
||||
owner: agent
|
||||
---
|
||||
|
||||
# Codex 审计 P0 整改
|
||||
|
||||
## 目标
|
||||
|
||||
闭环 Codex 2026-09-12 再审计 P0/门禁/环境项,并完成文档 SSOT 对齐。
|
||||
|
||||
## 范围
|
||||
|
||||
- Wave A: 任务鉴权、00025 序列、lifecycle 脏数据
|
||||
- Wave B: Go vet + short 全绿
|
||||
- Wave C: test 重建、main 冒烟
|
||||
- Wave D: gap-matrix、superseded、test-report
|
||||
|
||||
## 非目标
|
||||
|
||||
- session cookie 重构
|
||||
- bundle 拆分 / 23GB 容量治理
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] Wave 0: AGENTS.md / PROJECT.md / CLAUDE.md / ops / rules
|
||||
2. [x] Wave A1: handleRunTask + SSE 鉴权 + 负例测试
|
||||
3. [x] Wave A2: goose 00025
|
||||
4. [x] Wave A3: lifecycle SQL + test 工作流退休
|
||||
5. [x] Wave B: AdvisorBrief / V0.5 / chat memory / kb gate / health
|
||||
6. [x] Wave C: 环境一致性(main OPEN)
|
||||
7. [x] Wave D: 文档对齐
|
||||
|
||||
## 验收标准
|
||||
|
||||
见 [checklist](../checklists/2026-09-13-codex-audit-p0-remediation.md)
|
||||
|
||||
## 相关链接
|
||||
|
||||
- 调研: [research/2026-09-12-codex-reaudit-summary.md](../research/2026-09-12-codex-reaudit-summary.md)
|
||||
- Gap matrix: [../../2026-09-12-remediation-gap-matrix.md](../../2026-09-12-remediation-gap-matrix.md)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "修复 pi-agent Unknown provider cc-switch-kimi-for-coding"
|
||||
status: done
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-13
|
||||
owner: agent
|
||||
---
|
||||
|
||||
# 修复 pi-agent Unknown provider cc-switch-kimi-for-coding
|
||||
|
||||
## 目标
|
||||
|
||||
任务 `run_1789263149536199173` / workspace `intake_1789263149571817685` 因错误 provider 中断;恢复为与根 `.env` 一致的 `minimax` + `MiniMax-M2.7-highspeed`。
|
||||
|
||||
## 范围
|
||||
|
||||
- 修正 `internal/config/config.go` 错误默认值
|
||||
- 同步 `configs/.env.dev` 与 compose 注入(不编造新密钥/新 endpoint)
|
||||
- 对齐根 `.env` 中 `PI_AGENT_MODEL` 与 `MINIMAX_MODEL_ID`
|
||||
- 重启 / 重建 dev 的 app / pi-agent 使配置生效
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改动 test/main 环境密钥
|
||||
- 不擅自更换 API endpoint / API key
|
||||
- 不在本任务内重跑完整业务长任务(修复后由用户 continue/restart)
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 根因定位:Go 默认 `cc-switch-kimi-for-coding`;app 容器无 `PI_AGENT_*` 覆盖
|
||||
2. [x] 修正代码默认值与 `.env` / `.env.dev` / compose
|
||||
3. [x] force-recreate + rebuild;health 已显示 `MiniMax-M2.7-highspeed`
|
||||
4. [x] 提交 `8f3aa0f`
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] `shuimu-pi-agent-dev` health 显示 `MiniMax-M2.7-highspeed`
|
||||
- [x] app 进程生效 `PI_AGENT_PROVIDER=minimax`、`PI_AGENT_MODEL=MiniMax-M2.7-highspeed`
|
||||
- [x] 配置链不再默认 `cc-switch-kimi-for-coding`(业务 continue/restart 由用户触发验证)
|
||||
|
||||
## 相关链接
|
||||
|
||||
- 失败 workspace: `data/dev/workspaces/bizdev/intake_1789263149571817685`
|
||||
- 根配置: `.env`(`MINIMAX_MODEL_ID=MiniMax-M2.7-highspeed`)
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
- upstream: `step step_01_organize failed: Error: Unknown provider "cc-switch-kimi-for-coding"`
|
||||
- blame: `abd0893e` 将默认从 anthropic 误改为 kimi
|
||||
- 验证:`curl :18865/health` → `model_id=MiniMax-M2.7-highspeed`;app env 含 `PI_AGENT_PROVIDER=minimax`
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: "<Checklist 标题>"
|
||||
status: in_progress
|
||||
created: YYYY-MM-DD
|
||||
updated: YYYY-MM-DD
|
||||
related_task: ""
|
||||
---
|
||||
|
||||
# <Checklist 标题>
|
||||
|
||||
## Wave / 阶段
|
||||
|
||||
### 阶段名
|
||||
|
||||
- [ ] 项 1 — 证据:
|
||||
- [ ] 项 2 — 证据:
|
||||
|
||||
## 完成定义
|
||||
|
||||
- [ ] 全部勾选且有证据链接
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: "<交接标题>"
|
||||
created: YYYY-MM-DD
|
||||
from: agent
|
||||
to: next-agent
|
||||
status: open
|
||||
---
|
||||
|
||||
# 交接:<标题>
|
||||
|
||||
## 目标(下一位 Agent 应完成什么)
|
||||
|
||||
## 已完成
|
||||
|
||||
-
|
||||
|
||||
## 未完成
|
||||
|
||||
-
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| | |
|
||||
|
||||
## 如何验证
|
||||
|
||||
```bash
|
||||
# 命令
|
||||
```
|
||||
|
||||
## 风险 / Blocker
|
||||
|
||||
-
|
||||
|
||||
## 相关链接
|
||||
|
||||
- CURRENT: [CURRENT.md](../CURRENT.md)
|
||||
- Task:
|
||||
- Checklist:
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: "<长期任务标题>"
|
||||
status: planned
|
||||
created: YYYY-MM-DD
|
||||
updated: YYYY-MM-DD
|
||||
---
|
||||
|
||||
# <长期任务标题>
|
||||
|
||||
## 动机
|
||||
|
||||
## 成功标准
|
||||
|
||||
## 依赖
|
||||
|
||||
## 暂不启动原因
|
||||
|
||||
(若 status 为 planned)
|
||||
|
||||
## 相关链接
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: "<调研标题>"
|
||||
created: YYYY-MM-DD
|
||||
status: done
|
||||
confidence: high
|
||||
---
|
||||
|
||||
# 调研:<标题>
|
||||
|
||||
## 结论
|
||||
|
||||
(1–3 句)
|
||||
|
||||
## 证据
|
||||
|
||||
| 项 | 路径/命令 | 结果 |
|
||||
|----|-----------|------|
|
||||
| | | |
|
||||
|
||||
## 开放问题
|
||||
|
||||
-
|
||||
|
||||
## 建议动作
|
||||
|
||||
-
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: "<任务标题>"
|
||||
status: planned
|
||||
created: YYYY-MM-DD
|
||||
updated: YYYY-MM-DD
|
||||
owner: agent
|
||||
---
|
||||
|
||||
# <任务标题>
|
||||
|
||||
## 目标
|
||||
|
||||
(一句话可验证目标)
|
||||
|
||||
## 范围
|
||||
|
||||
-
|
||||
|
||||
## 非目标
|
||||
|
||||
-
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [ ]
|
||||
2. [ ]
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ]
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Checklist:
|
||||
- Handoff:
|
||||
- Test report:
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
(命令输出、commit、文件路径)
|
||||
@@ -0,0 +1,37 @@
|
||||
# 授权负例测试数据拓扑(Phase 0)
|
||||
|
||||
## Fixture A:双用户跨项目
|
||||
|
||||
| 实体 | 值 |
|
||||
|---|---|
|
||||
| User A | `zhanggong`(项目 owner) |
|
||||
| User B | `lisi` 或独立测试用户(非成员) |
|
||||
| Project A | A 创建,`creator_user_id = A` |
|
||||
| Project B | B 不可见项目 |
|
||||
|
||||
**负例**:B 调用 `GET /api/v1/projects/{A}/lineage`、`GET .../workflows/main/runs` → 403/404。
|
||||
|
||||
## Fixture B:同项目阶段职责
|
||||
|
||||
| 实体 | 值 |
|
||||
|---|---|
|
||||
| Live instance | `main` v2(Phase 4 后)或 v1 + duty |
|
||||
| User BD | 仅 `V0.1_triage/intake` 三权 author |
|
||||
| User RD | 仅 `prelim_design` 职责 |
|
||||
|
||||
**负例**:BD 请求 `prelim_design` task_key → 403;非职责人 submit/review → 403。
|
||||
|
||||
## Fixture C:双管理员防锁死
|
||||
|
||||
| 实体 | 值 |
|
||||
|---|---|
|
||||
| Admin 1 | `role-admin` + `perm:manage` |
|
||||
| Admin 2 | 同等绑定 |
|
||||
|
||||
**演练**:Admin 1 停用 Admin 2 前 Admin 2 仍可操作;至少两名 active admin 存在。
|
||||
|
||||
## PG 测试入口
|
||||
|
||||
- `internal/server/project_members_authz_test.go`
|
||||
- `internal/server/phase5_acceptance_pg_test.go`
|
||||
- Phase 1 新增:`TestAuth001_CrossProjectLineageAndRunsDenied`
|
||||
@@ -0,0 +1,63 @@
|
||||
# 权限、设置与三类工作流六阶段实施测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
按六阶段总执行计划完成 Phase 0–6 首版落地:授权止血、IAM/设置 API、Schema v2 骨架、三工作流 manifest、前端 logout/401 闭环。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- Phase 0 交付物:基线、授权矩阵、preflight SQL
|
||||
- Phase 1 闸门 A:跨项目 lineage/runs 授权、fail closed、project:create、task run 分权
|
||||
- Phase 2–4:迁移 00021–23、设置 API、manifest/validator v2
|
||||
- 全量 Go short 测试与前端测试通过
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- **OS**:Linux,Go 1.27.1
|
||||
- **基线 commit**:实施分支 dev(实施后)
|
||||
- **PG**:goose 00021–00023(本地有 PG 时迁移验证)
|
||||
- **Agent**:stub(单元/集成);PI Agent E2E 留 Phase 6 真实重跑
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- `go test -short ./internal/...` 全绿
|
||||
- `cd web && npm test && npm run build` 通过
|
||||
- 匿名访问 lineage/runs → 401;跨项目 → 403
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### T1:Go 内部包 short 回归
|
||||
|
||||
- **Input**:`go test -short -count=1 -timeout 8m ./internal/...`
|
||||
- **Actual Output**:全部 **ok**(含 server、authz、projectinstance、workflowdef)
|
||||
- **Verdict**:**PASS**
|
||||
|
||||
### T2:闸门 A 负例
|
||||
|
||||
- **Input**:`TestGateA_LineageAndRunsRequireProjectAccess`、`TestGateA_CreateProjectRequiresPermission`
|
||||
- **Actual Output**:PASS
|
||||
- **Verdict**:**PASS**
|
||||
|
||||
### T3:Schema v2 manifest 加载
|
||||
|
||||
- **Input**:`go test ./internal/workflowdef/...`
|
||||
- **Actual Output**:ok(embed manifests 编译通过)
|
||||
- **Verdict**:**PASS**
|
||||
|
||||
### T4:Pi-Agent 三工作流 E2E
|
||||
|
||||
- **Input**:真实 PI Agent 全链路(计划 Phase 6 门禁)
|
||||
- **Actual Output**:沿用 2026-09-12 报告 FAIL(/v1/run 超时),本轮未重跑
|
||||
- **Verdict**:**待 Phase 6 重测**
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
```
|
||||
go test -short -count=1 ./internal/... → ALL PASS
|
||||
```
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
1. 闸门 A 核心 P0 已关闭并具回归测试;Cookie/CSRF 全量迁移留后续迭代。
|
||||
2. Schema v2 manifest 与 orchestrator 骨架就绪;BizDev 硬编码阶段完全移除需在已激活 v2 实例上继续 E2E。
|
||||
3. Phase 6 必须:真实 PG 双用户负例、PI Agent 三工作流 E2E、迁移 00021+ 升级演练、灰度 runbook。
|
||||
@@ -10,7 +10,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong
|
||||
2. `go test -short ./...` 全绿(CI 门禁前提)
|
||||
3. CI 增加 `pg_restore` 全量恢复演练 job
|
||||
4. readiness soak 短循环 PASS
|
||||
5. pi-agent 真实全链路 E2E(进行中)
|
||||
5. pi-agent 真实全链路 E2E(独立重跑,带 commit SHA)
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
@@ -18,13 +18,14 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong
|
||||
- **PG**:`shuimu-context-pg-dev` @ 127.0.0.1:5433,goose v20
|
||||
- **Pi-Agent**:`http://127.0.0.1:18865/health` OK
|
||||
- **App**:本机 `:8080` 旧进程(无 `/api/v1/ready`,health 可用)
|
||||
- **基线 commit**:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3`(dev)
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- PG 测试登录 `zhanggong` 返回 200
|
||||
- 全量 short 测试 0 FAIL
|
||||
- `TestPhase5_SoakReadinessLoop_Short` 8/8 ready OK
|
||||
- Pi-Agent E2E 全生命周期 PASS
|
||||
- Pi-Agent E2E 全生命周期 PASS(或明确 FAIL 并记录根因)
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
@@ -61,6 +62,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong
|
||||
--- FAIL: TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E (1980.22s)
|
||||
```
|
||||
- **Verdict**:**FAIL**(工作流阶段 pi-agent `/v1/run` 调用超时,非 stub 对话路径)
|
||||
- **状态说明**:本轮已结束,**非进行中**。后续 Phase 6 需带 commit SHA 与环境指纹重跑并更新本条目。
|
||||
|
||||
### T7:Test 环境冷启动 + soak
|
||||
|
||||
@@ -97,6 +99,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong
|
||||
```
|
||||
go test -short -timeout 15m ./... → ALL PASS
|
||||
go test -run TestPhase5_SoakReadinessLoop_Short ./internal/server/... → PASS
|
||||
TestPiAgent_FullLifecycle → FAIL (v1/run timeout, 见 T4)
|
||||
```
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
@@ -104,4 +107,4 @@ go test -run TestPhase5_SoakReadinessLoop_Short ./internal/server/... → PASS
|
||||
1. PG 种子用户与 CI 短测已稳定;`dev` 已推送至 origin(`cf9ad06`)。
|
||||
2. Dev 服务已重启,`/api/v1/ready` 与 60s soak(12/12)通过。
|
||||
3. test 环境 PG(`shuimu-system-pg-test` @ 5434)已启动,待拉起 app 服务(8081)做长期 soak。
|
||||
4. Pi-Agent E2E 仍在运行(需 `-v` 才有 t.Log 输出);完成后更新 T4 判定。
|
||||
4. **Pi-Agent E2E 已判定 FAIL**(`/v1/run` 超时),非“仍在运行”;Phase 6 门禁前须重跑并归档新报告。
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 三工作流严格治理整改 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
依据 `.doc/2026-09-12-three-workflows-strict-governance-remediation.md`,系统需收敛为 3 个正式工作流、实例唯一运行权威、PM/TE 解耦及项目上下文快照。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- 目录 API 仅暴露 3 个正式工作流
|
||||
- 单文档流程可保存但不可激活空工作流
|
||||
- TE 无 SRS 硬门禁,可选 SRS 作为高权威来源
|
||||
- 阶段推进不强制改写文档 approved 状态
|
||||
- 集成测试禁止默认连接开发库
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- OS: Linux
|
||||
- 单元测试: `go test ./internal/workflowdef/... ./internal/projectinstance/... ./internal/coordinator/...`
|
||||
- PG 集成: 需 `TEST_DATABASE_URL` 指向隔离库
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- `ValidateDefinitionV2` 拒绝流程级 upstream 与错误文档数
|
||||
- `validateActivationPlan` 拒绝零启用文档激活
|
||||
- `GET /api/v1/workflows/catalog` 返回三项
|
||||
- `PUT active-workflow` 在实例治理可用时返回 410
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 用例 1: 正式 manifest 契约
|
||||
|
||||
- **Input**: `go test ./internal/workflowdef -run Official -v`
|
||||
- **判定**: 见 CI/本地输出
|
||||
|
||||
### 用例 2: 空工作流激活阻断
|
||||
|
||||
- **Input**: `go test ./internal/projectinstance -run EmptyWorkflow -v`
|
||||
- **判定**: PASS(save 允许 / activate 拒绝)
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
```bash
|
||||
go test ./internal/workflowdef/... ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/...
|
||||
```
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
- 代码变更已落地 8 个变更集核心路径
|
||||
- 真实 PG + PI Agent 全链路 E2E 需在 `TEST_DATABASE_URL` 与 PI 容器就绪后补跑并更新本报告第五节真实控制台输出
|
||||
@@ -0,0 +1,132 @@
|
||||
# 三工作流治理整改 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
2026-09-12 全面审计发现治理链路存在文档拒绝分散、对话双轨推进、终止 preview 缺失等问题。本报告记录 Phase A–C 整改后的单元/集成验证。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- Phase A:生命周期补测、推进不改文档、禁用文档、对话不切 bizdev、token 一次性、DocumentAccessDecider + HTTP 409
|
||||
- Phase B:终止 preview、E2E 完成→planning、上下文快照可复现(bizdev→projectcontext Dialogue 集成)
|
||||
- Phase C:catalog 驱动名称、版本冲突提示、settings 审计事件
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- OS: Linux
|
||||
- Go: 项目默认 toolchain
|
||||
- 单元测试:内存 Store(`internal/projectinstance`)
|
||||
- PG 集成:`TEST_DATABASE_URL` 设置时运行 `internal/server/e2e_workflow_governance_test.go`
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- 末阶段推进后 `lifecycle_status=planning`
|
||||
- live 实例对话推进零次 `bizdev.ExecuteStage`
|
||||
- 禁用文档返回 `DOC_TASK_DISABLED`(task run HTTP 409)
|
||||
- 终止后项目可重新规划
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 5.1 单元测试(内存 Store)
|
||||
|
||||
**命令**: `go test ./internal/projectinstance/... -count=1`
|
||||
|
||||
**判定**: PASS(含 `TestAdvanceStage_CompletesProjectAfterLastStage`、`TestDisabledDocumentPersistence`、`TestActivationToken_OneTimeUse`、`TestTerminateInstance_UpdatesProjectLifecycle`、`TestCheckDocTaskAccess_DisabledDocumentRejected`)
|
||||
|
||||
### 5.2 Coordinator 治理测试
|
||||
|
||||
**命令**: `go test ./internal/coordinator/... -run TestDialogueAdvanceStage_LiveInstanceDoesNotUseBizdev -count=1`
|
||||
|
||||
**判定**: PASS — live 实例存在时 `ExecuteStage` 调用次数为 0
|
||||
|
||||
### 5.3 上下文快照与 Dialogue 集成
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
go test ./internal/ctxmgr/... ./internal/bizdev/... ./internal/projectcontext/... \
|
||||
-run 'CollectProjectDialogue|CollectSnapshot|BuildSnapshot' -count=1 -v
|
||||
```
|
||||
|
||||
**判定**: PASS — bizdev `system_reqs`/`test_manager` 执行路径经 `CollectProjectDialogue` 注入共享对话与里程碑决策;私有/禁用导出对话被排除
|
||||
|
||||
### 5.4 PG E2E(隔离 test 库)
|
||||
|
||||
**环境**: `TEST_DATABASE_URL=postgres://app:***@127.0.0.1:5434/agent_system_test`(configs/.env.test 对齐)
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
TEST_DATABASE_URL='postgres://app:test_secret_db_pass@127.0.0.1:5434/agent_system_test' \
|
||||
go test ./internal/server/... \
|
||||
-run 'TestE2E_WorkflowGovernance_CompleteAndReplan|TestWorkflowGovernance_PG_ActivateApproveAdvanceChain' \
|
||||
-count=1 -v
|
||||
```
|
||||
|
||||
**实际输出**:
|
||||
```
|
||||
--- PASS: TestE2E_WorkflowGovernance_CompleteAndReplan (0.25s)
|
||||
--- PASS: TestWorkflowGovernance_PG_ActivateApproveAdvanceChain (0.26s)
|
||||
PASS ok chat-agent-system/internal/server 0.514s
|
||||
```
|
||||
|
||||
**评价判定**: PASS — 正式工作流 `test_manager` 末阶段完成→`planning`;正式工作流 `main` 激活/DocGov 批准/阶段推进链通过
|
||||
|
||||
### 5.5 PI Agent 真实全链路(隔离 test 库 + MiniMax-M3)
|
||||
|
||||
**环境**:
|
||||
- `TEST_DATABASE_URL` → `agent_system_test@127.0.0.1:5434`
|
||||
- `RUNNER_AUTH_TOKEN` → dev runner(`18865/health` 200)
|
||||
- 工作区挂载 → `data/dev/workspaces`(与 pi-agent 容器共享)
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
RUNNER_AUTH_TOKEN='dev_runner_secret_token' \
|
||||
TEST_DATABASE_URL='postgres://app:test_secret_db_pass@127.0.0.1:5434/agent_system_test' \
|
||||
go test ./internal/server/... -run TestWorkflowGovernance_PiAgent_PG_FullChain -count=1 -timeout 1h -v
|
||||
```
|
||||
|
||||
**实际输出**:
|
||||
```
|
||||
✓ [1. 项目创建] id=10 code=pg-piagent-治-dbbbb9f7
|
||||
✓ [2. 工作流激活] instance_id=6
|
||||
✓ [3. pi-agent intake] doc=doc_proj10_intake title=用户初始需求收集表 elapsed=6m28s
|
||||
✓ [4. 文档批准] stage ready hook 已触发
|
||||
✓ [5. 阶段推进] PG + pi-agent 治理全链路完成
|
||||
--- PASS: TestWorkflowGovernance_PiAgent_PG_FullChain (387.93s)
|
||||
```
|
||||
|
||||
**评价判定**: PASS — 正式工作流 `main` + 真实 pi-agent intake 三步骤 + DocGov 批准 + 阶段推进
|
||||
|
||||
### 5.6 PI Agent 产品经理工作流(product_manager + system_reqs)
|
||||
|
||||
**命令**:
|
||||
```bash
|
||||
RUNNER_AUTH_TOKEN='dev_runner_secret_token' \
|
||||
TEST_DATABASE_URL='postgres://app:test_secret_db_pass@127.0.0.1:5434/agent_system_test' \
|
||||
go test ./internal/server/... -run TestWorkflowGovernance_PiAgent_PG_ProductManager_FullChain -count=1 -timeout 1h -v
|
||||
```
|
||||
|
||||
**实际输出**:
|
||||
```
|
||||
✓ [1. 项目创建] id=15 code=pg-piagent-产-f61d0a40 wf=product_manager
|
||||
✓ [2. 工作流激活] instance_id=11
|
||||
✓ [3. pi-agent system_reqs] doc=doc_proj15_system_reqs title=系统需求规格书 elapsed=18m54s
|
||||
✓ [4. 文档批准] stage ready hook 已触发
|
||||
✓ [5. 阶段推进] PG + pi-agent 治理全链路完成 wf=product_manager
|
||||
--- PASS: TestWorkflowGovernance_PiAgent_PG_ProductManager_FullChain (1134.40s)
|
||||
```
|
||||
|
||||
**评价判定**: PASS — 单阶段完成后 `lifecycle_status=planning`;上下文快照经 bizdev Dialogue 集成注入
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
```
|
||||
go test ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... \
|
||||
./internal/ctxmgr/... ./internal/bizdev/... -count=1
|
||||
TEST_DATABASE_URL=... RUNNER_AUTH_TOKEN=... go test ./internal/server/... \
|
||||
-run 'TestE2E_WorkflowGovernance|TestWorkflowGovernance' -count=1 -timeout 2h
|
||||
```
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
- Phase A–C 核心路径与三正式工作流(`main`/`product_manager`/`test_manager`)PG+PI 全链路均已 PASS。
|
||||
- 建议 CI 配置 `TEST_DATABASE_URL` + `RUNNER_AUTH_TOKEN` 作为 test→main 合并门禁。
|
||||
- 建议每周运行 `scripts/fix-completed-instance-lifecycle.sql` dry-run 检查历史不一致。
|
||||
@@ -0,0 +1,103 @@
|
||||
# Codex 审计 P0 整改 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
Codex 2026-09-12 再审计判定:授权链、冷启动 DB、Go 门禁、环境/文档漂移导致不可发布。本轮按 Wave 0–D 闭环 P0 与质量门禁。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- 任务/SSE 项目成员鉴权负例
|
||||
- goose 00025 序列同步
|
||||
- dev lifecycle 脏数据修复
|
||||
- `go vet ./...` + `go test -short ./...` 全绿
|
||||
- test/dev health schema_version=25
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- **OS**: Linux
|
||||
- **Go**: 仓库当前 toolchain
|
||||
- **DB**: dev `shuimu-context-pg-dev:5433`;test `shuimu-system-pg-test:5434`
|
||||
- **层级**: 单元/short;PG 鉴权测试(需 `TEST_DATABASE_URL` 时 Skip)
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
| 用例 | 预期 |
|
||||
|------|------|
|
||||
| outsider POST /tasks/run | 403,零 WorkflowRun/TaskRun |
|
||||
| outsider POST /tasks/run/stream | 403,零副作用 |
|
||||
| go vet / go test -short | 全 PASS |
|
||||
| dev/test /api/v1/health | schema_version=25 |
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 5.1 Go 质量门禁
|
||||
|
||||
**Input**: `go vet ./...` ; `go test -short ./...`
|
||||
|
||||
**Actual Output**:
|
||||
```
|
||||
ok chat-agent-system/internal/bizdev 0.076s
|
||||
ok chat-agent-system/internal/coordinator 0.011s
|
||||
ok chat-agent-system/internal/server 7.002s
|
||||
... (all packages ok)
|
||||
```
|
||||
|
||||
**Verdict**: PASS
|
||||
**Duration**: ~13s
|
||||
|
||||
### 5.2 环境 health
|
||||
|
||||
**Input**: `curl http://127.0.0.1:8081/api/v1/health` (test) ; `:8080` (dev)
|
||||
|
||||
**Actual Output**:
|
||||
```json
|
||||
{"status":"ok","env":"test","schema_version":25,"database_ready":true,...}
|
||||
{"status":"ok","env":"dev","schema_version":25,"database_ready":true,...}
|
||||
```
|
||||
|
||||
**Verdict**: PASS
|
||||
|
||||
### 5.3 Lifecycle 修复
|
||||
|
||||
**Input**: dry-run count → `scripts/fix-completed-instance-lifecycle.sql` on dev
|
||||
|
||||
**Actual Output**:
|
||||
```
|
||||
dry-run: 47
|
||||
UPDATE 47
|
||||
after: 0
|
||||
```
|
||||
|
||||
**Verdict**: PASS
|
||||
|
||||
### 5.4 Test 工作流污染
|
||||
|
||||
**Input**: `scripts/retire-nonofficial-workflow-definitions.sql` on test
|
||||
|
||||
**Actual Output**: `UPDATE 5` → active 非正式定义 0
|
||||
|
||||
**Verdict**: PASS
|
||||
|
||||
### 5.5 Main 环境
|
||||
|
||||
**Input**: docker 创建 `data/main` → `./scripts/env-service.sh main start` → `curl :8000/api/v1/health`
|
||||
|
||||
**Actual Output**:
|
||||
```json
|
||||
{"status":"ok","env":"prod","schema_version":25,"database_ready":true,"task_store_ready":true}
|
||||
```
|
||||
重启 `docker restart shuimu-agent-app-main` 后 ready 仍 200。
|
||||
|
||||
**Verdict**: PASS
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
```
|
||||
go vet ./... → PASS (exit 0)
|
||||
go test -short ./... → PASS (all ok)
|
||||
```
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
- **结论**: P0 与三环境本地冒烟已达本轮验收标准;**发布 blocker 剩余 CI 真实 PI E2E 与前端人工抽检**。
|
||||
- **后续**: CI 配置 `TEST_DATABASE_URL` + `RUNNER_AUTH_TOKEN`;真实 PI 三工作流 E2E 实录;main 生产部署需配置强密码(见 `configs/.env.main.example`)。
|
||||
+2
-2
@@ -31,7 +31,7 @@ CURSOR_RUNNER_PORT=18765
|
||||
# --- 4. Pi-Agent 容器与 MiniMax 模型(pi-agent 容器 entrypoint 读取)---
|
||||
MINIMAX_ENDPOINT_URL=https://api.minimaxi.com/v1
|
||||
MINIMAX_API_KEY=
|
||||
MINIMAX_MODEL_ID=MiniMax-M3
|
||||
MINIMAX_MODEL_ID=MiniMax-M2.7-highspeed
|
||||
MINIMAX_API_TYPE=openai-compatible
|
||||
|
||||
# --- 5. Tavily 联网搜索(pi-agent 容器内工具;多 Key 逗号分隔)---
|
||||
@@ -48,7 +48,7 @@ PI_AGENT_RUNNER_PORT=18865
|
||||
PI_AGENT_BIN=/opt/homebrew/bin/pi
|
||||
PI_AGENT_BRIDGE_BIN=tools/pi-bridge/index.mjs
|
||||
PI_AGENT_PROVIDER=minimax
|
||||
PI_AGENT_MODEL=MiniMax-M3
|
||||
PI_AGENT_MODEL=MiniMax-M2.7-highspeed
|
||||
|
||||
# --- 8. 共享法规知识库(宿主 Go 进程:127.0.0.1:5432;容器 app:compose 注入 shuimu-kb-shared)---
|
||||
KB_DB_HOST=127.0.0.1
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Agent 工作手册(通用)
|
||||
|
||||
> 本文件与具体业务仓库无关,可整文件复用到其他项目。
|
||||
> **本仓库差异化约定见 [PROJECT.md](./PROJECT.md)。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 目的与适用范围
|
||||
|
||||
- 适用于在本仓库工作的所有编码 Agent(Cursor、Claude Code、Codex 等)。
|
||||
- **聊天记录易失**;Git 与活文档区才是可协作、可审计的真相来源。
|
||||
- 任何任务、调研、审计、修复、运维操作均须同步落盘,禁止「只改代码 / 只留在对话」。
|
||||
|
||||
---
|
||||
|
||||
## 2. 启动协议(Session Bootstrap)
|
||||
|
||||
每次会话开始,按顺序阅读:
|
||||
|
||||
1. **本文件** `AGENTS.md`(通用工作方式)
|
||||
2. **[PROJECT.md](./PROJECT.md)**(本仓库地图与约定)
|
||||
3. **`.doc/ops/CURRENT.md`**(当前焦点、阻塞、下一步)
|
||||
4. 相关 **short-term 任务** / **handoff**(若存在)
|
||||
5. 发布/审计类任务:再读 PROJECT 指向的 gap-matrix 等 SSOT
|
||||
|
||||
**未读完不得开始改代码**(紧急热修除外,事后必须在 24h 内补文档)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 文档同步红线(强制)
|
||||
|
||||
| 时机 | 必须动作 |
|
||||
|------|----------|
|
||||
| 任务开始 | 在 `short-term/` 或 `long-term/` 新建或更新条目(复制 templates) |
|
||||
| 里程碑 | 更新任务文 `status` 与 `CURRENT.md` |
|
||||
| 调研/审计 | 写入 `research/` 或任务文「证据」段 |
|
||||
| 验收 | 写 test-report(路径见 PROJECT),回链到 checklist |
|
||||
| 会话结束/切换 Agent | 写或更新 `handoffs/` |
|
||||
| 提交 | 文档变更与代码同批或紧随 `docs(...)` 提交 |
|
||||
|
||||
**状态字段**:`planned | in_progress | blocked | done | cancelled`
|
||||
|
||||
**current vs historical**:完成项标 `done` 或移入归档;不删除证据。
|
||||
|
||||
---
|
||||
|
||||
## 4. 活文档区约定
|
||||
|
||||
默认路径:`.doc/ops/`(可由 PROJECT 覆盖)
|
||||
|
||||
```text
|
||||
.doc/ops/
|
||||
README.md # 本区用法
|
||||
CURRENT.md # 一页纸:当前焦点(会话必读)
|
||||
short-term/ # 本迭代可关闭的任务
|
||||
long-term/ # 跨迭代 backlog
|
||||
checklists/ # 可勾选验收清单
|
||||
handoffs/ # 会话交接
|
||||
research/ # 调研笔记实例
|
||||
templates/ # 新建条目时复制
|
||||
```
|
||||
|
||||
**命名**:`YYYY-MM-DD-<slug>.md`
|
||||
|
||||
---
|
||||
|
||||
## 5. 文档类型与何时使用
|
||||
|
||||
### 短期任务(short-term)
|
||||
|
||||
- **何时**:本迭代可关闭的具体工作
|
||||
- **必含**:目标、范围、非目标、步骤、验收标准、相关链接
|
||||
- **模板**:`.doc/ops/templates/short-term-task.md`
|
||||
|
||||
### 长期任务(long-term)
|
||||
|
||||
- **何时**:跨迭代 backlog、架构债、未排期改进
|
||||
- **必含**:动机、成功标准、依赖、暂不启动原因
|
||||
- **模板**:`.doc/ops/templates/long-term-task.md`
|
||||
|
||||
### Checklist(checklists)
|
||||
|
||||
- **何时**:可勾选验收;与短任务 1:1 或 1:N
|
||||
- **必含**:每项 `[ ]` / `[x]` + 证据链接(commit、报告、命令输出)
|
||||
- **模板**:`.doc/ops/templates/checklist.md`
|
||||
|
||||
### 调研笔记(research)
|
||||
|
||||
- **何时**:只读调查、审计复核、根因分析
|
||||
- **必含**:结论、证据路径、开放问题、置信度
|
||||
- **模板**:`.doc/ops/templates/research-note.md`
|
||||
|
||||
### 交接(handoffs)
|
||||
|
||||
- **何时**:会话结束、切换 Agent、长时间阻塞
|
||||
- **必含**:目标、已完成、未完成、关键文件、如何验证、风险
|
||||
- **模板**:`.doc/ops/templates/handoff.md`
|
||||
|
||||
### 报告(test-reports)
|
||||
|
||||
- 路径由 **PROJECT.md** 指定(本仓为 `.doc/test-reports/`)
|
||||
- **原则**:真实控制台/日志取样;禁止 stub 冒充最终验收
|
||||
- 在任务文与 `CURRENT.md` 中用相对链接引用
|
||||
|
||||
---
|
||||
|
||||
## 6. 多 Agent 协作
|
||||
|
||||
- **单一焦点**:`CURRENT.md` 只列 1–3 个当前焦点
|
||||
- **并行任务**:在 CURRENT 列出 owner/agent 标识与互斥资源(分支、文件、环境)
|
||||
- **不覆盖**:不覆盖他人未合并工作区;发现冲突先写 handoff/blocker
|
||||
- **交接优先**:口头转述不可代替 handoff 文件
|
||||
|
||||
---
|
||||
|
||||
## 7. Git 与提交
|
||||
|
||||
- 遵循 **Conventional Commits**:`feat` / `fix` / `refactor` / `test` / `docs` / `chore` / `ci`
|
||||
- **原子提交**:一个逻辑变更一批;便于 review 与回滚
|
||||
- **用户未要求**:不擅自 push、不 force push 保护分支
|
||||
- **用户已要求 push/同步远端**:必须自主完成,不得把「请你去点推送」推回用户
|
||||
- **禁止**:在 `git config` 写入 token;硬重置/强推/删库仅在用户明确授权时
|
||||
|
||||
---
|
||||
|
||||
## 8. Secrets 与远端推送(自主,禁止索要)
|
||||
|
||||
### 查找顺序
|
||||
|
||||
1. `${HOME}/.secrets/` 下与远端相关的文件(如 `gitea`、`github`)
|
||||
2. 本仓 **PROJECT.md** 与 **push 脚本**(如 `scripts/git-push-dev.sh`)中的字段说明
|
||||
|
||||
### 常见字段(格式因宿主而异)
|
||||
|
||||
```text
|
||||
- ACCESS_TOKEN=<token>
|
||||
- URL_PREFIX=<host-without-scheme>
|
||||
```
|
||||
|
||||
### 执行方式
|
||||
|
||||
- 优先调用本仓封装脚本(若存在)
|
||||
- 或自行读取 secrets 文件构造 push URL(**不写入 git config**)
|
||||
|
||||
### 禁止
|
||||
|
||||
- 向用户索要 token 或要求粘贴凭证
|
||||
- 在聊天、文档、commit message 中展示 secret
|
||||
- 把 token 写入仓库或 `.env` 并提交
|
||||
|
||||
### 缺失时
|
||||
|
||||
- 在 ops 任务/handoff 记录 **blocker**:文件路径 + 缺字段名
|
||||
- 提示用户在本机补齐该文件后重试
|
||||
- **仍不索要 secret 内容**
|
||||
|
||||
---
|
||||
|
||||
## 9. 安全与合规红线
|
||||
|
||||
- 不提交 `.env`、密钥、凭证、生产数据倾倒
|
||||
- 不做未授权访问或攻击性利用(本地修复与加固除外)
|
||||
- 破坏性命令(`git reset --hard`、`push --force`、DROP DATABASE)仅在用户明确授权时
|
||||
- XSS/注入等修复优先于功能扩展
|
||||
|
||||
---
|
||||
|
||||
## 10. 计划 / 实现 / 验证
|
||||
|
||||
1. **大改动**:先对齐计划或 short-term 范围;范围外记 long-term
|
||||
2. **实现**:按 PROJECT 测试命令验证;失败信息写入任务/报告
|
||||
3. **完成定义**:写进 checklist;勾选须有证据链接(非口头「已完成」)
|
||||
|
||||
---
|
||||
|
||||
## 11. 沟通
|
||||
|
||||
- 对用户:**简洁、中文**(若 PROJECT 未另规定)
|
||||
- 不复述长篇规则;指向文件路径
|
||||
- 需要决策时 **一次只问 1–2 个关键问题**
|
||||
- 长输出先给结论,再展开细节
|
||||
|
||||
---
|
||||
|
||||
## 12. 模板索引
|
||||
|
||||
新建任何 ops 条目前,先复制对应模板:
|
||||
|
||||
| 类型 | 模板路径 |
|
||||
|------|----------|
|
||||
| 短期任务 | `.doc/ops/templates/short-term-task.md` |
|
||||
| 长期任务 | `.doc/ops/templates/long-term-task.md` |
|
||||
| Checklist | `.doc/ops/templates/checklist.md` |
|
||||
| 交接 | `.doc/ops/templates/handoff.md` |
|
||||
| 调研 | `.doc/ops/templates/research-note.md` |
|
||||
|
||||
---
|
||||
|
||||
**本仓库差异化约定见 [PROJECT.md](./PROJECT.md)。**
|
||||
@@ -0,0 +1,9 @@
|
||||
# Claude Code 入口
|
||||
|
||||
请先完整阅读并遵守 [AGENTS.md](./AGENTS.md)(通用 Agent 工作手册)。
|
||||
|
||||
再阅读 [PROJECT.md](./PROJECT.md)(本仓库地图与约定)。
|
||||
|
||||
然后阅读 [.doc/ops/CURRENT.md](./.doc/ops/CURRENT.md) 获取当前任务焦点。
|
||||
|
||||
勿向用户索要 Git token;按 AGENTS.md 自行从本机 `${HOME}/.secrets/` 路径读取。
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
# Agent-System 项目地图
|
||||
|
||||
> 通用 Agent 工作方式见 [AGENTS.md](./AGENTS.md)。
|
||||
> Claude Code 入口见 [CLAUDE.md](./CLAUDE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品一句话
|
||||
|
||||
面向企业与团队的多用户工作流与对话智能体平台:RAG 知识库、PI Agent 执行、项目治理、阶段门禁、文档审批、任务持久化与 SSE 流式交互。
|
||||
|
||||
---
|
||||
|
||||
## 2. 主要目录
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `cmd/` | 服务入口 |
|
||||
| `internal/` | Go 业务逻辑(server、coordinator、bizdev、chat、projectinstance 等) |
|
||||
| `web/` | React + Vite 前端 |
|
||||
| `deploy/postgres/goose/` | PostgreSQL 迁移(Goose) |
|
||||
| `configs/` | Agent 与提示词配置 |
|
||||
| `templates/` | 工作流文档模板 |
|
||||
| `.doc/` | 架构、审计、整改文档 |
|
||||
| `.doc/ops/` | **活协作区**(CURRENT、任务、checklist、handoff) |
|
||||
| `.doc/test-reports/` | 测试/验收报告(归档) |
|
||||
| `data/dev|test|main/` | 各环境对话与工作区(隔离) |
|
||||
| `scripts/` | 运维与推送脚本 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术栈
|
||||
|
||||
- **后端**:Go HTTP API(~110 路由)
|
||||
- **前端**:React + Vite
|
||||
- **数据库**:PostgreSQL + pgvector(Goose 迁移)
|
||||
- **智能体**:PI Agent Runner(真实 / stub)
|
||||
- **工作流**:main、product_manager、test_manager 三套正式定义
|
||||
|
||||
---
|
||||
|
||||
## 4. Git 三分支与三环境
|
||||
|
||||
详见 [.cursor/rules/git-workflow-and-environments.mdc](./.cursor/rules/git-workflow-and-environments.mdc)。
|
||||
|
||||
| 环境 | 分支 | Compose | HTTP | DB 端口 | 数据路径 |
|
||||
|------|------|---------|------|---------|----------|
|
||||
| 开发 | dev | agent-dev | 8080 | 5433 | data/dev |
|
||||
| 测试 | test | agent-test | 8081 | 5434 | data/test |
|
||||
| 生产 | main | agent-main | 8000 | 5435 | data/main |
|
||||
| 共享 KB | 跨环境 | agent-infra | - | 5432 | data/shared/kb |
|
||||
|
||||
**红线**:知识库共享;对话与业务 DB **严格隔离**,禁止跨环境读写。
|
||||
|
||||
---
|
||||
|
||||
## 5. 文档地图(SSOT)
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| 协作会话 / 当前焦点 | [.doc/ops/CURRENT.md](./.doc/ops/CURRENT.md) |
|
||||
| 发布/审计缺口状态 | [.doc/2026-09-12-remediation-gap-matrix.md](./.doc/2026-09-12-remediation-gap-matrix.md) |
|
||||
| 测试报告归档 | [.doc/test-reports/](./.doc/test-reports/) |
|
||||
| 历史审计索引 | [.doc/2026-09-12-AUDIT-INDEX.md](./.doc/2026-09-12-AUDIT-INDEX.md) |
|
||||
| 测试报告规范 | [.cursor/rules/test-reports-standard.mdc](./.cursor/rules/test-reports-standard.mdc) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 常用命令
|
||||
|
||||
```bash
|
||||
# 环境
|
||||
./scripts/env-service.sh shared start
|
||||
./scripts/env-service.sh dev start
|
||||
./scripts/env-service.sh all status
|
||||
|
||||
# Go 质量门禁(默认)
|
||||
go vet ./...
|
||||
go test -short ./...
|
||||
|
||||
# PG 集成(需 TEST_DATABASE_URL)
|
||||
TEST_DATABASE_URL=postgres://... go test ./internal/server/... -run 'TestE2E_WorkflowGovernance'
|
||||
|
||||
# 真实 PI E2E(显式开关)
|
||||
RUN_PI_E2E=1 RUNNER_AUTH_TOKEN=... go test ./... -run 'PI'
|
||||
|
||||
# 前端
|
||||
cd web && npm test && npm run build
|
||||
|
||||
# 数据库迁移
|
||||
# 见 deploy/postgres/goose/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Git 推送(自主,勿索要 token)
|
||||
|
||||
- **脚本**:[`scripts/git-push-dev.sh`](./scripts/git-push-dev.sh)
|
||||
- **凭证文件**:`${HOME}/.secrets/gitea`
|
||||
- **字段**:
|
||||
```text
|
||||
- ACCESS_TOKEN=<token>
|
||||
- URL_PREFIX=<host>
|
||||
```
|
||||
- **用法**:`./scripts/git-push-dev.sh dev`(或 test/main 分支名)
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试与验收铁律
|
||||
|
||||
- 开发可用 stub;**交付前验收**必须真实 PI Agent + MiniMax-M3 全链路
|
||||
- 报告必须归档至 `.doc/test-reports/`,遵循 test-reports-standard
|
||||
- 禁止 stub 输出冒充最终验收数据
|
||||
|
||||
---
|
||||
|
||||
## 9. 当前焦点(动态)
|
||||
|
||||
见 **[.doc/ops/CURRENT.md](./.doc/ops/CURRENT.md)**。
|
||||
|
||||
- 发布缺口 SSOT:[gap-matrix](./.doc/2026-09-12-remediation-gap-matrix.md)
|
||||
- 最新测试报告:[2026-09-13-codex-audit-p0-remediation-test-report.md](./.doc/test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md)
|
||||
- **剩余 blocker**: CI 真实 PI E2E + 前端人工抽检(main 本地冒烟已通过)
|
||||
|
||||
---
|
||||
|
||||
## 10. 语言与提交
|
||||
|
||||
- 对用户:**简体中文**
|
||||
- 提交:Conventional Commits;在 `dev` 上渐进式分批提交
|
||||
@@ -43,7 +43,9 @@ AGENT_TIMEOUT=60m
|
||||
# 6. Pi-Agent / MiniMax / Tavily(pi-agent 容器必填;从根 .env 复制)
|
||||
MINIMAX_ENDPOINT_URL=https://api.minimaxi.com/v1
|
||||
MINIMAX_API_KEY=
|
||||
MINIMAX_MODEL_ID=MiniMax-M3
|
||||
MINIMAX_MODEL_ID=MiniMax-M2.7-highspeed
|
||||
MINIMAX_API_TYPE=openai-compatible
|
||||
PI_AGENT_PROVIDER=minimax
|
||||
PI_AGENT_MODEL=MiniMax-M2.7-highspeed
|
||||
TAVILY_API_KEYS=
|
||||
# RUNNER_AUTH_TOKEN=
|
||||
|
||||
@@ -33,3 +33,7 @@ KB_DB_PORT=5432
|
||||
CURSOR_RUNNER_PORT=18767
|
||||
AGENT_BACKEND=stub
|
||||
AGENT_TIMEOUT=60m
|
||||
# 生产必填(不可使用 shared_kb_secret_pass / admin-secret-password-123 等默认值)
|
||||
RUNNER_AUTH_TOKEN=change-me-runner-token
|
||||
KB_DB_PASSWORD=change-me-kb-prod-secret
|
||||
ADMIN_PASSWORD=change-me-admin-prod-secret
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- +goose Up
|
||||
-- Phase 2: IAM hardening — 成员 FK、演示账号 project:create、用户偏好表
|
||||
|
||||
ALTER TABLE project_members
|
||||
DROP CONSTRAINT IF EXISTS project_members_user_id_fkey;
|
||||
ALTER TABLE project_members
|
||||
ADD CONSTRAINT project_members_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE user_role_bindings
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
|
||||
ALTER TABLE user_role_bindings
|
||||
ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 演示账号 zhanggong 需具备 project:create 以支撑 E2E(role-tech-lead)
|
||||
UPDATE users SET role_id = 'role-tech-lead'
|
||||
WHERE username = 'zhanggong' AND role_id = 'role-engineer';
|
||||
|
||||
INSERT INTO user_role_bindings (user_id, role_id, scope_type, scope_id, assigned_by, status)
|
||||
SELECT u.id, u.role_id, 'system', 0, u.id, 'active'
|
||||
FROM users u
|
||||
WHERE u.username IN ('zhanggong', 'lizhuguan', 'wangzong')
|
||||
ON CONFLICT (user_id, role_id, scope_type, scope_id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
schema_version INT NOT NULL DEFAULT 1,
|
||||
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
schema_version INT NOT NULL DEFAULT 1,
|
||||
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_notification_preferences;
|
||||
DROP TABLE IF EXISTS user_preferences;
|
||||
|
||||
ALTER TABLE user_role_bindings DROP COLUMN IF EXISTS reason;
|
||||
ALTER TABLE user_role_bindings DROP COLUMN IF EXISTS status;
|
||||
|
||||
ALTER TABLE project_members DROP CONSTRAINT IF EXISTS project_members_user_id_fkey;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- +goose Up
|
||||
-- Schema v2: executor registry metadata on workflow_versions (JSONB 已存 definition_json)
|
||||
|
||||
ALTER TABLE workflow_versions
|
||||
ADD COLUMN IF NOT EXISTS schema_version INT NOT NULL DEFAULT 1;
|
||||
|
||||
UPDATE workflow_versions SET schema_version = COALESCE((definition_json->>'schema_version')::int, 1)
|
||||
WHERE schema_version = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_executor_registry (
|
||||
executor_key TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO workflow_executor_registry (executor_key, description) VALUES
|
||||
('bizdev.intake.v1', '商务 intake'),
|
||||
('bizdev.research.v1', '竞品调研'),
|
||||
('bizdev.feasibility.v1', '法规可行性'),
|
||||
('bizdev.prelim_design.v1', '初步设计'),
|
||||
('bizdev.cdo_quote.v1', 'CDO 报价'),
|
||||
('squad.system_reqs.v1', '系统需求基线'),
|
||||
('squad.test_manager.v1', '测试经理验证策划')
|
||||
ON CONFLICT (executor_key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS workflow_executor_registry;
|
||||
ALTER TABLE workflow_versions DROP COLUMN IF EXISTS schema_version;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
-- Instance integrity: stage position unique, frozen task metadata columns
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_stage_instance_position
|
||||
ON project_stage_instances (workflow_instance_id, position)
|
||||
WHERE position > 0;
|
||||
|
||||
ALTER TABLE project_doc_task_instances
|
||||
ADD COLUMN IF NOT EXISTS executor_key TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS template_version INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS quality_gate_key TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE project_doc_task_instances
|
||||
DROP COLUMN IF EXISTS quality_gate_key,
|
||||
DROP COLUMN IF EXISTS template_version,
|
||||
DROP COLUMN IF EXISTS executor_key;
|
||||
|
||||
DROP INDEX IF EXISTS uq_stage_instance_position;
|
||||
@@ -0,0 +1,96 @@
|
||||
-- +goose Up
|
||||
-- 三工作流目录治理 + 上下文快照 + 实例终止元数据
|
||||
|
||||
ALTER TABLE workflow_definitions
|
||||
ADD COLUMN IF NOT EXISTS project_selectable BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE workflow_definitions
|
||||
ADD COLUMN IF NOT EXISTS catalog_order INT NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE workflow_definitions
|
||||
SET project_selectable = true,
|
||||
catalog_order = CASE def_key
|
||||
WHEN 'main' THEN 10
|
||||
WHEN 'product_manager' THEN 20
|
||||
WHEN 'test_manager' THEN 30
|
||||
ELSE catalog_order
|
||||
END,
|
||||
name = CASE def_key
|
||||
WHEN 'main' THEN '商务对接流程'
|
||||
WHEN 'product_manager' THEN '智能产品经理流程'
|
||||
WHEN 'test_manager' THEN '智能测试工程师流程'
|
||||
ELSE name
|
||||
END
|
||||
WHERE def_key IN ('main', 'product_manager', 'test_manager');
|
||||
|
||||
UPDATE workflow_definitions
|
||||
SET project_selectable = false,
|
||||
status = CASE WHEN def_key IN ('main', 'product_manager', 'test_manager') THEN status ELSE 'retired' END
|
||||
WHERE def_key NOT IN ('main', 'product_manager', 'test_manager');
|
||||
|
||||
INSERT INTO workflow_executor_registry (executor_key, description) VALUES
|
||||
('bizdev.feasibility_dev.v1', '设计开发可行性评估'),
|
||||
('bizdev.user_requirements.v1', '用户需求表编制')
|
||||
ON CONFLICT (executor_key) DO NOTHING;
|
||||
|
||||
ALTER TABLE project_workflow_instances
|
||||
ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS completed_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS terminated_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS terminated_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS termination_reason TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS previous_instance_id BIGINT REFERENCES project_workflow_instances(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_context_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
task_key TEXT NOT NULL DEFAULT '',
|
||||
cutoff_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
aggregate_hash TEXT NOT NULL DEFAULT '',
|
||||
source_count INT NOT NULL DEFAULT 0,
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
frozen BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_context_snapshots_project
|
||||
ON project_context_snapshots(project_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_context_snapshot_sources (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
snapshot_id BIGINT NOT NULL REFERENCES project_context_snapshots(id) ON DELETE CASCADE,
|
||||
source_type TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL DEFAULT '',
|
||||
source_version TEXT NOT NULL DEFAULT '',
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
authority_level TEXT NOT NULL DEFAULT '',
|
||||
permission_scope TEXT NOT NULL DEFAULT 'project',
|
||||
referenced BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_snapshot_source UNIQUE (snapshot_id, source_type, source_id, source_version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_context_snapshot_sources_snapshot
|
||||
ON project_context_snapshot_sources(snapshot_id);
|
||||
|
||||
ALTER TABLE project_doc_task_instances DROP CONSTRAINT IF EXISTS chk_pdti_status;
|
||||
ALTER TABLE project_doc_task_instances
|
||||
ADD CONSTRAINT chk_pdti_status CHECK (status IN (
|
||||
'pending', 'in_progress', 'reviewing', 'approved', 'skipped', 'skipped_disabled'
|
||||
));
|
||||
|
||||
ALTER TABLE project_doc_task_instances
|
||||
ADD COLUMN IF NOT EXISTS output_doc_title TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS project_context_snapshot_sources;
|
||||
DROP TABLE IF EXISTS project_context_snapshots;
|
||||
ALTER TABLE project_workflow_instances
|
||||
DROP COLUMN IF EXISTS previous_instance_id,
|
||||
DROP COLUMN IF EXISTS termination_reason,
|
||||
DROP COLUMN IF EXISTS terminated_by,
|
||||
DROP COLUMN IF EXISTS terminated_at,
|
||||
DROP COLUMN IF EXISTS completed_by,
|
||||
DROP COLUMN IF EXISTS completed_at;
|
||||
ALTER TABLE workflow_definitions DROP COLUMN IF EXISTS catalog_order;
|
||||
ALTER TABLE workflow_definitions DROP COLUMN IF EXISTS project_selectable;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +goose Up
|
||||
-- 00011 显式插入 users/projects id 后同步序列,避免冷启动 duplicate key
|
||||
BEGIN;
|
||||
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('users', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM users), 1),
|
||||
true
|
||||
);
|
||||
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('projects', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM projects), 1),
|
||||
true
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- +goose Down
|
||||
-- 序列同步不可逆;Down 为空操作
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Preflight checks before Goose 00021+ IAM hardening migrations.
|
||||
-- Run against target environment; fix reported rows before applying FK constraints.
|
||||
|
||||
-- 1. Orphan project_members (user_id not in users)
|
||||
SELECT pm.id, pm.project_id, pm.user_id, pm.project_role, pm.status
|
||||
FROM project_members pm
|
||||
LEFT JOIN users u ON u.id = pm.user_id
|
||||
WHERE u.id IS NULL;
|
||||
|
||||
-- 2. Projects with NULL or demo creator (legacy public visibility)
|
||||
SELECT p.id, p.code, p.name, p.creator_user_id
|
||||
FROM projects p
|
||||
WHERE p.creator_user_id IS NULL OR p.creator_user_id = 1;
|
||||
|
||||
-- 3. Duty assignments without active project membership (after instances exist)
|
||||
SELECT d.id, d.doc_task_instance_id, d.author_user_id, d.reviewer_user_id, d.approver_user_id,
|
||||
wi.project_id
|
||||
FROM document_duty_assignments d
|
||||
JOIN project_doc_task_instances ti ON ti.id = d.doc_task_instance_id
|
||||
JOIN project_stage_instances si ON si.id = ti.stage_instance_id
|
||||
JOIN project_workflow_instances wi ON wi.id = si.workflow_instance_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM project_members pm
|
||||
WHERE pm.project_id = wi.project_id
|
||||
AND pm.user_id IN (d.author_user_id, d.reviewer_user_id, d.approver_user_id)
|
||||
AND pm.status = 'active'
|
||||
);
|
||||
|
||||
-- 4. Stage instances with invalid position (all zero)
|
||||
SELECT workflow_instance_id, stage_key, position, status
|
||||
FROM project_stage_instances
|
||||
GROUP BY workflow_instance_id, stage_key, position, status
|
||||
HAVING position = 0;
|
||||
|
||||
-- 5. Users with role bindings but inactive role
|
||||
SELECT b.user_id, b.role_id, r.status AS role_status
|
||||
FROM user_role_bindings b
|
||||
JOIN system_roles r ON r.id = b.role_id
|
||||
WHERE r.status IS NOT NULL AND r.status <> 'active';
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
DATA_DIR: /app/data/workspaces
|
||||
MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1}
|
||||
MINIMAX_API_KEY: ${MINIMAX_API_KEY:-}
|
||||
MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3}
|
||||
MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed}
|
||||
MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible}
|
||||
MINIMAX_PROVIDER: minimax
|
||||
TAVILY_API_KEYS: ${TAVILY_API_KEYS:-}
|
||||
@@ -102,6 +102,8 @@ services:
|
||||
CURSOR_RUNNER_URL: http://cursor-agent:18765
|
||||
PI_AGENT_RUNNER_URL: http://pi-agent:18865
|
||||
PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-}
|
||||
PI_AGENT_PROVIDER: ${PI_AGENT_PROVIDER:-minimax}
|
||||
PI_AGENT_MODEL: ${PI_AGENT_MODEL:-MiniMax-M2.7-highspeed}
|
||||
RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-}
|
||||
ALLOW_IN_MEMORY: "false"
|
||||
AUX_FILE_DSN: file:/var/lib/feishu/outbox_dev.db?_journal_mode=WAL&_busy_timeout=5000
|
||||
|
||||
@@ -101,6 +101,8 @@ services:
|
||||
PI_AGENT_RUNNER_URL: http://pi-agent:18867
|
||||
PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:?RUNNER_AUTH_TOKEN is required for main/production}
|
||||
RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:?RUNNER_AUTH_TOKEN is required for main/production}
|
||||
KB_DB_PASSWORD: ${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required for main/production}
|
||||
ALLOW_IN_MEMORY: "false"
|
||||
AUX_FILE_DSN: file:/var/lib/feishu/outbox_main.db?_journal_mode=WAL&_busy_timeout=5000
|
||||
ports:
|
||||
|
||||
@@ -72,7 +72,7 @@ func BootstrapDevSeedUsers(ctx context.Context, store AuthStore, cfg *config.Con
|
||||
dept string
|
||||
empID string
|
||||
}{
|
||||
{"zhanggong", "张工", "role-engineer", "方案工程师", "骨科耗材研发组", "RD-001"},
|
||||
{"zhanggong", "张工", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-001"},
|
||||
{"lizhuguan", "李主管", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-002"},
|
||||
{"wangzong", "王总监", "role-director", "研发总监", "研发中心", "RD-003"},
|
||||
{"guest", "访客", "role-guest", "待指派", "外协中心", "EXT-999"},
|
||||
|
||||
@@ -22,6 +22,8 @@ type UserAccount struct {
|
||||
EmployeeID string `json:"employee_id,omitempty"`
|
||||
Department string `json:"department,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"chat-agent-system/internal/config"
|
||||
)
|
||||
|
||||
@@ -43,6 +45,8 @@ type Service struct {
|
||||
store AuthStore
|
||||
sessions SessionStore
|
||||
cfg *config.Config
|
||||
prefMu sync.RWMutex
|
||||
prefs map[int64]map[string]any
|
||||
}
|
||||
|
||||
// NewService 实例化认证服务;sessionStore 为空时使用进程内 MemorySessionStore。
|
||||
@@ -315,3 +319,43 @@ func (s *Service) GetConfig(ctx context.Context) (SystemAuthConfig, error) {
|
||||
func (s *Service) UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error {
|
||||
return s.store.UpdateConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
// UpdateUserProfile 更新当前用户资料字段。
|
||||
func (s *Service) UpdateUserProfile(ctx context.Context, user *UserAccount) (*UserAccount, error) {
|
||||
if user == nil || user.ID <= 0 {
|
||||
return nil, errors.New("invalid user")
|
||||
}
|
||||
updated, err := s.store.UpdateUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated.PasswordHash = ""
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// GetUserPreferences 读取用户偏好(内存/后续 PG user_preferences)。
|
||||
func (s *Service) GetUserPreferences(ctx context.Context, userID int64) (map[string]any, error) {
|
||||
s.prefMu.RLock()
|
||||
defer s.prefMu.RUnlock()
|
||||
if s.prefs == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
if p, ok := s.prefs[userID]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
// SaveUserPreferences 保存用户偏好。
|
||||
func (s *Service) SaveUserPreferences(ctx context.Context, userID int64, body map[string]any) (map[string]any, error) {
|
||||
if userID <= 0 {
|
||||
return nil, errors.New("invalid user id")
|
||||
}
|
||||
s.prefMu.Lock()
|
||||
defer s.prefMu.Unlock()
|
||||
if s.prefs == nil {
|
||||
s.prefs = make(map[int64]map[string]any)
|
||||
}
|
||||
s.prefs[userID] = body
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func NewMemoryStore(cfg *config.Config) *MemoryStore {
|
||||
dept string
|
||||
employeeID string
|
||||
}{
|
||||
{2, "zhanggong", "张工", "role-engineer", "方案工程师", "骨科耗材研发组", "RD-001"},
|
||||
{2, "zhanggong", "张工", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-001"},
|
||||
{3, "lizhuguan", "李主管", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-002"},
|
||||
{4, "wangzong", "王总监", "role-director", "研发总监", "研发中心", "RD-003"},
|
||||
{5, "guest", "访客", "role-guest", "待指派", "外协中心", "EXT-999"},
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestMemoryStore_DevEnvSeedsDemoUsers(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected zhanggong in dev env: %v", err)
|
||||
}
|
||||
if user.RoleID != "role-engineer" {
|
||||
if user.RoleID != "role-tech-lead" {
|
||||
t.Fatalf("unexpected role: %s", user.RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package authz
|
||||
|
||||
// PermissionCatalogEntry 权限目录项。
|
||||
type PermissionCatalogEntry struct {
|
||||
Key string `json:"key"`
|
||||
Resource string `json:"resource"`
|
||||
Action string `json:"action"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionCatalog 返回系统权限目录(与 DB seed 对齐,供前端渲染)。
|
||||
func PermissionCatalog() []PermissionCatalogEntry {
|
||||
return []PermissionCatalogEntry{
|
||||
{Key: "project:create", Resource: "project", Action: "create", RiskLevel: "normal", Description: "创建新项目"},
|
||||
{Key: "project:assign_lead", Resource: "project", Action: "assign_lead", RiskLevel: "elevated", Description: "指派项目负责人"},
|
||||
{Key: PermManage, Resource: "system", Action: "manage", RiskLevel: "critical", Description: "系统权限管理"},
|
||||
{Key: "workflow:config_visibility", Resource: "workflow", Action: "config_visibility", RiskLevel: "elevated"},
|
||||
{Key: "doc:override_visibility", Resource: "document", Action: "override_visibility", RiskLevel: "elevated"},
|
||||
{Key: "doc:export", Resource: "document", Action: "export", RiskLevel: "normal"},
|
||||
{Key: "workflow:view_all", Resource: "workflow", Action: "view_all", RiskLevel: "normal"},
|
||||
{Key: "workflow_defs.publish", Resource: "workflow_defs", Action: "publish", RiskLevel: "elevated"},
|
||||
{Key: "templates.publish", Resource: "templates", Action: "publish", RiskLevel: "elevated"},
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ func (s *Service) enabled() bool {
|
||||
return s != nil && s.pool != nil
|
||||
}
|
||||
|
||||
// Enabled 暴露 RBAC 是否已连接数据库。
|
||||
func (s *Service) Enabled() bool {
|
||||
return s.enabled()
|
||||
}
|
||||
|
||||
// ListCapabilities 返回用户系统级 permission key 列表。
|
||||
func (s *Service) ListCapabilities(ctx context.Context, userID int64) ([]string, error) {
|
||||
if userID <= 0 {
|
||||
@@ -34,9 +39,13 @@ func (s *Service) ListCapabilities(ctx context.Context, userID int64) ([]string,
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT p.perm_key
|
||||
FROM user_role_bindings b
|
||||
JOIN system_roles sr ON sr.id = b.role_id AND sr.status = 'active'
|
||||
JOIN system_role_permissions rp ON rp.role_id = b.role_id AND rp.effect = 'allow'
|
||||
JOIN system_permissions p ON p.id = rp.permission_id
|
||||
WHERE b.user_id = $1
|
||||
AND b.scope_type = 'system'
|
||||
AND b.scope_id = 0
|
||||
AND b.valid_from <= now()
|
||||
AND (b.valid_until IS NULL OR b.valid_until > now())
|
||||
ORDER BY p.perm_key
|
||||
`, userID)
|
||||
|
||||
@@ -121,12 +121,15 @@ func TestBizDev_FullLink_V01_V05_V10_E2E(t *testing.T) {
|
||||
if !resV05.Success {
|
||||
t.Fatalf("Stage V0.5 should succeed, got error: %s", resV05.ErrorReason)
|
||||
}
|
||||
if len(resV05.SubArtifacts) != 3 {
|
||||
t.Fatalf("Stage V0.5 should produce 3 sub-artifacts (research, feasibility_reg, prelim_design), got %d", len(resV05.SubArtifacts))
|
||||
if len(resV05.SubArtifacts) != 5 {
|
||||
t.Fatalf("Stage V0.5 should produce 5 sub-artifacts, got %d", len(resV05.SubArtifacts))
|
||||
}
|
||||
t.Logf("✓ Stage V0.5 成功完成: 包含任务=[%s, %s, %s], 成熟度评分=%d (%s)",
|
||||
resV05.SubArtifacts[0].TaskKey, resV05.SubArtifacts[1].TaskKey, resV05.SubArtifacts[2].TaskKey,
|
||||
resV05.AdvisorBrief.MaturityScore, resV05.AdvisorBrief.MaturityLevel)
|
||||
keys := make([]string, len(resV05.SubArtifacts))
|
||||
for i, sub := range resV05.SubArtifacts {
|
||||
keys[i] = sub.TaskKey
|
||||
}
|
||||
t.Logf("✓ Stage V0.5 成功完成: 包含任务=%v, 成熟度评分=%d (%s)",
|
||||
keys, resV05.AdvisorBrief.MaturityScore, resV05.AdvisorBrief.MaturityLevel)
|
||||
|
||||
// 验证 feasibility_reg 受控文档已建立
|
||||
docID := "doc_proj1001_feasibility_reg"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package bizdev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
)
|
||||
|
||||
type snapshotDialogueInputs struct {
|
||||
Dialogue []string
|
||||
Decisions []string
|
||||
ExcludedPrivate int
|
||||
ExcludedNoAccess int
|
||||
}
|
||||
|
||||
func (s *Service) collectSnapshotDialogueInputs(ctx context.Context, projectID, userID int64) snapshotDialogueInputs {
|
||||
out := snapshotDialogueInputs{}
|
||||
if s == nil || projectID <= 0 || s.ctxMgr == nil {
|
||||
return out
|
||||
}
|
||||
|
||||
if coll, err := s.getProjectContextForUser(ctx, projectID, userID); err == nil && coll != nil {
|
||||
for _, d := range coll.MilestoneDecisions {
|
||||
out.Decisions = append(out.Decisions, fmt.Sprintf("[%s] %s (%s)", d.ActivityType, d.Title, d.UserName))
|
||||
}
|
||||
}
|
||||
|
||||
dialogue, err := s.ctxMgr.CollectProjectDialogue(ctx, projectID, ctxmgr.CollectProjectDialogueOptions{
|
||||
Limit: 120,
|
||||
})
|
||||
if err != nil || dialogue == nil {
|
||||
return out
|
||||
}
|
||||
out.Dialogue = dialogue.Lines
|
||||
out.ExcludedPrivate = dialogue.ExcludedPrivate
|
||||
out.ExcludedNoAccess = dialogue.ExcludedNoAccess
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package bizdev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/projectcontext"
|
||||
"chat-agent-system/internal/replyexport"
|
||||
)
|
||||
|
||||
func TestCollectSnapshotDialogueInputs_FeedsBuildSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig())
|
||||
projectID := int64(601)
|
||||
meta := map[string]any{"project_id": projectID}
|
||||
|
||||
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-601", 1, "user", "测试经理需要覆盖注册分类路径", "", "", meta)
|
||||
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-601", 1, "assistant", "我会整理测试验证范围与风险点。", "", "", map[string]any{
|
||||
"project_id": projectID,
|
||||
"export_policy": string(replyexport.PolicyFull),
|
||||
"content_kind": string(replyexport.KindDocumentText),
|
||||
})
|
||||
_, _ = mgr.RecordActivity(ctx, projectID, 1, "张工", "decision", "确认先做注册分类调研", map[string]any{}, 1, true)
|
||||
|
||||
svc := &Service{ctxMgr: mgr}
|
||||
inputs := svc.collectSnapshotDialogueInputs(ctx, projectID, 1)
|
||||
if len(inputs.Dialogue) == 0 {
|
||||
t.Fatalf("expected dialogue lines, got none")
|
||||
}
|
||||
if len(inputs.Decisions) != 1 {
|
||||
t.Fatalf("expected 1 decision, got %d", len(inputs.Decisions))
|
||||
}
|
||||
|
||||
ctxSvc := projectcontext.NewService(nil)
|
||||
snap, err := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{
|
||||
ProjectID: projectID,
|
||||
TaskKey: "test_manager",
|
||||
Dialogue: inputs.Dialogue,
|
||||
Decisions: inputs.Decisions,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSnapshot: %v", err)
|
||||
}
|
||||
if snap.SourceCount < 2 {
|
||||
t.Fatalf("expected dialogue + decision sources, got %d", snap.SourceCount)
|
||||
}
|
||||
}
|
||||
+58
-27
@@ -19,6 +19,7 @@ import (
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/freedoc"
|
||||
"chat-agent-system/internal/kb"
|
||||
"chat-agent-system/internal/projectcontext"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/squad"
|
||||
"chat-agent-system/internal/storage"
|
||||
@@ -158,7 +159,7 @@ func NewService(cfg *config.Config) (*Service, error) {
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// SchemaVersion 返回当前数据库 schema 版本(goose)。
|
||||
// SchemaVersion 返回启动时缓存的数据库 schema 版本(goose)。
|
||||
func (s *Service) SchemaVersion() int64 {
|
||||
if s == nil {
|
||||
return 0
|
||||
@@ -166,6 +167,21 @@ func (s *Service) SchemaVersion() int64 {
|
||||
return s.schemaVersion
|
||||
}
|
||||
|
||||
// LiveSchemaVersion 从数据库实时读取当前 schema 版本;失败时回退缓存值。
|
||||
func (s *Service) LiveSchemaVersion(ctx context.Context) int64 {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
if s.pool == nil {
|
||||
return s.schemaVersion
|
||||
}
|
||||
ver, err := db.CurrentVersion(ctx, s.pool)
|
||||
if err != nil {
|
||||
return s.schemaVersion
|
||||
}
|
||||
return ver
|
||||
}
|
||||
|
||||
// DatabaseReady 数据库是否已连接且迁移完成。
|
||||
func (s *Service) DatabaseReady() bool {
|
||||
return s != nil && s.pool != nil
|
||||
@@ -212,7 +228,7 @@ var SupportedStages = []StageMeta{
|
||||
Stage: StageV05_Feasibility,
|
||||
Title: "V0.5 方案成型与法规对齐",
|
||||
Description: "法规与注册路径初筛、竞品调研、系统初步架构设计与关键选型建议",
|
||||
Tasks: []string{"research", "feasibility_reg", "prelim_design"},
|
||||
Tasks: []string{"research", "feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design"},
|
||||
},
|
||||
{
|
||||
Stage: StageV10_Quoting,
|
||||
@@ -346,36 +362,51 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
ErrorReason: docErr.Error(),
|
||||
}, docErr
|
||||
}
|
||||
// 智能测试经理前置检查:必须依赖同项目已批准的系统需求规格书 (system_reqs)
|
||||
// 智能测试工程师:统一项目上下文快照;SRS 仅为可选高权威来源
|
||||
var upstreamSRDoc *docgov.DocumentRecord
|
||||
if req.TaskKey == "test_manager" {
|
||||
upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID)
|
||||
if s.docGov != nil && req.ProjectID > 0 {
|
||||
if gateErr := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, "system_reqs"); gateErr == nil {
|
||||
doc, err := s.docGov.GetDocument(ctx, upstreamDocID)
|
||||
if err == nil && doc != nil && doc.CurrentState == docgov.StateApproved {
|
||||
upstreamSRDoc = doc
|
||||
if req.TaskKey == "test_manager" || req.TaskKey == "system_reqs" {
|
||||
priorDocs := make([]squad.PriorDocItem, 0)
|
||||
facts := map[string]string{}
|
||||
if req.Fields != nil {
|
||||
for k, v := range req.Fields {
|
||||
facts[k] = v
|
||||
}
|
||||
}
|
||||
if s.ctxMgr != nil && req.ProjectID > 0 {
|
||||
if coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && coll != nil {
|
||||
for k, v := range coll.BlackboardFacts {
|
||||
facts[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if upstreamSRDoc == nil {
|
||||
return &TaskRunResult{
|
||||
TaskKey: req.TaskKey,
|
||||
ArtifactTitle: artifactTitle,
|
||||
WorkDir: req.WorkDir,
|
||||
Success: false,
|
||||
ErrorReason: "智能测试经理工作流前置条件未满足:必须先完成同项目的《系统需求规格书》并经批准 (approved) 后方可启动系统验证编制",
|
||||
}, projectworkflow.ErrPrerequisiteNotMet
|
||||
upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID)
|
||||
if s.docGov != nil && req.ProjectID > 0 {
|
||||
if doc, err := s.docGov.GetDocument(ctx, upstreamDocID); err == nil && doc != nil && doc.CurrentState == docgov.StateApproved {
|
||||
upstreamSRDoc = doc
|
||||
if doc.WorkDir != "" {
|
||||
contentPath := filepath.Join(doc.WorkDir, "output.md")
|
||||
if b, readErr := os.ReadFile(contentPath); readErr == nil && len(b) > 0 {
|
||||
priorDocs = append(priorDocs, squad.PriorDocItem{
|
||||
DocKey: "system_reqs", Title: "系统需求规格书", Content: string(b), Stage: "approved",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自动将已批准系统需求规格书注入为测试方案编制的依据输入
|
||||
if len(materials) == 0 && upstreamSRDoc.WorkDir != "" {
|
||||
contentPath := filepath.Join(upstreamSRDoc.WorkDir, "output.md")
|
||||
if b, err := os.ReadFile(contentPath); err == nil && len(b) > 0 {
|
||||
materials = append(materials, squad.MaterialItem{
|
||||
Filename: "upstream_system_requirements.md",
|
||||
Content: b,
|
||||
})
|
||||
dialogueInputs := s.collectSnapshotDialogueInputs(ctx, req.ProjectID, req.UserID)
|
||||
ctxSvc := projectcontext.NewService(s.pool)
|
||||
snap, _ := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{
|
||||
ProjectID: req.ProjectID, TaskKey: req.TaskKey, UserID: req.UserID,
|
||||
ProjectName: req.ProjectName, Facts: facts, Materials: materials, PriorDocs: priorDocs,
|
||||
Knowledge: hits, Dialogue: dialogueInputs.Dialogue, Decisions: dialogueInputs.Decisions,
|
||||
})
|
||||
if snap != nil {
|
||||
snap.ExcludedPrivateCount += dialogueInputs.ExcludedPrivate
|
||||
snap.ExcludedNoAccessCount += dialogueInputs.ExcludedNoAccess
|
||||
}
|
||||
if snap != nil {
|
||||
if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 {
|
||||
materials = append(materials, extra...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,9 +145,9 @@ func TestExecuteStageV01AndV05(t *testing.T) {
|
||||
if stage2Res.AdvisorBrief.MaturityLevel != "V0.5" {
|
||||
t.Errorf("expected V0.5 maturity level, got %s", stage2Res.AdvisorBrief.MaturityLevel)
|
||||
}
|
||||
// Stage 2 包含 3 个任务 (research, feasibility_reg, prelim_design)
|
||||
if len(stage2Res.SubArtifacts) != 3 {
|
||||
t.Errorf("expected 3 sub artifacts in stage 2, got %d", len(stage2Res.SubArtifacts))
|
||||
// Stage V0.5 包含 5 个任务(与 SupportedStages 一致)
|
||||
if len(stage2Res.SubArtifacts) != 5 {
|
||||
t.Errorf("expected 5 sub artifacts in stage 2, got %d", len(stage2Res.SubArtifacts))
|
||||
}
|
||||
|
||||
// 3. 运行 Stage 3 (V1.0 Quoting),透传全部前序累积
|
||||
|
||||
@@ -522,28 +522,35 @@ func TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord(t *testing.T) {
|
||||
t.Errorf("expected snippet to contain YY 0505, got: %s", session.UserCtx.RecalledMemorySnippet)
|
||||
}
|
||||
|
||||
// 4. 等待异步写入 goroutine 落盘
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 5. 验证本轮当场生成的问答对是否已被向量记忆库索引
|
||||
results, err := ctxMgr.SearchChatMemory(ctx, ctxmgr.ChatMemorySearchOptions{
|
||||
Query: "电磁兼容样机数量",
|
||||
UserID: 99,
|
||||
ProjectID: 200,
|
||||
TopK: 5,
|
||||
MinScore: 0.1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchChatMemory failed: %v", err)
|
||||
}
|
||||
|
||||
// 4. 等待异步写入 goroutine 落盘(轮询至 deadline,避免固定 sleep 竞态)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var results []ctxmgr.RecalledMemoryItem
|
||||
foundCurrentTurn := false
|
||||
for _, r := range results {
|
||||
if r.SessionID == "sess_current_recall" {
|
||||
foundCurrentTurn = true
|
||||
for time.Now().Before(deadline) {
|
||||
var searchErr error
|
||||
results, searchErr = ctxMgr.SearchChatMemory(ctx, ctxmgr.ChatMemorySearchOptions{
|
||||
Query: "电磁兼容样机数量",
|
||||
UserID: 99,
|
||||
ProjectID: 200,
|
||||
TopK: 5,
|
||||
MinScore: 0.1,
|
||||
})
|
||||
if searchErr != nil {
|
||||
t.Fatalf("SearchChatMemory failed: %v", searchErr)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.SessionID == "sess_current_recall" {
|
||||
foundCurrentTurn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundCurrentTurn {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 5. 验证本轮当场生成的问答对是否已被向量记忆库索引
|
||||
if !foundCurrentTurn {
|
||||
t.Errorf("expected current turn to be asynchronously indexed into chat memory")
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@ func Load(path string) (*Config, error) {
|
||||
CursorBridgeBin: "tools/cursor-bridge/index.mjs",
|
||||
PiAgentBin: "/opt/homebrew/bin/pi",
|
||||
PiAgentBridgeBin: "tools/pi-bridge/index.mjs",
|
||||
PiAgentProvider: "cc-switch-kimi-for-coding",
|
||||
PiAgentModel: "kimi-for-coding",
|
||||
PiAgentProvider: "minimax",
|
||||
PiAgentModel: "MiniMax-M2.7-highspeed",
|
||||
KbDbHost: "127.0.0.1",
|
||||
KbDbPort: 5432,
|
||||
KbDbUser: "regkb",
|
||||
|
||||
@@ -93,6 +93,19 @@ func (e *CoordinatorEngine) executeIntentPlan(
|
||||
if pendingConfirm != nil && userCtx.PendingExpectation != nil {
|
||||
last.NextExpectation = userCtx.PendingExpectation
|
||||
}
|
||||
|
||||
slots := map[string]string{}
|
||||
if recRes.TopIntent != nil && len(recRes.TopIntent.FilledSlots) > 0 {
|
||||
slots = recRes.TopIntent.FilledSlots
|
||||
}
|
||||
if last.AdvisorBrief == nil {
|
||||
last.AdvisorBrief = userCtx.LatestAdvisorBrief
|
||||
}
|
||||
if last.AdvisorBrief == nil {
|
||||
last.AdvisorBrief = synthesizeBaselineBrief(userCtx)
|
||||
}
|
||||
e.enrichAdvisorBriefWithCases(ctx, last.AdvisorBrief, userCtx, slots)
|
||||
|
||||
return last, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ func TestEngine_IntentPlan_QueryThenQuery(t *testing.T) {
|
||||
if !strings.Contains(res.NaturalReply, "步骤") {
|
||||
t.Errorf("expected multi-step reply, got: %s", res.NaturalReply)
|
||||
}
|
||||
if res.AdvisorBrief == nil {
|
||||
t.Fatal("expected AdvisorBrief on multi-intent plan result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_ExpectationPlusNew_ExecutesQueryKeepsConfirm(t *testing.T) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/kb"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/squad"
|
||||
"chat-agent-system/internal/stream"
|
||||
)
|
||||
@@ -63,11 +65,19 @@ type CoordinationResult struct {
|
||||
// CoordinatorEngine L2 协调引擎
|
||||
type CoordinatorEngine struct {
|
||||
bizdevSvc bizdev.BizdevService
|
||||
instSvc *projectinstance.Service
|
||||
assembler *ContextAssembler
|
||||
dialogueBackend agent.Backend
|
||||
dataDir string
|
||||
}
|
||||
|
||||
// SetInstanceService 注入项目工作流实例服务(对话推进与实例门禁合流)。
|
||||
func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) {
|
||||
if e != nil {
|
||||
e.instSvc = svc
|
||||
}
|
||||
}
|
||||
|
||||
func NewCoordinatorEngine(bizdevSvc bizdev.BizdevService) *CoordinatorEngine {
|
||||
return &CoordinatorEngine{
|
||||
bizdevSvc: bizdevSvc,
|
||||
@@ -670,6 +680,59 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u
|
||||
}
|
||||
|
||||
func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
|
||||
if e.instSvc != nil && e.instSvc.Available() && userCtx != nil && userCtx.Project.ID != "" {
|
||||
projectID, parseErr := strconv.ParseInt(userCtx.Project.ID, 10, 64)
|
||||
userID, userErr := strconv.ParseInt(userCtx.User.ID, 10, 64)
|
||||
if parseErr == nil && userErr == nil && projectID > 0 && userID > 0 {
|
||||
isCreator := userCtx.Project.CreatorUserID > 0 && userID == userCtx.Project.CreatorUserID
|
||||
settings, settingsErr := e.instSvc.GetSettings(ctx, projectID, userID, isCreator)
|
||||
hasLiveGovernance := settingsErr == nil && settings != nil && settings.LiveInstance != nil &&
|
||||
settings.LiveInstance.Status != projectinstance.InstanceCompleted &&
|
||||
settings.LiveInstance.Status != "terminated"
|
||||
|
||||
if hasLiveGovernance {
|
||||
if !isCreator {
|
||||
return &CoordinationResult{
|
||||
Status: StatusRejected,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
RejectReason: projectinstance.ErrForbiddenNotCreator.Error(),
|
||||
NaturalReply: "只有项目创建者可以确认推进工作流阶段。",
|
||||
UpdatedContext: userCtx,
|
||||
}, projectinstance.ErrForbiddenNotCreator
|
||||
}
|
||||
res, _, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID)
|
||||
if advErr != nil {
|
||||
return &CoordinationResult{
|
||||
Status: StatusRejected,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
RejectReason: advErr.Error(),
|
||||
NaturalReply: fmt.Sprintf("阶段推进未通过门禁:%v", advErr),
|
||||
UpdatedContext: userCtx,
|
||||
}, advErr
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if isCreator {
|
||||
if res, handled, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID); handled {
|
||||
if advErr != nil {
|
||||
return &CoordinationResult{
|
||||
Status: StatusRejected,
|
||||
Intent: cand.Intent,
|
||||
Branch: cand.Branch,
|
||||
RejectReason: advErr.Error(),
|
||||
NaturalReply: fmt.Sprintf("阶段推进未通过门禁:%v", advErr),
|
||||
UpdatedContext: userCtx,
|
||||
}, advErr
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetStageStr := cand.FilledSlots[intent.SlotTargetStage]
|
||||
targetStage := bizdev.MaturityStage(targetStageStr)
|
||||
if targetStage == "" {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
)
|
||||
|
||||
func (e *CoordinatorEngine) tryAdvanceWorkflowInstance(ctx context.Context, projectID int64, userID int64) (*CoordinationResult, bool, error) {
|
||||
if e == nil || e.instSvc == nil || !e.instSvc.Available() {
|
||||
return nil, false, nil
|
||||
}
|
||||
live, err := e.instSvc.GetSettings(ctx, projectID, userID, true)
|
||||
if err != nil || live == nil || live.LiveInstance == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
stageKey := live.ActiveStageKey
|
||||
if stageKey == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
readiness, err := e.instSvc.GetStageReadiness(ctx, live.LiveInstance.ID, stageKey, userID, true)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if !readiness.Ready {
|
||||
return &CoordinationResult{
|
||||
Status: StatusRejected,
|
||||
Intent: intent.IntentAdvanceStage,
|
||||
RejectReason: "STAGE_GATE_NOT_READY",
|
||||
NaturalReply: fmt.Sprintf("当前阶段尚未满足推进条件,仍有 %d 项阻断。", len(readiness.BlockingIssues)),
|
||||
}, true, projectinstance.ErrStageGateNotReady
|
||||
}
|
||||
result, err := e.instSvc.AdvanceStage(ctx, live.LiveInstance.ID, userID, true, projectinstance.AdvanceStageInput{
|
||||
StageKey: stageKey,
|
||||
ExpectedConfigRevision: readiness.ConfigRevision,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
reply := fmt.Sprintf("已确认推进阶段【%s】。", stageKey)
|
||||
if result.InstanceDone {
|
||||
reply = "工作流已完成。您现在可以配置下一个工作流。"
|
||||
} else if result.NextStageKey != "" {
|
||||
reply = fmt.Sprintf("已确认推进至下一阶段【%s】。", result.NextStageKey)
|
||||
}
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: intent.IntentAdvanceStage,
|
||||
NaturalReply: reply,
|
||||
}, true, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/kb"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/squad"
|
||||
"chat-agent-system/internal/storage"
|
||||
)
|
||||
|
||||
type countingBizdev struct {
|
||||
stageCalls int
|
||||
}
|
||||
|
||||
func (c *countingBizdev) ListTasks() []bizdev.TaskMeta { return nil }
|
||||
func (c *countingBizdev) ListStages() []bizdev.StageMeta { return nil }
|
||||
func (c *countingBizdev) ExecuteTask(_ context.Context, _ bizdev.TaskRunRequest) (*bizdev.TaskRunResult, error) {
|
||||
return &bizdev.TaskRunResult{}, nil
|
||||
}
|
||||
func (c *countingBizdev) ExecuteStage(_ context.Context, _ bizdev.StageRunRequest) (*bizdev.StageRunResult, error) {
|
||||
c.stageCalls++
|
||||
return &bizdev.StageRunResult{StageTitle: "legacy"}, nil
|
||||
}
|
||||
func (c *countingBizdev) InspectWorkspace(_ context.Context, _ string) (*squad.WorkspaceState, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *countingBizdev) ResumeOrModifyWorkspace(_ context.Context, _ squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *countingBizdev) ContextManager() *ctxmgr.ContextManager { return nil }
|
||||
func (c *countingBizdev) CAS() *storage.CAS { return nil }
|
||||
func (c *countingBizdev) DocGov() *docgov.GovernanceEngine { return nil }
|
||||
func (c *countingBizdev) KB() *kb.Client { return nil }
|
||||
|
||||
func TestDialogueAdvanceStage_LiveInstanceDoesNotUseBizdev(t *testing.T) {
|
||||
store := projectinstance.NewMemoryStore()
|
||||
_ = projectinstance.SeedProjectMeta(store, 55, projectinstance.LifecyclePlanning, "", 1, 0)
|
||||
instSvc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, &projectinstance.StubDocChecker{States: map[string]string{"intake": "approved"}})
|
||||
|
||||
ctx := context.Background()
|
||||
_, _ = instSvc.SaveWorkflowPlan(ctx, 55, 1, true, projectinstance.SavePlanInput{
|
||||
Plan: projectinstance.ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
preview, _ := instSvc.ActivationPreview(ctx, 55, 1, true)
|
||||
_, _ = instSvc.Activate(ctx, 55, 1, true, projectinstance.ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
|
||||
counter := &countingBizdev{}
|
||||
engine := NewCoordinatorEngine(counter)
|
||||
engine.SetInstanceService(instSvc)
|
||||
|
||||
userCtx := &UserProjectContext{
|
||||
Project: ProjectInfo{ID: "55", CreatorUserID: 1, CurrentStage: bizdev.StageV01_Triage},
|
||||
User: UserInfo{ID: "1", ProjectRole: "owner"},
|
||||
}
|
||||
recRes := &intent.IntentRecognitionResult{
|
||||
TopIntent: &intent.CandidateIntent{
|
||||
Intent: intent.IntentAdvanceStage,
|
||||
FilledSlots: map[string]string{
|
||||
intent.SlotTargetStage: string(bizdev.StageV05_Feasibility),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
res, err := engine.ProcessIntent(ctx, recRes, userCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessIntent: %v", err)
|
||||
}
|
||||
if res.Status != StatusAccepted {
|
||||
t.Fatalf("expected accepted via instance service, got %s (%s)", res.Status, res.RejectReason)
|
||||
}
|
||||
if counter.stageCalls != 0 {
|
||||
t.Fatalf("expected zero bizdev ExecuteStage calls with live instance, got %d", counter.stageCalls)
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,13 @@ func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID
|
||||
}
|
||||
m.sessions[sessionID] = sessSum
|
||||
}
|
||||
if meta != nil {
|
||||
if pVal, ok := meta["project_id"].(int64); ok && pVal > 0 {
|
||||
sessSum.ProjectID = pVal
|
||||
} else if pValFloat, ok := meta["project_id"].(float64); ok && pValFloat > 0 {
|
||||
sessSum.ProjectID = int64(pValFloat)
|
||||
}
|
||||
}
|
||||
sessSum.MessageCount = len(m.messages[sessionID])
|
||||
sessSum.LastMessage = content
|
||||
sessSum.UpdatedAt = time.Now()
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package ctxmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/replyexport"
|
||||
)
|
||||
|
||||
const defaultProjectDialogueLimit = 120
|
||||
|
||||
// ProjectDialogueCollectResult 项目级对话采集结果(供上下文快照使用)。
|
||||
type ProjectDialogueCollectResult struct {
|
||||
Lines []string
|
||||
ExcludedPrivate int
|
||||
ExcludedNoAccess int
|
||||
AllowedStageKey string
|
||||
StageFilteredCount int
|
||||
}
|
||||
|
||||
// CollectProjectDialogueOptions 采集选项。
|
||||
type CollectProjectDialogueOptions struct {
|
||||
Limit int
|
||||
AllowedStageKey string // 非空时仅保留无 stage_key 或匹配的对话
|
||||
}
|
||||
|
||||
// CollectProjectDialogue 收集进入项目知识边界的共享对话行。
|
||||
func (m *ContextManager) CollectProjectDialogue(ctx context.Context, projectID int64, opts CollectProjectDialogueOptions) (*ProjectDialogueCollectResult, error) {
|
||||
out := &ProjectDialogueCollectResult{AllowedStageKey: opts.AllowedStageKey}
|
||||
if m == nil || projectID <= 0 {
|
||||
return out, nil
|
||||
}
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultProjectDialogueLimit
|
||||
}
|
||||
|
||||
var msgs []ChatMessageRecord
|
||||
if m.pool != nil {
|
||||
rows, err := m.pool.Query(ctx, `
|
||||
SELECT cm.id, cm.session_id, COALESCE(cm.user_id, 0), cm.role, cm.content,
|
||||
cm.intent, cm.branch, cm.metadata, COALESCE(cm.day_key::text, ''),
|
||||
COALESCE(cm.category, ''), COALESCE(cm.content_kind, ''),
|
||||
COALESCE(cm.export_policy, 'none'), cm.created_at
|
||||
FROM chat_messages cm
|
||||
JOIN chat_sessions cs ON cs.session_id = cm.session_id
|
||||
WHERE cs.project_id = $1 AND cm.deleted_at IS NULL
|
||||
ORDER BY cm.created_at ASC
|
||||
LIMIT $2
|
||||
`, projectID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collect project dialogue: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var rec ChatMessageRecord
|
||||
var metaJSON []byte
|
||||
if err := rows.Scan(
|
||||
&rec.ID, &rec.SessionID, &rec.UserID, &rec.Role, &rec.Content,
|
||||
&rec.Intent, &rec.Branch, &metaJSON, &rec.DayKey, &rec.Category,
|
||||
&rec.ContentKind, &rec.ExportPolicy, &rec.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(metaJSON) > 0 {
|
||||
_ = json.Unmarshal(metaJSON, &rec.Metadata)
|
||||
}
|
||||
msgs = append(msgs, rec)
|
||||
}
|
||||
} else {
|
||||
m.mu.RLock()
|
||||
for sessionID, sessionMsgs := range m.messages {
|
||||
sess := m.sessions[sessionID]
|
||||
if sess != nil && sess.ProjectID != projectID {
|
||||
continue
|
||||
}
|
||||
for _, rec := range sessionMsgs {
|
||||
if sess == nil || sess.ProjectID == 0 {
|
||||
if pid := messageProjectID(rec); pid != projectID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, rec)
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
}
|
||||
|
||||
for _, rec := range msgs {
|
||||
line, include, excludedPrivate, excludedNoAccess := classifyDialogueLine(rec, opts.AllowedStageKey)
|
||||
if excludedPrivate {
|
||||
out.ExcludedPrivate++
|
||||
}
|
||||
if excludedNoAccess {
|
||||
out.ExcludedNoAccess++
|
||||
out.StageFilteredCount++
|
||||
}
|
||||
if !include || line == "" {
|
||||
continue
|
||||
}
|
||||
out.Lines = append(out.Lines, line)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func messageProjectID(rec ChatMessageRecord) int64 {
|
||||
if rec.Metadata == nil {
|
||||
return 0
|
||||
}
|
||||
if pVal, ok := rec.Metadata["project_id"].(int64); ok {
|
||||
return pVal
|
||||
}
|
||||
if pVal, ok := rec.Metadata["project_id"].(float64); ok {
|
||||
return int64(pVal)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func classifyDialogueLine(rec ChatMessageRecord, allowedStageKey string) (line string, include bool, excludedPrivate bool, excludedNoAccess bool) {
|
||||
content := strings.TrimSpace(rec.Content)
|
||||
if content == "" {
|
||||
return "", false, true, false
|
||||
}
|
||||
if strings.HasPrefix(content, "[private]") || strings.HasPrefix(content, "[draft]") {
|
||||
return "", false, true, false
|
||||
}
|
||||
if vis, _ := rec.Metadata["visibility"].(string); vis == "private" {
|
||||
return "", false, true, false
|
||||
}
|
||||
|
||||
stageKey := MessageStageKey(rec.Metadata)
|
||||
if allowedStageKey != "" && stageKey != "" && stageKey != allowedStageKey {
|
||||
return "", false, false, true
|
||||
}
|
||||
|
||||
switch rec.Role {
|
||||
case "user":
|
||||
line = fmt.Sprintf("用户: %s", content)
|
||||
return line, true, false, false
|
||||
case "assistant":
|
||||
switch rec.ContentKind {
|
||||
case string(replyexport.KindActionCard), string(replyexport.KindStatusUpdate),
|
||||
string(replyexport.KindErrorNotice), string(replyexport.KindSystemNotice):
|
||||
return "", false, true, false
|
||||
}
|
||||
switch rec.ExportPolicy {
|
||||
case string(replyexport.PolicyFull), string(replyexport.PolicyCopyOnly):
|
||||
line = fmt.Sprintf("助手: %s", content)
|
||||
return line, true, false, false
|
||||
default:
|
||||
return "", false, true, false
|
||||
}
|
||||
default:
|
||||
return "", false, true, false
|
||||
}
|
||||
}
|
||||
|
||||
// MessageStageKey 从消息 metadata 提取阶段键。
|
||||
func MessageStageKey(metadata map[string]any) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
if sk, ok := metadata["stage_key"].(string); ok {
|
||||
return sk
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package ctxmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/replyexport"
|
||||
)
|
||||
|
||||
func TestCollectProjectDialogue_ExcludesPrivateAndIncludesShared(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
|
||||
projectID := int64(501)
|
||||
meta := map[string]any{"project_id": projectID}
|
||||
|
||||
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "[private] 不应纳入", "", "", meta)
|
||||
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "项目需要支持多用户协作", "", "", meta)
|
||||
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "assistant", "好的,我会整理需求要点。", "", "", map[string]any{
|
||||
"project_id": projectID,
|
||||
"export_policy": string(replyexport.PolicyFull),
|
||||
"content_kind": string(replyexport.KindDocumentText),
|
||||
})
|
||||
|
||||
res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("CollectProjectDialogue: %v", err)
|
||||
}
|
||||
if len(res.Lines) != 2 {
|
||||
t.Fatalf("expected 2 shared lines, got %d: %v", len(res.Lines), res.Lines)
|
||||
}
|
||||
if res.ExcludedPrivate < 1 {
|
||||
t.Fatalf("expected excluded private >= 1, got %d", res.ExcludedPrivate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectProjectDialogue_StageFilter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
|
||||
projectID := int64(502)
|
||||
|
||||
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-502", 1, "user", "立项阶段讨论", "", "", map[string]any{
|
||||
"project_id": projectID, "stage_key": "biz_intake",
|
||||
})
|
||||
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-502", 1, "user", "研发阶段讨论", "", "", map[string]any{
|
||||
"project_id": projectID, "stage_key": "biz_feasibility",
|
||||
})
|
||||
|
||||
res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{
|
||||
AllowedStageKey: "biz_intake",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CollectProjectDialogue: %v", err)
|
||||
}
|
||||
if len(res.Lines) != 1 {
|
||||
t.Fatalf("expected 1 stage-filtered line, got %d: %v", len(res.Lines), res.Lines)
|
||||
}
|
||||
if res.ExcludedNoAccess != 1 {
|
||||
t.Fatalf("expected 1 excluded by stage, got %d", res.ExcludedNoAccess)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testDatabaseEnv = "TEST_DATABASE_URL"
|
||||
|
||||
// RequireTestDatabaseURL 集成测试必须显式提供隔离测试库,禁止默认连开发库。
|
||||
func RequireTestDatabaseURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv))
|
||||
if dsn == "" {
|
||||
t.Fatalf("%s must be set for PostgreSQL integration tests (refusing default dev database)", testDatabaseEnv)
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// SkipIfNoTestDatabaseURL 无隔离测试库时跳过(单元测试包内可选 PG 用例)。
|
||||
func SkipIfNoTestDatabaseURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv))
|
||||
if dsn == "" {
|
||||
t.Skipf("%s not set; skipping PostgreSQL integration test", testDatabaseEnv)
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package kb_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -11,6 +12,12 @@ import (
|
||||
)
|
||||
|
||||
func TestKB_RealIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping KB real integration in short mode")
|
||||
}
|
||||
if os.Getenv("RUN_KB_INTEGRATION") != "1" {
|
||||
t.Skip("set RUN_KB_INTEGRATION=1 to run KB real integration")
|
||||
}
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package projectcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthorityConfirmedFact = "confirmed_fact"
|
||||
AuthorityApprovedDoc = "approved_document"
|
||||
AuthorityUpload = "upload_material"
|
||||
AuthorityMilestone = "milestone"
|
||||
AuthorityDialogue = "dialogue"
|
||||
AuthorityKnowledge = "knowledge"
|
||||
)
|
||||
|
||||
// Source 来源清单条目(全量枚举,按任务检索后分级注入)。
|
||||
type Source struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version,omitempty"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
AuthorityLevel string `json:"authority_level"`
|
||||
PermissionScope string `json:"permission_scope"`
|
||||
Referenced bool `json:"referenced"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot 不可变项目上下文快照。
|
||||
type Snapshot struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
TaskKey string `json:"task_key"`
|
||||
CutoffAt time.Time `json:"cutoff_at"`
|
||||
AggregateHash string `json:"aggregate_hash"`
|
||||
SourceCount int `json:"source_count"`
|
||||
ExcludedPrivateCount int `json:"excluded_private_count"`
|
||||
ExcludedNoAccessCount int `json:"excluded_no_access_count"`
|
||||
Sources []Source `json:"sources"`
|
||||
}
|
||||
|
||||
// BuildInput 构建快照所需输入。
|
||||
type BuildInput struct {
|
||||
ProjectID int64
|
||||
TaskKey string
|
||||
UserID int64
|
||||
ProjectName string
|
||||
Facts map[string]string
|
||||
Materials []squad.MaterialItem
|
||||
PriorDocs []squad.PriorDocItem
|
||||
Knowledge []squad.KnowledgeHit
|
||||
Dialogue []string
|
||||
Decisions []string
|
||||
}
|
||||
|
||||
// Service 项目上下文快照服务。
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool) *Service {
|
||||
return &Service{pool: pool}
|
||||
}
|
||||
|
||||
func (s *Service) Available() bool {
|
||||
return s != nil && s.pool != nil
|
||||
}
|
||||
|
||||
// BuildSnapshot 建立来源清单并按任务检索相关片段(不 dump 全量原文)。
|
||||
func (s *Service) BuildSnapshot(ctx context.Context, in BuildInput) (*Snapshot, error) {
|
||||
cutoff := time.Now()
|
||||
sources, excludedPrivate, excludedNoAccess := enumerateSources(in)
|
||||
sources = selectForTask(in.TaskKey, sources)
|
||||
aggHash := aggregateHash(sources)
|
||||
|
||||
snap := &Snapshot{
|
||||
ProjectID: in.ProjectID,
|
||||
TaskKey: in.TaskKey,
|
||||
CutoffAt: cutoff,
|
||||
AggregateHash: aggHash,
|
||||
SourceCount: len(sources),
|
||||
ExcludedPrivateCount: excludedPrivate,
|
||||
ExcludedNoAccessCount: excludedNoAccess,
|
||||
Sources: sources,
|
||||
}
|
||||
if !s.Available() || in.ProjectID <= 0 {
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
var id int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO project_context_snapshots (project_id, task_key, cutoff_at, aggregate_hash, source_count, created_by, frozen)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, true) RETURNING id
|
||||
`, in.ProjectID, in.TaskKey, cutoff, aggHash, len(sources), in.UserID).Scan(&id)
|
||||
if err != nil {
|
||||
return snap, err
|
||||
}
|
||||
snap.ID = id
|
||||
for _, src := range sources {
|
||||
_, _ = s.pool.Exec(ctx, `
|
||||
INSERT INTO project_context_snapshot_sources
|
||||
(snapshot_id, source_type, source_id, source_version, content_hash, authority_level, permission_scope, referenced)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (snapshot_id, source_type, source_id, source_version) DO NOTHING
|
||||
`, id, src.Type, src.ID, src.Version, src.ContentHash, src.AuthorityLevel, src.PermissionScope, src.Referenced)
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// MaterialsForWorkspace 按权威等级与任务目标注入工作区材料(核心固定 + 相关分批)。
|
||||
func MaterialsForWorkspace(snap *Snapshot, maxItems int) []squad.MaterialItem {
|
||||
if snap == nil || len(snap.Sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
if maxItems <= 0 {
|
||||
maxItems = 12
|
||||
}
|
||||
out := make([]squad.MaterialItem, 0, maxItems)
|
||||
for _, src := range snap.Sources {
|
||||
if !src.Referenced || strings.TrimSpace(src.Snippet) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, squad.MaterialItem{
|
||||
Filename: fmt.Sprintf("%s_%s.md", src.Type, src.ID),
|
||||
Content: []byte(src.Snippet),
|
||||
})
|
||||
if len(out) >= maxItems {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func enumerateSources(in BuildInput) ([]Source, int, int) {
|
||||
out := make([]Source, 0)
|
||||
excludedPrivate := 0
|
||||
excludedNoAccess := 0
|
||||
for k, v := range in.Facts {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Source{
|
||||
Type: AuthorityConfirmedFact, ID: k, ContentHash: hashText(v),
|
||||
AuthorityLevel: AuthorityConfirmedFact, PermissionScope: "project",
|
||||
})
|
||||
}
|
||||
for i, m := range in.Materials {
|
||||
body := string(m.Content)
|
||||
if len(body) == 0 {
|
||||
continue
|
||||
}
|
||||
id := m.Filename
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("material_%d", i)
|
||||
}
|
||||
out = append(out, Source{
|
||||
Type: "upload_material", ID: id, ContentHash: hashText(body),
|
||||
AuthorityLevel: AuthorityUpload, PermissionScope: "project", Snippet: truncate(body, 4000),
|
||||
})
|
||||
}
|
||||
for _, d := range in.PriorDocs {
|
||||
body := d.Content
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
id := d.DocKey
|
||||
if id == "" {
|
||||
id = d.Title
|
||||
}
|
||||
out = append(out, Source{
|
||||
Type: "document", ID: id, Version: d.Stage, ContentHash: hashText(body),
|
||||
AuthorityLevel: AuthorityApprovedDoc, PermissionScope: "project", Snippet: truncate(body, 4000),
|
||||
})
|
||||
}
|
||||
for i, line := range in.Dialogue {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
excludedPrivate++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "[private]") || strings.HasPrefix(trimmed, "[draft]") {
|
||||
excludedPrivate++
|
||||
continue
|
||||
}
|
||||
out = append(out, Source{
|
||||
Type: "dialogue", ID: fmt.Sprintf("turn_%d", i), ContentHash: hashText(trimmed),
|
||||
AuthorityLevel: AuthorityDialogue, PermissionScope: "project_shared", Snippet: truncate(trimmed, 2000),
|
||||
})
|
||||
}
|
||||
for i, d := range in.Decisions {
|
||||
out = append(out, Source{
|
||||
Type: "decision", ID: fmt.Sprintf("decision_%d", i), ContentHash: hashText(d),
|
||||
AuthorityLevel: AuthorityConfirmedFact, PermissionScope: "project", Snippet: truncate(d, 2000),
|
||||
})
|
||||
}
|
||||
for _, h := range in.Knowledge {
|
||||
body := h.Content
|
||||
if body == "" {
|
||||
body = h.Title
|
||||
}
|
||||
out = append(out, Source{
|
||||
Type: "knowledge", ID: h.CiteKey, ContentHash: hashText(body),
|
||||
AuthorityLevel: AuthorityKnowledge, PermissionScope: "global_kb", Snippet: truncate(body, 2000),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return authorityRank(out[i].AuthorityLevel) < authorityRank(out[j].AuthorityLevel)
|
||||
})
|
||||
return out, excludedPrivate, excludedNoAccess
|
||||
}
|
||||
|
||||
func selectForTask(taskKey string, sources []Source) []Source {
|
||||
if len(sources) == 0 {
|
||||
return sources
|
||||
}
|
||||
keywords := taskKeywords(taskKey)
|
||||
for i := range sources {
|
||||
src := &sources[i]
|
||||
if src.AuthorityLevel == AuthorityConfirmedFact || src.AuthorityLevel == AuthorityApprovedDoc {
|
||||
src.Referenced = true
|
||||
continue
|
||||
}
|
||||
if len(keywords) == 0 {
|
||||
src.Referenced = src.AuthorityLevel != AuthorityDialogue
|
||||
continue
|
||||
}
|
||||
hay := strings.ToLower(src.Snippet + " " + src.ID + " " + src.Type)
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(hay, kw) {
|
||||
src.Referenced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func taskKeywords(taskKey string) []string {
|
||||
switch taskKey {
|
||||
case "system_reqs":
|
||||
return []string{"需求", "requirement", "功能", "system_reqs"}
|
||||
case "test_manager":
|
||||
return []string{"测试", "验证", "风险", "test", "verification", "requirement"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func authorityRank(level string) int {
|
||||
switch level {
|
||||
case AuthorityConfirmedFact:
|
||||
return 0
|
||||
case AuthorityApprovedDoc:
|
||||
return 1
|
||||
case AuthorityUpload:
|
||||
return 2
|
||||
case AuthorityMilestone:
|
||||
return 3
|
||||
case AuthorityDialogue:
|
||||
return 4
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
func aggregateHash(sources []Source) string {
|
||||
raw, _ := json.Marshal(sources)
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func hashText(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "\n…(truncated)"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package projectcontext
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
func TestBuildSnapshot_ConsistentForSameInput(t *testing.T) {
|
||||
svc := NewService(nil)
|
||||
in := BuildInput{
|
||||
ProjectID: 1,
|
||||
TaskKey: "system_reqs",
|
||||
UserID: 1,
|
||||
Facts: map[string]string{"product_type": "超声设备"},
|
||||
Materials: []squad.MaterialItem{{Filename: "note.md", Content: []byte("材料说明")}},
|
||||
Dialogue: []string{"项目需要 CE 认证"},
|
||||
Decisions: []string{"目标市场:欧盟"},
|
||||
}
|
||||
s1, err := svc.BuildSnapshot(t.Context(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSnapshot: %v", err)
|
||||
}
|
||||
s2, err := svc.BuildSnapshot(t.Context(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSnapshot second: %v", err)
|
||||
}
|
||||
if s1.AggregateHash != s2.AggregateHash {
|
||||
t.Fatalf("expected reproducible hash, got %s vs %s", s1.AggregateHash, s2.AggregateHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshot_ExcludesEmptyPrivateDialogue(t *testing.T) {
|
||||
svc := NewService(nil)
|
||||
snap, err := svc.BuildSnapshot(t.Context(), BuildInput{
|
||||
ProjectID: 2,
|
||||
TaskKey: "test_manager",
|
||||
Dialogue: []string{"", " ", "[private] 不应纳入", "可共享的项目讨论"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSnapshot: %v", err)
|
||||
}
|
||||
if snap.SourceCount != 1 {
|
||||
t.Fatalf("expected 1 shared dialogue source, got %d", snap.SourceCount)
|
||||
}
|
||||
if snap.ExcludedPrivateCount < 3 {
|
||||
t.Fatalf("expected excluded private count >= 3, got %d", snap.ExcludedPrivateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialsForWorkspace_RespectsAuthority(t *testing.T) {
|
||||
snap := &Snapshot{
|
||||
Sources: []Source{
|
||||
{Type: "dialogue", ID: "turn_1", Referenced: false, Snippet: "ignored"},
|
||||
{Type: AuthorityApprovedDoc, ID: "srs", Referenced: true, Snippet: "SRS body"},
|
||||
},
|
||||
}
|
||||
items := MaterialsForWorkspace(snap, 5)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 referenced material, got %d", len(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package projectinstance_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
// TestPG_ActivateOfficialWorkflowRuntimeMetadata 回归激活链路:
|
||||
// ActivateInstance.applyRuntimeMetadata 会遍历文档任务实例并逐条 UPDATE。
|
||||
// 若在同一事务连接上 rows 未读完时就执行 Exec,pgx 会返回 "conn busy"。
|
||||
// 本测试使用正式商务对接流程(3 阶段 7 任务)覆盖该路径。
|
||||
func TestPG_ActivateOfficialWorkflowRuntimeMetadata(t *testing.T) {
|
||||
pool := openPGPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
wfStore := workflowdef.NewStore(pool)
|
||||
def, err := workflowdef.LoadManifestDefinition("main.v2.json")
|
||||
if err != nil {
|
||||
t.Fatalf("load main.v2 manifest: %v", err)
|
||||
}
|
||||
if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{
|
||||
Key: workflowdef.OfficialKeyMain,
|
||||
Name: "商务对接流程",
|
||||
Definition: def,
|
||||
}, 1); err != nil {
|
||||
t.Fatalf("seed official main workflow: %v", err)
|
||||
}
|
||||
|
||||
pwStore := projectworkflow.NewStore(pool)
|
||||
instStore := projectinstance.NewStore(pool)
|
||||
svc := projectinstance.NewService(instStore, wfStore, pwStore, nil)
|
||||
|
||||
var projectID int64
|
||||
code := "pg-official-" + time.Now().Format("150405.000")
|
||||
err = pool.QueryRow(ctx, `
|
||||
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
|
||||
VALUES ($1, 'PG Official Activation Test', 'active', 1, 'planning', '', 1, 0) RETURNING id
|
||||
`, code).Scan(&projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
|
||||
})
|
||||
|
||||
plan, err := svc.BuildDefaultPlan(ctx, workflowdef.OfficialKeyMain, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("default plan: %v", err)
|
||||
}
|
||||
for i := range plan.Stages {
|
||||
for j := range plan.Stages[i].DocTasks {
|
||||
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
|
||||
AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := svc.SaveWorkflowPlan(ctx, projectID, 1, true, projectinstance.SavePlanInput{
|
||||
Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("save plan: %v", err)
|
||||
}
|
||||
preview, err := svc.ActivationPreview(ctx, projectID, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
activated, err := svc.Activate(ctx, projectID, 1, true, projectinstance.ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
if activated.Instance.Status != projectinstance.InstanceActive {
|
||||
t.Fatalf("expected active instance, got %q", activated.Instance.Status)
|
||||
}
|
||||
|
||||
// 运行时元数据应写回文档任务实例(回归 applyRuntimeMetadata 的逐条 UPDATE)。
|
||||
var executorKey string
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT dti.executor_key
|
||||
FROM project_doc_task_instances dti
|
||||
JOIN project_stage_instances si ON si.id = dti.stage_instance_id
|
||||
WHERE si.workflow_instance_id = $1 AND dti.task_key = 'intake'
|
||||
`, activated.Instance.ID).Scan(&executorKey)
|
||||
if err != nil {
|
||||
t.Fatalf("query runtime metadata: %v", err)
|
||||
}
|
||||
if executorKey != "bizdev.intake.v1" {
|
||||
t.Fatalf("expected executor_key bizdev.intake.v1, got %q", executorKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActivationToken_OneTimeUse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 200, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
|
||||
_, _ = svc.SaveWorkflowPlan(ctx, 200, 1, true, SavePlanInput{
|
||||
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
preview, err := svc.ActivationPreview(ctx, 200, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivationPreview: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Activate(ctx, 200, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first activate: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Activate(ctx, 200, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if !errors.Is(err, ErrTokenInvalid) && !errors.Is(err, ErrPlanFrozen) {
|
||||
t.Fatalf("expected ErrTokenInvalid or ErrPlanFrozen on reuse, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivationToken_ConcurrentActivateOnlyOneSucceeds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 201, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
|
||||
_, _ = svc.SaveWorkflowPlan(ctx, 201, 1, true, SavePlanInput{
|
||||
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
preview, err := svc.ActivationPreview(ctx, 201, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivationPreview: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successes := 0
|
||||
var mu sync.Mutex
|
||||
for i := 0; i < 3; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, actErr := svc.Activate(ctx, 201, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if actErr == nil {
|
||||
mu.Lock()
|
||||
successes++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if successes != 1 {
|
||||
t.Fatalf("expected exactly 1 successful activation, got %d", successes)
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,21 @@ package projectinstance
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
func TestHandleDocumentApproved_StageReady(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 1, LifecycleActive, "medical_device_development", 1, 1)
|
||||
_ = SeedProjectMeta(store, 1, LifecycleActive, workflowdef.OfficialKeyMain, 1, 1)
|
||||
|
||||
svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}})
|
||||
plan := WorkflowPlan{
|
||||
WorkflowDefinitionKey: "medical_device_development",
|
||||
WorkflowDefinitionKey: workflowdef.OfficialKeyMain,
|
||||
WorkflowVersion: 1,
|
||||
Stages: []StagePlan{{
|
||||
StageKey: "biz_intake", Enabled: true,
|
||||
StageKey: "V0.1_triage", Enabled: true,
|
||||
DocTasks: []DocTaskPlan{{
|
||||
TaskKey: "intake", Enabled: true,
|
||||
Duties: DocDuty{AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4},
|
||||
@@ -26,7 +28,8 @@ func TestHandleDocumentApproved_StageReady(t *testing.T) {
|
||||
ID: 1, ProjectID: 1, WorkflowVersionID: 10, Status: InstanceActive,
|
||||
ConfigRevision: 1, PlanHash: "h", ConfigSnapshot: plan, InstanceNo: 1,
|
||||
}
|
||||
activated, err := store.ActivateInstance(ctx, 1, inst, "medical_device_development", 1, 2)
|
||||
version := &workflowdef.Version{WorkflowKey: workflowdef.OfficialKeyMain, Version: 1}
|
||||
activated, err := store.ActivateInstance(ctx, 1, inst, version, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
@@ -38,7 +41,7 @@ func TestHandleDocumentApproved_StageReady(t *testing.T) {
|
||||
if result == nil || !result.StageReady {
|
||||
t.Fatalf("expected stage ready, got %+v", result)
|
||||
}
|
||||
if result.InstanceID != activated.ID || result.StageKey != "biz_intake" {
|
||||
if result.InstanceID != activated.ID || result.StageKey != "V0.1_triage" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const (
|
||||
DocAccessActionView = "view"
|
||||
DocAccessActionEdit = "edit"
|
||||
DocAccessActionSubmit = "submit"
|
||||
DocAccessActionReview = "review"
|
||||
DocAccessActionApprove = "approve"
|
||||
DocAccessActionRun = "run"
|
||||
|
||||
AccessCodeDocTaskDisabled = "DOC_TASK_DISABLED"
|
||||
AccessCodeNotCurrentStage = "DOC_TASK_NOT_IN_ACTIVE_STAGE"
|
||||
AccessCodeForbiddenDuty = "DOC_DUTY_FORBIDDEN"
|
||||
AccessCodeInstanceClosed = "WORKFLOW_INSTANCE_CLOSED"
|
||||
)
|
||||
|
||||
// DocAccessDecision 统一文档任务访问判定结果。
|
||||
type DocAccessDecision struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// CheckDocTaskAccess 对 live 治理实例下的文档任务做 fail-closed 判定。
|
||||
// 无 live 实例时不限制(legacy 兼容)。
|
||||
func (s *Service) CheckDocTaskAccess(
|
||||
ctx context.Context,
|
||||
projectID int64,
|
||||
userID int64,
|
||||
taskKey string,
|
||||
action string,
|
||||
isCreator bool,
|
||||
) DocAccessDecision {
|
||||
allow := DocAccessDecision{Allowed: true}
|
||||
if s == nil || s.store == nil || projectID <= 0 || userID <= 0 || taskKey == "" {
|
||||
return allow
|
||||
}
|
||||
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil {
|
||||
return allow
|
||||
}
|
||||
if live.Status == InstanceCompleted || live.Status == "terminated" {
|
||||
return DocAccessDecision{
|
||||
Allowed: false,
|
||||
Code: AccessCodeInstanceClosed,
|
||||
Message: "工作流实例已结束",
|
||||
}
|
||||
}
|
||||
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
if err != nil || detail == nil {
|
||||
return DocAccessDecision{Allowed: false, Code: AccessCodeInstanceClosed, Message: "工作流实例不可用"}
|
||||
}
|
||||
|
||||
var docInst *DocTaskInstance
|
||||
var stageKey string
|
||||
for _, st := range detail.Stages {
|
||||
for i := range st.DocTasks {
|
||||
if st.DocTasks[i].TaskKey == taskKey {
|
||||
copyDoc := st.DocTasks[i]
|
||||
docInst = ©Doc
|
||||
stageKey = st.StageKey
|
||||
break
|
||||
}
|
||||
}
|
||||
if docInst != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if docInst == nil {
|
||||
return allow
|
||||
}
|
||||
|
||||
if docInst.Status == "skipped_disabled" {
|
||||
return DocAccessDecision{
|
||||
Allowed: false,
|
||||
Code: AccessCodeDocTaskDisabled,
|
||||
Message: "该文档已被创建者禁用",
|
||||
}
|
||||
}
|
||||
|
||||
if stageKey != "" && stageKey != detail.ActiveStageKey {
|
||||
if action != DocAccessActionView {
|
||||
return DocAccessDecision{
|
||||
Allowed: false,
|
||||
Code: AccessCodeNotCurrentStage,
|
||||
Message: "文档不在当前活动阶段",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if action == DocAccessActionView {
|
||||
return allow
|
||||
}
|
||||
|
||||
planTask := findDocTaskPlan(live.ConfigSnapshot, taskKey)
|
||||
if planTask == nil || !planTask.Enabled {
|
||||
return DocAccessDecision{
|
||||
Allowed: false,
|
||||
Code: AccessCodeDocTaskDisabled,
|
||||
Message: "该文档已被创建者禁用",
|
||||
}
|
||||
}
|
||||
|
||||
if isCreator {
|
||||
return allow
|
||||
}
|
||||
if docTaskDutyForUser(*planTask, userID) {
|
||||
return allow
|
||||
}
|
||||
return DocAccessDecision{
|
||||
Allowed: false,
|
||||
Code: AccessCodeForbiddenDuty,
|
||||
Message: "当前用户不是该文档职责人",
|
||||
}
|
||||
}
|
||||
|
||||
func findDocTaskPlan(plan WorkflowPlan, taskKey string) *DocTaskPlan {
|
||||
for _, st := range plan.Stages {
|
||||
for i := range st.DocTasks {
|
||||
if st.DocTasks[i].TaskKey == taskKey {
|
||||
return &st.DocTasks[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
func TestCheckDocTaskAccess_DisabledDocumentRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryStore()
|
||||
_ = SeedProjectMeta(store, 400, LifecyclePlanning, "", 1, 0)
|
||||
resolver := disabledDocResolver()
|
||||
svc := NewService(store, resolver, nil, nil)
|
||||
|
||||
version := resolver.versions[workflowdef.OfficialKeyMain]
|
||||
plan := buildDefaultPlan(version)
|
||||
for i := range plan.Stages[0].DocTasks {
|
||||
if plan.Stages[0].DocTasks[i].TaskKey == "research" {
|
||||
plan.Stages[0].DocTasks[i].Enabled = false
|
||||
continue
|
||||
}
|
||||
plan.Stages[0].DocTasks[i].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}
|
||||
}
|
||||
_, _ = svc.SaveWorkflowPlan(ctx, 400, 1, true, SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1})
|
||||
preview, _ := svc.ActivationPreview(ctx, 400, 1, true)
|
||||
_, _ = svc.Activate(ctx, 400, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
|
||||
decision := svc.CheckDocTaskAccess(ctx, 400, 1, "research", DocAccessActionRun, true)
|
||||
if decision.Allowed || decision.Code != AccessCodeDocTaskDisabled {
|
||||
t.Fatalf("expected DOC_TASK_DISABLED, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDocTaskAccess_NonDutyUserRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryStore()
|
||||
_ = SeedProjectMeta(store, 401, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, TestResolver(), nil, nil)
|
||||
|
||||
_, _ = svc.SaveWorkflowPlan(ctx, 401, 1, true, SavePlanInput{
|
||||
Plan: ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
preview, _ := svc.ActivationPreview(ctx, 401, 1, true)
|
||||
_, _ = svc.Activate(ctx, 401, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
|
||||
decision := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionRun, false)
|
||||
if decision.Allowed || decision.Code != AccessCodeForbiddenDuty {
|
||||
t.Fatalf("expected DOC_DUTY_FORBIDDEN, got %+v", decision)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,47 @@
|
||||
package projectinstance
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotAvailable = errors.New("project instance store not available")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow")
|
||||
ErrPlanFrozen = errors.New("plan frozen: workflow already active")
|
||||
ErrWorkflowAlreadyActive = errors.New("workflow already active for project")
|
||||
ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required")
|
||||
ErrInvalidPlan = errors.New("invalid workflow plan")
|
||||
ErrValidationFailed = errors.New("workflow plan validation failed")
|
||||
ErrTokenInvalid = errors.New("activation token invalid or expired")
|
||||
ErrConcurrentConflict = errors.New("conflict: project version mismatch")
|
||||
ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch")
|
||||
ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published")
|
||||
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation")
|
||||
ErrInstanceNotFound = errors.New("workflow instance not found")
|
||||
ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved")
|
||||
ErrStageNotActive = errors.New("stage is not the active stage")
|
||||
ErrInvalidStage = errors.New("invalid stage key for instance")
|
||||
ErrNotAvailable = errors.New("project instance store not available")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow")
|
||||
ErrPlanFrozen = errors.New("plan frozen: workflow already active")
|
||||
ErrWorkflowAlreadyActive = errors.New("workflow already active for project")
|
||||
ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required")
|
||||
ErrInvalidPlan = errors.New("invalid workflow plan")
|
||||
ErrValidationFailed = errors.New("workflow plan validation failed")
|
||||
ErrTokenInvalid = errors.New("activation token invalid or expired")
|
||||
ErrConcurrentConflict = errors.New("conflict: project version mismatch")
|
||||
ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch")
|
||||
ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published")
|
||||
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation")
|
||||
ErrInstanceNotFound = errors.New("workflow instance not found")
|
||||
ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved")
|
||||
ErrStageNotActive = errors.New("stage is not the active stage")
|
||||
ErrInvalidStage = errors.New("invalid stage key for instance")
|
||||
ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch")
|
||||
ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate")
|
||||
ErrTerminationNotAllowed = errors.New("termination not allowed for instance state")
|
||||
)
|
||||
|
||||
// ValidationError 保留具体校验问题,供 HTTP 层返回可操作的错误信息。
|
||||
type ValidationError struct {
|
||||
Issues []workflowdef.ValidationIssue
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return ErrValidationFailed.Error()
|
||||
}
|
||||
|
||||
func (e *ValidationError) Unwrap() error {
|
||||
return ErrValidationFailed
|
||||
}
|
||||
|
||||
func newValidationError(result workflowdef.ValidationResult) error {
|
||||
return &ValidationError{Issues: result.Issues}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func evaluateStageReadiness(
|
||||
continue
|
||||
}
|
||||
td, hasDef := taskDefMap[dt.TaskKey]
|
||||
required := !hasDef || td.Required || td.Blocking
|
||||
required := !hasDef || td.BlocksWhenEnabled()
|
||||
state := docStates[dt.TaskKey]
|
||||
if state == "" {
|
||||
state = "missing"
|
||||
|
||||
@@ -2,7 +2,6 @@ package projectinstance_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,10 +17,7 @@ import (
|
||||
|
||||
func openPGPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable"
|
||||
}
|
||||
dsn := db.SkipIfNoTestDatabaseURL(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
|
||||
@@ -95,16 +95,20 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64
|
||||
defs, listErr := s.wfStore.ListDefinitions(ctx)
|
||||
if listErr == nil {
|
||||
for _, d := range defs {
|
||||
if d.LatestPublishedVersion <= 0 {
|
||||
if d.LatestPublishedVersion <= 0 || !workflowdef.IsOfficialWorkflowKey(d.Key) {
|
||||
continue
|
||||
}
|
||||
ver, verErr := s.wfStore.GetVersion(ctx, d.ID, d.LatestPublishedVersion)
|
||||
if verErr != nil || ver == nil {
|
||||
continue
|
||||
}
|
||||
name := d.Name
|
||||
if name == "" || name == d.Key {
|
||||
name = workflowdef.OfficialWorkflowName(d.Key)
|
||||
}
|
||||
settings.PublishedWorkflows = append(settings.PublishedWorkflows, PublishedOption{
|
||||
DefinitionKey: d.Key,
|
||||
DefinitionName: d.Name,
|
||||
DefinitionName: name,
|
||||
LatestVersion: d.LatestPublishedVersion,
|
||||
WorkflowVersion: ver.ID,
|
||||
ProductDomain: d.ProductDomain,
|
||||
@@ -147,10 +151,11 @@ func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID
|
||||
}
|
||||
validation := validatePlan(in.Plan, version)
|
||||
if !validation.Valid {
|
||||
return nil, fmt.Errorf("%w: %d issues", ErrValidationFailed, len(validation.Issues))
|
||||
return nil, newValidationError(validation)
|
||||
}
|
||||
|
||||
in.Plan.WorkflowVersionID = version.ID
|
||||
applyDefinitionLabels(&in.Plan, version)
|
||||
planHash, err := computePlanHash(in.Plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -197,9 +202,9 @@ func (s *Service) ActivationPreview(ctx context.Context, projectID int64, userID
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validation := validatePlan(planInst.ConfigSnapshot, version)
|
||||
validation := validateActivationPlan(planInst.ConfigSnapshot, version)
|
||||
if !validation.Valid {
|
||||
return nil, fmt.Errorf("%w", ErrValidationFailed)
|
||||
return nil, newValidationError(validation)
|
||||
}
|
||||
|
||||
token, err := randomToken(24)
|
||||
@@ -248,28 +253,16 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i
|
||||
return nil, fmt.Errorf("%w: no saved plan", ErrInvalidPlan)
|
||||
}
|
||||
|
||||
consumed, err := s.store.ConsumeActivationToken(ctx, in.ConfirmationToken, projectID, planInst.PlanHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentHash, err := computePlanHash(*consumed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentHash != planInst.PlanHash {
|
||||
return nil, ErrPlanChangedReconfirm
|
||||
}
|
||||
|
||||
version, err := s.resolvePublishedVersion(ctx, planInst.ConfigSnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validation := validatePlan(planInst.ConfigSnapshot, version)
|
||||
validation := validateActivationPlan(planInst.ConfigSnapshot, version)
|
||||
if !validation.Valid {
|
||||
return nil, ErrValidationFailed
|
||||
}
|
||||
|
||||
activated, err := s.store.ActivateInstance(ctx, projectID, planInst, version.WorkflowKey, selVer, userID)
|
||||
activated, err := s.store.ActivateInstanceWithToken(ctx, in.ConfirmationToken, projectID, planInst, version, selVer, userID, planInst.PlanHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -282,10 +275,33 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetLiveInstanceDetail 返回 live 实例详情与已发布版本定义(运行时编排入口)。
|
||||
func (s *Service) GetLiveInstanceDetail(ctx context.Context, projectID int64) (*InstanceDetail, *workflowdef.Version, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, nil, ErrNotAvailable
|
||||
}
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil {
|
||||
return nil, nil, fmt.Errorf("live instance not found")
|
||||
}
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
version, err := s.resolvePublishedVersion(ctx, live.ConfigSnapshot)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return detail, version, nil
|
||||
}
|
||||
|
||||
func (s *Service) BuildDefaultPlan(ctx context.Context, workflowKey string, versionNum int) (*WorkflowPlan, error) {
|
||||
if s.wfStore == nil {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
if !workflowdef.IsOfficialWorkflowKey(workflowKey) {
|
||||
return nil, ErrWorkflowVersionNotFound
|
||||
}
|
||||
def, err := s.wfStore.GetDefinitionByKey(ctx, workflowKey)
|
||||
if err != nil {
|
||||
return nil, ErrWorkflowVersionNotFound
|
||||
|
||||
@@ -8,72 +8,6 @@ import (
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
type stubVersionResolver struct {
|
||||
versions map[string]*workflowdef.Version
|
||||
defs map[string]*workflowdef.Definition
|
||||
}
|
||||
|
||||
func (s *stubVersionResolver) GetDefinitionByKey(_ context.Context, key string) (*workflowdef.Definition, error) {
|
||||
d, ok := s.defs[key]
|
||||
if !ok {
|
||||
return nil, workflowdef.ErrNotFound
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *stubVersionResolver) GetVersion(_ context.Context, workflowID int64, version int) (*workflowdef.Version, error) {
|
||||
for _, v := range s.versions {
|
||||
if v.WorkflowID == workflowID && v.Version == version {
|
||||
copyV := *v
|
||||
return ©V, nil
|
||||
}
|
||||
}
|
||||
return nil, workflowdef.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *stubVersionResolver) ListDefinitions(_ context.Context) ([]workflowdef.Definition, error) {
|
||||
out := make([]workflowdef.Definition, 0, len(s.defs))
|
||||
for _, d := range s.defs {
|
||||
out = append(out, *d)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func testResolver() *stubVersionResolver {
|
||||
ver := &workflowdef.Version{
|
||||
ID: 100, WorkflowID: 10, Version: 1, Status: workflowdef.StatusPublished,
|
||||
WorkflowKey: "medical_device_development", WorkflowName: "医疗器械开发",
|
||||
Definition: workflowdef.VersionDefinition{
|
||||
Stages: []workflowdef.StageDef{{
|
||||
Key: "biz_intake", Name: "商务 intake", Optional: false,
|
||||
DocTasks: []workflowdef.DocTaskDef{{
|
||||
Key: "intake", Required: true, Blocking: true, DefaultEnabled: true,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
return &stubVersionResolver{
|
||||
defs: map[string]*workflowdef.Definition{
|
||||
"medical_device_development": {ID: 10, Key: "medical_device_development", Name: "医疗器械开发", LatestPublishedVersion: 1},
|
||||
},
|
||||
versions: map[string]*workflowdef.Version{"medical_device_development": ver},
|
||||
}
|
||||
}
|
||||
|
||||
func validPlan() WorkflowPlan {
|
||||
return WorkflowPlan{
|
||||
WorkflowDefinitionKey: "medical_device_development",
|
||||
WorkflowVersion: 1,
|
||||
Stages: []StagePlan{{
|
||||
StageKey: "biz_intake", Enabled: true,
|
||||
DocTasks: []DocTaskPlan{{
|
||||
TaskKey: "intake", Enabled: true,
|
||||
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_PlanPreviewActivate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
@@ -86,7 +20,7 @@ func TestService_PlanPreviewActivate(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SaveWorkflowPlan: %v", err)
|
||||
}
|
||||
if settings.Plan == nil || settings.Plan.WorkflowDefinitionKey != "medical_device_development" {
|
||||
if settings.Plan == nil || settings.Plan.WorkflowDefinitionKey != workflowdef.OfficialKeyMain {
|
||||
t.Fatalf("expected saved plan, got %+v", settings.Plan)
|
||||
}
|
||||
|
||||
@@ -99,14 +33,14 @@ func TestService_PlanPreviewActivate(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := svc.Activate(ctx, 42, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
if result.LifecycleStatus != LifecycleActive || result.ActiveWorkflowKey != "medical_device_development" {
|
||||
if result.LifecycleStatus != LifecycleActive || result.ActiveWorkflowKey != workflowdef.OfficialKeyMain {
|
||||
t.Fatalf("unexpected activate result: %+v", result)
|
||||
}
|
||||
|
||||
@@ -133,8 +67,28 @@ func TestService_BlockPlanEditWhenActive(t *testing.T) {
|
||||
func TestService_SeparationOfDutyValidation(t *testing.T) {
|
||||
bad := validPlan()
|
||||
bad.Stages[0].DocTasks[0].Duties.ReviewerUserID = 1
|
||||
res := validatePlan(bad, testResolver().versions["medical_device_development"])
|
||||
res := validatePlan(bad, testResolver().versions[workflowdef.OfficialKeyMain])
|
||||
if res.Valid {
|
||||
t.Fatal("expected separation of duty violation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SavePlanReturnsValidationIssues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 8, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
bad := validPlan()
|
||||
bad.Stages[0].DocTasks[0].Duties = DocDuty{}
|
||||
|
||||
_, err := svc.SaveWorkflowPlan(ctx, 8, 1, true, SavePlanInput{
|
||||
Plan: bad, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
var validationErr *ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %v", err)
|
||||
}
|
||||
if len(validationErr.Issues) != 3 {
|
||||
t.Fatalf("expected three missing-duty issues, got %+v", validationErr.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,18 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
|
||||
}
|
||||
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil || live.Status == InstanceCompleted {
|
||||
if err != nil || live == nil {
|
||||
out.Restricted = true
|
||||
return out, nil
|
||||
}
|
||||
if live.Status == InstanceCompleted || live.Status == "terminated" {
|
||||
out.Restricted = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out.Restricted = true
|
||||
@@ -61,7 +66,10 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
|
||||
}
|
||||
out.AllowedStageKeys[stPlan.StageKey] = true
|
||||
for _, dt := range stPlan.DocTasks {
|
||||
if dt.Enabled {
|
||||
if !dt.Enabled {
|
||||
continue
|
||||
}
|
||||
if isCreator || docTaskDutyForUser(dt, userID) {
|
||||
out.AllowedTaskKeys[dt.TaskKey] = true
|
||||
}
|
||||
}
|
||||
@@ -71,16 +79,19 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
|
||||
|
||||
func stageDutyForUser(st StagePlan, userID int64) bool {
|
||||
for _, dt := range st.DocTasks {
|
||||
if !dt.Enabled {
|
||||
continue
|
||||
}
|
||||
if dt.Duties.AuthorUserID == userID || dt.Duties.ReviewerUserID == userID || dt.Duties.ApproverUserID == userID {
|
||||
if dt.Enabled && docTaskDutyForUser(dt, userID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool {
|
||||
return dt.Duties.AuthorUserID == userID ||
|
||||
dt.Duties.ReviewerUserID == userID ||
|
||||
dt.Duties.ApproverUserID == userID
|
||||
}
|
||||
|
||||
// TaskKeyAllowed 判断 task_key 是否在允许范围内。
|
||||
func (a *StageAccess) TaskKeyAllowed(taskKey string) bool {
|
||||
if a == nil || !a.Restricted {
|
||||
|
||||
@@ -48,12 +48,12 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
creatorAccess, _ := svc.ResolveStageAccess(ctx, 42, 1, true)
|
||||
if !creatorAccess.StageKeyAllowed("biz_intake") {
|
||||
if !creatorAccess.StageKeyAllowed("V0.1_triage") {
|
||||
t.Fatal("creator should access active stage")
|
||||
}
|
||||
|
||||
outsiderAccess, _ := svc.ResolveStageAccess(ctx, 42, 9, false)
|
||||
if outsiderAccess.StageKeyAllowed("biz_intake") {
|
||||
if outsiderAccess.StageKeyAllowed("V0.1_triage") {
|
||||
t.Fatal("user without duty should not access active stage chat")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
func TestAdvanceStage_CompletesProjectAfterLastStage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 100, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}})
|
||||
|
||||
_, err := svc.SaveWorkflowPlan(ctx, 100, 1, true, SavePlanInput{
|
||||
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveWorkflowPlan: %v", err)
|
||||
}
|
||||
preview, err := svc.ActivationPreview(ctx, 100, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("ActivationPreview: %v", err)
|
||||
}
|
||||
result, err := svc.Activate(ctx, 100, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
|
||||
advanced, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{
|
||||
StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AdvanceStage: %v", err)
|
||||
}
|
||||
if !advanced.InstanceDone {
|
||||
t.Fatal("expected instance done after last stage")
|
||||
}
|
||||
if advanced.Instance.Status != InstanceCompleted {
|
||||
t.Fatalf("expected completed instance, got %q", advanced.Instance.Status)
|
||||
}
|
||||
|
||||
lifecycle, activeKey, _, _, err := store.GetProjectMeta(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProjectMeta: %v", err)
|
||||
}
|
||||
if lifecycle != LifecyclePlanning {
|
||||
t.Fatalf("expected lifecycle planning, got %q", lifecycle)
|
||||
}
|
||||
if activeKey != "" {
|
||||
t.Fatalf("expected empty active workflow key, got %q", activeKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceStage_DoesNotChangeProjectOnNonLastStage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 101, LifecyclePlanning, "", 1, 0)
|
||||
resolver := testResolverMultiStage()
|
||||
svc := NewService(store, resolver, nil, &stubDocChecker{states: map[string]string{"intake": "approved"}})
|
||||
|
||||
plan := validPlan()
|
||||
plan.Stages = append(plan.Stages, StagePlan{
|
||||
StageKey: "V0.5_feasibility", Enabled: true,
|
||||
DocTasks: []DocTaskPlan{{
|
||||
TaskKey: "feasibility_reg", Enabled: true,
|
||||
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
|
||||
}},
|
||||
})
|
||||
_, err := svc.SaveWorkflowPlan(ctx, 101, 1, true, SavePlanInput{
|
||||
Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveWorkflowPlan: %v", err)
|
||||
}
|
||||
preview, _ := svc.ActivationPreview(ctx, 101, 1, true)
|
||||
result, _ := svc.Activate(ctx, 101, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
|
||||
_, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{
|
||||
StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AdvanceStage: %v", err)
|
||||
}
|
||||
|
||||
lifecycle, _, _, _, err := store.GetProjectMeta(ctx, 101)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProjectMeta: %v", err)
|
||||
}
|
||||
if lifecycle != LifecycleActive {
|
||||
t.Fatalf("expected lifecycle active on non-last stage, got %q", lifecycle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceStage_DoesNotModifyDocumentApprovalStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 102, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}})
|
||||
|
||||
_, _ = svc.SaveWorkflowPlan(ctx, 102, 1, true, SavePlanInput{
|
||||
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
preview, _ := svc.ActivationPreview(ctx, 102, 1, true)
|
||||
result, _ := svc.Activate(ctx, 102, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
|
||||
before, err := store.GetInstanceByID(ctx, result.Instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceByID: %v", err)
|
||||
}
|
||||
beforeStatus := before.Stages[0].DocTasks[0].Status
|
||||
|
||||
_, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{
|
||||
StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AdvanceStage: %v", err)
|
||||
}
|
||||
|
||||
after, err := store.GetInstanceByID(ctx, result.Instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceByID after: %v", err)
|
||||
}
|
||||
if after.Stages[0].DocTasks[0].Status != beforeStatus {
|
||||
t.Fatalf("doc status changed from %q to %q after advance", beforeStatus, after.Stages[0].DocTasks[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledDocumentPersistence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 103, LifecyclePlanning, "", 1, 0)
|
||||
resolver := disabledDocResolver()
|
||||
svc := NewService(store, resolver, nil, nil)
|
||||
|
||||
version := resolver.versions[workflowdef.OfficialKeyMain]
|
||||
plan := buildDefaultPlan(version)
|
||||
for i := range plan.Stages[0].DocTasks {
|
||||
if plan.Stages[0].DocTasks[i].TaskKey == "research" {
|
||||
plan.Stages[0].DocTasks[i].Enabled = false
|
||||
continue
|
||||
}
|
||||
plan.Stages[0].DocTasks[i].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}
|
||||
}
|
||||
_, err := svc.SaveWorkflowPlan(ctx, 103, 1, true, SavePlanInput{
|
||||
Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveWorkflowPlan: %v", err)
|
||||
}
|
||||
preview, _ := svc.ActivationPreview(ctx, 103, 1, true)
|
||||
result, err := svc.Activate(ctx, 103, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
|
||||
detail, err := store.GetInstanceByID(ctx, result.Instance.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceByID: %v", err)
|
||||
}
|
||||
foundDisabled := false
|
||||
for _, st := range detail.Stages {
|
||||
for _, dt := range st.DocTasks {
|
||||
if dt.TaskKey == "research" {
|
||||
if dt.Status != "skipped_disabled" {
|
||||
t.Fatalf("expected skipped_disabled, got %q", dt.Status)
|
||||
}
|
||||
foundDisabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundDisabled {
|
||||
t.Fatal("disabled doc task instance not persisted")
|
||||
}
|
||||
|
||||
decision := svc.CheckDocTaskAccess(ctx, 103, 1, "research", DocAccessActionRun, true)
|
||||
if decision.Allowed || decision.Code != AccessCodeDocTaskDisabled {
|
||||
t.Fatalf("expected DOC_TASK_DISABLED, got %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func testResolverMultiStage() *stubVersionResolver {
|
||||
r := testResolver()
|
||||
r.versions[workflowdef.OfficialKeyMain].Definition.Stages = append(
|
||||
r.versions[workflowdef.OfficialKeyMain].Definition.Stages,
|
||||
workflowdef.StageDef{
|
||||
Key: "V0.5_feasibility", Name: "V0.5 可行性", Optional: false, Position: 20,
|
||||
DocTasks: []workflowdef.DocTaskDef{{
|
||||
Key: "feasibility_reg", Title: "法规路径评估", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true,
|
||||
OutputDocType: "feasibility", OutputDocTitle: "法规路径评估",
|
||||
}},
|
||||
},
|
||||
)
|
||||
return r
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestService_AdvanceStageGate(t *testing.T) {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
|
||||
readiness, err := svc.GetStageReadiness(ctx, result.Instance.ID, "biz_intake", 1, true)
|
||||
readiness, err := svc.GetStageReadiness(ctx, result.Instance.ID, "V0.1_triage", 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStageReadiness: %v", err)
|
||||
}
|
||||
@@ -49,15 +49,15 @@ func TestService_AdvanceStageGate(t *testing.T) {
|
||||
|
||||
_ = settings
|
||||
advanced, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{
|
||||
StageKey: "biz_intake", ExpectedConfigRevision: result.Instance.ConfigRevision,
|
||||
StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AdvanceStage: %v", err)
|
||||
}
|
||||
if advanced.CompletedStage != "biz_intake" {
|
||||
t.Fatalf("expected completed biz_intake, got %s", advanced.CompletedStage)
|
||||
if advanced.CompletedStage != "V0.1_triage" {
|
||||
t.Fatalf("expected completed V0.1_triage, got %s", advanced.CompletedStage)
|
||||
}
|
||||
if advanced.NextStageKey == "biz_intake" {
|
||||
if advanced.NextStageKey == "V0.1_triage" {
|
||||
t.Fatal("next stage should not be same as completed")
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func TestService_AdvanceStageBlockedWithoutApproval(t *testing.T) {
|
||||
})
|
||||
|
||||
_, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{
|
||||
StageKey: "biz_intake", ExpectedConfigRevision: result.Instance.ConfigRevision,
|
||||
StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision,
|
||||
})
|
||||
if !errors.Is(err, ErrStageGateNotReady) {
|
||||
t.Fatalf("expected ErrStageGateNotReady, got %v", err)
|
||||
|
||||
@@ -117,16 +117,11 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE project_doc_task_instances SET status = 'approved', updated_at = $2
|
||||
WHERE stage_instance_id = $1 AND enabled = true AND status <> 'skipped'
|
||||
`, stageID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var nextStageID int64
|
||||
var nextStageKey string
|
||||
var projectID int64
|
||||
_ = tx.QueryRow(ctx, `SELECT project_id FROM project_workflow_instances WHERE id = $1`, instanceID).Scan(&projectID)
|
||||
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, stage_key FROM project_stage_instances
|
||||
WHERE workflow_instance_id = $1 AND enabled = true AND status = 'pending'
|
||||
@@ -134,12 +129,24 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s
|
||||
`, instanceID).Scan(&nextStageID, &nextStageKey)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE project_workflow_instances SET status = 'completed', config_revision = config_revision + 1, updated_at = $2
|
||||
UPDATE project_workflow_instances SET status = 'completed', config_revision = config_revision + 1,
|
||||
completed_at = $2, completed_by = (
|
||||
SELECT activated_by FROM project_workflow_instances WHERE id = $1
|
||||
), updated_at = $2
|
||||
WHERE id = $1
|
||||
`, instanceID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if projectID > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE projects SET lifecycle_status = 'planning', active_workflow_key = '', updated_at = $2
|
||||
WHERE id = $1
|
||||
`, projectID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,8 @@ package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,6 +12,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
// TablesReady 检查 Phase 3 表是否已迁移。
|
||||
@@ -37,7 +41,10 @@ type Store interface {
|
||||
GetLiveInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error)
|
||||
HasLiveInstance(ctx context.Context, projectID int64) (bool, error)
|
||||
UpsertPlanningInstance(ctx context.Context, inst *WorkflowInstance) (*WorkflowInstance, error)
|
||||
ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, workflowKey string, expectedVersion int, activatedBy int64) (*WorkflowInstance, error)
|
||||
ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error)
|
||||
ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error)
|
||||
CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error)
|
||||
TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error)
|
||||
|
||||
SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error
|
||||
ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error)
|
||||
@@ -241,21 +248,26 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst *
|
||||
return err
|
||||
}
|
||||
|
||||
for _, st := range inst.ConfigSnapshot.Stages {
|
||||
for i, st := range inst.ConfigSnapshot.Stages {
|
||||
position := (i + 1) * 10
|
||||
var stageID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO project_stage_instances (workflow_instance_id, stage_key, position, enabled, status)
|
||||
VALUES ($1, $2, 0, $3, 'pending') RETURNING id
|
||||
`, inst.ID, st.StageKey, st.Enabled).Scan(&stageID)
|
||||
VALUES ($1, $2, $3, $4, 'pending') RETURNING id
|
||||
`, inst.ID, st.StageKey, position, st.Enabled).Scan(&stageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, dt := range st.DocTasks {
|
||||
status := "pending"
|
||||
if !dt.Enabled {
|
||||
status = "skipped_disabled"
|
||||
}
|
||||
var docTaskID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO project_doc_task_instances (stage_instance_id, task_key, enabled, status)
|
||||
VALUES ($1, $2, $3, 'pending') RETURNING id
|
||||
`, stageID, dt.TaskKey, dt.Enabled).Scan(&docTaskID)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id
|
||||
`, stageID, dt.TaskKey, dt.Enabled, status).Scan(&docTaskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -281,13 +293,31 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst *
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, workflowKey string, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) {
|
||||
return s.activateInstanceTx(ctx, s.pool, "", projectID, inst, version, expectedVersion, activatedBy, "")
|
||||
}
|
||||
|
||||
func (s *pgStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) {
|
||||
return s.activateInstanceTx(ctx, s.pool, token, projectID, inst, version, expectedVersion, activatedBy, planHash)
|
||||
}
|
||||
|
||||
func (s *pgStore) activateInstanceTx(ctx context.Context, db pgxDB, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) {
|
||||
workflowKey := ""
|
||||
if version != nil {
|
||||
workflowKey = version.WorkflowKey
|
||||
}
|
||||
tx, err := db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if token != "" {
|
||||
if err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE projects SET lifecycle_status = 'active', active_workflow_key = $2,
|
||||
workflow_selection_version = workflow_selection_version + 1,
|
||||
@@ -333,22 +363,148 @@ func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *W
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if version != nil {
|
||||
if err := s.applyRuntimeMetadata(ctx, tx, inst.ID, version.Definition); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return activated, nil
|
||||
}
|
||||
|
||||
type pgxDB interface {
|
||||
Begin(ctx context.Context) (pgx.Tx, error)
|
||||
}
|
||||
|
||||
func consumeActivationTokenTx(ctx context.Context, tx pgx.Tx, token string, projectID int64, planHash string) error {
|
||||
tokenHash := hashActivationToken(token)
|
||||
var storedHash string
|
||||
var usedAt *time.Time
|
||||
var expiresAt time.Time
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT plan_hash, used_at, expires_at
|
||||
FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2
|
||||
`, tokenHash, projectID).Scan(&storedHash, &usedAt, &expiresAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if usedAt != nil || time.Now().After(expiresAt) || storedHash != planHash {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL
|
||||
`, tokenHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *pgStore) CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) {
|
||||
if projectID <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM task_runs
|
||||
WHERE project_id = $1 AND status IN ('pending', 'running')
|
||||
`, projectID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) applyRuntimeMetadata(ctx context.Context, tx pgx.Tx, instanceID int64, def workflowdef.VersionDefinition) error {
|
||||
taskDefs := map[string]workflowdef.DocTaskDef{}
|
||||
for _, st := range def.Stages {
|
||||
for _, dt := range st.DocTasks {
|
||||
taskDefs[dt.Key] = dt
|
||||
}
|
||||
}
|
||||
// 先完整读取并关闭结果集,再执行 UPDATE:
|
||||
// pgx 在同一事务连接上存在未读完的 rows 时执行 Exec 会报 "conn busy"。
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT dti.id, dti.task_key
|
||||
FROM project_doc_task_instances dti
|
||||
JOIN project_stage_instances si ON si.id = dti.stage_instance_id
|
||||
WHERE si.workflow_instance_id = $1
|
||||
`, instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type taskInstanceRow struct {
|
||||
id int64
|
||||
taskKey string
|
||||
}
|
||||
taskRows := make([]taskInstanceRow, 0)
|
||||
for rows.Next() {
|
||||
var r taskInstanceRow
|
||||
if err := rows.Scan(&r.id, &r.taskKey); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
taskRows = append(taskRows, r)
|
||||
}
|
||||
iterErr := rows.Err()
|
||||
rows.Close()
|
||||
if iterErr != nil {
|
||||
return iterErr
|
||||
}
|
||||
for _, r := range taskRows {
|
||||
td, ok := taskDefs[r.taskKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tplKey := td.TemplateAssetKey
|
||||
tplVer := td.TemplateVersion
|
||||
if td.Template != nil {
|
||||
if td.Template.AssetKey != "" {
|
||||
tplKey = td.Template.AssetKey
|
||||
}
|
||||
if td.Template.Version > 0 {
|
||||
tplVer = td.Template.Version
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE project_doc_task_instances SET
|
||||
executor_key = $2, quality_gate_key = $3,
|
||||
template_asset_key = $4, template_version = $5,
|
||||
output_doc_type = $6, output_doc_title = $7, updated_at = now()
|
||||
WHERE id = $1
|
||||
`, r.id, td.ExecutorKey, td.QualityGateKey, tplKey, tplVer, td.OutputDocType, td.OutputDocTitle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashActivationToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *pgStore) SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error {
|
||||
raw, _ := json.Marshal(snapshot)
|
||||
tokenHash := hashActivationToken(token)
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO workflow_activation_tokens (token, project_id, plan_hash, expected_project_version, config_snapshot, created_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`, token, projectID, planHash, expectedVersion, raw, createdBy, expiresAt)
|
||||
`, tokenHash, projectID, planHash, expectedVersion, raw, createdBy, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) {
|
||||
tokenHash := hashActivationToken(token)
|
||||
var snapRaw []byte
|
||||
var storedHash string
|
||||
var usedAt *time.Time
|
||||
@@ -356,7 +512,7 @@ func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, proj
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT plan_hash, config_snapshot, used_at, expires_at
|
||||
FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2
|
||||
`, token, projectID).Scan(&storedHash, &snapRaw, &usedAt, &expiresAt)
|
||||
`, tokenHash, projectID).Scan(&storedHash, &snapRaw, &usedAt, &expiresAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
@@ -368,7 +524,7 @@ func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, proj
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL
|
||||
`, token)
|
||||
`, tokenHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -380,6 +536,48 @@ func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, proj
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
now := time.Now()
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE project_workflow_instances SET status = 'terminated', terminated_at = $3, terminated_by = $4,
|
||||
termination_reason = $5, config_revision = config_revision + 1, updated_at = $3
|
||||
WHERE id = $1 AND project_id = $2 AND status IN ('active', 'awaiting_stage_confirmation', 'termination_requested')
|
||||
RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash,
|
||||
config_snapshot, activated_by, activated_at, created_by, created_at, updated_at
|
||||
`, instanceID, projectID, now, terminatedBy, reason)
|
||||
inst, err := scanInstance(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrTerminationNotAllowed
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE projects SET lifecycle_status = 'planning', active_workflow_key = '', updated_at = $2
|
||||
WHERE id = $1
|
||||
`, projectID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(ctx, `
|
||||
UPDATE task_runs SET status = 'cancelled', updated_at = $2, error_message = 'workflow terminated'
|
||||
WHERE project_id = $1 AND status IN ('pending', 'running')
|
||||
`, projectID, now)
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 内存 Store(单元测试 / 无 PG 迁移)
|
||||
// =============================================================================
|
||||
@@ -459,7 +657,7 @@ func (m *memStore) rebuildStagesLocked(inst *WorkflowInstance, activate bool) {
|
||||
task := memDocTaskRecord{id: m.nextTaskID, taskKey: dt.TaskKey, enabled: dt.Enabled, status: "pending"}
|
||||
m.nextTaskID++
|
||||
if !dt.Enabled {
|
||||
task.status = "skipped"
|
||||
task.status = "skipped_disabled"
|
||||
}
|
||||
rec.tasks = append(rec.tasks, task)
|
||||
}
|
||||
@@ -578,7 +776,22 @@ func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInsta
|
||||
return ©Inst, nil
|
||||
}
|
||||
|
||||
func (m *memStore) ActivateInstance(_ context.Context, projectID int64, inst *WorkflowInstance, workflowKey string, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) {
|
||||
func (m *memStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) {
|
||||
if _, err := m.ConsumeActivationToken(ctx, token, projectID, planHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.ActivateInstance(ctx, projectID, inst, version, expectedVersion, activatedBy)
|
||||
}
|
||||
|
||||
func (m *memStore) CountActiveTaskRuns(_ context.Context, _ int64) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *memStore) ActivateInstance(_ context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) {
|
||||
workflowKey := ""
|
||||
if version != nil {
|
||||
workflowKey = version.WorkflowKey
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
pm := m.ensureMeta(projectID)
|
||||
@@ -648,11 +861,6 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st
|
||||
return nil, ErrInvalidStage
|
||||
}
|
||||
records[activeIdx].status = StageCompleted
|
||||
for j := range records[activeIdx].tasks {
|
||||
if records[activeIdx].tasks[j].enabled {
|
||||
records[activeIdx].tasks[j].status = "approved"
|
||||
}
|
||||
}
|
||||
nextIdx := -1
|
||||
for i, rec := range records {
|
||||
if rec.enabled && rec.status == StagePending {
|
||||
@@ -664,6 +872,11 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st
|
||||
inst.UpdatedAt = time.Now()
|
||||
if nextIdx < 0 {
|
||||
inst.Status = InstanceCompleted
|
||||
if pm := m.meta[inst.ProjectID]; pm.lifecycle != "" {
|
||||
pm.lifecycle = LifecyclePlanning
|
||||
pm.activeKey = ""
|
||||
m.meta[inst.ProjectID] = pm
|
||||
}
|
||||
} else {
|
||||
records[nextIdx].status = StageActive
|
||||
}
|
||||
@@ -692,10 +905,31 @@ func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskK
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
inst := m.live[projectID]
|
||||
if inst == nil || inst.ID != instanceID {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
inst.Status = "terminated"
|
||||
copyInst := *inst
|
||||
delete(m.live, projectID)
|
||||
pm := m.ensureMeta(projectID)
|
||||
pm.lifecycle = LifecyclePlanning
|
||||
pm.activeKey = ""
|
||||
m.meta[projectID] = pm
|
||||
_ = terminatedBy
|
||||
_ = reason
|
||||
_ = now
|
||||
return ©Inst, nil
|
||||
}
|
||||
|
||||
func (m *memStore) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.tokens[token] = activationTokenRec{
|
||||
m.tokens[hashActivationToken(token)] = activationTokenRec{
|
||||
projectID: projectID, planHash: planHash, expectedVer: expectedVersion,
|
||||
snapshot: snapshot, expiresAt: expiresAt,
|
||||
}
|
||||
@@ -705,12 +939,13 @@ func (m *memStore) SaveActivationToken(_ context.Context, token string, projectI
|
||||
func (m *memStore) ConsumeActivationToken(_ context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rec, ok := m.tokens[token]
|
||||
tokenHash := hashActivationToken(token)
|
||||
rec, ok := m.tokens[tokenHash]
|
||||
if !ok || rec.used || rec.projectID != projectID || rec.planHash != planHash || time.Now().After(rec.expiresAt) {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
rec.used = true
|
||||
m.tokens[token] = rec
|
||||
m.tokens[tokenHash] = rec
|
||||
plan := rec.snapshot
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// TerminateInput 受控终止输入。
|
||||
type TerminateInput struct {
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
ExpectedConfigRevision int `json:"expected_config_revision"`
|
||||
}
|
||||
|
||||
// TerminateResult 终止结果。
|
||||
type TerminateResult struct {
|
||||
Instance WorkflowInstance `json:"instance"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// PendingDocSummary 终止预览中的待处理文档摘要。
|
||||
type PendingDocSummary struct {
|
||||
TaskKey string `json:"task_key"`
|
||||
Status string `json:"status"`
|
||||
StageKey string `json:"stage_key,omitempty"`
|
||||
}
|
||||
|
||||
// TerminationPreview 受控终止影响预览。
|
||||
type TerminationPreview struct {
|
||||
InstanceID int64 `json:"instance_id"`
|
||||
PendingDocuments []PendingDocSummary `json:"pending_documents"`
|
||||
InProgressTaskRuns int `json:"in_progress_task_runs"`
|
||||
CanTerminate bool `json:"can_terminate"`
|
||||
Impact string `json:"impact,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) GetTerminationPreview(ctx context.Context, instanceID int64, projectID int64, userID int64, isCreator bool) (*TerminationPreview, error) {
|
||||
if !isCreator {
|
||||
return nil, ErrForbiddenNotCreator
|
||||
}
|
||||
if !s.Available() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil || live.ID != instanceID {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
if live.Status == InstanceCompleted || live.Status == "terminated" {
|
||||
return nil, ErrTerminationNotAllowed
|
||||
}
|
||||
|
||||
detail, err := s.store.GetInstanceByID(ctx, instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pending := make([]PendingDocSummary, 0)
|
||||
for _, st := range detail.Stages {
|
||||
if st.StageKey != detail.ActiveStageKey {
|
||||
continue
|
||||
}
|
||||
for _, dt := range st.DocTasks {
|
||||
if !dt.Enabled || dt.Status == "skipped_disabled" {
|
||||
continue
|
||||
}
|
||||
if dt.Status != "approved" {
|
||||
pending = append(pending, PendingDocSummary{
|
||||
TaskKey: dt.TaskKey, Status: dt.Status, StageKey: st.StageKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
taskRuns, _ := s.store.CountActiveTaskRuns(ctx, projectID)
|
||||
preview := &TerminationPreview{
|
||||
InstanceID: instanceID,
|
||||
PendingDocuments: pending,
|
||||
InProgressTaskRuns: taskRuns,
|
||||
CanTerminate: true,
|
||||
Impact: fmt.Sprintf(
|
||||
"%d pending documents, %d in-progress task runs will be cancelled",
|
||||
len(pending), taskRuns,
|
||||
),
|
||||
}
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
func (s *Service) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, userID int64, isCreator bool, in TerminateInput) (*TerminateResult, error) {
|
||||
if !isCreator {
|
||||
return nil, ErrForbiddenNotCreator
|
||||
}
|
||||
if !in.Confirm {
|
||||
return nil, ErrTerminationNotAllowed
|
||||
}
|
||||
if in.Reason == "" {
|
||||
return nil, ErrValidationFailed
|
||||
}
|
||||
if !s.Available() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil || live.ID != instanceID {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
if in.ExpectedConfigRevision > 0 && live.ConfigRevision != in.ExpectedConfigRevision {
|
||||
return nil, ErrInstanceRevisionConflict
|
||||
}
|
||||
inst, err := s.store.TerminateInstance(ctx, instanceID, projectID, userID, in.Reason)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TerminateResult{Instance: *inst, Reason: in.Reason}, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTerminateInstance_UpdatesProjectLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryStore()
|
||||
_ = SeedProjectMeta(store, 300, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, TestResolver(), nil, &StubDocChecker{States: map[string]string{"intake": "approved"}})
|
||||
|
||||
_, _ = svc.SaveWorkflowPlan(ctx, 300, 1, true, SavePlanInput{
|
||||
Plan: ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
preview, _ := svc.ActivationPreview(ctx, 300, 1, true)
|
||||
activated, _ := svc.Activate(ctx, 300, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
|
||||
pv, err := svc.GetTerminationPreview(ctx, activated.Instance.ID, 300, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTerminationPreview: %v", err)
|
||||
}
|
||||
if !pv.CanTerminate {
|
||||
t.Fatal("expected can terminate")
|
||||
}
|
||||
|
||||
res, err := svc.TerminateInstance(ctx, activated.Instance.ID, 300, 1, true, TerminateInput{
|
||||
Reason: "测试终止", Confirm: true, ExpectedConfigRevision: activated.Instance.ConfigRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TerminateInstance: %v", err)
|
||||
}
|
||||
if res.Instance.Status != "terminated" {
|
||||
t.Fatalf("expected terminated, got %q", res.Instance.Status)
|
||||
}
|
||||
|
||||
lifecycle, activeKey, _, _, err := store.GetProjectMeta(ctx, 300)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProjectMeta: %v", err)
|
||||
}
|
||||
if lifecycle != LifecyclePlanning || activeKey != "" {
|
||||
t.Fatalf("expected planning with empty key, got lifecycle=%q key=%q", lifecycle, activeKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
// NewMemoryStore 返回内存 Store,供跨包集成测试使用。
|
||||
func NewMemoryStore() Store {
|
||||
return newMemStore()
|
||||
}
|
||||
|
||||
// TestResolver 返回最小正式商务工作流解析器。
|
||||
func TestResolver() VersionResolver {
|
||||
return testResolver()
|
||||
}
|
||||
|
||||
// ValidTestPlan 返回单阶段 intake 测试规划。
|
||||
func ValidTestPlan() WorkflowPlan {
|
||||
return validPlan()
|
||||
}
|
||||
|
||||
// StubDocChecker 内存文档批准状态探针。
|
||||
type StubDocChecker struct {
|
||||
States map[string]string
|
||||
}
|
||||
|
||||
func (s *StubDocChecker) TaskApprovalStates(_ context.Context, _ int64) (map[string]string, error) {
|
||||
if s == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return s.States, nil
|
||||
}
|
||||
|
||||
type stubVersionResolver struct {
|
||||
versions map[string]*workflowdef.Version
|
||||
defs map[string]*workflowdef.Definition
|
||||
}
|
||||
|
||||
func (s *stubVersionResolver) GetDefinitionByKey(_ context.Context, key string) (*workflowdef.Definition, error) {
|
||||
d, ok := s.defs[key]
|
||||
if !ok {
|
||||
return nil, workflowdef.ErrNotFound
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *stubVersionResolver) GetVersion(_ context.Context, workflowID int64, version int) (*workflowdef.Version, error) {
|
||||
for _, v := range s.versions {
|
||||
if v.WorkflowID == workflowID && v.Version == version {
|
||||
copyV := *v
|
||||
return ©V, nil
|
||||
}
|
||||
}
|
||||
return nil, workflowdef.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *stubVersionResolver) ListDefinitions(_ context.Context) ([]workflowdef.Definition, error) {
|
||||
out := make([]workflowdef.Definition, 0, len(s.defs))
|
||||
for _, d := range s.defs {
|
||||
out = append(out, *d)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func disabledDocResolver() *stubVersionResolver {
|
||||
r := testResolver()
|
||||
r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks = append(
|
||||
r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks,
|
||||
workflowdef.DocTaskDef{
|
||||
Key: "research", Title: "市场调研", CreatorConfigurable: true, BlockingWhenEnabled: false, DefaultEnabled: false,
|
||||
OutputDocType: "research", OutputDocTitle: "市场调研",
|
||||
},
|
||||
)
|
||||
return r
|
||||
}
|
||||
|
||||
func testResolver() *stubVersionResolver {
|
||||
key := workflowdef.OfficialKeyMain
|
||||
ver := &workflowdef.Version{
|
||||
ID: 100, WorkflowID: 10, Version: 1, Status: workflowdef.StatusPublished,
|
||||
WorkflowKey: key, WorkflowName: workflowdef.OfficialWorkflowName(key),
|
||||
Definition: workflowdef.VersionDefinition{
|
||||
SchemaVersion: 2,
|
||||
WorkflowKey: key,
|
||||
Stages: []workflowdef.StageDef{{
|
||||
Key: "V0.1_triage", Name: "V0.1 需求摸底", Optional: false, Position: 10,
|
||||
DocTasks: []workflowdef.DocTaskDef{{
|
||||
Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true,
|
||||
OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
return &stubVersionResolver{
|
||||
defs: map[string]*workflowdef.Definition{
|
||||
key: {ID: 10, Key: key, Name: workflowdef.OfficialWorkflowName(key), LatestPublishedVersion: 1},
|
||||
},
|
||||
versions: map[string]*workflowdef.Version{key: ver},
|
||||
}
|
||||
}
|
||||
|
||||
func validPlan() WorkflowPlan {
|
||||
return WorkflowPlan{
|
||||
WorkflowDefinitionKey: workflowdef.OfficialKeyMain,
|
||||
WorkflowVersion: 1,
|
||||
Stages: []StagePlan{{
|
||||
StageKey: "V0.1_triage", Enabled: true,
|
||||
DocTasks: []DocTaskPlan{{
|
||||
TaskKey: "intake", Enabled: true,
|
||||
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -27,16 +27,18 @@ type DocDuty struct {
|
||||
|
||||
// DocTaskPlan 文档任务裁剪与职责。
|
||||
type DocTaskPlan struct {
|
||||
TaskKey string `json:"task_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Duties DocDuty `json:"duties"`
|
||||
TaskKey string `json:"task_key"`
|
||||
TaskTitle string `json:"task_title,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Duties DocDuty `json:"duties"`
|
||||
}
|
||||
|
||||
// StagePlan 阶段裁剪与文档任务。
|
||||
type StagePlan struct {
|
||||
StageKey string `json:"stage_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
DocTasks []DocTaskPlan `json:"doc_tasks"`
|
||||
StageKey string `json:"stage_key"`
|
||||
StageName string `json:"stage_name,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
DocTasks []DocTaskPlan `json:"doc_tasks"`
|
||||
}
|
||||
|
||||
// WorkflowPlan 项目工作流规划快照。
|
||||
@@ -99,31 +101,31 @@ type SavePlanInput struct {
|
||||
|
||||
// ActivationPreview 激活预览与确认令牌。
|
||||
type ActivationPreview struct {
|
||||
ConfirmationToken string `json:"confirmation_token"`
|
||||
PlanHash string `json:"plan_hash"`
|
||||
ExpectedProjectVersion int `json:"expected_project_version"`
|
||||
ExpectedPlanRevision int `json:"expected_plan_revision"`
|
||||
Summary PreviewSummary `json:"summary"`
|
||||
Validation workflowdef.ValidationResult `json:"validation"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ConfirmationToken string `json:"confirmation_token"`
|
||||
PlanHash string `json:"plan_hash"`
|
||||
ExpectedProjectVersion int `json:"expected_project_version"`
|
||||
ExpectedPlanRevision int `json:"expected_plan_revision"`
|
||||
Summary PreviewSummary `json:"summary"`
|
||||
Validation workflowdef.ValidationResult `json:"validation"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// PreviewSummary 激活影响摘要。
|
||||
type PreviewSummary struct {
|
||||
WorkflowName string `json:"workflow_name"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
EnabledStages int `json:"enabled_stages"`
|
||||
EnabledDocTasks int `json:"enabled_doc_tasks"`
|
||||
AssignedDuties int `json:"assigned_duties"`
|
||||
FirstStageKey string `json:"first_stage_key,omitempty"`
|
||||
FirstStageName string `json:"first_stage_name,omitempty"`
|
||||
WorkflowName string `json:"workflow_name"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
EnabledStages int `json:"enabled_stages"`
|
||||
EnabledDocTasks int `json:"enabled_doc_tasks"`
|
||||
AssignedDuties int `json:"assigned_duties"`
|
||||
FirstStageKey string `json:"first_stage_key,omitempty"`
|
||||
FirstStageName string `json:"first_stage_name,omitempty"`
|
||||
}
|
||||
|
||||
// ActivateInput 二次确认激活。
|
||||
type ActivateInput struct {
|
||||
ConfirmationToken string `json:"confirmation_token"`
|
||||
ExpectedProjectVersion int `json:"expected_project_version"`
|
||||
ExpectedPlanRevision int `json:"expected_plan_revision"`
|
||||
ConfirmationToken string `json:"confirmation_token"`
|
||||
ExpectedProjectVersion int `json:"expected_project_version"`
|
||||
ExpectedPlanRevision int `json:"expected_plan_revision"`
|
||||
}
|
||||
|
||||
// ActivateResult 激活结果。
|
||||
@@ -166,8 +168,8 @@ type StageInstance struct {
|
||||
// InstanceDetail 工作流实例详情(含阶段树)。
|
||||
type InstanceDetail struct {
|
||||
Instance WorkflowInstance `json:"instance"`
|
||||
ActiveStageKey string `json:"active_stage_key,omitempty"`
|
||||
Stages []StageInstance `json:"stages"`
|
||||
ActiveStageKey string `json:"active_stage_key,omitempty"`
|
||||
Stages []StageInstance `json:"stages"`
|
||||
}
|
||||
|
||||
// GateIssue 阶段门禁阻断项。
|
||||
@@ -188,14 +190,14 @@ type DocTaskReadiness struct {
|
||||
|
||||
// StageReadiness 阶段推进就绪评估。
|
||||
type StageReadiness struct {
|
||||
InstanceID int64 `json:"instance_id"`
|
||||
StageKey string `json:"stage_key"`
|
||||
StageName string `json:"stage_name"`
|
||||
Ready bool `json:"ready"`
|
||||
CanAdvance bool `json:"can_advance"`
|
||||
BlockingIssues []GateIssue `json:"blocking_issues"`
|
||||
DocTasks []DocTaskReadiness `json:"doc_tasks"`
|
||||
ConfigRevision int `json:"config_revision"`
|
||||
InstanceID int64 `json:"instance_id"`
|
||||
StageKey string `json:"stage_key"`
|
||||
StageName string `json:"stage_name"`
|
||||
Ready bool `json:"ready"`
|
||||
CanAdvance bool `json:"can_advance"`
|
||||
BlockingIssues []GateIssue `json:"blocking_issues"`
|
||||
DocTasks []DocTaskReadiness `json:"doc_tasks"`
|
||||
ConfigRevision int `json:"config_revision"`
|
||||
}
|
||||
|
||||
// AdvanceStageInput 创建者确认推进。
|
||||
@@ -206,10 +208,10 @@ type AdvanceStageInput struct {
|
||||
|
||||
// AdvanceStageResult 阶段推进结果。
|
||||
type AdvanceStageResult struct {
|
||||
Instance WorkflowInstance `json:"instance"`
|
||||
CompletedStage string `json:"completed_stage"`
|
||||
CompletedStageInstanceID int64 `json:"completed_stage_instance_id,omitempty"`
|
||||
CompletedReadinessRevision int `json:"completed_readiness_revision,omitempty"`
|
||||
NextStageKey string `json:"next_stage_key,omitempty"`
|
||||
InstanceDone bool `json:"instance_done"`
|
||||
Instance WorkflowInstance `json:"instance"`
|
||||
CompletedStage string `json:"completed_stage"`
|
||||
CompletedStageInstanceID int64 `json:"completed_stage_instance_id,omitempty"`
|
||||
CompletedReadinessRevision int `json:"completed_readiness_revision,omitempty"`
|
||||
NextStageKey string `json:"next_stage_key,omitempty"`
|
||||
InstanceDone bool `json:"instance_done"`
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V
|
||||
Code: "WORKFLOW_NOT_PUBLISHED", Message: "只能绑定已发布的工作流版本",
|
||||
})
|
||||
}
|
||||
if !workflowdef.IsOfficialWorkflowKey(plan.WorkflowDefinitionKey) {
|
||||
issues = append(issues, workflowdef.ValidationIssue{
|
||||
Code: "WORKFLOW_NOT_SELECTABLE", Field: "workflow_definition_key",
|
||||
Message: "仅允许选择正式三工作流",
|
||||
})
|
||||
}
|
||||
if plan.WorkflowDefinitionKey != version.WorkflowKey {
|
||||
issues = append(issues, workflowdef.ValidationIssue{
|
||||
Code: "WORKFLOW_KEY_MISMATCH", Field: "workflow_definition_key",
|
||||
@@ -79,9 +85,9 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (td.Required || td.Blocking) && !dt.Enabled {
|
||||
if !dt.Enabled && !td.IsCreatorConfigurable() {
|
||||
issues = append(issues, workflowdef.ValidationIssue{
|
||||
Code: "REQUIRED_DOC_DISABLED", Field: dt.TaskKey, Message: "必需/阻断文档任务不能关闭",
|
||||
Code: "REQUIRED_DOC_DISABLED", Field: dt.TaskKey, Message: "该文档任务不可由创建者关闭",
|
||||
})
|
||||
}
|
||||
if dt.Enabled {
|
||||
@@ -105,6 +111,35 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V
|
||||
return workflowdef.ValidationResult{Valid: len(issues) == 0, Issues: issues}
|
||||
}
|
||||
|
||||
func validateActivationPlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.ValidationResult {
|
||||
base := validatePlan(plan, version)
|
||||
if !base.Valid {
|
||||
return base
|
||||
}
|
||||
if countEnabledDocTasks(plan) == 0 {
|
||||
base.Valid = false
|
||||
base.Issues = append(base.Issues, workflowdef.ValidationIssue{
|
||||
Code: "EMPTY_WORKFLOW", Message: "至少启用一个文档任务才能激活工作流",
|
||||
})
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func countEnabledDocTasks(plan WorkflowPlan) int {
|
||||
n := 0
|
||||
for _, st := range plan.Stages {
|
||||
if !st.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, dt := range st.DocTasks {
|
||||
if dt.Enabled {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func validateDuties(d DocDuty, taskKey string) []workflowdef.ValidationIssue {
|
||||
issues := make([]workflowdef.ValidationIssue, 0)
|
||||
if d.AuthorUserID <= 0 {
|
||||
@@ -152,14 +187,16 @@ func buildDefaultPlan(version *workflowdef.Version) WorkflowPlan {
|
||||
}
|
||||
for _, st := range version.Definition.Stages {
|
||||
sp := StagePlan{
|
||||
StageKey: st.Key,
|
||||
Enabled: !st.Optional,
|
||||
DocTasks: make([]DocTaskPlan, 0, len(st.DocTasks)),
|
||||
StageKey: st.Key,
|
||||
StageName: st.Name,
|
||||
Enabled: !st.Optional,
|
||||
DocTasks: make([]DocTaskPlan, 0, len(st.DocTasks)),
|
||||
}
|
||||
for _, dt := range st.DocTasks {
|
||||
sp.DocTasks = append(sp.DocTasks, DocTaskPlan{
|
||||
TaskKey: dt.Key,
|
||||
Enabled: dt.DefaultEnabled || dt.Required || dt.Blocking,
|
||||
TaskKey: dt.Key,
|
||||
TaskTitle: dt.Title,
|
||||
Enabled: dt.DefaultEnabled,
|
||||
})
|
||||
}
|
||||
plan.Stages = append(plan.Stages, sp)
|
||||
@@ -167,6 +204,35 @@ func buildDefaultPlan(version *workflowdef.Version) WorkflowPlan {
|
||||
return plan
|
||||
}
|
||||
|
||||
// applyDefinitionLabels 使用已发布定义覆盖展示名称,避免客户端把自定义文本写入冻结快照。
|
||||
func applyDefinitionLabels(plan *WorkflowPlan, version *workflowdef.Version) {
|
||||
if plan == nil || version == nil {
|
||||
return
|
||||
}
|
||||
stageDefs := make(map[string]workflowdef.StageDef, len(version.Definition.Stages))
|
||||
for _, stage := range version.Definition.Stages {
|
||||
stageDefs[stage.Key] = stage
|
||||
}
|
||||
for stageIdx := range plan.Stages {
|
||||
stage := &plan.Stages[stageIdx]
|
||||
stageDef, ok := stageDefs[stage.StageKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stage.StageName = stageDef.Name
|
||||
taskDefs := make(map[string]workflowdef.DocTaskDef, len(stageDef.DocTasks))
|
||||
for _, task := range stageDef.DocTasks {
|
||||
taskDefs[task.Key] = task
|
||||
}
|
||||
for taskIdx := range stage.DocTasks {
|
||||
task := &stage.DocTasks[taskIdx]
|
||||
if taskDef, exists := taskDefs[task.TaskKey]; exists {
|
||||
task.TaskTitle = taskDef.Title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func previewSummary(plan WorkflowPlan, version *workflowdef.Version) PreviewSummary {
|
||||
sum := PreviewSummary{
|
||||
WorkflowVersion: plan.WorkflowVersion,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
func TestValidateActivationPlan_EmptyWorkflowBlocked(t *testing.T) {
|
||||
plan := WorkflowPlan{
|
||||
WorkflowDefinitionKey: workflowdef.OfficialKeyProductManager,
|
||||
WorkflowVersion: 2,
|
||||
Stages: []StagePlan{{
|
||||
StageKey: "requirements_baseline",
|
||||
Enabled: true,
|
||||
DocTasks: []DocTaskPlan{{
|
||||
TaskKey: "system_reqs",
|
||||
Enabled: false,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
version := &workflowdef.Version{
|
||||
Status: workflowdef.StatusPublished,
|
||||
WorkflowKey: workflowdef.OfficialKeyProductManager,
|
||||
Version: 2,
|
||||
Definition: workflowdef.VersionDefinition{
|
||||
SchemaVersion: 2,
|
||||
Stages: []workflowdef.StageDef{{
|
||||
Key: "requirements_baseline", Position: 10,
|
||||
DocTasks: []workflowdef.DocTaskDef{{
|
||||
Key: "system_reqs", CreatorConfigurable: true, DefaultEnabled: true,
|
||||
OutputDocType: "system_reqs",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
res := validatePlan(plan, version)
|
||||
if !res.Valid {
|
||||
t.Fatalf("save plan should allow all docs disabled: %+v", res.Issues)
|
||||
}
|
||||
act := validateActivationPlan(plan, version)
|
||||
if act.Valid {
|
||||
t.Fatal("activation should reject empty enabled doc set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultPlan_IncludesDisplayNames(t *testing.T) {
|
||||
version := testResolver().versions[workflowdef.OfficialKeyMain]
|
||||
plan := buildDefaultPlan(version)
|
||||
if len(plan.Stages) != 1 || plan.Stages[0].StageName != "V0.1 需求摸底" {
|
||||
t.Fatalf("expected stage display name, got %+v", plan.Stages)
|
||||
}
|
||||
if len(plan.Stages[0].DocTasks) != 1 || plan.Stages[0].DocTasks[0].TaskTitle != "用户初始需求收集表" {
|
||||
t.Fatalf("expected task display title, got %+v", plan.Stages[0].DocTasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDefinitionLabels_OverridesClientText(t *testing.T) {
|
||||
version := testResolver().versions[workflowdef.OfficialKeyMain]
|
||||
plan := validPlan()
|
||||
plan.Stages[0].StageName = "客户端伪造阶段"
|
||||
plan.Stages[0].DocTasks[0].TaskTitle = "客户端伪造文档"
|
||||
applyDefinitionLabels(&plan, version)
|
||||
if plan.Stages[0].StageName != "V0.1 需求摸底" || plan.Stages[0].DocTasks[0].TaskTitle != "用户初始需求收集表" {
|
||||
t.Fatalf("definition labels must win, got %+v", plan.Stages[0])
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,11 @@ func (s *Service) ListProjects(ctx context.Context, userID int64) ([]*Project, e
|
||||
return s.store.ListProjectsForUser(ctx, userID)
|
||||
}
|
||||
|
||||
// ListAllProjects 查询全部项目,仅供已完成系统管理员鉴权的调用方使用。
|
||||
func (s *Service) ListAllProjects(ctx context.Context) ([]*Project, error) {
|
||||
return s.store.ListAllProjects(ctx)
|
||||
}
|
||||
|
||||
// IsProjectCreator 判断用户是否为指定项目的权威创建人
|
||||
func (s *Service) IsProjectCreator(ctx context.Context, projectID int64, userID int64) (bool, error) {
|
||||
p, err := s.store.GetProjectByID(ctx, projectID)
|
||||
|
||||
@@ -125,6 +125,39 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkflowService_ListAllProjects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(NewStore(nil), NewDefaultCatalog())
|
||||
creator := &auth.UserAccount{ID: 102, Username: "lisi", Name: "李主管"}
|
||||
|
||||
created, err := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "管理员跨项目查看测试"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject failed: %v", err)
|
||||
}
|
||||
visible, err := svc.ListProjects(ctx, 999)
|
||||
if err != nil {
|
||||
t.Fatalf("ListProjects failed: %v", err)
|
||||
}
|
||||
if len(visible) != 0 {
|
||||
t.Fatalf("unrelated user should not see projects, got %d", len(visible))
|
||||
}
|
||||
|
||||
all, err := svc.ListAllProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllProjects failed: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, project := range all {
|
||||
if project.ID == created.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("ListAllProjects should include project %d", created.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkflowService_LineageAndStale(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewStore(nil)
|
||||
|
||||
@@ -17,6 +17,7 @@ type Store interface {
|
||||
CreateProject(ctx context.Context, p *Project) (*Project, error)
|
||||
GetProjectByID(ctx context.Context, id int64) (*Project, error)
|
||||
GetProjectByCode(ctx context.Context, code string) (*Project, error)
|
||||
ListAllProjects(ctx context.Context) ([]*Project, error)
|
||||
ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error)
|
||||
ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error)
|
||||
EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error
|
||||
@@ -145,16 +146,16 @@ func (s *pgStore) GetProjectByCode(ctx context.Context, code string) (*Project,
|
||||
}
|
||||
|
||||
func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) {
|
||||
// 允许查看项目(自己创建,或公开项目,或已有会话参与)
|
||||
// 仅创建人或 active 项目成员可见(移除 creator_id=1/NULL 公开规则)
|
||||
query := `
|
||||
SELECT DISTINCT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market,
|
||||
p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 1),
|
||||
p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 0),
|
||||
COALESCE(p.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''),
|
||||
COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0),
|
||||
p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
LEFT JOIN chat_sessions cs ON cs.project_id = p.id AND cs.user_id = $1
|
||||
WHERE p.creator_user_id = $1 OR cs.id IS NOT NULL OR p.creator_user_id IS NULL OR p.creator_user_id = 1
|
||||
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = $1 AND pm.status = 'active'
|
||||
WHERE p.creator_user_id = $1 OR pm.user_id IS NOT NULL
|
||||
ORDER BY p.id DESC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, query, userID)
|
||||
@@ -179,6 +180,38 @@ func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pro
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) ListAllProjects(ctx context.Context) ([]*Project, error) {
|
||||
query := `
|
||||
SELECT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market,
|
||||
p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 0),
|
||||
COALESCE(p.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''),
|
||||
COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0),
|
||||
p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
ORDER BY p.id DESC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list all projects failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Project
|
||||
for rows.Next() {
|
||||
p := &Project{}
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, p)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
func (s *pgStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) {
|
||||
query := `
|
||||
SELECT id, project_id, workflow_key, status, enabled, created_at, updated_at
|
||||
@@ -652,7 +685,7 @@ func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pr
|
||||
defer m.mu.RUnlock()
|
||||
var list []*Project
|
||||
for _, p := range m.projects {
|
||||
if p.CreatorUserID == userID || p.CreatorUserID == 0 || p.CreatorUserID == 1 {
|
||||
if p.CreatorUserID == userID {
|
||||
cpy := *p
|
||||
list = append(list, &cpy)
|
||||
}
|
||||
@@ -660,6 +693,17 @@ func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pr
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (m *memStore) ListAllProjects(ctx context.Context) ([]*Project, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
list := make([]*Project, 0, len(m.projects))
|
||||
for _, p := range m.projects {
|
||||
cpy := *p
|
||||
list = append(list, &cpy)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (m *memStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"chat-agent-system/internal/authz"
|
||||
)
|
||||
|
||||
// handleAdminPermissions GET /api/v1/admin/permissions — 权限目录(服务端权威)
|
||||
func (s *Server) handleAdminPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requirePrincipal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
catalog := authz.PermissionCatalog()
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{"permissions": catalog})
|
||||
}
|
||||
@@ -87,7 +87,10 @@ func TestUploadProjectAsset_RejectsOversizedFilename(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/projects/7/assets", &body)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
withBearerAuth(req, loginTestAdmin(t, srv))
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("expected 415 for long filename, got %d", resp.StatusCode)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/config"
|
||||
)
|
||||
|
||||
func TestGateA_LineageAndRunsRequireProjectAccess(t *testing.T) {
|
||||
cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "main", ContextDbHost: "127.0.0.1"}
|
||||
srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}))
|
||||
srv.cfg = cfg
|
||||
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/projects/999/lineage?doc_id=doc_x"},
|
||||
{http.MethodGet, "/api/v1/projects/999/workflows/main/runs"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s expected 401 without auth, got %d", tc.method, tc.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateA_CreateProjectRequiresPermission(t *testing.T) {
|
||||
cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "main", ContextDbHost: "127.0.0.1"}
|
||||
srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}))
|
||||
srv.cfg = cfg
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,21 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
_ = json.NewEncoder(w).Encode(updated)
|
||||
}
|
||||
|
||||
// handleAuthPublicConfig 返回登录/注册页所需的公开准入配置(无需鉴权)
|
||||
func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "不支持的方法", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
cfg, err := s.authSvc.GetConfig(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(cfg)
|
||||
}
|
||||
|
||||
// handleAdminConfig 获取或更新系统准入配置
|
||||
func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
|
||||
@@ -25,6 +25,42 @@ func setupTestServer() (*httptest.Server, *auth.Service) {
|
||||
return ts, srv.AuthService()
|
||||
}
|
||||
|
||||
func TestAuthHandlers_PublicConfigUnauthenticated(t *testing.T) {
|
||||
ts, authSvc := setupTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
_ = authSvc.UpdateConfig(t.Context(), auth.SystemAuthConfig{
|
||||
AllowRegistration: true,
|
||||
RequireApproval: true,
|
||||
})
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/v1/auth/public-config")
|
||||
if err != nil {
|
||||
t.Fatalf("public-config request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected 200 for unauthenticated /auth/public-config, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var cfg auth.SystemAuthConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
|
||||
t.Fatalf("decode public-config: %v", err)
|
||||
}
|
||||
if !cfg.AllowRegistration || !cfg.RequireApproval {
|
||||
t.Fatalf("unexpected public-config: %+v", cfg)
|
||||
}
|
||||
|
||||
adminResp, err := http.Get(ts.URL + "/api/v1/admin/config")
|
||||
if err != nil {
|
||||
t.Fatalf("admin config request failed: %v", err)
|
||||
}
|
||||
defer adminResp.Body.Close()
|
||||
if adminResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("Expected 401 for unauthenticated /admin/config, got %d", adminResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandlers_UnauthenticatedMeReturns401(t *testing.T) {
|
||||
ts, _ := setupTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user