feat(gateway): 链路图冷启动快照(v0.71)+ 面板来源说明条 —— 修「轮次结束后打开页面一片空白」
- 断点已证 = **时序层**:graph_update 只走实时帧从不重放、flowGraphs 不持久化、补拉通路此前根本不存在(EndRound 注释承诺过「刷新能重放」,代码里从来没有)
铁证:15:16:38 页面重载抹掉 15:09 那轮图,而下一轮心跳要等 ~15:24 ⇒ 打开后台思考页时桶必然为空
- 新增只读端点 GET /api/v1/admin/flow/graph?sessionId=(owner|admin;省略=主会话;graph 与 graph_update 帧的 data 逐字同形)
无图 ⇒ 200 + graph:null(不画假图);图源未接线 ⇒ 500 flow_unavailable(不伪装成「没有图」);不审计、不发布
- loop.FlowPublisher.LastGraph:只读快照(不发布/不改图/不动轮次计数),FlowGraph.Clone 深拷贝避免锁外编码撕裂
- 前端 useFlowSnapshot:只在「本会话还没有图」时补拉(避免每行一个请求);Flow/Agents/Backthink 三处接入 + 来源说明条(明确「最近一轮」与「多轮历史回放尚未实现」)
- 变异实测:LastGraph 恒 nil ⇒ 4 条测试红;白名单去掉 sess-backthink ⇒ 对应测试红(首次变异因未使用 import 编译失败 ⇒ 已作废重做)
- ⚠️ 诚实边界:每会话只回**最近一轮**(多轮回放属 F2);白名单外会话(IM 群聊/陌生人)永远没有图;**后端重启后未跑新轮次前端点如实返 null(图是内存资产,不落库)**—— 主人要的「本地数据」正是 F2 要补的
This commit is contained in:
1 parent
a3482ed06e
commit
1bf85a955b
23 files changed
+1427
-36
No files matched your search
@@ -124,6 +124,12 @@ type app struct {
|
||||
// design/19 §2)。持有它是为了让 wireFrontend 能往已在运行的 tracer 上注册
|
||||
// 网关出口。
|
||||
traceWriter *traceFanout
|
||||
// flow 是 loop 的实时链路图发布器(design/19 §1)。持有它是因为网关还要一个
|
||||
// **只读**出口:`GET /api/v1/admin/flow/graph` 回「最近一轮」的快照,补上
|
||||
// 「轮次结束后才打开的页面」看不到图的那个洞(契约 v0.71)。组合根把**同一个**
|
||||
// 发布器同时交给 loop/orchestration(写入面)与网关(查询面),因此查询到的
|
||||
// 就是实时发布的那张图,不会出现两份状态。
|
||||
flow *loop.FlowPublisher
|
||||
}
|
||||
|
||||
// newApp 依据 cfg 与 log 组装完整的 Cyrene 运行时:打开数据库、迁移表结构,接线
|
||||
|
||||
@@ -85,6 +85,10 @@ func (a *app) wireFrontend() error {
|
||||
DataDir: resolveDataDir(a.cfg),
|
||||
Database: a.db,
|
||||
Vector: a.vectorState(),
|
||||
// Flow 是「最近一轮链路图」的只读出口(契约 v0.71):注入的正是上面
|
||||
// loop/orchestration 用的**同一个** *loop.FlowPublisher,因此
|
||||
// GET /api/v1/admin/flow/graph 读到的就是实时发布的那张图。
|
||||
Flow: a.flow,
|
||||
// Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的
|
||||
// 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500
|
||||
// usage_unavailable,不回假汇总)。
|
||||
|
||||
@@ -218,12 +218,13 @@ func (a *app) wireRuntime() error {
|
||||
// 发布会话范围(裁决 01.4.5)的既有裁决,本批未改。nil flow 保持 loop 原行为。
|
||||
flowPub := loop.NewFlowPublisher(
|
||||
func(subject string, payload any) { _ = a.bus.Publish(context.Background(), subject, payload) },
|
||||
func(sessionID string) bool {
|
||||
return sessionID == history.MainSessionID ||
|
||||
strings.HasPrefix(sessionID, history.MainSessionID+":") ||
|
||||
sessionID == backthink.DefaultSessionID
|
||||
},
|
||||
flowSessionAllowed,
|
||||
)
|
||||
// 同一个发布器也交给网关:`GET /api/v1/admin/flow/graph` 的只读出口
|
||||
// (契约 v0.71)就近读它每会话保留的最近一轮图 —— 写入面(这里)与查询面
|
||||
// (wireFrontend)必须**是同一个对象**,否则面板补拉到的图与实时收到的图
|
||||
// 会是两份状态。
|
||||
a.flow = flowPub
|
||||
checkpoint, perr := persist.New(context.Background(), a.db, a.log)
|
||||
if perr != nil {
|
||||
return fmt.Errorf("checkpoint store: %w", perr)
|
||||
@@ -417,3 +418,34 @@ func (a *app) defaultInstructions() string {
|
||||
}
|
||||
return fallbackInstruction
|
||||
}
|
||||
|
||||
// flowSessionAllowed 是实时链路图的**发布会话白名单**(design/19 §1 裁决 01.4.5
|
||||
// + 裁决 16.4.2),也是后端唯一一条「哪些会话能产出链路图」的判据 ——
|
||||
// `FlowPublisher.LastGraph`(契约 v0.71 的只读快照出口)读的就是按它建过图的那些
|
||||
// 会话,因此白名单外的会话**既发不出实时帧、也查不到快照**。
|
||||
//
|
||||
// 三档(本项目当前实现口径,逐档都有理由):
|
||||
// - `web`:Web 主会话(`history.MainSessionID`)—— 主人对话的正身;
|
||||
// - `web:<uuid>`:Web 次级会话(IM 主人私聊镜像也落这个族);
|
||||
// - `sess-backthink`:自主后台思考的固定内部会话(design/20 §2)。
|
||||
//
|
||||
// **隔离的渠道会话(群聊 / 陌生人,`sess-xxx`)不在此列** —— 那是防敏感信息
|
||||
// 泄向管理面板的既有裁决,不是省事。子代理的 span 由 `loop.WithFlowBranch`
|
||||
// 并进**派生它的那个会话**(design/19 §2),因此不需要单独一档。
|
||||
//
|
||||
// 抽成具名函数只为可测:白名单是裁决本身,删掉任何一档都必须有测试打红
|
||||
// (见 wire_runtime_test.go 的 TestFlowSessionAllowed)。
|
||||
func flowSessionAllowed(sessionID string) bool {
|
||||
switch {
|
||||
case sessionID == "":
|
||||
return false
|
||||
case sessionID == history.MainSessionID:
|
||||
return true
|
||||
case strings.HasPrefix(sessionID, history.MainSessionID+":"):
|
||||
return true
|
||||
case sessionID == backthink.DefaultSessionID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// 链路图发布会话白名单是**裁决本身**(design/19 §1 裁决 01.4.5 + 裁决 16.4.2),
|
||||
// 不是实现细节:它决定哪些会话能产出实时帧,也决定契约 v0.71 的只读快照出口
|
||||
// (`GET /api/v1/admin/flow/graph`)能查到什么。删掉任何一档都必须打红。
|
||||
//
|
||||
// 这条测试同时钉住「隔离的渠道会话绝不进白名单」—— 群聊 / 陌生人会话一旦被
|
||||
// 发进管理面板,就是把主人的私聊以外的内容泄漏给面板,属红线。
|
||||
func TestFlowSessionAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
allowed := []string{
|
||||
"web",
|
||||
"web:0f3a0b1c-2d3e-4f50-8a9b-1c2d3e4f5061",
|
||||
"web:another-uuid",
|
||||
"sess-backthink",
|
||||
}
|
||||
for _, session := range allowed {
|
||||
if !flowSessionAllowed(session) {
|
||||
t.Errorf("flowSessionAllowed(%q) = false, want true(该档属 design/19 §1 的发布会话范围)", session)
|
||||
}
|
||||
}
|
||||
|
||||
denied := []string{
|
||||
"",
|
||||
"webbish", // 与 "web" 前缀相似但不是主会话族
|
||||
"webx:uuid", // 前缀不匹配必须整档拒绝(HasPrefix 只认 "web:")
|
||||
"sess-im-12345", // IM 隔离会话(群聊)
|
||||
"sess-stranger-999", // 陌生人私聊
|
||||
"sess-memorytidy", // 系统任务会话(design/23,刻意不在白名单)
|
||||
"sess-backthink-2", // 后缀不同即不是那个固定会话
|
||||
"sess-backthink ", // 尾部空格不得被宽容
|
||||
}
|
||||
for _, session := range denied {
|
||||
if flowSessionAllowed(session) {
|
||||
t.Errorf("flowSessionAllowed(%q) = true,但它必须被拒(隔离会话/系统任务/非法 id 一律不进白名单)", session)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user