merge: origin/feature/fav-u7-publisher

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-16 15:46:29 +00:00
co-authored by Cursor
18 changed files with 624 additions and 36 deletions
@@ -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 ===
+47 -9
View File
@@ -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)
}
+2
View File
@@ -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})
+3
View File
@@ -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:
+2
View File
@@ -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:
+2
View File
@@ -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:
+46
View File
@@ -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
}
+35
View File
@@ -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")
}
}
+57
View File
@@ -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))
+40
View File
@@ -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)
}
}
+2 -1
View File
@@ -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" {
+1 -1
View File
@@ -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,
}}
}
+11
View File
@@ -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
View File
@@ -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
}
+69
View File
@@ -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)
}
}