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

+290
View File
@@ -0,0 +1,290 @@
package main
// 本文件是**链路图落盘组合根接线**的验收(切片 F2;design/19 §4 P3)。
//
// 两道并行的取证(与 `wire_channel_events_test.go` 同一手法):
//
// 1. **接线存在性**:组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在
// 运行),因此「存储真的被构造」「真的交给发布器作落盘出口」「真的注入网关作查询
// 出口」「常驻循环真的被拉起」只能在源码层钉住 —— 少任何一处,落盘会**静默不工作**
// (图照旧只在内存、日志里一句都没有),而这不红任何既有用例。最后一段还核对
// `.env.example` 的两个键(AGENTS §10 的 E6 条目)。
// 2. **行为**:两个 env 键的默认值必须来自 `flowgraph` 导出的常量(**不另写一份字面量**)、
// 显式 ≤0 = 关闭清扫、只写在 `.env` 里也真实生效、非法取值回落默认 + WARN;以及
// GC 关闭时不建零间隔 ticker(`time.NewTicker(0)` 会 panic)。
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
)
// TestWireFlowGraphs_IsWired 钉住组合根的接缝 + `.env.example` 同步。
func TestWireFlowGraphs_IsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_flow_graphs.go": {
// 存储真的被构造,且默认值取 flowgraph 导出的常量(不另写字面量)。
"flowgraph.NewStore(flowgraph.Config{",
"durationEnv(a.log, envFilePath, flowGraphGCIntervalEnv, flowgraph.DefaultGCInterval)",
"durationEnv(a.log, envFilePath, flowGraphRetentionEnv, flowgraph.DefaultRetention)",
// 未接线时交出**真 nil 接口**(装 nil 指针的接口不等于 nil,会让发布器/网关的
// 未接线判据失效)。
"func (a *app) flowRoundRecorder() loop.RoundRecorder {",
"func (a *app) flowGraphStore() gateway.FlowGraphStore {",
// GC 关闭时不许建零间隔 ticker(time.NewTicker(0) 会 panic)。
"if !k.store.Enabled() {",
},
"wire_runtime.go": {
// 先建存储,再把它交给发布器作每轮落盘出口。
"a.wireFlowGraphs()",
"loop.WithRoundRecorder(a.flowRoundRecorder())",
},
"wire_frontend.go": {
// 网关只读出口注入**同一个**存储句柄。
"FlowGraphs: a.flowGraphStore(),",
},
"main.go": {
// 常驻循环:没有这一行,落盘的轮次图从不被清理(保留期形同虚设、无限增长)。
"a.startFlowGraphs(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 链路图落盘没有被接上组合根", file, want)
}
}
}
// 两个新键必须与代码里的键名**逐字一致**(AGENTS §10 的 E6 条目)。
src, err := os.ReadFile("wire_flow_graphs.go")
if err != nil {
t.Fatalf("read wire_flow_graphs.go: %v", err)
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
if !strings.Contains(string(src), `"`+key+`"`) {
t.Fatalf("wire_flow_graphs.go 里没有键名常量 %q", key)
}
if !strings.Contains(string(raw), key+"=") {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key+"=")
}
}
}
// flowGraphTestApp 构造一个只带落盘面所需依赖的 app(库 + 静默 logger)。
func flowGraphTestApp(t *testing.T, log *slog.Logger) *app {
t.Helper()
if log == nil {
log = testLog()
}
return &app{db: openMigratedTestDB(t, filepath.Join(t.TempDir(), "cyrene.db")), log: log}
}
// unsetFlowGraphEnv 把两个键从**进程环境**里拿掉并在用例结束后复原。
// ⚠️ 不能用 `t.Setenv(key, "")` 代替:空值在优先级链里仍然「存在」,会盖住 `.env`
// 里的同名声明,而「只写在 .env 里也生效」正是要验的东西(与渠道事件同一取舍)。
func unsetFlowGraphEnv(t *testing.T) {
t.Helper()
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
key := key
prev, had := os.LookupEnv(key)
if had {
t.Cleanup(func() { _ = os.Setenv(key, prev) })
} else {
t.Cleanup(func() { _ = os.Unsetenv(key) })
}
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
}
// TestNewFlowGraphStore_默认值取导出常量_正常:未配置时两个旋钮必须**就是** flowgraph
// 导出的默认值(168h / 1h),且清扫开启 —— 另写字面量迟早与文档承诺漂移。
func TestNewFlowGraphStore_默认值取导出常量_正常(t *testing.T) {
unsetFlowGraphEnv(t)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil {
t.Fatal("有库时必须构造出链路图存储")
}
if store.Retention() != flowgraph.DefaultRetention {
t.Fatalf("Retention = %v, want %v(导出的默认常量)", store.Retention(), flowgraph.DefaultRetention)
}
if store.Interval() != flowgraph.DefaultGCInterval {
t.Fatalf("GC 间隔 = %v, want %v", store.Interval(), flowgraph.DefaultGCInterval)
}
if !store.Enabled() {
t.Fatal("未配置时清扫必须开启(漏配 != 关闭)")
}
}
// TestNewFlowGraphStore_显式零关闭清扫_边界:`≤0 = 关闭`—— 0 刻意不解释成「立即清空」,
// 那会让一次笔误抹掉刚落盘的历史轮。
func TestNewFlowGraphStore_显式零关闭清扫_边界(t *testing.T) {
cases := []struct {
name string
key string
value string
}{
{"保留期为 0", flowGraphRetentionEnv, "0"},
{"保留期为负", flowGraphRetentionEnv, "-1h"},
{"清扫间隔为 0", flowGraphGCIntervalEnv, "0"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
unsetFlowGraphEnv(t)
t.Setenv(tc.key, tc.value)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil {
t.Fatal("配置非法不该让存储整个消失")
}
if store.Enabled() {
t.Fatalf("%s=%s 时清扫仍开启,want 关闭(显式 ≤0 = 关闭)", tc.key, tc.value)
}
})
}
}
// TestNewFlowGraphStore_只写在env文件里也生效_正常:`.env` 不写回进程环境,直接
// os.Getenv 会**静默忽略**它。本用例把两个键只写进临时 `.env`,取值必须原样生效。
func TestNewFlowGraphStore_只写在env文件里也生效_正常(t *testing.T) {
unsetFlowGraphEnv(t)
path := filepath.Join(t.TempDir(), ".env")
body := strings.Join([]string{
flowGraphRetentionEnv + "=24h",
flowGraphGCIntervalEnv + "=30m",
}, "\n") + "\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
store := flowGraphTestApp(t, nil).newFlowGraphStore(path)
if store == nil {
t.Fatal("有库时必须构造出链路图存储")
}
if store.Retention() != 24*time.Hour || store.Interval() != 30*time.Minute {
t.Fatalf("取值 = (%v, %v), want (24h, 30m)", store.Retention(), store.Interval())
}
}
// TestNewFlowGraphStore_非法取值回落默认并告警_异常:配了但读不懂 ⇒ 回落默认值**且**记
// 一条带键名的 WARN(非法配置不许静默失效,也不许一票否决整条落盘面)。
func TestNewFlowGraphStore_非法取值回落默认并告警_异常(t *testing.T) {
unsetFlowGraphEnv(t)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(flowGraphRetentionEnv+"=七天\n"), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
store := flowGraphTestApp(t, log).newFlowGraphStore(path)
if store == nil {
t.Fatal("单个键非法不该让存储整个消失")
}
if store.Retention() != flowgraph.DefaultRetention {
t.Fatalf("非法取值时的保留期 = %v, want %v", store.Retention(), flowgraph.DefaultRetention)
}
logged := buf.String()
if !strings.Contains(logged, "level=WARN") || !strings.Contains(logged, flowGraphRetentionEnv) {
t.Fatalf("非法取值没有留下带键名的 WARN:%q", logged)
}
}
// TestFlowGraphStore_未接线时交出真nil接口_边界:未接数据库时两个出口都必须返回**真
// nil 接口** —— 返回「装着 nil 指针的接口」会让发布器/网关的 nil 判据失效。
func TestFlowGraphStore_未接线时交出真nil接口_边界(t *testing.T) {
blank := &app{log: testLog()}
if store := blank.newFlowGraphStore(""); store != nil {
t.Fatalf("库未接时不该构造存储:%+v", store)
}
if rec := blank.flowRoundRecorder(); rec != nil {
t.Fatalf("未接线时落盘出口必须是真 nil 接口,拿到的是 %#v", rec)
}
if got := blank.flowGraphStore(); got != nil {
t.Fatalf("未接线时网关只读出口必须是真 nil 接口,拿到的是 %#v", got)
}
}
// TestFlowGraphKeeper_GC关闭时不建零间隔ticker_边界:`time.NewTicker(0)` 会 panic,所以
// 「配了 ≤0 = 关闭清扫」必须在建 ticker 之前分岔,且 loop 立即返回(不留空转 goroutine)。
func TestFlowGraphKeeper_GC关闭时不建零间隔ticker_边界(t *testing.T) {
unsetFlowGraphEnv(t)
t.Setenv(flowGraphGCIntervalEnv, "0")
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil || store.Enabled() {
t.Fatalf("GC 应关闭:store=%v enabled=%v", store, store != nil && store.Enabled())
}
keeper := &flowGraphKeeper{store: store, log: testLog(), done: make(chan struct{})}
ticks, stop := keeper.gcTicker()
if ticks != nil {
t.Fatal("GC 关闭时不该建 ticker(零间隔 ticker 会 panic)")
}
stop()
// loop 在 GC 关闭时必须立刻返回并关闭 done(否则常驻 goroutine 泄漏)。
keeper.loop(context.Background())
select {
case <-keeper.done:
default:
t.Fatal("GC 关闭时 loop 应立即返回并关闭 done")
}
}
// TestFlowGraphKeeper_开启时给出定时通道_正常:默认配置下 GC 开启,必须给出非 nil 通道。
func TestFlowGraphKeeper_开启时给出定时通道_正常(t *testing.T) {
unsetFlowGraphEnv(t)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil || !store.Enabled() {
t.Fatal("默认配置下 GC 应开启")
}
ticks, stop := (&flowGraphKeeper{store: store, log: testLog()}).gcTicker()
if ticks == nil {
t.Fatal("GC 开启时必须给出定时通道")
}
stop()
}
// TestFlowGraphEnv_模板取值是合法duration_正常 是 E6 自查的机械化版本:两个键名必须能
// 在 `.env.example` 里逐字找到,且模板值是合法 duration(占位符形态,从不放凭据)。
func TestFlowGraphEnv_模板取值是合法duration_正常(t *testing.T) {
t.Parallel()
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
lines := strings.Split(string(raw), "\n")
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
found := false
for _, line := range lines {
value, ok := strings.CutPrefix(line, key+"=")
if !ok {
continue
}
found = true
if _, err := time.ParseDuration(value); err != nil {
t.Fatalf("%s 的模板值 %q 不是合法 duration", key, value)
}
}
if !found {
t.Fatalf(".env.example 缺少 %s(AGENTS §10:键名必须与代码逐字一致)", key)
}
}
}