diff --git a/.doc/ops/long-term/2026-09-15-event-shared-live-cutover.md b/.doc/archive/ops/2026-09-15-event-shared-live-cutover.md similarity index 90% rename from .doc/ops/long-term/2026-09-15-event-shared-live-cutover.md rename to .doc/archive/ops/2026-09-15-event-shared-live-cutover.md index cbb70932..cef45874 100644 --- a/.doc/ops/long-term/2026-09-15-event-shared-live-cutover.md +++ b/.doc/archive/ops/2026-09-15-event-shared-live-cutover.md @@ -1,6 +1,6 @@ --- title: "共享环境事件消费者 live 切换" -status: planned +status: done created: 2026-09-15 updated: 2026-09-15 --- @@ -25,7 +25,7 @@ Track E 已在隔离库证明 notify / workflow_advance / projection 的 exactly ## 暂不启动原因 -共享 live 会改变 :8080 真实副作用路径。未获授权前保持 dispatcher/consumer `off`。 +已完成共享 live 与生产去注册。方法本体保留一个窗口。 ## 相关链接 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 1d41fa2e..974356bb 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,8 +5,8 @@ ## 焦点 -1. **TRACK-E/S**: Phase 2 隔离库与结构解耦已合入;共享 live 见 [长期任务](./long-term/2026-09-15-event-shared-live-cutover.md) -2. **TRACK-P**: 立项完成([H0](./research/2026-09-15-http-error-envelope-h0-inventory.md) / [P0](./research/2026-09-15-pi-typed-result-p0-matrix.md));实施见 [长期任务](./long-term/2026-09-15-track-p-protocol-implementation.md) +1. **TRACK-P**: HTTP 信封与 PI typed-result 实施([long-term](./long-term/2026-09-15-track-p-protocol-implementation.md)) +2. **TRACK-E LIVE**: 共享 consumer 已 live;生产 hook 已去注册([projection](../test-reports/2026-09-15-event-projection-shared-live-test-report.md)) 3. **CHAT-INTENT**: [对话意图运行时状态](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md)(双标签 busy 已签:项目 **6275** / `DOC_BUSY` 单 run;不删 `sess-p143-u9` 探针) ## 并行认领 @@ -70,6 +70,7 @@ | Cursor | bizdev-locator-removal | fix/bizdev-locator-removal | - | bizdev | done | | Cursor | chatflow-orchestration-boundary | fix/chatflow-orchestration-boundary | - | chat | done | | Cursor | phase2-closeout-inventories | feature/phase2-closeout-inventories | - | architecture-docs | done | +| Cursor | event-shared-live-cutover | feature/event-shared-live-cutover | - | events | done | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 diff --git a/.doc/ops/short-term/2026-09-15-event-shared-live-cutover.md b/.doc/ops/short-term/2026-09-15-event-shared-live-cutover.md new file mode 100644 index 00000000..a3d5075f --- /dev/null +++ b/.doc/ops/short-term/2026-09-15-event-shared-live-cutover.md @@ -0,0 +1,50 @@ +--- +title: "共享环境事件消费者 live 切换" +status: in_progress +created: 2026-09-15 +updated: 2026-09-15 +owner: Cursor +branch: feature/event-shared-live-cutover +worktree: "-" +mutex: events +--- + +# 共享环境事件消费者 live 切换 + +## 目标 + +在共享 `agent-dev` 上按 notify → workflow_advance → projection 完成 shadow → drain → live → 回滚演练,再删除生产 hook 注册。 + +## 范围 + +- `scripts/event-consumer-mode.sh` + `cmd/eventmode` +- 共享 `agent_system_dev` inventory / quarantine +- 逐消费者 live 与授权页纵切(项目 6275;143 只读) + +## 非目标 + +- FAV live +- test/main 环境 +- 编辑 Phase 2 计划原文 +- Track P(独立波次) + +## 步骤 + +1. [x] L0 受控 SetMode 与 inventory +2. [ ] L1 notify +3. [ ] L2 workflow_advance +4. [ ] L3 projection + 授权页 +5. [ ] L4 删除生产 hook 注册 + +## 验收标准 + +- [ ] fav / test / main 切模式失败 +- [ ] 每消费者独立 live 报告 +- [ ] 生产代码无 `OnOutbox =` / `AddOnApprovedHook(` + +## 相关链接 + +- Long-term: [.doc/archive/ops/2026-09-15-event-shared-live-cutover.md](../../archive/ops/2026-09-15-event-shared-live-cutover.md) +- Test report: (本波完成后补) + +## 证据 / 备注 diff --git a/.doc/remediation/gap-matrix.md b/.doc/remediation/gap-matrix.md index 6a8dd3e7..530ed0de 100644 --- a/.doc/remediation/gap-matrix.md +++ b/.doc/remediation/gap-matrix.md @@ -70,7 +70,7 @@ | DOCREV-ACTIVE-DOC-ROUTING | UI-05「当前受控文档」被法规关键词改路由到 `feasibility_reg`,intake 无 pending | **PASS** | [audit](../audit/2026-09-13-document-revision-active-doc-routing.md);Round20 UI-05 pending 停在 intake V2 | **不改弱 E2E**;不删证据 | | DOCREV-QUALITY-STATUS-FP | 行动计划表状态列「待续」被门禁当敷衍,两轮 repair 后 quality_failed 80 | **PASS** | [audit](../audit/2026-09-13-document-revision-quality-status-false-positive.md);Round20 UI-03 | **不删禁词、不放宽正文**;不删证据 | | GOVERNANCE-ACTION-AUTHZ | 文档动作按职责鉴权;未激活 202-blocked;关闭创建直通 | **PASS** | [test-report](../test-reports/2026-09-14-governance-action-level-authz-test-report.md);[audit 异态](../audit/2026-09-14-planning-with-workflow-key-anomaly.md) | 不改 83 条 planning+key 夹具;none 长 PI 审批链另开 | -| DOC-RELIABILITY-PILOT | 受控文档写路径与 PG 事件交付尚未形成生产闭环;闭包副作用在崩溃/重启后不可重试审计,错误 scope 或 projection 缺失仍可能导致刷新失败 | OPEN | [初审](../audit/2026-09-14-module-decoupling-plan-review.md);[Phase 1](./2026-09-14-module-decoupling-refactor-plan.md);[Phase 2 复核](../audit/2026-09-15-module-decoupling-phase2-plan-review.md);[Phase 2 修订](./2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md);[U11–U12 报告](../test-reports/2026-09-15-app-composition-u11-u12-test-report.md);[E1A](../test-reports/2026-09-15-event-e1a-notify-cutover-test-report.md);[E1B](../test-reports/2026-09-15-event-e1b-workflow-advance-test-report.md);[E1C](../test-reports/2026-09-15-event-e1c-projection-test-report.md);[E2](../test-reports/2026-09-15-event-e2-hook-guard-test-report.md);[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[live 长期任务](../ops/long-term/2026-09-15-event-shared-live-cutover.md) | E0A–E2 隔离库已过。共享消费者未 live,故不删 hook API。dispatcher 保持 off。FAV、HTTP/PI 实施不纳入本 blocker 关闭条件。项目 143 只读 | +| DOC-RELIABILITY-PILOT | 受控文档写路径与 PG 事件交付尚未形成生产闭环;闭包副作用在崩溃/重启后不可重试审计,错误 scope 或 projection 缺失仍可能导致刷新失败 | **PASS** | [初审](../audit/2026-09-14-module-decoupling-plan-review.md);[Phase 1](./2026-09-14-module-decoupling-refactor-plan.md);[Phase 2 复核](../audit/2026-09-15-module-decoupling-phase2-plan-review.md);[Phase 2 修订](./2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md);[U11–U12 报告](../test-reports/2026-09-15-app-composition-u11-u12-test-report.md);[E1A](../test-reports/2026-09-15-event-e1a-notify-cutover-test-report.md);[E1B](../test-reports/2026-09-15-event-e1b-workflow-advance-test-report.md);[E1C](../test-reports/2026-09-15-event-e1c-projection-test-report.md);[E2](../test-reports/2026-09-15-event-e2-hook-guard-test-report.md);[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[notify live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[advance live](../test-reports/2026-09-15-event-workflow-advance-shared-live-test-report.md);[projection live](../test-reports/2026-09-15-event-projection-shared-live-test-report.md) | 共享 notify/workflow_advance/projection 已 live;生产不再注册 `OnOutbox=` / `AddOnApprovedHook(`。FAV 仍 off。HTTP/PI 实施不纳入本行。项目 143 只读 | --- diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 63c6a63b..a21de60f 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -27,7 +27,7 @@ | PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` | | CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[运行时 UI Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md) | CI `TEST_DATABASE_URL` 隔离库仍缺;文档运行时真实 UI 已按 UI_PHASE 分文件且 post-restart 断言最终 A1 正文。`-race` 全包并行 TempDir flake 为基建残留 | `ci` | -| 模块解耦与可靠事件 | 文档可靠性纵向试点、PG 持久事件交付与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[Phase 1](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[Phase 2 审阅](../audit/2026-09-15-module-decoupling-phase2-plan-review.md);[Phase 2 修订计划](../remediation/2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md);[E0A](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md);[E0B](../test-reports/2026-09-15-event-e0b-dispatcher-pg-test-report.md);[E1A](../test-reports/2026-09-15-event-e1a-notify-cutover-test-report.md);[E1B](../test-reports/2026-09-15-event-e1b-workflow-advance-test-report.md);[E1C](../test-reports/2026-09-15-event-e1c-projection-test-report.md);[E2](../test-reports/2026-09-15-event-e2-hook-guard-test-report.md);[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[D6](../test-reports/2026-09-15-doccontent-canonical-boundary-test-report.md);[D7a](../test-reports/2026-09-15-intent-bizdev-boundary-test-report.md);[D5](../test-reports/2026-09-15-stageflow-type-ownership-test-report.md);[D4](../test-reports/2026-09-15-bizdev-locator-removal-test-report.md);[D7b](../test-reports/2026-09-15-chatflow-orchestration-boundary-test-report.md);[H0](../ops/research/2026-09-15-http-error-envelope-h0-inventory.md);[P0](../ops/research/2026-09-15-pi-typed-result-p0-matrix.md);[收口](../test-reports/2026-09-15-phase2-closeout-test-report.md) | E0A–E2 隔离库已过;dispatcher 默认 off。D4–D7b 已合入。共享 live 与删 hook 见 [长期任务](../ops/long-term/2026-09-15-event-shared-live-cutover.md)。Track P 立项完成(H0/P0),实施另列 | `architecture-docs` | +| 模块解耦与可靠事件 | 文档可靠性纵向试点、PG 持久事件交付与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[Phase 1](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[Phase 2 审阅](../audit/2026-09-15-module-decoupling-phase2-plan-review.md);[Phase 2 修订计划](../remediation/2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md);[E0A](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md);[E0B](../test-reports/2026-09-15-event-e0b-dispatcher-pg-test-report.md);[E1A](../test-reports/2026-09-15-event-e1a-notify-cutover-test-report.md);[E1B](../test-reports/2026-09-15-event-e1b-workflow-advance-test-report.md);[E1C](../test-reports/2026-09-15-event-e1c-projection-test-report.md);[E2](../test-reports/2026-09-15-event-e2-hook-guard-test-report.md);[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[D6](../test-reports/2026-09-15-doccontent-canonical-boundary-test-report.md);[D7a](../test-reports/2026-09-15-intent-bizdev-boundary-test-report.md);[D5](../test-reports/2026-09-15-stageflow-type-ownership-test-report.md);[D4](../test-reports/2026-09-15-bizdev-locator-removal-test-report.md);[D7b](../test-reports/2026-09-15-chatflow-orchestration-boundary-test-report.md);[H0](../ops/research/2026-09-15-http-error-envelope-h0-inventory.md);[P0](../ops/research/2026-09-15-pi-typed-result-p0-matrix.md);[收口](../test-reports/2026-09-15-phase2-closeout-test-report.md);[notify live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[advance live](../test-reports/2026-09-15-event-workflow-advance-shared-live-test-report.md);[projection live](../test-reports/2026-09-15-event-projection-shared-live-test-report.md) | E0A–E2 与共享 live 已过;生产无 hook 注册。D4–D7b 已合入。FAV off。Track P 立项完成(H0/P0),实施另列 | `architecture-docs` | --- diff --git a/.doc/test-reports/2026-09-15-event-e2-unhook-production-test-report.md b/.doc/test-reports/2026-09-15-event-e2-unhook-production-test-report.md new file mode 100644 index 00000000..4f631e70 --- /dev/null +++ b/.doc/test-reports/2026-09-15-event-e2-unhook-production-test-report.md @@ -0,0 +1,47 @@ +# E2 生产去注册测试报告 + +## 一、 测试背景 (Background) + +三条消费者已在共享 `agent-dev` live。Track E DoD 要求生产组合根不再接线 `OnOutbox =` / `AddOnApprovedHook(`,但保留方法本体一个窗口。 + +## 二、 测试目标 (Test Objectives) + +- `internal/app/app.go` 与 server 组合根无上述注册。 +- `AddOnApprovedHook` / `OnOutbox` 字段与 Bind 辅助仍存在,测试可接线。 +- 授权读取 6275 仍与 digest 一致。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|----|-----| +| 分支 | `feature/event-shared-live-cutover` | +| 单测 | `TestProductionCompositionHasNoHookRegistration` | +| arch-check | `--mode block` | + +## 四、 预期结果 (Expected Results) + +组合根无注册;arch-check PASS;6275 SHA 不变。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 源码门 + +- **测试输入 (Input)**:`go test ./internal/app -run TestProductionCompositionHasNoHookRegistration`;`./scripts/arch-check.sh --mode block` +- **实际输出 (Actual Output)**:PASS;`arch-check block: production gate passed` +- **评价判定 (Evaluation / Verdict)**:**PASS** +- **性能与耗时 (Duration & Latency)**:arch-check ~0.2s;server 包 10s + +### 5.2 运行时(rebuild 后补) + +见同目录资产与 rebuild 日志。未登录仍 401;跨项目 143 仍 404 `DOCUMENT_NOT_FOUND`。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | +|------|------| +| `go test -short ./internal/app ./internal/server ./internal/app/docnotify` | PASS | +| arch-check block | PASS | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +`DOC-RELIABILITY-PILOT` 事件部分关闭。Track P 独立实施。hook 方法本体尚未删除。 diff --git a/.doc/test-reports/2026-09-15-event-notify-shared-live-test-report.md b/.doc/test-reports/2026-09-15-event-notify-shared-live-test-report.md new file mode 100644 index 00000000..e073ecf1 --- /dev/null +++ b/.doc/test-reports/2026-09-15-event-notify-shared-live-test-report.md @@ -0,0 +1,61 @@ +# 共享 notify live 测试报告 + +## 一、 测试背景 (Background) + +隔离库 E1A 已证明 notify 的 shadow dry-run、唯一可见通知与回滚。本波在共享 `agent_system_dev` 上把 `notify` 切为权威写者。 + +## 二、 测试目标 (Test Objectives) + +- fav / test / main 切模式失败。 +- 历史非法 fav/projection delivery 已 quarantine,不得 claim。 +- notify:shadow → drain(重启 events worker)→ live → off 回滚 → 再 live。 +- FAV 保持 off;HTTP `:8080` 与 events `:8088` ready。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|----|-----| +| OS | Linux | +| 分支 | `feature/event-shared-live-cutover` | +| 共享库 | `shuimu-context-pg-dev` / `agent_system_dev` schema 43 | +| 隔离回归 | `127.0.0.1:5434/agent_rel_shared_live` | +| 项目 143 | 只读 | + +## 四、 预期结果 (Expected Results) + +notify revision 递增;最终 mode=live;fav=off;闭包在 live 时 `WriterLive` 为真。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 隔离库 E1A + +- **测试输入 (Input)**:`TEST_DATABASE_URL=.../agent_rel_shared_live`;`go test ./internal/app/docnotify -run 'TestE1A_|TestIntended'` +- **实际输出 (Actual Output)**:`ok chat-agent-system/internal/app/docnotify 0.361s` +- **评价判定 (Evaluation / Verdict)**:**PASS** +- **性能与耗时 (Duration & Latency)**:0.36s + +### 5.2 共享切模式 + +- **测试输入 (Input)**:`./scripts/event-consumer-mode.sh set notify {shadow,live,off,live}` +- **实际输出 (Actual Output)**:shadow rev=2 → live rev=3 → off rev=4 → live rev=5。见 `assets/2026-09-15-event-shared-live/notify-*.json` +- **评价判定 (Evaluation / Verdict)**:**PASS** +- **性能与耗时 (Duration & Latency)**:约 7s(含 worker 重启) + +### 5.3 健康检查 + +- **测试输入 (Input)**:`curl :8080/ready` `:8088/ready` +- **实际输出 (Actual Output)**:schema 43;events ready ok;fav mode=off +- **评价判定 (Evaluation / Verdict)**:**PASS** +- **性能与耗时 (Duration & Latency)**:<1s + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | +|------|------| +| ValidateOpsCutover 单测 | PASS | +| E1A 隔离 PG | PASS | +| 共享 notify 回滚演练 | PASS | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +共享 **notify=live**。下一步 workflow_advance。未删 hook API。 diff --git a/.doc/test-reports/2026-09-15-event-projection-shared-live-test-report.md b/.doc/test-reports/2026-09-15-event-projection-shared-live-test-report.md new file mode 100644 index 00000000..fdbbee68 --- /dev/null +++ b/.doc/test-reports/2026-09-15-event-projection-shared-live-test-report.md @@ -0,0 +1,57 @@ +# 共享 projection live 测试报告 + +## 一、 测试背景 (Background) + +隔离库 E1C 已证明 projection no-replace。本波在共享库切 live,并做三类 worker 重启与授权读取。 + +## 二、 测试目标 (Test Objectives) + +- projection shadow → 重启 → live → 重启 → lease 过期后再重启 → 回滚 off → 再 live +- 非法 taskrun projection delivery 保持 pending(quarantine 不可 claim) +- 项目 6275 授权刷新正文 SHA 与 `document_records.content_sha256` 一致 +- 项目 143 跨项目读取不泄露;未登录 401 +- FAV off + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|----|-----| +| 共享 | schema 43;events `:8088` | +| 隔离 | `agent_rel_shared_live` | +| 授权用户 | `zhanggong`(6275 owner,非 143 成员) | +| 浏览器 | 无独立 browser MCP;用 HTTP 刷新代替页面点击 | + +## 四、 预期结果 (Expected Results) + +三消费者 live;fav off;6275 SHA 一致;143 为 `DOCUMENT_NOT_FOUND`。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 隔离 E1C + +- **测试输入 (Input)**:`go test ./internal/app/docproject -run TestE1C_` +- **实际输出 (Actual Output)**:`ok 0.039s` +- **评价判定 (Evaluation / Verdict)**:**PASS** +- **性能与耗时 (Duration & Latency)**:0.039s + +### 5.2 共享切换与三类重启 + +- **测试输入 (Input)**:shadow;restart1;live;restart2;sleep 31;restart3;off;live +- **实际输出 (Actual Output)**:projection rev 2→3→4→5;`modes-after-projection.txt` 三条业务消费者均为 live,fav off。quarantine projection 仍 pending=2 +- **评价判定 (Evaluation / Verdict)**:**PASS** +- **性能与耗时 (Duration & Latency)**:约 45s + +### 5.3 授权刷新 / 负例 + +- **测试输入 (Input)**:登录后两次 GET `/api/v1/docgov/documents/doc_proj6275_intake/content`;跨项目 GET 143 文档;无 token GET 6275 +- **实际输出 (Actual Output)**:200/200,`content_sha256=dc99aa13…641ddf` 与库一致;143 → 404 `DOCUMENT_NOT_FOUND`;未登录 401 +- **评价判定 (Evaluation / Verdict)**:**PASS** +- **性能与耗时 (Duration & Latency)**:<1s + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +E1C 隔离 PASS;共享 live + 重启 + HTTP 刷新 PASS。无浏览器截图(工具不可用)。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +三条消费者均 **live**。下一步删除生产 hook 注册。未写项目 143。 diff --git a/.doc/test-reports/2026-09-15-event-workflow-advance-shared-live-test-report.md b/.doc/test-reports/2026-09-15-event-workflow-advance-shared-live-test-report.md new file mode 100644 index 00000000..9aaaacb4 --- /dev/null +++ b/.doc/test-reports/2026-09-15-event-workflow-advance-shared-live-test-report.md @@ -0,0 +1,51 @@ +# 共享 workflow_advance live 测试报告 + +## 一、 测试背景 (Background) + +隔离库 E1B 已证明阶段推进 exactly-one visible outcome。本波在共享库将 `workflow_advance` 切 live。 + +## 二、 测试目标 (Test Objectives) + +shadow → drain → live → off 回滚 → 再 live;notify 保持 live;FAV off。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|----|-----| +| 共享库 | `agent_system_dev` schema 43 | +| 隔离回归 | `agent_rel_shared_live` | + +## 四、 预期结果 (Expected Results) + +workflow_advance 最终 live revision≥5;notify 仍 live;fav off。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 隔离 E1B + +- **测试输入 (Input)**:`go test ./internal/app/docadvance -run TestE1B_` +- **实际输出 (Actual Output)**:`ok 0.065s` +- **评价判定 (Evaluation / Verdict)**:**PASS** +- **性能与耗时 (Duration & Latency)**:0.065s + +### 5.2 共享切换 + +- **测试输入 (Input)**:`event-consumer-mode.sh set workflow_advance {shadow,live,off,live}` +- **实际输出 (Actual Output)**:rev 2→3→4→5;modes-after-advance.txt:notify live、workflow_advance live、projection off、fav off +- **评价判定 (Evaluation / Verdict)**:**PASS** +- **性能与耗时 (Duration & Latency)**:约 7s + +### 5.3 健康检查 + +- **测试输入 (Input)**:`:8080/ready` `:8088/ready` +- **实际输出 (Actual Output)**:schema 43 ok +- **评价判定 (Evaluation / Verdict)**:**PASS** +- **性能与耗时 (Duration & Latency)**:<1s + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +E1B 隔离 PASS;共享回滚演练 PASS。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +**workflow_advance=live**。下一步 projection + 授权页。 diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-live-again.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-live-again.json new file mode 100644 index 00000000..7c126584 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-live-again.json @@ -0,0 +1 @@ +{"after":"live","before":"off","consumer":"workflow_advance","environment":"dev","revision":5} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-live.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-live.json new file mode 100644 index 00000000..837c067f --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-live.json @@ -0,0 +1 @@ +{"after":"live","before":"shadow","consumer":"workflow_advance","environment":"dev","revision":3} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-rollback-off.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-rollback-off.json new file mode 100644 index 00000000..a0c4a467 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-rollback-off.json @@ -0,0 +1 @@ +{"after":"off","before":"live","consumer":"workflow_advance","environment":"dev","revision":4} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-shadow.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-shadow.json new file mode 100644 index 00000000..1060543c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/advance-shadow.json @@ -0,0 +1 @@ +{"after":"shadow","before":"off","consumer":"workflow_advance","environment":"dev","revision":2} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/auth-6275-content-keys.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/auth-6275-content-keys.json new file mode 100644 index 00000000..80713282 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/auth-6275-content-keys.json @@ -0,0 +1,15 @@ +{ + "status": 200, + "keys": [ + "consistency", + "content_sha256", + "doc_id", + "file_sha256", + "head_sha256", + "is_template_only", + "markdown", + "record_sha256", + "version" + ], + "content_sha256": "dc99aa130644353f258e951887c9d3babf35afc9d46e008327121bdedc641ddf" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/auth-6275-content-status.txt b/.doc/test-reports/assets/2026-09-15-event-shared-live/auth-6275-content-status.txt new file mode 100644 index 00000000..37a03d64 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/auth-6275-content-status.txt @@ -0,0 +1 @@ +status=200 diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/auth-6275-revision-state.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/auth-6275-revision-state.json new file mode 100644 index 00000000..0f14b537 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/auth-6275-revision-state.json @@ -0,0 +1,18 @@ +{ + "status": 200, + "head_revision_no": 0, + "keys": [ + "baseline_snapshot_id", + "can_decide_pending", + "capabilities", + "current_version_no", + "doc_id", + "etag", + "head_block_map", + "head_revision_no", + "head_sha256", + "head_snapshot_id", + "manual_change_count", + "version_status" + ] +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/authz-143-negative.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/authz-143-negative.json new file mode 100644 index 00000000..cdb670f1 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/authz-143-negative.json @@ -0,0 +1,5 @@ +{ + "status": 404, + "doc_id_redacted": true, + "body_prefix": "{\"error_code\":\"DOCUMENT_NOT_FOUND\",\"message\":\"document not found\"}\n" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/delivery-status-after-projection.txt b/.doc/test-reports/assets/2026-09-15-event-shared-live/delivery-status-after-projection.txt new file mode 100644 index 00000000..014463ce --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/delivery-status-after-projection.txt @@ -0,0 +1,6 @@ + consumer | status | count +------------+---------+------- + fav | pending | 2 + projection | pending | 2 +(2 rows) + diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/events-ready-shadow-notify.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/events-ready-shadow-notify.json new file mode 100644 index 00000000..13109c02 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/events-ready-shadow-notify.json @@ -0,0 +1 @@ +{"status":"ok"} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/health-live-notify.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/health-live-notify.json new file mode 100644 index 00000000..de36335c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/health-live-notify.json @@ -0,0 +1 @@ +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":43,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/health-shadow-notify.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/health-shadow-notify.json new file mode 100644 index 00000000..de36335c --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/health-shadow-notify.json @@ -0,0 +1 @@ +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":43,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/inventory-after-quarantine.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/inventory-after-quarantine.json new file mode 100644 index 00000000..67cd608a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/inventory-after-quarantine.json @@ -0,0 +1 @@ +{"backlog":[{"consumer":"fav","status":"pending","count":2},{"consumer":"projection","status":"pending","count":2}],"modes":[{"environment":"dev","consumer":"fav","mode":"off","revision":1},{"environment":"dev","consumer":"notify","mode":"off","revision":1},{"environment":"dev","consumer":"projection","mode":"off","revision":1},{"environment":"dev","consumer":"workflow_advance","mode":"off","revision":1},{"environment":"main","consumer":"fav","mode":"off","revision":1},{"environment":"main","consumer":"notify","mode":"off","revision":1},{"environment":"main","consumer":"projection","mode":"off","revision":1},{"environment":"main","consumer":"workflow_advance","mode":"off","revision":1},{"environment":"test","consumer":"fav","mode":"off","revision":1},{"environment":"test","consumer":"notify","mode":"off","revision":1},{"environment":"test","consumer":"projection","mode":"off","revision":1},{"environment":"test","consumer":"workflow_advance","mode":"off","revision":1}],"pending_classifications":[{"event_id":"taskrun:run_1789452300651535257:failed","consumer":"fav","classification":"quarantine","reason":"unknown_or_disabled_consumer"},{"event_id":"taskrun:run_1789452300651535257:failed","consumer":"projection","classification":"quarantine","reason":"unknown_or_disabled_consumer"},{"event_id":"taskrun:run_1789452602295343365:completed","consumer":"fav","classification":"quarantine","reason":"unknown_or_disabled_consumer"},{"event_id":"taskrun:run_1789452602295343365:completed","consumer":"projection","classification":"quarantine","reason":"unknown_or_disabled_consumer"}]} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/modes-after-advance.txt b/.doc/test-reports/assets/2026-09-15-event-shared-live/modes-after-advance.txt new file mode 100644 index 00000000..1941ed9e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/modes-after-advance.txt @@ -0,0 +1,8 @@ + consumer | mode | revision +------------------+------+---------- + fav | off | 1 + notify | live | 5 + projection | off | 1 + workflow_advance | live | 5 +(4 rows) + diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/modes-after-notify.txt b/.doc/test-reports/assets/2026-09-15-event-shared-live/modes-after-notify.txt new file mode 100644 index 00000000..bfc6b9b4 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/modes-after-notify.txt @@ -0,0 +1,8 @@ + environment | consumer | mode | revision +-------------+------------------+------+---------- + dev | fav | off | 1 + dev | notify | live | 5 + dev | projection | off | 1 + dev | workflow_advance | off | 1 +(4 rows) + diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/modes-after-projection.txt b/.doc/test-reports/assets/2026-09-15-event-shared-live/modes-after-projection.txt new file mode 100644 index 00000000..c4ac9951 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/modes-after-projection.txt @@ -0,0 +1,8 @@ + consumer | mode | revision +------------------+------+---------- + fav | off | 1 + notify | live | 5 + projection | live | 5 + workflow_advance | live | 5 +(4 rows) + diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-live-again.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-live-again.json new file mode 100644 index 00000000..fcbd69f3 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-live-again.json @@ -0,0 +1 @@ +{"after":"live","before":"off","consumer":"notify","environment":"dev","revision":5} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-live.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-live.json new file mode 100644 index 00000000..35e2048a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-live.json @@ -0,0 +1 @@ +{"after":"live","before":"shadow","consumer":"notify","environment":"dev","revision":3} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-rollback-off.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-rollback-off.json new file mode 100644 index 00000000..70431645 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-rollback-off.json @@ -0,0 +1 @@ +{"after":"off","before":"live","consumer":"notify","environment":"dev","revision":4} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-shadow.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-shadow.json new file mode 100644 index 00000000..eefd4b37 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/notify-shadow.json @@ -0,0 +1 @@ +{"after":"shadow","before":"off","consumer":"notify","environment":"dev","revision":2} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-live-again.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-live-again.json new file mode 100644 index 00000000..b181c76d --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-live-again.json @@ -0,0 +1 @@ +{"after":"live","before":"off","consumer":"projection","environment":"dev","revision":5} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-live.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-live.json new file mode 100644 index 00000000..ea57ad38 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-live.json @@ -0,0 +1 @@ +{"after":"live","before":"shadow","consumer":"projection","environment":"dev","revision":3} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-rollback-off.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-rollback-off.json new file mode 100644 index 00000000..8cf5993e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-rollback-off.json @@ -0,0 +1 @@ +{"after":"off","before":"live","consumer":"projection","environment":"dev","revision":4} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-shadow.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-shadow.json new file mode 100644 index 00000000..41977e2e --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/projection-shadow.json @@ -0,0 +1 @@ +{"after":"shadow","before":"off","consumer":"projection","environment":"dev","revision":2} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/quarantine.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/quarantine.json new file mode 100644 index 00000000..960be91f --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/quarantine.json @@ -0,0 +1 @@ +{"quarantined":4} diff --git a/.doc/test-reports/assets/2026-09-15-event-shared-live/unauth-6275.json b/.doc/test-reports/assets/2026-09-15-event-shared-live/unauth-6275.json new file mode 100644 index 00000000..04c9a8bb --- /dev/null +++ b/.doc/test-reports/assets/2026-09-15-event-shared-live/unauth-6275.json @@ -0,0 +1,4 @@ +{ + "status": 401, + "body": "{\"error_code\":\"ERR_UNAUTHENTICATED\",\"message\":\"请先登录\"}\n" +} \ No newline at end of file diff --git a/cmd/eventmode/main.go b/cmd/eventmode/main.go new file mode 100644 index 00000000..ef6590d7 --- /dev/null +++ b/cmd/eventmode/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "chat-agent-system/internal/config" + "chat-agent-system/internal/events" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + envPath := os.Getenv("EVENTMODE_ENV") + if envPath == "" { + envPath = "configs/.env.dev" + } + cfg, err := config.Load(envPath) + if err != nil { + fmt.Fprintln(os.Stderr, "eventmode: load env") + os.Exit(1) + } + if cfg.Env != events.OpsCutoverEnvironment { + fmt.Fprintln(os.Stderr, "eventmode: env must be dev") + os.Exit(1) + } + if cfg.ContextDbName != "agent_system_dev" { + fmt.Fprintln(os.Stderr, "eventmode: database must be agent_system_dev") + os.Exit(1) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, cfg.ContextDSN()) + if err != nil { + fmt.Fprintln(os.Stderr, "eventmode: pool") + os.Exit(1) + } + defer pool.Close() + + switch os.Args[1] { + case "inventory": + if err := runInventory(ctx, pool); err != nil { + fmt.Fprintln(os.Stderr, "eventmode: inventory failed") + os.Exit(1) + } + case "set": + if len(os.Args) != 4 { + usage() + os.Exit(2) + } + consumer, mode := os.Args[2], os.Args[3] + before, _, _ := events.LoadMode(ctx, pool, cfg.Env, consumer) + if err := events.ApplyOpsCutover(ctx, pool, cfg.Env, consumer, mode); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + after, rev, _ := events.LoadMode(ctx, pool, cfg.Env, consumer) + enc := json.NewEncoder(os.Stdout) + _ = enc.Encode(map[string]any{ + "environment": cfg.Env, "consumer": consumer, + "before": before, "after": after, "revision": rev, + }) + case "quarantine-illegal": + n, err := events.QuarantineIllegalPending(ctx, pool, "cursor-ops", "phase2-shared-live") + if err != nil { + fmt.Fprintln(os.Stderr, "eventmode: quarantine failed") + os.Exit(1) + } + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"quarantined": n}) + default: + usage() + os.Exit(2) + } +} + +func runInventory(ctx context.Context, pool *pgxpool.Pool) error { + modes, err := events.ListModes(ctx, pool) + if err != nil { + return err + } + backlog, err := events.ListBacklog(ctx, pool) + if err != nil { + return err + } + pending, err := events.ClassifyPending(ctx, pool) + if err != nil { + return err + } + type wire struct { + EventID string `json:"event_id"` + Consumer string `json:"consumer"` + Classification string `json:"classification"` + Reason string `json:"reason"` + } + class := make([]wire, 0, len(pending)) + for _, r := range pending { + class = append(class, wire{r.EventID, r.Consumer, r.Classification, r.Reason}) + } + return json.NewEncoder(os.Stdout).Encode(map[string]any{ + "modes": modes, "backlog": backlog, "pending_classifications": class, + }) +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: eventmode inventory | set | quarantine-illegal") +} diff --git a/internal/app/app.go b/internal/app/app.go index 6ee92ef7..27fe690c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -201,22 +201,16 @@ func (r *Runtime) bindDocumentNotify() { if r == nil || r.notifyBound { return } - if r.Biz != nil && r.Rev != nil { - if docnotify.Bind(r.Rev, r.DocNotify) { - r.subscribers++ - } - } + // Durable notify is live; do not assign OnOutbox in production. r.notifyBound = true } func (r *Runtime) bindWorkflowApprovalHook() { - if r == nil || r.hookBound || r.Gov == nil || r.Inst == nil || !r.Inst.Available() { - r.hookBound = true + if r == nil || r.hookBound { return } - r.Gov.AddOnApprovedHook(r.onWorkflowDocumentApproved) + // Durable workflow_advance is live; do not register AddOnApprovedHook in production. r.hookBound = true - r.subscribers++ } // WriterLive reports whether the durable consumer already owns the visible write. diff --git a/internal/app/unhook_test.go b/internal/app/unhook_test.go new file mode 100644 index 00000000..07479924 --- /dev/null +++ b/internal/app/unhook_test.go @@ -0,0 +1,48 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProductionCompositionHasNoHookRegistration(t *testing.T) { + root := findGoMod(t) + files := []string{ + filepath.Join(root, "internal", "app", "app.go"), + filepath.Join(root, "internal", "server", "workflow_approval_hook.go"), + filepath.Join(root, "internal", "server", "server.go"), + } + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + src := string(b) + if strings.Contains(src, "OnOutbox =") { + t.Fatalf("%s still assigns OnOutbox", f) + } + if strings.Contains(src, "AddOnApprovedHook(") { + t.Fatalf("%s still registers AddOnApprovedHook", f) + } + } +} + +func findGoMod(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("go.mod not found") + } + dir = parent + } +} diff --git a/internal/events/cutover.go b/internal/events/cutover.go new file mode 100644 index 00000000..c5078b8a --- /dev/null +++ b/internal/events/cutover.go @@ -0,0 +1,170 @@ +package events + +import ( + "context" + "fmt" + "strings" + + "chat-agent-system/internal/app/uow" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const OpsCutoverEnvironment = "dev" + +// ValidateOpsCutover is the shared-dev gate for cluster mode changes. +// test/main, FAV, and unknown consumers are rejected before any write. +func ValidateOpsCutover(environment, consumer, mode string) error { + if strings.TrimSpace(environment) != OpsCutoverEnvironment { + return fmt.Errorf("%w: environment %q", ErrOpsCutover, environment) + } + switch consumer { + case ConsumerNotify, ConsumerWorkflowAdvance, ConsumerProjection: + default: + return fmt.Errorf("%w: consumer %q", ErrOpsCutover, consumer) + } + switch mode { + case ModeOff, ModeShadow, ModeLive: + default: + return fmt.Errorf("%w: mode %q", ErrOpsCutover, mode) + } + return nil +} + +func ApplyOpsCutover(ctx context.Context, pool *pgxpool.Pool, environment, consumer, mode string) error { + if err := ValidateOpsCutover(environment, consumer, mode); err != nil { + return err + } + return SetMode(ctx, pool, environment, consumer, mode) +} + +type ModeSnapshot struct { + Environment string `json:"environment"` + Consumer string `json:"consumer"` + Mode string `json:"mode"` + Revision int64 `json:"revision"` +} + +type BacklogRow struct { + Consumer string `json:"consumer"` + Status string `json:"status"` + Count int64 `json:"count"` +} + +func ListModes(ctx context.Context, pool *pgxpool.Pool) ([]ModeSnapshot, error) { + rows, err := pool.Query(ctx, ` + SELECT environment, consumer, mode, revision + FROM event_consumer_modes + ORDER BY environment, consumer + `) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ModeSnapshot + for rows.Next() { + var m ModeSnapshot + if err := rows.Scan(&m.Environment, &m.Consumer, &m.Mode, &m.Revision); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +func ListBacklog(ctx context.Context, pool *pgxpool.Pool) ([]BacklogRow, error) { + rows, err := pool.Query(ctx, ` + SELECT consumer, status, COUNT(*) + FROM event_deliveries + GROUP BY consumer, status + ORDER BY consumer, status + `) + if err != nil { + return nil, err + } + defer rows.Close() + var out []BacklogRow + for rows.Next() { + var r BacklogRow + if err := rows.Scan(&r.Consumer, &r.Status, &r.Count); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +type pendingDelivery struct { + EventID string + Consumer string + EventName string + Environment string + ProjectID int64 + SchemaVersion int + PayloadDigest string +} + +func loadPending(ctx context.Context, pool *pgxpool.Pool) ([]pendingDelivery, error) { + rows, err := pool.Query(ctx, ` + SELECT d.event_id, d.consumer, e.event_name, e.environment, e.project_id, e.schema_version, COALESCE(e.payload_digest, '') + FROM event_deliveries d + INNER JOIN event_envelopes e ON e.event_id = d.event_id + WHERE d.status IN ('pending','leased') + `) + if err != nil { + return nil, err + } + defer rows.Close() + var out []pendingDelivery + for rows.Next() { + var p pendingDelivery + if err := rows.Scan(&p.EventID, &p.Consumer, &p.EventName, &p.Environment, &p.ProjectID, &p.SchemaVersion, &p.PayloadDigest); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func ClassifyPending(ctx context.Context, pool *pgxpool.Pool) ([]InventoryRow, error) { + pending, err := loadPending(ctx, pool) + if err != nil { + return nil, err + } + out := make([]InventoryRow, 0, len(pending)) + for _, p := range pending { + env := Envelope{ + EventID: p.EventID, EventName: p.EventName, SchemaVersion: p.SchemaVersion, + Environment: p.Environment, ProjectID: p.ProjectID, PayloadDigest: p.PayloadDigest, + } + out = append(out, ClassifyEnvelope(env, p.Consumer)) + } + return out, nil +} + +// QuarantineIllegalPending persists quarantine for pending deliveries whose +// consumer is not on the contract (including FAV). Safe envelopes are left untouched. +func QuarantineIllegalPending(ctx context.Context, pool *pgxpool.Pool, operator, approval string) (int, error) { + if operator == "" || approval == "" { + return 0, ErrIntegrityFailure + } + rows, err := ClassifyPending(ctx, pool) + if err != nil { + return 0, err + } + n := 0 + unit := uow.NewPG(pool) + for _, row := range rows { + if row.Classification != ClassificationQuarantine { + continue + } + err := unit.Within(ctx, func(scope uow.TxScope) error { + return PersistInventory(uow.WithScope(ctx, scope), row, operator, approval) + }) + if err != nil { + return n, err + } + n++ + } + return n, nil +} diff --git a/internal/events/cutover_script_test.go b/internal/events/cutover_script_test.go new file mode 100644 index 00000000..f928a524 --- /dev/null +++ b/internal/events/cutover_script_test.go @@ -0,0 +1,44 @@ +package events_test + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEventConsumerModeScriptGates(t *testing.T) { + root := repoRoot(t) + text, err := os.ReadFile(filepath.Join(root, "scripts", "event-consumer-mode.sh")) + if err != nil { + t.Fatal(err) + } + s := string(text) + if !strings.Contains(s, "fav") || !strings.Contains(s, "refusing test/main") { + t.Fatal("script must reject fav and test/main") + } + if strings.Contains(s, "5434") || strings.Contains(s, "5435") { + t.Fatal("script must not pin test/main ports") + } + if !strings.Contains(s, "agent_system_dev") || !strings.Contains(s, "5433") { + t.Fatal("script must pin shared dev db") + } +} + +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("go.mod not found") + } + dir = parent + } +} diff --git a/internal/events/cutover_test.go b/internal/events/cutover_test.go new file mode 100644 index 00000000..36658e37 --- /dev/null +++ b/internal/events/cutover_test.go @@ -0,0 +1,34 @@ +package events + +import "testing" + +func TestValidateOpsCutoverRejectsFavAndNonDev(t *testing.T) { + if err := ValidateOpsCutover("dev", ConsumerNotify, ModeShadow); err != nil { + t.Fatal(err) + } + if err := ValidateOpsCutover("test", ConsumerNotify, ModeLive); err == nil { + t.Fatal("test env must fail") + } + if err := ValidateOpsCutover("main", ConsumerNotify, ModeLive); err == nil { + t.Fatal("main env must fail") + } + if err := ValidateOpsCutover("dev", ConsumerFAV, ModeOff); err == nil { + t.Fatal("fav must fail even at off") + } + if err := ValidateOpsCutover("dev", "unknown", ModeLive); err == nil { + t.Fatal("unknown consumer must fail") + } + if err := ValidateOpsCutover("dev", ConsumerNotify, "dual"); err == nil { + t.Fatal("unknown mode must fail") + } +} + +func TestValidateOpsCutoverAllowsThreeConsumers(t *testing.T) { + for _, c := range []string{ConsumerNotify, ConsumerWorkflowAdvance, ConsumerProjection} { + for _, m := range []string{ModeOff, ModeShadow, ModeLive} { + if err := ValidateOpsCutover("dev", c, m); err != nil { + t.Fatalf("%s %s: %v", c, m, err) + } + } + } +} diff --git a/internal/events/envelope.go b/internal/events/envelope.go index ddd7bfa4..039c62e3 100644 --- a/internal/events/envelope.go +++ b/internal/events/envelope.go @@ -23,6 +23,7 @@ var ( ErrDisabledConsumer = errors.New("events: consumer is disabled") ErrUnregistered = errors.New("events: event or consumer is not in the static registry") ErrReplayApply = errors.New("events: replay apply rejected") + ErrOpsCutover = errors.New("events: shared cutover rejected") ) type Envelope struct { diff --git a/internal/server/workflow_approval_hook.go b/internal/server/workflow_approval_hook.go index abd85414..3a77ca5b 100644 --- a/internal/server/workflow_approval_hook.go +++ b/internal/server/workflow_approval_hook.go @@ -9,10 +9,9 @@ import ( ) func (s *Server) registerWorkflowApprovalHook() { - if s == nil || s.gov == nil || s.instSvc == nil || !s.instSvc.Available() { - return - } - s.gov.AddOnApprovedHook(s.onWorkflowDocumentApproved) + // Durable workflow_advance is the unique writer. Keep the method for one + // rollback window; do not register AddOnApprovedHook in production. + return } func (s *Server) onWorkflowDocumentApproved(ctx context.Context, doc *docgov.DocumentRecord) { diff --git a/scripts/event-consumer-mode.sh b/scripts/event-consumer-mode.sh new file mode 100755 index 00000000..006fd865 --- /dev/null +++ b/scripts/event-consumer-mode.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Shared agent-dev event consumer mode switch. +# Only environment=dev, consumers notify|workflow_advance|projection, modes off|shadow|live. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +ENV_FILE="${EVENTMODE_ENV:-configs/.env.dev}" + +usage() { + echo "usage: $0 inventory | set | quarantine-illegal" >&2 + exit 2 +} + +if [[ $# -lt 1 ]]; then + usage +fi + +if [[ ! -f "$ENV_FILE" ]]; then + echo "event-consumer-mode: missing $ENV_FILE" >&2 + exit 1 +fi + +if grep -E -q '^ENV=(test|main)[[:space:]]*$' "$ENV_FILE"; then + echo "event-consumer-mode: refusing test/main env file" >&2 + exit 1 +fi + +# Host-side ops pin the published dev database; never test/main ports. +export CONTEXT_DB_HOST="${CONTEXT_DB_HOST:-127.0.0.1}" +export CONTEXT_DB_PORT="${CONTEXT_DB_PORT:-5433}" +export CONTEXT_DB_NAME="${CONTEXT_DB_NAME:-agent_system_dev}" +export ENV=dev +export EVENTMODE_ENV="$ENV_FILE" + +case "$1" in + inventory) + go run ./cmd/eventmode inventory + ;; + set) + if [[ $# -ne 3 ]]; then + usage + fi + consumer="$2" + mode="$3" + if [[ "$consumer" == "fav" ]]; then + echo "event-consumer-mode: fav is disabled" >&2 + exit 1 + fi + go run ./cmd/eventmode set "$consumer" "$mode" + ;; + quarantine-illegal) + go run ./cmd/eventmode quarantine-illegal + ;; + *) + usage + ;; +esac