merge: feature/multi-intent-u5-decision-api
This commit is contained in:
+4
-2
@@ -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)
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 查询执行。
|
||||
BIN
Binary file not shown.
+12
@@ -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]
|
||||
@@ -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{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user