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
@@ -1,9 +1,11 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
@@ -13,11 +15,41 @@ import (
|
||||
// 作为 WS 事件类型 graph_update 转发(design/19 §1)。
|
||||
const graphUpdateSubject = "ws.graph_update"
|
||||
|
||||
// flowLogSource 是发布器日志的 source 标签(落盘失败记 WARN;AGENTS §10 禁裸日志)。
|
||||
const flowLogSource = "runtime/loop/flow"
|
||||
|
||||
// allowedSessions 是用于控制哪些会话可发布
|
||||
// flow 图的谓词签名(主人的 web 会话),使 IM/陌生人/子代理轮次绝不
|
||||
// 泄露进管理面板。谓词为 nil 时允许所有会话。
|
||||
type allowedSessions func(sessionID string) bool
|
||||
|
||||
// RoundRecorder 把一条**已收尾轮次**的图落盘(design/19 §4 的 P3「多轮历史回放」;
|
||||
// 切片 F2)。生产实现是 `internal/runtime/flowgraph` 的 `*SQLStore`。
|
||||
//
|
||||
// 语义约定(实现方必须遵守):**每轮一条**(只在 EndRound 里调用,绝不在每个 span
|
||||
// 上调用),失败**上抛**给发布器(发布器记 WARN 后继续)—— 图是可视化资产,落盘失败
|
||||
// 绝不允许影响会话收尾,也绝不改变实时帧。
|
||||
//
|
||||
// 白名单不在实现方:EndRound 对发布会话范围外的会话**直接返回**,因此落盘出口天然
|
||||
// 只被白名单内的会话触发(复用 flowSessionAllowed,不存在第二份白名单)。
|
||||
type RoundRecorder interface {
|
||||
RecordRound(g *trace.FlowGraph) error
|
||||
}
|
||||
|
||||
// FlowPublisherOption 配置 FlowPublisher 的可选行为(落盘出口 + 日志)。
|
||||
type FlowPublisherOption func(*FlowPublisher)
|
||||
|
||||
// WithRoundRecorder 挂一个「每轮收尾时落一条」的落盘出口。传 nil 等价于不落盘。
|
||||
func WithRoundRecorder(rec RoundRecorder) FlowPublisherOption {
|
||||
return func(p *FlowPublisher) { p.record = rec }
|
||||
}
|
||||
|
||||
// WithFlowLogger 给发布器一个 logger:落盘失败要**可见**(记 WARN),但绝不把图内容
|
||||
// 写进日志(图里可能含用户输入文本,只记 session/turn 标识)。
|
||||
func WithFlowLogger(log *slog.Logger) FlowPublisherOption {
|
||||
return func(p *FlowPublisher) { p.log = log }
|
||||
}
|
||||
|
||||
// FlowPublisher 从循环的 trace span 累积每会话 flow 图,
|
||||
// 并在每个 span 开始/结束时发布 ws.graph_update,给前端
|
||||
// 一张实时的 input→think→tool→output 图(design/19 §1)。它容忍 nil
|
||||
@@ -34,19 +66,32 @@ type FlowPublisher struct {
|
||||
allow allowedSessions
|
||||
graphs map[string]*trace.RunGraph
|
||||
rounds map[string]int
|
||||
// record 是每轮收尾的落盘出口(design/19 §4 P3;切片 F2)。nil = 不落盘
|
||||
// (保持 F1 之前的纯内存行为,且测试可只测发布)。
|
||||
record RoundRecorder
|
||||
// log 用于把落盘失败记成 WARN(带 source)。nil 时静默(测试/未接线)。
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewFlowPublisher 构建一个 FlowPublisher。publish 非 nil 时,会在
|
||||
// 每次 span 状态迁移时以 graphUpdateSubject 与 *trace.FlowGraph
|
||||
// 快照调用。allow 非 nil 时,控制哪些 session id 可发布(返回
|
||||
// false 则整个会话被丢弃)。两者都可以为 nil(nil publish 为空操作)。
|
||||
func NewFlowPublisher(publish func(subject string, payload any), allow func(sessionID string) bool) *FlowPublisher {
|
||||
return &FlowPublisher{
|
||||
//
|
||||
// 可变项经 Functional Options 传入(不改变既有两参调用):落盘出口(F2)与日志。
|
||||
func NewFlowPublisher(publish func(subject string, payload any), allow func(sessionID string) bool, opts ...FlowPublisherOption) *FlowPublisher {
|
||||
p := &FlowPublisher{
|
||||
publish: publish,
|
||||
allow: allow,
|
||||
graphs: make(map[string]*trace.RunGraph),
|
||||
rounds: make(map[string]int),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(p)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// StartRound 为 sessionID 开始一张新图,用用户文本
|
||||
@@ -72,18 +117,34 @@ func (p *FlowPublisher) StartRound(sessionID, inputText string) {
|
||||
// **重放的出口是 `LastGraph`**(网关只读查询,契约 v0.71),而不是又发一帧:
|
||||
// 实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取补齐
|
||||
// (`graph_update` 本身不重放)。
|
||||
//
|
||||
// 收尾也是**落盘**的唯一时机(切片 F2;design/19 §4 的 P3「多轮历史回放」):每轮落
|
||||
// **一条**(绝不是每 span 一条),且只在该会话处于发布会话白名单内时发生(本方法对
|
||||
// 白名单外的会话直接返回 ⇒ 落盘出口天然只被白名单会话触发)。深拷贝与写库在**放锁
|
||||
// 之后**做:一次库写绝不把其他会话的 span 发布挡住;失败只记 WARN,绝不影响本轮收尾。
|
||||
func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
||||
if sessionID == "" || !p.allowed(sessionID) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
if g == nil {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
g.SeedOutput(outputText)
|
||||
p.publishLocked(g)
|
||||
var snap *trace.FlowGraph
|
||||
if p.record != nil {
|
||||
snap = g.Snapshot().Clone()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if snap != nil {
|
||||
if err := p.record.RecordRound(snap); err != nil {
|
||||
p.logPersistError(sessionID, snap.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarkInputSteered 给本轮的 input 节点打上 `steered:true` 标记并发布一帧
|
||||
@@ -188,6 +249,16 @@ func (p *FlowPublisher) allowed(sessionID string) bool {
|
||||
return p.allow(sessionID)
|
||||
}
|
||||
|
||||
// logPersistError 把一次落盘失败记成带来源的 WARN。它只记 session/turn 标识,
|
||||
// **绝不打印图内容**(图里可能含用户输入文本);logger 未接线时静默。
|
||||
func (p *FlowPublisher) logPersistError(sessionID, turnID string, err error) {
|
||||
if p.log == nil {
|
||||
return
|
||||
}
|
||||
logger.WithSource(p.log, flowLogSource).Warn("flow round persist failed",
|
||||
"session", sessionID, "turn", turnID, "err", err)
|
||||
}
|
||||
|
||||
// lastUserText 返回 messages 中最新用户消息的文本,用于
|
||||
// 标注该轮的 input 节点。没有用户文本时返回 ""。
|
||||
func lastUserText(messages []model.Message) string {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package loop
|
||||
|
||||
// 本文件钉住**每轮落一条**的落盘口径(切片 F2;design/19 §4 P3)。它是「写放大」
|
||||
// 这条红线在代码里的落点:落盘只能发生在 EndRound 上(每轮一次),**绝不**在每个
|
||||
// span 上发生;且只对发布会话白名单内的会话发生。用假 recorder 数调用次数,
|
||||
// 不依赖任何数据库。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// fakeRecorder 记录每次落盘调用(含图的深拷贝)并可选地报错。
|
||||
type fakeRecorder struct {
|
||||
mu sync.Mutex
|
||||
graphs []*trace.FlowGraph
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRecorder) RecordRound(g *trace.FlowGraph) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.graphs = append(f.graphs, g)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeRecorder) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.graphs)
|
||||
}
|
||||
|
||||
func (f *fakeRecorder) last() *trace.FlowGraph {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.graphs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return f.graphs[len(f.graphs)-1]
|
||||
}
|
||||
|
||||
// runRoundWithSpans 跑一轮:起轮 → 三个 span(起止)→ 收轮。它不读任何全局状态,
|
||||
// 发布/落盘的计数由各用例自己的闭包与 recorder 负责。
|
||||
func runRoundWithSpans(p *FlowPublisher, session string) {
|
||||
p.StartRound(session, "帮我查天气")
|
||||
for i, name := range []string{"llm.call", "tool_call", "llm.call"} {
|
||||
sp := &trace.Span{ID: "span-" + string(rune('a'+i)), Name: name, Kind: trace.SpanKindLLM, StartedAt: time.Now()}
|
||||
p.SpanStarted(session, sp)
|
||||
sp.Finish(trace.SpanStatusOK)
|
||||
p.SpanFinished(session, sp)
|
||||
}
|
||||
p.EndRound(session, "今天晴,26 度。")
|
||||
}
|
||||
|
||||
// TestFlowPublisher_EndRoundPersistsOnePerRound_正常 是本片的核心断言:无论这一轮
|
||||
// 有多少个 span,**落盘恰好一次**(每轮一行),且落的是**收尾后**的图(含 output 节点)。
|
||||
func TestFlowPublisher_EndRoundPersistsOnePerRound_正常(t *testing.T) {
|
||||
rec := &fakeRecorder{}
|
||||
p := NewFlowPublisher(func(string, any) {}, func(string) bool { return true },
|
||||
WithRoundRecorder(rec))
|
||||
|
||||
runRoundWithSpans(p, "web")
|
||||
|
||||
if got := rec.count(); got != 1 {
|
||||
t.Fatalf("落盘次数 = %d, want 1(每轮一行;每 span 一行是写放大,本片红线)", got)
|
||||
}
|
||||
g := rec.last()
|
||||
if g == nil {
|
||||
t.Fatal("recorder 没收到图")
|
||||
}
|
||||
if g.ID != "web-1" {
|
||||
t.Fatalf("落盘 turn id = %q, want web-1", g.ID)
|
||||
}
|
||||
if g.SessionID != "web" {
|
||||
t.Fatalf("落盘 session = %q, want web", g.SessionID)
|
||||
}
|
||||
kinds := map[string]bool{}
|
||||
for _, n := range g.Nodes {
|
||||
kinds[n.Kind] = true
|
||||
}
|
||||
if !kinds["input"] || !kinds["output"] {
|
||||
t.Fatalf("落盘的图必须含主干端点 input/output: %+v", g.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_EndRoundSkipsWhitelistOutside_正常 钉住落盘的**白名单复用之**:
|
||||
// 发布会话范围外的会话(隔离渠道会话)连图都不建,EndRound 直接返回 ⇒ 落盘调用为零。
|
||||
func TestFlowPublisher_EndRoundSkipsWhitelistOutside_正常(t *testing.T) {
|
||||
rec := &fakeRecorder{}
|
||||
allow := func(session string) bool { return session == "web" }
|
||||
p := NewFlowPublisher(func(string, any) {}, allow, WithRoundRecorder(rec))
|
||||
|
||||
runRoundWithSpans(p, "sess-im-stranger")
|
||||
if got := rec.count(); got != 0 {
|
||||
t.Fatalf("白名单外会话落盘 %d 次, want 0(白名单是同一处判据,绝不放行)", got)
|
||||
}
|
||||
runRoundWithSpans(p, "web")
|
||||
if got := rec.count(); got != 1 {
|
||||
t.Fatalf("白名单内会话落盘 %d 次, want 1(反向证明上面的 0 不是整体坏了)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_RecorderFailureDoesNotDropRound_异常 钉住「落盘失败不影响会话」:
|
||||
// recorder 报错时 EndRound 照常发布收尾帧(图仍在内存、面板仍实时看得到),且不 panic。
|
||||
func TestFlowPublisher_RecorderFailureDoesNotDropRound_异常(t *testing.T) {
|
||||
frames := 0
|
||||
rec := &fakeRecorder{err: errors.New("db down")}
|
||||
p := NewFlowPublisher(func(string, any) { frames++ }, func(string) bool { return true },
|
||||
WithRoundRecorder(rec))
|
||||
|
||||
runRoundWithSpans(p, "web")
|
||||
|
||||
if rec.count() != 1 {
|
||||
t.Fatalf("recorder 应被调用一次(失败也要尝试)")
|
||||
}
|
||||
if frames == 0 {
|
||||
t.Fatal("落盘失败不该吞掉实时帧 —— EndRound 仍须发布收尾帧")
|
||||
}
|
||||
// 实时图仍保留最近一轮(LastGraph 是收尾快照的出口)。
|
||||
if snap := p.LastGraph("web"); snap == nil || len(snap.Nodes) == 0 {
|
||||
t.Fatal("落盘失败后最近一轮图仍在内存(LastGraph 仍可读)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_NoRecorderStillPublishes_边界 钉住 F2 是**纯增量**:没挂 recorder
|
||||
// 时 EndRound 行为与 F1 之前逐字相同(只发布、不落盘、不 panic)。
|
||||
func TestFlowPublisher_NoRecorderStillPublishes_边界(t *testing.T) {
|
||||
frames := 0
|
||||
p := NewFlowPublisher(func(string, any) { frames++ }, func(string) bool { return true })
|
||||
runRoundWithSpans(p, "web")
|
||||
if frames == 0 {
|
||||
t.Fatal("没有 recorder 时 EndRound 仍须正常发布")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user