feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」:
- 落盘:迁移 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)
This commit is contained in:
AskaEth committed 2026-10-05 16:19:32 +08:00
1 parent 84c124f5a9
commit a6cb44b8a8
38 files changed
+3047 -91

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
package flowgraph
// 本文件是**保留期清扫**的验收(切片 F2)。断言的是库里的事实(哪些行还在),
// 与 `internal/runtime/events/gc_test.go` 同一口径。
import (
"context"
"testing"
"time"
)
// TestSweep_DisabledIsNoOp_边界 钉住「配了 ≤0 = 关闭 GC」:`Sweep` 是**空操作**,
// 一行都不删。0 刻意不解释成「立即清空」—— 那会让一次笔误抹掉刚落盘的历史轮。
func TestSweep_DisabledIsNoOp_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
for _, tc := range []struct {
name string
gcInterval, retain time.Duration
}{
{"interval=0", 0, time.Hour},
{"interval<0", -time.Hour, time.Hour},
{"retention=0", time.Hour, 0},
{"retention<0", time.Hour, -time.Hour},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, func(c *Config) {
c.GCInterval = tc.gcInterval
c.Retention = tc.retain
})
if st.Enabled() {
t.Fatalf("Enabled() = true, want false (%s)", tc.name)
}
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
t.Fatalf("RecordRound: %v", err)
}
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != 0 || stats.Batches != 0 {
t.Fatalf("disabled Sweep = %+v, want zero", stats)
}
turns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 1 {
t.Fatalf("行被删了(turns=%d),但 GC 关闭时必须是空操作", len(turns))
}
})
}
}
// TestSweep_DeletesOnlyExpired_正常 钉住「正数只删超期」:保留期内的行**必须**留下,
// 只有 `created_at < now − 保留期` 的行被回收。时钟注入让这条判定可复现。
func TestSweep_DeletesOnlyExpired_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := testNow()
clock := base
st, _ := newTestStore(t, func(c *Config) {
c.GCInterval = time.Hour
c.Retention = 24 * time.Hour
c.Now = func() time.Time { return clock }
})
if !st.Enabled() {
t.Fatal("Enabled() = false, want true(间隔与保留期都 > 0)")
}
// 旧轮:T0 落盘。
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
t.Fatalf("record old round: %v", err)
}
// 时钟拨到 T0+48h,落一个新轮。
clock = base.Add(48 * time.Hour)
if err := st.RecordRound(sampleGraph("web", 2)); err != nil {
t.Fatalf("record new round: %v", err)
}
// cutoff = (T0+48h) − 24h = T0+24h ⇒ web-1(T0)超期、web-2(T0+48h)留下。
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("Deleted = %d, want 1(只删超期的那一轮)", stats.Deleted)
}
turns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 1 || turns[0].TurnID != "web-2" {
t.Fatalf("turns after sweep = %+v, want [web-2](保留期内的那轮必须留下)", turns)
}
if _, err := st.Graph(ctx, "web", "web-1"); err != nil {
t.Fatalf("Graph(被删轮) err = %v, want nil(缺席不是错误)", err)
}
old, err := st.Graph(ctx, "web", "web-1")
if err != nil {
t.Fatalf("Graph(web-1): %v", err)
}
if old != nil {
t.Fatalf("web-1 应已被回收,却仍读得到: %+v", old)
}
}
// TestSweep_ScopedPerSession_正常 钉住清扫是**全表按时间**的(不是按会话):过期的
// 会话行照样回收,不会因为另一个会话还有新轮就整表留着。
func TestSweep_ScopedPerSession_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := testNow()
clock := base
st, _ := newTestStore(t, func(c *Config) {
c.GCInterval = time.Hour
c.Retention = time.Hour
c.Now = func() time.Time { return clock }
})
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
t.Fatalf("record web: %v", err)
}
clock = base.Add(3 * time.Hour)
if err := st.RecordRound(sampleGraph("sess-backthink", 1)); err != nil {
t.Fatalf("record backthink: %v", err)
}
if _, err := st.Sweep(ctx); err != nil {
t.Fatalf("Sweep: %v", err)
}
webTurns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns(web): %v", err)
}
if len(webTurns) != 0 {
t.Fatalf("web turns = %+v, want empty(3h 前的轮已超 1h 保留期)", webTurns)
}
btTurns, err := st.Turns(ctx, "sess-backthink", 0)
if err != nil {
t.Fatalf("Turns(backthink): %v", err)
}
if len(btTurns) != 1 {
t.Fatalf("backthink turns = %+v, want [sess-backthink-1]", btTurns)
}
}