merge: feature/multi-intent-u5-decision-api

This commit is contained in:
weichen
2026-09-16 01:01:39 +00:00
20 changed files with 1055 additions and 77 deletions
+4 -2
View File
@@ -5,7 +5,7 @@
## 焦点
1. **MULTI-INTENT HITL**:U4 已合入本地 `dev`([U4 报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md));下一单元 U5 decision API 待认领
1. **MULTI-INTENT HITL**:U5 合入中;U6 Web 待认领
2. **发布门禁**:CI `TEST_DATABASE_URL`、真实 PI 三工作流 E2E、前端人工抽检仍开放([gap-matrix](../remediation/gap-matrix.md))
3. **CHAT-INTENT**:双标签 busy 已签(项目 **6275**);不删 `sess-p143-u9`;项目 143 只读
@@ -13,6 +13,7 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Cursor | 2026-09-16-multi-intent-u5-decision-api | feature/multi-intent-u5-decision-api | - | intent-plan | done |
| Cursor | 2026-09-16-multi-intent-u4-outbox-projection | feature/multi-intent-u4-outbox-projection | - | events | done |
| Cursor | 2026-09-15-multi-intent-u3-intentflow-pg | feature/multi-intent-u3-intentflow-pg | - | intent-plan | done |
| Cursor | 2026-09-15-multi-intent-u2-segmenter | feature/multi-intent-u2-segmenter | - | intent | done |
@@ -88,7 +89,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| - | idle | - | - | - |
| Cursor | merging | 2026-09-16 | U5 decision API merge | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -126,6 +127,7 @@
| 多意图 HITL U2 分段 | [../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md) |
| 多意图 HITL U3 PG | [../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) |
| 多意图 HITL U4 投影 | [../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md) |
| 多意图 HITL U5 API | [../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) |
| 文档可靠性 M1 报告 | [../test-reports/2026-09-14-document-reliability-pilot-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) |
| 文档可靠性 M1 活验 | [../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md) |
| 文档可靠性 M2 | [../test-reports/2026-09-14-document-reliability-m2-test-report.md](../test-reports/2026-09-14-document-reliability-m2-test-report.md) |
@@ -0,0 +1,20 @@
---
title: "多意图 HITL U5 decision API checklist"
status: done
created: 2026-09-16
updated: 2026-09-16
related_task: "2026-09-16-multi-intent-u5-decision-api"
---
# 多意图 HITL U5 decision API checklist
- [x] 冻结 REST 路径挂到认证 mux — 证据:[http-verbose.txt](../../test-reports/assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt)
- [x] Principal 不从 body 复制 — 证据:`TestIntentPlanHTTP_IgnoresBodyPrincipalAndSameReceipt`
- [x] 非成员/跨作用域/管理员负例 — 证据:`TestIntentPlanHTTP_ScopeNegatives` / `AdminDoesNotBypassMembership`
- [x] StepAuthorizer 在 confirm 与 execute 前调用 — 证据:[authorize-verbose.txt](../../test-reports/assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt)
- [x] stale/nonce/edit/非法部分确认 — 证据:同上
- [x] arch-check / short 绿 — 证据:[short.txt](../../test-reports/assets/2026-09-16-multi-intent-u5-decision-api/short.txt)
## 完成定义
- [x] 全部勾选且有证据链接
@@ -55,7 +55,7 @@ mutex: "-"
2. [x] U2 characterization-first 建 golden/hard-negative 与 shadow 基线([独立任务](./2026-09-15-multi-intent-u2-segmenter.md);[报告](../../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md));
3. [x] U3 建 intentflow owner、PG schema、CAS/lease/fencing/retention([独立任务](./2026-09-15-multi-intent-u3-intentflow-pg.md);[报告](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md));
4. [x] U4 接 Track E outbox/dispatcher,只投影状态事实([独立任务](./2026-09-16-multi-intent-u4-outbox-projection.md);[报告](../../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md));
5. [ ] U5 建统一 decision command 与动作 owner 授权端口;
5. [x] U5 建统一 decision command 与动作 owner 授权端口([独立任务](./2026-09-16-multi-intent-u5-decision-api.md);[报告](../../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md));
6. [ ] U6 取消计划动作的前端本地成功路径,以 PlanView 恢复;
7. [ ] U7 只开放独立低风险查询;
8. [ ] U8 只接 task generate,完成首版 real PI 闭环;
@@ -94,7 +94,7 @@ mutex: "-"
- Checklist: [U1](../checklists/2026-09-15-multi-intent-u1-contracts.md);[U2](../checklists/2026-09-15-multi-intent-u2-segmenter.md);[U3](../checklists/2026-09-15-multi-intent-u3-intentflow-pg.md)
- Handoff: 每个 U-ID 独立建立
- Test report: [U1](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2](../../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md)
- Test report: [U1](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2](../../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md);[U4](../../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md);[U5](../../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md)
## 证据 / 备注
@@ -0,0 +1,46 @@
---
title: "晋升 dev → test 并部署测试环境"
status: done
created: 2026-09-15
updated: 2026-09-15
owner: Cursor
branch: feature/promote-dev-to-test-2026-09-15
worktree: "-"
mutex: infra
---
# 晋升 dev → test 并部署测试环境
## 目标
把已完成的 Phase 2 代码从本地 `dev` 合入 `test`,重建并冒烟测试环境,供人工实测。不实施多意图 HITL。
## 范围
- `test` 分支 `--no-ff` 合入当前 `dev`
- `./scripts/env-service.sh test rebuild`
- test 库 notify/workflow_advance/projection 切 live(L4 去 hook 后 off 会停写);FAV 保持 off
## 非目标
- 不实施多意图 HITL U1+
- 不合入 `main`、不启用 FAV
- 不写项目 143
## 步骤
1. [x] 确认 Phase 2 已在本地 `dev`
2. [x] occupy 集成槽并合入 `test`
3. [x] rebuild 测试服务
4. [x] health/ready + schema 冒烟
## 验收标准
- [x] `:8081` health/ready PASS
- [x] events `:8089` health PASS
- [x] FAV 仍 off
## 相关链接
- Tag: `rc-v1.0.0-beta.1`
- Merge: `test@2e826e7`
@@ -0,0 +1,54 @@
---
title: "多意图 HITL U5:决策 API 与授权端口"
status: done
created: 2026-09-16
updated: 2026-09-16
owner: Cursor
branch: feature/multi-intent-u5-decision-api
worktree: "-"
mutex: intent-plan
---
# 多意图 HITL U5:决策 API 与授权端口
## 目标
按钮与自然语言走同一 `DecidePlan` 命令;GET 发现活动计划不依赖投影;执行前由动作属主重新授权。确认不是授权。
## 范围
- 冻结路径:`GET /api/v1/intent-plans/active`、`GET /api/v1/intent-plans/{plan_id}`、`POST /api/v1/intent-plans/{plan_id}/decisions`
- Principal 只从认证会话 + query 的 project/session 组装,忽略 body 中的 user/project/session
- 四元作用域;非成员 403;跨 session/user 隐藏为 404
- 系统管理员不得绕过项目成员检查
- `StepAuthorizer` 端口 + Fake/dry-run;确认与执行前鉴权
- expected revision、nonce、依赖闭包、edit 升 revision
## 非目标
- 不接真实业务属主 adapter(U7/U8)
- 不改 Web(U6)、不改默认 legacy HandleTurn
- 不 rebuild 共享 `:8080`
## 步骤
1. [x] 认领 CURRENT(mutex `intent-plan`)
2. [x] 授权端口与 confirm/execute 即时鉴权
3. [x] HTTP handlers + 权限负例
4. [x] 报告并合回本地 dev
## 验收标准
- [ ] 投影缺失仍能 GET active
- [ ] 未登录 401;非成员 403;跨项目/会话/用户隐藏
- [ ] stale revision 409;重复 nonce 同 digest 同 receipt
- [ ] 非法部分确认拒绝;edit 使旧 revision 失效
- [ ] 确认前零副作用;权限撤销/资源版本 fail closed
- [ ] 无系统身份绕过
- [ ] `go test -short` 绿
## 相关链接
- 总览: [2026-09-15-multi-intent-hitl.md](./2026-09-15-multi-intent-hitl.md)
- 设计: [../../system-design/2026-09-15-multi-intent-hitl-plan.md](../../system-design/2026-09-15-multi-intent-hitl-plan.md)
- Checklist: [../checklists/2026-09-16-multi-intent-u5-decision-api.md](../checklists/2026-09-16-multi-intent-u5-decision-api.md)
+2 -2
View File
@@ -15,8 +15,8 @@
| 模块 | 能力 | 状态 | 证据 | 缺口/后续 | mutex |
|------|------|------|------|-----------|-------|
| 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md);[运行态报告](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md);[双标签活验](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/live.json);[多意图复核方案](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 契约](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 分段](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 PG](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | 明确写动作与只读总结已分流;双标签 `DOC_BUSY` 已签(项目 6275)。多意图 U1–U3 已完成(契约/shadow 提案/PG CAS);U4–U8 未实施;intent 纯依赖边界不得回退 | `intent` |
| 多意图持久计划 | app/intentflow 生命周期与 HITL 契约 | partial | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 报告](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md);[U4 报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md) | U1–U4 已完成。下一单元 U5 决策 API;U6 Web;U7–U8 只读执行与 task.generate。共享环境 schema 45 待晋升 | `intent-plan` |
| 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md);[运行态报告](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md);[双标签活验](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/live.json);[多意图复核方案](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 契约](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 分段](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 PG](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | 明确写动作与只读总结已分流;双标签 `DOC_BUSY` 已签(项目 6275)。多意图 U1–U5 已完成;U6–U8 未实施;intent 纯依赖边界不得回退 | `intent` |
| 多意图持久计划 | app/intentflow 生命周期与 HITL 契约 | partial | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 报告](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md);[U4 报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md);[U5 报告](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) | U1–U5 已完成。下一单元 U6 Web;U7–U8 只读执行与 task.generate。共享环境 schema 45 待晋升 | `intent-plan` |
| 对话 / ctxmgr | 多轮上下文、记忆、摘要 | partial | [system-design/multi-turn-dialogue](../system-design/multi-turn-dialogue/);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md);[U10 端口化](../archive/ops/2026-09-15-chatlog-contextasm-u10.md);[U10 报告](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md) | `CHAT-HISTORY-REFRESH` 闲聊刷新活验已补。U10 原地端口 **停止扩面**(未物理拆包)。语义摘要(D5 backlog) | `chat` |
| 智能体小队 | 角色-任务-工作区-黑板 | done | [system-design/agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6 backlog) | `squad` |
| 工作流治理 | 三正式工作流 + 阶段门禁 | partial | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md);[冻结态/职责可选整改](../remediation/2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md);[Wave 5 页面矩阵](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[职责-动作鉴权](../test-reports/2026-09-14-governance-action-level-authz-test-report.md) | 设置页/DutyMode + 动作级鉴权/未激活 202-blocked/关闭创建直通已落地。后续:none 文档真实 PI 审批长链路、CI 三工作流 E2E | `workflow` |
+1
View File
@@ -130,6 +130,7 @@
| U2 分段 | [../ops/short-term/2026-09-15-multi-intent-u2-segmenter.md](../ops/short-term/2026-09-15-multi-intent-u2-segmenter.md);[报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md) |
| U3 PG | [../ops/short-term/2026-09-15-multi-intent-u3-intentflow-pg.md](../ops/short-term/2026-09-15-multi-intent-u3-intentflow-pg.md);[报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) |
| U4 投影 | [../ops/short-term/2026-09-16-multi-intent-u4-outbox-projection.md](../ops/short-term/2026-09-16-multi-intent-u4-outbox-projection.md);[报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md) |
| U5 API | [../ops/short-term/2026-09-16-multi-intent-u5-decision-api.md](../ops/short-term/2026-09-16-multi-intent-u5-decision-api.md);[报告](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) |
| 一句话总结 | intent 保持纯规则,intentflow 持久化计划并统一确认、授权、幂等、恢复和逐动作执行 |
**硬边界**:确认不等于授权;PG 是计划真相,卡片/SSE 是投影;写动作按 owner 端口逐项接入;LLM 只能提出不可信分段建议。
@@ -164,6 +164,7 @@ imports:
- app
- app/chatflow
- app/docflow
- app/intentflow
- app/stageflow
- app/uow
- auth
@@ -0,0 +1,66 @@
# 多意图 HITL U5 决策 API 与授权端口 测试报告
## 一、 测试背景 (Background)
U4 已把计划事实写入 outbox。U5 要把按钮和自然语言接到同一 `DecidePlan` 命令,并在确认/执行前由动作属主重新授权。确认不是授权;系统管理员不得绕过项目成员检查。
## 二、 测试目标 (Test Objectives)
- 冻结 REST:active / `{plan_id}` / decisions
- Principal 只来自认证会话,忽略 body 中的 user/project/session
- 未登录 401;非成员 403;跨项目/会话隐藏 404
- 投影缺失仍能 GET active
- 权限撤销、资源版本、stale revision、非法部分确认、edit 升 revision
- 重复 nonce 同 digest 返回同一 receipt
## 三、 测试方案与环境 (Test Plan & Environment)
| 项 | 值 |
|----|-----|
| OS | Linux 7.0.0-31-generic |
| Go | go test -short |
| 分支 | `feature/multi-intent-u5-decision-api` |
| 后端 | 内存计划账本 + FakeActionOwner(dry-run) |
| 项目 143 | 只读;不 rebuild `:8080` |
| 证据 | `.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/` |
## 四、 预期结果 (Expected Results)
确认被拒绝时计划 revision/status 不变;执行前撤销权限 fail closed;HTTP 作用域负例全绿;arch-check 与全仓 short 绿。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 授权端口
- **测试输入**:`go test -v ./internal/app/intentflow -run 'TestConfirmDenied|TestResourceVersion|TestRevokeBetween|TestEditBumps|TestIllegalPartial'`
- **实际输出**:见 [authorize-verbose.txt](assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt)。五用例 PASS。
- **评价判定**:PASS。确认前零副作用;旧卡 edit 后 409。
- **性能与耗时**:4ms
### 5.2 HTTP 作用域与幂等
- **测试输入**:`go test -v ./internal/server -run TestIntentPlanHTTP`
- **实际输出**:见 [http-verbose.txt](assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt)。六用例 PASS(含管理员不得绕过成员检查、body principal 忽略、同 nonce receipt)。
- **评价判定**:PASS。
- **性能与耗时**:0.77s
### 5.3 全仓 short / arch-check
- **测试输入**:`./scripts/arch-check.sh --mode block`;`go test -short -count=1 ./...`
- **实际输出**:`arch-check block: production gate passed`。[short.txt](assets/2026-09-16-multi-intent-u5-decision-api/short.txt)
- **评价判定**:PASS。
- **性能与耗时**:约 13s
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 命令 | 结果 |
|------|------|
| `go test ./internal/app/intentflow ./internal/server ./internal/archtest` | PASS |
| `go test -short ./...` | PASS |
| `./scripts/arch-check.sh --mode block` | PASS |
通过率 100%。真实业务 adapter 与 Web 不在本单元。
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
U5 达到合入标准。后续 U6 以 PlanView 渲染卡片并 GET reconcile;U7 才开放 auto_read 查询执行。
@@ -0,0 +1,12 @@
=== RUN TestConfirmDeniedDoesNotMutatePlan
--- PASS: TestConfirmDeniedDoesNotMutatePlan (0.00s)
=== RUN TestResourceVersionConflictOnConfirm
--- PASS: TestResourceVersionConflictOnConfirm (0.00s)
=== RUN TestRevokeBetweenConfirmAndExecute
--- PASS: TestRevokeBetweenConfirmAndExecute (0.00s)
=== RUN TestEditBumpsRevisionAndInvalidatesOldCard
--- PASS: TestEditBumpsRevisionAndInvalidatesOldCard (0.00s)
=== RUN TestIllegalPartialConfirmMissingDependency
--- PASS: TestIllegalPartialConfirmMissingDependency (0.00s)
PASS
ok chat-agent-system/internal/app/intentflow 0.004s
@@ -0,0 +1,20 @@
=== RUN TestIntentPlanHTTP_Unauthenticated
2026/09/16 00:59:18 [kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式
--- PASS: TestIntentPlanHTTP_Unauthenticated (0.13s)
=== RUN TestIntentPlanHTTP_ActiveWithoutProjection
time=2026-09-16T00:59:18.421Z level=INFO msg="[kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式"
--- PASS: TestIntentPlanHTTP_ActiveWithoutProjection (0.13s)
=== RUN TestIntentPlanHTTP_ScopeNegatives
time=2026-09-16T00:59:18.550Z level=INFO msg="[kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式"
--- PASS: TestIntentPlanHTTP_ScopeNegatives (0.13s)
=== RUN TestIntentPlanHTTP_IgnoresBodyPrincipalAndSameReceipt
time=2026-09-16T00:59:18.678Z level=INFO msg="[kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式"
--- PASS: TestIntentPlanHTTP_IgnoresBodyPrincipalAndSameReceipt (0.12s)
=== RUN TestIntentPlanHTTP_StaleRevision
time=2026-09-16T00:59:18.800Z level=INFO msg="[kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式"
--- PASS: TestIntentPlanHTTP_StaleRevision (0.14s)
=== RUN TestIntentPlanHTTP_AdminDoesNotBypassMembership
time=2026-09-16T00:59:18.938Z level=INFO msg="[kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/?sslmode=disable): cannot parse `postgres://:xxxxx@:0/?sslmode=disable`: invalid port (outside range), 将降级运行在离线模式"
--- PASS: TestIntentPlanHTTP_AdminDoesNotBypassMembership (0.12s)
PASS
ok chat-agent-system/internal/server 0.774s
@@ -0,0 +1,61 @@
ok chat-agent-system/cmd/bizdev-cli 1.940s
? chat-agent-system/cmd/dataset [no test files]
ok chat-agent-system/cmd/document-content-reconcile 0.003s
? chat-agent-system/cmd/document-revision-backfill [no test files]
? chat-agent-system/cmd/eventmode [no test files]
? chat-agent-system/cmd/events [no test files]
? chat-agent-system/cmd/recategorize [no test files]
? chat-agent-system/cmd/server [no test files]
ok chat-agent-system/internal/agent 5.029s
ok chat-agent-system/internal/app 0.014s
ok chat-agent-system/internal/app/chatflow 0.004s
ok chat-agent-system/internal/app/docadvance 0.003s
ok chat-agent-system/internal/app/docflow 0.004s
ok chat-agent-system/internal/app/docnotify 0.003s
ok chat-agent-system/internal/app/docproject 0.003s
ok chat-agent-system/internal/app/intentflow 0.003s
ok chat-agent-system/internal/app/stageflow 0.004s
ok chat-agent-system/internal/app/uow 0.003s
ok chat-agent-system/internal/archtest 0.182s
ok chat-agent-system/internal/artifactvault 0.005s
ok chat-agent-system/internal/auth 0.822s
ok chat-agent-system/internal/authz 0.003s
ok chat-agent-system/internal/bizdev 0.170s
ok chat-agent-system/internal/chat 0.018s
ok chat-agent-system/internal/config 0.002s
ok chat-agent-system/internal/coordinator 0.016s
ok chat-agent-system/internal/ctxmgr 0.087s
ok chat-agent-system/internal/dataset 0.005s
ok chat-agent-system/internal/db 0.005s
ok chat-agent-system/internal/doccontent 0.002s
ok chat-agent-system/internal/docduty 0.005s
ok chat-agent-system/internal/docgov 0.018s
ok chat-agent-system/internal/docquery 0.005s
ok chat-agent-system/internal/docrevision 0.007s
ok chat-agent-system/internal/events 0.006s
ok chat-agent-system/internal/freedoc 0.027s
ok chat-agent-system/internal/fsutil 0.003s
ok chat-agent-system/internal/httpx 0.004s
ok chat-agent-system/internal/intent 0.030s
ok chat-agent-system/internal/kb 0.005s
ok chat-agent-system/internal/maturity 0.002s
ok chat-agent-system/internal/notification 0.079s
? chat-agent-system/internal/observability [no test files]
ok chat-agent-system/internal/projectcontext 0.005s
ok chat-agent-system/internal/projectinstance 0.007s
ok chat-agent-system/internal/projectworkflow 0.004s
ok chat-agent-system/internal/quote 0.009s
ok chat-agent-system/internal/replyexport 0.003s
ok chat-agent-system/internal/server 10.686s
ok chat-agent-system/internal/squad 0.031s
ok chat-agent-system/internal/storage 0.016s
ok chat-agent-system/internal/stream 0.003s
ok chat-agent-system/internal/stream/transport 0.002s
ok chat-agent-system/internal/taskrun 0.147s
ok chat-agent-system/internal/template 2.338s
ok chat-agent-system/internal/upload 0.002s
? chat-agent-system/internal/workflow [no test files]
ok chat-agent-system/internal/workflow/runtime 0.002s
ok chat-agent-system/internal/workflowdef 0.003s
ok chat-agent-system/internal/workspace 0.003s
? chat-agent-system/templates [no test files]
+1
View File
@@ -193,6 +193,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
rt.Docflow.UoW = uow.NewPG(pool)
rt.Docflow.Events = events.PGSink{}
rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{
Owner: intentflow.NewFakeActionOwner(),
Events: events.PGSink{},
})
}
+126
View File
@@ -0,0 +1,126 @@
package intentflow_test
import (
"context"
"errors"
"testing"
"chat-agent-system/internal/app/intentflow"
)
func TestConfirmDeniedDoesNotMutatePlan(t *testing.T) {
p, _, owner := testPlans()
ctx := context.Background()
created, err := p.CreatePlan(ctx, draft("digest-a"))
if err != nil {
t.Fatal(err)
}
owner.Denied = true
_, err = p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-deny", DecisionDigest: "d-deny", Kind: intentflow.DecisionConfirm,
})
if !errors.Is(err, intentflow.ErrPlanActionForbidden) {
t.Fatalf("want forbidden, got %v", err)
}
got, err := p.GetPlan(ctx, principal(), created.PlanID)
if err != nil {
t.Fatal(err)
}
if got.Revision != created.Revision || got.Status != intentflow.PlanAwaitingConfirmation {
t.Fatalf("confirm must be side-effect free on deny: %+v", got)
}
}
func TestResourceVersionConflictOnConfirm(t *testing.T) {
p, _, owner := testPlans()
ctx := context.Background()
cmd := draft("digest-ver")
cmd.Steps[1].ExpectedResourceVersion = "v1"
created, err := p.CreatePlan(ctx, cmd)
if err != nil {
t.Fatal(err)
}
owner.RequiredVersion = map[string]string{"task.run": "v2"}
_, err = p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-ver", DecisionDigest: "d-ver", Kind: intentflow.DecisionConfirm,
})
if !errors.Is(err, intentflow.ErrPlanActionVersionConflict) {
t.Fatalf("want version conflict, got %v", err)
}
}
func TestRevokeBetweenConfirmAndExecute(t *testing.T) {
p, _, owner := testPlans()
ctx := context.Background()
created, err := p.CreatePlan(ctx, draft("digest-rev"))
if err != nil {
t.Fatal(err)
}
confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-ok", DecisionDigest: "d-ok", Kind: intentflow.DecisionConfirm,
})
if err != nil {
t.Fatal(err)
}
owner.Denied = true
stepID := confirmed.Steps[0].StepID
claimed, err := p.ClaimStep(ctx, "w1", confirmed.PlanID, stepID)
if err != nil {
t.Fatal(err)
}
if _, err := p.ExecuteClaimed(ctx, claimed); !errors.Is(err, intentflow.ErrPlanActionForbidden) {
t.Fatalf("revoked execute must fail closed, got %v", err)
}
}
func TestEditBumpsRevisionAndInvalidatesOldCard(t *testing.T) {
p, _, _ := testPlans()
ctx := context.Background()
created, err := p.CreatePlan(ctx, draft("digest-edit"))
if err != nil {
t.Fatal(err)
}
edited, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-edit", DecisionDigest: "d-edit", Kind: intentflow.DecisionEdit,
})
if err != nil {
t.Fatal(err)
}
if edited.Revision <= created.Revision {
t.Fatalf("edit must bump revision, got %d", edited.Revision)
}
if edited.Status != intentflow.PlanAwaitingConfirmation {
t.Fatalf("edit stays awaiting, got %s", edited.Status)
}
_, err = p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-old", DecisionDigest: "d-old", Kind: intentflow.DecisionConfirm,
})
if !errors.Is(err, intentflow.ErrPlanStaleRevision) {
t.Fatalf("old card must be stale, got %v", err)
}
}
func TestIllegalPartialConfirmMissingDependency(t *testing.T) {
p, _, _ := testPlans()
ctx := context.Background()
cmd := draft("digest-dep")
cmd.Steps[1].DependsOn = []string{"stp_missing"}
created, err := p.CreatePlan(ctx, cmd)
if err != nil {
t.Fatal(err)
}
writeID := created.Steps[1].StepID
_, err = p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-part", DecisionDigest: "d-part", Kind: intentflow.DecisionConfirm,
StepIDs: []string{writeID},
})
if !errors.Is(err, intentflow.ErrPlanDependencyInvalid) {
t.Fatalf("partial without closure must be invalid, got %v", err)
}
}
+32 -5
View File
@@ -20,11 +20,20 @@ type FenceObserver interface {
// FakeActionOwner is the U3 test double. It records executions and receipts
// without touching business tables.
type FakeActionOwner struct {
mu sync.Mutex
execCount map[string]int
receipts map[string]Receipt
fences map[string]int64
FailWrite bool
mu sync.Mutex
execCount map[string]int
receipts map[string]Receipt
fences map[string]int64
FailWrite bool
Denied bool
DeniedKeys map[string]bool
RequiredVersion map[string]string
}
// StepAuthorizer re-checks the current principal before confirm and execute.
// Confirmation is not authorization.
type StepAuthorizer interface {
Authorize(ctx context.Context, exec StepExec) error
}
func NewFakeActionOwner() *FakeActionOwner {
@@ -44,10 +53,28 @@ func (f *FakeActionOwner) ObserveFence(stepID string, token int64) {
f.fences[stepID] = token
}
func (f *FakeActionOwner) Authorize(ctx context.Context, exec StepExec) error {
if f == nil {
return ErrPlanActionForbidden
}
f.mu.Lock()
defer f.mu.Unlock()
if f.Denied || f.DeniedKeys[exec.ActionKey] {
return ErrPlanActionForbidden
}
if want, ok := f.RequiredVersion[exec.ActionKey]; ok && exec.ExpectedResourceVersion != "" && exec.ExpectedResourceVersion != want {
return ErrPlanActionVersionConflict
}
return nil
}
func (f *FakeActionOwner) Execute(ctx context.Context, exec StepExec) (Receipt, error) {
if f == nil {
return Receipt{}, ErrPlanActionForbidden
}
if err := f.Authorize(ctx, exec); err != nil {
return Receipt{}, err
}
f.mu.Lock()
defer f.mu.Unlock()
if cur, ok := f.fences[exec.StepID]; ok && exec.FencingToken != cur {
+163 -12
View File
@@ -22,6 +22,7 @@ type Plans struct {
uow uow.UnitOfWork
store Store
owner ActionOwner
authz StepAuthorizer
events EventSink
now func() time.Time
newID func(prefix string) string
@@ -30,12 +31,13 @@ type Plans struct {
}
type Options struct {
Owner ActionOwner
Events EventSink
Now func() time.Time
NewID func(prefix string) string
Lease time.Duration
PlanTTL time.Duration
Owner ActionOwner
Authorizer StepAuthorizer
Events EventSink
Now func() time.Time
NewID func(prefix string) string
Lease time.Duration
PlanTTL time.Duration
}
func NewPlans(unit uow.UnitOfWork, store Store, opt Options) *Plans {
@@ -55,7 +57,26 @@ func NewPlans(unit uow.UnitOfWork, store Store, opt Options) *Plans {
if ttl <= 0 {
ttl = defaultPlanTTL
}
return &Plans{uow: unit, store: store, owner: opt.Owner, events: opt.Events, now: now, newID: newID, lease: lease, ttl: ttl}
authz := opt.Authorizer
if authz == nil {
if a, ok := opt.Owner.(StepAuthorizer); ok {
authz = a
}
}
return &Plans{uow: unit, store: store, owner: opt.Owner, authz: authz, events: opt.Events, now: now, newID: newID, lease: lease, ttl: ttl}
}
func (p *Plans) stepAuthorizer() StepAuthorizer {
if p == nil {
return nil
}
if p.authz != nil {
return p.authz
}
if a, ok := p.owner.(StepAuthorizer); ok {
return a
}
return nil
}
func randomID(prefix string) string {
@@ -296,6 +317,9 @@ func (p *Plans) DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView,
if in.ExpectedRevision != plan.Revision {
return ErrPlanStaleRevision
}
if err := p.authorizeDecision(ctx, plan, in); err != nil {
return err
}
dec := &Decision{
DecisionID: p.newID("dec_"),
PlanID: plan.PlanID,
@@ -329,8 +353,12 @@ func (p *Plans) DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView,
from := []PlanStatus{plan.Status}
var to PlanStatus
switch in.Kind {
case DecisionConfirm:
if plan.Status != PlanAwaitingConfirmation && plan.Status != PlanAwaitingClarification && plan.Status != PlanProposed {
case DecisionConfirm, DecisionConfirmRetry, DecisionContinue:
if in.Kind == DecisionConfirm {
if plan.Status != PlanAwaitingConfirmation && plan.Status != PlanAwaitingClarification && plan.Status != PlanProposed {
return ErrPlanStaleRevision
}
} else if plan.Status != PlanPartiallyCompleted && plan.Status != PlanExecuting {
return ErrPlanStaleRevision
}
if err := applyConfirm(plan, in.StepIDs, in.ExcludeUnselected, now); err != nil {
@@ -343,6 +371,22 @@ func (p *Plans) DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView,
}
cancelUnstarted(plan, now)
to = PlanCancelled
case DecisionExclude:
if !isWaitingStatus(plan.Status) && plan.Status != PlanPartiallyCompleted {
return ErrPlanStaleRevision
}
if err := applyExclude(plan, in.StepIDs, now); err != nil {
return err
}
to = plan.Status
if allStepsTerminal(plan) {
to = PlanCancelled
}
case DecisionEdit:
if !isWaitingStatus(plan.Status) {
return ErrPlanStaleRevision
}
to = plan.Status
default:
return ErrPlanDependencyInvalid
}
@@ -364,6 +408,106 @@ func (p *Plans) DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView,
return view, err
}
func (p *Plans) authorizeDecision(ctx context.Context, plan *Plan, in DecisionCommand) error {
switch in.Kind {
case DecisionConfirm, DecisionConfirmRetry, DecisionContinue:
default:
return nil
}
auth := p.stepAuthorizer()
if auth == nil {
return nil
}
ids, err := selectedStepIDs(plan, in.StepIDs)
if err != nil {
return err
}
byID := map[string]Step{}
for _, s := range plan.Steps {
byID[s.StepID] = s
}
for _, id := range ids {
s := byID[id]
if err := auth.Authorize(ctx, StepExec{
Principal: in.Principal,
PlanID: plan.PlanID,
StepID: s.StepID,
ActionKey: s.ActionKey,
ActionDigest: s.ActionDigest,
ExpectedResourceVersion: s.ExpectedResourceVersion,
}); err != nil {
return err
}
}
return nil
}
func selectedStepIDs(plan *Plan, selected []string) ([]string, error) {
byID := map[string]*Step{}
for i := range plan.Steps {
byID[plan.Steps[i].StepID] = &plan.Steps[i]
}
if len(selected) == 0 {
ids := make([]string, 0, len(plan.Steps))
for i := range plan.Steps {
ids = append(ids, plan.Steps[i].StepID)
}
return ids, nil
}
chosen := map[string]bool{}
for _, id := range selected {
if _, ok := byID[id]; !ok {
return nil, ErrPlanDependencyInvalid
}
chosen[id] = true
}
for id := range chosen {
for _, dep := range byID[id].DependsOn {
depStep, ok := byID[dep]
if !ok || (!chosen[dep] && depStep.Status != StepSucceeded && depStep.Status != StepSkipped) {
return nil, ErrPlanDependencyInvalid
}
}
}
ids := make([]string, 0, len(chosen))
for id := range chosen {
ids = append(ids, id)
}
return ids, nil
}
func applyExclude(plan *Plan, stepIDs []string, now time.Time) error {
if len(stepIDs) == 0 {
return ErrPlanDependencyInvalid
}
byID := map[string]*Step{}
for i := range plan.Steps {
byID[plan.Steps[i].StepID] = &plan.Steps[i]
}
for _, id := range stepIDs {
s, ok := byID[id]
if !ok {
return ErrPlanDependencyInvalid
}
if s.Status == StepPending || s.Status == StepEligible {
s.Status = StepCancelled
s.UpdatedAt = now
}
}
return nil
}
func allStepsTerminal(plan *Plan) bool {
for _, s := range plan.Steps {
switch s.Status {
case StepSucceeded, StepFailed, StepDenied, StepSkipped, StepCancelled:
default:
return false
}
}
return true
}
func applyConfirm(plan *Plan, selected []string, excludeUnselected bool, now time.Time) error {
byID := map[string]*Step{}
for i := range plan.Steps {
@@ -383,7 +527,8 @@ func applyConfirm(plan *Plan, selected []string, excludeUnselected bool, now tim
}
for id := range chosen {
for _, dep := range byID[id].DependsOn {
if !chosen[dep] && byID[dep].Status != StepSucceeded && byID[dep].Status != StepSkipped {
depStep, ok := byID[dep]
if !ok || (!chosen[dep] && depStep.Status != StepSucceeded && depStep.Status != StepSkipped) {
return ErrPlanDependencyInvalid
}
}
@@ -462,7 +607,7 @@ func (p *Plans) ExecuteClaimed(ctx context.Context, claimed *ClaimedStep) (*Plan
if p.owner == nil {
return nil, ErrPlanActionForbidden
}
rec, err := p.owner.Execute(ctx, StepExec{
exec := StepExec{
Principal: claimed.Plan.Principal,
PlanID: claimed.Plan.PlanID,
StepID: claimed.Step.StepID,
@@ -471,7 +616,13 @@ func (p *Plans) ExecuteClaimed(ctx context.Context, claimed *ClaimedStep) (*Plan
ActionDigest: claimed.Step.ActionDigest,
ExpectedResourceVersion: claimed.Step.ExpectedResourceVersion,
FencingToken: claimed.FencingToken,
})
}
if auth := p.stepAuthorizer(); auth != nil {
if err := auth.Authorize(ctx, exec); err != nil {
return nil, err
}
}
rec, err := p.owner.Execute(ctx, exec)
if err != nil {
return nil, err
}
+185
View File
@@ -0,0 +1,185 @@
package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/authz"
)
type intentPlanDecisionBody struct {
ExpectedRevision int64 `json:"expected_revision"`
DecisionNonce string `json:"decision_nonce"`
DecisionDigest string `json:"decision_digest"`
Kind string `json:"kind"`
StepIDs []string `json:"step_ids"`
ExcludeUnselected bool `json:"exclude_unselected"`
UserID int64 `json:"user_id"`
ProjectID int64 `json:"project_id"`
SessionID string `json:"session_id"`
}
func (s *Server) handleGetActiveIntentPlan(w http.ResponseWriter, r *http.Request) {
plans, prin, ok := s.planRequestScope(w, r)
if !ok {
return
}
view, err := plans.GetActivePlan(r.Context(), prin)
if err != nil {
writeIntentPlanError(w, r, plans, prin, "", err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
}
func (s *Server) handleGetIntentPlan(w http.ResponseWriter, r *http.Request) {
plans, prin, ok := s.planRequestScope(w, r)
if !ok {
return
}
planID := r.PathValue("plan_id")
view, err := plans.GetPlan(r.Context(), prin, planID)
if err != nil {
writeIntentPlanError(w, r, plans, prin, planID, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
}
func (s *Server) handleIntentPlanDecision(w http.ResponseWriter, r *http.Request) {
plans, prin, ok := s.planRequestScope(w, r)
if !ok {
return
}
var body intentPlanDecisionBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
"error_code": "PLAN_DEPENDENCY_INVALID",
"message": "无效的决策请求",
})
return
}
kind := intentflow.DecisionKind(strings.TrimSpace(body.Kind))
view, err := plans.DecidePlan(r.Context(), intentflow.DecisionCommand{
Principal: prin,
PlanID: r.PathValue("plan_id"),
ExpectedRevision: body.ExpectedRevision,
DecisionNonce: body.DecisionNonce,
DecisionDigest: body.DecisionDigest,
Kind: kind,
StepIDs: body.StepIDs,
ExcludeUnselected: body.ExcludeUnselected,
})
if err != nil {
writeIntentPlanError(w, r, plans, prin, r.PathValue("plan_id"), err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"plan": view})
}
func (s *Server) planRequestScope(w http.ResponseWriter, r *http.Request) (intentflow.Service, intentflow.Principal, bool) {
if s == nil || s.intentPlans == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{
"error_code": "PLAN_NOT_FOUND_OR_HIDDEN",
"message": "计划服务不可用",
})
return nil, intentflow.Principal{}, false
}
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return nil, intentflow.Principal{}, false
}
projectID, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("project_id")), 10, 64)
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if err != nil || projectID <= 0 || sessionID == "" {
writeJSONResponse(w, http.StatusBadRequest, map[string]string{
"error_code": "PLAN_DEPENDENCY_INVALID",
"message": "缺少 project_id 或 session_id",
})
return nil, intentflow.Principal{}, false
}
if err := s.planProjectAccess(r.Context(), projectID, user.ID); err != nil {
if errors.Is(err, authz.ErrUnauthenticated) {
writeJSONResponse(w, http.StatusUnauthorized, map[string]string{
"error_code": "ERR_UNAUTHENTICATED",
"message": "请先登录",
})
return nil, intentflow.Principal{}, false
}
writeJSONResponse(w, http.StatusForbidden, map[string]string{
"error_code": "ERR_FORBIDDEN",
"message": "无权访问该项目",
})
return nil, intentflow.Principal{}, false
}
ownerID, ownerErr := s.planSessionOwner(r.Context(), sessionID)
if ownerErr != nil || ownerID <= 0 || ownerID != user.ID {
writeJSONResponse(w, http.StatusNotFound, map[string]string{
"error_code": "PLAN_NOT_FOUND_OR_HIDDEN",
"message": "计划不存在或不可见",
})
return nil, intentflow.Principal{}, false
}
env := "dev"
if s.cfg != nil && strings.TrimSpace(s.cfg.Env) != "" {
env = s.cfg.Env
}
return s.intentPlans, intentflow.Principal{
UserID: user.ID,
Environment: env,
ProjectID: projectID,
SessionID: sessionID,
}, true
}
func (s *Server) planProjectAccess(ctx context.Context, projectID, userID int64) error {
if s.planAccessFn != nil {
return s.planAccessFn(ctx, projectID, userID)
}
if s.authorizer == nil {
return authz.ErrForbidden
}
return s.authorizer.RequireProjectAccess(ctx, projectID, userID)
}
func (s *Server) planSessionOwner(ctx context.Context, sessionID string) (int64, error) {
if s.planSessionOwnerFn != nil {
return s.planSessionOwnerFn(ctx, sessionID)
}
if s.ctxMgr == nil {
return 0, intentflow.ErrPlanNotFoundOrHidden
}
return s.ctxMgr.GetSessionOwnerID(ctx, sessionID)
}
func writeIntentPlanError(w http.ResponseWriter, r *http.Request, plans intentflow.Service, prin intentflow.Principal, planID string, err error) {
code := intentflow.ErrorCode(err)
status := http.StatusInternalServerError
switch {
case errors.Is(err, intentflow.ErrUnauthenticated):
status = http.StatusUnauthorized
case errors.Is(err, intentflow.ErrPlanNotFoundOrHidden):
status = http.StatusNotFound
case errors.Is(err, intentflow.ErrPlanActionForbidden):
status = http.StatusForbidden
case errors.Is(err, intentflow.ErrPlanStaleRevision),
errors.Is(err, intentflow.ErrPlanExpired),
errors.Is(err, intentflow.ErrPlanDecisionConflict),
errors.Is(err, intentflow.ErrPlanActionVersionConflict):
status = http.StatusConflict
case errors.Is(err, intentflow.ErrPlanDependencyInvalid):
status = http.StatusBadRequest
}
body := map[string]any{"error_code": code, "message": code}
if plans != nil && (errors.Is(err, intentflow.ErrPlanStaleRevision) || errors.Is(err, intentflow.ErrPlanDecisionConflict)) && planID != "" {
if latest, getErr := plans.GetPlan(r.Context(), prin, planID); getErr == nil && latest != nil {
body["plan"] = latest
}
}
writeJSONResponse(w, status, body)
}
@@ -0,0 +1,196 @@
package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
)
func u5Server(t *testing.T) (*Server, *intentflow.Plans, *intentflow.FakeActionOwner, string, int64) {
t.Helper()
cfg := &config.Config{
DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "dev",
ContextDbHost: "127.0.0.1", AdminUsername: "admin", AdminPassword: "admin-secret-password-123",
}
srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}))
owner := intentflow.NewFakeActionOwner()
plans := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{Owner: owner})
srv.intentPlans = plans
token, userID := u5Login(t, srv)
srv.planAccessFn = func(ctx context.Context, projectID, userID int64) error {
if projectID == 99 {
return authz.ErrForbidden
}
return nil
}
srv.planSessionOwnerFn = func(ctx context.Context, sessionID string) (int64, error) {
switch sessionID {
case "sess-u5", "sess-other":
return userID, nil
case "sess-foreign":
return userID + 99, nil
default:
return 0, intentflow.ErrPlanNotFoundOrHidden
}
}
return srv, plans, owner, token, userID
}
func u5Login(t *testing.T, srv *Server) (string, int64) {
t.Helper()
body, _ := json.Marshal(map[string]string{"username": "admin", "password": "admin-secret-password-123"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
}
var sess auth.SessionInfo
if err := json.NewDecoder(rec.Body).Decode(&sess); err != nil || sess.Token == "" || sess.User == nil {
t.Fatalf("login decode: %v body=%s", err, rec.Body.String())
}
return sess.Token, sess.User.ID
}
func u5Do(srv *Server, method, path, token string, body any) *httptest.ResponseRecorder {
var r *http.Request
if body != nil {
raw, _ := json.Marshal(body)
r = httptest.NewRequest(method, path, bytes.NewReader(raw))
r.Header.Set("Content-Type", "application/json")
} else {
r = httptest.NewRequest(method, path, nil)
}
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, r)
return rec
}
func u5Create(t *testing.T, plans *intentflow.Plans, userID int64, projectID int64, sessionID, turn string) *intentflow.PlanView {
t.Helper()
view, err := plans.CreatePlan(context.Background(), intentflow.CreateCommand{
Principal: intentflow.Principal{UserID: userID, Environment: "dev", ProjectID: projectID, SessionID: sessionID},
ClientTurnID: turn,
RequestDigest: turn + "-digest",
WritePlan: true,
SafeSummary: "query then generate",
Steps: []intentflow.StepDraft{
{ActionKey: "regulation.query", ActionDigest: "q1"},
{ActionKey: "task.run", ActionDigest: "w1"},
},
})
if err != nil {
t.Fatal(err)
}
return view
}
func TestIntentPlanHTTP_Unauthenticated(t *testing.T) {
srv, _, _, _, _ := u5Server(t)
rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=42&session_id=sess-u5", "", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d %s", rec.Code, rec.Body.String())
}
}
func TestIntentPlanHTTP_ActiveWithoutProjection(t *testing.T) {
srv, plans, _, token, userID := u5Server(t)
created := u5Create(t, plans, userID, 42, "sess-u5", "turn-active")
rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=42&session_id=sess-u5", token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("active %d %s", rec.Code, rec.Body.String())
}
var out struct {
Plan *intentflow.PlanView `json:"plan"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Plan == nil || out.Plan.PlanID != created.PlanID {
t.Fatalf("want stored plan, got %s err=%v", rec.Body.String(), err)
}
}
func TestIntentPlanHTTP_ScopeNegatives(t *testing.T) {
srv, plans, _, token, userID := u5Server(t)
created := u5Create(t, plans, userID, 42, "sess-u5", "turn-scope")
if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=99&session_id=sess-u5", token, nil); rec.Code != http.StatusForbidden {
t.Fatalf("non-member want 403, got %d %s", rec.Code, rec.Body.String())
}
if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/"+created.PlanID+"?project_id=7&session_id=sess-u5", token, nil); rec.Code != http.StatusNotFound {
t.Fatalf("cross-project want 404, got %d %s", rec.Code, rec.Body.String())
}
if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/"+created.PlanID+"?project_id=42&session_id=sess-other", token, nil); rec.Code != http.StatusNotFound {
t.Fatalf("cross-session want 404, got %d %s", rec.Code, rec.Body.String())
}
if rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=42&session_id=sess-foreign", token, nil); rec.Code != http.StatusNotFound {
t.Fatalf("foreign session want 404, got %d %s", rec.Code, rec.Body.String())
}
}
func TestIntentPlanHTTP_IgnoresBodyPrincipalAndSameReceipt(t *testing.T) {
srv, plans, _, token, userID := u5Server(t)
created := u5Create(t, plans, userID, 42, "sess-u5", "turn-dec")
path := "/api/v1/intent-plans/" + created.PlanID + "/decisions?project_id=42&session_id=sess-u5"
body := map[string]any{
"expected_revision": created.Revision,
"decision_nonce": "nonce-same",
"decision_digest": "digest-same",
"kind": "confirm",
"user_id": userID + 8,
"project_id": 99,
"session_id": "injected",
}
first := u5Do(srv, http.MethodPost, path, token, body)
if first.Code != http.StatusOK {
t.Fatalf("confirm %d %s", first.Code, first.Body.String())
}
again := u5Do(srv, http.MethodPost, path, token, body)
if again.Code != http.StatusOK {
t.Fatalf("replay %d %s", again.Code, again.Body.String())
}
var a, b struct {
Plan *intentflow.PlanView `json:"plan"`
}
_ = json.Unmarshal(first.Body.Bytes(), &a)
_ = json.Unmarshal(again.Body.Bytes(), &b)
if a.Plan == nil || b.Plan == nil || a.Plan.Revision != b.Plan.Revision || a.Plan.Status != intentflow.PlanExecuting {
t.Fatalf("same nonce must share receipt a=%+v b=%+v", a.Plan, b.Plan)
}
}
func TestIntentPlanHTTP_StaleRevision(t *testing.T) {
srv, plans, _, token, userID := u5Server(t)
created := u5Create(t, plans, userID, 42, "sess-u5", "turn-stale")
path := "/api/v1/intent-plans/" + created.PlanID + "/decisions?project_id=42&session_id=sess-u5"
rec := u5Do(srv, http.MethodPost, path, token, map[string]any{
"expected_revision": created.Revision + 3,
"decision_nonce": "n-stale",
"decision_digest": "d-stale",
"kind": "confirm",
})
if rec.Code != http.StatusConflict {
t.Fatalf("stale want 409, got %d %s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("PLAN_STALE_REVISION")) {
t.Fatalf("want stale code, got %s", rec.Body.String())
}
}
func TestIntentPlanHTTP_AdminDoesNotBypassMembership(t *testing.T) {
srv, _, _, token, _ := u5Server(t)
rec := u5Do(srv, http.MethodGet, "/api/v1/intent-plans/active?project_id=99&session_id=sess-u5", token, nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("admin must not bypass membership, got %d %s", rec.Code, rec.Body.String())
}
}
+63 -54
View File
@@ -12,6 +12,7 @@ import (
"chat-agent-system/internal/app"
"chat-agent-system/internal/app/chatflow"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/app/stageflow"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
@@ -38,35 +39,38 @@ import (
// Server 提供基于 HTTP RESTful 与 SSE 的商务与研发参谋接口服务
type Server struct {
rt *app.Runtime
cfg *config.Config
svc *bizdev.Service
pwSvc *projectworkflow.Service
rec intent.Recognizer
engine *coordinator.CoordinatorEngine
authSvc *auth.Service
sessions map[string]*chat.Session
sessionsMu sync.RWMutex
taskRuns *taskrun.Manager
authorizer *authz.Authorizer
authzSvc *authz.Service
wfDefSvc *workflowdef.Service
instSvc *projectinstance.Service
notifySvc *notification.Service
resolver *workspace.Resolver
wsCap *workspace.Capability
cmds *taskrun.Commands
chatLog chatflow.ChatLog
assembler chatflow.ContextAssembler
stageflow *stageflow.UseCase
gov *docgov.GovernanceEngine
rev *docrevision.Service
ctxMgr *ctxmgr.ContextManager
cas *storage.CAS
pool *pgxpool.Pool
freeDoc *freedoc.Service
kb *kb.Client
httpServer *http.Server
rt *app.Runtime
cfg *config.Config
svc *bizdev.Service
pwSvc *projectworkflow.Service
rec intent.Recognizer
engine *coordinator.CoordinatorEngine
authSvc *auth.Service
sessions map[string]*chat.Session
sessionsMu sync.RWMutex
taskRuns *taskrun.Manager
authorizer *authz.Authorizer
authzSvc *authz.Service
wfDefSvc *workflowdef.Service
instSvc *projectinstance.Service
notifySvc *notification.Service
resolver *workspace.Resolver
wsCap *workspace.Capability
cmds *taskrun.Commands
chatLog chatflow.ChatLog
assembler chatflow.ContextAssembler
stageflow *stageflow.UseCase
gov *docgov.GovernanceEngine
rev *docrevision.Service
ctxMgr *ctxmgr.ContextManager
cas *storage.CAS
pool *pgxpool.Pool
freeDoc *freedoc.Service
kb *kb.Client
httpServer *http.Server
intentPlans intentflow.Service
planAccessFn func(ctx context.Context, projectID, userID int64) error
planSessionOwnerFn func(ctx context.Context, sessionID string) (int64, error)
}
// NewFromRuntime attaches the HTTP adapter to an already-wired composition root.
@@ -75,31 +79,32 @@ func NewFromRuntime(rt *app.Runtime) *Server {
panic("server: nil runtime")
}
s := &Server{
rt: rt,
cfg: rt.Cfg,
svc: rt.Biz,
pwSvc: rt.PW,
rec: rt.Rec,
engine: rt.Engine,
authSvc: rt.Auth,
sessions: make(map[string]*chat.Session),
taskRuns: rt.TaskRuns,
authorizer: rt.Authorizer,
authzSvc: rt.Authz,
wfDefSvc: rt.WFDef,
instSvc: rt.Inst,
notifySvc: rt.Notify,
resolver: rt.Resolver,
wsCap: rt.WSCap,
cmds: rt.Commands,
stageflow: rt.Stageflow,
gov: rt.Gov,
rev: rt.Rev,
ctxMgr: rt.Ctx,
cas: rt.CAS,
pool: rt.Pool,
freeDoc: rt.FreeDoc,
kb: rt.KB,
rt: rt,
cfg: rt.Cfg,
svc: rt.Biz,
pwSvc: rt.PW,
rec: rt.Rec,
engine: rt.Engine,
authSvc: rt.Auth,
sessions: make(map[string]*chat.Session),
taskRuns: rt.TaskRuns,
authorizer: rt.Authorizer,
authzSvc: rt.Authz,
wfDefSvc: rt.WFDef,
instSvc: rt.Inst,
notifySvc: rt.Notify,
resolver: rt.Resolver,
wsCap: rt.WSCap,
cmds: rt.Commands,
stageflow: rt.Stageflow,
gov: rt.Gov,
rev: rt.Rev,
ctxMgr: rt.Ctx,
cas: rt.CAS,
pool: rt.Pool,
freeDoc: rt.FreeDoc,
kb: rt.KB,
intentPlans: rt.IntentPlans,
}
s.chatLog = chatflow.NewAdapter(s.ctxMgr, s.resolveStageAccess, s.failClosedAuthz())
s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog)
@@ -187,6 +192,10 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/chat/stream", s.handleChatStream)
mux.HandleFunc("GET /api/v1/chat/turns/{client_turn_id}", s.handleGetChatTurn)
mux.HandleFunc("GET "+intentflow.PathActivePlan, s.handleGetActiveIntentPlan)
mux.HandleFunc("GET "+intentflow.PathPlanByID, s.handleGetIntentPlan)
mux.HandleFunc("POST "+intentflow.PathPlanDecisions, s.handleIntentPlanDecision)
// 5. 独立任务 / 阶段执行
mux.HandleFunc("POST /api/v1/tasks/run", s.handleRunTask)
mux.HandleFunc("POST /api/v1/tasks/run/stream", s.handleRunTaskStream)