merge: refactor/document-reliability-pilot
M2 WorkspaceRef, durable events, and audited FAV (goose 00034–00036).
@@ -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/<slug>` 或 `fix/<slug>`,**禁止** `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) |
|
||||
|
||||
|
||||
@@ -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 证据。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
## 相关链接
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 182 KiB |
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 批注类型
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||