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
@@ -68,8 +68,10 @@ func (p *FlowPublisher) StartRound(sessionID, inputText string) {
|
||||
}
|
||||
|
||||
// EndRound 用回复播种一个 output 节点并发布最终帧,
|
||||
// 以此收尾该轮次。它保留该图,使错过最终帧的客户端
|
||||
// 刷新时能重放最后一份快照。
|
||||
// 以此收尾该轮次。它保留该图,使错过最终帧的客户端能重放最后一份快照 ——
|
||||
// **重放的出口是 `LastGraph`**(网关只读查询,契约 v0.71),而不是又发一帧:
|
||||
// 实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取补齐
|
||||
// (`graph_update` 本身不重放)。
|
||||
func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
||||
if sessionID == "" || !p.allowed(sessionID) {
|
||||
return
|
||||
@@ -142,6 +144,34 @@ func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) {
|
||||
p.publishLocked(g)
|
||||
}
|
||||
|
||||
// LastGraph 返回 sessionID **最近一轮**链路图的一份只读深拷贝快照,供
|
||||
// 管理面板在「轮次已经结束、实时帧已经错过」时补拉(契约 v0.71 的
|
||||
// `GET /api/v1/admin/flow/graph`;design/19 §4 的 P3「多轮历史回放」的
|
||||
// 最小一步)。
|
||||
//
|
||||
// 它是**只读**的:不发布、不改图、不动轮次计数 —— 因此补拉绝不会在
|
||||
// 管理面板里凭空造出一帧 `graph_update`,也绝不改任何会话的实时图。
|
||||
//
|
||||
// 该会话还没有图时返回 nil:从未跑过带 span 的轮次、或该会话本就**不在
|
||||
// 发布会话范围内**(`StartRound` 对白名单外的会话直接返回 ⇒ 从不建图,
|
||||
// 隔离渠道会话因此天然查不到)。调用方**必须**把 nil 读作「确实没有图」,
|
||||
// 绝不允许据此补画任何节点或假图。
|
||||
//
|
||||
// 返回的是深拷贝(`trace.FlowGraph.Clone`):读者在锁外编码时,
|
||||
// 活图可能正被下一帧 span 就地合并,共享底层 map/切片会撕裂。
|
||||
func (p *FlowPublisher) LastGraph(sessionID string) *trace.FlowGraph {
|
||||
if p == nil || sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return g.Snapshot().Clone()
|
||||
}
|
||||
|
||||
// publishLocked 发布 g 的一份快照。调用方必须持有 p.mu。
|
||||
func (p *FlowPublisher) publishLocked(g *trace.RunGraph) {
|
||||
if p.publish == nil {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件钉住契约 v0.71 的只读快照出口 `FlowPublisher.LastGraph`:
|
||||
// 「轮次结束后才打开的页面也能看到该轮的图」。它是 loop 侧的半边;
|
||||
// 网关侧的半边(`GET /api/v1/admin/flow/graph`)在
|
||||
// internal/services/gateway/admin_flow_graph_test.go。
|
||||
|
||||
// countingPublisher 接上捕获式 publish 并计数,用来断言
|
||||
// 「只读查询不发布」。
|
||||
func countingPublisher(allow func(string) bool) (*FlowPublisher, func() int) {
|
||||
var mu sync.Mutex
|
||||
frames := 0
|
||||
p := NewFlowPublisher(func(string, any) {
|
||||
mu.Lock()
|
||||
frames++
|
||||
mu.Unlock()
|
||||
}, allow)
|
||||
return p, func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return frames
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LastGraph_AfterRoundEnded 跑一轮**真实**循环,然后在
|
||||
// Run 返回之后(= 所有实时帧都已发完、晚开的页面一帧都收不到)取快照:
|
||||
// 它必须仍然带上该轮的主干与回复 —— 这就是主人报的那个场景。
|
||||
func TestFlowPublisher_LastGraph_AfterRoundEnded(t *testing.T) {
|
||||
flow, frames := countingPublisher(func(string) bool { return true })
|
||||
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: call}},
|
||||
{resp: model.Response{Content: "Echoed!"}},
|
||||
}}
|
||||
l := New(Options{
|
||||
Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "echo", result: "hi"})),
|
||||
MaxRounds: 8,
|
||||
Flow: flow,
|
||||
Tracer: trace.New(nil),
|
||||
})
|
||||
if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "echo this")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
before := frames()
|
||||
|
||||
g := flow.LastGraph("web")
|
||||
if g == nil {
|
||||
t.Fatal("LastGraph = nil,但该会话刚跑完一整轮(轮次结束后打开的页面将看不到图)")
|
||||
}
|
||||
kinds := map[string]bool{}
|
||||
for _, n := range g.Nodes {
|
||||
kinds[n.Kind] = true
|
||||
}
|
||||
for _, want := range []string{"input", "llm", "tool", "output"} {
|
||||
if !kinds[want] {
|
||||
t.Fatalf("快照缺少 %q 节点:%+v", want, g.Nodes)
|
||||
}
|
||||
}
|
||||
if g.SessionID != "web" {
|
||||
t.Fatalf("SessionID = %q, want web", g.SessionID)
|
||||
}
|
||||
if g.EndedAt.IsZero() {
|
||||
t.Fatal("EndedAt 为零值:快照不是收轮之后的那一份")
|
||||
}
|
||||
// 只读查询绝不发布 —— 否则补拉会在面板里凭空造出一帧 graph_update。
|
||||
if got := frames(); got != before {
|
||||
t.Fatalf("LastGraph 发布了 %d 帧新图(%d → %d)", got-before, before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LastGraph_KeepsLatestRound 钉住「最近一轮」的语义:
|
||||
// 第二轮开始后快照就是第二轮的,而不是第一轮残留。
|
||||
func TestFlowPublisher_LastGraph_KeepsLatestRound(t *testing.T) {
|
||||
flow, _ := countingPublisher(func(string) bool { return true })
|
||||
flow.StartRound("web", "第一轮")
|
||||
flow.EndRound("web", "第一轮回复")
|
||||
first := flow.LastGraph("web")
|
||||
if first == nil || first.ID != "web-1" {
|
||||
t.Fatalf("第一轮快照 = %+v, want id web-1", first)
|
||||
}
|
||||
flow.StartRound("web", "第二轮")
|
||||
flow.EndRound("web", "第二轮回复")
|
||||
second := flow.LastGraph("web")
|
||||
if second == nil || second.ID != "web-2" {
|
||||
t.Fatalf("第二轮快照 = %+v, want id web-2", second)
|
||||
}
|
||||
for _, n := range second.Nodes {
|
||||
if n.Kind == "input" && n.Label == "第一轮" {
|
||||
t.Fatalf("快照仍是第一轮的输入节点: %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LastGraph_EmptyCasesAreNil 钉住「确实没有图」的三种形态:
|
||||
// nil 接收者、空会话 id、从未建过图的会话,一律 nil(调用方据此渲染诚实空态,
|
||||
// 绝不允许补画节点)。
|
||||
func TestFlowPublisher_LastGraph_EmptyCasesAreNil(t *testing.T) {
|
||||
flow, _ := countingPublisher(func(string) bool { return true })
|
||||
flow.StartRound("web", "你好")
|
||||
|
||||
var nilPub *FlowPublisher
|
||||
if g := nilPub.LastGraph("web"); g != nil {
|
||||
t.Fatalf("nil 接收者应返回 nil,得到 %+v", g)
|
||||
}
|
||||
if g := flow.LastGraph(""); g != nil {
|
||||
t.Fatalf("空会话 id 应返回 nil,得到 %+v", g)
|
||||
}
|
||||
if g := flow.LastGraph("sess-never-ran"); g != nil {
|
||||
t.Fatalf("从未建图的会话应返回 nil,得到 %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LastGraph_WhitelistStillApplies 钉住只读查询**不绕过**
|
||||
// 发布会话白名单(design/19 §1 裁决 01.4.5 未被本版改动):白名单外的会话
|
||||
// 从未建图 ⇒ 查询也拿不到。
|
||||
func TestFlowPublisher_LastGraph_WhitelistStillApplies(t *testing.T) {
|
||||
flow, _ := countingPublisher(func(session string) bool { return session == "web" })
|
||||
flow.StartRound("web", "你好")
|
||||
flow.StartRound("sess-im-stranger", "群里的消息")
|
||||
|
||||
if g := flow.LastGraph("web"); g == nil {
|
||||
t.Fatal("白名单内的会话应当有图")
|
||||
}
|
||||
if g := flow.LastGraph("sess-im-stranger"); g != nil {
|
||||
t.Fatalf("白名单外的会话不该有图,得到 %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LastGraph_IsDetachedCopy 钉住快照是**深拷贝**:
|
||||
// 读者拿到之后在锁外编码时,活图可能正被下一帧 span 就地合并
|
||||
// (RunGraph.merge 往同一个 map 写、往同一个切片追加)—— 共享底层存储
|
||||
// 会撕裂,Go 里并发读写 map 直接是致命错误。因此改读者手里的那份,
|
||||
// 绝不能影响发布器内部的图。
|
||||
func TestFlowPublisher_LastGraph_IsDetachedCopy(t *testing.T) {
|
||||
flow, _ := countingPublisher(func(string) bool { return true })
|
||||
flow.StartRound("web", "你好")
|
||||
flow.MarkInputSteered("web")
|
||||
|
||||
first := flow.LastGraph("web")
|
||||
if first == nil {
|
||||
t.Fatal("LastGraph = nil")
|
||||
}
|
||||
for i := range first.Nodes {
|
||||
if first.Nodes[i].ID != "input" {
|
||||
continue
|
||||
}
|
||||
first.Nodes[i].Props["tampered"] = true
|
||||
first.Nodes[i].Props["steered"] = false
|
||||
}
|
||||
first.Nodes = append(first.Nodes, trace.FlowNode{ID: "ghost", Kind: "input"})
|
||||
|
||||
second := flow.LastGraph("web")
|
||||
if len(second.Nodes) == len(first.Nodes) {
|
||||
t.Fatal("第二次快照的节点数与读者改过的切片相同 ⇒ 返回的不是独立副本")
|
||||
}
|
||||
for _, n := range second.Nodes {
|
||||
if n.ID == "ghost" {
|
||||
t.Fatal("读者追加的节点漏进了发布器内部的图 ⇒ 不是深拷贝")
|
||||
}
|
||||
if n.ID != "input" {
|
||||
continue
|
||||
}
|
||||
if _, ok := n.Props["tampered"]; ok {
|
||||
t.Fatal("读者改写 props 影响到了内部图 ⇒ props 未深拷贝")
|
||||
}
|
||||
if steered, ok := n.Props["steered"].(bool); !ok || !steered {
|
||||
t.Fatalf("内部图原有的 steered 标记被读者改坏: %+v", n.Props)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,3 +175,51 @@ func (g *RunGraph) Snapshot() *FlowGraph {
|
||||
EndedAt: g.ended,
|
||||
}
|
||||
}
|
||||
|
||||
// Clone 深拷贝一份图,供**锁外**的读者使用(契约 v0.71 的
|
||||
// `GET /api/v1/admin/flow/graph` 就近取材于 `loop.FlowPublisher.LastGraph`)。
|
||||
//
|
||||
// 为什么必须深拷贝:`Snapshot` 只复制节点结构体,`Props` 映射与 `Log` 切片
|
||||
// 仍与活图**共享底层存储**;而 `RunGraph.Upsert` 对同一个节点是**就地**合并
|
||||
// (`merge` 往同一个 map 写键、往同一个切片追加)。发布路径在持有发布器
|
||||
// 互斥锁时就把图编码掉了,因此共享无碍;但只读查询把图交出去之后还要在
|
||||
// 锁外编码,与下一帧 span 的合并并发 ⇒ 会读到撕裂的 map(Go 里并发读写
|
||||
// map 直接是致命错误)。深拷贝把这份共享切断,且只发生在人为的只读查询上,
|
||||
// 不在实时发布路径上,因此不影响每帧开销。
|
||||
func (g *FlowGraph) Clone() *FlowGraph {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
out := &FlowGraph{
|
||||
ID: g.ID,
|
||||
Nodes: make([]FlowNode, 0, len(g.Nodes)),
|
||||
Edges: append([]FlowEdge(nil), g.Edges...),
|
||||
SessionID: g.SessionID,
|
||||
StartedAt: g.StartedAt,
|
||||
EndedAt: g.EndedAt,
|
||||
}
|
||||
for _, n := range g.Nodes {
|
||||
cp := n
|
||||
if n.Props != nil {
|
||||
cp.Props = make(map[string]any, len(n.Props))
|
||||
for k, v := range n.Props {
|
||||
cp.Props[k] = v
|
||||
}
|
||||
}
|
||||
if n.Log != nil {
|
||||
cp.Log = make([]Event, len(n.Log))
|
||||
for i, e := range n.Log {
|
||||
ev := e
|
||||
if e.Props != nil {
|
||||
ev.Props = make(map[string]any, len(e.Props))
|
||||
for k, v := range e.Props {
|
||||
ev.Props[k] = v
|
||||
}
|
||||
}
|
||||
cp.Log[i] = ev
|
||||
}
|
||||
}
|
||||
out.Nodes = append(out.Nodes, cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// mainSessionID 是未指定 `?sessionId=` 时的默认会话 —— Web 主会话
|
||||
// (`internal/runtime/history.MainSessionID`)。它与网关其余默认会话
|
||||
// 口径同源(见 ws_hub_turn.go 的 streamChat),因此不新增第二个「默认会话」
|
||||
// 常量。
|
||||
const mainSessionID = history.MainSessionID
|
||||
|
||||
// flowUnavailableCode 是图源未接线时的机器可读错误码。它**不**回
|
||||
// graph:null:那会把「图源没接线」伪装成「这一轮确实没有图」,与
|
||||
// usage_unavailable / channel_events_unavailable 同一口径。
|
||||
const flowUnavailableCode = "flow_unavailable"
|
||||
|
||||
// adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体(契约 v0.71)。
|
||||
//
|
||||
// Graph 为 nil 表示「该会话确实还没有图」——这是一个**诚实的空态**,不是错误:
|
||||
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b)-3)。
|
||||
// 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与
|
||||
// 「这个键是空」。
|
||||
type adminFlowGraphResponse struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
// Graph 是**最近一轮**的图快照,与 `graph_update` 帧的 data 逐字同形
|
||||
// (契约 §3.4.5 / §4.11(a))。字段以 `any` 承载,因为「没有图」(nil ⇒
|
||||
// 线上 JSON `null`)与「有图」的区分就是本字段的语义。
|
||||
Graph any `json:"graph"`
|
||||
}
|
||||
|
||||
// adminFlowGraph 是 `GET /api/v1/admin/flow/graph` 的处理器(契约 v0.71,
|
||||
// design/19 §4 的 P3「多轮历史回放」的最小一步)。
|
||||
//
|
||||
// 它回答的是主人第二次报的那个洞:`graph_update` **只走实时帧、从不重放**,
|
||||
// 而前端图桶(`flowGraphs`)**不持久化** ⇒ 「轮次结束后才打开页面」或
|
||||
// 「刷新过页面」永远空着。本端点让打开页面时能**只读拉一次**最近一轮的图。
|
||||
//
|
||||
// 三条硬约束(与 §4.11(b) 逐条对应):
|
||||
// 1. **只读**:经 `FlowGraphSource` 拿快照,网关不发布、不改图、不动任何会话;
|
||||
// 2. **不画假图**:该会话没有图 ⇒ `graph:null`;图源未接线 ⇒ `500 flow_unavailable`
|
||||
// (**不是** graph:null,见 flowUnavailableCode);
|
||||
// 3. **只回最近一轮**:更早轮次的历史回放仍未实现(design/19 §4 P3)——
|
||||
// 因此面板必须在图上说明「这不是你要的那一行那轮的图」。
|
||||
//
|
||||
// 角色门:`writeAuth`(owner|admin),与 design/19 §3「flow 可视化仅 owner/admin」
|
||||
// 一致;它与同一张图的 WS 下行帧(`graph_update` 落 frameReachesAll 的 fail-closed
|
||||
// 默认档 = 只发 owner+admin)是同一档,因此**不存在**「帧看不到、REST 看得到」的
|
||||
// 旁路。本端点只读、不审计。
|
||||
func (s *Server) adminFlowGraph(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := strings.TrimSpace(r.URL.Query().Get("sessionId"))
|
||||
if sessionID == "" {
|
||||
sessionID = mainSessionID
|
||||
}
|
||||
if s.flow == nil {
|
||||
s.log.Warn("flow graph source not wired", "session", sessionID)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "flow graph source is not wired"))
|
||||
return
|
||||
}
|
||||
// Graph 恒在响应里(没有图时是 `null`),见 adminFlowGraphResponse 的注释。
|
||||
var graph any
|
||||
if snap := s.flow.LastGraph(sessionID); snap != nil {
|
||||
graph = snap
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminFlowGraphResponse{SessionID: sessionID, Graph: graph})
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件钉住契约 v0.71 的唯一新增面:`GET /api/v1/admin/flow/graph` ——
|
||||
// 「最近一轮链路图快照」的只读出口。它修的是主人第二次报的那个洞:
|
||||
// `graph_update` 只走实时帧、从不重放(前端图桶也不持久化)⇒ 轮次结束后
|
||||
// 才打开的页面永远空着。
|
||||
|
||||
// flowTestEnv 构建一个接了**真** *loop.FlowPublisher 作图源的网关。
|
||||
// 与 newEnv 一样接真 bus、真 JWT,只是多注入了 Flow —— 因此测的是
|
||||
// 生产那条路径(发布器 → 只读查询),不是假实现。
|
||||
type flowTestEnv struct {
|
||||
ts *httptest.Server
|
||||
auth *auth.JWTService
|
||||
flow *loop.FlowPublisher
|
||||
bus bus.Bus
|
||||
graph *trace.RunGraph
|
||||
}
|
||||
|
||||
func newFlowTestEnv(t *testing.T, allow func(string) bool) *flowTestEnv {
|
||||
t.Helper()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
b := bus.New()
|
||||
// 图的发布出口在这里只用于「证明查询面不影响发布」:收集每一帧发布。
|
||||
pub := loop.NewFlowPublisher(func(string, any) {}, allow)
|
||||
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
|
||||
srv, err := New(cfg, Deps{Auth: jwt, Bus: b, Flow: pub})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return &flowTestEnv{ts: ts, auth: jwt, flow: pub, bus: b}
|
||||
}
|
||||
|
||||
func (e *flowTestEnv) token(t *testing.T, role auth.Role) string {
|
||||
t.Helper()
|
||||
tok, err := e.auth.Sign("acct", role, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// runFinishedRound 让发布器跑完**一整轮**(起轮 → span 起止 → 收轮),
|
||||
// 模拟「轮次已经结束」的时刻。返回时该轮所有实时帧都已发完 ——
|
||||
// 因此此刻打开页面的客户端一帧都收不到,只能靠只读快照。
|
||||
func (e *flowTestEnv) runFinishedRound(t *testing.T, session string) {
|
||||
t.Helper()
|
||||
e.flow.StartRound(session, "帮我查一下天气")
|
||||
sp := &trace.Span{ID: "span-llm", Name: "llm.call", Kind: trace.SpanKindLLM, StartedAt: time.Now()}
|
||||
e.flow.SpanStarted(session, sp)
|
||||
sp.Finish(trace.SpanStatusOK)
|
||||
e.flow.SpanFinished(session, sp)
|
||||
e.flow.EndRound(session, "今天晴,26 度。")
|
||||
}
|
||||
|
||||
// flowGraphData 解码 {data:{sessionId,graph}} 信封;`graph` 键必须**恒在**
|
||||
// (没有图时是 JSON null),因为客户端靠它区分「没有图」与「字段缺失」。
|
||||
func flowGraphData(t *testing.T, body []byte) (string, map[string]json.RawMessage, bool) {
|
||||
t.Helper()
|
||||
env := decodeBody(t, body)
|
||||
var data struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Graph map[string]json.RawMessage `json:"graph"`
|
||||
}
|
||||
if err := json.Unmarshal(env["data"], &data); err != nil {
|
||||
t.Fatalf("decode data %s: %v", body, err)
|
||||
}
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(env["data"], &envelope); err != nil {
|
||||
t.Fatalf("decode data envelope %s: %v", body, err)
|
||||
}
|
||||
_, present := envelope["graph"]
|
||||
return data.SessionID, data.Graph, present
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_LatePageSeesLastRound 是主人的原话场景:
|
||||
// **轮次结束后才打开页面**(本次测试里没有任何 WS 客户端连过)也必须能看到
|
||||
// 该轮的图。它是本片的核心断言 —— 去掉只读快照通路它必须失败。
|
||||
func TestAdminFlowGraph_LatePageSeesLastRound(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(string) bool { return true })
|
||||
env.runFinishedRound(t, "web")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
sessionID, graph, present := flowGraphData(t, body)
|
||||
if !present {
|
||||
t.Fatalf("`graph` key must always be present (null when empty): %s", body)
|
||||
}
|
||||
if graph == nil {
|
||||
t.Fatalf("graph = null,但该会话刚跑完一整轮 ⇒ 轮次结束后打开的页面看不到图(这正是本次要修的那个洞): %s", body)
|
||||
}
|
||||
if sessionID != "web" {
|
||||
t.Fatalf("sessionId = %q, want web", sessionID)
|
||||
}
|
||||
var nodes []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(graph["nodes"], &nodes); err != nil {
|
||||
t.Fatalf("decode nodes: %v (graph=%s)", err, graph)
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
t.Fatalf("nodes 为空,快照没带上该轮的节点: %s", graph)
|
||||
}
|
||||
// 主干两端必须在:input(用户输入)与 output(回复)。
|
||||
kinds := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
var kind string
|
||||
_ = json.Unmarshal(n["kind"], &kind)
|
||||
kinds[kind] = true
|
||||
}
|
||||
if !kinds["input"] || !kinds["output"] {
|
||||
t.Fatalf("快照缺少主干端点 input/output(kinds=%v): %s", kinds, graph)
|
||||
}
|
||||
// 图的归属必须是它自己的会话 id —— 前端按这个键入桶(裁决 16.4.2)。
|
||||
var gsid string
|
||||
_ = json.Unmarshal(graph["sessionId"], &gsid)
|
||||
if gsid != "web" {
|
||||
t.Fatalf("graph.sessionId = %q, want web", gsid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_NoGraphYetIsHonestNull 钉住空态:该会话**确实**还没有图时
|
||||
// 回 200 + `graph:null`(键恒在)—— 绝不允许画假图、也绝不把「没有图」当错误。
|
||||
func TestAdminFlowGraph_NoGraphYetIsHonestNull(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(string) bool { return true })
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=sess-backthink", nil, env.token(t, auth.RoleAdmin))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
_, graph, present := flowGraphData(t, body)
|
||||
if !present {
|
||||
t.Fatalf("`graph` 键必须恒在(空时是 null): %s", body)
|
||||
}
|
||||
if graph != nil {
|
||||
t.Fatalf("graph = %s,但该会话从未跑过轮次 ⇒ 必须如实为 null", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_DefaultSessionIsMain 钉住 `?sessionId=` 省略时落到主会话
|
||||
// `web`(与网关其余默认会话口径同源)。
|
||||
func TestAdminFlowGraph_DefaultSessionIsMain(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(string) bool { return true })
|
||||
env.runFinishedRound(t, "web")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph", nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
sessionID, graph, _ := flowGraphData(t, body)
|
||||
if sessionID != "web" || graph == nil {
|
||||
t.Fatalf("省略 sessionId 应落到主会话 web 并带回它的图;got sessionId=%q graph=%v (body=%s)", sessionID, graph, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_UnwiredSourceIsHonest 钉住接线缺失的诚实口径:图源未注入时
|
||||
// 回 `500 flow_unavailable`,**不是** `graph:null` —— 后者会把「图源没接线」伪装成
|
||||
// 「这一轮确实没有图」,正是项目明令禁止的那类静默降级。
|
||||
func TestAdminFlowGraph_UnwiredSourceIsHonest(t *testing.T) {
|
||||
env := newEnv(t, nil, false) // newEnv 不注入 Flow
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph", nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
envErr := decodeBody(t, body)
|
||||
var eobj struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(envErr["error"], &eobj); err != nil {
|
||||
t.Fatalf("error envelope: %v (body=%s)", err, body)
|
||||
}
|
||||
if eobj.Code != flowUnavailableCode {
|
||||
t.Fatalf("error code = %q, want %q", eobj.Code, flowUnavailableCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_RoleGate 钉住角色门与同一张图的 WS 帧同档
|
||||
// (owner/admin 可读、viewer 403、匿名 401):不存在「帧收不到、REST 看得到」的旁路。
|
||||
func TestAdminFlowGraph_RoleGate(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(string) bool { return true })
|
||||
env.runFinishedRound(t, "web")
|
||||
url := env.ts.URL + "/api/v1/admin/flow/graph"
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, url, nil, env.token(t, auth.RoleAdmin))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
resp, body = doReq(t, http.MethodGet, url, nil, env.token(t, auth.RoleViewer))
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
resp, _ = doReq(t, http.MethodGet, url, nil, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_ReadDoesNotPublish 钉住「只读查询不改任何会话」:
|
||||
// 拉快照不产生新的 bus 事件(否则补拉会在管理面板里凭空造出一帧 graph_update)。
|
||||
func TestAdminFlowGraph_ReadDoesNotPublish(t *testing.T) {
|
||||
published := 0
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
b := bus.New()
|
||||
pub := loop.NewFlowPublisher(func(string, any) { published++ }, func(string) bool { return true })
|
||||
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
|
||||
srv, err := New(cfg, Deps{Auth: jwt, Bus: b, Flow: pub})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
pub.StartRound("web", "你好")
|
||||
pub.EndRound("web", "你好呀")
|
||||
before := published
|
||||
|
||||
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
if published != before {
|
||||
t.Fatalf("补拉发布了 %d 帧新图(%d → %d),只读查询必须不发布", published-before, before, published)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_OutsideWhitelistStaysNil 钉住图源白名单仍然生效:
|
||||
// 发布会话范围之外的会话(隔离渠道会话)从来没有建过图,因此只读查询
|
||||
// **不可能**把它们变成可查的(design/19 §1 裁决 01.4.5 未被本版改动)。
|
||||
func TestAdminFlowGraph_OutsideWhitelistStaysNil(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(session string) bool { return session == "web" })
|
||||
env.runFinishedRound(t, "web")
|
||||
// 白名单外的会话:起轮与 span 都被发布器直接丢弃 ⇒ 从不建图。
|
||||
env.runFinishedRound(t, "sess-im-stranger")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=sess-im-stranger", nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
_, graph, present := flowGraphData(t, body)
|
||||
if !present {
|
||||
t.Fatalf("`graph` 键必须恒在: %s", body)
|
||||
}
|
||||
if graph != nil {
|
||||
t.Fatalf("白名单外的会话不该有图,却拿到了 %s", body)
|
||||
}
|
||||
// 反向:白名单内的会话照常可查(保证上面的 null 不是「端点整体坏了」)。
|
||||
_, ok, _ := flowGraphData(t, mustBody(t, env, "web"))
|
||||
if ok == nil {
|
||||
t.Fatal("白名单内的 web 会话应当仍有图")
|
||||
}
|
||||
}
|
||||
|
||||
// mustBody 直接问端点并把响应体交回给调用方(上面反向断言的辅助)。
|
||||
func mustBody(t *testing.T, env *flowTestEnv, session string) []byte {
|
||||
t.Helper()
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId="+session, nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
)
|
||||
@@ -284,3 +285,23 @@ type ChannelEventStore interface {
|
||||
// 的表,不含内存里待 flush 的部分(即 §4.3 的「近似值」)。
|
||||
Counters(ctx context.Context, platform string) ([]events.Counter, error)
|
||||
}
|
||||
|
||||
// FlowGraphSource 是**最近一轮链路图**的只读出口(契约 v0.71 的
|
||||
// `GET /api/v1/admin/flow/graph`;design/19 §4 的 P3)。生产实现就是
|
||||
// `*loop.FlowPublisher`(`internal/runtime/loop/flow.go` 的 `LastGraph`),
|
||||
// 组合根把**同一个**发布器既交给 loop/orchestration(写入面:每帧 span
|
||||
// 发布 `ws.graph_update`)也交给网关(本查询面)。
|
||||
//
|
||||
// 它刻意只声明**一个读方法**:发布、建图、轮次计数与发布会话白名单都留在
|
||||
// 发布器自己手里,管理查询面拿不到、也不该拿到它们(最小权限)——因此
|
||||
// 只读查询**不可能**改变任何会话的实时图,也不可能把面板看不见的会话
|
||||
// 变成可查的(白名单外的会话从未建图,`LastGraph` 自然回 nil)。
|
||||
//
|
||||
// 为 nil 时端点诚实作答 500 `flow_unavailable`,绝不回 `graph:null`
|
||||
// (那会把「图源没接线」伪装成「这一轮确实没有图」——
|
||||
// 与 `usage_unavailable` / `channel_events_unavailable` 同一口径)。
|
||||
type FlowGraphSource interface {
|
||||
// LastGraph 返回 sessionID 最近一轮图的一份只读快照;该会话还没有图
|
||||
// (从未跑过带 span 的轮次,或本就不在发布会话范围内)时返回 nil。
|
||||
LastGraph(sessionID string) *trace.FlowGraph
|
||||
}
|
||||
@@ -154,4 +154,10 @@ type Deps struct {
|
||||
// 它刻意是编排器的构造期旋钮而不是一个全局:真并行只由显式声明
|
||||
// (上行 `chat.parallel`)触发,因此调大它不会改变任何未声明并行的客户端行为。
|
||||
MaxParallelTurns int
|
||||
// Flow 是「最近一轮链路图」的只读出口(契约 v0.71 的
|
||||
// GET /api/v1/admin/flow/graph)。组合根注入的正是交给 loop 的那个
|
||||
// *loop.FlowPublisher —— 同一个对象,因此查询面读到的就是实时发布的那张图。
|
||||
// 可选;为 nil 时端点诚实作答 500 flow_unavailable,绝不回
|
||||
// graph:null(那会把「图源没接线」伪装成「这一轮确实没有图」)。
|
||||
Flow FlowGraphSource
|
||||
}
|
||||
@@ -182,6 +182,11 @@ func (s *Server) registerAdminRoutes(
|
||||
mux.Handle("GET /api/v1/admin/llm/catalog", writeAuth(http.HandlerFunc(s.adminGetLlmCatalog)))
|
||||
mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers)))
|
||||
mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink)))
|
||||
// 最近一轮链路图快照(**v0.71**):`graph_update` 只走实时帧、从不重放,
|
||||
// 前端图桶也不持久化 ⇒ 「轮次结束后才打开的页面」此前只能看到空态。
|
||||
// 只读、不审计;与同一张图的 WS 下行帧同档(只发 owner|admin,
|
||||
// design/19 §3「flow 可视化仅 owner/admin」),因此不存在绕过帧门的旁路。
|
||||
mux.Handle("GET /api/v1/admin/flow/graph", writeAuth(http.HandlerFunc(s.adminFlowGraph)))
|
||||
// 配置事实面(切片 P3,**v0.68**):记忆整理 7 键 + 安静时段两键的**进程生效值**
|
||||
// 与声明默认值。只读、不审计、**不读 `.env`**、**不含任何凭据**;与消费它的面板页
|
||||
// (记忆管理 / 后台思考)同门,故走 owner|admin 档而不是 viewer 监控档。
|
||||
|
||||
@@ -100,6 +100,9 @@ type Server struct {
|
||||
// maxParallelTurns 是单会话在途并行路数的上限(见 Deps.MaxParallelTurns)。
|
||||
// 零值 = 未接线 ⇒ 编排器用默认 2。
|
||||
maxParallelTurns int
|
||||
// flow 是「最近一轮链路图」的只读出口(见 Deps.Flow / FlowGraphSource)。
|
||||
// 为 nil 时 GET /api/v1/admin/flow/graph 回 500 flow_unavailable。
|
||||
flow FlowGraphSource
|
||||
// envResolver 解析会话每轮的 loop 环境(其角色)。见
|
||||
// persona_env.go / design/22 §4.5。
|
||||
envResolver turnEnvResolver
|
||||
@@ -152,6 +155,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
vector: deps.Vector,
|
||||
log: sourcedLog(deps.Log),
|
||||
maxParallelTurns: deps.MaxParallelTurns,
|
||||
flow: deps.Flow,
|
||||
}
|
||||
// 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner
|
||||
// 的入口(REST、WS、语音)以同样方式解析会话角色(design/22 §4.5),
|
||||
|
||||
Reference in new issue
Block a user