package gateway import ( "context" "net/http" "strings" "time" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 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" // 图上那张图**是哪来的**(契约 v0.73 §4.11): // - live = 内存里**最近一轮**的实时图(`loop.FlowPublisher.LastGraph`); // - persisted = 从本地库读回的**已落盘轮次**(历史回放); // - none = 没有图(诚实空态)。 // // 前端据此渲染来源说明条(v0.73 起也可经它的 `source` 字段直接分辨, // 不再只靠对象引用)。 const ( flowSourceLive = "live" flowSourcePersisted = "persisted" flowSourceNone = "none" ) // flowTurnDTO 是「该会话已落盘轮次」的一条摘要(契约 v0.73 的 `turns[]`)。 // 它是给面板选历史轮用的:`turnId` 回带给本端点当 `?turnId=`,`createdAt` 供排序/展示。 type flowTurnDTO struct { TurnID string `json:"turnId"` CreatedAt string `json:"createdAt"` } // flowLaneDTO 是「最近一轮里的一条路」(契约 v0.77 的 `lanes[]`):`turnId` 是**那一路** // 的权威回合标识(`graph.turnId`,design/15 §2.5.9),`graph` 是那一路的图快照 // (与 `graph_update` 帧的 data / 本响应的 `graph` 逐字同形)。 // // 真并行时同一会话同一轮有多条路,`graph` 只给出最新的一条(向后兼容:老客户端读它 // 照旧可用),`lanes[]` 才是全貌 —— 并行子代理与并行路各自的图因此不再互相顶掉。 type flowLaneDTO struct { TurnID string `json:"turnId"` Graph any `json:"graph"` } // adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体 // (v0.71 起;**v0.73 起新增 `turnId`/`source`/`turns` 三键**,**v0.77 起新增 `lanes` // 一键**,均为纯增量)。 // // Graph 为 nil 表示「该轮确实没有图」——这是一个**诚实的空态**,不是错误: // 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b))。 // 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与 // 「这个键是空」。 // // Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。Lanes 同此口径。 type adminFlowGraphResponse struct { SessionID string `json:"sessionId"` // TurnID 是**实际返回的那一轮**的标识(`graph.id`);没有图时为空串。 TurnID string `json:"turnId"` // Source ∈ live / persisted / none,见上方常量。 Source string `json:"source"` // Graph 是**该轮**的图快照,与 `graph_update` 帧的 data 逐字同形 // (契约 §3.4.5 / §4.11(a))。字段以 `any` 承载,因为「没有图」(nil ⇒ // 线上 JSON `null`)与「有图」的区分就是本字段的语义。 Graph any `json:"graph"` // Lanes 是**最近一轮每一路**的图(契约 v0.77,纯增量;真并行才有意义的全貌)。 // 它取内存里的实时图(`FlowGraphSource.LatestLanes`),因此与 `?turnId=` 无关 —— // 翻历史轮时 `lanes` 仍报「当前这一轮的各路」,而 `graph` 才是你指定的那一轮。 // 图源未接线或该会话还没有图时是空数组(恒非 nil)。 Lanes []flowLaneDTO `json:"lanes"` // Turns 是该会话**已落盘轮次**的摘要,最新在前(契约 v0.73)。 Turns []flowTurnDTO `json:"turns"` } // adminFlowGraph 是 `GET /api/v1/admin/flow/graph` 的处理器(契约 v0.71 起, // v0.73 增量;design/19 §4 的 P3「多轮历史回放」)。 // // 它回答的是主人报的两个洞:① `graph_update` **只走实时帧、从不重放**(v0.71 修); // ② 图只活在进程内存、刷新/重启/翻更早轮次就没了 ⇒ 现在每轮收尾**落盘**,本端点可按 // `turnId` 取历史轮、并给出可选轮次列表(v0.73)。三条硬约束: // // 1. **只读**:经 `FlowGraphSource` / `FlowGraphStore` 拿快照,网关不发布、不改图、 // 不动任何会话、也不落盘; // 2. **不画假图**:没有该轮 ⇒ `graph:null`;图源未接线(内存 + 落盘都没接)⇒ // `500 flow_unavailable`(**不是** graph:null,见 flowUnavailableCode); // 3. **最近一轮优先内存,miss 才落盘**:省略 `turnId` 时先查内存 `LastGraph`(正在跑/ // 刚跑完的那一轮,最新),取不到才回退落盘最新 —— 「最新一轮永远是最新的」。 // // 角色门:`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 } turnID := strings.TrimSpace(r.URL.Query().Get("turnId")) if s.flow == nil && s.flowGraphs == 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 } ctx := r.Context() graph, source, err := s.resolveFlowGraph(ctx, sessionID, turnID) if err != nil { // 原始错误(可能带 SQL/路径)只进日志;响应只给稳定 code + 中文说明。 s.log.Warn("flow graph read failed", "session", sessionID, "turn", turnID, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "读取链路图失败")) return } turns, err := s.flowTurns(ctx, sessionID) if err != nil { s.log.Warn("flow turns read failed", "session", sessionID, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "读取轮次列表失败")) return } // Graph 恒在响应里(没有图时是 `null`),见 adminFlowGraphResponse 的注释。 var graphAny any resolvedTurn := "" if graph != nil { graphAny = graph resolvedTurn = graph.ID } writeJSON(w, http.StatusOK, adminFlowGraphResponse{ SessionID: sessionID, TurnID: resolvedTurn, Source: source, Graph: graphAny, Lanes: s.flowLanes(sessionID), Turns: turns, }) } // flowLanes 取**最近一轮每一路**的图(契约 v0.77 的 `lanes[]`):真并行时同一会话 // 同一轮有多条路,只回 `graph` 一棵会把其余路从面板上抹掉(design/15 §2.5.10-1)。 // // 它恒返回非 nil 切片(没有路时是 `[]`),使客户端不必判空。图源未接线(`s.flow` 为 // nil)时回空切片而不是错误:那时端点本就走「只有落盘」的形态(v0.73 口径), // `lanes` 缺席语义 = 「没有实时路」。 func (s *Server) flowLanes(sessionID string) []flowLaneDTO { out := make([]flowLaneDTO, 0) if s.flow == nil { return out } for _, g := range s.flow.LatestLanes(sessionID) { if g == nil { continue } out = append(out, flowLaneDTO{TurnID: g.TurnID, Graph: g}) } return out } // resolveFlowGraph 解析「这一轮」的图:**先内存、miss 才落盘**(契约 v0.73 §4.11(b))。 // // 判定顺序(三条都必要): // - 内存里有图,且(省略 turnId **或** turnId 正是内存那一轮)⇒ 回内存图 // (`source:"live"`)—— 最新一轮永远最新; // - 否则若给了 turnId ⇒ 精确读落盘那一轮(`source:"persisted"`); // - 否则(省略 turnId、内存 miss)⇒ 读落盘**最新**一轮(`source:"persisted"`)。 // // 落盘查询失败**上抛**(调用方回 500):把「读库失败」伪装成「没有这一轮」是 // 项目明令禁止的那类静默降级。该轮确实缺席返回 (nil, "none", nil)。 func (s *Server) resolveFlowGraph(ctx context.Context, sessionID, turnID string) (*trace.FlowGraph, string, error) { if s.flow != nil { if snap := s.flow.LastGraph(sessionID); snap != nil && (turnID == "" || snap.ID == turnID) { return snap, flowSourceLive, nil } } if s.flowGraphs == nil { return nil, flowSourceNone, nil } var ( graph *trace.FlowGraph err error ) if turnID == "" { graph, err = s.flowGraphs.Latest(ctx, sessionID) } else { graph, err = s.flowGraphs.Graph(ctx, sessionID, turnID) } if err != nil { return nil, flowSourceNone, err } if graph == nil { return nil, flowSourceNone, nil } return graph, flowSourcePersisted, nil } // flowTurns 取该会话已落盘轮次的摘要(最新在前)。落盘未接线时回**空切片** // (不是错误):端点在「只有内存」的形态下照常可用(v0.71 口径),只是没有历史轮可选。 func (s *Server) flowTurns(ctx context.Context, sessionID string) ([]flowTurnDTO, error) { out := make([]flowTurnDTO, 0) if s.flowGraphs == nil { return out, nil } turns, err := s.flowGraphs.Turns(ctx, sessionID, 0) if err != nil { return nil, err } for _, t := range turns { out = append(out, flowTurnDTO{TurnID: t.TurnID, CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339)}) } return out, nil }