Files
Cyrene_v2/internal/services/gateway/server.go
T
AskaEth 0624d9ee36
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(session,runtime): 多角色并存—按会话选角(迁移0011 + 按回合解析 + agent池 + 提取器修正)
按 DevDocs/design/22 批次 1 后端(§2/§3/§4.1-4.5/§4.7/§5 验收 1-6)。
- 迁移 0011:chat_sessions.persona_id + chat_messages.persona_id(均 DEFAULT '',无回填 → 升级零风险);同步包内幂等 DDL;**真实库升级验证**(拷贝 data/cyrene.db 到临时目录跑 MigrateUp:既有 1 会话/109 消息 persona 仍为 '',原库未被触碰)
- history:SessionMeta/Message 加 PersonaID + SetSessionPersona;ListSessions/List/scan/Append 带上
- runtime:SessionEnv.PersonaID + loop.personaFor(ctx) 替换固定 l.personaID(memory/request/turn)+ **agent 池**(Options.Agents,按角色取 instructions,未命中回落)+ **提取器不再把 persona 烤死在构造期**(多角色下记忆不再永远记在 cyrene 名下)+ **thinking 旋钮改为按会话角色解析**(否则角色 Y 会用角色 X 的预算/展示)
- gateway:会话 DTO/POST/PATCH 支持 personaId;ws/rest/voice 的 OwnerEnv 硬编改为**按会话解析角色**(actor 元数据语义不变);admin memories 搜索去掉硬编 cyrene
- Docs/api-client v0.42;**两处有意的行为收紧**已写明:POST /sessions body 非法 JSON → 400(原先完全忽略 body;空/null/{} 仍 201);POST /admin/memories/search 省略 personaId 且无默认角色 → 400(原先默认查 cyrene)
- 测试:design/22 §5 验收 1-6(记忆隔离/提示词首条 system/情绪按角色/提取 persona 归属/迁移兼容)+ 进程内 e2e(断言 adapter 收到的 model.Request.Messages[0] 即该角色的 systemPrompt)
- **已知未做(留批次 2 / 待确认)**:① IM 与渠道级未做 → **§7-3「owner 私聊镜像随主会话角色」尚未生效**;② 请求级覆盖未实现(本批只做会话级);③ **§7-5 的「上下文发送角色标记」只完成存储+DTO 半边**——消息 persona 已落库且 DTO 已带,但模型上下文不从 chat_messages 重建,「第 N 条是谁说的」尚未进上下文(需确认是否属批次 2);④「切角色重建摘要」当前是空操作(压缩未接线);⑤ thinking 旋钮仅在流式路径生效(非流式 agent.RunStep 不带,A 批次既有性质)
- 验收:go build/vet/test 全绿 + linux/amd64 与 darwin/arm64 交叉编译(既有 flake 本轮未触发;同批 session_orchestrator.go 一度 501 行 → 抽出 session_tail.go)
2026-10-03 20:12:29 +08:00

212 lines
7.5 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
// envResolver resolves the per-turn loop environment (its role) for a session.
// See persona_env.go / design/22 §4.5.
envResolver turnEnvResolver
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),
}
// One role resolver shared by the Server handlers and the hub, so every
// owner-facing entry point (REST, WS, voice) resolves the session's role the
// same way (design/22 §4.5) instead of each hardcoding loop.OwnerEnv().
s.envResolver = turnEnvResolver{history: deps.History, personas: deps.Personas, log: s.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)
}