Files
Agent-System/internal/controlcenter/server/server.go
T

191 lines
6.8 KiB
Go

// Package server exposes the Control Center HTTP API. Loopback-first, token
// authenticated: console token for the human UI, per-agent bearer tokens for
// /api/v1/agent/*.
package server
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"chat-agent-system/internal/controlcenter/audit"
"chat-agent-system/internal/controlcenter/config"
"chat-agent-system/internal/controlcenter/docs"
"chat-agent-system/internal/controlcenter/embed"
"chat-agent-system/internal/controlcenter/store"
"chat-agent-system/internal/controlcenter/webui"
)
type ctxKey string
const ctxAgent ctxKey = "cc-agent"
// AgentFromCtx returns the authenticated agent name, or "" for console users.
func AgentFromCtx(ctx context.Context) string {
v, _ := ctx.Value(ctxAgent).(string)
return v
}
type Server struct {
cfg config.Config
store *store.Store
agents *AgentAuthenticator
mux *http.ServeMux
broker *Broker
emb embed.Embedder // nil = search degrades to keyword-only
docSync *docs.Syncer // nil = artifact indexing disabled
audit *audit.Runner // nil = audit endpoints unavailable
}
func New(cfg config.Config, st *store.Store) *Server {
s := &Server{
cfg: cfg,
store: st,
agents: &AgentAuthenticator{st: st},
mux: http.NewServeMux(),
broker: NewBroker(),
}
s.routes()
return s
}
func (s *Server) Handler() http.Handler { return s.authMiddleware(s.mux) }
func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Agent API
s.mux.HandleFunc("GET /api/v1/agent/tasks/next", s.handleAgentNext)
s.mux.HandleFunc("GET /api/v1/agent/tasks/{id}/instruction", s.handleAgentInstruction)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/claim", s.handleAgentClaim)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/heartbeat", s.handleAgentHeartbeat)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/events", s.handleAgentEvent)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/artifacts", s.handleAgentArtifact)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/advance-request", s.handleAgentAdvanceRequest)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/decisions", s.handleAgentDecision)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/release", s.handleAgentRelease)
s.mux.HandleFunc("POST /api/v1/agent/reports", s.handleAgentReport)
// Console API
s.mux.HandleFunc("POST /api/v1/plans", s.handlePlanCreate)
s.mux.HandleFunc("GET /api/v1/plans", s.handlePlanList)
s.mux.HandleFunc("PATCH /api/v1/plans/{id}", s.handlePlanPatch)
s.mux.HandleFunc("POST /api/v1/tasks", s.handleTaskCreate)
s.mux.HandleFunc("GET /api/v1/tasks", s.handleTaskList)
s.mux.HandleFunc("GET /api/v1/tasks/{id}", s.handleTaskGet)
s.mux.HandleFunc("PATCH /api/v1/tasks/{id}", s.handleTaskPatch)
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/advance", s.handleTaskAdvance)
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/send-back", s.handleTaskSendBack)
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/{stage}/external-submit", s.handleTaskExternalSubmit)
s.mux.HandleFunc("GET /api/v1/tasks/{id}/pipeline", s.handleTaskPipeline)
s.mux.HandleFunc("POST /api/v1/tasks/{id}/export-doc", s.handleTaskExportDoc)
s.mux.HandleFunc("GET /api/v1/decisions", s.handleDecisionList)
s.mux.HandleFunc("POST /api/v1/decisions/{id}/resolve", s.handleDecisionResolve)
s.mux.HandleFunc("GET /api/v1/stage-settings", s.handleStageSettingsGet)
s.mux.HandleFunc("PUT /api/v1/stage-settings", s.handleStageSettingsPut)
s.mux.HandleFunc("GET /api/v1/overview", s.handleOverview)
s.mux.HandleFunc("GET /api/v1/stream", s.handleStream)
s.mux.HandleFunc("GET /api/v1/docs", s.handleDocList)
s.mux.HandleFunc("GET /api/v1/docs/search", s.handleDocSearch)
s.mux.HandleFunc("GET /api/v1/docs/{id}", s.handleDocGet)
s.mux.HandleFunc("GET /api/v1/audits/runs", s.handleAuditRunList)
s.mux.HandleFunc("GET /api/v1/audits/findings", s.handleAuditFindingList)
s.mux.HandleFunc("POST /api/v1/audits/run", s.handleAuditRun)
s.mux.HandleFunc("POST /api/v1/audits/findings/{id}/convert", s.handleAuditFindingConvert)
s.mux.HandleFunc("POST /api/v1/audits/findings/{id}/dismiss", s.handleAuditFindingDismiss)
// SPA (embedded build of control-center/web); auth middleware already
// passes non-/api paths through.
s.mux.Handle("GET /", webui.Handler())
}
// SetDocs attaches the embedding client and document syncer used by the
// /api/v1/docs search routes and asynchronous artifact indexing.
func (s *Server) SetDocs(emb embed.Embedder, syncer *docs.Syncer) {
s.emb = emb
s.docSync = syncer
}
// indexArtifactAsync indexes an artifact's markdown in the background;
// failures are logged only.
func (s *Server) indexArtifactAsync(artifactID string) {
if s.docSync == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := s.docSync.IndexArtifact(ctx, artifactID); err != nil {
slog.Error("artifact indexing", "artifact", artifactID, "err", err)
}
}()
}
func (s *Server) Store() *store.Store { return s.store }
func (s *Server) Config() config.Config { return s.cfg }
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if p == "/healthz" || !strings.HasPrefix(p, "/api/") {
next.ServeHTTP(w, r)
return
}
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return
}
if strings.HasPrefix(p, "/api/v1/agent/") {
name, ok := s.agents.Authenticate(r.Context(), token)
if !ok {
writeError(w, http.StatusUnauthorized, "invalid agent token")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxAgent, name)))
return
}
if subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.ConsoleToken)) != 1 {
writeError(w, http.StatusUnauthorized, "invalid console token")
return
}
next.ServeHTTP(w, r)
})
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if rest, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(rest)
}
if q := r.URL.Query().Get("token"); q != "" && !strings.HasPrefix(r.URL.Path, "/api/") {
return q
}
return ""
}
func TokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("write json", "err", err)
}
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}