主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」: - 落盘:迁移 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)
318 lines
11 KiB
Go
318 lines
11 KiB
Go
package flowgraph
|
||
|
||
// 本文件是**链路图落盘存储**的验收(design/19 §4 P3;切片 F2)。断言的是**库里的事实**
|
||
// (行在不在、读回来的图是不是原图、每轮一行),不按日志文本断言 —— 与
|
||
// `internal/runtime/events` 的测试同一口径。
|
||
//
|
||
// 核心一条(主人原话的内核):「后端重启后仍能查回历史轮次的图」——
|
||
// `TestStore_SurvivesRestart_正常` 用**同一个文件库、两条连接、两个 store** 模拟
|
||
// 「进程重启」,第二个 store 内存里没有任何图,只能靠落盘读回。
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// testNow 是夹具的固定时钟(可复现:所有时间断言都基于它)。
|
||
func testNow() time.Time {
|
||
return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||
}
|
||
|
||
// newTestStore 建一套隔离夹具:一个跑过迁移的内存库 + 一个把时钟钉在 testNow 的
|
||
// 存储。默认 **关闭 GC**(存储用例与保留期无关,GC 由 gc_test.go 专门覆盖),
|
||
// tune 可覆盖任何配置项。第二个返回值是底层连接,供用例做结构级断言。
|
||
func newTestStore(t *testing.T, tune func(*Config)) (*SQLStore, *sql.DB) {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
conn, err := db.Open(ctx, ":memory:", nil)
|
||
if err != nil {
|
||
t.Fatalf("打开内存库: %v", err)
|
||
}
|
||
if err := db.MigrateUp(ctx, conn, nil); err != nil {
|
||
_ = conn.Close()
|
||
t.Fatalf("内存库跑迁移: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = conn.Close() })
|
||
|
||
cfg := Config{DB: conn, Now: testNow, GCInterval: 0, Retention: 0}
|
||
if tune != nil {
|
||
tune(&cfg)
|
||
}
|
||
st, err := NewStore(cfg)
|
||
if err != nil {
|
||
t.Fatalf("NewStore: %v", err)
|
||
}
|
||
return st, conn
|
||
}
|
||
|
||
// sampleGraph 造一轮最小图(input→llm→output),轮次标识 = "<session>-<round>",
|
||
// 与 `trace.RunGraph.Snapshot` 的命名逐字同形。
|
||
func sampleGraph(session string, round int) *trace.FlowGraph {
|
||
return &trace.FlowGraph{
|
||
ID: session + "-" + itoa(round),
|
||
SessionID: session,
|
||
StartedAt: testNow().Add(-time.Minute),
|
||
EndedAt: testNow(),
|
||
Nodes: []trace.FlowNode{
|
||
{ID: "input", Label: "你好", Kind: "input", Status: trace.FlowStatusDone},
|
||
{ID: "llm-1", Label: "思考", Kind: "llm", Status: trace.FlowStatusDone},
|
||
{ID: "output", Label: "回复", Kind: "output", Status: trace.FlowStatusDone},
|
||
},
|
||
Edges: []trace.FlowEdge{{From: "input", To: "llm-1"}, {From: "llm-1", To: "output"}},
|
||
}
|
||
}
|
||
|
||
// itoa 是个极小的十进制转换(避免为一个测试引入 fmt 的字符串依赖)。
|
||
func itoa(n int) string {
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
digits := make([]byte, 0, 4)
|
||
for n > 0 {
|
||
digits = append([]byte{byte('0' + n%10)}, digits...)
|
||
n /= 10
|
||
}
|
||
return string(digits)
|
||
}
|
||
|
||
// TestNewStore_NilDB_异常:没有数据库就没有存储 —— 宁可明确失败,也不给一个写不进去
|
||
// 的替身(与 events / documents 同一口径)。
|
||
func TestNewStore_NilDB_异常(t *testing.T) {
|
||
t.Parallel()
|
||
if _, err := NewStore(Config{}); !errors.Is(err, ErrNilDB) {
|
||
t.Fatalf("NewStore(nil DB) err = %v, want ErrNilDB", err)
|
||
}
|
||
}
|
||
|
||
// TestRecordRound_ReadBack_正常 钉住「写进去什么、按轮次读回来就是什么」:Latest 回
|
||
// 最新一轮、Graph 按 turnId 精确取,且主干端点与边都完整往返。
|
||
func TestRecordRound_ReadBack_正常(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
st, _ := newTestStore(t, nil)
|
||
|
||
for round := 1; round <= 3; round++ {
|
||
if err := st.RecordRound(sampleGraph("web", round)); err != nil {
|
||
t.Fatalf("RecordRound(round=%d): %v", round, err)
|
||
}
|
||
}
|
||
latest, err := st.Latest(ctx, "web")
|
||
if err != nil {
|
||
t.Fatalf("Latest: %v", err)
|
||
}
|
||
if latest == nil || latest.ID != "web-3" {
|
||
t.Fatalf("Latest = %+v, want web-3", latest)
|
||
}
|
||
got, err := st.Graph(ctx, "web", "web-2")
|
||
if err != nil {
|
||
t.Fatalf("Graph(web-2): %v", err)
|
||
}
|
||
if got == nil || got.ID != "web-2" {
|
||
t.Fatalf("Graph(web-2) = %+v, want web-2", got)
|
||
}
|
||
if len(got.Nodes) != 3 || len(got.Edges) != 2 {
|
||
t.Fatalf("round-trip lost structure: nodes=%d edges=%d", len(got.Nodes), len(got.Edges))
|
||
}
|
||
if got.SessionID != "web" {
|
||
t.Fatalf("SessionID = %q, want web(图自带会话,前端按它键入桶)", got.SessionID)
|
||
}
|
||
}
|
||
|
||
// TestGraph_MissingIsHonestNil_边界 钉住「该轮不存在」是**诚实的空**(nil, nil),
|
||
// 不是错误 —— 客户端据此渲染空态,绝不补画假图。
|
||
func TestGraph_MissingIsHonestNil_边界(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
st, _ := newTestStore(t, nil)
|
||
|
||
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
|
||
t.Fatalf("RecordRound: %v", err)
|
||
}
|
||
for _, turn := range []string{"web-999", "sess-backthink-1"} {
|
||
got, err := st.Graph(ctx, "web", turn)
|
||
if err != nil {
|
||
t.Fatalf("Graph(%s) err = %v, want nil(缺席不是错误)", turn, err)
|
||
}
|
||
if got != nil {
|
||
t.Fatalf("Graph(%s) = %+v, want nil", turn, got)
|
||
}
|
||
}
|
||
latest, err := st.Latest(ctx, "sess-backthink")
|
||
if err != nil {
|
||
t.Fatalf("Latest(unknown session) err = %v, want nil", err)
|
||
}
|
||
if latest != nil {
|
||
t.Fatalf("Latest(unknown session) = %+v, want nil", latest)
|
||
}
|
||
}
|
||
|
||
// TestRecordRound_SameTurnMerges_正常 钉住唯一键:同一会话同一轮二次收尾是**同轮覆盖**
|
||
// (一行、取后者),绝不插出两行「同一轮」。
|
||
func TestRecordRound_SameTurnMerges_正常(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
st, conn := newTestStore(t, nil)
|
||
|
||
first := sampleGraph("web", 1)
|
||
if err := st.RecordRound(first); err != nil {
|
||
t.Fatalf("record first: %v", err)
|
||
}
|
||
second := sampleGraph("web", 1)
|
||
second.Nodes = second.Nodes[:1] // 第二次少两个节点:读回来必须看到「后写的那份」
|
||
if err := st.RecordRound(second); err != nil {
|
||
t.Fatalf("record second: %v", err)
|
||
}
|
||
|
||
var rows int
|
||
if err := conn.QueryRowContext(ctx,
|
||
`SELECT count(*) FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1").Scan(&rows); err != nil {
|
||
t.Fatalf("count rows: %v", err)
|
||
}
|
||
if rows != 1 {
|
||
t.Fatalf("rows = %d, want 1 (unique key must merge, not duplicate)", rows)
|
||
}
|
||
got, err := st.Graph(ctx, "web", "web-1")
|
||
if err != nil {
|
||
t.Fatalf("Graph: %v", err)
|
||
}
|
||
if got == nil || len(got.Nodes) != 1 {
|
||
t.Fatalf("merged round = %+v, want the second (1 node)", got)
|
||
}
|
||
}
|
||
|
||
// TestRecordRound_MissingID_异常 钉住缺坐标是显式的编程错误(ErrNoGraphID),
|
||
// 绝不写一行「不知道是哪一轮」的记录进库。
|
||
func TestRecordRound_MissingID_异常(t *testing.T) {
|
||
t.Parallel()
|
||
st, _ := newTestStore(t, nil)
|
||
|
||
cases := map[string]*trace.FlowGraph{
|
||
"nil graph": nil,
|
||
"no session": {ID: "web-1"},
|
||
"no turn id": {SessionID: "web"},
|
||
"blank turn id": {SessionID: "web", ID: " "},
|
||
"blank session": {SessionID: " ", ID: "web-1"},
|
||
}
|
||
for name, g := range cases {
|
||
if err := st.RecordRound(g); !errors.Is(err, ErrNoGraphID) {
|
||
t.Fatalf("%s: err = %v, want ErrNoGraphID", name, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTurns_NewestFirstAndClamped_正常 钉住轮次列表的排序口径(最新在前)与分页钳制:
|
||
// 面板据此选历史轮,顺序错了就等于把「最近几轮」讲反。
|
||
func TestTurns_NewestFirstAndClamped_正常(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
// 时钟递增,保证 created_at 严格不同(排序可复现)。
|
||
base := testNow()
|
||
clock := base
|
||
st, _ := newTestStore(t, func(c *Config) { c.Now = func() time.Time { return clock } })
|
||
|
||
for round := 1; round <= 4; round++ {
|
||
clock = base.Add(time.Duration(round) * time.Minute)
|
||
if err := st.RecordRound(sampleGraph("web", round)); err != nil {
|
||
t.Fatalf("RecordRound(round=%d): %v", round, err)
|
||
}
|
||
}
|
||
turns, err := st.Turns(ctx, "web", 0)
|
||
if err != nil {
|
||
t.Fatalf("Turns: %v", err)
|
||
}
|
||
if len(turns) != 4 {
|
||
t.Fatalf("turns = %d, want 4", len(turns))
|
||
}
|
||
want := []string{"web-4", "web-3", "web-2", "web-1"}
|
||
for i, w := range want {
|
||
if turns[i].TurnID != w {
|
||
t.Fatalf("turns[%d] = %q, want %q(最新在前)", i, turns[i].TurnID, w)
|
||
}
|
||
}
|
||
// 负/零取缺省;超上限被钳住(这里给 2 取 2)。
|
||
limited, err := st.Turns(ctx, "web", 2)
|
||
if err != nil {
|
||
t.Fatalf("Turns(limit=2): %v", err)
|
||
}
|
||
if len(limited) != 2 || limited[0].TurnID != "web-4" {
|
||
t.Fatalf("Turns(limit=2) = %+v, want newest 2", limited)
|
||
}
|
||
if MaxTurnsLimit < 3 || normalizeLimit(MaxTurnsLimit+1000) != MaxTurnsLimit {
|
||
t.Fatalf("normalizeLimit must clamp to MaxTurnsLimit=%d", MaxTurnsLimit)
|
||
}
|
||
if normalizeLimit(0) != DefaultTurnsLimit || normalizeLimit(-5) != DefaultTurnsLimit {
|
||
t.Fatalf("normalizeLimit(≤0) must fall back to DefaultTurnsLimit=%d", DefaultTurnsLimit)
|
||
}
|
||
}
|
||
|
||
// TestStore_SurvivesRestart_正常 是**本切片的内核**(主人要求):后端重启后仍能查回
|
||
// 历史轮次的图。用同一个**文件库**、先后两条连接 + 两个 store 模拟进程重启 —— 第二个
|
||
// store 内存里没有任何图,唯一能拿到 web-3 的方式就是从库里读回。
|
||
func TestStore_SurvivesRestart_正常(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
path := filepath.Join(t.TempDir(), "cyrene.db")
|
||
|
||
// 第一个「进程」:建库、迁移、落一轮。
|
||
conn1, err := db.Open(ctx, path, nil)
|
||
if err != nil {
|
||
t.Fatalf("open db (first): %v", err)
|
||
}
|
||
if err := db.MigrateUp(ctx, conn1, nil); err != nil {
|
||
_ = conn1.Close()
|
||
t.Fatalf("migrate (first): %v", err)
|
||
}
|
||
st1, err := NewStore(Config{DB: conn1, Now: testNow})
|
||
if err != nil {
|
||
_ = conn1.Close()
|
||
t.Fatalf("NewStore (first): %v", err)
|
||
}
|
||
if err := st1.RecordRound(sampleGraph("web", 3)); err != nil {
|
||
_ = conn1.Close()
|
||
t.Fatalf("RecordRound: %v", err)
|
||
}
|
||
_ = conn1.Close() // 模拟进程退出
|
||
|
||
// 第二个「进程」:新连接、新 store(内存里没有任何图),只能靠落盘。
|
||
conn2, err := db.Open(ctx, path, nil)
|
||
if err != nil {
|
||
t.Fatalf("open db (second): %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = conn2.Close() })
|
||
if err := db.MigrateUp(ctx, conn2, nil); err != nil {
|
||
t.Fatalf("migrate (second): %v", err)
|
||
}
|
||
st2, err := NewStore(Config{DB: conn2, Now: testNow})
|
||
if err != nil {
|
||
t.Fatalf("NewStore (second): %v", err)
|
||
}
|
||
got, err := st2.Graph(ctx, "web", "web-3")
|
||
if err != nil {
|
||
t.Fatalf("Graph after restart: %v", err)
|
||
}
|
||
if got == nil || got.ID != "web-3" {
|
||
t.Fatalf("重启后查回 = %+v, want web-3(这正是主人要的「本地数据」)", got)
|
||
}
|
||
latest, err := st2.Latest(ctx, "web")
|
||
if err != nil {
|
||
t.Fatalf("Latest after restart: %v", err)
|
||
}
|
||
if latest == nil || latest.ID != "web-3" {
|
||
t.Fatalf("重启后 Latest = %+v, want web-3", latest)
|
||
}
|
||
turns, err := st2.Turns(ctx, "web", 0)
|
||
if err != nil {
|
||
t.Fatalf("Turns after restart: %v", err)
|
||
}
|
||
if len(turns) != 1 || turns[0].TurnID != "web-3" {
|
||
t.Fatalf("重启后 turns = %+v, want [web-3]", turns)
|
||
}
|
||
}
|