Files
Cyrene_v2/internal/services/gateway/admin_flow.go
T
AskaEth a6cb44b8a8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」:
- 落盘:迁移 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)
2026-10-05 16:19:32 +08:00

180 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}