主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」: - 落盘:迁移 0022_flow_graphs(session_id/turn_id/graph_json/时间戳 + 唯一键 upsert)+ internal/runtime/flowgraph(RecordRound/Graph/Latest/Turns/Turn + 保留期 GC) - 写入:loop.EndRound **每轮一条**(锁外深拷贝后写;失败 WARN 且不打印图内容);只落发布会话白名单(复用 flowSessionAllowed) - 读回:端点加 turnId + source(live/persisted/none) + turns[](最新在前,空为 []);**内存优先、miss 才落盘**(最新一轮永远最新) - 前端:flowGraphs 纳入 persistOptions.partialize(**刷新即刻有图**,version 3→4 + migrate)+ 历史轮选择器 + 诚实文案;顺带收 L1 转交的 flow/NodeDetail.tsx 节点日志倒序 - 新 env 键:FLOW_GRAPH_RETENTION=168h / FLOW_GRAPH_GC_INTERVAL=1h - 变异实测:每 span 一行 / 保留期守卫失效 / partialize 去掉 flowGraphs / 去掉内存优先 / 白名单失效 ⇒ 全部打红 - 3 个禁动面文件的纯追加改动(Flow.tsx 挂选择器、FlowSnapshotHint 纠已成假话的文案、其渲染断言随之更新)由主会话裁定认可 - 登记存疑:图桶持久化无体积上限;useFlowTurns 的 effect 无自动化证据(项目无 jsdom)
180 lines
7.7 KiB
Go
180 lines
7.7 KiB
Go
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"`
|
||
}
|
||
|
||
// adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体
|
||
// (v0.71 起;**v0.73 起新增 `turnId`/`source`/`turns` 三键**,纯增量)。
|
||
//
|
||
// Graph 为 nil 表示「该轮确实没有图」——这是一个**诚实的空态**,不是错误:
|
||
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b))。
|
||
// 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与
|
||
// 「这个键是空」。
|
||
//
|
||
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。
|
||
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"`
|
||
// 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,
|
||
Turns: turns,
|
||
})
|
||
}
|
||
|
||
// 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
|
||
}
|