diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 29d07afc..a126ee16 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -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) | diff --git a/.doc/ops/checklists/2026-09-16-multi-intent-u5-decision-api.md b/.doc/ops/checklists/2026-09-16-multi-intent-u5-decision-api.md new file mode 100644 index 00000000..76c908d0 --- /dev/null +++ b/.doc/ops/checklists/2026-09-16-multi-intent-u5-decision-api.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] 全部勾选且有证据链接 diff --git a/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md b/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md index 656e0103..605b0495 100644 --- a/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md +++ b/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md @@ -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) ## 证据 / 备注 diff --git a/.doc/ops/short-term/2026-09-15-promote-dev-to-test.md b/.doc/ops/short-term/2026-09-15-promote-dev-to-test.md new file mode 100644 index 00000000..91ecf3eb --- /dev/null +++ b/.doc/ops/short-term/2026-09-15-promote-dev-to-test.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` diff --git a/.doc/ops/short-term/2026-09-16-multi-intent-u5-decision-api.md b/.doc/ops/short-term/2026-09-16-multi-intent-u5-decision-api.md new file mode 100644 index 00000000..d8cb8309 --- /dev/null +++ b/.doc/ops/short-term/2026-09-16-multi-intent-u5-decision-api.md @@ -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) diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 9df4ce28..f696ebde 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -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` | diff --git a/.doc/system-design/README.md b/.doc/system-design/README.md index 29369446..04bef869 100644 --- a/.doc/system-design/README.md +++ b/.doc/system-design/README.md @@ -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 只能提出不可信分段建议。 diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 770beb63..c87d3d00 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -164,6 +164,7 @@ imports: - app - app/chatflow - app/docflow + - app/intentflow - app/stageflow - app/uow - auth diff --git a/.doc/test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md b/.doc/test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md new file mode 100644 index 00000000..9344d729 --- /dev/null +++ b/.doc/test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md @@ -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 查询执行。 diff --git a/.doc/test-reports/assets/2026-09-15-conversation-intent-dual-tab/__pycache__/dual-tab-live.cpython-314.pyc b/.doc/test-reports/assets/2026-09-15-conversation-intent-dual-tab/__pycache__/dual-tab-live.cpython-314.pyc new file mode 100644 index 00000000..7310a84c Binary files /dev/null and b/.doc/test-reports/assets/2026-09-15-conversation-intent-dual-tab/__pycache__/dual-tab-live.cpython-314.pyc differ diff --git a/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt new file mode 100644 index 00000000..a1141596 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/authorize-verbose.txt @@ -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 diff --git a/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt new file mode 100644 index 00000000..d1cff64e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/http-verbose.txt @@ -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 diff --git a/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/short.txt b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/short.txt new file mode 100644 index 00000000..9449e826 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-16-multi-intent-u5-decision-api/short.txt @@ -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] diff --git a/internal/app/app.go b/internal/app/app.go index 25bdce9f..52934929 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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{}, }) } diff --git a/internal/app/intentflow/authorize_test.go b/internal/app/intentflow/authorize_test.go new file mode 100644 index 00000000..77bb135b --- /dev/null +++ b/internal/app/intentflow/authorize_test.go @@ -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) + } +} diff --git a/internal/app/intentflow/owner.go b/internal/app/intentflow/owner.go index c5e1fd5c..d1790251 100644 --- a/internal/app/intentflow/owner.go +++ b/internal/app/intentflow/owner.go @@ -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 { diff --git a/internal/app/intentflow/plans.go b/internal/app/intentflow/plans.go index 28c9bcd3..f5136e1a 100644 --- a/internal/app/intentflow/plans.go +++ b/internal/app/intentflow/plans.go @@ -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 } diff --git a/internal/server/intent_plan_handlers.go b/internal/server/intent_plan_handlers.go new file mode 100644 index 00000000..70ced247 --- /dev/null +++ b/internal/server/intent_plan_handlers.go @@ -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) +} diff --git a/internal/server/intent_plan_handlers_test.go b/internal/server/intent_plan_handlers_test.go new file mode 100644 index 00000000..82d8ad61 --- /dev/null +++ b/internal/server/intent_plan_handlers_test.go @@ -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()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 68570335..5411cf6b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)