Files
Cyrene_v2/internal/services/gateway/admin_flow_timeline.go
T
AskaEth 2bf3b536dd fix(flow): 时间轴两处诚实性缺陷(X1)
① round 号跨重启被复用 ⇒ 时间轴把多个真实轮次并成一块(真机实测 web 的 r:1 挤了 4 路、
   startedAt 跨 13:52→21:05)。修法:发号前从落盘播种(MaxRound,json_extract + json_valid 挡坏行),
   每会话每进程只播一次、失败只 WARN 且不重试、并发首轮共享一次读;接口形状零变化
② truncated 一个字段兼两个意思 ⇒ 一页没拉完时前端会说「更早的轮次已过保留期」(假话)。
   新增 retentionBoundaryReached(= 保留期启用 且 已翻到底 且 现存最早一轮 round>1 ⇒ 确有轮次被清扫),
   truncated 语义一字未改;盲区如实登记(整段被清扫会说少、最早一轮落盘失败可能多说)
3 个变异自检(不落账/floor>0/去掉 hasMore 守卫)
2026-10-06 21:16:21 +08:00

422 lines
20 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
// 本文件是**多轮时间轴**出口(design/19 §7;切片 W1,契约 v0.80):
// `GET /api/v1/admin/flow/timeline?sessionId=&limit=&before=`
//
// 主人 2026-10-06 的原话:「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的
// 流程图,而不是只有最新一个来回。」老出口 `GET /admin/flow/graph` 只给**最近一轮**
// 的图(v0.71 的冷启动快照)与 `turns[]` 摘要(v0.73 的轮次键列表),没有任何一处把
// 多轮按时间串起来 —— 本文件补的就是它,且**纯增量**:老出口逐字未改(老客户端零影响)。
//
// 四条口径:
//
// 1. **只读**:经读侧出口拿快照,网关不发布、不改图、不动任何会话、不落盘、不审计
// (与同族的 `/admin/flow/graph` 同门同档:writeAuth = owner|admin,design/19 §3)。
// 2. **两个数据源合用**:主体是**落盘**的 `flow_graphs`(迁移 0022,保留期见
// `Retention()`),另把**内存里的最近一轮**(在飞/刚落盘失败的那一轮)并进来 ——
// 面板打开时最新一轮还在跑,不该在时间轴上缺席。
// 3. **时间正序 + 游标向更早翻页**:轮次按开始时刻升序返回;`before` 是「取开始时刻
// 严格早于它的轮次」,响应带 `nextBefore` 供客户端原样回带(分页不重不漏的口径由
// `flowgraph.SQLStore.Timeline` 保证并测试)。
// 4. **保留期如实**:过期轮次已被清扫,不在返回值里;响应给 `retentionHours` /
// `retentionEnabled` / `truncated` / `retentionBoundaryReached`,由前端据此标注
// 「更早的轮次已过保留期」——绝不静默少一块(design/19 §7.1 的原话)。
// 🔴 **两个字段各说一件事**(切片 X1 修的一处真缺陷):`truncated` 只回答「本次是不是
// 全部」(`hasMore` 或落盘源未接线),`retentionBoundaryReached` 才回答「更早的轮次
// 是不是**确实被保留期清扫掉了**」——后者由 `flowgraph.EarlierRoundsSwept` 给出判据
// (该会话现存最早一轮的轮次号 > 1 ⇒ 号更小的轮次曾落盘、且已被清扫)。混用会让面板
// 对着**仍然可翻**的历史说「已过保留期」,那是假话。
//
// 未接线(内存与落盘**都没接**)时回 `500 flow_unavailable`,与老出口同一码、同一口径:
// 绝不回一份空的 `rounds` 把「图源没接线」伪装成「这台会话没有轮次」。
import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// flowTimelineSource 是**落盘多轮时间轴**的读侧出口(契约 v0.80)。它**故意**与
// `FlowGraphStore` 分开声明:那条接口是为「最小权限读面」定的四个方法(`contracts.go`
// 的注释逐条写明不许扩),而时间轴还要两件它没有的东西 —— 分组分页读(`Timeline`)与
// 保留期值(`Retention`)。分开声明使既有查询面与它的桩(`history_toolcalls_test.go`
// 的 `stubFlowGraphStore`)一字不动。
//
// 生产实现恒为 `*flowgraph.SQLStore`(由下面的编译期断言钉住);组合根若把
// `Deps.FlowGraphs` 换成别的实现而它没有这两个方法,端点会**显式**回
// `500 flow_unavailable`(见 handler),绝不静默退化成「只有内存」假装一切正常。
type flowTimelineSource interface {
// Timeline 按时间正序取该会话保留期内的多轮(每轮含该轮每一路),并报告库里
// 是否还有更早的轮次(`hasMore`)。
Timeline(ctx context.Context, sessionID string, limit int, before time.Time) ([]flowgraph.TimelineRound, bool, error)
// Retention 是该存储的保留期;≤0 = 未启用保留期清扫(`flowgraph.Config` 的口径)。
Retention() time.Duration
}
// 编译期断言:链路图落盘存储必须能满足时间轴读侧(否则生产组合根一换实现就会 500)。
var _ flowTimelineSource = (*flowgraph.SQLStore)(nil)
// flowTimelineLaneDTO 是时间轴里一轮中的**某一路**:`turnId` 是那一路的路标识
// (`graph.turnId`,design/15 §2.5.9;**可能为空** = 这一路没有权威标识),`graph` 与
// `graph_update` 帧的 data 逐字同形(与老出口的 `lanes[]` 同一形状)。
type flowTimelineLaneDTO struct {
TurnID string `json:"turnId"`
Graph any `json:"graph"`
}
// flowTimelineRoundDTO 是时间轴里的**一轮**(design/19 §7.1 给定了形状)。
//
// 「轮」= 一条用户消息引出的全部工作;真并行时一轮有多路,各路的图都在 `lanes[]` 里。
type flowTimelineRoundDTO struct {
// TurnID 是这一轮**首路**的图 id(一个真 id:`{会话}-{序号}[#{路}]`)。多路时它只代表
// 首路,全貌在 `lanes[]`(每路的图 id 见 `lanes[].graph.id`)—— 绝不臆造一个
// 「轮次 id」出来(design/19 §6.1 的诚实口径)。
TurnID string `json:"turnId"`
// Round 是轮次号;`null` = **未知**(那批行早于轮次号落库)—— 不用 0 冒充。
Round *int `json:"round"`
// StartedAt / EndedAt 是这一轮的开始/结束时刻(UTC RFC3339 **毫秒**,与 `before`
// 游标同一口径)。EndedAt 为空串 = 这一轮**还没有收尾**(内存里的在飞轮),
// 客户端据此显示「进行中」,而不是画一个假的结束时刻。
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt"`
// Lanes 是该轮每一路,按路的开始时刻升序。恒非 nil。
Lanes []flowTimelineLaneDTO `json:"lanes"`
}
// adminFlowTimelineResponse 是 `GET /api/v1/admin/flow/timeline` 的响应体(契约 v0.80)。
type adminFlowTimelineResponse struct {
SessionID string `json:"sessionId"`
// Source ∈ persisted / live / none(沿用老出口的取值域):
// persisted = 本页**至少有一轮**来自落盘(可能同时含内存里的在飞路);
// live = 本页**全部**来自内存(落盘未接线,或该会话还没有落盘轮);
// none = 没有任何轮次(`rounds:[]`)。
Source string `json:"source"`
// Rounds 恒非 nil(没有轮次时是 `[]`),按时间**正序**(最早在前)—— 时间轴就是
// 从上往下读成时间序。
Rounds []flowTimelineRoundDTO `json:"rounds"`
// NextBefore 是**本页最早一轮**的开始时刻:`hasMore` 为 true 时原样回带成下一次
// 请求的 `before` 即可继续向更早翻页。没有轮次时为空串。
NextBefore string `json:"nextBefore"`
// HasMore 报告库里**还有更早的轮次**没被本次返回(用 NextBefore 继续取)。
HasMore bool `json:"hasMore"`
// Truncated 报告本次**不是全部**:`hasMore`(分页未到底)或落盘源未接线(只剩内存
// 最近的这一轮)。它是**分页口径**,不是保留期口径 —— 面板据此标注「更早的轮次未
// 显示」,**绝不静默少一块**。要问「更早的是不是被清扫掉了」请看
// `retentionBoundaryReached`(两个字段各说一件事,切片 X1 修的正是这处混用)。
Truncated bool `json:"truncated"`
// RetentionBoundaryReached 报告**保留期边界已到达**:该会话**确有更早的轮次已被
// 保留期清扫删掉**,且本次已取到它现存最早的一端(切片 X1 新增;契约版本待主会话
// 登记 —— 本字段需一次版本号,建议 v0.81)。
//
// 判据(三条一起成立,逐条可复核):① 保留期清扫已启用(`retentionEnabled`);
// ② `hasMore:false`(本次不是半页 —— 只看半页就说「已过保留期」正是修掉的那句假话);
// ③ `flowgraph.EarlierRoundsSwept`:该会话现存最早一轮的轮次号 > 1(轮次号自 1 起、
// 每会话跨重启单调 ⇒ 号更小的轮次曾落盘,而 `flow_graphs` 的唯一删除者就是保留期
// 清扫)。⇒ **客户端只有看到 `true` 才可以说「更早的轮次已过保留期」**,其余情形一律
// 说「更早的轮次未显示」(`truncated`)或「只拿到最近一轮」(`source:"live"`)。
//
// 诚实的盲区(写在契约里,不掩盖):一个会话若**整段历史都被清扫**(一条可用行都不
// 剩),发号从 1 重新开始 ⇒ 本字段为 false(**少说**,绝不多说);有轮次号未知的老行
// 在场时同样为 false。
RetentionBoundaryReached bool `json:"retentionBoundaryReached"`
// RetentionHours 是落盘保留期(小时,下取整);`RetentionEnabled` 为 false 时保留期
// 清扫**未启用** ⇒ "更早的轮次" 不是被清扫掉的,别把它说成过期。
// `FLOW_GRAPH_RETENTION` 是既有旋钮(默认 168h),本片**不新增任何配置键**。
RetentionHours int `json:"retentionHours"`
RetentionEnabled bool `json:"retentionEnabled"`
}
// adminFlowTimeline 是 `GET /api/v1/admin/flow/timeline` 的处理器(契约 v0.80;
// design/19 §7「所有消息按时间顺序串起来」)。
//
// 参数:
// - `sessionId` 省略时落到主会话 `web`(与老出口同一默认,见 mainSessionID);
// - `limit` 缺省 50、硬上限 200(超过即钳住,绝不返回全表);
// - `before` 取「开始时刻严格早于它」的轮次(省略 = 最新那一页),取值是响应里的
// `nextBefore`,也接受 Unix 毫秒(与 `llm/usage` / 渠道事件同一解析口径)。
//
// 角色门:`writeAuth`(owner|admin),与老出口、与同一张图的 WS 下行帧同档。
func (s *Server) adminFlowTimeline(w http.ResponseWriter, r *http.Request) {
sessionID := strings.TrimSpace(r.URL.Query().Get("sessionId"))
if sessionID == "" {
sessionID = mainSessionID
}
if s.flow == nil && s.flowGraphs == nil {
s.log.Warn("flow timeline source not wired", "session", sessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "flow graph source is not wired"))
return
}
limit, err := timelineLimitParam(r.URL.Query().Get("limit"))
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%s", err.Error()))
return
}
before, err := parseInstantParam(r.URL.Query().Get("before"))
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "before 非法:%s", err.Error()))
return
}
store, storeWired := s.flowGraphs.(flowTimelineSource)
if s.flowGraphs != nil && !storeWired {
s.log.Warn("flow timeline store lacks timeline reads", "session", sessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "链路图落盘源不支持时间轴读取"))
return
}
rounds := []flowgraph.TimelineRound{}
hasMore := false
retention := time.Duration(0)
if storeWired {
rounds, hasMore, err = store.Timeline(r.Context(), sessionID, limit, before)
if err != nil {
// 原始错误(可能带 SQL/路径)只进日志;响应只给稳定 code + 中文说明。
s.log.Warn("flow timeline read failed", "session", sessionID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "读取链路图时间轴失败"))
return
}
retention = store.Retention()
}
persisted := len(rounds) > 0
// 并入内存里的最近一轮:同一轮并进已有那一轮,库里还没有的那一轮当最新一轮。
merged, droppedOldest := mergeLiveRounds(rounds, s.liveFlowLanes(sessionID), before, limit)
if droppedOldest {
// 被挤掉的那一轮仍在库里 ⇒ 必须如实说「还有更早的」,否则客户端以为已翻到底。
hasMore = true
}
writeJSON(w, http.StatusOK, adminFlowTimelineResponse{
SessionID: sessionID,
Source: timelineSource(persisted, len(merged) > 0),
Rounds: flowTimelineRoundsOf(merged),
NextBefore: timelineNextBefore(merged),
HasMore: hasMore,
Truncated: hasMore || !storeWired,
// 保留期边界:只有「清扫已启用 + 已到最早一端 + 确有更早的轮次被清扫删掉」三条
// 同时成立才为 true(判据见 DTO 注释与 design/19 §7.1)。
RetentionBoundaryReached: retainedEarlierRoundsSwept(merged, hasMore, retention),
RetentionHours: int(retention / time.Hour),
RetentionEnabled: retention > 0,
})
}
// retainedEarlierRoundsSwept 是 `retentionBoundaryReached` 的判据本体:清扫已启用
// (retention > 0)、本次已到该会话现存最早的一端(!hasMore)、且该会话**确有**更早的
// 轮次已被清扫删掉(`flowgraph.EarlierRoundsSwept` 的轮次号下界判据)。
//
// 三条缺一不可:少了第一条会说「保留期」却根本没开清扫;少了第二条会对着**仍然可翻**的
// 历史说「已过保留期」(切片 X1 修掉的那句假话);少了第三条会把一个刚开张、什么都没被
// 清掉的会话说成「更早的轮次已过保留期」。
func retainedEarlierRoundsSwept(merged []flowgraph.TimelineRound, hasMore bool, retention time.Duration) bool {
if retention <= 0 || hasMore {
return false
}
return flowgraph.EarlierRoundsSwept(merged)
}
// timelineLimitParam 解析 `?limit=`:空串 = 未给(0 ⇒ 存储层用缺省 `DefaultTurnsLimit`);
// 非法整数报错(400,绝不静默当缺省);负值归一成 0;超过硬上限 `MaxTurnsLimit` 即钳住。
func timelineLimitParam(raw string) (int, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return 0, nil
}
n, err := strconv.Atoi(trimmed)
if err != nil {
return 0, fmt.Errorf("limit 必须是整数:%q", raw)
}
switch {
case n < 0:
return 0, nil
case n > flowgraph.MaxTurnsLimit:
return flowgraph.MaxTurnsLimit, nil
default:
return n, nil
}
}
// liveFlowLanes 取该会话**内存最近一轮每一路**的图(图源未接线时为空切片)。
func (s *Server) liveFlowLanes(sessionID string) []*trace.FlowGraph {
if s.flow == nil {
return nil
}
return s.flow.LatestLanes(sessionID)
}
// mergeLiveRounds 把内存里的实时路并进落盘读回的时间轴,返回合并后的轮次(时间升序)与
// 「因超出 limit 丢掉了本页最早一轮」的标志。
//
// 三条规则:
// - **同一分组键 = 同一轮**:内存里那一路并进落盘已有的那一轮(真并行时落盘可能只有
// 先收尾的那一路,后一路还在内存里);
// - **同一个图 id 只算一次**:内存里的那一份与刚落盘那一条是同一条路的两次快照,
// 去重后以**落盘的那一份**为准(它才是收尾后的最终快照);
// - **`before` 口径一致**:传了游标时,比游标新的内存轮不并进这一页(那是**下一页
// 之后**的内容,并进来会让分页既重复又错序)。
//
// `limit` ≤0 时不做截断(调用方已由存储层钳过;这是防御性的一支)。
func mergeLiveRounds(persisted []flowgraph.TimelineRound, live []*trace.FlowGraph, before time.Time, limit int) ([]flowgraph.TimelineRound, bool) {
rounds := make([]flowgraph.TimelineRound, len(persisted))
copy(rounds, persisted)
index := make(map[string]int, len(rounds))
for i := range rounds {
index[rounds[i].Key] = i
}
for _, g := range live {
if g == nil || roundHasLane(rounds, g.ID) {
continue
}
if !before.IsZero() && !g.StartedAt.Before(before) {
continue
}
key := flowgraph.RoundKeyOf(g)
i, ok := index[key]
if !ok {
rounds = append(rounds, flowgraph.TimelineRound{Key: key, Round: g.Round})
i = len(rounds) - 1
index[key] = i
}
rounds[i].Lanes = append(rounds[i].Lanes, g)
if rounds[i].StartedAt.IsZero() || (!g.StartedAt.IsZero() && g.StartedAt.Before(rounds[i].StartedAt)) {
rounds[i].StartedAt = g.StartedAt
}
}
sortRoundsAsc(rounds)
if limit <= 0 || len(rounds) <= limit {
return rounds, false
}
return rounds[len(rounds)-limit:], true
}
// roundHasLane 报告某个**图 id**是否已在这一页里(跨轮检查:同一张图只可能出现一次)。
func roundHasLane(rounds []flowgraph.TimelineRound, id string) bool {
for i := range rounds {
for _, lane := range rounds[i].Lanes {
if lane != nil && lane.ID == id {
return true
}
}
}
return false
}
// sortRoundsAsc 按(开始时刻、分组键)升序排轮 —— 时间轴从上往下读成时间序。
//
// 开始时刻**为零**(未知,理论上只可能来自内存里那一路)的轮排在**最后**:它无从定位,
// 与其把它当成最旧(会被 limit 截掉、凭空消失)或编造一个时刻,不如把它放在最新端。
// 定序含分组键,因此次序不随 map 遍历序抖动。
func sortRoundsAsc(rounds []flowgraph.TimelineRound) {
sort.SliceStable(rounds, func(i, j int) bool {
return compareRoundStart(rounds[i], rounds[j]) < 0
})
}
// compareRoundStart 是轮次定序的三值比较(<0 / 0 / >0),见 sortRoundsAsc 的口径。
func compareRoundStart(a, b flowgraph.TimelineRound) int {
switch {
case a.StartedAt.IsZero() && b.StartedAt.IsZero():
return strings.Compare(a.Key, b.Key)
case a.StartedAt.IsZero():
return 1
case b.StartedAt.IsZero():
return -1
case a.StartedAt.Equal(b.StartedAt):
return strings.Compare(a.Key, b.Key)
case a.StartedAt.Before(b.StartedAt):
return -1
default:
return 1
}
}
// flowTimelineRoundsOf 把读侧模型翻成线上形状:每一路按开始时刻升序,轮的 `turnId` 取
// **首路**的图 id,轮的 `endedAt` 取各路结束时刻里**最晚**的一个(都没收尾则为空串)。
func flowTimelineRoundsOf(rounds []flowgraph.TimelineRound) []flowTimelineRoundDTO {
out := make([]flowTimelineRoundDTO, 0, len(rounds))
for _, r := range rounds {
lanes := sortLanesByStart(r.Lanes)
dto := flowTimelineRoundDTO{
StartedAt: flowgraph.CursorStamp(r.StartedAt),
EndedAt: flowgraph.CursorStamp(roundEndedAt(lanes)),
Lanes: make([]flowTimelineLaneDTO, 0, len(lanes)),
Round: timelineRoundNo(r.Round),
}
for _, g := range lanes {
dto.Lanes = append(dto.Lanes, flowTimelineLaneDTO{TurnID: g.TurnID, Graph: g})
}
if len(lanes) > 0 {
dto.TurnID = lanes[0].ID
}
out = append(out, dto)
}
return out
}
// timelineRoundNo 把轮次号翻成线上可空值:>0 出数字,否则出 `null`(未知不冒充 0)。
func timelineRoundNo(round int) *int {
if round <= 0 {
return nil
}
return &round
}
// roundEndedAt 取一轮的结束时刻:各路 `endedAt` 里最晚的一个;都没有收尾则零值
// (线上出空串 = 进行中/未收尾)。
func roundEndedAt(lanes []*trace.FlowGraph) time.Time {
var latest time.Time
for _, g := range lanes {
if g == nil || g.EndedAt.IsZero() {
continue
}
if latest.IsZero() || g.EndedAt.After(latest) {
latest = g.EndedAt
}
}
return latest
}
// sortLanesByStart 按「开始时刻升序、同刻按图 id」给一路路图定序(**不改入参**)。
// 口径与 `loop.sortLanes` 逐字相同(同轮多路的展示次序不随 map 遍历序抖动);
// 那边是未导出的小助手,这里是第二处 —— 出现第三处时应当抽成共享件。
func sortLanesByStart(lanes []*trace.FlowGraph) []*trace.FlowGraph {
out := make([]*trace.FlowGraph, 0, len(lanes))
for _, g := range lanes {
if g != nil {
out = append(out, g)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].StartedAt.Equal(out[j].StartedAt) {
return out[i].ID < out[j].ID
}
return out[i].StartedAt.Before(out[j].StartedAt)
})
return out
}
// timelineNextBefore 给出下一页要用的游标:本页**最早一轮**的开始时刻(没有轮次时空串)。
func timelineNextBefore(rounds []flowgraph.TimelineRound) string {
if len(rounds) == 0 {
return ""
}
return flowgraph.CursorStamp(rounds[0].StartedAt)
}
// timelineSource 归一 `source`:没有任何轮次 ⇒ none;至少一轮来自落盘 ⇒ persisted;
// 其余(本页全来自内存)⇒ live。
func timelineSource(persisted, hasRounds bool) string {
switch {
case !hasRounds:
return flowSourceNone
case persisted:
return flowSourcePersisted
default:
return flowSourceLive
}
}