feat(flow): 链路图时间轴出口(W1)—— 所有轮次按时间正序

- 新增 GET /api/v1/admin/flow/timeline?sessionId=&limit=&before=:时间正序多轮,
  每轮 {turnId,round,startedAt,endedAt,lanes[]};limit 缺省 50/上限 200 + before 游标
- 数据源=落盘 flow_graphs + 内存最近一轮(同图 id 去重);保留期 168h 外的轮次不返回,
  但响应给 retentionHours/retentionEnabled/truncated ⇒ 前端如实标注,不悄悄少一块
- 老出口 GET /admin/flow/graph 零 diff;角色门 writeAuth(owner|admin)
- 4 个变异自检(游标 <= 重复、分组降级、去重缺失、truncated 静默)
This commit is contained in:
AskaEth committed 2026-10-06 15:59:09 +08:00
1 parent 78e99d9f4c
commit 1efe3a6a79
5 files changed
+1401

No files matched your search

@@ -0,0 +1,382 @@
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`,由前端据此标注「更早的轮次已过保留期」——
// 绝不静默少一块(design/19 §7.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`(分页未到底)或落盘源未接线(只剩内存
// 最近的这一轮)。前端据此如实标注「更早的轮次未显示」,**绝不静默少一块**。
Truncated bool `json:"truncated"`
// 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,
RetentionHours: int(retention / time.Hour),
RetentionEnabled: retention > 0,
})
}
// 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
}
}
@@ -0,0 +1,454 @@
package gateway
// 本文件钉住契约 v0.80 的新出口 `GET /api/v1/admin/flow/timeline`(design/19 §7;切片 W1)——
// 主人 2026-10-06 的原话:「web 后台的全链路流程图我要的是所有消息按时间顺序串起来的流程图,
// 而不是只有最新一个来回」。
//
// 走真路径(真发布器 + 真 SQLite + 真迁移 0022/0023 + 真 HTTP handler),断言的是**线上
// 形状**与**库里的事实**:多轮按时间正序、一轮含该轮每一路、`before` 游标翻页不重不漏、
// 内存里在飞的那一轮并进同一轮且不重复、保留期两个字段如实、未接线时 500 flow_unavailable。
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// flowTimelineGraphProj / flowTimelineLaneProj / flowTimelineRoundProj 是本文件断言的
// 线上投影(命名类型,使 `roundNos` 之类的助手能直接吃它,不必复写匿名结构)。
type flowTimelineGraphProj struct {
ID string `json:"id"`
Round int `json:"round"`
}
type flowTimelineLaneProj struct {
TurnID string `json:"turnId"`
Graph *flowTimelineGraphProj `json:"graph"`
}
type flowTimelineRoundProj struct {
TurnID string `json:"turnId"`
Round *int `json:"round"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt"`
Lanes []flowTimelineLaneProj `json:"lanes"`
}
// flowTimelineResponseDTO 是 v0.80 响应体的线上投影(只解本文件要断言的键)。
type flowTimelineResponseDTO struct {
SessionID string `json:"sessionId"`
Source string `json:"source"`
Rounds []flowTimelineRoundProj `json:"rounds"`
NextBefore string `json:"nextBefore"`
HasMore bool `json:"hasMore"`
Truncated bool `json:"truncated"`
RetentionHours int `json:"retentionHours"`
RetentionEnabled bool `json:"retentionEnabled"`
}
func decodeFlowTimelineResp(t *testing.T, body []byte) flowTimelineResponseDTO {
t.Helper()
var out flowTimelineResponseDTO
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode flow timeline data: %v (body=%s)", err, body)
}
return out
}
// timelineURL 拼出本端点的 URL(limit / before 为空时省略对应参数)。
func timelineURL(ts *httptest.Server, session, limit, before string) string {
u := ts.URL + "/api/v1/admin/flow/timeline?sessionId=" + session
if limit != "" {
u += "&limit=" + limit
}
if before != "" {
u += "&before=" + before
}
return u
}
// getTimeline 发一次请求并把解好的响应交回(非 200 即 Fatal)。
func getTimeline(t *testing.T, ts *httptest.Server, tok, session, limit, before string) flowTimelineResponseDTO {
t.Helper()
resp, body := doReq(t, http.MethodGet, timelineURL(ts, session, limit, before), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
return decodeFlowTimelineResp(t, body)
}
// runParallelRound 跑一轮**真并行两路**(网关那条路径:`WithTurnID` 给权威回合标识、
// `FlowOwnerKey` 给各自的归属桶)。两路**先都起、再分别收尾** —— 只有这样它们才共享
// 同一个轮次号(发布器的轮次号只在「上一轮的路全部收尾」后才推进)。
func runParallelRound(p *loop.FlowPublisher, session, laneA, laneB string) {
for _, lane := range []string{laneA, laneB} {
owner := loop.FlowOwnerKey(session, lane)
p.StartRoundWithTurnID(loop.WithTurnID(context.Background(), lane), owner, "输入-"+lane)
}
for _, lane := range []string{laneA, laneB} {
owner := loop.FlowOwnerKey(session, lane)
sp := &trace.Span{ID: "span-" + lane, Name: "llm.call", Kind: trace.SpanKindLLM, StartedAt: time.Now()}
p.SpanStarted(owner, sp)
sp.Finish(trace.SpanStatusOK)
p.SpanFinished(owner, sp)
p.EndRound(owner, "回复-"+lane)
}
}
// tickFlow 让相邻两个回合的开始时刻**落在不同的毫秒**(发布器的时刻来自真实时钟,
// 而时间轴的游标精度就是毫秒)。没有它,一串瞬间跑完的回合会挤在同一毫秒里。
func tickFlow() { time.Sleep(3 * time.Millisecond) }
// TestAdminFlowTimeline_MultiRoundAscendingWithParallelLanes_正常 是本文件的核心断言:
// 三轮按**时间正序**返回,其中真并行那一轮的**两路**都在同一个轮次元素里(`lanes` 两条,
// 各带自己的路标识与图),最新一轮来自内存的那一份**不重复**成第二条。
func TestAdminFlowTimeline_MultiRoundAscendingWithParallelLanes_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
pub := flowPublisherWithStore(store, func(string) bool { return true })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
runFlowRound(pub, "web", "第一轮", "答一")
tickFlow()
runParallelRound(pub, "web", "turn-a", "turn-b")
tickFlow()
runFlowRound(pub, "web", "第三轮", "答三")
got := getTimeline(t, ts, tok, "web", "", "")
if got.Source != "persisted" {
t.Fatalf("source = %q, want persisted(主体来自落盘)", got.Source)
}
if len(got.Rounds) != 3 {
t.Fatalf("轮数 = %d, want 3(真并行的两路属**同一轮**): %+v", len(got.Rounds), got.Rounds)
}
rounds := roundNos(t, got.Rounds)
if rounds[0] == -1 || rounds[1] == -1 || rounds[2] == -1 {
t.Fatalf("轮次号出现 null(未知),但每个回合都带轮次号: %v", rounds)
}
if rounds[0] >= rounds[1] || rounds[1] >= rounds[2] {
t.Fatalf("轮次号 %v 不是**时间正序**(发布器的轮次号 = 该轮首路的建图序号,允许跳号但必须递增)", rounds)
}
parallel := got.Rounds[1]
if len(parallel.Lanes) != 2 {
t.Fatalf("真并行那一轮的 lanes = %d, want 2: %+v", len(parallel.Lanes), parallel.Lanes)
}
if got.Rounds[0].TurnID != got.Rounds[0].Lanes[0].Graph.ID {
t.Fatalf("轮的 turnId = %q, want 首路的图 id %q", got.Rounds[0].TurnID, got.Rounds[0].Lanes[0].Graph.ID)
}
if parallel.Lanes[0].TurnID != "turn-a" || parallel.Lanes[1].TurnID != "turn-b" {
t.Fatalf("各路的 turnId = %q/%q, want turn-a/turn-b(路标识,不是图 id)",
parallel.Lanes[0].TurnID, parallel.Lanes[1].TurnID)
}
if parallel.Lanes[0].Graph.ID == parallel.Lanes[1].Graph.ID {
t.Fatalf("两路的图 id 撞了:%q", parallel.Lanes[0].Graph.ID)
}
// 最新一轮:落盘一份 + 内存一份是同一条路 ⇒ 必须去重成一条。
if lanes := len(got.Rounds[2].Lanes); lanes != 1 {
t.Fatalf("最新一轮 lanes = %d, want 1(内存那份与落盘那份是同一条路)", lanes)
}
for i, r := range got.Rounds {
if r.StartedAt == "" || r.EndedAt == "" {
t.Fatalf("rounds[%d] 的 startedAt/endedAt 必须有值(这些轮都已收尾): %+v", i, r)
}
if r.EndedAt < r.StartedAt {
t.Fatalf("rounds[%d] endedAt(%s) 早于 startedAt(%s)", i, r.EndedAt, r.StartedAt)
}
}
}
// TestAdminFlowTimeline_InFlightRoundHasNoEndedAt_边界 钉住「在飞轮」的线上形状:还没
// 收尾的那一轮照常出现在时间轴里(内存是它的唯一来源),但 `endedAt` 是**空串** ——
// 客户端据此显示「进行中」,而绝不被喂一个假的结束时刻。
func TestAdminFlowTimeline_InFlightRoundHasNoEndedAt_边界(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
// 发布器**不挂 recorder**:这一轮只在内存里,一行都不落盘。
pub := loop.NewFlowPublisher(func(string, any) {}, func(string) bool { return true })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
pub.StartRound("web", "还在跑的这轮")
sp := &trace.Span{ID: "span-llm", Name: "llm.call", Kind: trace.SpanKindLLM, StartedAt: time.Now()}
pub.SpanStarted("web", sp)
sp.Finish(trace.SpanStatusOK)
pub.SpanFinished("web", sp)
got := getTimeline(t, ts, tok, "web", "", "")
if got.Source != "live" {
t.Fatalf("source = %q, want live(这一页全部来自内存)", got.Source)
}
if len(got.Rounds) != 1 {
t.Fatalf("轮数 = %d, want 1(在飞的那一轮也要在时间轴上): %+v", len(got.Rounds), got.Rounds)
}
if got.Rounds[0].EndedAt != "" {
t.Fatalf("endedAt = %q, want 空串(这一轮还没收尾,不许编一个结束时刻)", got.Rounds[0].EndedAt)
}
if got.Rounds[0].StartedAt == "" || got.NextBefore != got.Rounds[0].StartedAt {
t.Fatalf("startedAt/nextBefore = %q/%q, want 同一非空时刻",
got.Rounds[0].StartedAt, got.NextBefore)
}
if got.Truncated {
t.Fatal("truncated = true,但落盘源已接线、库里也确实没有更早的轮次")
}
}
// TestAdminFlowTimeline_BeforeCursorPagesWithoutGapOrDup_正常 钉住分页:`limit=2` 逐页向
// 更早翻,各页**不重不漏**、`hasMore` 与 `nextBefore` 如实、翻到底后是空数组。
func TestAdminFlowTimeline_BeforeCursorPagesWithoutGapOrDup_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
base := time.Now().UTC().Add(-4 * time.Hour)
for round := 1; round <= 4; round++ {
recordTimelineGraph(t, store, "web", round, fmt.Sprintf("turn-%d", round), base.Add(time.Duration(round)*time.Hour))
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
page1 := getTimeline(t, ts, tok, "web", "2", "")
if len(page1.Rounds) != 2 || !page1.HasMore || page1.NextBefore == "" {
t.Fatalf("第 1 页 = %d 轮 / hasMore=%v / nextBefore=%q, want 2 / true / 非空",
len(page1.Rounds), page1.HasMore, page1.NextBefore)
}
if got := fmt.Sprint(roundNos(t, page1.Rounds)); got != "[3 4]" {
t.Fatalf("第 1 页轮次 = %s, want [3 4](最新两轮、正序)", got)
}
page2 := getTimeline(t, ts, tok, "web", "2", page1.NextBefore)
if got := fmt.Sprint(roundNos(t, page2.Rounds)); got != "[1 2]" {
t.Fatalf("第 2 页轮次 = %s, want [1 2]", got)
}
// 游标也接受 Unix 毫秒(与 `llm/usage` / 渠道事件同一解析口径),且与 RFC3339 同解。
cursor, err := time.Parse(time.RFC3339, page1.NextBefore)
if err != nil {
t.Fatalf("nextBefore %q 不是 RFC3339: %v", page1.NextBefore, err)
}
byMillis := getTimeline(t, ts, tok, "web", "2", fmt.Sprint(cursor.UnixMilli()))
if got := fmt.Sprint(roundNos(t, byMillis.Rounds)); got != "[1 2]" {
t.Fatalf("毫秒游标那一页轮次 = %s, want [1 2](与 RFC3339 同一时刻应同解)", got)
}
if page2.HasMore {
t.Fatal("第 2 页 hasMore = true,但库里只有四轮")
}
page3 := getTimeline(t, ts, tok, "web", "2", page2.NextBefore)
if len(page3.Rounds) != 0 || page3.HasMore {
t.Fatalf("翻到底 = %d 轮 / hasMore=%v, want 0 / false", len(page3.Rounds), page3.HasMore)
}
seen := map[int]int{}
for _, page := range []flowTimelineResponseDTO{page1, page2, page3} {
for _, r := range page.Rounds {
seen[*r.Round]++
}
}
for round := 1; round <= 4; round++ {
if seen[round] != 1 {
t.Fatalf("第 %d 轮出现 %d 次, want 1(翻页不重不漏;全部=%v)", round, seen[round], seen)
}
}
}
// TestAdminFlowTimeline_LiveOnlyWhenStoreUnwired_正常 钉住降级口径:只接了内存图源
// (落盘未接线)时仍能给出**最近一轮**的时间轴,但 `source`/`truncated` 如实说明
// 「更早的取不到」—— 老后端形态下前端据此提示「当前只支持最近一轮」。
func TestAdminFlowTimeline_LiveOnlyWhenStoreUnwired_正常(t *testing.T) {
t.Parallel()
jwt := mustJWT(t)
pub := loop.NewFlowPublisher(func(string, any) {}, func(string) bool { return true })
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt, Flow: pub})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
runFlowRound(pub, "web", "问题", "回答")
got := getTimeline(t, ts, tok, "web", "", "")
if got.Source != "live" {
t.Fatalf("source = %q, want live(落盘未接线)", got.Source)
}
if !got.Truncated {
t.Fatal("truncated = false,但落盘未接线 ⇒ 更早的轮次取不到,必须如实标注")
}
if len(got.Rounds) != 1 || len(got.Rounds[0].Lanes) != 1 {
t.Fatalf("轮/路 = %d/%d, want 1/1: %+v", len(got.Rounds), len(got.Rounds[0].Lanes), got.Rounds)
}
if got.RetentionEnabled || got.RetentionHours != 0 {
t.Fatalf("保留期 = %d h / enabled=%v, want 0 / false(没有落盘源就没有保留期口径)",
got.RetentionHours, got.RetentionEnabled)
}
}
// TestAdminFlowTimeline_UnwiredIsHonest_异常 钉住未接线的诚实口径:内存与落盘**都没接**
// 时回 `500 flow_unavailable`,**不是**一份空 `rounds` —— 后者会把「图源没接线」伪装成
// 「这台会话没有轮次」(与老出口同一口径)。
func TestAdminFlowTimeline_UnwiredIsHonest_异常(t *testing.T) {
env := newEnv(t, nil, false) // newEnv 不注入 Flow / FlowGraphs
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/timeline", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
}
var eobj struct {
Code string `json:"code"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("error envelope: %v (body=%s)", err, body)
}
if eobj.Code != flowUnavailableCode {
t.Fatalf("error code = %q, want %q", eobj.Code, flowUnavailableCode)
}
}
// TestAdminFlowTimeline_BadParamsAre400_边界 钉住参数错误**不静默**:非法 `limit` /
// 非法 `before` 一律 400,绝不当成缺省值继续跑(那会把一次拼错变成一份看似正常的响应)。
func TestAdminFlowTimeline_BadParamsAre400_边界(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for _, query := range []string{"limit=abc", "before=昨天"} {
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/flow/timeline?"+query, nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%s status = %d, want 400 (body=%s)", query, resp.StatusCode, body)
}
}
}
// TestAdminFlowTimeline_RoleGate 钉住角色门与老出口同档(owner/admin 可读、viewer 403、
// 匿名 401):不存在「WS 帧收不到、REST 看得到」的旁路。
func TestAdminFlowTimeline_RoleGate(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{FlowGraphs: store})
url := ts.URL + "/api/v1/admin/flow/timeline"
resp, body := doReq(t, http.MethodGet, url, nil, signAny(t, jwt, "acct", auth.RoleAdmin))
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodGet, url, nil, signAny(t, jwt, "acct", auth.RoleViewer))
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
resp, _ = doReq(t, http.MethodGet, url, nil, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anonymous status = %d, want 401", resp.StatusCode)
}
}
// TestAdminFlowTimeline_ExpiredRowsAbsentRetentionReported_正常 钉住保留期口径:
// 已过保留期、被清扫掉的轮次**不在**返回值里(绝不静默补一个空块),而响应把保留期
// 两个字段如实给出 —— 前端的「更早的轮次已过保留期」这句话只能由它们推出来。
func TestAdminFlowTimeline_ExpiredRowsAbsentRetentionReported_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
conn, _ := openFlowGraphStore(t, path)
retention := 168 * time.Hour
store, err := flowgraph.NewStore(flowgraph.Config{
DB: conn, Log: gwTestLogger(), GCInterval: time.Hour, Retention: retention,
})
if err != nil {
t.Fatalf("new store with retention: %v", err)
}
now := time.Now().UTC()
recordTimelineGraph(t, store, "web", 1, "turn-old", now.Add(-200*time.Hour))
recordTimelineGraph(t, store, "web", 2, "turn-new", now.Add(-time.Hour))
if _, err := conn.ExecContext(context.Background(),
`UPDATE flow_graphs SET created_at = ? WHERE turn_id = ?`,
now.Add(-200*time.Hour).Format("2006-01-02T15:04:05.000Z"), "web-1#turn-old"); err != nil {
t.Fatalf("把第 1 轮做旧: %v", err)
}
if stats, err := store.Sweep(context.Background()); err != nil {
t.Fatalf("sweep: %v", err)
} else if stats.Deleted == 0 {
t.Fatal("清扫没有删掉任何行,夹具没做到「已过保留期」这一步")
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
got := getTimeline(t, ts, tok, "web", "", "")
if got.RetentionHours != int(retention/time.Hour) || !got.RetentionEnabled {
t.Fatalf("保留期 = %d h / enabled=%v, want %d / true",
got.RetentionHours, got.RetentionEnabled, int(retention/time.Hour))
}
if rounds := fmt.Sprint(roundNos(t, got.Rounds)); rounds != "[2]" {
t.Fatalf("轮次 = %s, want [2](过期那轮已被清扫,绝不出现在时间轴里)", rounds)
}
}
// TestAdminFlowTimeline_UnknownSessionIsHonestEmpty_边界 钉住空态:发布会话范围外的会话
// (隔离渠道会话)从不建图 ⇒ `rounds:[]` + `source:"none"`,绝不用别的会话顶替。
func TestAdminFlowTimeline_UnknownSessionIsHonestEmpty_边界(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
pub := flowPublisherWithStore(store, func(session string) bool { return session == "web" })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
runFlowRound(pub, "web", "主人说话", "回应")
resp, body := doReq(t, http.MethodGet, timelineURL(ts, "sess-im-stranger", "", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if !jsonHasEmptyRounds(t, body) {
t.Fatalf("空时间轴的 rounds 必须是 `[]`(客户端不必判空 null): %s", body)
}
got := decodeFlowTimelineResp(t, body)
if len(got.Rounds) != 0 || got.Source != "none" {
t.Fatalf("白名单外会话 = %d 轮 / source=%q, want 0 / none", len(got.Rounds), got.Source)
}
}
// recordTimelineGraph 直接经落盘出口写一轮图(不经发布器):时刻**显式给定**,
// 因此分页/定序类断言完全确定(不依赖真实时钟的毫秒粒度)。
func recordTimelineGraph(t *testing.T, store *flowgraph.SQLStore, session string, round int, lane string, started time.Time) {
t.Helper()
g := trace.NewRunGraph(session)
g.SetTurnID(lane)
g.SetSeq(round)
g.Start(round, started)
g.SeedInput("输入-" + lane)
g.SeedOutput("回复-" + lane)
if err := store.RecordRound(g.Snapshot()); err != nil {
t.Fatalf("落盘轮 %d: %v", round, err)
}
}
// roundNos 取出各轮的轮次号(断言用;`null` 记为 -1,让它在断言里显形而不是被当成 0)。
func roundNos(_ *testing.T, rounds []flowTimelineRoundProj) []int {
out := make([]int, 0, len(rounds))
for _, r := range rounds {
if r.Round == nil {
out = append(out, -1)
continue
}
out = append(out, *r.Round)
}
return out
}
// jsonHasEmptyRounds 报告响应体里 `rounds` 是否序列化成空数组(而不是 null)。
func jsonHasEmptyRounds(t *testing.T, body []byte) bool {
t.Helper()
var envelope struct {
Rounds []json.RawMessage `json:"rounds"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &envelope); err != nil {
t.Fatalf("decode data: %v (body=%s)", err, body)
}
return envelope.Rounds != nil && len(envelope.Rounds) == 0
}
+5
View File
@@ -204,6 +204,11 @@ func (s *Server) registerAdminRoutes(
// 只读、不审计;与同一张图的 WS 下行帧同档(只发 owner|admin,
// design/19 §3「flow 可视化仅 owner/admin」),因此不存在绕过帧门的旁路。
mux.Handle("GET /api/v1/admin/flow/graph", writeAuth(http.HandlerFunc(s.adminFlowGraph)))
// 多轮时间轴(切片 W1,**v0.80**;design/19 §7):把**所有轮次按时间正序串起来**
// (每轮含该轮每一路),`before` 游标向更早翻页,响应另带保留期口径供面板如实标注
// 「更早的轮次已过保留期」。与上面的最近一轮快照**同门同档**(writeAuth = owner|admin)、
// 同样只读不审计;老出口逐字不变,老客户端零影响。
mux.Handle("GET /api/v1/admin/flow/timeline", writeAuth(http.HandlerFunc(s.adminFlowTimeline)))
// 配置事实面(切片 P3,**v0.68**):记忆整理 7 键 + 安静时段两键的**进程生效值**
// 与声明默认值。只读、不审计、**不读 `.env`**、**不含任何凭据**;与消费它的面板页
// (记忆管理 / 后台思考)同门,故走 owner|admin 档而不是 viewer 监控档。