Files
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

318 lines
11 KiB
Go
Raw Permalink 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 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)
}
}