- 断点已证 = **时序层**: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 要补的
71 lines
3.5 KiB
Go
71 lines
3.5 KiB
Go
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})
|
||
}
|