主人要「现在就想看到在 736 条上它会建议合并哪些」,而不是等 23:00 的低峰首跑。
- 后端:Tidier.DryRun + TidyService.DryRun,**复用同一条扫描→召回→判定→构造候选路径**(抽出 tidyRoundPolicy,生产轮与试算轮只差「候选出口」与「召回降级策略」两处;禁止第二套实现)
- 🔴 零副作用:不写 memory_tidy_candidates / memory_tidy_runs,绝不改 memory_items 任何列;机械证明 = 跑前跑后 cyrene.db 与 -wal 的 SHA256 一致(测试断言「表行数不变」)
- 失败码诚实:500 tidy_unavailable / 409 tidy_disabled / 500 tidy_recall_unavailable(**embedding 不可用不降级成 0 条**)/ 503 tidy_timeout / 500 tidy_dry_run_failed;**空数组唯一含义 = 算过了没建议**
- 端点 POST /api/v1/admin/memories/tidy/dry-run(writeAuth);面板「记忆」页加试算卡(点了才跑、无轮询、三态分开、明写「仅试算,不会改动任何记忆」)
- 真机 e2e(741 条活库副本):76 ms / 15 分区 / 53 候选 / 41 skipped / 1 vetoed(low_confidence);样例 merge reason=fact_subset score=1.0000、link reason=similar_but_not_contained score=0.9248
- 变异:dry-run 顺手写库 ⇒ 两条测试红(行数 0→2);自造阈值 ⇒ 红;失败回 200 空数组 ⇒ 红
- 如实登记:Compared=688 被 400/分区护栏截断、候选上限 20/分区被打满 ⇒ 护栏值需在 741 条量级重定(非 bug)
253 lines
10 KiB
Go
253 lines
10 KiB
Go
// Package gateway 实现 Cyrene v2 的 HTTP 网关:单二进制唯一打开的套接字。
|
||
// 它在同一个端口(:8080)上提供版本化 REST API(/api/v1)、实时
|
||
// WebSocket hub(/ws/chat)以及受 RBAC 保护的管理 API(/api/v1/admin/*)。
|
||
//
|
||
// 它是刻意精简的。所有后端能力都经由注入的依赖(构造期依赖注入)和
|
||
// 进程内 bus 流动,绝不跨进程 IPC,因此本包除网关端口外永不自行
|
||
// 打开监听器。
|
||
//
|
||
// 客户端契约(DevDocs/02-development-constraints.md §九、§十一):
|
||
// - REST 成功响应为 {data},错误响应为 {error:{code,message}}。
|
||
// - 认证是 JWT bearer(internal/auth);授权是 owner>admin>viewer 的
|
||
// RBAC 层级(internal/auth)。
|
||
// - 管理写操作有审计(谁/做了什么/何时/结果)。
|
||
// - 实时事件遵循 DevDocs/design/07 §3(WS 协议)。
|
||
//
|
||
// 文件布局:contracts.go 声明注入的依赖接口,deps.go 声明 Deps 组合,
|
||
// server.go 是 Server 生命周期(构造 / 服务 / 关闭),routes.go 是路由表
|
||
// 加外层中间件,broadcast.go 是下行实时推送。
|
||
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"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 是本包每条日志记录的来源标签。
|
||
const source = "gateway"
|
||
|
||
// Server 是网关本体。它持有聚合 REST + WS + admin 的 ServeMux 与一个
|
||
// WebSocket hub。构造完成后可安全并发使用。
|
||
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 是支撑上传存储的已解析数据目录(<dataDir>/uploads)。
|
||
// 由组合根注入;cfg.DataDir 是回退值。见 Deps.DataDir。
|
||
dataDir string
|
||
// llm 是可热切换的提供方栈(models.json)。为 nil 时 admin 的 /llm
|
||
// 端点回退到环境变量配置。它是可选的,以便未接它的测试仍能工作。
|
||
llm *model.RuntimeLLM
|
||
// envPath 是启动时解析的 `.env` 路径(见 Deps.EnvPath):只供
|
||
// 「重置为 env 默认」动作按既有解析入口重读 env 默认层,网关不 fopen `.env`。
|
||
envPath string
|
||
channels ChannelConfigStore
|
||
// plugins 是可在运行时安装的外部工具提供方管理器。见
|
||
// Deps.Plugins。
|
||
plugins *pluginmgr.Manager
|
||
// ownerRegistrar 把扫码登录绑定的 owner id 写入 owner 白名单
|
||
// (尽力而为)。见 Deps.OwnerRegistrar。
|
||
ownerRegistrar OwnerRegistrar
|
||
webhooks map[string]http.Handler
|
||
// usage 是 LLM 用量汇总的只读出口(切片 U1,见 Deps.Usage)。为 nil 时
|
||
// GET /api/v1/admin/llm/usage 回 500 usage_unavailable。
|
||
usage LlmUsageStore
|
||
// channelEvents 是渠道事件留存的只读查询出口(切片 E6,见 Deps.EventStore)。
|
||
// 为 nil 时 GET /api/v1/admin/channels/events(及其 /counters)回
|
||
// 500 channel_events_unavailable —— 未接线就说未接线,不回全 0 假汇总。
|
||
channelEvents ChannelEventStore
|
||
// db 是组合根注入的活 SQLite 连接池(见 Deps.Database)。管理侧
|
||
// 「存储状态」只读端点用它读页级统计与 documents 表;为 nil 时该
|
||
// 端点回 500 storage_unavailable(不另开池、不假装有数据)。
|
||
db *sql.DB
|
||
// vector 是组合根报告的 pgvector 增强运行时状态(见
|
||
// Deps.Vector / VectorState)。零值 = 未配置。
|
||
vector VectorState
|
||
// maxParallelTurns 是单会话在途并行路数的上限(见 Deps.MaxParallelTurns)。
|
||
// 零值 = 未接线 ⇒ 编排器用默认 2。
|
||
maxParallelTurns int
|
||
// flow 是「最近一轮链路图」的只读出口(见 Deps.Flow / FlowGraphSource)。
|
||
// 为 nil 时 GET /api/v1/admin/flow/graph 回 500 flow_unavailable。
|
||
flow FlowGraphSource
|
||
// flowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;见 Deps.FlowGraphs)。
|
||
// 为 nil 时该端点回退到只有内存的行为(turns 为空、turnId 只能命中内存那一轮)。
|
||
flowGraphs FlowGraphStore
|
||
// memoryTidy 是记忆整理「试算」的注入出口(契约 v0.75;见 Deps.MemoryTidy)。
|
||
// 为 nil 时 POST /api/v1/admin/memories/tidy/dry-run 回 500 tidy_unavailable。
|
||
memoryTidy MemoryTidyDryRunner
|
||
// envResolver 解析会话每轮的 loop 环境(其角色)。见
|
||
// persona_env.go / design/22 §4.5。
|
||
envResolver turnEnvResolver
|
||
log *slog.Logger
|
||
hub *hub
|
||
}
|
||
|
||
// New 校验 cfg 与 deps 并返回就绪的 Server。Auth 必填;WebSocket hub
|
||
// 会立即订阅 deps.Bus,因此 ListenAndServe 之前发布的事件仍会转发给
|
||
// 客户端。
|
||
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,
|
||
envPath: deps.EnvPath,
|
||
channels: deps.Channels,
|
||
plugins: deps.Plugins,
|
||
ownerRegistrar: deps.OwnerRegistrar,
|
||
webhooks: deps.Webhooks,
|
||
usage: deps.Usage,
|
||
channelEvents: deps.EventStore,
|
||
dbPath: deps.DatabasePath,
|
||
dataDir: deps.DataDir,
|
||
db: deps.Database,
|
||
vector: deps.Vector,
|
||
log: sourcedLog(deps.Log),
|
||
maxParallelTurns: deps.MaxParallelTurns,
|
||
flow: deps.Flow,
|
||
flowGraphs: deps.FlowGraphs,
|
||
memoryTidy: deps.MemoryTidy,
|
||
}
|
||
// 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner
|
||
// 的入口(REST、WS、语音)以同样方式解析会话角色(design/22 §4.5),
|
||
// 而不是各自硬编码 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 在 goroutine 中启动网关,返回携带 ListenAndServe 结果的
|
||
// channel。服务器运行至 ctx 被取消。
|
||
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 绑定配置的 host:port(默认 :8080)并阻塞至 ctx 被
|
||
// 取消,随后优雅地排空在途请求。
|
||
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 释放 hub 的 bus 订阅并关闭存活连接。
|
||
func (s *Server) Close() { s.hub.close() }
|
||
|
||
// newOrchestrator 构建绑定到本 server 的 hub 的会话消息 orchestrator
|
||
// (design/15)。TurnRunner 复用 hub 的 runTurn(因此一轮总是发出相同的
|
||
// 流契约),StateSink 则把每会话状态迁移经由 hub 广播扇出给前端 ——
|
||
// 这些迁移属于 design/11 §7.1 的「状态机信号」,因此每个已认证连接都
|
||
// 可以跟随它们。它被包在函数里,以便组合根之后能覆盖注入(一个显式
|
||
// 的接线点,而不是硬编码的构造)。
|
||
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})
|
||
},
|
||
// EmitTurn 是带 turnId 的变体(design/15 §2.5.9):帧壳上的 turnId 让客户端
|
||
// 把「这条状态帧属于哪一路」与流帧对齐 —— 真并行下同一会话有两条交错的路,
|
||
// 只按 sessionId 分桶会把它们糊在一起。
|
||
EmitTurn: func(sessionID, turnID, typ string, data map[string]any) {
|
||
s.hub.broadcast(wsEvent{Type: typ, SessionID: sessionID, TurnID: turnID, Data: data})
|
||
},
|
||
OnPersisted: s.BroadcastPersisted,
|
||
Log: s.log,
|
||
History: s.history,
|
||
MaxParallelTurns: s.maxParallelTurns,
|
||
})
|
||
}
|
||
|
||
// sourcedLog 返回带 source "gateway" 标签的 logger,nil 时替换为丢弃型
|
||
// logger,使 Server 总能安全地打日志。
|
||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||
if l == nil {
|
||
l = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
}
|
||
return logger.WithSource(l, source)
|
||
}
|