merge: origin/feature/fav-u7-publisher
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: "FAV U7 incremental — remaining port inventory"
|
||||
status: open
|
||||
created: 2026-09-16
|
||||
updated: 2026-09-16
|
||||
---
|
||||
|
||||
# FAV U7 增量:未移植清单
|
||||
|
||||
相对 `feature/final-artifact-vault`,本切片 **未** 整支 merge,且因 `internal/artifactvault` 与当前 `dev`(dataset Candidate backfill)大面积 DIFF,下列能力仍待后续手工移植 + 审计:
|
||||
|
||||
| 项 | 旁支路径 | 阻塞 |
|
||||
|----|----------|------|
|
||||
| EnumerateTerminals / LoadExclusions | `internal/artifactvault/enumerate.go` | 依赖 TerminalRow;与现 `Enumerate(Candidate)` 并存需设计 |
|
||||
| TerminalRow Backfill | FAV `backfill.go` | 现 backfill 已改为 dataset 路径 |
|
||||
| 完整 CLI | `cmd/artifact-vault` 全命令 | 本切片仅 status stub |
|
||||
| ingest hooks | `internal/server/vault.go` OnFinalArtifact* | 会写 staging;需与 publisher 门闩同批审批 |
|
||||
| schema JSON | `*.schema.json` | 可选文档资产 |
|
||||
| durable Register(fav) | `cmd/events` | events 硬门 + 单独 cutover 审批 |
|
||||
|
||||
**红线保持**:`FAV_DEGRADED_READ=false`;fav consumer mode=off;禁止整支 merge。
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: "FAV U7 增量合入(非整支 merge)"
|
||||
status: planned
|
||||
status: done
|
||||
created: 2026-09-16
|
||||
updated: 2026-09-16
|
||||
owner: Cursor
|
||||
branch: feature/fav-u7-publisher
|
||||
worktree: "-"
|
||||
worktree: ".worktrees/feature-fav-u7-publisher"
|
||||
mutex: events
|
||||
---
|
||||
|
||||
@@ -15,13 +15,40 @@ mutex: events
|
||||
|
||||
从 `feature/final-artifact-vault` **手工移植**经审计切片到 `dev`;consumer 默认 off;禁止整支 merge;禁止默认开 `FAV_DEGRADED_READ`。
|
||||
|
||||
## 步骤
|
||||
## 范围
|
||||
|
||||
1. [ ] cherry-pick / 移植 cmd/artifact-vault + vault HTTP + publisher 订阅
|
||||
2. [ ] 隔离库负例 + 默认 off 冒烟
|
||||
3. [ ] 独立 go/no-go 报告
|
||||
- 配置门闩(`FAV_DEGRADED_READ` / `FAV_PUBLISHER_ENABLED` 默认 false)
|
||||
- HTTP `GET /api/v1/vault/status` + gated in-process publisher stub
|
||||
- `internal/app/fav` durable subscriber stub(不注册到 cmd/events)
|
||||
- `cmd/artifact-vault` status stub(完整 CLI 未移植)
|
||||
|
||||
## 非目标
|
||||
|
||||
- 整支 merge `feature/final-artifact-vault`
|
||||
- 未审计启用 degraded read
|
||||
- 未审计启用 degraded read / fav live / publisher production Drain
|
||||
- 移植 enumerate / TerminalRow Backfill / OnFinalArtifact hooks
|
||||
- 合入 `dev`(本切片仅 feature 推送)
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 审计 FAV vs `dev` artifactvault 冲突(多数 DIFF → 改 stub)
|
||||
2. [x] 落地 stub + 默认 off 测试
|
||||
3. [x] go/no-go 报告
|
||||
4. [x] 推送 `feature/fav-u7-publisher`(不合 dev)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] fav consumer / Register 仍 fail-closed
|
||||
- [x] `FAV_DEGRADED_READ` 默认 false
|
||||
- [x] 测试报告含 NO-GO:subscriber 保持 OFF 直至单独审批
|
||||
- [x] 已 push 远端 feature 分支
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Test report: [.doc/test-reports/2026-09-16-fav-u7-incremental-test-report.md](../../test-reports/2026-09-16-fav-u7-incremental-test-report.md)
|
||||
- Assets: [.doc/test-reports/assets/2026-09-16-fav-u7-incremental/](../../test-reports/assets/2026-09-16-fav-u7-incremental/)
|
||||
- Remote: `origin/feature/fav-u7-publisher`
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
因 `internal/artifactvault` 与 FAV 旁支大面积分叉,未 cherry-pick 核心库;采用最小可编译、默认关闭的 publisher/HTTP/CLI stub。commits: `3478a5a..de65cee`。
|
||||
|
||||
@@ -1,22 +1,110 @@
|
||||
# FAV U7 增量 stub 测试报告
|
||||
# FAV U7 增量(publisher stub)测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
覆盖 no-go 全量 backlog 实施。
|
||||
|
||||
P7 Final Artifact Vault 禁止整支 merge `feature/final-artifact-vault`。相对当前 `dev`,FAV 旁支的 `internal/artifactvault/*` 多数文件已大幅分叉(dataset Candidate backfill vs TerminalRow),强行 cherry-pick 会引入不可控冲突。本切片改为 **U7 增量 stub**:配置门闩、HTTP status、events 订阅桩与 CLI stub,全部默认关闭。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
见计划对应章节。
|
||||
|
||||
- `FAV_DEGRADED_READ` 默认 false(config + docquery + vault GET)
|
||||
- `FAV_PUBLISHER_ENABLED` 默认 false;进程内 Drain 不启动
|
||||
- fav durable consumer 保持 mode=off;`Registry.Register(fav)` fail-closed
|
||||
- `cmd/artifact-vault` 可编译;仅 `status|version`,无破坏性操作
|
||||
- 产出 go/no-go:**subscriber 保持 OFF,直至单独审批**
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
Go unit / flag default off。
|
||||
|
||||
| 项 | 值 |
|
||||
|----|----|
|
||||
| OS | Linux |
|
||||
| Go | 本机 `go test` / `go vet` |
|
||||
| 分支 | `feature/fav-u7-publisher`(自 `dev@ebe177e`) |
|
||||
| 层级 | 单元测试 + CLI 冒烟(无 stub 冒充真实 PI) |
|
||||
| 证据 | `.doc/test-reports/assets/2026-09-16-fav-u7-incremental/unit-tests.txt` |
|
||||
|
||||
核心用例矩阵:
|
||||
|
||||
| ID | 用例 | 期望 |
|
||||
|----|------|------|
|
||||
| C1 | config 默认 | degraded/publisher false |
|
||||
| C2 | vault init/start | publisher nil / not running |
|
||||
| C3 | GET vault/status | consumer_mode=off |
|
||||
| C4 | fav EventConsumer 零值 | HandleLease → ErrDisabledConsumer |
|
||||
| C5 | events Registry | Register(fav) 失败;LiveConsumers 不含 fav |
|
||||
| C6 | docquery | FavDegradedReadEnabled() false |
|
||||
| C7 | artifact-vault status | 打印 remaining_to_port;无 backfill |
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
默认 off;开启路径 fail-closed 或 shadow。
|
||||
|
||||
- 所有默认路径 FAIL-CLOSED:不写 vault blob、不 claim fav lease、不启用 degraded GET
|
||||
- 显式 `FAV_*=true` 仅打开配置位;durable Register 仍被 events 硬门拒绝
|
||||
- go/no-go 结论:**NO-GO for enabling fav subscriber**
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
- Verdict: PASS(单元)
|
||||
|
||||
### C1–C3 config / vault
|
||||
|
||||
- **测试输入**:`config.Load("/dev/null")`;`Server{cfg}.initArtifactVault/startVaultPublisher`;`GET /api/v1/vault/status`
|
||||
- **实际输出**:见 assets `unit-tests.txt` — `TestFAVFlagsDefaultOff` / `TestVaultDefaultsStayDisabled` / `TestVaultStatusReportsOff` PASS
|
||||
- **评价判定**:PASS
|
||||
- **耗时**:<10ms
|
||||
|
||||
### C4 fav subscriber stub
|
||||
|
||||
- **测试输入**:零值 `EventConsumer`;`Enabled=true` 仍调用 HandleLease
|
||||
- **实际输出**:均 `events: consumer is disabled`;`Register(fav)` 失败
|
||||
- **评价判定**:PASS
|
||||
|
||||
### C5–C6 events / docquery
|
||||
|
||||
- **测试输入**:既有 `TestAssertLiveConsumersRejectsFAV`、`TestLiveConsumersOmitsFAV`、`TestFavDegradedReadDefaultOff`
|
||||
- **实际输出**:PASS(PG claim 负例缺 `TEST_DATABASE_URL` 时 SKIP,与既有约定一致)
|
||||
- **评价判定**:PASS
|
||||
|
||||
### C7 CLI stub
|
||||
|
||||
- **测试输入**:`go build ./cmd/artifact-vault && artifact-vault status`
|
||||
- **实际输出**:JSON 含 `consumer_mode=off`、`degraded_read=false`、`remaining_to_port` 列表
|
||||
- **评价判定**:PASS
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
见各 worktree `go test` 输出。
|
||||
|
||||
```text
|
||||
ok internal/config (FAV flags + ValidateIndependentVault)
|
||||
ok internal/app/fav (subscriber defaults off)
|
||||
ok internal/server (vault stub defaults off)
|
||||
ok internal/events (FAV register/live omit)
|
||||
ok internal/docquery (FavDegradedRead default off)
|
||||
go vet PASS on touched packages
|
||||
```
|
||||
|
||||
完整控制台:`assets/2026-09-16-fav-u7-incremental/unit-tests.txt`
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
默认 flag off;不升 MULTI_INTENT_PLAN_TIER。
|
||||
|
||||
### Go / No-Go
|
||||
|
||||
| 决策点 | 结论 |
|
||||
|--------|------|
|
||||
| 合入本 stub 分支到 `dev`(另占集成槽) | **条件 GO**(默认 off,可审) |
|
||||
| 启用 fav durable subscriber / `Register(fav)` | **NO-GO** — 需单独审批 |
|
||||
| 启用 `FAV_DEGRADED_READ` | **NO-GO** — 默认 false,未做 M2 负例放行 |
|
||||
| 启用 `FAV_PUBLISHER_ENABLED` 生产 Drain | **NO-GO** — 缺 ingest hooks 与完整 CAS 接线审计 |
|
||||
| 整支 merge `feature/final-artifact-vault` | **NO-GO**(红线) |
|
||||
|
||||
### 仍待从 FAV 旁支手工移植(冲突重,本切片未搬)
|
||||
|
||||
1. `enumerate.go`(`EnumerateTerminals` / `LoadExclusions`)
|
||||
2. `TerminalRow` + `Backfill(rows)`(与现 dataset `Candidate` 路径分叉)
|
||||
3. 完整 `recover-plan` / `recover-apply` / `gc-preflight` / `destroy` CLI
|
||||
4. `server` ingest hooks(`OnFinalArtifact*`)
|
||||
5. durable fav `Register` + live cutover(需审批解锁 events 硬门)
|
||||
|
||||
### 本切片交付物
|
||||
|
||||
- `internal/config`:`FAV_*` / `VAULT_*` + `ValidateIndependentVault`
|
||||
- `internal/server/vault.go`:status HTTP + gated publisher
|
||||
- `internal/app/fav`:subscriber stub(默认 off)
|
||||
- `cmd/artifact-vault`:status stub
|
||||
- compose:`FAV_DEGRADED_READ` / `FAV_PUBLISHER_ENABLED` 钉死 false(main 硬编码)
|
||||
- **未** merge 到 `dev`
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
=== go test FAV disablement suite ===
|
||||
=== RUN TestFAVFlagsDefaultOff
|
||||
--- PASS: TestFAVFlagsDefaultOff (0.00s)
|
||||
=== RUN TestFAVFlagsOptInOnly
|
||||
--- PASS: TestFAVFlagsOptInOnly (0.00s)
|
||||
=== RUN TestValidateIndependentVault
|
||||
--- PASS: TestValidateIndependentVault (0.00s)
|
||||
PASS
|
||||
ok chat-agent-system/internal/config 0.002s
|
||||
=== RUN TestFavSubscriberDefaultsDisabled
|
||||
--- PASS: TestFavSubscriberDefaultsDisabled (0.00s)
|
||||
=== RUN TestFavSubscriberStillDisabledWhenFlagOnWithoutRegistryApproval
|
||||
--- PASS: TestFavSubscriberStillDisabledWhenFlagOnWithoutRegistryApproval (0.00s)
|
||||
PASS
|
||||
ok chat-agent-system/internal/app/fav 0.002s
|
||||
=== RUN TestVaultDefaultsStayDisabled
|
||||
--- PASS: TestVaultDefaultsStayDisabled (0.00s)
|
||||
=== RUN TestVaultStatusReportsOff
|
||||
--- PASS: TestVaultStatusReportsOff (0.00s)
|
||||
=== RUN TestValidateIndependentVaultRejectsDataRoot
|
||||
--- PASS: TestValidateIndependentVaultRejectsDataRoot (0.00s)
|
||||
PASS
|
||||
ok chat-agent-system/internal/server 0.007s
|
||||
=== RUN TestValidateOpsCutoverRejectsFavAndNonDev
|
||||
--- PASS: TestValidateOpsCutoverRejectsFavAndNonDev (0.00s)
|
||||
=== RUN TestPrincipalRejectsWildcardAndFAV
|
||||
--- PASS: TestPrincipalRejectsWildcardAndFAV (0.00s)
|
||||
=== RUN TestAssertLiveConsumersRejectsFAV
|
||||
--- PASS: TestAssertLiveConsumersRejectsFAV (0.00s)
|
||||
=== RUN TestLiveConsumersOmitsFAV
|
||||
--- PASS: TestLiveConsumersOmitsFAV (0.00s)
|
||||
=== RUN TestManifestJSONIncludesDisabledFAV
|
||||
--- PASS: TestManifestJSONIncludesDisabledFAV (0.00s)
|
||||
=== RUN TestE0B_FAVNeverClaimed_PG
|
||||
dispatcher_pg_test.go:85: TEST_DATABASE_URL not set; skipping PostgreSQL integration test
|
||||
--- SKIP: TestE0B_FAVNeverClaimed_PG (0.00s)
|
||||
PASS
|
||||
ok chat-agent-system/internal/events 0.004s
|
||||
=== RUN TestFavDegradedReadDefaultOff
|
||||
--- PASS: TestFavDegradedReadDefaultOff (0.00s)
|
||||
=== RUN TestFavDegradedReadOnlyAfterAuthorize
|
||||
--- PASS: TestFavDegradedReadOnlyAfterAuthorize (0.00s)
|
||||
PASS
|
||||
ok chat-agent-system/internal/docquery 0.002s
|
||||
=== go build cmd/artifact-vault ===
|
||||
{
|
||||
"binary": "artifact-vault",
|
||||
"consumer": "fav",
|
||||
"consumer_mode": "off",
|
||||
"degraded_read": false,
|
||||
"note": "do not enable FAV_DEGRADED_READ or fav consumer without approval",
|
||||
"publisher_default": false,
|
||||
"remaining_to_port": [
|
||||
"enumerate.go (EnumerateTerminals / LoadExclusions)",
|
||||
"TerminalRow + Backfill(rows) API (diverged from dataset Candidate path)",
|
||||
"full recover-plan / recover-apply / gc-preflight / destroy",
|
||||
"server ingest hooks (OnFinalArtifact*)",
|
||||
"durable fav event Register after separate approval"
|
||||
],
|
||||
"slice": "u7-incremental-stub",
|
||||
"source_branch": "feature/final-artifact-vault"
|
||||
}
|
||||
=== go vet ===
|
||||
@@ -1,19 +1,57 @@
|
||||
// Command artifact-vault is the FAV publisher worker stub.
|
||||
// It refuses to start unless FAV_PUBLISHER_ENABLE=1 (default off).
|
||||
// Command artifact-vault is the FAV operator CLI stub for U7 incremental.
|
||||
//
|
||||
// Full backfill / inventory / recover-apply / destroy live on
|
||||
// feature/final-artifact-vault and were NOT whole-branch merged. This binary
|
||||
// compiles on current dev and stays non-destructive: it only prints status /
|
||||
// remaining-port inventory.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := strings.TrimSpace(os.Getenv("FAV_PUBLISHER_ENABLE"))
|
||||
if v != "1" && !strings.EqualFold(v, "true") {
|
||||
fmt.Fprintln(os.Stderr, "artifact-vault: disabled (set FAV_PUBLISHER_ENABLE=1 after go/no-go)")
|
||||
os.Exit(0)
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "status", "version":
|
||||
writeJSON(map[string]any{
|
||||
"binary": "artifact-vault",
|
||||
"slice": "u7-incremental-stub",
|
||||
"publisher_default": false,
|
||||
"degraded_read": false,
|
||||
"consumer": "fav",
|
||||
"consumer_mode": "off",
|
||||
"remaining_to_port": []string{
|
||||
"enumerate.go (EnumerateTerminals / LoadExclusions)",
|
||||
"TerminalRow + Backfill(rows) API (diverged from dataset Candidate path)",
|
||||
"full recover-plan / recover-apply / gc-preflight / destroy",
|
||||
"server ingest hooks (OnFinalArtifact*)",
|
||||
"durable fav event Register after separate approval",
|
||||
},
|
||||
"source_branch": "feature/final-artifact-vault",
|
||||
"note": "do not enable FAV_DEGRADED_READ or fav consumer without approval",
|
||||
})
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "artifact-vault: unknown command %q (full CLI remains on feature/final-artifact-vault)\n", os.Args[1])
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "artifact-vault: enable path reserved; subscriber still mode=off in events registry")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: artifact-vault <status|version|help>")
|
||||
fmt.Fprintln(os.Stderr, "U7 stub only — backfill/recover/destroy not ported; fav stays off")
|
||||
}
|
||||
|
||||
func writeJSON(v any) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ func main() {
|
||||
(&docproject.EventConsumer{Docs: gov, Env: cfg.Env}).HandleLease,
|
||||
(&intentflow.ProjectionConsumer{Env: cfg.Env}).HandleLease,
|
||||
))
|
||||
// Intentionally NOT registering events.ConsumerFAV / internal/app/fav.
|
||||
// Registry.Register("fav") fails closed; mode stays off until separate approval.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "env": cfg.Env, "owner": owner})
|
||||
|
||||
@@ -126,6 +126,9 @@ services:
|
||||
WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true}
|
||||
MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true}
|
||||
SEMANTIC_FALLBACK_ENABLED: ${SEMANTIC_FALLBACK_ENABLED:-false}
|
||||
# FAV U7: keep degraded read + in-process publisher off until separate approval
|
||||
FAV_DEGRADED_READ: ${FAV_DEGRADED_READ:-false}
|
||||
FAV_PUBLISHER_ENABLED: ${FAV_PUBLISHER_ENABLED:-false}
|
||||
ports:
|
||||
- "${APP_PUBLISH_HOST:-127.0.0.1}:${HTTP_PORT:-8080}:${HTTP_PORT:-8080}"
|
||||
healthcheck:
|
||||
|
||||
@@ -125,6 +125,8 @@ services:
|
||||
WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true}
|
||||
MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true}
|
||||
SEMANTIC_FALLBACK_ENABLED: ${SEMANTIC_FALLBACK_ENABLED:-false}
|
||||
FAV_DEGRADED_READ: "false"
|
||||
FAV_PUBLISHER_ENABLED: "false"
|
||||
ports:
|
||||
- "${APP_PUBLISH_HOST:-0.0.0.0}:${HTTP_PORT:-8000}:${HTTP_PORT:-8000}"
|
||||
healthcheck:
|
||||
|
||||
@@ -125,6 +125,8 @@ services:
|
||||
WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true}
|
||||
MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true}
|
||||
SEMANTIC_FALLBACK_ENABLED: ${SEMANTIC_FALLBACK_ENABLED:-false}
|
||||
FAV_DEGRADED_READ: ${FAV_DEGRADED_READ:-false}
|
||||
FAV_PUBLISHER_ENABLED: ${FAV_PUBLISHER_ENABLED:-false}
|
||||
ports:
|
||||
- "${APP_PUBLISH_HOST:-0.0.0.0}:${HTTP_PORT:-8081}:${HTTP_PORT:-8081}"
|
||||
healthcheck:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package fav holds the durable Final Artifact Vault event subscriber stub.
|
||||
//
|
||||
// U7 incremental rule: the fav consumer stays mode=off and unregistered until
|
||||
// a separate approval. Registry.Register("fav") and SetMode(fav, live) already
|
||||
// fail closed in internal/events; this package must not bypass those gates.
|
||||
package fav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/artifactvault"
|
||||
"chat-agent-system/internal/events"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// EventConsumer is the durable FAV publisher subscriber.
|
||||
// Enabled defaults to false (zero value). Do not register into cmd/events
|
||||
// until separate cutover approval.
|
||||
type EventConsumer struct {
|
||||
Publisher *artifactvault.Publisher
|
||||
Env string
|
||||
// Enabled must stay false by default. Even when true, events.Registry
|
||||
// still rejects Register(ConsumerFAV) until the hard gate is lifted.
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// HandleLease refuses work while the subscriber is disabled (default).
|
||||
// Even when Enabled is flipped locally, the stub still refuses until the
|
||||
// events registry hard-gate and a separate cutover approval are both lifted.
|
||||
func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx pgx.Tx) error {
|
||||
_ = ctx
|
||||
_ = lease
|
||||
_ = tx
|
||||
if c == nil || !c.Enabled {
|
||||
return fmt.Errorf("%w: fav", events.ErrDisabledConsumer)
|
||||
}
|
||||
_ = c.Publisher
|
||||
return fmt.Errorf("%w: fav publisher cutover not approved", events.ErrDisabledConsumer)
|
||||
}
|
||||
|
||||
// DefaultEnabled reports the compile-time / zero-value default for the subscriber.
|
||||
func DefaultEnabled() bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package fav_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/app/fav"
|
||||
"chat-agent-system/internal/events"
|
||||
)
|
||||
|
||||
func TestFavSubscriberDefaultsDisabled(t *testing.T) {
|
||||
if fav.DefaultEnabled() {
|
||||
t.Fatal("fav subscriber DefaultEnabled must be false")
|
||||
}
|
||||
c := &fav.EventConsumer{}
|
||||
if c.Enabled {
|
||||
t.Fatal("zero-value EventConsumer.Enabled must be false")
|
||||
}
|
||||
err := c.HandleLease(context.Background(), events.Lease{}, nil)
|
||||
if !errors.Is(err, events.ErrDisabledConsumer) {
|
||||
t.Fatalf("disabled HandleLease: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavSubscriberStillDisabledWhenFlagOnWithoutRegistryApproval(t *testing.T) {
|
||||
c := &fav.EventConsumer{Enabled: true, Env: "dev"}
|
||||
err := c.HandleLease(context.Background(), events.Lease{Environment: "dev"}, nil)
|
||||
if !errors.Is(err, events.ErrDisabledConsumer) {
|
||||
t.Fatalf("enabled stub must still refuse until cutover approved: %v", err)
|
||||
}
|
||||
if err := events.NewRegistry().Register(events.ConsumerFAV); err == nil {
|
||||
t.Fatal("Registry.Register(fav) must remain fail-closed")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -65,6 +66,13 @@ type Config struct {
|
||||
|
||||
// CORSAllowedOrigins 允许的浏览器 Origin 列表;空列表时不回显 Access-Control-Allow-Origin
|
||||
CORSAllowedOrigins []string `json:"cors_allowed_origins"`
|
||||
|
||||
// Final Artifact Vault (FAV) — defaults stay off until separate approval.
|
||||
VaultStateRoot string `json:"vault_state_root"`
|
||||
VaultStorageIdentity string `json:"vault_storage_identity"`
|
||||
VaultMasterKey string `json:"vault_master_key"`
|
||||
FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false
|
||||
FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
@@ -228,6 +236,16 @@ func Load(path string) (*Config, error) {
|
||||
cfg.AllowInMemory = val == "true" || val == "1"
|
||||
case "CORS_ALLOWED_ORIGINS":
|
||||
cfg.CORSAllowedOrigins = parseCSVOrigins(val)
|
||||
case "VAULT_STATE_ROOT":
|
||||
cfg.VaultStateRoot = val
|
||||
case "VAULT_STORAGE_IDENTITY":
|
||||
cfg.VaultStorageIdentity = val
|
||||
case "VAULT_MASTER_KEY":
|
||||
cfg.VaultMasterKey = val
|
||||
case "FAV_DEGRADED_READ":
|
||||
cfg.FAVDegradedRead = val == "true" || val == "1"
|
||||
case "FAV_PUBLISHER_ENABLED":
|
||||
cfg.FAVPublisherEnabled = val == "true" || val == "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,6 +383,21 @@ func Load(path string) (*Config, error) {
|
||||
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
|
||||
cfg.CORSAllowedOrigins = parseCSVOrigins(v)
|
||||
}
|
||||
if v := os.Getenv("VAULT_STATE_ROOT"); v != "" {
|
||||
cfg.VaultStateRoot = v
|
||||
}
|
||||
if v := os.Getenv("VAULT_STORAGE_IDENTITY"); v != "" {
|
||||
cfg.VaultStorageIdentity = v
|
||||
}
|
||||
if v := os.Getenv("VAULT_MASTER_KEY"); v != "" {
|
||||
cfg.VaultMasterKey = v
|
||||
}
|
||||
if v := os.Getenv("FAV_DEGRADED_READ"); v != "" {
|
||||
cfg.FAVDegradedRead = v == "true" || v == "1"
|
||||
}
|
||||
if v := os.Getenv("FAV_PUBLISHER_ENABLED"); v != "" {
|
||||
cfg.FAVPublisherEnabled = v == "true" || v == "1"
|
||||
}
|
||||
if v := os.Getenv("HTTP_ADDR"); v != "" && strings.HasPrefix(v, ":") {
|
||||
if p, err := strconv.Atoi(strings.TrimPrefix(v, ":")); err == nil {
|
||||
cfg.HTTPPort = p
|
||||
@@ -377,10 +410,34 @@ func Load(path string) (*Config, error) {
|
||||
if err := validateProductionSecrets(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateIndependentVault(cfg.VaultStateRoot, cfg.DataDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ValidateIndependentVault refuses vault roots that collide with runtime data/{env}.
|
||||
// Empty root is allowed (FAV stays unconfigured / disabled).
|
||||
func ValidateIndependentVault(root, dataDir string) error {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil
|
||||
}
|
||||
cr := filepath.Clean(root)
|
||||
for _, env := range []string{"dev", "test", "main"} {
|
||||
if strings.HasSuffix(cr, filepath.Join("data", env)) {
|
||||
return fmt.Errorf("config: VAULT_STATE_ROOT must not be runtime data root %s", root)
|
||||
}
|
||||
}
|
||||
if dataDir != "" {
|
||||
cd := filepath.Clean(dataDir)
|
||||
if cr == cd {
|
||||
return fmt.Errorf("config: VAULT_STATE_ROOT must be independent from DATA_DIR")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCSVOrigins(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
|
||||
@@ -144,3 +144,43 @@ func TestConfigLoadRejectsProductionDatabasePlaceholder(t *testing.T) {
|
||||
t.Fatal("expected config load to reject the documented production database placeholder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFAVFlagsDefaultOff(t *testing.T) {
|
||||
t.Setenv("FAV_DEGRADED_READ", "")
|
||||
t.Setenv("FAV_PUBLISHER_ENABLED", "")
|
||||
cfg, err := config.Load("/dev/null")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.FAVDegradedRead {
|
||||
t.Fatal("FAV_DEGRADED_READ must default false")
|
||||
}
|
||||
if cfg.FAVPublisherEnabled {
|
||||
t.Fatal("FAV_PUBLISHER_ENABLED must default false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFAVFlagsOptInOnly(t *testing.T) {
|
||||
t.Setenv("FAV_DEGRADED_READ", "true")
|
||||
t.Setenv("FAV_PUBLISHER_ENABLED", "1")
|
||||
t.Setenv("VAULT_STATE_ROOT", "/tmp/vault-independent-test")
|
||||
cfg, err := config.Load("/dev/null")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.FAVDegradedRead || !cfg.FAVPublisherEnabled {
|
||||
t.Fatalf("expected explicit opt-in: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIndependentVault(t *testing.T) {
|
||||
if err := config.ValidateIndependentVault("./data/dev", "./other"); err == nil {
|
||||
t.Fatal("expected reject data/dev root")
|
||||
}
|
||||
if err := config.ValidateIndependentVault("/var/vault", "/var/vault"); err == nil {
|
||||
t.Fatal("expected reject equal DATA_DIR")
|
||||
}
|
||||
if err := config.ValidateIndependentVault("/var/vault", "./data/dev"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ func (s *Server) allowLegacyTestPrincipal() bool {
|
||||
|
||||
func isPublicRoute(method, path string) bool {
|
||||
if path == "/" || path == "/health" || path == "/api/v1/health" ||
|
||||
path == "/ready" || path == "/api/v1/ready" {
|
||||
path == "/ready" || path == "/api/v1/ready" ||
|
||||
path == "/api/v1/vault/status" {
|
||||
return true
|
||||
}
|
||||
if method == http.MethodPost && path == "/api/v1/auth/login" {
|
||||
|
||||
@@ -25,7 +25,7 @@ func (s *Server) docflowQuery() *docflow.Query {
|
||||
Authorize: func(ctx context.Context, p docquery.Principal, rec *docgov.DocumentRecord) error {
|
||||
return s.authorizeControlledRead(ctx, p, rec)
|
||||
},
|
||||
FavDegradedRead: false,
|
||||
FavDegradedRead: s.cfg != nil && s.cfg.FAVDegradedRead,
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"chat-agent-system/internal/app/chatflow"
|
||||
"chat-agent-system/internal/app/intentflow"
|
||||
"chat-agent-system/internal/app/stageflow"
|
||||
"chat-agent-system/internal/artifactvault"
|
||||
"chat-agent-system/internal/auth"
|
||||
"chat-agent-system/internal/authz"
|
||||
"chat-agent-system/internal/bizdev"
|
||||
@@ -71,6 +72,12 @@ type Server struct {
|
||||
intentPlans intentflow.Service
|
||||
planAccessFn func(ctx context.Context, projectID, userID int64) error
|
||||
planSessionOwnerFn func(ctx context.Context, sessionID string) (int64, error)
|
||||
|
||||
// FAV stubs (U7 incremental): nil / off unless FAV_PUBLISHER_ENABLED.
|
||||
vaultReg artifactvault.Registry
|
||||
vaultStore *artifactvault.FilesystemStore
|
||||
vaultPub *artifactvault.Publisher
|
||||
vaultPublisherStarted int32
|
||||
}
|
||||
|
||||
// NewFromRuntime attaches the HTTP adapter to an already-wired composition root.
|
||||
@@ -114,6 +121,8 @@ func NewFromRuntime(rt *app.Runtime) *Server {
|
||||
s.svc.SetStageAccessGate(newBizdevStageGate(s.instSvc, s.pwSvc))
|
||||
}
|
||||
s.initTaskRunExecutors()
|
||||
s.initArtifactVault()
|
||||
s.startVaultPublisher()
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -163,6 +172,8 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/health", s.handleHealth)
|
||||
mux.HandleFunc("GET /ready", s.handleReadiness)
|
||||
mux.HandleFunc("GET /api/v1/ready", s.handleReadiness)
|
||||
// FAV status stub (publisher/degraded default off; no ingest)
|
||||
mux.HandleFunc("GET /api/v1/vault/status", s.handleVaultStatus)
|
||||
|
||||
// 2. 项目与多工作流控制面
|
||||
mux.HandleFunc("POST /api/v1/projects", s.handleCreateProject)
|
||||
|
||||
+93
-10
@@ -1,21 +1,104 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/artifactvault"
|
||||
"chat-agent-system/internal/config"
|
||||
)
|
||||
|
||||
// handleVaultGet is a gated FAV read stub. Always 404/403 unless explicitly enabled
|
||||
// and never enables FAV_DEGRADED_READ by default.
|
||||
func (s *Server) handleVaultGet(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(os.Getenv("FAV_HTTP_ENABLE")) != "1" {
|
||||
http.NotFound(w, r)
|
||||
// Vault HTTP + in-process publisher stubs for U7 incremental.
|
||||
// Default: FAV_PUBLISHER_ENABLED=false, FAV_DEGRADED_READ=false.
|
||||
// Full ingest hooks / enumerate backfill remain on feature/final-artifact-vault.
|
||||
|
||||
func (s *Server) initArtifactVault() {
|
||||
if s == nil || s.cfg == nil {
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("FAV_DEGRADED_READ")), "true") {
|
||||
writeSafeError(w, r, http.StatusForbidden, `{"error":"FAV_DEGRADED_READ_REQUIRES_APPROVAL"}`)
|
||||
// Publisher store stays nil unless explicitly enabled AND vault roots are set.
|
||||
if !s.cfg.FAVPublisherEnabled {
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
if s.cfg.VaultStateRoot == "" || s.cfg.VaultMasterKey == "" {
|
||||
return
|
||||
}
|
||||
if err := config.ValidateIndependentVault(s.cfg.VaultStateRoot, s.cfg.DataDir); err != nil {
|
||||
return
|
||||
}
|
||||
ident := s.cfg.VaultStorageIdentity
|
||||
if ident == "" {
|
||||
env := s.cfg.Env
|
||||
if env == "" {
|
||||
env = "dev"
|
||||
}
|
||||
ident = "vault-" + env
|
||||
}
|
||||
store, err := artifactvault.NewFilesystemStore(s.cfg.VaultStateRoot, ident, []byte(s.cfg.VaultMasterKey))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var reg artifactvault.Registry
|
||||
if s.pool != nil {
|
||||
reg = artifactvault.NewPGRegistry(s.pool)
|
||||
} else {
|
||||
reg = artifactvault.NewMemoryRegistry()
|
||||
}
|
||||
s.vaultStore = store
|
||||
s.vaultReg = reg
|
||||
s.vaultPub = &artifactvault.Publisher{Reg: reg, Store: store}
|
||||
}
|
||||
|
||||
func (s *Server) startVaultPublisher() {
|
||||
if s == nil || s.vaultPub == nil || s.cfg == nil || !s.cfg.FAVPublisherEnabled {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
_ = s.vaultPub.Drain(context.Background())
|
||||
}
|
||||
}()
|
||||
atomic.StoreInt32(&s.vaultPublisherStarted, 1)
|
||||
}
|
||||
|
||||
func (s *Server) tryVaultDegraded(ctx context.Context, env, logical, projectKey string) ([]byte, string, error) {
|
||||
if s == nil || s.vaultPub == nil || s.cfg == nil || !s.cfg.FAVDegradedRead {
|
||||
return nil, "", fmt.Errorf("artifactvault: degraded read disabled")
|
||||
}
|
||||
return s.vaultPub.DegradedPayload(ctx, env, logical, projectKey)
|
||||
}
|
||||
|
||||
func (s *Server) handleVaultStatus(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r
|
||||
cfg := s.cfg
|
||||
degraded := false
|
||||
publisher := false
|
||||
storeReady := false
|
||||
if cfg != nil {
|
||||
degraded = cfg.FAVDegradedRead
|
||||
publisher = cfg.FAVPublisherEnabled
|
||||
}
|
||||
if s.vaultStore != nil {
|
||||
storeReady = true
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"consumer": "fav",
|
||||
"consumer_mode": "off",
|
||||
"publisher_enabled": publisher,
|
||||
"publisher_running": atomic.LoadInt32(&s.vaultPublisherStarted) == 1,
|
||||
"degraded_read": degraded,
|
||||
"store_configured": storeReady,
|
||||
"subscriber_default": false,
|
||||
"note": "fav consumer remains OFF until separate approval; full FAV branch not merged",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) vaultPublisherRunning() bool {
|
||||
return atomic.LoadInt32(&s.vaultPublisherStarted) == 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/config"
|
||||
)
|
||||
|
||||
func TestVaultDefaultsStayDisabled(t *testing.T) {
|
||||
cfg, err := config.Load("/dev/null")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.FAVDegradedRead {
|
||||
t.Fatal("FAV_DEGRADED_READ must default false")
|
||||
}
|
||||
if cfg.FAVPublisherEnabled {
|
||||
t.Fatal("FAV_PUBLISHER_ENABLED must default false")
|
||||
}
|
||||
|
||||
s := &Server{cfg: cfg}
|
||||
s.initArtifactVault()
|
||||
s.startVaultPublisher()
|
||||
if s.vaultPub != nil {
|
||||
t.Fatal("vault publisher must stay nil when flag off")
|
||||
}
|
||||
if s.vaultPublisherRunning() {
|
||||
t.Fatal("vault publisher must not start when flag off")
|
||||
}
|
||||
if _, _, err := s.tryVaultDegraded(context.Background(), "dev", "doc", "1"); err == nil {
|
||||
t.Fatal("degraded read must refuse when flag off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultStatusReportsOff(t *testing.T) {
|
||||
s := &Server{cfg: &config.Config{Env: "dev"}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/vault/status", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleVaultStatus(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rr.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["consumer_mode"] != "off" {
|
||||
t.Fatalf("consumer_mode=%v", body["consumer_mode"])
|
||||
}
|
||||
if body["publisher_enabled"] != false {
|
||||
t.Fatalf("publisher_enabled=%v", body["publisher_enabled"])
|
||||
}
|
||||
if body["degraded_read"] != false {
|
||||
t.Fatalf("degraded_read=%v", body["degraded_read"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIndependentVaultRejectsDataRoot(t *testing.T) {
|
||||
if err := config.ValidateIndependentVault("./data/dev", "./data/dev"); err == nil {
|
||||
t.Fatal("expected vault/data collision error")
|
||||
}
|
||||
if err := config.ValidateIndependentVault("", "./data/dev"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user