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)
This commit is contained in:
1 parent
84c124f5a9
commit
a6cb44b8a8
38 files changed
+3047
-91
No files matched your search
@@ -102,6 +102,9 @@ func run() error {
|
||||
// 渠道事件留存维护(切片 E3+E4):保留期清扫一次 + 此后定时清扫,内存计数
|
||||
// 按 CHANNEL_EVENT_COUNTER_FLUSH 定时落盘(退出前最后一次落盘在循环里)。
|
||||
a.startChannelEvents(ctx)
|
||||
// 链路图留存维护(切片 F2):保留期清扫一次 + 此后按 FLOW_GRAPH_GC_INTERVAL 定时清扫。
|
||||
// 没有它,落盘的轮次图会**无限增长**(design/19 §4 P3 的 GC 口径)。
|
||||
a.startFlowGraphs(ctx)
|
||||
// LLM 用量落库与保留期清理(切片 U1):单写者 + 有界队列的异步写入器,
|
||||
// 加上按 LLM_USAGE_RETENTION 的过期样本清理。
|
||||
a.startUsage(ctx)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
@@ -130,6 +131,14 @@ type app struct {
|
||||
// 发布器同时交给 loop/orchestration(写入面)与网关(查询面),因此查询到的
|
||||
// 就是实时发布的那张图,不会出现两份状态。
|
||||
flow *loop.FlowPublisher
|
||||
// flowStore 是链路图的**本地落盘**(design/19 §4 P3;切片 F2;迁移 0022):发布器
|
||||
// 每轮收尾经它落一条,网关按 turnId / turns 读回。库未接时为 nil(发布器随即走
|
||||
// 纯内存路径,见 wire_flow_graphs.go)。它与 flow 是两件事:flow 是实时内存图,
|
||||
// flowStore 是收尾存档。
|
||||
flowStore *flowgraph.SQLStore
|
||||
// flowGraphKeeper 是链路图留存的常驻维护任务(保留期清扫),由 main 的
|
||||
// startFlowGraphs 拉起(见 wire_flow_graphs.go)。
|
||||
flowGraphKeeper *flowGraphKeeper
|
||||
}
|
||||
|
||||
// newApp 依据 cfg 与 log 组装完整的 Cyrene 运行时:打开数据库、迁移表结构,接线
|
||||
|
||||
@@ -81,12 +81,13 @@ func (a *app) newChannelEventStore(envFilePath string) *events.SQLStore {
|
||||
}
|
||||
|
||||
// durationEnv 读一个 duration 型 env 键(进程环境 > `.env` > def)。配了但读不懂时记
|
||||
// 一条带原始取值的 WARN 并回落 def —— 错的是那一个键,不该让整条留存面失效
|
||||
// (AGENTS §10:非法配置不许静默失效,但也不许一票否决)。
|
||||
// 一条带键名与原始取值的 WARN 并回落 def —— 错的是那一个键,不该让整条留存面失效
|
||||
// (AGENTS §10:非法配置不许静默失效,但也不许一票否决)。消息刻意保持中立(不带
|
||||
// 具体功能名),使渠道事件与链路图两处共用同一份读取口径(切片 E4 / F2)。
|
||||
func durationEnv(log *slog.Logger, envFilePath, key string, def time.Duration) time.Duration {
|
||||
value, err := config.LoadDurationEnv(envFilePath, key, def)
|
||||
if err != nil {
|
||||
log.Warn("channel event env ignored, falling back to default",
|
||||
log.Warn("duration env ignored, falling back to default",
|
||||
"env", key, "error", err, "fallback", def.String())
|
||||
}
|
||||
return value
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
// 本文件把**链路图落盘**接进组合根(design/19 §4 P3;切片 F2):按配置构造链路图
|
||||
// 存储,交给发布器当「每轮收尾落一条」的出口,并拉起常驻的保留期清扫循环。网关的
|
||||
// 只读出口(契约 v0.73 的 `?turnId=` 与 `turns[]`)在 wire_frontend.go 里注入**同一个**
|
||||
// 存储句柄,因此「写进去的」与「查得到的」永远是一份状态。
|
||||
//
|
||||
// 三条取舍逐字对齐参照实现 `wire_channel_events.go` / `wire_document_gc.go`
|
||||
// (design 明写「不另造」):
|
||||
//
|
||||
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事,而不是在 newApp/run 里
|
||||
// 同步等它跑完。
|
||||
// 2. **失败不退出进程**:清扫失败只记一条 WARN 并等下一轮;一轮失败只是「这一轮没
|
||||
// 扫干净」,绝不让进程退出。
|
||||
// 3. **关闭 = 显式配 ≤0**:`FLOW_GRAPH_RETENTION=0`(或负值)或
|
||||
// `FLOW_GRAPH_GC_INTERVAL=0` 都不开启清扫,NewStore 会把关闭原因记进日志(静默的
|
||||
// 「配了却不生效」不存在)。
|
||||
//
|
||||
// ⚠️ 与 channelEventKeeper 的一处刻意留白:本 keeper 只在 ctx 取消时退出循环(不额外
|
||||
// 挂退出钩子)—— 与 `wire_channel_events.go` 的已知差异一致,因为 `wire_services.go`
|
||||
// 由别的切片在改(本片不许动)。清扫本就是幂等且尽力而为的。
|
||||
//
|
||||
// 两个键的读取走 `config.LoadDurationEnv`(进程环境 > `.env` > 本包导出的默认常量):
|
||||
// `.env` 不写回进程环境,直接 os.Getenv 会让只写在 `.env` 里的声明静默失效。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"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/services/gateway"
|
||||
)
|
||||
|
||||
// 两个 env 键名:必须与 `.env.example` **逐字一致**(AGENTS §10 的 E6 条目)。
|
||||
const (
|
||||
flowGraphRetentionEnv = "FLOW_GRAPH_RETENTION"
|
||||
flowGraphGCIntervalEnv = "FLOW_GRAPH_GC_INTERVAL"
|
||||
)
|
||||
|
||||
// wireFlowGraphs 构造链路图存储并挂到 app 上(随后 wireRuntime 把它交给发布器作落盘
|
||||
// 出口,wireFrontend 把它交给网关作只读查询出口)。它**不**启动任何 goroutine:常驻
|
||||
// 循环由 main 的 startFlowGraphs 在子系统启动之后拉起(与渠道事件留存的分工逐字相同)。
|
||||
func (a *app) wireFlowGraphs() {
|
||||
a.flowStore = a.newFlowGraphStore(envPath)
|
||||
}
|
||||
|
||||
// newFlowGraphStore 按配置构造链路图存储。库未接(`a.db == nil`,只可能出现在测试
|
||||
// 夹具里)时返回 nil:发布器随即走「不落盘」的纯内存路径(F1 之前的行为),会话收尾
|
||||
// 照常,绝不因为没有存储而失败或改变实时帧。
|
||||
//
|
||||
// 默认值一律取 `internal/runtime/flowgraph` 导出的常量 —— **不在这里另写一份字面量**:
|
||||
// 两处各写一份 168h/1h,改一处就会让「文档承诺的默认值」与「代码里的默认值」漂移。
|
||||
func (a *app) newFlowGraphStore(envFilePath string) *flowgraph.SQLStore {
|
||||
if a.db == nil {
|
||||
a.log.Warn("flow graph store skipped: no database connection")
|
||||
return nil
|
||||
}
|
||||
store, err := flowgraph.NewStore(flowgraph.Config{
|
||||
DB: a.db,
|
||||
Log: a.log,
|
||||
GCInterval: durationEnv(a.log, envFilePath, flowGraphGCIntervalEnv, flowgraph.DefaultGCInterval),
|
||||
Retention: durationEnv(a.log, envFilePath, flowGraphRetentionEnv, flowgraph.DefaultRetention),
|
||||
})
|
||||
if err != nil {
|
||||
// NewStore 唯一的失败是 DB 为 nil(上面已挡掉):仍如实处理,绝不吞错。
|
||||
a.log.Warn("flow graph store unavailable", "err", err)
|
||||
return nil
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// flowRoundRecorder 返回注入发布器的落盘出口:未接线时返回**真正的 nil 接口**,而不是
|
||||
// 「装着 nil 指针的接口」—— 后者在 Go 里不等于 nil,会让发布器的 `p.record != nil`
|
||||
// 判据失效(症状是每一轮收尾都撞一次 nil 接收者并记一条 WARN)。
|
||||
func (a *app) flowRoundRecorder() loop.RoundRecorder {
|
||||
if a.flowStore == nil {
|
||||
return nil
|
||||
}
|
||||
return a.flowStore
|
||||
}
|
||||
|
||||
// flowGraphStore 返回注入网关的只读出口:未接线时同样交出**真 nil 接口**(与
|
||||
// channelEventStore 同一取舍)。发布器与网关拿到的是**同一个**存储句柄。
|
||||
func (a *app) flowGraphStore() gateway.FlowGraphStore {
|
||||
if a.flowStore == nil {
|
||||
return nil
|
||||
}
|
||||
return a.flowStore
|
||||
}
|
||||
|
||||
// startFlowGraphs 拉起链路图留存的维护循环(未接线时是空操作)。
|
||||
func (a *app) startFlowGraphs(ctx context.Context) {
|
||||
if a.flowStore == nil {
|
||||
a.log.Info("flow graph upkeep disabled: store not wired",
|
||||
"effect", "rounds are kept in memory only (no local history)")
|
||||
return
|
||||
}
|
||||
keeper := &flowGraphKeeper{
|
||||
store: a.flowStore,
|
||||
log: logger.WithSource(a.log, "cmd/cyrene/wire_flow_graphs"),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
a.flowGraphKeeper = keeper
|
||||
go keeper.loop(ctx)
|
||||
}
|
||||
|
||||
// flowGraphKeeper 是链路图留存的常驻维护任务(保留期清扫)。它持有**存储句柄**
|
||||
// (不是 app),因此生命周期与用法一目了然。
|
||||
type flowGraphKeeper struct {
|
||||
store *flowgraph.SQLStore
|
||||
log *slog.Logger
|
||||
// done 在循环退出时关闭(让退出路径可观测)。
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// loop 是维护循环:启动期先清扫一次,此后按间隔定时清扫,直到 ctx 被取消。
|
||||
// GC 关闭时启动期那次是空操作,循环随即返回(不留空转 goroutine)。
|
||||
func (k *flowGraphKeeper) loop(ctx context.Context) {
|
||||
defer close(k.done)
|
||||
k.sweep(ctx)
|
||||
ticks, stop := k.gcTicker()
|
||||
defer stop()
|
||||
if ticks == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticks:
|
||||
k.sweep(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gcTicker 给出清扫的定时通道;GC 关闭时返回 **nil 通道**(select 上永不触发)。
|
||||
// `time.NewTicker(0)` 会 panic,所以「配了 ≤0 = 关闭」必须**在这里**就分岔,而不是
|
||||
// 建一个零间隔 ticker 再指望它不跑。
|
||||
func (k *flowGraphKeeper) gcTicker() (<-chan time.Time, func()) {
|
||||
if !k.store.Enabled() {
|
||||
return nil, func() {}
|
||||
}
|
||||
ticker := time.NewTicker(k.store.Interval())
|
||||
return ticker.C, ticker.Stop
|
||||
}
|
||||
|
||||
// sweep 跑一轮保留期清扫。每轮的**成功**汇总由 store 自己记(含删除行数与批次),
|
||||
// 这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
|
||||
func (k *flowGraphKeeper) sweep(ctx context.Context) {
|
||||
if _, err := k.store.Sweep(ctx); err != nil {
|
||||
k.log.Warn("flow graph sweep failed", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,10 @@ func (a *app) wireFrontend() error {
|
||||
// loop/orchestration 用的**同一个** *loop.FlowPublisher,因此
|
||||
// GET /api/v1/admin/flow/graph 读到的就是实时发布的那张图。
|
||||
Flow: a.flow,
|
||||
// FlowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;切片 F2):注入的正是
|
||||
// 发布器每轮落盘用的**同一个**存储句柄,因此 `?turnId=` 与 `turns[]` 读到的就是
|
||||
// 收尾时写进去的历史轮。未接线时为真 nil(端点回退到只有内存的行为)。
|
||||
FlowGraphs: a.flowGraphStore(),
|
||||
// Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的
|
||||
// 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500
|
||||
// usage_unavailable,不回假汇总)。
|
||||
|
||||
@@ -125,6 +125,10 @@ func (a *app) wireMemory(ctx context.Context) error {
|
||||
// 追踪、持久化与编排 scheduler。
|
||||
func (a *app) wireRuntime() error {
|
||||
a.attachToolPolicyStore()
|
||||
// 链路图本地落盘(design/19 §4 P3;切片 F2):先建存储,随后在下面交给发布器作
|
||||
// 每轮收尾的落盘出口。它的常驻清扫由 main 的 startFlowGraphs 拉起(见
|
||||
// wire_flow_graphs.go);网关的只读出口在 wireFrontend 注入**同一个**句柄。
|
||||
a.wireFlowGraphs()
|
||||
|
||||
// LLM 提供商栈可热插拔:它加载 models.json(或回落 .env),面板的保存端点会
|
||||
// 就地重载它而无需重启(design/04 §3.1)。a.modelAdapter 仍保持 Adapter 接口
|
||||
@@ -219,6 +223,11 @@ func (a *app) wireRuntime() error {
|
||||
flowPub := loop.NewFlowPublisher(
|
||||
func(subject string, payload any) { _ = a.bus.Publish(context.Background(), subject, payload) },
|
||||
flowSessionAllowed,
|
||||
// 每轮收尾落一条(**不是每 span 一条**):白名单由上面的 allow 把守
|
||||
// (EndRound 对白名单外的会话直接返回),因此落盘出口不重复判白名单。
|
||||
// 未接线时交出真 nil 接口 ⇒ 发布器走纯内存路径(F1 之前的行为)。
|
||||
loop.WithRoundRecorder(a.flowRoundRecorder()),
|
||||
loop.WithFlowLogger(a.log),
|
||||
)
|
||||
// 同一个发布器也交给网关:`GET /api/v1/admin/flow/graph` 的只读出口
|
||||
// (契约 v0.71)就近读它每会话保留的最近一轮图 —— 写入面(这里)与查询面
|
||||
|
||||
Reference in new issue
Block a user