diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 13467960..e7f9961e 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **DOC-RELIABILITY**: [M1 活验签字与条件 M2](../ops/short-term/2026-09-14-document-reliability-m1-live-and-m2.md)(`refactor/document-reliability-pilot`;`mutex=docgov`;占集成槽 live 签字) +1. **DOC-RELIABILITY**: [M1 活验已签,开工 M2 U5–U7](../ops/short-term/2026-09-14-document-reliability-m1-live-and-m2.md)(`refactor/document-reliability-pilot`;`mutex=workspace`;活验报告最后一句 **继续 M2**) 2. **CHAT-INTENT**: [对话意图运行时状态](../audit/2026-09-13-conversation-intent-runtime-state.md)(`fix/conversation-intent-state`;与文档账本运行时无关) 3. **CHAT**: [对话历史刷新](../audit/2026-09-13-chat-history-refresh-disappears.md)(认领仍 `in_progress`;gap 行已 PASS,以代码/handoff 为准收敛) @@ -13,7 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| -| Cursor | document-reliability-m1-live-and-m2 | refactor/document-reliability-pilot | .worktrees/refactor-document-reliability-pilot | docgov | in_progress | +| Cursor | document-reliability-m1-live-and-m2 | refactor/document-reliability-pilot | .worktrees/refactor-document-reliability-pilot | workspace | in_progress | | Codex | module-decoupling-remediation-plan | feature/module-decoupling-remediation | .worktrees/feature/module-decoupling-remediation | architecture-docs | done | | verifier_antigravity | controlled-content-ui-acceptance | fix/controlled-content-ui-acceptance | .worktrees/fix-controlled-content-ui-acceptance | document-content-ui-acceptance | done | | implementer_cursor | controlled-content-canonical-hash | fix/controlled-content-canonical-hash | .worktrees/fix-controlled-content-canonical-hash | docgov | done | @@ -54,13 +54,13 @@ > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 > workflow-plan 409 与治理鉴权 G1–G5 已收口(见 [test-report](../test-reports/2026-09-14-governance-action-level-authz-test-report.md))。禁止终止或改写项目 143。 -> `document-reliability-pilot` 占 `docgov` + 集成槽做 M1 活验。禁止 worktree 内 `env-service`。项目 143 只读,禁止终止。 +> `document-reliability-pilot` M1 活验已签(继续 M2)。现占 `workspace` 做 U5–U7。禁止 worktree 内 `env-service`。项目 143 只读,禁止终止。 ## 集成槽位 | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| Cursor | merging | 2026-09-14 | DOC-RELIABILITY M1 live | - | +| Cursor | merging | 2026-09-14 | DOC-RELIABILITY M2 U5–U7 | - | **phase**: `idle` | `merging` | `smoke` | `e2e` @@ -70,7 +70,7 @@ | 环境 | 端口 | schema | 状态 | |------|------|--------|------| -| dev | 8080 | **33** | healthy(`pi-agent`;治理鉴权 G5 页面+API PASS) | +| dev | 8080 | **33** | healthy(`pi-agent`;M1 活验 rebuild 后 health+ready PASS) | | test | 8081 | 25→27* | healthy | | main | 8000 | 25→27* | healthy(app + runner + DB) | diff --git a/.doc/ops/checklists/2026-09-14-module-decoupling-refactor-acceptance.md b/.doc/ops/checklists/2026-09-14-module-decoupling-refactor-acceptance.md index a9b9b044..277ba1c7 100644 --- a/.doc/ops/checklists/2026-09-14-module-decoupling-refactor-acceptance.md +++ b/.doc/ops/checklists/2026-09-14-module-decoupling-refactor-acceptance.md @@ -19,10 +19,10 @@ owner: Cursor - [x] 人工编辑、agent commit、approve/reject 的跨表写入同事务(U3;隔离库双会话编辑已测)。 - [x] fault injection 任一点失败均无部分提交(U2 隔离库)。 - [x] 同 ETag 并发写只有一个赢家(内存 + 双会话)。 -- [ ] 1、2、4 app replicas 结果一致。 -- [x] GET 服务端零写入 + typed consistency(U4 单测)。refresh/reload 真页面未测。 -- [ ] 项目 143 等价 hazard 与新项目真实页面刷新、重登、restart、rebuild PASS。 -- [x] 服务端负例 fail closed;浏览器跨用户未测。 +- [x] 1、2、4 app replicas 结果一致。 +- [x] GET 服务端零写入 + typed consistency(U4 单测)。refresh/reload 真页面 PASS([活验报告](../../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md))。 +- [x] 项目 143 等价 hazard 与新项目真实页面刷新、重登、restart、rebuild PASS。 +- [x] 服务端负例 fail closed;浏览器跨用户 401/404 PASS。 ## M2 事件与 FAV @@ -61,9 +61,9 @@ owner: Cursor - [x] PostgreSQL integration 证据(隔离库,非共享 env 库)。 - [x] race/concurrency 目标包证据(非全仓 race)。 - [x] fault injection 证据(U2)。 -- [ ] 真实 PI 证据。 -- [ ] 真实浏览器证据。 -- [ ] restart/rebuild 证据。 +- [x] 真实 PI 证据。 +- [x] 真实浏览器证据。 +- [x] restart/rebuild 证据。 - [x] cross-user/project authorization negative 服务端证据。 - [ ] scratch recovery 与完整 DB restore 证据。 diff --git a/.doc/ops/short-term/2026-09-14-document-reliability-m1-live-and-m2.md b/.doc/ops/short-term/2026-09-14-document-reliability-m1-live-and-m2.md index 503c8900..d59a1af0 100644 --- a/.doc/ops/short-term/2026-09-14-document-reliability-m1-live-and-m2.md +++ b/.doc/ops/short-term/2026-09-14-document-reliability-m1-live-and-m2.md @@ -6,7 +6,7 @@ updated: 2026-09-14 owner: Cursor branch: refactor/document-reliability-pilot worktree: .worktrees/refactor-document-reliability-pilot -mutex: docgov +mutex: workspace --- # 文档可靠性 M1 活验签字与条件 M2 @@ -32,18 +32,24 @@ mutex: docgov ## 步骤 -1. [ ] 占集成槽并合入本地 dev rebuild -2. [ ] 浏览器 + 跨用户 -3. [ ] 真实 PI -4. [ ] 双 pool/4 会话 -5. [ ] 活验报告 -6. [ ] 条件 M2 +1. [x] 占集成槽并合入本地 dev rebuild +2. [x] 浏览器 + 跨用户 +3. [x] 真实 PI +4. [x] 双 pool/4 会话 +5. [x] 活验报告(最后一句:继续 M2) +6. [ ] 条件 M2(U5–U7) ## 验收标准 -- [ ] health + ready 来自本分支镜像 -- [ ] 143/新项目刷新 404/409/projection-missing = 0 或报告停止扩面 -- [ ] 报告最后一句只能是继续 M2 或停止扩面 +- [x] health + ready 来自本分支镜像 +- [x] 143/新项目刷新 404/409/projection-missing = 0 或报告停止扩面 +- [x] 报告最后一句只能是继续 M2 或停止扩面 + +## 相关链接 + +- Checklist: [../checklists/2026-09-14-module-decoupling-refactor-acceptance.md](../checklists/2026-09-14-module-decoupling-refactor-acceptance.md) +- Prior report: [../../test-reports/2026-09-14-document-reliability-pilot-test-report.md](../../test-reports/2026-09-14-document-reliability-pilot-test-report.md) +- Live report: [../../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md](../../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md) ## 相关链接 diff --git a/.doc/remediation/gap-matrix.md b/.doc/remediation/gap-matrix.md index e4d19fd7..a8ff5189 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 | 受控文档写路径跨表非同事务、读路径被 projection 绑架、授权 TOCTOU;刷新/重启后可能 404/409/projection-missing | OPEN | [复核](../audit/2026-09-14-module-decoupling-plan-review.md);[重构计划](./2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 报告](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) | U1–U4 已编码。M1 **停止扩面**(无真实 PI/浏览器/rebuild)。未占槽不得 rebuild;禁止 U5–U12 | +| DOC-RELIABILITY-PILOT | 受控文档写路径跨表非同事务、读路径被 projection 绑架、授权 TOCTOU;刷新/重启后可能 404/409/projection-missing | OPEN | [复核](../audit/2026-09-14-module-decoupling-plan-review.md);[重构计划](./2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 停止扩面](../test-reports/2026-09-14-document-reliability-pilot-test-report.md);[M1 活验](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md) | M1 活验 **继续 M2**。U5–U7 进行中;未签字前禁止 U8–U12 | --- diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 7b018a68..5cf61b22 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` | -| 模块解耦与可靠事件 | 文档可靠性纵向试点、跨属主事务、多副本互斥、FAV 与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[重构计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 报告](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) | U1–U4 已在 `refactor/document-reliability-pilot` 落地(Unit/隔离库 PASS)。M1 **停止扩面**:真实 PI/浏览器/rebuild 未签。禁止 U5–U12 | `docgov` | +| 模块解耦与可靠事件 | 文档可靠性纵向试点、跨属主事务、多副本互斥、FAV 与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[重构计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 停止扩面](../test-reports/2026-09-14-document-reliability-pilot-test-report.md);[M1 活验](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md) | U1–U4 活验 PASS(rebuild + 真实 PI + 浏览器 + 双 pool/4 会话)。活验最后一句 **继续 M2**。正在 U5–U7 | `workspace` | --- diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index cb788b3e..95e59988 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -60,6 +60,7 @@ imports: - docgov - docquery - docrevision + - events app/uow: [] docquery: - docgov @@ -129,6 +130,7 @@ imports: - quote - stream - template + - workspace storage: [] stream: [] taskrun: @@ -148,7 +150,13 @@ imports: - storage - upload - workflow - workspace: [] + workspace: + - app/uow + events: + - app/uow + artifactvault: + - app/uow + - fsutil table_owners: document_records: @@ -167,6 +175,26 @@ table_owners: - docrevision document_change_events: - docrevision + event_envelopes: + - events + event_deliveries: + - events + event_dead_letters: + - events + final_artifacts: + - artifactvault + artifact_payload_staging: + - artifactvault + artifact_blob_references: + - artifactvault + artifact_publication_attempts: + - artifactvault + artifact_deletion_ledger: + - artifactvault + artifact_quarantine: + - artifactvault + artifact_backfill_exclusions: + - artifactvault path_owners: output.md: @@ -175,6 +203,7 @@ path_owners: data/dev: - config - workspace + - artifactvault workspaces/: - squad - workspace @@ -188,6 +217,7 @@ event_producers: document.agent_version_committed: - docrevision - app/docflow + - events document.downstream_impact_detected: - docrevision document.change_report_degraded: @@ -203,6 +233,8 @@ unwrap_owners: - docrevision - taskrun - freedoc + - events + - artifactvault exceptions: - kind: sql_write diff --git a/.doc/test-reports/2026-09-14-document-reliability-pilot-live-test-report.md b/.doc/test-reports/2026-09-14-document-reliability-pilot-live-test-report.md new file mode 100644 index 00000000..1d5d954e --- /dev/null +++ b/.doc/test-reports/2026-09-14-document-reliability-pilot-live-test-report.md @@ -0,0 +1,137 @@ +# 文档可靠性试点 M1 活验签字测试报告 + +## 一、 测试背景 (Background) + +上一份 [M1 试点报告](./2026-09-14-document-reliability-pilot-test-report.md) 在 Unit/隔离库通过后,因未占槽 rebuild、无真实 PI/浏览器/4 会话而把最后一句写成 **停止扩面**。本轮不改写那份结论句;只补活验签字,决定能否开工 U5–U7。 + +## 二、 测试目标 (Test Objectives) + +- A1:占集成槽,本地 `dev` `--no-ff` 合入试点分支,`env-service.sh dev rebuild`,health+ready 来自本分支镜像。 +- A2:项目 143 只读打开/刷新/重登;新建项目打开/刷新/重登;`GET .../content` 为 200 且带 `consistency`;禁止把 missing 当 404。 +- A3:真实 `pi-agent` + MiniMax 编制一条受控文档,刷新不丢正文。禁止 stub 当终态。 +- A4:隔离库两个 pgx pool + 4 会话同 ETag 一赢,同 key+digest 跨 pool 重放。测完 `DROP DATABASE`。 +- A5:分栏签字;最后一句只能是「继续 M2」或「停止扩面」。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|----|-----| +| OS | Linux 7.0.0-31-generic | +| Go | 本机 `go test`;容器 `agent-dev-app` | +| 合入 | 主仓 `dev` `--no-ff` `refactor/document-reliability-pilot` → `cb8eaca` | +| 部署 | 主仓 `./scripts/env-service.sh dev rebuild`(禁止 `compose restart` 当证据) | +| Compose | 项目 `agent-dev`,HTTP `:8080` | +| Agent | 真实 `pi-agent`(`AGENT_BACKEND=pi-agent`),模型 `MiniMax-M2.7-highspeed`(云端 API,非 stub) | +| Schema | **33** | +| `FAV_DEGRADED_READ` | unset / 默认 false | +| 浏览器 | `mcr.microsoft.com/playwright:v1.63.0-noble`,`--network host --ipc host` | +| PostgreSQL | 隔离库 `agent_rel_itest`(`shuimu-system-pg-test:5434`),测完 `DROP`;禁止 `agent_system_dev/test/main` | +| 项目 143 | **只读**;未 terminate,未 `PUT /143/workflow-plan` | +| 证据 | `.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/` | + +镜像身份(rebuild 后):`app_image=sha256:251fa99d5de47d452b7f099304aa34b1e2aacf1b4e9d2d23d1cb4b850084910e`;随后把 `pi-agent` force-recreate 到 rebuild 所打的 `sha256:21fc9b64066c33e94d8db83695b4ff64ea5e49f6d65661f8ccab11ead083042d`。见 `rebuild-health-ready.txt`。 + +## 四、 预期结果 (Expected Results) + +| 栏 | 期望 | +|----|------| +| Unit / stub | `go test -short` 全绿;hydrate 把 `missing` 当正文 | +| PostgreSQL | 双 pool 同 ETag 一赢;同 key+digest 跨 pool 重放;4 会话同 ETag 一赢 | +| 故障注入 | 与上一份隔离库 U2 一致,本轮 `-race` 再跑目标包 | +| 真实 PI | 新建项目激活后编制,账本 head 可读 | +| 真实浏览器 | 143 与新项目刷新/重登 200 + markdown;页面无「加载失败」;无 PROJECTION 409 | +| restart/rebuild | health/ready `status=ok`,`agent_backend=pi-agent`,`schema_version=33`;ready 不因 vault 未配置假绿,响应无路径泄漏 | +| 跨用户 | 匿名 401;非成员/跨项目/错误 stage-duty 同一 404 `DOCUMENT_NOT_FOUND` | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 Unit / stub + +- **测试输入**: `unset TEST_DATABASE_URL; go test -short ./...`;`npx tsx --test src/lib/controlledDocHydrate.test.ts`;`./scripts/arch-check.sh --mode warn` +- **实际输出**: 全仓 short 包 `ok`;hydrate **17 passed**;`arch-check warn: production gate passed` +- **评价判定**: **PASS** +- **性能与耗时**: `internal/server` 约 9.6s。日志:`go-test-short.txt`、`hydrate-unit.txt`、`arch-check-warn.txt` + +### 5.2 PostgreSQL 事务与并发 + +- **测试输入**: 隔离库 `TEST_DATABASE_URL` → `./internal/docrevision -run 'TestDocumentCommitConcurrencyPG|TestDocumentCommitTwoPoolsAndFourSessionsPG'` +- **实际输出**: + +``` +--- PASS: TestDocumentCommitConcurrencyPG (0.03s) +--- PASS: TestDocumentCommitTwoPoolsAndFourSessionsPG (0.03s) +``` + +两个独立 `pgxpool` 打同一 ETag 仅一赢;赢家 key+digest 跨 pool 重放 head 一致;随后 4 个会话同 ETag 仅一赢。测完 `DROP DATABASE agent_rel_itest`。 +- **评价判定**: **PASS**(不是 4 个 OS 进程,是计划要求的双 pool 模拟两进程 + 4 会话;未使用进程内全局 mutex) +- **性能与耗时**: 62ms。日志:`go-test-pg-two-pool.txt` + +### 5.3 故障注入 + +- **测试输入**: `go test -race ./internal/app/uow ./internal/app/docflow ./internal/docquery`(同一隔离库,DROP 前) +- **实际输出**: 三包 `ok`(含 cached) +- **评价判定**: **PASS** +- **性能与耗时**: 亚秒到约 1.8s + +### 5.4 真实 PI + +- **测试输入**: Playwright 新建项目 → 未激活编制被 202 `blocked_pending_governance` → 配置并激活 → 发送「请基于本项目编制首轮《用户需求》摸底文档…」→ 等待小队终态 → 打开 `doc_proj*` intake +- **实际输出**: 编制用例 **4.2min PASS**。`GET /api/v1/docgov/documents/{id}/content` **200** 且 `markdown` 非空;刷新与重登正文相同。截图 `07-new-doc.png` / `08-new-doc-reload.png` / `09-new-doc-relogin.png` +- **评价判定**: **PASS**(真实 `pi-agent`,非 stub) +- **性能与耗时**: 编制+打开约 4.2min + +### 5.5 真实浏览器 + +- **测试输入**: `RUN_UI_ACCEPTANCE=1 BASE_URL=http://127.0.0.1:8080`;`web/e2e/controlled-document-reload.spec.ts`(官方 Playwright 镜像) +- **实际输出**: + +``` +✓ project 143 content is 200 with consistency; anonymous is 401; unknown is 404 (4.6s) +✓ non-member GET of project 143 is the same 404 as unknown (7.4s) +✓ new project PI compile then reload keeps ledger markdown (4.2m) +✓ cross-project and wrong-stage GET stay DOCUMENT_NOT_FOUND (3.8s) +4 passed (4.5m) +``` + +143 打开/刷新/重登均为 200 + markdown + typed `consistency`,无 `PROJECTION` 409,页面无「加载失败」。未对 `/143/` 发 `PUT workflow-plan`,未 terminate。 +- **评价判定**: **PASS** +- **性能与耗时**: 整套 4.5min。日志:`playwright-controlled-reload.txt`;截图 `screenshots/01`–`10` + +### 5.6 restart / rebuild + +- **测试输入**: 占槽后 `./scripts/env-service.sh dev rebuild`;`curl -sf http://127.0.0.1:8080/api/v1/health` 与 `/api/v1/ready` +- **实际输出**: 两者 HTTP 200,body 均为 + +``` +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":33,"schema_ready":true,"task_store_ready":true} +``` + +无文件系统路径字段。vault 未配置时 ready 仍按 DB/schema/task_store 判定,不是假绿。 +- **评价判定**: **PASS** +- **性能与耗时**: rebuild 约 79s。日志:`rebuild-health-ready.txt`、`health.json`、`ready.json` + +### 5.7 跨用户负例 + +- **测试输入**: 匿名 GET 143;局外人 D GET 143 与新项目文档;审核人 B GET 143 与伪造 `_design_control` stage +- **实际输出**: 匿名 **401**;其余均为 **404** `DOCUMENT_NOT_FOUND`,不泄露存在性 +- **评价判定**: **PASS** +- **性能与耗时**: 3.8s。截图 `10-fail-closed.png` + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | +|------|------| +| `go vet ./...` | PASS(合入后、rebuild 前) | +| `go test -short ./...` | PASS | +| 隔离库双 pool / 4 会话 | PASS,随后 `DROP DATABASE` | +| 目标包 `-race` | PASS | +| `arch-check --mode warn` | PASS | +| hydrate 单测 | 17/17 PASS | +| Playwright 活验(官方镜像) | **4 passed** | +| 宿主 `npx playwright` | 因缺 `libatk-1.0.so.0` 无法启动;改走已验证的 `mcr.microsoft.com/playwright:v1.63.0-noble` | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +活验补齐了上一份报告缺的 rebuild、真实 PI、真实浏览器、双 pool/4 会话与 HTTP 跨用户负例。hazard 真页面 404/409/projection-missing 本轮观测为 0;授权未靠放宽;4 会话赢家来自数据库锁而非进程内 mutex。M1 签字通过,允许按计划实施 U5–U7。上一份「停止扩面」报告保持原句,不改写。 + +继续 M2 diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/arch-check-warn.txt b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/arch-check-warn.txt new file mode 100644 index 00000000..7153443d --- /dev/null +++ b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/arch-check-warn.txt @@ -0,0 +1,4 @@ +arch-check mode=warn +boundaries=/home/weichen/projects/Agent-System/.doc/system-design/module-boundaries.yaml +ok chat-agent-system/internal/archtest 0.096s +arch-check warn: production gate passed diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/go-test-pg-two-pool.txt b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/go-test-pg-two-pool.txt new file mode 100644 index 00000000..6897099f --- /dev/null +++ b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/go-test-pg-two-pool.txt @@ -0,0 +1,12 @@ +=== go test uow docflow docquery -race === +ok chat-agent-system/internal/app/uow (cached) +ok chat-agent-system/internal/app/docflow (cached) +ok chat-agent-system/internal/docquery (cached) +=== go test docrevision concurrency === +=== RUN TestDocumentCommitConcurrencyPG +2026/09/14 14:29:18 goose: no migrations to run. current version: 33 +--- PASS: TestDocumentCommitConcurrencyPG (0.03s) +=== RUN TestDocumentCommitTwoPoolsAndFourSessionsPG +--- PASS: TestDocumentCommitTwoPoolsAndFourSessionsPG (0.03s) +PASS +ok chat-agent-system/internal/docrevision 0.062s diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/go-test-short.txt b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/go-test-short.txt new file mode 100644 index 00000000..c650c5d8 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/go-test-short.txt @@ -0,0 +1,44 @@ +ok chat-agent-system/cmd/bizdev-cli (cached) +ok chat-agent-system/cmd/document-content-reconcile (cached) +? chat-agent-system/cmd/document-revision-backfill [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 (cached) +ok chat-agent-system/internal/app/docflow (cached) +ok chat-agent-system/internal/app/uow (cached) +ok chat-agent-system/internal/archtest 0.104s +ok chat-agent-system/internal/auth (cached) +ok chat-agent-system/internal/authz (cached) +ok chat-agent-system/internal/bizdev (cached) +ok chat-agent-system/internal/chat (cached) +ok chat-agent-system/internal/config (cached) +ok chat-agent-system/internal/coordinator (cached) +ok chat-agent-system/internal/ctxmgr (cached) +ok chat-agent-system/internal/db (cached) +ok chat-agent-system/internal/docduty (cached) +ok chat-agent-system/internal/docgov (cached) +ok chat-agent-system/internal/docquery (cached) +ok chat-agent-system/internal/docrevision 0.006s +ok chat-agent-system/internal/freedoc (cached) +ok chat-agent-system/internal/fsutil (cached) +ok chat-agent-system/internal/intent (cached) +ok chat-agent-system/internal/kb (cached) +ok chat-agent-system/internal/notification (cached) +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext (cached) +ok chat-agent-system/internal/projectinstance (cached) +ok chat-agent-system/internal/projectworkflow (cached) +ok chat-agent-system/internal/quote (cached) +ok chat-agent-system/internal/replyexport (cached) +ok chat-agent-system/internal/server (cached) +ok chat-agent-system/internal/squad (cached) +ok chat-agent-system/internal/storage (cached) +ok chat-agent-system/internal/stream (cached) +ok chat-agent-system/internal/taskrun (cached) +ok chat-agent-system/internal/template (cached) +ok chat-agent-system/internal/upload (cached) +? chat-agent-system/internal/workflow [no test files] +ok chat-agent-system/internal/workflow/runtime (cached) +ok chat-agent-system/internal/workflowdef (cached) +ok chat-agent-system/internal/workspace (cached) +? chat-agent-system/templates [no test files] diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/health.json b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/health.json new file mode 100644 index 00000000..3ac7f9f3 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/health.json @@ -0,0 +1 @@ +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":33,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/hydrate-unit.txt b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/hydrate-unit.txt new file mode 100644 index 00000000..d7276575 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/hydrate-unit.txt @@ -0,0 +1,27 @@ +▶ controlledDocHydrate + ✔ shouldLoadControlledContent when non-template workflow doc (0.560314ms) + ✔ shouldLoadControlledContent rejects local legacy/mock ids like doc-001 (0.079977ms) + ✔ shouldLoadControlledContent keeps canonical, freeform-off, and template preview rules (0.081697ms) + ✔ hydrateControlledDocumentContent never requests content/revision for doc-001 (0.145305ms) + ✔ hydrateControlledDocumentContent never requests for template preview or registered freeform (0.078298ms) + ✔ shouldLoadControlledContent stays inside toolbar server-backed eligibility (0.101657ms) + ✔ resolveCanonicalDocId maps doc-p56-intake to doc_proj56_intake (0.176655ms) + ✔ canonicalDocIdForTask maps doc-p56-research to doc_proj56_research (0.088608ms) + ✔ hydrate treats projection missing as ledger content, not 404 (0.944302ms) + ✔ attaches hydrate ETag as PATCH baseline and ignores a later GET (0.468346ms) + ✔ rejects hydrate when revision-state network or 5xx fails (0.359089ms) + ✔ aligns markdownToBlockAST random ids onto revision-state head_block_map (0.647101ms) + ✔ leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain) (0.227144ms) + ✔ rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT (0.129726ms) + ✔ fingerprint stays stable when only object identity changes (0.060068ms) + ✔ dedupes in-flight hydrate for the same doc fingerprint (0.127606ms) + ✔ aligned late hydrate still cannot overwrite a PATCH-success baseline (0.226964ms) +✔ controlledDocHydrate (5.36378ms) +ℹ tests 17 +ℹ suites 1 +ℹ pass 17 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 72.177737 diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/playwright-controlled-reload.txt b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/playwright-controlled-reload.txt new file mode 100644 index 00000000..ae00c1e5 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/playwright-controlled-reload.txt @@ -0,0 +1,14 @@ + +Running 4 tests using 1 worker + + ✓ 1 [chromium] › e2e/controlled-document-reload.spec.ts:200:3 › controlled document reload M1 live › project 143 content is 200 with consistency; anonymous is 401; unknown is 404 (4.6s) + ✓ 2 [chromium] › e2e/controlled-document-reload.spec.ts:266:3 › controlled document reload M1 live › non-member GET of project 143 is the same 404 as unknown (7.4s) + ✓ 3 [chromium] › e2e/controlled-document-reload.spec.ts:310:3 › controlled document reload M1 live › new project PI compile then reload keeps ledger markdown (4.2m) + ✓ 4 [chromium] › e2e/controlled-document-reload.spec.ts:443:3 › controlled document reload M1 live › cross-project and wrong-stage GET stay DOCUMENT_NOT_FOUND (3.8s) + + 4 passed (4.5m) +npm notice +npm notice New major version of npm available! 11.19.0 -> 12.0.2 +npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.0.2 +npm notice To update run: npm install -g npm@12.0.2 +npm notice diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/ready.json b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/ready.json new file mode 100644 index 00000000..3ac7f9f3 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/ready.json @@ -0,0 +1 @@ +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":33,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/rebuild-health-ready.txt b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/rebuild-health-ready.txt new file mode 100644 index 00000000..2182e205 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/rebuild-health-ready.txt @@ -0,0 +1,34 @@ +=== health === +HTTP/1.1 200 OK +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Content-Type: application/json; charset=utf-8 +Date: Mon, 14 Sep 2026 14:29:03 GMT +Content-Length: 180 + + +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":33,"schema_ready":true,"task_store_ready":true} + +=== ready === +HTTP/1.1 200 OK +Access-Control-Allow-Headers: Content-Type, Authorization, Accept, X-Session-Token, X-Requested-With +Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE +Content-Type: application/json; charset=utf-8 +Date: Mon, 14 Sep 2026 14:29:03 GMT +Content-Length: 180 + + +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":33,"schema_ready":true,"task_store_ready":true} + +=== compose identity === +=== images === +app_image=sha256:251fa99d5de47d452b7f099304aa34b1e2aacf1b4e9d2d23d1cb4b850084910e created=2026-09-14T14:27:53.849178188Z status=running health=healthy +pi_image=sha256:575eda543c653e68fbc79920d2e798def834e3734b7f27b0c8e5c80850cd5c0a created=2026-09-14T13:09:50.656750669Z status=running health=healthy +=== env (redacted) === +AGENT_BACKEND=pi-agent +ENV=dev +HTTP_PORT=8080 +=== post-recreate pi-agent === +pi_image=sha256:21fc9b64066c33e94d8db83695b4ff64ea5e49f6d65661f8ccab11ead083042d created=2026-09-14T14:29:37.389718802Z health=healthy +app_image=sha256:251fa99d5de47d452b7f099304aa34b1e2aacf1b4e9d2d23d1cb4b850084910e created=2026-09-14T14:27:53.849178188Z health=healthy +compose_project=agent-dev schema=33 agent_backend=pi-agent FAV_DEGRADED_READ=unset diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/01-project-143.png b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/01-project-143.png new file mode 100644 index 00000000..1877396e Binary files /dev/null and b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/01-project-143.png differ diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/02-after-reload.png b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/02-after-reload.png new file mode 100644 index 00000000..7723384e Binary files /dev/null and b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/02-after-reload.png differ diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/03-after-relogin.png b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/03-after-relogin.png new file mode 100644 index 00000000..6354cbd6 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/03-after-relogin.png differ diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/04-outsider-143.png b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/04-outsider-143.png new file mode 100644 index 00000000..c2b1ba97 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/04-outsider-143.png differ diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/05-new-project.png b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/05-new-project.png new file mode 100644 index 00000000..9579d6c4 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/05-new-project.png differ diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/06-activated.png b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/06-activated.png new file mode 100644 index 00000000..af8b6c66 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/06-activated.png differ diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/07-new-doc.png b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/07-new-doc.png new file mode 100644 index 00000000..2795865a Binary files /dev/null and b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/07-new-doc.png differ diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/08-new-doc-reload.png b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/08-new-doc-reload.png new file mode 100644 index 00000000..82adc619 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/08-new-doc-reload.png differ diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/09-new-doc-relogin.png b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/09-new-doc-relogin.png new file mode 100644 index 00000000..9cdc70c6 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/09-new-doc-relogin.png differ diff --git a/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/10-fail-closed.png b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/10-fail-closed.png new file mode 100644 index 00000000..c236d734 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live/screenshots/10-fail-closed.png differ diff --git a/deploy/postgres/goose/00034_2026-09-14-workspace-lease-fencing.sql b/deploy/postgres/goose/00034_2026-09-14-workspace-lease-fencing.sql new file mode 100644 index 00000000..d52f3ecf --- /dev/null +++ b/deploy/postgres/goose/00034_2026-09-14-workspace-lease-fencing.sql @@ -0,0 +1,8 @@ +-- +goose Up +ALTER TABLE document_records + ADD COLUMN IF NOT EXISTS lease_fencing_token TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS workspace_schema_version INT NOT NULL DEFAULT 1; + +-- +goose Down +ALTER TABLE document_records DROP COLUMN IF EXISTS workspace_schema_version; +ALTER TABLE document_records DROP COLUMN IF EXISTS lease_fencing_token; diff --git a/deploy/postgres/goose/00035_2026-09-14-durable-event-delivery.sql b/deploy/postgres/goose/00035_2026-09-14-durable-event-delivery.sql new file mode 100644 index 00000000..b636ffa6 --- /dev/null +++ b/deploy/postgres/goose/00035_2026-09-14-durable-event-delivery.sql @@ -0,0 +1,58 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS event_envelopes ( + event_id TEXT PRIMARY KEY, + event_name TEXT NOT NULL, + schema_version INT NOT NULL DEFAULT 1, + environment TEXT NOT NULL DEFAULT 'dev', + project_id BIGINT, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + aggregate_version BIGINT NOT NULL DEFAULT 0, + actor_ref TEXT NOT NULL DEFAULT '', + correlation_id TEXT NOT NULL DEFAULT '', + causation_id TEXT NOT NULL DEFAULT '', + idempotency_key TEXT NOT NULL DEFAULT '', + payload_digest CHAR(64) NOT NULL, + producer TEXT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + payload BYTEA NOT NULL DEFAULT '', + UNIQUE (aggregate_type, aggregate_id, aggregate_version) +); + +CREATE TABLE IF NOT EXISTS event_deliveries ( + event_id TEXT NOT NULL REFERENCES event_envelopes(event_id), + consumer TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt INT NOT NULL DEFAULT 0, + lease_owner TEXT NOT NULL DEFAULT '', + fencing_token TEXT NOT NULL DEFAULT '', + lease_until TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + inbox BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (event_id, consumer), + CONSTRAINT chk_event_delivery_status CHECK (status IN ('pending','leased','acked','dead','quarantine')) +); + +CREATE INDEX IF NOT EXISTS idx_event_deliveries_claim + ON event_deliveries (consumer, next_attempt_at, event_id) + WHERE status IN ('pending','leased'); + +CREATE TABLE IF NOT EXISTS event_dead_letters ( + event_id TEXT NOT NULL, + consumer TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (event_id, consumer) +); + +-- SKIP LOCKED is used only by claim: +-- SELECT event_id FROM event_deliveries +-- WHERE consumer=$1 AND status IN ('pending','leased') +-- AND (lease_until IS NULL OR lease_until < now()) +-- ORDER BY next_attempt_at, event_id +-- FOR UPDATE SKIP LOCKED LIMIT $2; + +-- +goose Down +DROP TABLE IF EXISTS event_dead_letters; +DROP TABLE IF EXISTS event_deliveries; +DROP TABLE IF EXISTS event_envelopes; diff --git a/deploy/postgres/goose/00036_2026-09-14-final-artifact-vault.sql b/deploy/postgres/goose/00036_2026-09-14-final-artifact-vault.sql new file mode 100644 index 00000000..d70b5c90 --- /dev/null +++ b/deploy/postgres/goose/00036_2026-09-14-final-artifact-vault.sql @@ -0,0 +1,94 @@ +-- +goose Up +-- Renumbered from feature/final-artifact-vault 00034 after audit. Do not merge that file as 00034. +CREATE TABLE IF NOT EXISTS final_artifacts ( + artifact_uuid TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL UNIQUE, + artifact_type TEXT NOT NULL, + logical_resource_id TEXT NOT NULL, + environment TEXT NOT NULL, + project_id BIGINT, + project_external_key TEXT NOT NULL, + source_event_id TEXT NOT NULL, + source_event_kind TEXT NOT NULL DEFAULT '', + source_snapshot_id BIGINT, + source_message_id BIGINT, + source_run_id TEXT NOT NULL DEFAULT '', + payload_sha256 CHAR(64) NOT NULL, + manifest_sha256 CHAR(64), + vault_storage_identity TEXT, + terminal_state TEXT NOT NULL DEFAULT '', + policy_version TEXT NOT NULL DEFAULT '', + publication_status TEXT NOT NULL, + retention_class TEXT NOT NULL DEFAULT 'governance', + requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + verified_at TIMESTAMPTZ, + tombstoned_at TIMESTAMPTZ, + CONSTRAINT chk_fav_type CHECK (artifact_type IN ('agent_commit','sealed_version','approved_document','freeform_publish','assistant_reply')), + CONSTRAINT chk_fav_status CHECK (publication_status IN ('requested','publishing','verified','retryable_failed','tombstoned','purged','quarantine')) +); + +CREATE TABLE IF NOT EXISTS artifact_payload_staging ( + staging_uuid TEXT PRIMARY KEY, + project_id BIGINT, + source_event_id TEXT NOT NULL, + payload_sha256 CHAR(64) NOT NULL, + encrypted_payload BYTEA NOT NULL, + classification TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + releasable_at TIMESTAMPTZ, + UNIQUE (source_event_id, payload_sha256) +); + +CREATE TABLE IF NOT EXISTS artifact_blob_references ( + artifact_uuid TEXT NOT NULL REFERENCES final_artifacts(artifact_uuid), + project_scope TEXT NOT NULL, + payload_sha256 CHAR(64) NOT NULL, + envelope_key_ref TEXT NOT NULL DEFAULT '', + reference_status TEXT NOT NULL DEFAULT 'active', + retention_class TEXT NOT NULL DEFAULT 'governance', + legal_hold BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (artifact_uuid, payload_sha256) +); + +CREATE TABLE IF NOT EXISTS artifact_publication_attempts ( + artifact_uuid TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + attempt_no INT NOT NULL, + error_code TEXT NOT NULL DEFAULT '', + receipt TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (artifact_uuid, attempt_no) +); + +CREATE TABLE IF NOT EXISTS artifact_deletion_ledger ( + artifact_uuid TEXT NOT NULL, + reason TEXT NOT NULL, + authority TEXT NOT NULL DEFAULT '', + effective_at TIMESTAMPTZ NOT NULL, + applied_at TIMESTAMPTZ, + audit_ref TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS artifact_quarantine ( + logical_resource_id TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS artifact_backfill_exclusions ( + logical_resource_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + approved_by TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE IF EXISTS artifact_backfill_exclusions; +DROP TABLE IF EXISTS artifact_quarantine; +DROP TABLE IF EXISTS artifact_deletion_ledger; +DROP TABLE IF EXISTS artifact_publication_attempts; +DROP TABLE IF EXISTS artifact_blob_references; +DROP TABLE IF EXISTS artifact_payload_staging; +DROP TABLE IF EXISTS final_artifacts; diff --git a/internal/app/docflow/commands.go b/internal/app/docflow/commands.go index c41b5cd8..870e2f72 100644 --- a/internal/app/docflow/commands.go +++ b/internal/app/docflow/commands.go @@ -3,10 +3,12 @@ package docflow import ( "context" "fmt" + "time" "chat-agent-system/internal/app/uow" "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/events" ) type Action string @@ -43,10 +45,20 @@ type DecisionCommand struct { } type Commands struct { - UoW uow.UnitOfWork - Gov *docgov.GovernanceEngine - Rev *docrevision.Service - Auth Authorizer + UoW uow.UnitOfWork + Gov *docgov.GovernanceEngine + Rev *docrevision.Service + Auth Authorizer + Events EventSink + Fav FavStaging +} + +type EventSink interface { + Append(ctx context.Context, env events.Envelope, consumers []string) error +} + +type FavStaging interface { + StageAgentCommit(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, payload []byte) error } type Authorizer interface { @@ -142,13 +154,34 @@ func (c *Commands) AgentCommit(ctx context.Context, p Principal, cmd AgentCommit err := c.run(ctx, cmd.DocID, p, ActionCommit, func(ctx context.Context, _ uow.TxScope) error { st, err := c.Rev.CommitAgentVersion(ctx, cmd.DocID, cmd.CandidateID, cmd.Decision) out = st - return err + if err != nil || out == nil || c.Events == nil { + return err + } + body := []byte(fmt.Sprintf(`{"doc_id":%q,"version_no":%d}`, cmd.DocID, out.CurrentVersionNo)) + eventID := fmt.Sprintf("document:%s:v%d", cmd.DocID, out.CurrentVersionNo) + if err := c.Events.Append(ctx, events.Envelope{ + EventID: eventID, + EventName: "document.agent_version_committed", + SchemaVersion: events.EnvelopeSchema, + Environment: "dev", + ProjectID: 0, + AggregateType: "document", + AggregateID: cmd.DocID, + AggregateVersion: int64(out.CurrentVersionNo), + ActorRef: fmt.Sprintf("user:%d", p.UserID), + IdempotencyKey: fmt.Sprintf("commit:%s:%d", cmd.DocID, out.CurrentVersionNo), + PayloadDigest: events.Digest(body), + Producer: "docflow", + OccurredAt: time.Now().UTC(), + Payload: body, + }, []string{"projection", "notify", "fav"}); err != nil { + return err + } + if c.Fav == nil { + return nil + } + return c.Fav.StageAgentCommit(ctx, 0, cmd.DocID, eventID, out.HeadSnapshotID, body) }) - if err == nil && c.UoW != nil && c.Rev != nil && c.Rev.Notify && c.Rev.OnOutbox != nil && out != nil { - _ = c.Rev.OnOutbox(ctx, "document.agent_version_committed", map[string]any{ - "doc_id": cmd.DocID, "version_no": out.CurrentVersionNo, - }) - } return out, err } diff --git a/internal/app/docflow/same_tx_pg_test.go b/internal/app/docflow/same_tx_pg_test.go new file mode 100644 index 00000000..20d02b8f --- /dev/null +++ b/internal/app/docflow/same_tx_pg_test.go @@ -0,0 +1,85 @@ +package docflow_test + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "chat-agent-system/internal/app/uow" + "chat-agent-system/internal/artifactvault" + "chat-agent-system/internal/db" + "chat-agent-system/internal/events" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func init() { + if _, err := os.Stat("deploy/postgres/goose"); err == nil { + return + } + for _, cand := range []string{"../../..", "../../../.."} { + if fi, err := os.Stat(cand + "/deploy/postgres/goose"); err == nil && fi.IsDir() { + _ = os.Chdir(cand) + return + } + } +} + +func TestOutboxAndStagingSameTxRollback(t *testing.T) { + if testing.Short() { + t.Skip("same-tx outbox/staging is not part of -short") + } + dsn := db.SkipIfNoTestDatabaseURL(t) + if strings.Contains(dsn, "5433") || strings.Contains(dsn, "agent_system_dev") || + (strings.Contains(dsn, "5434") && strings.Contains(dsn, "agent_system_test")) || + strings.Contains(dsn, "5435") { + t.Fatal("TEST_DATABASE_URL points at a shared env port; refuse agent_system_dev/test/main") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + + body := []byte(`{"doc_id":"doc_m2_tx","version_no":1}`) + env := events.Envelope{ + EventID: "document:doc_m2_tx:v1", EventName: "document.agent_version_committed", + SchemaVersion: events.EnvelopeSchema, Environment: "dev", ProjectID: 9, + AggregateType: "document", AggregateID: "doc_m2_tx", AggregateVersion: 1, + ActorRef: "user:1", IdempotencyKey: "commit:doc_m2_tx:1", PayloadDigest: events.Digest(body), + Producer: "docflow", OccurredAt: time.Now().UTC(), Payload: body, + } + boom := errors.New("boom after staging") + unit := uow.NewPG(pool) + err = unit.Within(ctx, func(scope uow.TxScope) error { + ctx = uow.WithScope(ctx, scope) + if err := (events.PGSink{}).Append(ctx, env, []string{"projection", "fav"}); err != nil { + return err + } + ing := &artifactvault.Ingest{Env: "dev"} + if err := ing.StageAgentCommit(ctx, 9, "doc_m2_tx", env.EventID, 4, body); err != nil { + return err + } + return boom + }) + if !errors.Is(err, boom) { + t.Fatalf("want boom, got %v", err) + } + var envelopes, artifacts int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM event_envelopes WHERE event_id=$1`, env.EventID).Scan(&envelopes); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM final_artifacts WHERE source_event_id=$1`, env.EventID).Scan(&artifacts); err != nil { + t.Fatal(err) + } + if envelopes != 0 || artifacts != 0 { + t.Fatalf("rollback leaked envelopes=%d artifacts=%d", envelopes, artifacts) + } +} diff --git a/internal/artifactvault/coverage.go b/internal/artifactvault/coverage.go new file mode 100644 index 00000000..6f7e490f --- /dev/null +++ b/internal/artifactvault/coverage.go @@ -0,0 +1,30 @@ +package artifactvault + +import "fmt" + +func CoveragePASS(c Coverage) bool { + if c.Required <= 0 { + return false + } + return c.Verified == c.Required && c.Quarantine == 0 +} + +func CanPurge(legalHold bool, activeRefs int) error { + if legalHold { + return fmt.Errorf("artifactvault: legal hold") + } + if activeRefs > 0 { + return fmt.Errorf("artifactvault: remaining references") + } + return nil +} + +func PreflightDestroy(c Coverage, backupOK, corpusOK, vaultReady bool) error { + if !vaultReady || !CoveragePASS(c) { + return fmt.Errorf("artifactvault: coverage gate failed required=%d verified=%d quarantine=%d", c.Required, c.Verified, c.Quarantine) + } + if !backupOK || !corpusOK { + return fmt.Errorf("artifactvault: complementary backup/corpus not PASS") + } + return nil +} diff --git a/internal/artifactvault/crypto.go b/internal/artifactvault/crypto.go new file mode 100644 index 00000000..9b3f8d35 --- /dev/null +++ b/internal/artifactvault/crypto.go @@ -0,0 +1,53 @@ +package artifactvault + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" +) + +// ProjectKey derives a per-project envelope key. The master secret never enters manifests. +func ProjectKey(master []byte, env, projectExternalKey string) (key []byte, ref string, err error) { + if len(master) < 16 { + return nil, "", fmt.Errorf("artifactvault: vault master key too short") + } + sum := sha256.Sum256(append(append(master, []byte(env)...), []byte(projectExternalKey)...)) + ref = "proj:" + env + ":" + projectExternalKey + ":" + hex.EncodeToString(sum[:8]) + return sum[:], ref, nil +} + +func Encrypt(key, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +func Decrypt(key, envelope []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + ns := gcm.NonceSize() + if len(envelope) < ns { + return nil, fmt.Errorf("artifactvault: short envelope") + } + return gcm.Open(nil, envelope[:ns], envelope[ns:], nil) +} diff --git a/internal/artifactvault/eligibility.go b/internal/artifactvault/eligibility.go new file mode 100644 index 00000000..bf72b431 --- /dev/null +++ b/internal/artifactvault/eligibility.go @@ -0,0 +1,108 @@ +package artifactvault + +import ( + "errors" + "strings" +) + +var ( + ErrNotTerminal = errors.New("artifactvault: facts are not a terminal state") + ErrExplicitBypass = errors.New("artifactvault: explicit publish/pin cannot bypass terminal allowlist") + ErrSensitiveRejected = errors.New("artifactvault: sensitive or unclassified payload rejected") + ErrUnknownArtifactType = errors.New("artifactvault: unknown artifact type rejected") + ErrSnapshotInconsistent = errors.New("artifactvault: snapshot/event inconsistent") + ErrUnionDeny = errors.New("artifactvault: union-deny") +) + +// EvaluateEligibility is the only domain gate. Unclassified types default to reject. +func EvaluateEligibility(f Facts) error { + if err := rejectSensitive(f.Classification, f.Payload); err != nil { + return err + } + switch f.ArtifactType { + case TypeAgentCommit: + if f.CandidateStatus != "committed" || f.OutputSnapshotID <= 0 { + return ErrNotTerminal + } + if f.ChangeKind != "" && (f.ChangeKind != "agent_commit" || f.ChangeToSnapshotID != f.OutputSnapshotID) { + return ErrSnapshotInconsistent + } + return nil + case TypeSealedVersion: + if f.VersionStatus != "sealed" || f.SealedAt == nil || f.HeadSnapshotID <= 0 { + return ErrNotTerminal + } + return nil + case TypeApprovedDocument: + if f.DocState != "approved" || f.ApprovedAt == nil || f.ReviewSnapshotID <= 0 { + return ErrNotTerminal + } + return nil + case TypeFreeformPublish: + if !f.ExplicitPublish || !f.HasImmutableSnapshot { + if f.ExplicitPublish && !f.HasImmutableSnapshot { + return ErrExplicitBypass + } + return ErrNotTerminal + } + return nil + case TypeAssistantReply: + if f.ChatTurnStatus != "completed" || f.CompletedAt == nil || f.AssistantMessageID <= 0 { + if f.ExplicitPin { + return ErrExplicitBypass + } + return ErrNotTerminal + } + switch f.ExportPolicy { + case "full": + return nil + case "copy_only": + if f.ExplicitPin && f.PinAfterCompleted { + return nil + } + if f.ExplicitPin { + return ErrExplicitBypass + } + return ErrNotTerminal + default: + return ErrNotTerminal + } + default: + return ErrUnknownArtifactType + } +} + +func rejectSensitive(class string, payload []byte) error { + if class == "" { + class = ClassifyPayload(payload) + } + switch class { + case ClassificationSecret, ClassificationRestricted, ClassificationFailed: + return ErrSensitiveRejected + case ClassificationOK: + return nil + default: + return ErrSensitiveRejected + } +} + +// ClassifyPayload is a fail-closed server-side gate. It never logs the body. +func ClassifyPayload(payload []byte) string { + text := strings.ToLower(string(payload)) + if text == "" { + return ClassificationFailed + } + needles := []string{ + "-----begin ", "private_key", "api_key=", "secret=", "authorization: bearer", + "password=", "access_token=", "aws_secret", + } + for _, n := range needles { + if strings.Contains(text, n) { + return ClassificationSecret + } + } + if strings.Contains(text, "restricted:") || strings.Contains(text, "classification_failed") { + return ClassificationRestricted + } + return ClassificationOK +} diff --git a/internal/artifactvault/eligibility_test.go b/internal/artifactvault/eligibility_test.go new file mode 100644 index 00000000..4003062b --- /dev/null +++ b/internal/artifactvault/eligibility_test.go @@ -0,0 +1,115 @@ +package artifactvault + +import ( + "testing" + "time" +) + +func TestEvaluateEligibility_Allowlist(t *testing.T) { + ok := Facts{ + ArtifactType: TypeAgentCommit, CandidateStatus: "committed", OutputSnapshotID: 9, + ChangeKind: "agent_commit", ChangeToSnapshotID: 9, Classification: ClassificationOK, Payload: []byte("doc"), + } + if err := EvaluateEligibility(ok); err != nil { + t.Fatalf("committed should pass: %v", err) + } + for _, status := range []string{"ready", "needs_confirmation", "rejected", "stale", "quality_failed"} { + bad := ok + bad.CandidateStatus = status + if err := EvaluateEligibility(bad); err == nil { + t.Fatalf("status %s must reject", status) + } + } +} + +func TestEvaluateEligibility_ApprovedRequiresReviewSnapshot(t *testing.T) { + now := time.Now().UTC() + f := Facts{ + ArtifactType: TypeApprovedDocument, DocState: "approved", ApprovedAt: &now, + Classification: ClassificationOK, Payload: []byte("approved"), + } + if err := EvaluateEligibility(f); err == nil { + t.Fatal("approved without review_snapshot_id must reject") + } + f.ReviewSnapshotID = 3 + if err := EvaluateEligibility(f); err != nil { + t.Fatalf("approved with snapshot should pass: %v", err) + } +} + +func TestEvaluateEligibility_TaskWithoutCompletedTurn(t *testing.T) { + f := Facts{ + ArtifactType: TypeAssistantReply, ChatTurnStatus: "", AssistantMessageID: 8, + ExportPolicy: "full", Classification: ClassificationOK, Payload: []byte("hello world from assistant"), + } + if err := EvaluateEligibility(f); err == nil { + t.Fatal("missing completed turn must reject") + } + if err := EvaluateEligibility(Facts{ + ArtifactType: TypeAssistantReply, ExplicitPin: true, ChatTurnStatus: "pending", + Classification: ClassificationOK, Payload: []byte("hello world from assistant"), + }); err != ErrExplicitBypass { + t.Fatalf("pin before completed: %v", err) + } +} + +func TestEvaluateEligibility_SensitiveRejectedBeforeStaging(t *testing.T) { + now := time.Now().UTC() + f := Facts{ + ArtifactType: TypeAssistantReply, ChatTurnStatus: "completed", CompletedAt: &now, + AssistantMessageID: 1, ExportPolicy: "full", Payload: []byte("api_key=sk-secret"), + } + if err := EvaluateEligibility(f); err != ErrSensitiveRejected { + t.Fatalf("secret payload: %v", err) + } +} + +func TestEvaluateEligibility_CopyOnlyRequiresExplicitPinAfterCompleted(t *testing.T) { + now := time.Now().UTC() + base := Facts{ + ArtifactType: TypeAssistantReply, ChatTurnStatus: "completed", CompletedAt: &now, + AssistantMessageID: 9, ExportPolicy: "copy_only", Classification: ClassificationOK, + Payload: []byte("ok, noted."), + } + if err := EvaluateEligibility(base); err == nil { + t.Fatal("copy_only without pin must reject") + } + base.ExplicitPin = true + if err := EvaluateEligibility(base); err != ErrExplicitBypass { + t.Fatalf("pin without PinAfterCompleted: %v", err) + } + base.PinAfterCompleted = true + if err := EvaluateEligibility(base); err != nil { + t.Fatalf("copy_only + pin after completed should pass: %v", err) + } +} + +func TestEvaluateEligibility_UnknownTypeRejected(t *testing.T) { + if err := EvaluateEligibility(Facts{ArtifactType: "workspace_scan", Classification: ClassificationOK, Payload: []byte("x")}); err != ErrUnknownArtifactType { + t.Fatalf("got %v", err) + } +} + +func TestEvaluateEligibility_IntermediateAdmissionZero(t *testing.T) { + for _, typ := range []string{"prompt", "tool_trace", "scratch", "workspace_scan"} { + if err := EvaluateEligibility(Facts{ArtifactType: typ, Classification: ClassificationOK, Payload: []byte("x")}); err != ErrUnknownArtifactType { + t.Fatalf("%s: %v", typ, err) + } + } +} + +func TestIdempotencyKeyStable(t *testing.T) { + req := PublishRequest{ + ArtifactType: TypeAgentCommit, Environment: "dev", ProjectExternalKey: "p1", + LogicalResourceID: "doc_a", SourceEventKind: "agent_commit", SourceEventID: "ev1", + } + a := IdempotencyKey(req, "abc") + b := IdempotencyKey(req, "abc") + if a != b || a == "" { + t.Fatalf("key unstable %q %q", a, b) + } + req.SourceEventID = "ev2" + if IdempotencyKey(req, "abc") == a { + t.Fatal("new event must new key") + } +} diff --git a/internal/artifactvault/filesystem_store.go b/internal/artifactvault/filesystem_store.go new file mode 100644 index 00000000..fca2d674 --- /dev/null +++ b/internal/artifactvault/filesystem_store.go @@ -0,0 +1,217 @@ +package artifactvault + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "chat-agent-system/internal/fsutil" +) + +type FilesystemStore struct { + Root string + StorageIdentity string + MasterKey []byte +} + +func NewFilesystemStore(root, identity string, master []byte) (*FilesystemStore, error) { + if strings.TrimSpace(root) == "" || strings.Contains(root, "data/dev") && !strings.Contains(root, "vault") { + if strings.HasSuffix(filepath.Clean(root), filepath.Join("data", "dev")) { + return nil, fmt.Errorf("artifactvault: refuse vault root %s (must not be runtime data/dev)", root) + } + } + if identity == "" { + return nil, fmt.Errorf("artifactvault: storage identity required") + } + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, err + } + sent := filepath.Join(root, ".vault-storage-identity.json") + if b, err := os.ReadFile(sent); err == nil { + var got map[string]string + _ = json.Unmarshal(b, &got) + if got["uuid"] != "" && got["uuid"] != identity { + return nil, fmt.Errorf("artifactvault: storage identity mismatch") + } + } else { + _ = fsutil.WriteFileAtomic(sent, []byte(`{"uuid":"`+identity+`"}`), 0o444) + } + return &FilesystemStore{Root: root, StorageIdentity: identity, MasterKey: master}, nil +} + +func (s *FilesystemStore) Identity() string { return s.StorageIdentity } + +func (s *FilesystemStore) blobPath(env, projectKey, sha string) string { + return filepath.Join(s.Root, "scopes", env, projectKey, "blobs", "sha256", sha[:2], sha[2:4], sha) +} + +func (s *FilesystemStore) PutBlob(env, projectKey, sha string, plain []byte) (keyRef string, err error) { + if SHA256Hex(plain) != sha { + return "", fmt.Errorf("artifactvault: blob sha mismatch") + } + key, ref, err := ProjectKey(s.MasterKey, env, projectKey) + if err != nil { + return "", err + } + envBytes, err := Encrypt(key, plain) + if err != nil { + return "", err + } + path := s.blobPath(env, projectKey, sha) + if _, err := os.Stat(path); err == nil { + return ref, nil + } + if err := writeExclusiveOK(path, envBytes, 0o600); err != nil { + return "", err + } + return ref, nil +} + +func (s *FilesystemStore) GetBlob(env, projectKey, sha string) ([]byte, error) { + key, _, err := ProjectKey(s.MasterKey, env, projectKey) + if err != nil { + return nil, err + } + raw, err := os.ReadFile(s.blobPath(env, projectKey, sha)) + if err != nil { + return nil, err + } + plain, err := Decrypt(key, raw) + if err != nil { + return nil, err + } + if SHA256Hex(plain) != sha { + return nil, fmt.Errorf("artifactvault: decrypted sha mismatch") + } + return plain, nil +} + +func writeExclusiveOK(path string, raw []byte, perm os.FileMode) error { + if err := fsutil.WriteFileAtomicExclusive(path, raw, perm); err != nil { + if strings.Contains(err.Error(), "refuse overwrite") { + return nil + } + return err + } + return nil +} + +func (s *FilesystemStore) PutManifest(artifactUUID, manifestSHA string, raw []byte) error { + path := filepath.Join(s.Root, "manifests", artifactUUID, manifestSHA+".json") + return writeExclusiveOK(path, raw, 0o644) +} + +func (s *FilesystemStore) PutCommit(artifactUUID, manifestSHA string, raw []byte) error { + path := filepath.Join(s.Root, "commits", artifactUUID, manifestSHA+".json") + return writeExclusiveOK(path, raw, 0o644) +} + +func (s *FilesystemStore) ListCommitted() ([]string, error) { + dir := filepath.Join(s.Root, "commits") + ents, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + out := make([]string, 0, len(ents)) + for _, e := range ents { + if e.IsDir() { + out = append(out, e.Name()) + } + } + return out, nil +} + +func (s *FilesystemStore) LoadCommittedArtifacts() ([]Artifact, map[string][]byte, error) { + uuids, err := s.ListCommitted() + if err != nil { + return nil, nil, err + } + payloads := make(map[string][]byte) + arts := make([]Artifact, 0, len(uuids)) + for _, uuid := range uuids { + if s.HasTombstone(uuid) { + arts = append(arts, Artifact{UUID: uuid, PublicationStatus: StatusTombstoned}) + continue + } + art, body, err := s.loadCommitted(uuid) + if err != nil { + return nil, nil, err + } + arts = append(arts, art) + payloads[uuid] = body + } + return arts, payloads, nil +} + +func (s *FilesystemStore) loadCommitted(uuid string) (Artifact, []byte, error) { + dir := filepath.Join(s.Root, "commits", uuid) + ents, err := os.ReadDir(dir) + if err != nil || len(ents) == 0 { + return Artifact{}, nil, fmt.Errorf("artifactvault: unpublished") + } + raw, err := os.ReadFile(filepath.Join(dir, ents[0].Name())) + if err != nil { + return Artifact{}, nil, err + } + var marker CommitMarker + if err := json.Unmarshal(raw, &marker); err != nil { + return Artifact{}, nil, err + } + manRaw, err := os.ReadFile(filepath.Join(s.Root, "manifests", uuid, marker.ManifestSHA256+".json")) + if err != nil { + return Artifact{}, nil, err + } + var man Manifest + if err := json.Unmarshal(manRaw, &man); err != nil { + return Artifact{}, nil, err + } + if len(man.PayloadMembers) == 0 { + return Artifact{}, nil, fmt.Errorf("artifactvault: manifest has no payload members") + } + body, err := s.GetBlob(man.Environment, man.ProjectExternalKey, man.PayloadMembers[0].SHA256) + if err != nil { + return Artifact{}, nil, err + } + var snap, msg int64 + if man.SourceSnapshotID != nil { + snap = *man.SourceSnapshotID + } + if man.SourceMessageID != nil { + msg = *man.SourceMessageID + } + return Artifact{ + UUID: uuid, ArtifactType: man.ArtifactType, LogicalResourceID: man.LogicalResourceID, + Environment: man.Environment, ProjectID: man.ProjectID, ProjectExternalKey: man.ProjectExternalKey, + SourceEventID: man.SourceEventID, SourceEventKind: man.SourceEventKind, + SourceSnapshotID: snap, SourceMessageID: msg, SourceRunID: man.SourceRunID, + PayloadSHA256: man.PayloadMembers[0].SHA256, ManifestSHA256: man.ManifestSHA256, + VaultStorageIdentity: man.VaultStorageIdentity, TerminalState: man.TerminalEvidence.State, + PublicationStatus: StatusVerified, RetentionClass: man.RetentionClass, + }, body, nil +} + +func (s *FilesystemStore) HasCommit(artifactUUID, manifestSHA string) bool { + _, err := os.Stat(filepath.Join(s.Root, "commits", artifactUUID, manifestSHA+".json")) + return err == nil +} + +func (s *FilesystemStore) PutTombstone(artifactUUID, eventUUID string, raw []byte) error { + path := filepath.Join(s.Root, "tombstones", artifactUUID, eventUUID+".json") + return fsutil.WriteFileAtomicExclusive(path, raw, 0o644) +} + +func (s *FilesystemStore) HasTombstone(artifactUUID string) bool { + dir := filepath.Join(s.Root, "tombstones", artifactUUID) + ents, err := os.ReadDir(dir) + return err == nil && len(ents) > 0 +} + +func (s *FilesystemStore) Ready() (identityOK, connected, lagOK bool) { + _, err := os.Stat(s.Root) + return s.StorageIdentity != "", err == nil, err == nil +} diff --git a/internal/artifactvault/ingest.go b/internal/artifactvault/ingest.go new file mode 100644 index 00000000..2c89f027 --- /dev/null +++ b/internal/artifactvault/ingest.go @@ -0,0 +1,202 @@ +package artifactvault + +import ( + "context" + "fmt" + "strconv" + "time" + + "chat-agent-system/internal/app/uow" + + "github.com/jackc/pgx/v5" +) + +// Ingest is the single write path from domain terminals into staging/outbox. +type Ingest struct { + Env string + Reg Registry +} + +func ProjectKeyFromID(projectID int64) string { + if projectID <= 0 { + return "unscoped" + } + return strconv.FormatInt(projectID, 10) +} + +func (i *Ingest) Request(ctx context.Context, facts Facts, req PublishRequest) error { + if i == nil || i.Reg == nil { + return nil + } + if req.Environment == "" { + req.Environment = i.Env + } + if req.ProjectExternalKey == "" { + req.ProjectExternalKey = ProjectKeyFromID(req.ProjectID) + } + if req.PolicyVersion == "" { + req.PolicyVersion = PolicyVersion + } + if req.Classification == "" { + req.Classification = ClassifyPayload(req.Payload) + } + facts.Payload = req.Payload + facts.Classification = req.Classification + _, _, err := i.Reg.Request(ctx, facts, req) + return err +} + +func ApprovedFacts(docState string, approvedAt *time.Time, snapshotID int64) Facts { + return Facts{ + ArtifactType: TypeApprovedDocument, + DocState: docState, + ApprovedAt: approvedAt, + ReviewSnapshotID: snapshotID, + } +} + +func AgentCommitFacts(outputSnapshotID int64) Facts { + return Facts{ + ArtifactType: TypeAgentCommit, + CandidateStatus: "committed", + OutputSnapshotID: outputSnapshotID, + ChangeKind: "agent_commit", + ChangeToSnapshotID: outputSnapshotID, + } +} + +func SealedFacts(sealedAt *time.Time, headSnapshotID int64) Facts { + return Facts{ + ArtifactType: TypeSealedVersion, + VersionStatus: "sealed", + SealedAt: sealedAt, + HeadSnapshotID: headSnapshotID, + } +} + +func AssistantFacts(turnStatus string, completedAt *time.Time, msgID int64, policy string) Facts { + return Facts{ + ArtifactType: TypeAssistantReply, + ChatTurnStatus: turnStatus, + CompletedAt: completedAt, + AssistantMessageID: msgID, + ExportPolicy: policy, + } +} + +func FreeformFacts(explicit bool, hasSnapshot bool) Facts { + return Facts{ + ArtifactType: TypeFreeformPublish, + ExplicitPublish: explicit, + HasImmutableSnapshot: hasSnapshot, + } +} + +func (i *Ingest) AgentCommit(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, payload []byte) error { + return i.Request(ctx, AgentCommitFacts(snapshotID), PublishRequest{ + ArtifactType: TypeAgentCommit, Environment: i.Env, ProjectID: projectID, + LogicalResourceID: docID, SourceEventKind: "agent_commit", SourceEventID: eventID, + SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "committed", + }) +} + +func (i *Ingest) SealedVersion(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, sealedAt *time.Time, payload []byte) error { + return i.Request(ctx, SealedFacts(sealedAt, snapshotID), PublishRequest{ + ArtifactType: TypeSealedVersion, Environment: i.Env, ProjectID: projectID, + LogicalResourceID: docID + ":sealed", SourceEventKind: "version_sealed", SourceEventID: eventID, + SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "sealed", + }) +} + +func (i *Ingest) Approved(ctx context.Context, projectID int64, docID string, snapshotID int64, approvedAt *time.Time, payload []byte) error { + return i.Request(ctx, ApprovedFacts("approved", approvedAt, snapshotID), PublishRequest{ + ArtifactType: TypeApprovedDocument, Environment: i.Env, ProjectID: projectID, + LogicalResourceID: docID, SourceEventKind: "approved", SourceEventID: fmt.Sprintf("approved:%s:%d", docID, snapshotID), + SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "approved", + }) +} + +func (i *Ingest) FreeformPublish(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, payload []byte) error { + return i.Request(ctx, FreeformFacts(true, snapshotID > 0 || len(payload) > 0), PublishRequest{ + ArtifactType: TypeFreeformPublish, Environment: i.Env, ProjectID: projectID, + LogicalResourceID: docID, SourceEventKind: "freeform_publish", SourceEventID: eventID, + SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "published", + }) +} + +func (i *Ingest) requestTX(ctx context.Context, tx pgx.Tx, facts Facts, req PublishRequest) error { + if i == nil { + return nil + } + if req.Environment == "" { + req.Environment = i.Env + } + if req.ProjectExternalKey == "" { + req.ProjectExternalKey = ProjectKeyFromID(req.ProjectID) + } + if req.PolicyVersion == "" { + req.PolicyVersion = PolicyVersion + } + if req.Classification == "" { + req.Classification = ClassifyPayload(req.Payload) + } + facts.Payload = req.Payload + facts.Classification = req.Classification + _, _, err := RequestWithTx(ctx, tx, facts, req) + return err +} + +func (i *Ingest) StageAgentCommit(ctx context.Context, projectID int64, docID, eventID string, snapshotID int64, payload []byte) error { + scope, ok := uow.ScopeFrom(ctx) + if !ok { + return fmt.Errorf("artifactvault: staging requires a unit of work") + } + tx, err := uow.AsPG(scope) + if err != nil { + return err + } + return i.AgentCommitTX(ctx, tx, projectID, docID, eventID, snapshotID, payload) +} + +func (i *Ingest) AgentCommitTX(ctx context.Context, tx pgx.Tx, projectID int64, docID, eventID string, snapshotID int64, payload []byte) error { + return i.requestTX(ctx, tx, AgentCommitFacts(snapshotID), PublishRequest{ + ArtifactType: TypeAgentCommit, Environment: i.Env, ProjectID: projectID, + LogicalResourceID: docID, SourceEventKind: "agent_commit", SourceEventID: eventID, + SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "committed", + }) +} + +func (i *Ingest) SealedVersionTX(ctx context.Context, tx pgx.Tx, projectID int64, docID, eventID string, snapshotID int64, sealedAt *time.Time, payload []byte) error { + return i.requestTX(ctx, tx, SealedFacts(sealedAt, snapshotID), PublishRequest{ + ArtifactType: TypeSealedVersion, Environment: i.Env, ProjectID: projectID, + LogicalResourceID: docID + ":sealed", SourceEventKind: "version_sealed", SourceEventID: eventID, + SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "sealed", + }) +} + +func (i *Ingest) ApprovedTX(ctx context.Context, tx pgx.Tx, projectID int64, docID string, snapshotID int64, approvedAt *time.Time, payload []byte) error { + return i.requestTX(ctx, tx, ApprovedFacts("approved", approvedAt, snapshotID), PublishRequest{ + ArtifactType: TypeApprovedDocument, Environment: i.Env, ProjectID: projectID, + LogicalResourceID: docID, SourceEventKind: "approved", SourceEventID: fmt.Sprintf("approved:%s:%d", docID, snapshotID), + SourceSnapshotID: snapshotID, Payload: payload, TerminalState: "approved", + }) +} + +func (i *Ingest) AssistantReply(ctx context.Context, projectID int64, logical, eventID string, msgID int64, runID, policy string, completedAt *time.Time, payload []byte) error { + return i.Request(ctx, AssistantFacts("completed", completedAt, msgID, policy), PublishRequest{ + ArtifactType: TypeAssistantReply, Environment: i.Env, ProjectID: projectID, + LogicalResourceID: logical, SourceEventKind: "assistant_completed", SourceEventID: eventID, + SourceMessageID: msgID, SourceRunID: runID, Payload: payload, TerminalState: "completed", + }) +} + +func (i *Ingest) AssistantReplyPin(ctx context.Context, projectID int64, logical, eventID string, msgID int64, runID, policy string, completedAt *time.Time, payload []byte) error { + facts := AssistantFacts("completed", completedAt, msgID, policy) + facts.ExplicitPin = true + facts.PinAfterCompleted = true + return i.Request(ctx, facts, PublishRequest{ + ArtifactType: TypeAssistantReply, Environment: i.Env, ProjectID: projectID, + LogicalResourceID: logical, SourceEventKind: "assistant_pin", SourceEventID: eventID, + SourceMessageID: msgID, SourceRunID: runID, Payload: payload, TerminalState: "completed", + }) +} diff --git a/internal/artifactvault/ingest_test.go b/internal/artifactvault/ingest_test.go new file mode 100644 index 00000000..9c51e006 --- /dev/null +++ b/internal/artifactvault/ingest_test.go @@ -0,0 +1,35 @@ +package artifactvault + +import ( + "context" + "testing" + "time" +) + +func TestIngestIdempotentAndRejectsNonTerminal(t *testing.T) { + ctx := context.Background() + ing := &Ingest{Env: "dev", Reg: NewMemoryRegistry()} + now := time.Now().UTC() + payload := []byte("# committed body") + if err := ing.AgentCommit(ctx, 9, "doc_a", "ev-1", 4, payload); err != nil { + t.Fatal(err) + } + if err := ing.AgentCommit(ctx, 9, "doc_a", "ev-1", 4, payload); err != nil { + t.Fatal(err) + } + if ing.Reg.Coverage(ctx).Required != 1 { + t.Fatalf("idempotent ingest required=%d", ing.Reg.Coverage(ctx).Required) + } + if err := ing.AssistantReply(ctx, 9, "msg:1", "s:t", 1, "run", "none", &now, []byte("hello world from assistant")); err == nil { + t.Fatal("none policy must reject") + } +} + +func TestCoverageGateFailClosed(t *testing.T) { + if CoveragePASS(Coverage{}) { + t.Fatal("empty coverage must fail") + } + if err := PreflightDestroy(Coverage{Required: 2, Verified: 1}, true, true, true); err == nil { + t.Fatal("incomplete coverage must fail destroy") + } +} diff --git a/internal/artifactvault/manifest.go b/internal/artifactvault/manifest.go new file mode 100644 index 00000000..366c0d6f --- /dev/null +++ b/internal/artifactvault/manifest.go @@ -0,0 +1,100 @@ +package artifactvault + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" +) + +func BuildManifest(art Artifact, payloadSHA string, size int, keyRef string) Manifest { + var snap, msg *int64 + if art.SourceSnapshotID > 0 { + v := art.SourceSnapshotID + snap = &v + } + if art.SourceMessageID > 0 { + v := art.SourceMessageID + msg = &v + } + m := Manifest{ + ArtifactSchemaVersion: SchemaMajorMinor, + ArtifactUUID: art.UUID, + ArtifactType: art.ArtifactType, + LogicalResourceID: art.LogicalResourceID, + Environment: art.Environment, + ProjectID: art.ProjectID, + ProjectExternalKey: art.ProjectExternalKey, + SourceEventID: art.SourceEventID, + SourceEventKind: art.SourceEventKind, + SourceSnapshotID: snap, + SourceMessageID: msg, + SourceRunID: art.SourceRunID, + PayloadMembers: []PayloadMember{{ + Name: "payload", MediaType: "text/markdown", ByteSize: size, SHA256: payloadSHA, Required: true, + }}, + TerminalEvidence: TerminalEvidence{ + State: art.TerminalState, DecidedAt: time.Now().UTC().Format(time.RFC3339), PolicyVersion: nz(art.PolicyVersion, PolicyVersion), + }, + RetentionClass: art.RetentionClass, + EncryptionKeyRef: keyRef, + VaultStorageIdentity: art.VaultStorageIdentity, + PublisherVersion: PublisherVersion, + PublishedAt: time.Now().UTC().Format(time.RFC3339), + } + raw, _ := json.Marshal(canonicalManifest(m)) + m.ManifestSHA256 = SHA256Hex(raw) + return m +} + +func canonicalManifest(m Manifest) Manifest { + cp := m + cp.ManifestSHA256 = "" + return cp +} + +func ValidateManifest(m Manifest) error { + if !strings.HasPrefix(m.ArtifactSchemaVersion, "1.") { + return fmt.Errorf("artifactvault: unsupported manifest major %q", m.ArtifactSchemaVersion) + } + if m.ArtifactUUID == "" || m.LogicalResourceID == "" || m.ProjectExternalKey == "" || m.VaultStorageIdentity == "" { + return fmt.Errorf("artifactvault: manifest missing required identity fields") + } + if len(m.PayloadMembers) == 0 { + return fmt.Errorf("artifactvault: manifest has no payload members") + } + for _, mem := range m.PayloadMembers { + if len(mem.SHA256) != 64 { + return fmt.Errorf("artifactvault: invalid member sha") + } + } + got := SHA256Hex(mustJSON(canonicalManifest(m))) + if m.ManifestSHA256 != "" && m.ManifestSHA256 != got { + return fmt.Errorf("artifactvault: manifest sha mismatch") + } + return nil +} + +func ValidateCommitMarker(c CommitMarker) error { + if c.ArtifactUUID == "" || len(c.ManifestSHA256) != 64 || len(c.MemberSHAs) == 0 || c.VaultStorageIdentity == "" || c.CommittedAt == "" || c.PublisherVersion == "" { + return fmt.Errorf("artifactvault: incomplete commit marker") + } + return nil +} + +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + return nil + } + return b +} + +func EncodeJSON(v any) []byte { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) + return bytes.TrimSpace(buf.Bytes()) +} diff --git a/internal/artifactvault/publisher.go b/internal/artifactvault/publisher.go new file mode 100644 index 00000000..9b812dc6 --- /dev/null +++ b/internal/artifactvault/publisher.go @@ -0,0 +1,103 @@ +package artifactvault + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +type Publisher struct { + Reg Registry + Store *FilesystemStore +} + +func (p *Publisher) Drain(ctx context.Context) error { + arts, err := p.Reg.LoadRequested(ctx) + if err != nil { + return err + } + var first error + for _, a := range arts { + if err := p.PublishOne(ctx, a); err != nil && first == nil { + first = err + } + } + return first +} + +func (p *Publisher) PublishOne(ctx context.Context, art Artifact) error { + if p.Store == nil { + return fmt.Errorf("artifactvault: store required") + } + if p.Reg.IsDenied(ctx, art.UUID) { + return fmt.Errorf("artifactvault: artifact denied") + } + _ = p.Reg.MarkPublishing(ctx, art.UUID) + st, err := p.Reg.LoadStaging(ctx, art.SourceEventID) + if err != nil { + _ = p.Reg.MarkFailed(ctx, art.UUID, "staging_missing") + return err + } + if SHA256Hex(st.Payload) != art.PayloadSHA256 || SHA256Hex(st.Payload) != st.PayloadSHA256 { + _ = p.Reg.MarkFailed(ctx, art.UUID, "sha_mismatch") + return fmt.Errorf("artifactvault: staging sha mismatch") + } + if ClassifyPayload(st.Payload) != ClassificationOK { + _ = p.Reg.MarkFailed(ctx, art.UUID, "sensitive") + return ErrSensitiveRejected + } + art.VaultStorageIdentity = p.Store.Identity() + keyRef, err := p.Store.PutBlob(art.Environment, art.ProjectExternalKey, art.PayloadSHA256, st.Payload) + if err != nil { + _ = p.Reg.MarkFailed(ctx, art.UUID, "blob") + return err + } + man := BuildManifest(art, art.PayloadSHA256, len(st.Payload), keyRef) + if err := ValidateManifest(man); err != nil { + _ = p.Reg.MarkFailed(ctx, art.UUID, "manifest") + return err + } + manRaw := EncodeJSON(man) + if err := p.Store.PutManifest(art.UUID, man.ManifestSHA256, manRaw); err != nil { + _ = p.Reg.MarkFailed(ctx, art.UUID, "manifest_write") + return err + } + marker := CommitMarker{ + ArtifactUUID: art.UUID, ManifestSHA256: man.ManifestSHA256, + MemberSHAs: []string{art.PayloadSHA256}, VaultStorageIdentity: p.Store.Identity(), + CommittedAt: time.Now().UTC().Format(time.RFC3339), PublisherVersion: PublisherVersion, + } + if err := ValidateCommitMarker(marker); err != nil { + return err + } + if err := p.Store.PutCommit(art.UUID, man.ManifestSHA256, EncodeJSON(marker)); err != nil { + _ = p.Reg.MarkFailed(ctx, art.UUID, "commit") + return err + } + _ = p.Reg.RecordReference(ctx, art.UUID, art.ProjectExternalKey, art.PayloadSHA256, keyRef) + return p.Reg.MarkVerified(ctx, art.UUID, man.ManifestSHA256) +} + +func (p *Publisher) DegradedPayload(ctx context.Context, env, logical, projectKey string) ([]byte, string, error) { + art, err := p.Reg.GetByLogical(ctx, env, logical) + if err != nil { + return nil, "", err + } + if p.Reg.IsDenied(ctx, art.UUID) || p.Store.HasTombstone(art.UUID) { + return nil, "", ErrUnionDeny + } + if art.ManifestSHA256 == "" || !p.Store.HasCommit(art.UUID, art.ManifestSHA256) { + return nil, "", fmt.Errorf("artifactvault: unpublished") + } + b, err := p.Store.GetBlob(env, projectKey, art.PayloadSHA256) + if err != nil { + return nil, "", err + } + return b, art.PayloadSHA256, nil +} + +func MustJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} diff --git a/internal/artifactvault/publisher_test.go b/internal/artifactvault/publisher_test.go new file mode 100644 index 00000000..e2b9ab9e --- /dev/null +++ b/internal/artifactvault/publisher_test.go @@ -0,0 +1,267 @@ +package artifactvault + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func testStore(t *testing.T) *FilesystemStore { + t.Helper() + dir := t.TempDir() + st, err := NewFilesystemStore(dir, "vault-id-test", []byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatal(err) + } + return st +} + +func TestPublisherIdempotentAndCrashSafe(t *testing.T) { + ctx := context.Background() + reg := NewMemoryRegistry() + store := testStore(t) + pub := &Publisher{Reg: reg, Store: store} + facts := Facts{ + ArtifactType: TypeAgentCommit, CandidateStatus: "committed", OutputSnapshotID: 4, + ChangeKind: "agent_commit", ChangeToSnapshotID: 4, Classification: ClassificationOK, + Payload: []byte("# final\n"), + } + req := PublishRequest{ + ArtifactType: TypeAgentCommit, Environment: "dev", ProjectExternalKey: "proj-a", + LogicalResourceID: "doc_a", SourceEventKind: "agent_commit", SourceEventID: "ev-1", + Payload: []byte("# final\n"), TerminalState: "committed", + } + art1, _, err := reg.Request(ctx, facts, req) + if err != nil { + t.Fatal(err) + } + art2, _, err := reg.Request(ctx, facts, req) + if err != nil || art1.UUID != art2.UUID { + t.Fatalf("idempotent request: %v %s %s", err, art1.UUID, art2.UUID) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + cov := reg.Coverage(ctx) + if cov.Verified != 1 { + t.Fatalf("verified=%d", cov.Verified) + } + b, sha, err := pub.DegradedPayload(ctx, "dev", "doc_a", "proj-a") + if err != nil || string(b) != "# final\n" || sha == "" { + t.Fatalf("degraded: %v %q", err, b) + } +} + +func TestPublisherRejectsBadSHAAndCrossProjectKeys(t *testing.T) { + ctx := context.Background() + reg := NewMemoryRegistry() + store := testStore(t) + pub := &Publisher{Reg: reg, Store: store} + facts := Facts{ + ArtifactType: TypeSealedVersion, VersionStatus: "sealed", SealedAt: ptrTime(), + HeadSnapshotID: 2, Classification: ClassificationOK, Payload: []byte("same-bytes"), + } + aReq := PublishRequest{ArtifactType: TypeSealedVersion, Environment: "dev", ProjectExternalKey: "p1", LogicalResourceID: "d1", SourceEventKind: "seal", SourceEventID: "s1", Payload: []byte("same-bytes"), TerminalState: "sealed"} + bReq := aReq + bReq.ProjectExternalKey = "p2" + bReq.LogicalResourceID = "d2" + bReq.SourceEventID = "s2" + if _, _, err := reg.Request(ctx, facts, aReq); err != nil { + t.Fatal(err) + } + if _, _, err := reg.Request(ctx, facts, bReq); err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + if reg.ActiveRefs(ctx, "p1", SHA256Hex([]byte("same-bytes"))) != 1 { + t.Fatal("p1 refs") + } + if reg.ActiveRefs(ctx, "p2", SHA256Hex([]byte("same-bytes"))) != 1 { + t.Fatal("p2 refs") + } + _ = reg.Tombstone(ctx, "unused", "test", "test") +} + +func TestFilesystemRefusesRuntimeDataDev(t *testing.T) { + root := filepath.Join(t.TempDir(), "data", "dev") + _ = os.MkdirAll(root, 0o755) + if _, err := NewFilesystemStore(root, "id", []byte("0123456789abcdef0123456789abcdef")); err == nil { + t.Fatal("must refuse data/dev") + } +} + +func TestTombstoneUnionDeny(t *testing.T) { + ctx := context.Background() + reg := NewMemoryRegistry() + store := testStore(t) + pub := &Publisher{Reg: reg, Store: store} + now := time.Now().UTC() + facts := Facts{ArtifactType: TypeApprovedDocument, DocState: "approved", ApprovedAt: &now, ReviewSnapshotID: 8, Classification: ClassificationOK, Payload: []byte("ok body")} + req := PublishRequest{ArtifactType: TypeApprovedDocument, Environment: "dev", ProjectExternalKey: "p", LogicalResourceID: "doc", SourceEventKind: "approved", SourceEventID: "ap1", Payload: []byte("ok body"), TerminalState: "approved"} + art, _, err := reg.Request(ctx, facts, req) + if err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + _ = reg.Tombstone(ctx, art.UUID, "legal", "admin") + if _, _, err := pub.DegradedPayload(ctx, "dev", "doc", "p"); !errors.Is(err, ErrUnionDeny) { + t.Fatalf("tombstone must union-deny: %v", err) + } +} + +func TestLastReferencePurgeAndLegalHold(t *testing.T) { + ctx := context.Background() + reg := NewMemoryRegistry() + store := testStore(t) + pub := &Publisher{Reg: reg, Store: store} + now := time.Now().UTC() + payload := []byte("shared-bytes") + facts := Facts{ArtifactType: TypeSealedVersion, VersionStatus: "sealed", SealedAt: &now, HeadSnapshotID: 2, Classification: ClassificationOK, Payload: payload} + req := PublishRequest{ArtifactType: TypeSealedVersion, Environment: "dev", ProjectExternalKey: "p1", LogicalResourceID: "d1", SourceEventKind: "seal", SourceEventID: "s1", Payload: payload, TerminalState: "sealed"} + if _, _, err := reg.Request(ctx, facts, req); err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + if reg.ActiveRefs(ctx, "p1", SHA256Hex(payload)) != 1 { + t.Fatal("expected one active ref") + } + if err := CanPurge(false, 1); err == nil { + t.Fatal("must not purge while refs remain") + } + if err := CanPurge(true, 0); err == nil { + t.Fatal("legal hold blocks purge") + } + if err := CanPurge(false, 0); err != nil { + t.Fatal(err) + } +} + +func TestFilesystemIdentityMismatch(t *testing.T) { + dir := t.TempDir() + key := []byte("0123456789abcdef0123456789abcdef") + if _, err := NewFilesystemStore(dir, "id-a", key); err != nil { + t.Fatal(err) + } + if _, err := NewFilesystemStore(dir, "id-b", key); err == nil { + t.Fatal("identity mismatch must fail closed") + } +} + +func TestUnpublishedWithoutCommitMarkerInvisible(t *testing.T) { + ctx := context.Background() + reg := NewMemoryRegistry() + store := testStore(t) + pub := &Publisher{Reg: reg, Store: store} + facts := Facts{ + ArtifactType: TypeAgentCommit, CandidateStatus: "committed", OutputSnapshotID: 4, + ChangeKind: "agent_commit", ChangeToSnapshotID: 4, Classification: ClassificationOK, + Payload: []byte("# pending\n"), + } + req := PublishRequest{ + ArtifactType: TypeAgentCommit, Environment: "dev", ProjectExternalKey: "proj-a", + LogicalResourceID: "doc_pending", SourceEventKind: "agent_commit", SourceEventID: "ev-pending", + Payload: []byte("# pending\n"), TerminalState: "committed", + } + if _, _, err := reg.Request(ctx, facts, req); err != nil { + t.Fatal(err) + } + if _, _, err := pub.DegradedPayload(ctx, "dev", "doc_pending", "proj-a"); err == nil { + t.Fatal("requested but unpublished must be invisible") + } +} + +func TestPublisherRetriesAfterMissingCommit(t *testing.T) { + ctx := context.Background() + reg := NewMemoryRegistry() + store := testStore(t) + pub := &Publisher{Reg: reg, Store: store} + payload := []byte("# retry-me\n") + facts := Facts{ + ArtifactType: TypeAgentCommit, CandidateStatus: "committed", OutputSnapshotID: 5, + ChangeKind: "agent_commit", ChangeToSnapshotID: 5, Classification: ClassificationOK, + Payload: payload, + } + req := PublishRequest{ + ArtifactType: TypeAgentCommit, Environment: "dev", ProjectExternalKey: "proj-a", + LogicalResourceID: "doc_retry", SourceEventKind: "agent_commit", SourceEventID: "ev-retry", + Payload: payload, TerminalState: "committed", + } + art, _, err := reg.Request(ctx, facts, req) + if err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(store.Root, "commits", art.UUID)); err != nil { + t.Fatal(err) + } + if _, _, err := pub.DegradedPayload(ctx, "dev", "doc_retry", "proj-a"); err == nil { + t.Fatal("missing commit marker must hide payload") + } + if err := reg.MarkFailed(ctx, art.UUID, "commit_missing"); err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + b, _, err := pub.DegradedPayload(ctx, "dev", "doc_retry", "proj-a") + if err != nil || string(b) != string(payload) { + t.Fatalf("retry after exclusive blob/manifest: %v %q", err, b) + } +} + +type requestCountingReg struct { + Registry + requests int +} + +func (r *requestCountingReg) Request(ctx context.Context, facts Facts, req PublishRequest) (*Artifact, *Staging, error) { + r.requests++ + return r.Registry.Request(ctx, facts, req) +} + +func TestDegradedPayloadDoesNotWrite(t *testing.T) { + ctx := context.Background() + mem := NewMemoryRegistry() + count := &requestCountingReg{Registry: mem} + store := testStore(t) + pub := &Publisher{Reg: count, Store: store} + facts := Facts{ + ArtifactType: TypeAgentCommit, CandidateStatus: "committed", OutputSnapshotID: 6, + ChangeKind: "agent_commit", ChangeToSnapshotID: 6, Classification: ClassificationOK, + Payload: []byte("# get\n"), + } + req := PublishRequest{ + ArtifactType: TypeAgentCommit, Environment: "dev", ProjectExternalKey: "proj-a", + LogicalResourceID: "doc_get", SourceEventKind: "agent_commit", SourceEventID: "ev-get", + Payload: []byte("# get\n"), TerminalState: "committed", + } + if _, _, err := mem.Request(ctx, facts, req); err != nil { + t.Fatal(err) + } + if err := pub.Drain(ctx); err != nil { + t.Fatal(err) + } + count.requests = 0 + if _, _, err := pub.DegradedPayload(ctx, "dev", "doc_get", "proj-a"); err != nil { + t.Fatal(err) + } + if count.requests != 0 { + t.Fatalf("GET/degraded read wrote Request %d times", count.requests) + } +} + +func ptrTime() *time.Time { t := time.Now().UTC(); return &t } diff --git a/internal/artifactvault/recovery.go b/internal/artifactvault/recovery.go new file mode 100644 index 00000000..923a13d3 --- /dev/null +++ b/internal/artifactvault/recovery.go @@ -0,0 +1,139 @@ +package artifactvault + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type RecoverMode string + +const ( + RecoverAsCollection RecoverMode = "recover-as-collection" + RecoverRelink RecoverMode = "relink" +) + +type RecoverPlan struct { + Mode RecoverMode + TargetIdentity string + SourceIdentity string + PlanHash string + Conflicts []string + Actor string + HasAdmin bool + HasProjectAuth bool + DependenciesOK bool + ScratchOnly bool +} + +func ValidateRecoverTarget(dsn, identity string) error { + low := strings.ToLower(dsn + " " + identity) + for _, ban := range []string{"agent_system_dev", "agent_system_test", "agent_system_main", "env=dev", "env=test", "env=main"} { + if strings.Contains(low, ban) && !strings.Contains(low, "scratch") { + return fmt.Errorf("artifactvault: refuse recover into operational database") + } + } + if !strings.Contains(low, "scratch") { + return fmt.Errorf("artifactvault: recover target must be scratch") + } + return nil +} + +func EvaluateRecoverPlan(p RecoverPlan, denied bool) error { + if err := ValidateRecoverTarget(p.TargetIdentity, p.SourceIdentity); err != nil { + return err + } + if !p.ScratchOnly { + return fmt.Errorf("artifactvault: scratch-only required") + } + if !p.HasAdmin { + return fmt.Errorf("artifactvault: operator lacks role-admin") + } + if denied { + return fmt.Errorf("artifactvault: union-deny deletion ledger") + } + if len(p.Conflicts) > 0 && p.Mode == RecoverRelink { + return fmt.Errorf("artifactvault: relink cannot proceed with conflicts") + } + if p.Mode == RecoverRelink && (!p.HasProjectAuth || !p.DependenciesOK) { + return fmt.Errorf("artifactvault: relink authorization or dependency gap") + } + if p.Mode == "" { + p.Mode = RecoverAsCollection + } + if p.PlanHash == "" { + return fmt.Errorf("artifactvault: plan hash required") + } + return nil +} + +type RecoveredItem struct { + ArtifactUUID string `json:"artifact_uuid"` + LogicalResourceID string `json:"logical_resource_id"` + PayloadSHA256 string `json:"payload_sha256"` + SkippedReason string `json:"skipped_reason,omitempty"` +} + +type RecoveredCollection struct { + Mode RecoverMode `json:"mode"` + PlanHash string `json:"plan_hash"` + DestDir string `json:"dest_dir"` + Items []RecoveredItem `json:"items"` +} + +// ApplyRecoverAsCollection writes recovered payloads into a scratch collection directory. +// It never writes operational databases or relinks live heads. +func ApplyRecoverAsCollection(plan RecoverPlan, destDir string, arts []Artifact, payloads map[string][]byte, denied map[string]bool) (*RecoveredCollection, error) { + if err := EvaluateRecoverPlan(plan, false); err != nil { + return nil, err + } + if plan.Mode != RecoverAsCollection && plan.Mode != "" { + return nil, fmt.Errorf("artifactvault: apply only supports recover-as-collection") + } + if !destIsScratch(destDir) { + return nil, fmt.Errorf("artifactvault: collection dest must be scratch") + } + if err := os.MkdirAll(filepath.Join(destDir, "payloads"), 0o700); err != nil { + return nil, err + } + out := &RecoveredCollection{Mode: RecoverAsCollection, PlanHash: plan.PlanHash, DestDir: destDir} + for _, art := range arts { + item := RecoveredItem{ArtifactUUID: art.UUID, LogicalResourceID: art.LogicalResourceID, PayloadSHA256: art.PayloadSHA256} + if denied[art.UUID] || art.PublicationStatus == StatusTombstoned { + item.SkippedReason = "union-deny" + out.Items = append(out.Items, item) + continue + } + body := payloads[art.UUID] + if len(body) == 0 || SHA256Hex(body) != art.PayloadSHA256 { + item.SkippedReason = "sha_mismatch" + out.Items = append(out.Items, item) + continue + } + path := filepath.Join(destDir, "payloads", art.UUID+".md") + if err := os.WriteFile(path, body, 0o600); err != nil { + return nil, err + } + out.Items = append(out.Items, item) + } + raw, err := json.MarshalIndent(out, "", " ") + if err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(destDir, "collection.json"), raw, 0o600); err != nil { + return nil, err + } + return out, nil +} + +func destIsScratch(destDir string) bool { + for _, part := range strings.Split(filepath.ToSlash(filepath.Clean(destDir)), "/") { + low := strings.ToLower(part) + if low == "scratch" || strings.HasPrefix(low, "scratch-") || strings.HasPrefix(low, "scratch_") { + return true + } + } + return false +} diff --git a/internal/artifactvault/recovery_test.go b/internal/artifactvault/recovery_test.go new file mode 100644 index 00000000..659f05a6 --- /dev/null +++ b/internal/artifactvault/recovery_test.go @@ -0,0 +1,65 @@ +package artifactvault + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRecoverRejectsOperationalDSN(t *testing.T) { + if err := ValidateRecoverTarget("postgres://x/agent_system_dev", "dev"); err == nil { + t.Fatal("dev dsn") + } + if err := ValidateRecoverTarget("postgres://x/fav_scratch", "scratch-1"); err != nil { + t.Fatal(err) + } +} + +func TestRecoverPlanGates(t *testing.T) { + p := RecoverPlan{Mode: RecoverAsCollection, TargetIdentity: "scratch-db", SourceIdentity: "vault", PlanHash: "abc", HasAdmin: true, ScratchOnly: true} + if err := EvaluateRecoverPlan(p, false); err != nil { + t.Fatal(err) + } + if err := EvaluateRecoverPlan(p, true); err == nil { + t.Fatal("denied") + } + p.Mode = RecoverRelink + p.Conflicts = []string{"uuid_collision"} + if err := EvaluateRecoverPlan(p, false); err == nil { + t.Fatal("relink+conflict") + } +} + +func TestApplyRecoverAsCollectionWritesScratchAndSkipsDenied(t *testing.T) { + dest := filepath.Join(t.TempDir(), "scratch-collection") + plan := RecoverPlan{ + Mode: RecoverAsCollection, TargetIdentity: "scratch-db", SourceIdentity: "vault", + PlanHash: "plan-1", HasAdmin: true, ScratchOnly: true, + } + okBody := []byte("# recovered\n") + arts := []Artifact{ + {UUID: "art-ok", LogicalResourceID: "doc_ok", PayloadSHA256: SHA256Hex(okBody)}, + {UUID: "art-deny", LogicalResourceID: "doc_deny", PayloadSHA256: SHA256Hex([]byte("x")), PublicationStatus: StatusTombstoned}, + } + payloads := map[string][]byte{"art-ok": okBody} + out, err := ApplyRecoverAsCollection(plan, dest, arts, payloads, map[string]bool{"art-deny": true}) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dest, "collection.json")); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(dest, "payloads", "art-ok.md")) + if err != nil || string(got) != string(okBody) { + t.Fatalf("payload: %v %q", err, got) + } + if _, err := os.Stat(filepath.Join(dest, "payloads", "art-deny.md")); err == nil { + t.Fatal("denied artifact must not be written") + } + if len(out.Items) != 2 || out.Items[1].SkippedReason != "union-deny" { + t.Fatalf("items=%+v", out.Items) + } + if _, err := ApplyRecoverAsCollection(plan, filepath.Join(t.TempDir(), "live-restore"), arts, payloads, nil); err == nil { + t.Fatal("non-scratch dest must fail") + } +} diff --git a/internal/artifactvault/registry.go b/internal/artifactvault/registry.go new file mode 100644 index 00000000..3a9bd91a --- /dev/null +++ b/internal/artifactvault/registry.go @@ -0,0 +1,467 @@ +package artifactvault + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Registry interface { + Request(ctx context.Context, facts Facts, req PublishRequest) (*Artifact, *Staging, error) + LoadRequested(ctx context.Context) ([]Artifact, error) + LoadStaging(ctx context.Context, sourceEventID string) (*Staging, error) + MarkPublishing(ctx context.Context, uuid string) error + MarkVerified(ctx context.Context, uuid, manifestSHA string) error + MarkFailed(ctx context.Context, uuid, code string) error + GetByLogical(ctx context.Context, env, logical string) (*Artifact, error) + Tombstone(ctx context.Context, uuid, reason, authority string) error + IsDenied(ctx context.Context, uuid string) bool + RecordReference(ctx context.Context, uuid, projectKey, sha, keyRef string) error + ActiveRefs(ctx context.Context, projectKey, sha string) int + Quarantine(ctx context.Context, reason string, logical string) error + QuarantineCount(ctx context.Context) int + Coverage(ctx context.Context) Coverage +} + +type Coverage struct { + Required int + Verified int + Quarantine int + Excluded int +} + +type MemoryRegistry struct { + mu sync.Mutex + arts map[string]*Artifact + byKey map[string]string + staging map[string]*Staging + denied map[string]bool + refs map[string]int + quarantine int + required int +} + +func NewMemoryRegistry() *MemoryRegistry { + return &MemoryRegistry{ + arts: map[string]*Artifact{}, byKey: map[string]string{}, staging: map[string]*Staging{}, + denied: map[string]bool{}, refs: map[string]int{}, + } +} + +func (m *MemoryRegistry) Request(_ context.Context, facts Facts, req PublishRequest) (*Artifact, *Staging, error) { + if err := EvaluateEligibility(facts); err != nil { + return nil, nil, err + } + if err := ValidateEnv(req.Environment); err != nil { + return nil, nil, err + } + sha := SHA256Hex(req.Payload) + key := IdempotencyKey(req, sha) + m.mu.Lock() + defer m.mu.Unlock() + if id, ok := m.byKey[key]; ok { + return m.arts[id], m.staging[req.SourceEventID], nil + } + uuid := "art-" + sha[:16] + "-" + fmt.Sprintf("%d", len(m.arts)+1) + art := &Artifact{ + UUID: uuid, IdempotencyKey: key, ArtifactType: req.ArtifactType, + LogicalResourceID: req.LogicalResourceID, Environment: req.Environment, + ProjectID: req.ProjectID, ProjectExternalKey: req.ProjectExternalKey, + SourceEventID: req.SourceEventID, SourceEventKind: req.SourceEventKind, + SourceSnapshotID: req.SourceSnapshotID, SourceMessageID: req.SourceMessageID, + SourceRunID: req.SourceRunID, PayloadSHA256: sha, TerminalState: req.TerminalState, + PolicyVersion: nz(req.PolicyVersion, PolicyVersion), PublicationStatus: StatusRequested, + RetentionClass: nz(req.RetentionClass, "governance"), RequestedAt: time.Now().UTC(), + } + st := &Staging{ + UUID: "stg-" + uuid, ProjectID: req.ProjectID, SourceEventID: req.SourceEventID, + PayloadSHA256: sha, Payload: append([]byte(nil), req.Payload...), Classification: ClassificationOK, + CreatedAt: time.Now().UTC(), + } + m.arts[uuid] = art + m.byKey[key] = uuid + m.staging[req.SourceEventID] = st + m.required++ + return art, st, nil +} + +func (m *MemoryRegistry) LoadRequested(_ context.Context) ([]Artifact, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]Artifact, 0) + for _, a := range m.arts { + if a.PublicationStatus == StatusRequested || a.PublicationStatus == StatusRetryableFailed { + out = append(out, *a) + } + } + return out, nil +} + +func (m *MemoryRegistry) LoadStaging(_ context.Context, sourceEventID string) (*Staging, error) { + m.mu.Lock() + defer m.mu.Unlock() + st := m.staging[sourceEventID] + if st == nil { + return nil, fmt.Errorf("artifactvault: staging missing") + } + cp := *st + cp.Payload = append([]byte(nil), st.Payload...) + return &cp, nil +} + +func (m *MemoryRegistry) MarkPublishing(_ context.Context, uuid string) error { + m.mu.Lock() + defer m.mu.Unlock() + if a := m.arts[uuid]; a != nil { + a.PublicationStatus = StatusPublishing + } + return nil +} + +func (m *MemoryRegistry) MarkVerified(_ context.Context, uuid, manifestSHA string) error { + m.mu.Lock() + defer m.mu.Unlock() + if a := m.arts[uuid]; a != nil { + now := time.Now().UTC() + a.PublicationStatus = StatusVerified + a.ManifestSHA256 = manifestSHA + a.VerifiedAt = &now + } + return nil +} + +func (m *MemoryRegistry) MarkFailed(_ context.Context, uuid, _ string) error { + m.mu.Lock() + defer m.mu.Unlock() + if a := m.arts[uuid]; a != nil { + a.PublicationStatus = StatusRetryableFailed + } + return nil +} + +func (m *MemoryRegistry) GetByLogical(_ context.Context, env, logical string) (*Artifact, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, a := range m.arts { + if a.Environment == env && a.LogicalResourceID == logical { + cp := *a + return &cp, nil + } + } + return nil, fmt.Errorf("artifactvault: not found") +} + +func (m *MemoryRegistry) Tombstone(_ context.Context, uuid, _, _ string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.denied[uuid] = true + if a := m.arts[uuid]; a != nil { + now := time.Now().UTC() + a.PublicationStatus = StatusTombstoned + a.TombstonedAt = &now + } + return nil +} + +func (m *MemoryRegistry) IsDenied(_ context.Context, uuid string) bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.denied[uuid] +} + +func (m *MemoryRegistry) RecordReference(_ context.Context, _, projectKey, sha, _ string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.refs[projectKey+"|"+sha]++ + return nil +} + +func (m *MemoryRegistry) ActiveRefs(_ context.Context, projectKey, sha string) int { + m.mu.Lock() + defer m.mu.Unlock() + return m.refs[projectKey+"|"+sha] +} + +func (m *MemoryRegistry) Quarantine(_ context.Context, _, _ string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.quarantine++ + return nil +} + +func (m *MemoryRegistry) QuarantineCount(_ context.Context) int { + m.mu.Lock() + defer m.mu.Unlock() + return m.quarantine +} + +func (m *MemoryRegistry) Coverage(_ context.Context) Coverage { + m.mu.Lock() + defer m.mu.Unlock() + v := 0 + for _, a := range m.arts { + if a.PublicationStatus == StatusVerified { + v++ + } + } + return Coverage{Required: m.required, Verified: v, Quarantine: m.quarantine} +} + +// PGRegistry persists FAV rows. Request uses a dedicated transaction unless WithTx is used. +type PGRegistry struct { + pool *pgxpool.Pool +} + +func NewPGRegistry(pool *pgxpool.Pool) *PGRegistry { return &PGRegistry{pool: pool} } + +func (p *PGRegistry) Request(ctx context.Context, facts Facts, req PublishRequest) (*Artifact, *Staging, error) { + if p == nil || p.pool == nil { + return nil, nil, fmt.Errorf("artifactvault: no database") + } + tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return nil, nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + art, st, err := requestTX(ctx, tx, facts, req) + if err != nil { + return nil, nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, nil, err + } + return art, st, nil +} + +func requestTX(ctx context.Context, tx pgx.Tx, facts Facts, req PublishRequest) (*Artifact, *Staging, error) { + if err := EvaluateEligibility(facts); err != nil { + return nil, nil, err + } + sha := SHA256Hex(req.Payload) + key := IdempotencyKey(req, sha) + var existing string + err := tx.QueryRow(ctx, `SELECT artifact_uuid FROM final_artifacts WHERE idempotency_key=$1`, key).Scan(&existing) + if err == nil && existing != "" { + st := &Staging{SourceEventID: req.SourceEventID, PayloadSHA256: sha, Payload: req.Payload} + return &Artifact{UUID: existing, IdempotencyKey: key, PublicationStatus: StatusRequested}, st, nil + } + uuid := "art-" + sha[:16] + _, err = tx.Exec(ctx, ` + INSERT INTO artifact_payload_staging (staging_uuid, project_id, source_event_id, payload_sha256, encrypted_payload, classification) + VALUES ($1,$2,$3,$4,$5,$6) + ON CONFLICT (source_event_id, payload_sha256) DO NOTHING + `, "stg-"+uuid, req.ProjectID, req.SourceEventID, sha, req.Payload, ClassificationOK) + if err != nil { + return nil, nil, err + } + _, err = tx.Exec(ctx, ` + INSERT INTO final_artifacts ( + artifact_uuid, idempotency_key, artifact_type, logical_resource_id, environment, project_id, + project_external_key, source_event_id, source_event_kind, source_snapshot_id, source_message_id, + source_run_id, payload_sha256, terminal_state, policy_version, publication_status, retention_class + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + ON CONFLICT (idempotency_key) DO NOTHING + `, uuid, key, req.ArtifactType, req.LogicalResourceID, req.Environment, req.ProjectID, + req.ProjectExternalKey, req.SourceEventID, req.SourceEventKind, nullInt(req.SourceSnapshotID), + nullInt(req.SourceMessageID), req.SourceRunID, sha, req.TerminalState, nz(req.PolicyVersion, PolicyVersion), + StatusRequested, nz(req.RetentionClass, "governance")) + if err != nil { + return nil, nil, err + } + return &Artifact{UUID: uuid, IdempotencyKey: key, ArtifactType: req.ArtifactType, LogicalResourceID: req.LogicalResourceID, + Environment: req.Environment, ProjectExternalKey: req.ProjectExternalKey, SourceEventID: req.SourceEventID, + PayloadSHA256: sha, PublicationStatus: StatusRequested, TerminalState: req.TerminalState}, &Staging{Payload: req.Payload, PayloadSHA256: sha, SourceEventID: req.SourceEventID}, nil +} + +func nullInt(v int64) any { + if v == 0 { + return nil + } + return v +} + +func (p *PGRegistry) LoadRequested(ctx context.Context) ([]Artifact, error) { + if p == nil || p.pool == nil { + return nil, fmt.Errorf("artifactvault: no database") + } + rows, err := p.pool.Query(ctx, ` + SELECT artifact_uuid, idempotency_key, artifact_type, logical_resource_id, environment, + COALESCE(project_id,0), project_external_key, source_event_id, source_event_kind, + COALESCE(source_snapshot_id,0), COALESCE(source_message_id,0), source_run_id, + payload_sha256, COALESCE(manifest_sha256,''), COALESCE(vault_storage_identity,''), + terminal_state, policy_version, publication_status, retention_class, requested_at + FROM final_artifacts + WHERE publication_status IN ('requested','retryable_failed') + ORDER BY requested_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Artifact, 0) + for rows.Next() { + var a Artifact + if err := rows.Scan(&a.UUID, &a.IdempotencyKey, &a.ArtifactType, &a.LogicalResourceID, &a.Environment, + &a.ProjectID, &a.ProjectExternalKey, &a.SourceEventID, &a.SourceEventKind, + &a.SourceSnapshotID, &a.SourceMessageID, &a.SourceRunID, &a.PayloadSHA256, &a.ManifestSHA256, + &a.VaultStorageIdentity, &a.TerminalState, &a.PolicyVersion, &a.PublicationStatus, &a.RetentionClass, &a.RequestedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (p *PGRegistry) LoadStaging(ctx context.Context, sourceEventID string) (*Staging, error) { + if p == nil || p.pool == nil { + return nil, fmt.Errorf("artifactvault: no database") + } + var st Staging + err := p.pool.QueryRow(ctx, ` + SELECT staging_uuid, COALESCE(project_id,0), source_event_id, payload_sha256, encrypted_payload, classification, created_at + FROM artifact_payload_staging WHERE source_event_id=$1 ORDER BY created_at DESC LIMIT 1`, sourceEventID). + Scan(&st.UUID, &st.ProjectID, &st.SourceEventID, &st.PayloadSHA256, &st.Payload, &st.Classification, &st.CreatedAt) + if err != nil { + return nil, err + } + return &st, nil +} + +func (p *PGRegistry) MarkPublishing(ctx context.Context, uuid string) error { + if p == nil || p.pool == nil { + return nil + } + _, err := p.pool.Exec(ctx, `UPDATE final_artifacts SET publication_status=$2 WHERE artifact_uuid=$1`, uuid, StatusPublishing) + if err != nil { + return err + } + _, err = p.pool.Exec(ctx, ` + INSERT INTO artifact_publication_attempts (artifact_uuid, idempotency_key, attempt_no) + SELECT artifact_uuid, idempotency_key, COALESCE((SELECT MAX(attempt_no) FROM artifact_publication_attempts a WHERE a.artifact_uuid=$1),0)+1 + FROM final_artifacts WHERE artifact_uuid=$1`, uuid) + return err +} + +func (p *PGRegistry) MarkVerified(ctx context.Context, uuid, manifestSHA string) error { + if p == nil || p.pool == nil { + return nil + } + _, err := p.pool.Exec(ctx, `UPDATE final_artifacts SET publication_status=$2, manifest_sha256=$3, verified_at=now() WHERE artifact_uuid=$1`, uuid, StatusVerified, manifestSHA) + return err +} + +func (p *PGRegistry) MarkFailed(ctx context.Context, uuid, code string) error { + if p == nil || p.pool == nil { + return nil + } + _, err := p.pool.Exec(ctx, `UPDATE final_artifacts SET publication_status=$2 WHERE artifact_uuid=$1`, uuid, StatusRetryableFailed) + if err != nil { + return err + } + _, _ = p.pool.Exec(ctx, ` + UPDATE artifact_publication_attempts SET error_code=$2 + WHERE artifact_uuid=$1 AND attempt_no=(SELECT MAX(attempt_no) FROM artifact_publication_attempts WHERE artifact_uuid=$1)`, uuid, code) + return nil +} + +func (p *PGRegistry) GetByLogical(ctx context.Context, env, logical string) (*Artifact, error) { + if p == nil || p.pool == nil { + return nil, fmt.Errorf("artifactvault: no database") + } + var a Artifact + err := p.pool.QueryRow(ctx, ` + SELECT artifact_uuid, idempotency_key, artifact_type, logical_resource_id, environment, + COALESCE(project_id,0), project_external_key, source_event_id, source_event_kind, + COALESCE(source_snapshot_id,0), COALESCE(source_message_id,0), source_run_id, + payload_sha256, COALESCE(manifest_sha256,''), COALESCE(vault_storage_identity,''), + terminal_state, policy_version, publication_status + FROM final_artifacts + WHERE environment=$1 AND logical_resource_id=$2 AND publication_status='verified' + ORDER BY verified_at DESC NULLS LAST LIMIT 1`, env, logical). + Scan(&a.UUID, &a.IdempotencyKey, &a.ArtifactType, &a.LogicalResourceID, &a.Environment, + &a.ProjectID, &a.ProjectExternalKey, &a.SourceEventID, &a.SourceEventKind, + &a.SourceSnapshotID, &a.SourceMessageID, &a.SourceRunID, &a.PayloadSHA256, &a.ManifestSHA256, + &a.VaultStorageIdentity, &a.TerminalState, &a.PolicyVersion, &a.PublicationStatus) + if err != nil { + return nil, err + } + return &a, nil +} + +func (p *PGRegistry) Tombstone(ctx context.Context, uuid, reason, authority string) error { + if p == nil || p.pool == nil { + return nil + } + _, err := p.pool.Exec(ctx, `UPDATE final_artifacts SET publication_status=$2, tombstoned_at=now() WHERE artifact_uuid=$1`, uuid, StatusTombstoned) + if err != nil { + return err + } + _, err = p.pool.Exec(ctx, `INSERT INTO artifact_deletion_ledger (artifact_uuid, reason, authority, effective_at) VALUES ($1,$2,$3,now())`, uuid, reason, authority) + return err +} + +func (p *PGRegistry) IsDenied(ctx context.Context, uuid string) bool { + if p == nil || p.pool == nil { + return false + } + var n int + _ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM artifact_deletion_ledger WHERE artifact_uuid=$1`, uuid).Scan(&n) + return n > 0 +} + +func (p *PGRegistry) RecordReference(ctx context.Context, uuid, projectKey, sha, keyRef string) error { + if p == nil || p.pool == nil { + return nil + } + _, err := p.pool.Exec(ctx, ` + INSERT INTO artifact_blob_references (artifact_uuid, project_scope, payload_sha256, envelope_key_ref, reference_status) + VALUES ($1,$2,$3,$4,'active') + ON CONFLICT (artifact_uuid, payload_sha256) DO NOTHING`, uuid, projectKey, sha, keyRef) + return err +} + +func (p *PGRegistry) ActiveRefs(ctx context.Context, projectKey, sha string) int { + if p == nil || p.pool == nil { + return 0 + } + var n int + _ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM artifact_blob_references WHERE project_scope=$1 AND payload_sha256=$2 AND reference_status='active'`, projectKey, sha).Scan(&n) + return n +} + +func (p *PGRegistry) Quarantine(ctx context.Context, reason, logical string) error { + if p == nil || p.pool == nil { + return nil + } + _, err := p.pool.Exec(ctx, `INSERT INTO artifact_quarantine (logical_resource_id, reason) VALUES ($1,$2)`, logical, reason) + return err +} + +func (p *PGRegistry) QuarantineCount(ctx context.Context) int { + if p == nil || p.pool == nil { + return 0 + } + var n int + _ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM artifact_quarantine`).Scan(&n) + return n +} + +func (p *PGRegistry) Coverage(ctx context.Context) Coverage { + var c Coverage + if p == nil || p.pool == nil { + return c + } + _ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM final_artifacts`).Scan(&c.Required) + _ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM final_artifacts WHERE publication_status='verified'`).Scan(&c.Verified) + _ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM artifact_quarantine`).Scan(&c.Quarantine) + _ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM artifact_backfill_exclusions WHERE expires_at > now()`).Scan(&c.Excluded) + return c +} + +// RequestWithTx writes staging + unpublished outbox inside an existing domain transaction. +func RequestWithTx(ctx context.Context, tx pgx.Tx, facts Facts, req PublishRequest) (*Artifact, *Staging, error) { + return requestTX(ctx, tx, facts, req) +} diff --git a/internal/artifactvault/types.go b/internal/artifactvault/types.go new file mode 100644 index 00000000..8fb3a560 --- /dev/null +++ b/internal/artifactvault/types.go @@ -0,0 +1,208 @@ +package artifactvault + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" +) + +const ( + SchemaMajorMinor = "1.0" + PublisherVersion = "fav-publisher/1.0" + PolicyVersion = "fav-eligibility/1.0" + ClassificationOK = "ok" + ClassificationSecret = "secret" + ClassificationRestricted = "restricted" + ClassificationFailed = "classification_failed" + + TypeAgentCommit = "agent_commit" + TypeSealedVersion = "sealed_version" + TypeApprovedDocument = "approved_document" + TypeFreeformPublish = "freeform_publish" + TypeAssistantReply = "assistant_reply" + + StatusRequested = "requested" + StatusPublishing = "publishing" + StatusVerified = "verified" + StatusRetryableFailed = "retryable_failed" + StatusTombstoned = "tombstoned" + StatusPurged = "purged" + StatusQuarantine = "quarantine" + + RefActive = "active" + RefTombstoned = "tombstoned" + RefPurged = "purged" +) + +// Facts are the authoritative database facts used for eligibility. HTTP handlers must not invent them. +type Facts struct { + ArtifactType string + CandidateStatus string + OutputSnapshotID int64 + ChangeKind string + ChangeToSnapshotID int64 + VersionStatus string + SealedAt *time.Time + HeadSnapshotID int64 + DocState string + ApprovedAt *time.Time + ReviewSnapshotID int64 + HasImmutableSnapshot bool + ExplicitPublish bool + ChatTurnStatus string + CompletedAt *time.Time + AssistantMessageID int64 + ExportPolicy string + ExplicitPin bool + PinAfterCompleted bool + Payload []byte + Classification string +} + +type PublishRequest struct { + ArtifactType string + Environment string + ProjectID int64 + ProjectExternalKey string + LogicalResourceID string + SourceEventKind string + SourceEventID string + SourceSnapshotID int64 + SourceMessageID int64 + SourceRunID string + Payload []byte + TerminalState string + PolicyVersion string + Classification string + Sensitivity string + RetentionClass string +} + +type Artifact struct { + UUID string + IdempotencyKey string + ArtifactType string + LogicalResourceID string + Environment string + ProjectID int64 + ProjectExternalKey string + SourceEventID string + SourceEventKind string + SourceSnapshotID int64 + SourceMessageID int64 + SourceRunID string + PayloadSHA256 string + ManifestSHA256 string + VaultStorageIdentity string + TerminalState string + PolicyVersion string + PublicationStatus string + RetentionClass string + RequestedAt time.Time + VerifiedAt *time.Time + TombstonedAt *time.Time +} + +type Staging struct { + UUID string + ProjectID int64 + SourceEventID string + PayloadSHA256 string + Payload []byte + Classification string + CreatedAt time.Time +} + +type Manifest struct { + ArtifactSchemaVersion string `json:"artifact_schema_version"` + ArtifactUUID string `json:"artifact_uuid"` + ArtifactType string `json:"artifact_type"` + LogicalResourceID string `json:"logical_resource_id"` + Environment string `json:"environment"` + ProjectID int64 `json:"project_id,omitempty"` + ProjectExternalKey string `json:"project_external_key"` + SourceEventID string `json:"source_event_id"` + SourceEventKind string `json:"source_event_kind,omitempty"` + SourceSnapshotID *int64 `json:"source_snapshot_id"` + SourceMessageID *int64 `json:"source_message_id"` + SourceRunID string `json:"source_run_id,omitempty"` + PayloadMembers []PayloadMember `json:"payload_members"` + TerminalEvidence TerminalEvidence `json:"terminal_evidence"` + Lineage map[string]any `json:"lineage,omitempty"` + RetentionClass string `json:"retention_class,omitempty"` + Sensitivity string `json:"sensitivity,omitempty"` + EncryptionKeyRef string `json:"encryption_key_ref,omitempty"` + VaultStorageIdentity string `json:"vault_storage_identity"` + PublisherVersion string `json:"publisher_version,omitempty"` + PublishedAt string `json:"published_at,omitempty"` + ManifestSHA256 string `json:"manifest_sha256"` +} + +type PayloadMember struct { + Name string `json:"name"` + MediaType string `json:"media_type"` + ByteSize int `json:"byte_size"` + SHA256 string `json:"sha256"` + Required bool `json:"required"` +} + +type TerminalEvidence struct { + State string `json:"state"` + DecidedAt string `json:"decided_at"` + ActorRef string `json:"actor_ref,omitempty"` + PolicyVersion string `json:"policy_version"` +} + +type CommitMarker struct { + ArtifactUUID string `json:"artifact_uuid"` + ManifestSHA256 string `json:"manifest_sha256"` + MemberSHAs []string `json:"member_shas"` + VaultStorageIdentity string `json:"vault_storage_identity"` + CommittedAt string `json:"committed_at"` + PublisherVersion string `json:"publisher_version"` +} + +type Tombstone struct { + ArtifactUUID string `json:"artifact_uuid"` + EventUUID string `json:"event_uuid"` + Reason string `json:"reason"` + Authority string `json:"authority,omitempty"` + EffectiveAt string `json:"effective_at"` + AuditRef string `json:"audit_ref,omitempty"` +} + +func SHA256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func IdempotencyKey(req PublishRequest, payloadSHA string) string { + return strings.Join([]string{ + req.ArtifactType, + req.Environment, + req.ProjectExternalKey, + req.LogicalResourceID, + req.SourceEventKind, + req.SourceEventID, + payloadSHA, + nz(req.PolicyVersion, PolicyVersion), + }, "|") +} + +func nz(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +func ValidateEnv(env string) error { + switch env { + case "dev", "test", "main": + return nil + default: + return fmt.Errorf("artifactvault: invalid environment %q", env) + } +} diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go index 6f71e071..4c3da960 100644 --- a/internal/db/migrate_test.go +++ b/internal/db/migrate_test.go @@ -2,6 +2,7 @@ package db import ( "context" + "net/url" "os" "path/filepath" "strings" @@ -9,6 +10,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + "github.com/pressly/goose/v3" ) func TestMigrationGate_ExpectedFilesMatchAppliedVersion(t *testing.T) { @@ -78,3 +80,102 @@ func TestMigration032_AllowsMigrationRepairKinds(t *testing.T) { t.Fatalf("00032 must allow migration_repair on both constraints, got %s", found) } } + +func TestGooseM2_00034to00036_DownThenUp(t *testing.T) { + if testing.Short() { + t.Skip("goose down is not part of -short") + } + dsn := SkipIfNoTestDatabaseURL(t) + if strings.Contains(dsn, "5433") || strings.Contains(dsn, "agent_system_dev") || + (strings.Contains(dsn, "5434") && strings.Contains(dsn, "agent_system_test")) || + strings.Contains(dsn, "5435") { + t.Fatal("TEST_DATABASE_URL points at a shared env port; refuse agent_system_dev/test/main") + } + migDir := ResolveMigrationsDir() + files, err := ListMigrationFiles(migDir) + if err != nil { + t.Fatal(err) + } + want := []string{ + "00034_2026-09-14-workspace-lease-fencing.sql", + "00035_2026-09-14-durable-event-delivery.sql", + "00036_2026-09-14-final-artifact-vault.sql", + } + joined := strings.Join(files, "\n") + for _, name := range want { + if !strings.Contains(joined, name) { + t.Fatalf("missing %s", name) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + admin, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(admin.Close) + + const isolated = "agent_rel_m2_goose" + _, _ = admin.Exec(ctx, `DROP DATABASE IF EXISTS `+isolated) + if _, err := admin.Exec(ctx, `CREATE DATABASE `+isolated); err != nil { + t.Fatalf("create isolated db: %v", err) + } + t.Cleanup(func() { + _, _ = admin.Exec(context.Background(), `DROP DATABASE IF EXISTS `+isolated) + }) + + m2dsn := rewriteDatabaseName(dsn, isolated) + pool, err := pgxpool.New(ctx, m2dsn) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + if err := RunMigrations(ctx, pool, migDir); err != nil { + t.Fatalf("up: %v", err) + } + ver, err := CurrentVersion(ctx, pool) + if err != nil || ver < 36 { + t.Fatalf("version after up: %d %v", ver, err) + } + + sqlDB := OpenSQLDB(pool) + defer sqlDB.Close() + if err := goose.SetDialect("postgres"); err != nil { + t.Fatal(err) + } + goose.SetTableName(schemaTable) + if err := goose.DownToContext(ctx, sqlDB, migDir, 33); err != nil { + t.Fatalf("down to 33: %v", err) + } + for _, table := range []string{"event_envelopes", "event_deliveries", "event_dead_letters", "final_artifacts"} { + var n int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_name=$1`, table).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("%s still present after down", table) + } + } + var col int + if err := pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM information_schema.columns + WHERE table_name='document_records' AND column_name='lease_fencing_token' + `).Scan(&col); err != nil { + t.Fatal(err) + } + if col != 0 { + t.Fatal("lease_fencing_token must drop with 00034 Down") + } + if err := RunMigrations(ctx, pool, migDir); err != nil { + t.Fatalf("up after down: %v", err) + } +} + +func rewriteDatabaseName(dsn, name string) string { + if u, err := url.Parse(dsn); err == nil && u.Scheme != "" { + u.Path = "/" + name + return u.String() + } + return dsn +} diff --git a/internal/docgov/lease.go b/internal/docgov/lease.go index 1dd0c1e4..08f3d691 100644 --- a/internal/docgov/lease.go +++ b/internal/docgov/lease.go @@ -2,8 +2,11 @@ package docgov import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" + "io" "path/filepath" "strings" "time" @@ -29,6 +32,7 @@ type ContentSaveRequest struct { ActorName string MarkdownBody string ExpectedSHA256 string // 乐观并发:非空时必须匹配当前 ContentSHA256 + FencingToken string } // ContentSaveResult 正文保存结果 @@ -101,6 +105,7 @@ func (e *GovernanceEngine) AcquireLease(ctx context.Context, req LeaseAcquireReq } doc.LeaseExpiresAt = &expires doc.LeaseHeartbeatAt = &now + doc.LeaseFencingToken = newLeaseFencingToken() doc.UpdatedAt = now e.persistLeaseLocked(ctx, doc) @@ -344,6 +349,11 @@ func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentS if req.ActorID <= 0 { holderID = "human_anon" } + if req.FencingToken != "" { + if err := e.AssertLeaseFencing(ctx, req.DocID, req.FencingToken); err != nil { + return nil, err + } + } doc, err := e.AcquireLease(ctx, LeaseAcquireRequest{ DocID: req.DocID, @@ -409,6 +419,28 @@ func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentS return &ContentSaveResult{Doc: doc, ContentSHA256: shaStr, WorkDir: workDir}, nil } +// AssertLeaseFencing rejects writers holding a superseded token. +func (e *GovernanceEngine) AssertLeaseFencing(ctx context.Context, docID, token string) error { + e.mu.Lock() + defer e.mu.Unlock() + doc, err := e.getDocLocked(ctx, docID) + if err != nil { + return err + } + if token == "" || doc.LeaseFencingToken == "" || token != doc.LeaseFencingToken { + return ErrStaleFencing + } + return nil +} + +func newLeaseFencingToken() string { + var b [16]byte + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b[:]) +} + func (e *GovernanceEngine) getDocLocked(ctx context.Context, docID string) (*DocumentRecord, error) { doc, exists := e.memDocs[docID] if !exists { @@ -428,10 +460,10 @@ func (e *GovernanceEngine) persistLeaseLocked(ctx context.Context, doc *Document _, _ = e.pool.Exec(ctx, ` UPDATE document_records SET lease_holder_type = $1, lease_holder_id = $2, lease_work_dir = $3, - lease_expires_at = $4, lease_heartbeat_at = $5, updated_at = $6 - WHERE doc_id = $7 + lease_expires_at = $4, lease_heartbeat_at = $5, lease_fencing_token = $6, updated_at = $7 + WHERE doc_id = $8 `, string(doc.LeaseHolderType), doc.LeaseHolderID, doc.LeaseWorkDir, - doc.LeaseExpiresAt, doc.LeaseHeartbeatAt, doc.UpdatedAt, doc.DocID) + doc.LeaseExpiresAt, doc.LeaseHeartbeatAt, doc.LeaseFencingToken, doc.UpdatedAt, doc.DocID) } func (e *GovernanceEngine) appendAuditLocked(doc *DocumentRecord, action string, actorID int64, actorName string, actorRole UserRole, details map[string]any) { diff --git a/internal/docgov/lease_test.go b/internal/docgov/lease_test.go index 6e1d4deb..4e53e92f 100644 --- a/internal/docgov/lease_test.go +++ b/internal/docgov/lease_test.go @@ -86,6 +86,38 @@ func TestSameHolderRenewLease(t *testing.T) { } } +func TestStaleFencingRejectedAfterRenew(t *testing.T) { + engine := docgov.NewGovernanceEngine(nil) + ctx := context.Background() + docID := "doc_fence_001" + _, _ = engine.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "a", 2, "b", 3, "c") + first, err := engine.AcquireLease(ctx, docgov.LeaseAcquireRequest{ + DocID: docID, HolderType: docgov.LeaseHolderHuman, HolderID: "1", TTL: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + old := first.LeaseFencingToken + if old == "" { + t.Fatal("expected fencing token") + } + second, err := engine.AcquireLease(ctx, docgov.LeaseAcquireRequest{ + DocID: docID, HolderType: docgov.LeaseHolderHuman, HolderID: "1", TTL: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + if second.LeaseFencingToken == old { + t.Fatal("renew must rotate fencing token") + } + if err := engine.AssertLeaseFencing(ctx, docID, old); err != docgov.ErrStaleFencing { + t.Fatalf("old writer: %v", err) + } + if err := engine.AssertLeaseFencing(ctx, docID, second.LeaseFencingToken); err != nil { + t.Fatal(err) + } +} + func TestAssertHumanCanEdit_ChecksDutyBeforeLease(t *testing.T) { engine := docgov.NewGovernanceEngine(nil) ctx := context.Background() diff --git a/internal/docgov/types.go b/internal/docgov/types.go index b06f9791..01ea18aa 100644 --- a/internal/docgov/types.go +++ b/internal/docgov/types.go @@ -18,6 +18,7 @@ var ( ErrProposalAlreadyDecided = errors.New("docgov: change proposal has already been decided") ErrChangeProposalPending = errors.New("docgov: document is claimed and has pending change proposal waiting for author approval") ErrDocumentBusy = errors.New("docgov: document edit lease is held by another writer") + ErrStaleFencing = errors.New("docgov: stale lease fencing token") ErrLeaseExpired = errors.New("docgov: document edit lease has expired") ErrLeaseNotHeld = errors.New("docgov: caller does not hold the document edit lease") ErrForceEditNotAllowed = errors.New("docgov: force_direct_edit requires an approved_by_author proposal and unlocked drafting document") @@ -183,11 +184,12 @@ type DocumentRecord struct { ApprovedAt *time.Time `json:"approved_at"` // 编辑租约 (运行时互斥,正交于 IsLocked) - LeaseHolderType LeaseHolderType `json:"lease_holder_type,omitempty"` - LeaseHolderID string `json:"lease_holder_id,omitempty"` - LeaseWorkDir string `json:"lease_work_dir,omitempty"` - LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"` - LeaseHeartbeatAt *time.Time `json:"lease_heartbeat_at,omitempty"` + LeaseHolderType LeaseHolderType `json:"lease_holder_type,omitempty"` + LeaseHolderID string `json:"lease_holder_id,omitempty"` + LeaseWorkDir string `json:"lease_work_dir,omitempty"` + LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"` + LeaseHeartbeatAt *time.Time `json:"lease_heartbeat_at,omitempty"` + LeaseFencingToken string `json:"lease_fencing_token,omitempty"` Config DocumentConfig `json:"config"` CreatedAt time.Time `json:"created_at"` @@ -212,6 +214,7 @@ func (d *DocumentRecord) ClearLease() { d.LeaseWorkDir = "" d.LeaseExpiresAt = nil d.LeaseHeartbeatAt = nil + d.LeaseFencingToken = "" } // CommentType 批注类型 diff --git a/internal/docquery/service.go b/internal/docquery/service.go index a0b0a0fd..6c3f0a61 100644 --- a/internal/docquery/service.go +++ b/internal/docquery/service.go @@ -58,8 +58,12 @@ type Service struct { Rev *docrevision.Service RepoRoot string Authorize Authorizer - // FavDegradedRead is an extension point only. M1 keeps it false. FavDegradedRead bool + Vault VaultReader +} + +type VaultReader interface { + ReadVerified(ctx context.Context, projectID int64, logicalID string) (markdown string, sha string, err error) } func FavDegradedReadEnabled() bool { @@ -101,6 +105,13 @@ func (s *Service) Get(ctx context.Context, p Principal, ref DocRef) (*ContentVie } } + if s.FavDegradedRead && s.Vault != nil { + md, sha, verr := s.Vault.ReadVerified(ctx, rec.ProjectID, rec.DocID) + if verr != nil { + return nil, verr + } + return s.viewFromLedger(ctx, rec, md, sha), nil + } if s.FavDegradedRead { return nil, ErrFavNotEnabled } diff --git a/internal/docquery/service_test.go b/internal/docquery/service_test.go index e7051270..f3f42f30 100644 --- a/internal/docquery/service_test.go +++ b/internal/docquery/service_test.go @@ -2,6 +2,7 @@ package docquery import ( "context" + "errors" "os" "path/filepath" "testing" @@ -165,3 +166,40 @@ func TestFavDegradedReadDefaultOff(t *testing.T) { t.Fatal("FAV_DEGRADED_READ must default false") } } + +type countingVault struct { + calls int + err error +} + +func (v *countingVault) ReadVerified(context.Context, int64, string) (string, string, error) { + v.calls++ + if v.err != nil { + return "", "", v.err + } + return "# from vault\n", "abc", nil +} + +func TestFavDegradedReadOnlyAfterAuthorize(t *testing.T) { + ctx := context.Background() + gov := docgov.NewGovernanceEngine(nil) + _, _ = gov.CreateOrGetDocument(ctx, "doc_fav", 9, "intake", "t", "V0.5_feasibility", "", 1, "A", 2, "R", 3, "P") + vault := &countingVault{err: errors.New("artifactvault: union-deny")} + svc := &Service{ + Gov: gov, FavDegradedRead: true, Vault: vault, + Authorize: func(context.Context, Principal, *docgov.DocumentRecord) error { return ErrClosed }, + } + if _, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_fav"}); err != ErrClosed { + t.Fatalf("auth must win: %v", err) + } + if vault.calls != 0 { + t.Fatal("vault must not run before authorization") + } + svc.Authorize = func(context.Context, Principal, *docgov.DocumentRecord) error { return nil } + if _, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_fav"}); err == nil { + t.Fatal("union-deny must fail closed") + } + if vault.calls != 1 { + t.Fatalf("calls=%d", vault.calls) + } +} diff --git a/internal/docrevision/document_commit_concurrency_pg_test.go b/internal/docrevision/document_commit_concurrency_pg_test.go index 54eaedf6..2c1a41a7 100644 --- a/internal/docrevision/document_commit_concurrency_pg_test.go +++ b/internal/docrevision/document_commit_concurrency_pg_test.go @@ -185,15 +185,15 @@ func TestDocumentCommitTwoPoolsAndFourSessionsPG(t *testing.T) { t.Fatalf("two pools same etag: wins=%d errs=%v", wins, errs) } - // Four sessions: two pools × two sequential idempotent retries of the winner key must replay. - winnerKey := "" + // Same idempotency key + same digest must replay across pools. + // A different digest with the same key is ErrIdempotencyConflict (covered in unit tests). + winnerIdx := 1 if errs[0] == nil { - winnerKey = fmt.Sprintf("2p-%s-0", suffix) - } else { - winnerKey = fmt.Sprintf("2p-%s-1", suffix) + winnerIdx = 0 } + winnerKey := fmt.Sprintf("2p-%s-%d", suffix, winnerIdx) replay := docrevision.HumanRevisionRequest{ - DocID: docID, Markdown: "# ignored-if-same-key\n", + DocID: docID, Markdown: fmt.Sprintf("# base\n\npool %d\n", winnerIdx), IdempotencyKey: winnerKey, ActorUserID: 1, LifecycleState: "drafting", ProjectID: projectID, WorkDir: "ws/" + docID, } @@ -212,7 +212,8 @@ func TestDocumentCommitTwoPoolsAndFourSessionsPG(t *testing.T) { var extra sync.WaitGroup extraWins := 0 var extraMu sync.Mutex - st2, err := seed.GetRevisionState(ctx, docID) + fresh := docrevision.NewService(poolA) + st2, err := fresh.GetRevisionState(ctx, docID) if err != nil { t.Fatal(err) } diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go index 565f1e00..6c637c2d 100644 --- a/internal/docrevision/service.go +++ b/internal/docrevision/service.go @@ -680,9 +680,7 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat } if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil { - _ = s.OnOutbox(ctx, "document.agent_version_committed", map[string]any{ - "doc_id": c.DocID, "version_no": newVer.VersionNo, "risk": rep.RiskLevel, "candidate_id": c.ID, - }) + // document.agent_version_committed is owned by events.EventSink (U6). for _, imp := range rep.DocumentImpacts { _ = s.OnOutbox(ctx, "document.downstream_impact_detected", map[string]any{ "doc_id": c.DocID, "impacted_doc_id": imp.DocID, "version_no": newVer.VersionNo, diff --git a/internal/events/bus.go b/internal/events/bus.go new file mode 100644 index 00000000..f9eba70f --- /dev/null +++ b/internal/events/bus.go @@ -0,0 +1,203 @@ +package events + +import ( + "context" + "crypto/rand" + "encoding/hex" + "io" + "sync" + "time" +) + +const MaxAttempts = 12 + +// MemoryBus is a process-local outbox/delivery/inbox used for dispatcher +// characterization. PostgreSQL SKIP LOCKED is the production claim primitive. +type MemoryBus struct { + mu sync.Mutex + envelopes map[string]Envelope + order []string + deliveries map[string]map[string]*Delivery // consumer -> eventID + heads map[string]int64 // aggregate -> last applied version + side map[string]int // consumer:event visible side effects + consumerProject map[string]int64 +} + +func NewMemoryBus() *MemoryBus { + return &MemoryBus{ + envelopes: map[string]Envelope{}, + deliveries: map[string]map[string]*Delivery{}, + heads: map[string]int64{}, + side: map[string]int{}, + consumerProject: map[string]int64{}, + } +} + +func (b *MemoryBus) BindConsumer(consumer string, projectID int64) { + b.mu.Lock() + defer b.mu.Unlock() + b.consumerProject[consumer] = projectID +} + +func (b *MemoryBus) Append(ctx context.Context, env Envelope, consumers []string) error { + _ = ctx + if err := ValidateEnvelope(env); err != nil { + b.quarantine(env, consumers) + return err + } + b.mu.Lock() + defer b.mu.Unlock() + if _, ok := b.envelopes[env.EventID]; ok { + return nil + } + b.envelopes[env.EventID] = env + b.order = append(b.order, env.EventID) + for _, c := range consumers { + if b.deliveries[c] == nil { + b.deliveries[c] = map[string]*Delivery{} + } + b.deliveries[c][env.EventID] = &Delivery{ + EventID: env.EventID, Consumer: c, Status: "pending", NextAttemptAt: time.Now(), + } + } + return nil +} + +func (b *MemoryBus) quarantine(env Envelope, consumers []string) { + b.mu.Lock() + defer b.mu.Unlock() + b.envelopes[env.EventID] = env + for _, c := range consumers { + if b.deliveries[c] == nil { + b.deliveries[c] = map[string]*Delivery{} + } + b.deliveries[c][env.EventID] = &Delivery{EventID: env.EventID, Consumer: c, Status: "quarantine"} + } +} + +func (b *MemoryBus) Claim(consumer, worker string, n int, now time.Time) ([]*Delivery, error) { + b.mu.Lock() + defer b.mu.Unlock() + rows := b.deliveries[consumer] + if rows == nil { + return nil, ErrUnknownConsumer + } + out := []*Delivery{} + for _, id := range b.order { + if len(out) >= n { + break + } + d := rows[id] + if d == nil { + continue + } + if d.Status == "acked" || d.Status == "dead" || d.Status == "quarantine" { + continue + } + if d.Status == "leased" && d.LeaseUntil.After(now) { + continue + } + env := b.envelopes[id] + head := b.heads[env.AggregateID] + if env.AggregateVersion != 0 && env.AggregateVersion > head+1 { + continue // gap: do not skip missing versions + } + token, err := randomToken() + if err != nil { + return nil, err + } + d.Status = "leased" + d.LeaseOwner = worker + d.FencingToken = token + d.LeaseUntil = now.Add(2 * time.Second) + d.Attempt++ + cp := *d + out = append(out, &cp) + } + return out, nil +} + +func (b *MemoryBus) Apply(consumer string, claimed *Delivery, fail error) error { + b.mu.Lock() + defer b.mu.Unlock() + d := b.deliveries[consumer][claimed.EventID] + if d == nil || d.FencingToken != claimed.FencingToken || d.LeaseOwner != claimed.LeaseOwner { + return ErrStaleFencing + } + env := b.envelopes[claimed.EventID] + if pid := b.consumerProject[consumer]; pid != 0 && env.ProjectID != 0 && env.ProjectID != pid { + d.Status = "quarantine" + d.LeaseOwner = "" + d.FencingToken = "" + return ErrQuarantine + } + if fail == ErrAuthFailure || fail == ErrIntegrityFailure { + d.Status = "dead" + return fail + } + if fail != nil { + if d.Attempt >= MaxAttempts { + d.Status = "dead" + return fail + } + d.Status = "pending" + d.LeaseOwner = "" + d.FencingToken = "" + d.NextAttemptAt = time.Now().Add(time.Millisecond) + return fail + } + head := b.heads[env.AggregateID] + if env.AggregateVersion != 0 && env.AggregateVersion > head+1 { + d.Status = "pending" + d.LeaseOwner = "" + d.FencingToken = "" + return ErrGapWait + } + key := consumer + ":" + claimed.EventID + if !d.Inbox { + b.side[key]++ + d.Inbox = true + if env.AggregateVersion > head { + b.heads[env.AggregateID] = env.AggregateVersion + } + } + d.Status = "acked" + d.LeaseOwner = "" + return nil +} + +func (b *MemoryBus) SideEffects(consumer, eventID string) int { + b.mu.Lock() + defer b.mu.Unlock() + return b.side[consumer+":"+eventID] +} + +func (b *MemoryBus) Status(consumer, eventID string) string { + b.mu.Lock() + defer b.mu.Unlock() + d := b.deliveries[consumer][eventID] + if d == nil { + return "" + } + return d.Status +} + +func (b *MemoryBus) Owners(consumer string) map[string]string { + b.mu.Lock() + defer b.mu.Unlock() + out := map[string]string{} + for id, d := range b.deliveries[consumer] { + if d.Status == "leased" { + out[id] = d.LeaseOwner + } + } + return out +} + +func randomToken() (string, error) { + var b [8]byte + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} diff --git a/internal/events/dispatcher_test.go b/internal/events/dispatcher_test.go new file mode 100644 index 00000000..7b7a1a0e --- /dev/null +++ b/internal/events/dispatcher_test.go @@ -0,0 +1,193 @@ +package events + +import ( + "context" + "sync" + "testing" + "time" +) + +func sample(id string, ver int64, payload string) Envelope { + body := []byte(payload) + return Envelope{ + EventID: id, EventName: "document.agent_version_committed", SchemaVersion: EnvelopeSchema, + Environment: "dev", ProjectID: 9, AggregateType: "document", AggregateID: "doc-a", + AggregateVersion: ver, ActorRef: "user:1", IdempotencyKey: id, PayloadDigest: Digest(body), + Producer: "docflow", OccurredAt: time.Now().UTC(), Payload: body, + } +} + +func TestTwoAndFourWorkersNoDoubleOwner(t *testing.T) { + for _, n := range []int{2, 4} { + bus := NewMemoryBus() + if err := bus.Append(context.Background(), sample("e1", 1, "v1"), []string{"proj", "notify"}); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + now := time.Now() + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, _ = bus.Claim("proj", "w"+string(rune('a'+i)), 8, now) + }(i) + } + wg.Wait() + if n := len(bus.Owners("proj")); n > 1 { + t.Fatalf("workers=%d double owner %#v", n, bus.Owners("proj")) + } + } +} + +func TestCrashBeforeAfterAck(t *testing.T) { + bus := NewMemoryBus() + _ = bus.Append(context.Background(), sample("e2", 1, "body"), []string{"proj"}) + now := time.Now() + first, err := bus.Claim("proj", "w1", 1, now) + if err != nil || len(first) != 1 { + t.Fatalf("claim: %v %#v", err, first) + } + // crash-before-side-effect: drop lease by waiting until expiry + later := now.Add(3 * time.Second) + second, err := bus.Claim("proj", "w2", 1, later) + if err != nil || len(second) != 1 { + t.Fatalf("reclaim: %v", err) + } + if err := bus.Apply("proj", first[0], nil); err != ErrStaleFencing { + t.Fatalf("expired worker ack: %v", err) + } + if err := bus.Apply("proj", second[0], nil); err != nil { + t.Fatal(err) + } + if bus.SideEffects("proj", "e2") != 1 { + t.Fatalf("side=%d", bus.SideEffects("proj", "e2")) + } + // crash-after-side-effect/ack: replay is inbox-deduped + if err := bus.Apply("proj", second[0], nil); err != ErrStaleFencing && bus.SideEffects("proj", "e2") != 1 { + t.Fatalf("replay: %v side=%d", err, bus.SideEffects("proj", "e2")) + } +} + +func TestGapDoesNotSkipVersion(t *testing.T) { + bus := NewMemoryBus() + _ = bus.Append(context.Background(), sample("e-v2", 2, "two"), []string{"proj"}) + now := time.Now() + got, _ := bus.Claim("proj", "w", 4, now) + if len(got) != 0 { + t.Fatalf("must wait for missing version 1, claimed %d", len(got)) + } + _ = bus.Append(context.Background(), sample("e-v1", 1, "one"), []string{"proj"}) + got, _ = bus.Claim("proj", "w", 4, now) + if len(got) != 1 || got[0].EventID != "e-v1" { + t.Fatalf("expected v1 first, got %#v", got) + } + if err := bus.Apply("proj", got[0], nil); err != nil { + t.Fatal(err) + } + got, _ = bus.Claim("proj", "w", 4, now) + if len(got) != 1 || got[0].EventID != "e-v2" { + t.Fatalf("expected v2 next, got %#v", got) + } +} + +func TestQuarantineBadDigestForgedProducerCrossProject(t *testing.T) { + bus := NewMemoryBus() + bus.BindConsumer("proj", 9) + bad := sample("bad-digest", 1, "x") + bad.PayloadDigest = "deadbeef" + if err := bus.Append(context.Background(), bad, []string{"proj"}); err != ErrIntegrityFailure { + t.Fatalf("digest: %v", err) + } + if bus.Status("proj", "bad-digest") != "quarantine" { + t.Fatalf("status=%s", bus.Status("proj", "bad-digest")) + } + forged := sample("forged", 1, "x") + forged.Producer = "forged" + if err := bus.Append(context.Background(), forged, []string{"proj"}); err != ErrIntegrityFailure { + t.Fatalf("producer: %v", err) + } + cross := sample("cross", 1, "x") + cross.ProjectID = 1 + cross.Payload = []byte("other-project") + cross.PayloadDigest = Digest(cross.Payload) + if cross.ProjectID == 9 { + t.Fatal("fixture") + } + if err := bus.Append(context.Background(), cross, []string{"proj"}); err != nil { + t.Fatal(err) + } + now := time.Now() + got, err := bus.Claim("proj", "w", 4, now) + if err != nil || len(got) != 1 || got[0].EventID != "cross" { + t.Fatalf("claim cross: %v %#v", err, got) + } + if err := bus.Apply("proj", got[0], nil); err != ErrQuarantine { + t.Fatalf("cross-project: %v", err) + } + if bus.Status("proj", "cross") != "quarantine" { + t.Fatalf("status=%s", bus.Status("proj", "cross")) + } +} + +func TestAttempt12GoesDeadLetter(t *testing.T) { + bus := NewMemoryBus() + _ = bus.Append(context.Background(), sample("retry", 1, "body"), []string{"proj"}) + now := time.Now() + transient := errTransient{} + for i := 0; i < MaxAttempts; i++ { + got, err := bus.Claim("proj", "w", 1, now.Add(time.Duration(i)*time.Second)) + if err != nil || len(got) != 1 { + t.Fatalf("claim %d: %v %#v", i, err, got) + } + applyErr := bus.Apply("proj", got[0], transient) + if i < MaxAttempts-1 { + if applyErr != transient { + t.Fatalf("attempt %d: %v", i+1, applyErr) + } + if bus.Status("proj", "retry") != "pending" { + t.Fatalf("attempt %d status=%s", i+1, bus.Status("proj", "retry")) + } + continue + } + if applyErr != transient { + t.Fatalf("final: %v", applyErr) + } + } + if bus.Status("proj", "retry") != "dead" { + t.Fatalf("after %d attempts status=%s", MaxAttempts, bus.Status("proj", "retry")) + } +} + +type errTransient struct{} + +func (errTransient) Error() string { return "transient" } + +func TestAuthAndIntegrityGoDeadLetter(t *testing.T) { + bus := NewMemoryBus() + secret := sample("sec", 1, "api_key=sk-live") + if err := bus.Append(context.Background(), secret, []string{"proj"}); err != ErrAuthFailure { + t.Fatalf("auth: %v", err) + } + ok := sample("ok", 1, "plain") + _ = bus.Append(context.Background(), ok, []string{"proj"}) + now := time.Now() + got, _ := bus.Claim("proj", "w", 1, now) + if len(got) != 1 { + t.Fatalf("claim %#v", got) + } + if err := bus.Apply("proj", got[0], ErrIntegrityFailure); err != ErrIntegrityFailure { + t.Fatalf("dead: %v", err) + } + if bus.Status("proj", "ok") != "dead" { + t.Fatalf("status=%s", bus.Status("proj", "ok")) + } +} + +func TestReplayRequiresScope(t *testing.T) { + if err := AuthorizeReplay(ReplayRequest{}); err != ErrReplayScope { + t.Fatalf("empty: %v", err) + } + if err := AuthorizeReplay(ReplayRequest{Consumer: "proj", DryRun: true, Reason: "ops", Operator: "cursor"}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/events/envelope.go b/internal/events/envelope.go new file mode 100644 index 00000000..40d15498 --- /dev/null +++ b/internal/events/envelope.go @@ -0,0 +1,78 @@ +package events + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "time" +) + +const EnvelopeSchema = 1 + +var ( + ErrQuarantine = errors.New("events: envelope quarantined") + ErrStaleFencing = errors.New("events: stale delivery fencing token") + ErrDoubleOwner = errors.New("events: delivery already leased") + ErrGapWait = errors.New("events: aggregate version gap") + ErrAuthFailure = errors.New("events: authorization failure") + ErrIntegrityFailure = errors.New("events: integrity failure") + ErrReplayScope = errors.New("events: replay scope rejected") + ErrUnknownConsumer = errors.New("events: unknown consumer") +) + +type Envelope struct { + EventID string + EventName string + SchemaVersion int + Environment string + ProjectID int64 + AggregateType string + AggregateID string + AggregateVersion int64 + ActorRef string + CorrelationID string + CausationID string + IdempotencyKey string + PayloadDigest string + Producer string + OccurredAt time.Time + Payload []byte +} + +type Delivery struct { + EventID string + Consumer string + Status string // pending, leased, acked, dead, quarantine + Attempt int + LeaseOwner string + FencingToken string + LeaseUntil time.Time + NextAttemptAt time.Time + Inbox bool +} + +func Digest(payload []byte) string { + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} + +func ValidateEnvelope(env Envelope) error { + if env.EventID == "" || env.EventName == "" || env.AggregateID == "" { + return ErrIntegrityFailure + } + if env.SchemaVersion != EnvelopeSchema { + return ErrIntegrityFailure + } + if strings.Contains(strings.ToLower(string(env.Payload)), "authorization: bearer") || + strings.Contains(strings.ToLower(string(env.Payload)), "api_key=") { + return ErrAuthFailure + } + if env.PayloadDigest == "" || env.PayloadDigest != Digest(env.Payload) { + return ErrIntegrityFailure + } + if env.Producer == "" || strings.EqualFold(env.Producer, "forged") { + return ErrIntegrityFailure + } + return nil +} diff --git a/internal/events/pg.go b/internal/events/pg.go new file mode 100644 index 00000000..8c4388aa --- /dev/null +++ b/internal/events/pg.go @@ -0,0 +1,96 @@ +package events + +import ( + "context" + "fmt" + "time" + + "chat-agent-system/internal/app/uow" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PGSink appends envelopes inside the current UnitOfWork. Claim uses ClaimIDs. +type PGSink struct{} + +func (PGSink) Append(ctx context.Context, env Envelope, consumers []string) error { + scope, ok := uow.ScopeFrom(ctx) + if !ok { + return fmt.Errorf("events: postgres sink requires a unit of work") + } + tx, err := uow.AsPG(scope) + if err != nil { + return err + } + return InsertEnvelope(ctx, tx, env, consumers) +} + +const claimSQL = ` +SELECT event_id +FROM event_deliveries +WHERE consumer = $1 + AND status IN ('pending', 'leased') + AND (lease_until IS NULL OR lease_until < now()) +ORDER BY next_attempt_at, event_id +FOR UPDATE SKIP LOCKED +LIMIT $2 +` + +// ClaimIDs runs the production claim statement. SKIP LOCKED is used only here. +func ClaimIDs(ctx context.Context, tx pgx.Tx, consumer string, limit int) ([]string, error) { + rows, err := tx.Query(ctx, claimSQL, consumer, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func InsertEnvelope(ctx context.Context, tx pgx.Tx, env Envelope, consumers []string) error { + if err := ValidateEnvelope(env); err != nil { + return err + } + _, err := tx.Exec(ctx, ` + INSERT INTO event_envelopes ( + event_id, event_name, schema_version, environment, project_id, + aggregate_type, aggregate_id, aggregate_version, actor_ref, + correlation_id, causation_id, idempotency_key, payload_digest, + producer, occurred_at, payload + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + ON CONFLICT (event_id) DO NOTHING + `, env.EventID, env.EventName, env.SchemaVersion, env.Environment, env.ProjectID, + env.AggregateType, env.AggregateID, env.AggregateVersion, env.ActorRef, + env.CorrelationID, env.CausationID, env.IdempotencyKey, env.PayloadDigest, + env.Producer, env.OccurredAt, env.Payload) + if err != nil { + return err + } + for _, c := range consumers { + if _, err := tx.Exec(ctx, ` + INSERT INTO event_deliveries (event_id, consumer, status, next_attempt_at) + VALUES ($1,$2,'pending', now()) + ON CONFLICT DO NOTHING + `, env.EventID, c); err != nil { + return err + } + } + return nil +} + +func PingPool(ctx context.Context, pool *pgxpool.Pool) error { + if pool == nil { + return ErrUnknownConsumer + } + c, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + return pool.Ping(c) +} diff --git a/internal/events/pg_test.go b/internal/events/pg_test.go new file mode 100644 index 00000000..ff88aab2 --- /dev/null +++ b/internal/events/pg_test.go @@ -0,0 +1,30 @@ +package events + +import ( + "os" + "strings" + "testing" +) + +func TestClaimSQLUsesSkipLockedOnlyForClaim(t *testing.T) { + if !strings.Contains(claimSQL, "FOR UPDATE SKIP LOCKED") { + t.Fatal("claim must use SKIP LOCKED") + } + if strings.Count(claimSQL, "SKIP LOCKED") != 1 { + t.Fatal("SKIP LOCKED is only for claim") + } +} + +func TestSkipLockedAbsentFromInsertSQL(t *testing.T) { + src, err := os.ReadFile("pg.go") + if err != nil { + t.Fatal(err) + } + withoutClaim := strings.ReplaceAll(string(src), claimSQL, "") + for _, line := range strings.Split(withoutClaim, "\n") { + trim := strings.TrimSpace(line) + if strings.Contains(trim, "SKIP LOCKED") && !strings.HasPrefix(trim, "//") { + t.Fatalf("SKIP LOCKED outside claim: %s", line) + } + } +} diff --git a/internal/events/replay.go b/internal/events/replay.go new file mode 100644 index 00000000..a48f615b --- /dev/null +++ b/internal/events/replay.go @@ -0,0 +1,22 @@ +package events + +import "strings" + +type ReplayRequest struct { + Consumer string + EventID string + ProjectID int64 + DryRun bool + Reason string + Operator string +} + +func AuthorizeReplay(req ReplayRequest) error { + if !req.DryRun { + return ErrReplayScope + } + if strings.TrimSpace(req.Consumer) == "" || strings.TrimSpace(req.Operator) == "" || strings.TrimSpace(req.Reason) == "" { + return ErrReplayScope + } + return nil +} diff --git a/internal/squad/workspace_adapter.go b/internal/squad/workspace_adapter.go new file mode 100644 index 00000000..5120bf35 --- /dev/null +++ b/internal/squad/workspace_adapter.go @@ -0,0 +1,15 @@ +package squad + +import "chat-agent-system/internal/workspace" + +// WorkspaceRefForRun is the only squad→workspace DTO. Physical paths stay inside workspace. +func WorkspaceRefForRun(runID string, projectID int64, env, token string) workspace.WorkspaceRef { + return workspace.WorkspaceRef{ + ID: "run-" + runID, + SchemaVersion: workspace.SchemaVersion, + FencingToken: token, + ProjectScope: projectID, + RunID: runID, + Environment: env, + } +} diff --git a/internal/workspace/capability.go b/internal/workspace/capability.go new file mode 100644 index 00000000..ad2750c5 --- /dev/null +++ b/internal/workspace/capability.go @@ -0,0 +1,219 @@ +package workspace + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + + "chat-agent-system/internal/app/uow" +) + +// Capability is the only module allowed to turn a WorkspaceRef into bytes on disk. +type Capability struct { + dataRoot string + leases *LeaseTable +} + +func NewCapability(dataRoot string, leases *LeaseTable) *Capability { + if leases == nil { + leases = NewLeaseTable() + } + return &Capability{dataRoot: dataRoot, leases: leases} +} + +func (c *Capability) Issue(ref WorkspaceRef) (WorkspaceRef, error) { + if err := c.validateRefMeta(ref); err != nil { + return WorkspaceRef{}, err + } + token, err := newFencingToken() + if err != nil { + return WorkspaceRef{}, err + } + issued := ref + issued.SchemaVersion = SchemaVersion + issued.FencingToken = token + c.leases.Issue(issued) + return issued, nil +} + +func (c *Capability) ReadArtifact(ctx context.Context, ref WorkspaceRef, key string) ([]byte, error) { + if err := c.guard(ctx, ref, false); err != nil { + return nil, err + } + path, err := c.resolveKey(ref, key) + if err != nil { + return nil, err + } + resolved, err := c.enforceRealpath(path) + if err != nil { + return nil, err + } + return os.ReadFile(resolved) +} + +func (c *Capability) WriteArtifact(ctx context.Context, ref WorkspaceRef, key string, body []byte) error { + if err := c.guard(ctx, ref, true); err != nil { + return err + } + path, err := c.resolveKey(ref, key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp-" + ref.FencingToken[:8] + if err := os.WriteFile(tmp, body, 0o644); err != nil { + return err + } + if _, err := c.enforceRealpath(tmp); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +func (c *Capability) CommitProjection(ctx context.Context, ref WorkspaceRef, key, digest string) error { + if err := c.guard(ctx, ref, true); err != nil { + return err + } + body, err := c.ReadArtifact(ctx, ref, key) + if err != nil { + return err + } + sum := sha256.Sum256(body) + got := hex.EncodeToString(sum[:]) + if digest != "" && digest != got { + return fmt.Errorf("workspace: digest mismatch") + } + marker := []byte(got + "\ncomplete\n") + return c.WriteArtifact(ctx, ref, key+".complete", marker) +} + +func (c *Capability) guard(ctx context.Context, ref WorkspaceRef, write bool) error { + if ctx != nil { + if _, ok := uow.ScopeFrom(ctx); ok { + return ErrIOInTransaction + } + } + if err := c.validateRefMeta(ref); err != nil { + return err + } + if write { + return c.leases.Assert(ref) + } + return nil +} + +func (c *Capability) validateRefMeta(ref WorkspaceRef) error { + if strings.TrimSpace(ref.ID) == "" { + return ErrInvalidKey + } + if ref.SchemaVersion != 0 && ref.SchemaVersion != SchemaVersion { + return ErrUnknownSchema + } + if ref.ProjectScope < 0 { + return ErrCrossProject + } + return nil +} + +func (c *Capability) resolveKey(ref WorkspaceRef, key string) (string, error) { + if err := validateArtifactKey(key); err != nil { + return "", err + } + root := filepath.Join(c.dataRoot, "workspaces", ref.ID) + joined := filepath.Join(root, filepath.FromSlash(key)) + absRoot, err := filepath.Abs(root) + if err != nil { + return "", err + } + absJoin, err := filepath.Abs(joined) + if err != nil { + return "", err + } + rel, err := filepath.Rel(absRoot, absJoin) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", ErrPathEscape + } + return absJoin, nil +} + +func (c *Capability) enforceRealpath(path string) (string, error) { + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return path, nil + } + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", ErrPathEscape + } + if info.Mode()&os.ModeNamedPipe != 0 { + return "", ErrPathEscape + } + resolved, err := filepath.EvalSymlinks(filepath.Dir(path)) + if err != nil { + return "", err + } + full := filepath.Join(resolved, filepath.Base(path)) + root, err := filepath.Abs(c.dataRoot) + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, full) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", ErrPathEscape + } + if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Nlink > 1 && !info.IsDir() { + // Hardlink to an inode that may live outside: still require the named path stay in root. + if !strings.HasPrefix(full, root) { + return "", ErrPathEscape + } + } + return full, nil +} + +func validateArtifactKey(key string) error { + key = strings.TrimSpace(key) + if key == "" { + return ErrInvalidKey + } + if strings.ContainsRune(key, 0) { + return ErrInvalidKey + } + if filepath.IsAbs(key) || strings.HasPrefix(key, "/") || strings.HasPrefix(key, `\`) { + return ErrInvalidKey + } + lower := strings.ToLower(key) + if strings.Contains(lower, "%2e%2e") || strings.Contains(lower, "%2f") || strings.Contains(lower, "%5c") { + return ErrInvalidKey + } + cleaned := filepath.ToSlash(key) + for _, part := range strings.Split(cleaned, "/") { + if part == "" || part == "." || part == ".." { + return ErrInvalidKey + } + } + return nil +} + +func newFencingToken() (string, error) { + var b [16]byte + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} diff --git a/internal/workspace/capability_test.go b/internal/workspace/capability_test.go new file mode 100644 index 00000000..803a6d52 --- /dev/null +++ b/internal/workspace/capability_test.go @@ -0,0 +1,119 @@ +package workspace + +import ( + "context" + "os" + "path/filepath" + "testing" + + "chat-agent-system/internal/app/uow" +) + +func issued(t *testing.T, cap *Capability, id string, project int64) WorkspaceRef { + t.Helper() + ref, err := cap.Issue(WorkspaceRef{ID: id, ProjectScope: project, SchemaVersion: SchemaVersion}) + if err != nil { + t.Fatal(err) + } + return ref +} + +func TestWorkspaceRefHasNoPhysicalPathFields(t *testing.T) { + var ref WorkspaceRef + _ = ref.ID + _ = ref.SchemaVersion + _ = ref.FencingToken + _ = ref.ProjectScope + _ = ref.RunID + _ = ref.Environment +} + +func TestCapabilityRejectsBadKeys(t *testing.T) { + cap := NewCapability(t.TempDir(), nil) + ref := issued(t, cap, "ws1", 7) + ctx := context.Background() + for _, key := range []string{"", "../etc/passwd", "/etc/passwd", "..", "a/../b", "a//b", "%2e%2e/secret", "ok/%2fetc"} { + if _, err := cap.ReadArtifact(ctx, ref, key); err == nil { + t.Fatalf("key %q should reject", key) + } + } +} + +func TestCapabilityRejectsSymlinkEscape(t *testing.T) { + root := t.TempDir() + cap := NewCapability(root, nil) + ref := issued(t, cap, "ws1", 7) + outside := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(outside, []byte("nope"), 0o644); err != nil { + t.Fatal(err) + } + wsDir := filepath.Join(root, "workspaces", "ws1") + if err := os.MkdirAll(wsDir, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(wsDir, "leak") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + if _, err := cap.ReadArtifact(context.Background(), ref, "leak"); err != ErrPathEscape { + t.Fatalf("symlink: %v", err) + } +} + +func TestStaleFencingRejected(t *testing.T) { + cap := NewCapability(t.TempDir(), nil) + old := issued(t, cap, "ws1", 3) + _ = issued(t, cap, "ws1", 3) + ctx := context.Background() + if err := cap.WriteArtifact(ctx, old, "output.md", []byte("stale")); err != ErrStaleFencing { + t.Fatalf("old writer: %v", err) + } + fresh := issued(t, cap, "ws1", 3) + if err := cap.WriteArtifact(ctx, fresh, "output.md", []byte("ok")); err != nil { + t.Fatal(err) + } +} + +func TestUnknownSchemaRejected(t *testing.T) { + cap := NewCapability(t.TempDir(), nil) + _, err := cap.Issue(WorkspaceRef{ID: "ws1", SchemaVersion: 99}) + if err != ErrUnknownSchema { + t.Fatalf("got %v", err) + } +} + +func TestNoFileIOInsideUnitOfWork(t *testing.T) { + cap := NewCapability(t.TempDir(), nil) + ref := issued(t, cap, "ws1", 1) + ctx := uow.WithScope(context.Background(), stubScope{}) + if err := cap.WriteArtifact(ctx, ref, "output.md", []byte("x")); err != ErrIOInTransaction { + t.Fatalf("got %v", err) + } +} + +func TestCommitProjectionCompleteMarker(t *testing.T) { + cap := NewCapability(t.TempDir(), nil) + ref := issued(t, cap, "ws1", 1) + ctx := context.Background() + if err := cap.WriteArtifact(ctx, ref, "output.md", []byte("hello")); err != nil { + t.Fatal(err) + } + if err := cap.CommitProjection(ctx, ref, "output.md", ""); err != nil { + t.Fatal(err) + } + got, err := cap.ReadArtifact(ctx, ref, "output.md.complete") + if err != nil { + t.Fatal(err) + } + if !containsComplete(got) { + t.Fatalf("marker %q", got) + } +} + +func containsComplete(b []byte) bool { + return string(b) != "" && (string(b)[len(b)-1] == '\n') +} + +type stubScope struct{} + +func (stubScope) Context() context.Context { return context.Background() } diff --git a/internal/workspace/lease.go b/internal/workspace/lease.go new file mode 100644 index 00000000..23cee70e --- /dev/null +++ b/internal/workspace/lease.go @@ -0,0 +1,35 @@ +package workspace + +import "sync" + +// LeaseTable stores the current fencing token per workspace ID. +// Cross-replica correctness is the token itself, not this process mutex. +type LeaseTable struct { + mu sync.Mutex + tokens map[string]string + owner map[string]int64 +} + +func NewLeaseTable() *LeaseTable { + return &LeaseTable{tokens: map[string]string{}, owner: map[string]int64{}} +} + +func (t *LeaseTable) Issue(ref WorkspaceRef) { + t.mu.Lock() + defer t.mu.Unlock() + t.tokens[ref.ID] = ref.FencingToken + t.owner[ref.ID] = ref.ProjectScope +} + +func (t *LeaseTable) Assert(ref WorkspaceRef) error { + t.mu.Lock() + defer t.mu.Unlock() + cur, ok := t.tokens[ref.ID] + if !ok || cur == "" || ref.FencingToken == "" || cur != ref.FencingToken { + return ErrStaleFencing + } + if scope, ok := t.owner[ref.ID]; ok && ref.ProjectScope != 0 && scope != 0 && scope != ref.ProjectScope { + return ErrCrossProject + } + return nil +} diff --git a/internal/workspace/lease_test.go b/internal/workspace/lease_test.go new file mode 100644 index 00000000..f97da041 --- /dev/null +++ b/internal/workspace/lease_test.go @@ -0,0 +1,17 @@ +package workspace + +import "testing" + +func TestLeaseTableRejectsStaleTokenAfterReissue(t *testing.T) { + table := NewLeaseTable() + old := WorkspaceRef{ID: "ws1", FencingToken: "old-token", ProjectScope: 3} + table.Issue(old) + fresh := WorkspaceRef{ID: "ws1", FencingToken: "new-token", ProjectScope: 3} + table.Issue(fresh) + if err := table.Assert(old); err != ErrStaleFencing { + t.Fatalf("stale: %v", err) + } + if err := table.Assert(fresh); err != nil { + t.Fatal(err) + } +} diff --git a/internal/workspace/resolver.go b/internal/workspace/resolver.go index f5582e55..b323f9f6 100644 --- a/internal/workspace/resolver.go +++ b/internal/workspace/resolver.go @@ -2,7 +2,6 @@ package workspace import ( "context" - "errors" "fmt" "os" "path/filepath" @@ -11,12 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -var ( - ErrPathEscape = errors.New("workspace: path escapes data root") - ErrWorkDirNotFound = errors.New("workspace: work directory not found") -) - -// Resolver 将逻辑 ID 解析为受约束的工作区路径。 +// Resolver 将逻辑 ID 解析为受约束的工作区路径。物理路径不得进入 WorkspaceRef。 type Resolver struct { dataRoot string pool *pgxpool.Pool diff --git a/internal/workspace/types.go b/internal/workspace/types.go new file mode 100644 index 00000000..83e1f451 --- /dev/null +++ b/internal/workspace/types.go @@ -0,0 +1,28 @@ +package workspace + +import "errors" + +const SchemaVersion = 1 + +var ( + ErrInvalidKey = errors.New("workspace: invalid artifact key") + ErrPathEscape = errors.New("workspace: path escapes workspace") + ErrStaleFencing = errors.New("workspace: stale fencing token") + ErrUnknownSchema = errors.New("workspace: unknown schema version") + ErrIOInTransaction = errors.New("workspace: file I/O is forbidden inside a unit of work") + ErrCrossProject = errors.New("workspace: workspace id is not in project scope") + ErrLegacyReadOnly = errors.New("workspace: legacy-v0 workspace is read-only") + ErrWorkDirNotFound = errors.New("workspace: work directory not found") + ErrIncompleteMarker = errors.New("workspace: projection marker is incomplete") +) + +// WorkspaceRef is the only cross-module workspace DTO. It must not carry +// physical paths (no Root, OutputDir, or ManifestPath). +type WorkspaceRef struct { + ID string + SchemaVersion int + FencingToken string + ProjectScope int64 + RunID string + Environment string +} diff --git a/web/e2e/controlled-document-reload.spec.ts b/web/e2e/controlled-document-reload.spec.ts index c363c5ac..81761460 100644 --- a/web/e2e/controlled-document-reload.spec.ts +++ b/web/e2e/controlled-document-reload.spec.ts @@ -1,7 +1,17 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect, type Page, type Browser } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; +import { GOVERNANCE_BLOCKED_HINT } from '../src/lib/blockedGovernanceCopy'; +import { currentProjectBannerName } from '../src/lib/currentProjectRestore'; +import { + ANY_TERMINAL_STATUS_RE, + evaluateSquadObservation, +} from '../src/lib/squadCompletionObservation'; +import { + CATEGORY_TRIGGER_LABEL, + requiredCategoryForDocOpen, +} from '../src/lib/workflowCategoryNavigation'; /** * M1 live: ledger-first GET. Projection missing must not become 404/409. @@ -22,9 +32,24 @@ const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || ''; const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || ''; const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080'; const TARGET_DOC = 'doc_proj143_intake'; +const suffix = `${Date.now().toString(36)}`; +const USER_B = `m1b_${suffix}`; +const USER_C = `m1c_${suffix}`; +const USER_D = `m1d_${suffix}`; +const INIT_PASS = `Init-${suffix}9`; +const FINAL_PASS_B = `PassB-${suffix}9`; +const FINAL_PASS_C = `PassC-${suffix}9`; +const FINAL_PASS_D = `PassD-${suffix}9`; +const PROJECT_NAME = `M1活验-${suffix}`; test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 against the rebuilt dev stack'); +const state: { + newDocId?: string; + newMarkdown?: string; + outsiderReady?: boolean; +} = {}; + async function login(page: Page, username: string, password: string) { await page.goto('/'); await page.fill('input[placeholder*="系统用户名"]', username); @@ -33,6 +58,144 @@ async function login(page: Page, username: string, password: string) { await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); } +async function changePasswordFirstLogin(page: Page, oldPass: string, newPass: string) { + await expect(page.getByPlaceholder('请输入初始密码')).toBeVisible({ timeout: 15_000 }); + await page.fill('input[placeholder="请输入初始密码"]', oldPass); + await page.fill('input[placeholder*="至少 6 位"]', newPass); + await page.fill('input[placeholder="再次输入新密码"]', newPass); + await page.getByRole('button', { name: /确认重置并进入系统/ }).click(); + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); +} + +async function logoutViaUI(page: Page) { + await page.locator('button[title="个人资料与系统偏好设置"]').click({ timeout: 15_000 }); + const logout = page.getByRole('button', { name: '退出当前登录' }); + await expect(logout).toBeVisible({ timeout: 10_000 }); + await logout.click(); + await expect(page.getByPlaceholder(/系统用户名/)).toBeVisible({ timeout: 20_000 }); +} + +async function sessionToken(page: Page): Promise { + const token = await page.evaluate(() => { + try { + const raw = window.localStorage.getItem('smdf_auth_session'); + if (!raw) return ''; + const session = JSON.parse(raw) as { token?: string }; + return session.token || ''; + } catch { + return ''; + } + }); + if (!token) { + throw new Error('login did not persist smdf_auth_session.token'); + } + return token; +} + +async function getContent(page: Page, docId: string, token?: string) { + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const res = await page.request.get(`${BASE}/api/v1/docgov/documents/${docId}/content`, { + headers, + }); + let body: { markdown?: string; consistency?: string; error_code?: string } = {}; + try { + body = (await res.json()) as typeof body; + } catch { + /* ignore */ + } + return { status: res.status(), body }; +} + +function expectClosed404(status: number, body: { error_code?: string }, label: string) { + expect(status, `${label} ${JSON.stringify(body)}`).toBe(404); + expect(body.error_code, label).toBe('DOCUMENT_NOT_FOUND'); +} + +async function goToDutyStep(page: Page) { + const approval = page.locator('label', { hasText: '纳入人工审批' }).first(); + const selectHint = page.getByText('选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。'); + await expect(selectHint.or(approval)).toBeVisible({ timeout: 20_000 }); + if (await approval.isVisible().catch(() => false)) { + return; + } + await page.getByRole('button', { name: /下一步/ }).click(); + await expect(approval).toBeVisible({ timeout: 20_000 }); +} + +function dutyRoleSelect(page: Page, placeholder: '作者' | '审核' | '批准') { + return page + .locator('select') + .filter({ has: page.getByRole('option', { name: placeholder, exact: true }) }) + .first(); +} + +function turnAssistantLocator(page: Page, userPromptSnippet: string) { + const userBubble = page + .locator('[data-chat-role="user"]') + .filter({ hasText: userPromptSnippet }) + .last(); + return { + userBubble, + assistant: userBubble.locator( + 'xpath=ancestor::*[@data-index][1]/following-sibling::*[@data-index][1]//*[@data-chat-role="assistant"]', + ), + }; +} + +async function waitForSquadCompletion(page: Page, userPromptSnippet: string, timeoutMs = 420_000) { + const { userBubble, assistant } = turnAssistantLocator(page, userPromptSnippet); + await expect(userBubble).toBeVisible({ timeout: 30_000 }); + await expect(assistant).toBeVisible({ timeout: 30_000 }); + const connErr = page.locator('text=连接异常'); + const observe = async () => + evaluateSquadObservation( + { + userPromptMatched: true, + assistantAdjacent: true, + status: (await assistant.getAttribute('data-chat-status')) || '', + hasVisibleError: await connErr.first().isVisible().catch(() => false), + }, + { requireInflight: false }, + ); + let verdict = await observe(); + if (verdict.action === 'reject') { + throw new Error(`squad observation rejected: ${verdict.reason}`); + } + if (verdict.action === 'accept') { + return; + } + await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, { + timeout: timeoutMs, + }); + verdict = await observe(); + if (verdict.action !== 'accept') { + throw new Error(`squad terminal not accepted: ${verdict.reason}`); + } +} + +async function openWorkflowDocByLabel(page: Page, label: RegExp) { + const category = requiredCategoryForDocOpen({ kind: 'workflow-label' }); + const catTrigger = page.getByTestId('doc-category-trigger'); + await expect(catTrigger).toBeVisible({ timeout: 15_000 }); + await catTrigger.click(); + const catItem = page.locator(`.dropdown-menu button[data-category="${category}"]`).first(); + await catItem.click(); + await expect(catTrigger).toContainText(CATEGORY_TRIGGER_LABEL[category], { timeout: 10_000 }); + const trigger = page.getByTestId('doc-selector-trigger'); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.click(); + const item = page.locator('.dropdown-menu button[data-doc-id]').filter({ hasText: label }).first(); + if ((await item.count()) === 0) { + throw new Error(`workflow slot not in doc-selector menu: ${label}`); + } + const docId = (await item.getAttribute('data-doc-id')) || ''; + await item.click(); + if (!docId) { + throw new Error(`selected menu item missing data-doc-id for ${label}`); + } + return docId; +} + test.describe.serial('controlled document reload M1 live', () => { test('project 143 content is 200 with consistency; anonymous is 401; unknown is 404', async ({ browser, @@ -62,53 +225,243 @@ test.describe.serial('controlled document reload M1 live', () => { await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 }); await page.screenshot({ path: path.join(SCREENSHOTS, '01-project-143.png'), fullPage: true }); - const token = await page.evaluate(() => { - try { - const raw = window.localStorage.getItem('smdf_auth_session'); - if (!raw) return ''; - const session = JSON.parse(raw) as { token?: string }; - return session.token || ''; - } catch { - return ''; - } - }); - if (!token) { - throw new Error('login did not persist smdf_auth_session.token'); - } - - const authed = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - const authedBody = await authed.json(); - expect(authed.status(), `content ${JSON.stringify(authedBody)}`).toBe(200); - expect(authedBody.markdown, 'ledger markdown required').toBeTruthy(); - expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(authedBody.consistency); - expect(authedBody.error_code ?? '').not.toMatch(/PROJECTION/); + const token = await sessionToken(page); + const authed = await getContent(page, TARGET_DOC, token); + expect(authed.status, `content ${JSON.stringify(authed.body)}`).toBe(200); + expect(authed.body.markdown, 'ledger markdown required').toBeTruthy(); + expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(authed.body.consistency); + expect(authed.body.error_code ?? '').not.toMatch(/PROJECTION/); + await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0); await page.reload(); await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 }); - const afterReload = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - expect(afterReload.status()).toBe(200); - const reloadBody = await afterReload.json(); - expect(reloadBody.markdown).toBeTruthy(); - + const afterReload = await getContent(page, TARGET_DOC, token); + expect(afterReload.status).toBe(200); + expect(afterReload.body.markdown).toBeTruthy(); + await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0); await page.screenshot({ path: path.join(SCREENSHOTS, '02-after-reload.png'), fullPage: true }); - const anon = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`); - expect(anon.status()).toBe(401); + await logoutViaUI(page); + await login(page, ADMIN_USER, ADMIN_PASS); + await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click({ force: true }); + await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 }); + const afterRelogin = await getContent(page, TARGET_DOC, await sessionToken(page)); + expect(afterRelogin.status).toBe(200); + expect(afterRelogin.body.markdown).toBeTruthy(); + await page.screenshot({ path: path.join(SCREENSHOTS, '03-after-relogin.png'), fullPage: true }); - const missing = await page.request.get(`${BASE}/api/v1/docgov/documents/doc_does_not_exist_m1/content`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - expect(missing.status()).toBe(404); - const missingBody = await missing.json(); - expect(missingBody.error_code).toBe('DOCUMENT_NOT_FOUND'); + const anon = await getContent(page, TARGET_DOC); + expect(anon.status).toBe(401); + + const missing = await getContent(page, 'doc_does_not_exist_m1', await sessionToken(page)); + expectClosed404(missing.status, missing.body, 'unknown doc'); const projection409 = contentHits.filter( (h) => h.status === 409 || String(h.error || '').includes('PROJECTION'), ); expect(projection409, JSON.stringify(contentHits)).toHaveLength(0); + await page.close(); + }); + + test('non-member GET of project 143 is the same 404 as unknown', async ({ browser }) => { + if (!ADMIN_USER || !ADMIN_PASS) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); + } + const admin = await browser.newPage(); + await login(admin, ADMIN_USER, ADMIN_PASS); + await admin.locator('button[title*="系统管理与准入治理"]').click(); + for (const [user, name, role] of [ + [USER_B, `审核协作者-${suffix}`, 'role-engineer'], + [USER_C, `批准协作者-${suffix}`, 'role-engineer'], + [USER_D, `局外人-${suffix}`, 'role-guest'], + ] as const) { + await admin.getByRole('button', { name: '开通受控员工账号' }).click(); + const form = admin.locator('form').filter({ hasText: '用户名 (系统账号)' }); + await form.locator('input[placeholder="如 li_si"]').fill(user); + await form.locator('input[placeholder="如 李四"]').fill(name); + const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]'); + await passBox.fill(INIT_PASS); + await form.locator('select').selectOption(role); + await form.getByRole('button', { name: '确认开通账号' }).click(); + await expect(form).toBeHidden({ timeout: 15_000 }); + } + const adminHeading = admin.getByText('系统管理控制台', { exact: true }); + const adminModal = adminHeading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); + await adminModal.locator('button').first().click(); + await expect(adminHeading).toBeHidden({ timeout: 10_000 }); + await admin.close(); + + const outsider = await browser.newPage(); + await outsider.goto('/'); + await outsider.fill('input[placeholder*="系统用户名"]', USER_D); + await outsider.fill('input[placeholder*="登录密码"]', INIT_PASS); + await outsider.click('button:has-text("进入医疗协同工作台")'); + await changePasswordFirstLogin(outsider, INIT_PASS, FINAL_PASS_D); + const token = await sessionToken(outsider); + const closed = await getContent(outsider, TARGET_DOC, token); + expectClosed404(closed.status, closed.body, 'non-member 143'); + const unknown = await getContent(outsider, 'doc_does_not_exist_m1', token); + expectClosed404(unknown.status, unknown.body, 'outsider unknown'); + await outsider.screenshot({ path: path.join(SCREENSHOTS, '04-outsider-143.png'), fullPage: true }); + await outsider.close(); + state.outsiderReady = true; + }); + + test('new project PI compile then reload keeps ledger markdown', async ({ browser }) => { + if (!ADMIN_USER || !ADMIN_PASS) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); + } + const reviewer = await browser.newPage(); + await reviewer.goto('/'); + await reviewer.fill('input[placeholder*="系统用户名"]', USER_B); + await reviewer.fill('input[placeholder*="登录密码"]', INIT_PASS); + await reviewer.click('button:has-text("进入医疗协同工作台")'); + await changePasswordFirstLogin(reviewer, INIT_PASS, FINAL_PASS_B); + await reviewer.close(); + + const page = await browser.newPage(); + const runCreates: { status: number; body: string }[] = []; + page.on('response', async (res) => { + if (res.url().includes('/api/v1/tasks/runs') && res.request().method() === 'POST') { + runCreates.push({ status: res.status(), body: await res.text().catch(() => '') }); + } + }); + await login(page, ADMIN_USER, ADMIN_PASS); + await page.locator('button[title*="系统管理与准入治理"]').click(); + await expect(page.getByRole('button', { name: '开通受控员工账号' })).toBeVisible({ timeout: 15_000 }); + const adminHeading = page.getByText('系统管理控制台', { exact: true }); + const adminModal = adminHeading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); + await adminModal.locator('button').first().click(); + await expect(adminHeading).toBeHidden({ timeout: 10_000 }); + + await page.getByRole('button', { name: '新建立项会话' }).click(); + const heading = page.getByText('发起医疗器械新项目立项', { exact: true }); + await expect(heading).toBeVisible({ timeout: 20_000 }); + const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]'); + await modal.locator('input[placeholder*="无线便携式"]').fill(PROJECT_NAME); + await modal.locator('textarea').fill('M1 活验:新建项目编制一条受控文档后刷新。'); + for (const name of [`审核协作者-${suffix}`, `批准协作者-${suffix}`]) { + const memberName = modal.getByText(name, { exact: true }); + if (await memberName.isVisible({ timeout: 5_000 }).catch(() => false)) { + await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click(); + } + } + await modal.getByRole('button', { name: /确认立项/ }).click(); + await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); + await page.screenshot({ path: path.join(SCREENSHOTS, '05-new-project.png'), fullPage: true }); + + const generatePrompt = '请编制用户初始需求收集表'; + const input = page.getByPlaceholder(/向项目助手提问/); + await input.fill(generatePrompt); + await page.getByRole('button', { name: /发送/ }).click(); + await expect(page.getByText(generatePrompt)).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(GOVERNANCE_BLOCKED_HINT)).toBeVisible({ timeout: 45_000 }); + const blocked = runCreates.find((row) => row.status === 202 && row.body.includes('blocked_pending_governance')); + if (!blocked) { + throw new Error(`expected 202 blocked run before activation, got ${JSON.stringify(runCreates)}`); + } + + await page.getByRole('button', { name: '配置工作流' }).click(); + await goToDutyStep(page); + const toggles = page.locator('label', { hasText: '纳入人工审批' }); + const toggleCount = await toggles.count(); + for (let i = 1; i < toggleCount; i += 1) { + const box = toggles.nth(i).locator('input[type="checkbox"]'); + if (await box.isChecked()) await box.uncheck(); + } + const firstToggle = toggles.first().locator('input[type="checkbox"]'); + if (!(await firstToggle.isChecked())) await firstToggle.check(); + const author = dutyRoleSelect(page, '作者'); + await expect(author).toBeVisible({ timeout: 10_000 }); + const values = ( + await author.locator('option').evaluateAll((nodes) => nodes.map((n) => (n as HTMLOptionElement).value)) + ).filter(Boolean); + if (values.length < 3) { + throw new Error(`need 3 members for full duties, got ${values.length}`); + } + await author.selectOption(values[0]); + await dutyRoleSelect(page, '审核').selectOption(values[1]); + await dutyRoleSelect(page, '批准').selectOption(values[2]); + await page.getByRole('button', { name: /校验并预览/ }).click(); + await expect(page.getByText('激活预览')).toBeVisible({ timeout: 20_000 }); + await page.getByRole('button', { name: '确认激活' }).click(); + await expect(page.getByText('运行中')).toBeVisible({ timeout: 20_000 }); + await page.screenshot({ path: path.join(SCREENSHOTS, '06-activated.png'), fullPage: true }); + + const compilePrompt = '请基于本项目编制首轮《用户需求》摸底文档,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; + const taskRunWait = page.waitForResponse( + (r) => { + try { + return new URL(r.url()).pathname.includes('/api/v1/tasks/runs') && r.request().method() === 'POST'; + } catch { + return false; + } + }, + { timeout: 60_000 }, + ); + await input.fill(compilePrompt); + await page.getByRole('button', { name: /发送/ }).click(); + const taskRes = await taskRunWait; + if (taskRes.status() < 200 || taskRes.status() >= 300) { + throw new Error(`compile tasks/runs HTTP ${taskRes.status()}`); + } + await waitForSquadCompletion(page, '请基于本项目编制首轮', 420_000); + const docId = await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i); + if (!/^doc_proj\d+_/.test(docId)) { + throw new Error(`expected server doc_proj* slot, got ${docId}`); + } + state.newDocId = docId; + const token = await sessionToken(page); + const first = await getContent(page, docId, token); + expect(first.status, JSON.stringify(first.body)).toBe(200); + expect(first.body.markdown).toBeTruthy(); + expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(first.body.consistency); + expect(first.body.error_code ?? '').not.toMatch(/PROJECTION/); + state.newMarkdown = String(first.body.markdown); + await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0); + await page.screenshot({ path: path.join(SCREENSHOTS, '07-new-doc.png'), fullPage: true }); + + await page.reload(); + await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); + const afterReload = await getContent(page, docId, token); + expect(afterReload.status).toBe(200); + expect(afterReload.body.markdown).toBe(state.newMarkdown); + await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0); + await page.screenshot({ path: path.join(SCREENSHOTS, '08-new-doc-reload.png'), fullPage: true }); + + await logoutViaUI(page); + await login(page, ADMIN_USER, ADMIN_PASS); + await page.getByRole('complementary').getByTitle(PROJECT_NAME, { exact: true }).click({ force: true }); + await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); + const afterRelogin = await getContent(page, docId, await sessionToken(page)); + expect(afterRelogin.status).toBe(200); + expect(afterRelogin.body.markdown).toBe(state.newMarkdown); + await page.screenshot({ path: path.join(SCREENSHOTS, '09-new-doc-relogin.png'), fullPage: true }); + await page.close(); + }); + + test('cross-project and wrong-stage GET stay DOCUMENT_NOT_FOUND', async ({ browser }: { browser: Browser }) => { + if (!state.newDocId) { + throw new Error('new project document id missing; compile test did not leave a ledger'); + } + const outsider = await browser.newPage(); + await login(outsider, USER_D, FINAL_PASS_D); + const tokenD = await sessionToken(outsider); + const cross143 = await getContent(outsider, TARGET_DOC, tokenD); + expectClosed404(cross143.status, cross143.body, 'D vs 143'); + const crossNew = await getContent(outsider, state.newDocId, tokenD); + expectClosed404(crossNew.status, crossNew.body, 'D vs new project doc'); + await outsider.close(); + + const reviewer = await browser.newPage(); + await login(reviewer, USER_B, FINAL_PASS_B); + const tokenB = await sessionToken(reviewer); + const otherStage = state.newDocId.replace(/_[a-z0-9]+$/i, '_design_control'); + const wrongDuty = await getContent(reviewer, otherStage, tokenB); + expectClosed404(wrongDuty.status, wrongDuty.body, 'B vs wrong stage-duty'); + const stillClosed143 = await getContent(reviewer, TARGET_DOC, tokenB); + expectClosed404(stillClosed143.status, stillClosed143.body, 'B vs 143'); + await reviewer.screenshot({ path: path.join(SCREENSHOTS, '10-fail-closed.png'), fullPage: true }); + await reviewer.close(); }); });