diff --git a/internal/server/auth_middleware.go b/internal/server/auth_middleware.go index 0ba7961b..98c5c0b6 100644 --- a/internal/server/auth_middleware.go +++ b/internal/server/auth_middleware.go @@ -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" { diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index ed1feb12..b0f10ea1 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -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, }} } diff --git a/internal/server/server.go b/internal/server/server.go index a459929b..b0d9681c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/vault.go b/internal/server/vault.go new file mode 100644 index 00000000..ef015028 --- /dev/null +++ b/internal/server/vault.go @@ -0,0 +1,104 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "sync/atomic" + "time" + + "chat-agent-system/internal/artifactvault" + "chat-agent-system/internal/config" +) + +// 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 + } + // Publisher store stays nil unless explicitly enabled AND vault roots are set. + if !s.cfg.FAVPublisherEnabled { + return + } + 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 +} diff --git a/internal/server/vault_test.go b/internal/server/vault_test.go new file mode 100644 index 00000000..c6b7afe2 --- /dev/null +++ b/internal/server/vault_test.go @@ -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) + } +}