- server.go 204 + 新 routes.go(193,路由注册/Handler/withMiddleware)/deps.go(108)/contracts.go(231,17依赖接口)/broadcast.go(78,BroadcastPersisted/CapacityAwaken/Mood); 导出面不变; 74条路由pattern+RBAC门逐条比对一致 - model/openai_stream.go: scanSSE 138→25(parse/apply/finalize 对齐 anthropic_stream); openaiStreamState结构体 - channelbridge/inbound.go: processFrom 112→46(blacklistDrop/inboundTargetFor/runInboundTurn/deliverReply) - 纯行为等价; build/vet/test绿(56包)+win/darwin交叉编译
205 lines
7.1 KiB
Go
205 lines
7.1 KiB
Go
// Package gateway implements the Cyrene v2 HTTP gateway: the only socket the
|
|
// single binary opens. It serves the versioned REST API (/api/v1), the realtime
|
|
// WebSocket hub (/ws/chat) and the RBAC-protected admin API (/api/v1/admin/*)
|
|
// all on one port (:8080).
|
|
//
|
|
// It is deliberately lean. All backend capability flows through injected
|
|
// dependencies (construction-time dependency injection) and the in-process bus,
|
|
// never through cross-process IPC, so the package never opens a listener of its
|
|
// own beyond the gateway port.
|
|
//
|
|
// Client contract (DevDocs/02-development-constraints.md §九, §十一):
|
|
// - REST responses are {data} on success and {error:{code,message}} on error.
|
|
// - Authentication is JWT bearer (internal/auth); authorization is the
|
|
// owner>admin>viewer RBAC hierarchy (internal/auth).
|
|
// - Admin writes are audited (who/what/when/result).
|
|
// - Realtime events follow DevDocs/design/07 §3 (WS protocol).
|
|
//
|
|
// Layout: contracts.go declares the injected dependency interfaces, deps.go the
|
|
// Deps bundle, server.go the Server lifecycle (construct / serve / close),
|
|
// routes.go the route table + outer middleware, broadcast.go the outbound
|
|
// realtime pushes.
|
|
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
|
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// source is the log provenance tag for every record this package emits.
|
|
const source = "gateway"
|
|
|
|
// Server is the gateway. It owns a ServeMux aggregating REST + WS + admin and a
|
|
// WebSocket hub. It is safe for concurrent use once constructed.
|
|
type Server struct {
|
|
cfg *config.Config
|
|
auth Authenticator
|
|
bus bus.Bus
|
|
caps CapabilityStore
|
|
runner Chatter
|
|
sessions SessionStore
|
|
personas PersonaStore
|
|
memories MemoryStore
|
|
history HistoryStore
|
|
iot IoTStore
|
|
auditor SubagentAuditor
|
|
taskOutputs TaskOutputStore
|
|
automations AutomationStore
|
|
users UserStore
|
|
refresh RefreshGrantStore
|
|
backthink BackthinkStore
|
|
rag RagStore
|
|
obs *observability
|
|
voice VoiceService
|
|
streaming bool
|
|
frontend fs.FS
|
|
dbPath string
|
|
// dataDir is the resolved data directory backing the upload store
|
|
// (<dataDir>/uploads). Injected by the composition root; cfg.DataDir is the
|
|
// fallback. See Deps.DataDir.
|
|
dataDir string
|
|
// llm is the hot-swappable provider stack (models.json). When nil the
|
|
// admin /llm endpoints fall back to the env config. It is optional so tests
|
|
// that do not wire it keep working.
|
|
llm *model.RuntimeLLM
|
|
channels ChannelConfigStore
|
|
// plugins is the runtime-installable external tool provider manager. See
|
|
// Deps.Plugins.
|
|
plugins *pluginmgr.Manager
|
|
// ownerRegistrar stamps a scan-login bound owner id into the owner whitelist
|
|
// (best-effort). See Deps.OwnerRegistrar.
|
|
ownerRegistrar OwnerRegistrar
|
|
webhooks map[string]http.Handler
|
|
log *slog.Logger
|
|
hub *hub
|
|
}
|
|
|
|
// New validates cfg and deps and returns a ready Server. Auth is required; the
|
|
// WebSocket hub subscribes to deps.Bus immediately so events published before
|
|
// ListenAndServe are still relayed to clients.
|
|
func New(cfg *config.Config, deps Deps) (*Server, error) {
|
|
if cfg == nil {
|
|
return nil, fmt.Errorf("gateway: nil config")
|
|
}
|
|
if deps.Auth == nil {
|
|
return nil, fmt.Errorf("gateway: auth dependency required")
|
|
}
|
|
s := &Server{
|
|
cfg: cfg,
|
|
auth: deps.Auth,
|
|
bus: deps.Bus,
|
|
caps: deps.Registry,
|
|
runner: deps.Loop,
|
|
sessions: deps.Session,
|
|
personas: deps.Personas,
|
|
memories: deps.Memories,
|
|
history: deps.History,
|
|
iot: deps.IoT,
|
|
auditor: deps.Scheduler,
|
|
taskOutputs: deps.Tasks,
|
|
automations: deps.Automations,
|
|
users: deps.Users,
|
|
refresh: deps.Refresh,
|
|
backthink: deps.Backthink,
|
|
rag: deps.RAG,
|
|
obs: newObservability(deps),
|
|
streaming: deps.Streaming,
|
|
frontend: deps.Frontend,
|
|
llm: deps.LLM,
|
|
channels: deps.Channels,
|
|
plugins: deps.Plugins,
|
|
ownerRegistrar: deps.OwnerRegistrar,
|
|
webhooks: deps.Webhooks,
|
|
dbPath: deps.DatabasePath,
|
|
dataDir: deps.DataDir,
|
|
log: sourcedLog(deps.Log),
|
|
}
|
|
s.hub = newHub(s)
|
|
s.hub.orchestrator = newOrchestrator(s)
|
|
s.hub.start()
|
|
return s, nil
|
|
}
|
|
|
|
// Start launches the gateway in a goroutine, returning a channel that carries the
|
|
// ListenAndServe result. The server runs until ctx is canceled.
|
|
func (s *Server) Start(ctx context.Context) (<-chan error, error) {
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- s.ListenAndServe(ctx) }()
|
|
return errCh, nil
|
|
}
|
|
|
|
// ListenAndServe binds the configured host:port (default :8080) and blocks until
|
|
// ctx is canceled, then gracefully drains in-flight requests.
|
|
func (s *Server) ListenAndServe(ctx context.Context) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
addr := net.JoinHostPort(s.cfg.Server.Host, strconv.Itoa(s.cfg.Server.Port))
|
|
httpSrv := &http.Server{Addr: addr, Handler: s.Handler()}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
<-ctx.Done()
|
|
s.log.Info("gateway shutting down", "addr", addr)
|
|
s.hub.close()
|
|
shCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
_ = httpSrv.Shutdown(shCtx)
|
|
close(done)
|
|
}()
|
|
|
|
s.log.Info("gateway listening", "addr", addr)
|
|
err := httpSrv.ListenAndServe()
|
|
if err != nil && err != http.ErrServerClosed {
|
|
return fmt.Errorf("gateway: listen: %w", err)
|
|
}
|
|
<-done
|
|
return nil
|
|
}
|
|
|
|
// Close frees the hub's bus subscription and closes live connections.
|
|
func (s *Server) Close() { s.hub.close() }
|
|
|
|
// newOrchestrator builds the session-message orchestrator (design/15) bound to
|
|
// this server's hub. The TurnRunner shares the hub's runTurn (so a turn always
|
|
// emits the same stream contract), and the StateSink fans per-session state
|
|
// transitions out through the hub broadcast for the frontend. It is wrapped in a
|
|
// function so the composition root can later override the injection (an explicit
|
|
// wiring point rather than a hard-coded construct).
|
|
func newOrchestrator(s *Server) *Orchestrator {
|
|
return NewOrchestrator(OrchestratorOptions{
|
|
Run: func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
|
return s.hub.runTurn(ctx, sessionID, messages, env)
|
|
},
|
|
Emit: func(sessionID, typ string, data map[string]any) {
|
|
s.hub.broadcast(wsEvent{Type: typ, SessionID: sessionID, Data: data})
|
|
},
|
|
OnPersisted: s.BroadcastPersisted,
|
|
Log: s.log,
|
|
History: s.history,
|
|
})
|
|
}
|
|
|
|
// sourcedLog returns a logger tagged with source "gateway", substituting a
|
|
// discard logger for nil so Server can always log safely.
|
|
func sourcedLog(l *slog.Logger) *slog.Logger {
|
|
if l == nil {
|
|
l = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
return logger.WithSource(l, source)
|
|
}
|