feat(gateway): 链路图冷启动快照(v0.71)+ 面板来源说明条 —— 修「轮次结束后打开页面一片空白」
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 断点已证 = **时序层**: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:
AskaEth committed 2026-10-05 15:38:53 +08:00
1 parent a3482ed06e
commit 1bf85a955b
23 files changed
+1427 -36

No files matched your search

+48
View File
@@ -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
}