design/11 §7.1(主人 2026-10-03 拍板)。此前 hub.broadcast 是全连接广播,而任意角色
(含 viewer)都能连 /ws/chat ⇒ viewer 能收到角色回复正文(message.*)、推理内容
(thinking.*)、消息增量、graph_update、mood 等。这与 design/19 §1/§3「flow 可视化仅
owner/admin 可见」冲突,也与 GET /sessions/{id}/messages 的 owner/admin 档位不一致。
收口:唯一分类点 internal/services/gateway/ws_frame_scope.go 的 frameReachesAll(typ)
(fail-closed switch,默认 false);hub.broadcast 投递前查表,白名单外对非 owner/admin
连接直接跳过。**不给 broadcast 加 bool 参数、不让调用点自己判断** —— 那正是"漏一处就是
泄漏"的形状。全包只有一个 fan-out 循环,14 个 .broadcast( 调用点全部落进该入口。
- 白名单只有 7 个状态机信号帧(不含任何内容):session_busy / pending_ack / pending_drain /
session_idle / session_interrupted / pending_full / session_read
- 其余全部(流式 message/thinking/tool_call、message_persisted/edited/deleted、graph_update、
subagent_event/result、task_output/task_done、automation_result、tool_used、capacity_awaken、
mode_switch、mood、device_update、reminder)仅 owner+admin
- 语义澄清:本门管「谁被主动推送」,数据可见性仍由 REST 决定(viewer 的 12 条只读监控端点
不变,含 GET /personas 的 mood);/ws/chat 连接鉴权与 voice_output 定向帧不动
契约:Docs/api-client.md → v0.48(对 viewer 是收紧;owner/admin 零变化;无端点/字段形状变更)
验证:go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;-race ./internal/services/gateway/... ok;
linux/amd64 + darwin/arm64 交叉编译绿。真 httptest WS + 真 JWT 按角色逐帧实测 37 帧:
viewer 只收到 7 个状态信号,owner/admin 37/37。
变异验证:① graph_update 加回白名单 → 真 WS 抓到 viewer 收到 graph_update,3 个测试 FAIL;
② session_idle 移出白名单 → 3 个测试 FAIL(viewer 等不到哨兵帧而超时)。均已还原。
遗留(已登记,待主人裁决):① 握手 history 回放对任意角色照发(viewer 连上即得主会话最近
50 条正文);② /ws/chat 上行未按角色授权(viewer 可发 chat = 真驱动模型并落库;
subagent_ask / task_output_query 的定向应答绕过本门)。
214 lines
7.7 KiB
Go
214 lines
7.7 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 — those transitions
|
|
// are the design/11 §7.1 「状态机信号」, so every authenticated connection may follow
|
|
// them. 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)
|
|
}
|