Update 1.5.0
release-tag / release-image (push) Successful in 10m52s

This commit is contained in:
2026-08-27 07:51:39 +02:00
parent 1decb831d6
commit 8c67c7a7fa
58 changed files with 10768 additions and 626 deletions
+164 -26
View File
@@ -6,9 +6,11 @@ import (
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync/atomic"
@@ -25,13 +27,14 @@ import (
var webFS embed.FS
type Server struct {
store *store.Store
brain *brain.Engine
router *provider.Router
cost *cost.Manager
mux *http.ServeMux
metrics *metricsRegistry
inflight atomic.Int64
store *store.Store
brain *brain.Engine
router *provider.Router
cost *cost.Manager
mux *http.ServeMux
metrics *metricsRegistry
inflight atomic.Int64
readinessOllamaLive bool
}
func New(s *store.Store, b *brain.Engine, r *provider.Router, c *cost.Manager) *Server {
@@ -39,6 +42,8 @@ func New(s *store.Store, b *brain.Engine, r *provider.Router, c *cost.Manager) *
x.routes()
return x
}
func (s *Server) SetReadinessOllamaLive(enabled bool) { s.readinessOllamaLive = enabled }
func (s *Server) Handler() http.Handler {
var h http.Handler = s.mux
h = s.requestLimits(h)
@@ -61,7 +66,7 @@ func (s *Server) routes() {
s.mux.Handle("POST /api/v1/search/vector", s.appAuth(http.HandlerFunc(s.searchVector)))
s.mux.Handle("POST /api/v1/memory/import", s.appAuth(http.HandlerFunc(s.importMemory)))
s.mux.Handle("POST /api/v1/feedback", s.appAuth(http.HandlerFunc(s.feedback)))
s.mux.Handle("GET /api/v1/stats", s.appAuth(http.HandlerFunc(s.stats)))
s.mux.Handle("GET /api/v1/stats", s.controlReadAuth(http.HandlerFunc(s.stats)))
s.mux.Handle("GET /api/v1/goals", s.appAuth(http.HandlerFunc(s.goalsList)))
s.mux.Handle("POST /api/v1/goals", s.appAuth(http.HandlerFunc(s.goalsCreate)))
s.mux.Handle("GET /api/v1/goals/{id}", s.appAuth(http.HandlerFunc(s.goalsGet)))
@@ -79,14 +84,14 @@ func (s *Server) routes() {
s.mux.Handle("GET /api/v1/sources", s.appAuth(http.HandlerFunc(s.sourcesList)))
s.mux.Handle("GET /api/v1/sources/{id}", s.appAuth(http.HandlerFunc(s.sourceGet)))
s.mux.Handle("POST /api/v1/research", s.appAuth(http.HandlerFunc(s.researchSearch)))
s.mux.Handle("POST /api/v1/integrations/knowledge/upsert", s.appAuth(http.HandlerFunc(s.integrationKnowledgeUpsert)))
s.mux.Handle("DELETE /api/v1/integrations/knowledge/{namespace}/{document_id}", s.appAuth(http.HandlerFunc(s.integrationKnowledgeDelete)))
s.mux.Handle("POST /api/v1/integrations/knowledge/search", s.appAuth(http.HandlerFunc(s.integrationKnowledgeSearch)))
s.mux.Handle("POST /api/v1/integrations/events", s.appAuth(http.HandlerFunc(s.integrationEvent)))
s.mux.Handle("POST /api/v1/integrations/outcomes", s.appAuth(http.HandlerFunc(s.integrationValidatedOutcome)))
s.mux.Handle("POST /api/v1/integrations/outcomes/search", s.appAuth(http.HandlerFunc(s.integrationValidatedOutcomeSearch)))
s.mux.Handle("GET /api/v1/integrations/graph/research", s.appAuth(http.HandlerFunc(s.integrationResearchGraph)))
s.mux.Handle("GET /api/v1/integrations/graph/brain", s.appAuth(http.HandlerFunc(s.integrationBrainGraph)))
s.mux.Handle("POST /api/v1/integrations/knowledge/upsert", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeUpsert)))
s.mux.Handle("DELETE /api/v1/integrations/knowledge/{namespace}/{document_id}", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeDelete)))
s.mux.Handle("POST /api/v1/integrations/knowledge/search", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeSearch)))
s.mux.Handle("POST /api/v1/integrations/events", s.integrationAuth(http.HandlerFunc(s.integrationEvent)))
s.mux.Handle("POST /api/v1/integrations/outcomes", s.integrationAuth(http.HandlerFunc(s.integrationValidatedOutcome)))
s.mux.Handle("POST /api/v1/integrations/outcomes/search", s.integrationAuth(http.HandlerFunc(s.integrationValidatedOutcomeSearch)))
s.mux.Handle("GET /api/v1/integrations/graph/research", s.controlReadAuth(http.HandlerFunc(s.integrationResearchGraph)))
s.mux.Handle("GET /api/v1/integrations/graph/brain", s.controlReadAuth(http.HandlerFunc(s.integrationBrainGraph)))
s.mux.Handle("POST /internal/v1/cluster/request-vote", s.clusterAuth(http.HandlerFunc(s.clusterRequestVote)))
s.mux.Handle("POST /internal/v1/cluster/heartbeat", s.clusterAuth(http.HandlerFunc(s.clusterHeartbeat)))
@@ -232,6 +237,33 @@ func (s *Server) appAuth(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
func (s *Server) integrationAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sec := s.store.Secrets()
adminOK := secureEqual(r.Header.Get("X-Admin-Token"), sec.AdminToken)
integrationOK := secureEqual(bearer(r), sec.IntegrationToken)
if !adminOK && !integrationOK {
s.err(w, http.StatusUnauthorized, errors.New("invalid integration token or admin token"))
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) controlReadAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sec := s.store.Secrets()
adminOK := secureEqual(r.Header.Get("X-Admin-Token"), sec.AdminToken)
controlOK := secureEqual(bearer(r), sec.ControlReadToken)
appOK := secureEqual(bearer(r), sec.AppAPIKey)
if !adminOK && !controlOK && !appOK {
s.err(w, http.StatusUnauthorized, errors.New("invalid control/app read token or admin token"))
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) workerAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !secureEqual(bearer(r), s.store.Secrets().WorkerToken) {
@@ -571,7 +603,7 @@ func (s *Server) adminPutModelRouting(w http.ResponseWriter, r *http.Request) {
func (s *Server) adminSecretsStatus(w http.ResponseWriter, r *http.Request) {
sec := s.store.Secrets()
s.json(w, 200, map[string]any{"openai_configured": sec.OpenAIAPIKey != "", "app_key_configured": sec.AppAPIKey != "", "worker_token_configured": sec.WorkerToken != "", "metrics_token_configured": sec.MetricsToken != "", "shard_tokens": len(sec.ShardAPIToken), "cluster_token_configured": sec.ClusterToken != ""})
s.json(w, 200, map[string]any{"openai_configured": sec.OpenAIAPIKey != "", "app_key_configured": sec.AppAPIKey != "", "integration_token_configured": sec.IntegrationToken != "", "control_read_token_configured": sec.ControlReadToken != "", "worker_token_configured": sec.WorkerToken != "", "metrics_token_configured": sec.MetricsToken != "", "shard_tokens": len(sec.ShardAPIToken), "cluster_token_configured": sec.ClusterToken != ""})
}
func maskedSecret(v string) string {
if v == "" {
@@ -586,35 +618,61 @@ func (s *Server) adminGetSecrets(w http.ResponseWriter, r *http.Request) {
sec := s.store.Secrets()
reveal := r.URL.Query().Get("reveal") == "1" && s.store.Config().Security.AllowSecretReveal
if reveal {
s.json(w, 200, map[string]any{"revealed": true, "app_api_key": sec.AppAPIKey, "worker_token": sec.WorkerToken, "metrics_token": sec.MetricsToken, "shard_api_tokens": sec.ShardAPIToken, "cluster_token": sec.ClusterToken})
s.json(w, 200, map[string]any{"revealed": true, "app_api_key": sec.AppAPIKey, "integration_token": sec.IntegrationToken, "control_read_token": sec.ControlReadToken, "worker_token": sec.WorkerToken, "metrics_token": sec.MetricsToken, "shard_api_tokens": sec.ShardAPIToken, "cluster_token": sec.ClusterToken})
return
}
maskedShards := map[string]string{}
for k, v := range sec.ShardAPIToken {
maskedShards[k] = maskedSecret(v)
}
s.json(w, 200, map[string]any{"revealed": false, "reveal_allowed": s.store.Config().Security.AllowSecretReveal, "app_api_key": maskedSecret(sec.AppAPIKey), "worker_token": maskedSecret(sec.WorkerToken), "metrics_token": maskedSecret(sec.MetricsToken), "shard_api_tokens": maskedShards, "cluster_token": maskedSecret(sec.ClusterToken)})
s.json(w, 200, map[string]any{"revealed": false, "reveal_allowed": s.store.Config().Security.AllowSecretReveal, "app_api_key": maskedSecret(sec.AppAPIKey), "integration_token": maskedSecret(sec.IntegrationToken), "control_read_token": maskedSecret(sec.ControlReadToken), "worker_token": maskedSecret(sec.WorkerToken), "metrics_token": maskedSecret(sec.MetricsToken), "shard_api_tokens": maskedShards, "cluster_token": maskedSecret(sec.ClusterToken)})
}
func (s *Server) adminPutSecrets(w http.ResponseWriter, r *http.Request) {
var q struct {
OpenAIAPIKey string `json:"openai_api_key,omitempty"`
AppAPIKey string `json:"app_api_key,omitempty"`
WorkerToken string `json:"worker_token,omitempty"`
MetricsToken string `json:"metrics_token,omitempty"`
ShardAPIToken map[string]string `json:"shard_api_tokens,omitempty"`
ClusterToken string `json:"cluster_token,omitempty"`
OpenAIAPIKey string `json:"openai_api_key,omitempty"`
AppAPIKey string `json:"app_api_key,omitempty"`
IntegrationToken string `json:"integration_token,omitempty"`
ControlReadToken string `json:"control_read_token,omitempty"`
WorkerToken string `json:"worker_token,omitempty"`
MetricsToken string `json:"metrics_token,omitempty"`
ShardAPIToken map[string]string `json:"shard_api_tokens,omitempty"`
ClusterToken string `json:"cluster_token,omitempty"`
}
if err := decode(r, &q); err != nil {
s.err(w, 400, err)
return
}
sec := s.store.Secrets()
envLocked := func(name string) bool {
_, ok := os.LookupEnv(name)
return ok && strings.TrimSpace(os.Getenv(name)) != ""
}
for name, value := range map[string]string{
"OPENAI_API_KEY": q.OpenAIAPIKey,
"NEUROFORGE_APP_API_KEY": q.AppAPIKey,
"NEUROFORGE_INTEGRATION_TOKEN": q.IntegrationToken,
"NEUROFORGE_CONTROL_READ_TOKEN": q.ControlReadToken,
"NEUROFORGE_WORKER_TOKEN": q.WorkerToken,
"NEUROFORGE_METRICS_TOKEN": q.MetricsToken,
"NEUROFORGE_CLUSTER_TOKEN": q.ClusterToken,
} {
if value != "" && envLocked(name) {
s.err(w, http.StatusConflict, fmt.Errorf("%s is environment-managed and cannot be changed through the admin API", name))
return
}
}
if q.OpenAIAPIKey != "" {
sec.OpenAIAPIKey = q.OpenAIAPIKey
}
if q.AppAPIKey != "" {
sec.AppAPIKey = q.AppAPIKey
}
if q.IntegrationToken != "" {
sec.IntegrationToken = q.IntegrationToken
}
if q.ControlReadToken != "" {
sec.ControlReadToken = q.ControlReadToken
}
if q.WorkerToken != "" {
sec.WorkerToken = q.WorkerToken
}
@@ -737,6 +795,67 @@ func (s *Server) livez(w http.ResponseWriter, r *http.Request) {
s.json(w, http.StatusOK, map[string]any{"ok": true, "status": "alive", "time": time.Now().UTC(), "version": "0.8.2"})
}
func configuredModelAvailable(models map[string]bool, configured string) bool {
configured = strings.TrimSpace(configured)
if configured == "" {
return false
}
if models[configured] {
return true
}
if !strings.Contains(configured, ":") && models[configured+":latest"] {
return true
}
return false
}
func checkConfiguredOllamaModels(ctx context.Context, cfg core.Config) (bool, any) {
type tagsResponse struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
details := map[string]any{}
anyEnabled := false
for _, node := range cfg.Ollama {
if !node.Enabled || strings.TrimSpace(node.BaseURL) == "" {
continue
}
anyEnabled = true
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(node.BaseURL, "/")+"/api/tags", nil)
if err != nil {
details[node.ID] = err.Error()
continue
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
details[node.ID] = err.Error()
continue
}
var tags tagsResponse
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tags)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 || decodeErr != nil {
details[node.ID] = fmt.Sprintf("HTTP %d / invalid tags response", resp.StatusCode)
continue
}
models := map[string]bool{}
for _, model := range tags.Models {
models[strings.TrimSpace(model.Name)] = true
}
chatOK := configuredModelAvailable(models, node.ChatModel)
embedOK := configuredModelAvailable(models, node.EmbeddingModel)
details[node.ID] = map[string]any{"reachable": true, "chat_model": node.ChatModel, "chat_present": chatOK, "embedding_model": node.EmbeddingModel, "embedding_present": embedOK}
if chatOK && embedOK {
return true, details
}
}
if !anyEnabled {
return false, map[string]any{"error": "no enabled Ollama node configured"}
}
return false, details
}
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
cfg := s.store.Config()
sec := s.store.Secrets()
@@ -764,7 +883,26 @@ func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
components["embedding_route_configured"] = embedReady
clusterReady := !cfg.Cluster.Enabled || obs.ClusterLeaderID != ""
components["cluster"] = clusterReady
ready := configOK && chatReady && embedReady && clusterReady && (!cfg.API.RequireKey || sec.AppAPIKey != "")
ollamaLiveReady := true
if s.readinessOllamaLive {
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
var detail any
ollamaLiveReady, detail = checkConfiguredOllamaModels(ctx, cfg)
cancel()
components["ollama_live_models"] = detail
}
stagingReady := true
if s.brain != nil {
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
if err := s.brain.CheckStagingPublisher(ctx); err != nil {
stagingReady = false
components["kb_staging"] = err.Error()
} else {
components["kb_staging"] = true
}
cancel()
}
ready := configOK && chatReady && embedReady && clusterReady && ollamaLiveReady && stagingReady && (!cfg.API.RequireKey || sec.AppAPIKey != "")
status := http.StatusOK
if !ready {
status = http.StatusServiceUnavailable
@@ -24,7 +24,7 @@ func integrationRequest(t *testing.T, s *Server, method, path, token, body strin
func TestIntegrationKnowledgeLifecycleAndNamespaceIsolation(t *testing.T) {
s, _ := newMetricsTestServer(t)
key := s.store.Secrets().AppAPIKey
key := s.store.Secrets().IntegrationToken
unauth := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/knowledge/upsert", "", `{"namespace":"agent","document_id":"KB-1","chunks":[{"index":0,"text":"vpn","vector":[1,0],"content_hash":"a"}]}`)
if unauth.Code != http.StatusUnauthorized {
@@ -107,3 +107,27 @@ func TestIntegrationKnowledgeLifecycleAndNamespaceIsolation(t *testing.T) {
t.Fatalf("deleted document still searchable: %s", search.Body.String())
}
}
func TestScopedTokensCannotCrossTrustBoundaries(t *testing.T) {
s, _ := newMetricsTestServer(t)
sec := s.store.Secrets()
// The general App key is deliberately insufficient for integration writes.
if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/events", sec.AppAPIKey, `{"type":"test","source":"agent","message":"x"}`); rr.Code != http.StatusUnauthorized {
t.Fatalf("app key wrote integration event: status=%d body=%s", rr.Code, rr.Body.String())
}
// Control is read-only and cannot write integration events or generic learn.
if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/events", sec.ControlReadToken, `{"type":"test","source":"control","message":"x"}`); rr.Code != http.StatusUnauthorized {
t.Fatalf("control token wrote integration event: status=%d body=%s", rr.Code, rr.Body.String())
}
if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/learn", sec.ControlReadToken, `{"text":"must not learn"}`); rr.Code != http.StatusUnauthorized {
t.Fatalf("control token reached /learn: status=%d body=%s", rr.Code, rr.Body.String())
}
// Integration credentials are not generic app credentials either.
if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/learn", sec.IntegrationToken, `{"text":"must not learn"}`); rr.Code != http.StatusUnauthorized {
t.Fatalf("integration token reached /learn: status=%d body=%s", rr.Code, rr.Body.String())
}
if rr := integrationRequest(t, s, http.MethodGet, "/api/v1/stats", sec.ControlReadToken, ""); rr.Code != http.StatusOK {
t.Fatalf("control token cannot read stats: status=%d body=%s", rr.Code, rr.Body.String())
}
}
@@ -11,16 +11,16 @@ import (
"neuroforge/internal/core"
)
func appGraphRequest(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
func controlGraphRequest(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+s.store.Secrets().AppAPIKey)
req.Header.Set("Authorization", "Bearer "+s.store.Secrets().ControlReadToken)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
return rr
}
func TestIntegrationGraphEndpointsRequireAppKey(t *testing.T) {
func TestIntegrationGraphEndpointsRequireControlReadAuth(t *testing.T) {
s, _ := newMetricsTestServer(t)
for _, path := range []string{"/api/v1/integrations/graph/brain", "/api/v1/integrations/graph/research"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
@@ -38,7 +38,7 @@ func TestIntegrationBrainGraphIsBoundedAndRedacted(t *testing.T) {
if err := s.store.AddMemory(m); err != nil {
t.Fatal(err)
}
rr := appGraphRequest(t, s, "/api/v1/integrations/graph/brain?max_nodes=50")
rr := controlGraphRequest(t, s, "/api/v1/integrations/graph/brain?max_nodes=50")
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
@@ -72,7 +72,7 @@ func TestIntegrationResearchGraphShowsProvenanceChain(t *testing.T) {
if _, err := s.store.FinishResearchRun(run.ID, "completed", ""); err != nil {
t.Fatal(err)
}
rr := appGraphRequest(t, s, "/api/v1/integrations/graph/research?runs=2&max_events=50")
rr := controlGraphRequest(t, s, "/api/v1/integrations/graph/research?runs=2&max_events=50")
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
@@ -68,3 +68,60 @@ func TestReadinessEndpoint(t *testing.T) {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestReadinessLiveOllamaRequiresChatAndEmbeddingModels(t *testing.T) {
models := []string{"gemma3:latest"}
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/tags" {
http.NotFound(w, r)
return
}
items := make([]map[string]string, 0, len(models))
for _, name := range models {
items = append(items, map[string]string{"name": name})
}
_ = json.NewEncoder(w).Encode(map[string]any{"models": items})
}))
defer ollama.Close()
s, _ := newMetricsTestServer(t)
cfg := s.store.Config()
cfg.Ollama[0].BaseURL = ollama.URL
cfg.Ollama[0].ChatModel = "gemma3"
cfg.Ollama[0].EmbeddingModel = "embeddinggemma"
if err := s.store.UpdateConfig(cfg); err != nil {
t.Fatal(err)
}
s.SetReadinessOllamaLive(true)
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("missing embedding model: status=%d body=%s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), `"embedding_present":false`) {
t.Fatalf("readiness does not expose missing embedding model: %s", rr.Body.String())
}
models = append(models, "embeddinggemma:latest")
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("both models present: status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestEnvironmentManagedSecretCannotBeRotatedThroughAdminAPI(t *testing.T) {
s, _ := newMetricsTestServer(t)
t.Setenv("NEUROFORGE_APP_API_KEY", "environment-owned-app-key-1234567890")
admin := s.store.Secrets().AdminToken
req := httptest.NewRequest(http.MethodPut, "/admin/api/secrets", strings.NewReader(`{"app_api_key":"different-runtime-value-1234567890"}`))
req.Header.Set("X-Admin-Token", admin)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
}
@@ -33,7 +33,7 @@ func TestValidatedOutcomeLearnsTrustedProvenance(t *testing.T) {
body := `{"outcome_id":"out-1","run_id":"run-1","ticket_id":42,"decision":"accepted","ticket_input":"VPN verbindet nicht","proposed_reply":"VPN Client neu starten","confirmed_reply":"VPN Client neu starten","category_id":5,"category_name":"VPN","knowledge_id":"kb-vpn","actor":"tech-a"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey)
req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
@@ -62,7 +62,7 @@ func TestValidatedOutcomeRejectsUnconfirmedDecision(t *testing.T) {
s, _ := newMetricsTestServer(t)
sec := s.store.Secrets()
req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(`{"outcome_id":"o","run_id":"r","ticket_id":1,"decision":"rejected","ticket_input":"x","confirmed_reply":"y","actor":"tech"}`))
req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey)
req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
@@ -101,7 +101,7 @@ func TestValidatedOutcomeCorrectionSupersedesPriorMemoryAndSearchesOnlyActiveRev
sec := s.store.Secrets()
post := func(body string) map[string]any {
req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey)
req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
@@ -139,7 +139,7 @@ func TestValidatedOutcomeCorrectionSupersedesPriorMemoryAndSearchesOnlyActiveRev
}
search := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes/search", strings.NewReader(`{"text":"Drucker korrigierte Loesung","k":10,"min_similarity":0}`))
search.Header.Set("Authorization", "Bearer "+sec.AppAPIKey)
search.Header.Set("Authorization", "Bearer "+sec.IntegrationToken)
search.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, search)
+6 -1
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"time"
"neuroforge/internal/brain"
"neuroforge/internal/core"
)
@@ -91,7 +92,11 @@ func (s *Server) goalResume(w http.ResponseWriter, r *http.Request) {
func (s *Server) goalCycle(w http.ResponseWriter, r *http.Request) {
cycle, err := s.brain.RunGoalCycle(r.Context(), r.PathValue("id"))
if err != nil {
s.err(w, 400, err)
if errors.Is(err, brain.ErrGoalCycleInProgress) {
s.err(w, http.StatusConflict, err)
return
}
s.err(w, http.StatusBadRequest, err)
return
}
s.json(w, 200, cycle)