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

+130 -21
View File
@@ -1,11 +1,14 @@
package gateway
import (
"context"
"net/http"
"strings"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// mainSessionID 是未指定 `?sessionId=` 时的默认会话 —— Web 主会话
@@ -19,33 +22,62 @@ const mainSessionID = history.MainSessionID
// usage_unavailable / channel_events_unavailable 同一口径。
const flowUnavailableCode = "flow_unavailable"
// adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体(契约 v0.71)。
// 图上那张图**是哪来的**(契约 v0.73 §4.11):
// - live = 内存里**最近一轮**的实时图(`loop.FlowPublisher.LastGraph`);
// - persisted = 从本地库读回的**已落盘轮次**(历史回放);
// - none = 没有图(诚实空态)。
//
// Graph 为 nil 表示「该会话确实还没有图」——这是一个**诚实的空态**,不是错误:
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b)-3)。
// 前端据此渲染来源说明条(v0.73 起也可经它的 `source` 字段直接分辨,
// 不再只靠对象引用)。
const (
flowSourceLive = "live"
flowSourcePersisted = "persisted"
flowSourceNone = "none"
)
// flowTurnDTO 是「该会话已落盘轮次」的一条摘要(契约 v0.73 的 `turns[]`)。
// 它是给面板选历史轮用的:`turnId` 回带给本端点当 `?turnId=`,`createdAt` 供排序/展示。
type flowTurnDTO struct {
TurnID string `json:"turnId"`
CreatedAt string `json:"createdAt"`
}
// adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体
// (v0.71 起;**v0.73 起新增 `turnId`/`source`/`turns` 三键**,纯增量)。
//
// Graph 为 nil 表示「该轮确实没有图」——这是一个**诚实的空态**,不是错误:
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b))。
// 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与
// 「这个键是空」。
//
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。
type adminFlowGraphResponse struct {
SessionID string `json:"sessionId"`
// Graph 是**最近一轮**的图快照,与 `graph_update` 帧的 data 逐字同形
// TurnID 是**实际返回的那一轮**的标识(`graph.id`);没有图时为空串。
TurnID string `json:"turnId"`
// Source ∈ live / persisted / none,见上方常量。
Source string `json:"source"`
// Graph 是**该轮**的图快照,与 `graph_update` 帧的 data 逐字同形
// (契约 §3.4.5 / §4.11(a))。字段以 `any` 承载,因为「没有图」(nil ⇒
// 线上 JSON `null`)与「有图」的区分就是本字段的语义。
Graph any `json:"graph"`
// Turns 是该会话**已落盘轮次**的摘要,最新在前(契约 v0.73)。
Turns []flowTurnDTO `json:"turns"`
}
// adminFlowGraph 是 `GET /api/v1/admin/flow/graph` 的处理器(契约 v0.71,
// design/19 §4 的 P3「多轮历史回放」的最小一步)。
// adminFlowGraph 是 `GET /api/v1/admin/flow/graph` 的处理器(契约 v0.71 起,
// v0.73 增量;design/19 §4 的 P3「多轮历史回放」)。
//
// 它回答的是主人第二次报的那个洞:`graph_update` **只走实时帧、从不重放**,
// 而前端图桶(`flowGraphs`)**不持久化** ⇒ 「轮次结束后才打开页面」或
// 「刷新过页面」永远空着。本端点让打开页面时能**只读拉一次**最近一轮的图。
// 它回答的是主人报的两个洞:① `graph_update` **只走实时帧、从不重放**(v0.71 修);
// ② 图只活在进程内存、刷新/重启/翻更早轮次就没了 ⇒ 现在每轮收尾**落盘**,本端点可按
// `turnId` 取历史轮、并给出可选轮次列表(v0.73)。三条硬约束:
//
// 三条硬约束(与 §4.11(b) 逐条对应):
// 1. **只读**:经 `FlowGraphSource` 拿快照,网关不发布、不改图、不动任何会话;
// 2. **不画假图**:该会话没有图 ⇒ `graph:null`;图源未接线 ⇒ `500 flow_unavailable`
// (**不是** graph:null,见 flowUnavailableCode);
// 3. **只回最近一轮**:更早轮次的历史回放仍未实现(design/19 §4 P3)——
// 因此面板必须在图上说明「这不是你要的那一行那轮的图」。
// 1. **只读**:经 `FlowGraphSource` / `FlowGraphStore` 拿快照,网关不发布、不改图、
// 不动任何会话、也不落盘;
// 2. **不画假图**:没有该轮 ⇒ `graph:null`;图源未接线(内存 + 落盘都没接)⇒
// `500 flow_unavailable`(**不是** graph:null,见 flowUnavailableCode);
// 3. **最近一轮优先内存,miss 才落盘**:省略 `turnId` 时先查内存 `LastGraph`(正在跑/
// 刚跑完的那一轮,最新),取不到才回退落盘最新 —— 「最新一轮永远是最新的」。
//
// 角色门:`writeAuth`(owner|admin),与 design/19 §3「flow 可视化仅 owner/admin」
// 一致;它与同一张图的 WS 下行帧(`graph_update` 落 frameReachesAll 的 fail-closed
@@ -56,15 +88,92 @@ func (s *Server) adminFlowGraph(w http.ResponseWriter, r *http.Request) {
if sessionID == "" {
sessionID = mainSessionID
}
if s.flow == nil {
turnID := strings.TrimSpace(r.URL.Query().Get("turnId"))
if s.flow == nil && s.flowGraphs == nil {
s.log.Warn("flow graph source not wired", "session", sessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "flow graph source is not wired"))
return
}
// Graph 恒在响应里(没有图时是 `null`),见 adminFlowGraphResponse 的注释。
var graph any
if snap := s.flow.LastGraph(sessionID); snap != nil {
graph = snap
ctx := r.Context()
graph, source, err := s.resolveFlowGraph(ctx, sessionID, turnID)
if err != nil {
// 原始错误(可能带 SQL/路径)只进日志;响应只给稳定 code + 中文说明。
s.log.Warn("flow graph read failed", "session", sessionID, "turn", turnID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "读取链路图失败"))
return
}
writeJSON(w, http.StatusOK, adminFlowGraphResponse{SessionID: sessionID, Graph: graph})
turns, err := s.flowTurns(ctx, sessionID)
if err != nil {
s.log.Warn("flow turns read failed", "session", sessionID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "读取轮次列表失败"))
return
}
// Graph 恒在响应里(没有图时是 `null`),见 adminFlowGraphResponse 的注释。
var graphAny any
resolvedTurn := ""
if graph != nil {
graphAny = graph
resolvedTurn = graph.ID
}
writeJSON(w, http.StatusOK, adminFlowGraphResponse{
SessionID: sessionID,
TurnID: resolvedTurn,
Source: source,
Graph: graphAny,
Turns: turns,
})
}
// resolveFlowGraph 解析「这一轮」的图:**先内存、miss 才落盘**(契约 v0.73 §4.11(b))。
//
// 判定顺序(三条都必要):
// - 内存里有图,且(省略 turnId **或** turnId 正是内存那一轮)⇒ 回内存图
// (`source:"live"`)—— 最新一轮永远最新;
// - 否则若给了 turnId ⇒ 精确读落盘那一轮(`source:"persisted"`);
// - 否则(省略 turnId、内存 miss)⇒ 读落盘**最新**一轮(`source:"persisted"`)。
//
// 落盘查询失败**上抛**(调用方回 500):把「读库失败」伪装成「没有这一轮」是
// 项目明令禁止的那类静默降级。该轮确实缺席返回 (nil, "none", nil)。
func (s *Server) resolveFlowGraph(ctx context.Context, sessionID, turnID string) (*trace.FlowGraph, string, error) {
if s.flow != nil {
if snap := s.flow.LastGraph(sessionID); snap != nil && (turnID == "" || snap.ID == turnID) {
return snap, flowSourceLive, nil
}
}
if s.flowGraphs == nil {
return nil, flowSourceNone, nil
}
var (
graph *trace.FlowGraph
err error
)
if turnID == "" {
graph, err = s.flowGraphs.Latest(ctx, sessionID)
} else {
graph, err = s.flowGraphs.Graph(ctx, sessionID, turnID)
}
if err != nil {
return nil, flowSourceNone, err
}
if graph == nil {
return nil, flowSourceNone, nil
}
return graph, flowSourcePersisted, nil
}
// flowTurns 取该会话已落盘轮次的摘要(最新在前)。落盘未接线时回**空切片**
// (不是错误):端点在「只有内存」的形态下照常可用(v0.71 口径),只是没有历史轮可选。
func (s *Server) flowTurns(ctx context.Context, sessionID string) ([]flowTurnDTO, error) {
out := make([]flowTurnDTO, 0)
if s.flowGraphs == nil {
return out, nil
}
turns, err := s.flowGraphs.Turns(ctx, sessionID, 0)
if err != nil {
return nil, err
}
for _, t := range turns {
out = append(out, flowTurnDTO{TurnID: t.TurnID, CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339)})
}
return out, nil
}
@@ -0,0 +1,345 @@
package gateway
// 本文件是链路图**落盘持久化 + 多轮历史回放**的跨层闭环(切片 F2;design/19 §4 P3)。
// 它用真的写入路径(`loop.FlowPublisher.EndRound` → `flowgraph.SQLStore.RecordRound`
// → 真 SQLite + 迁移 0022),再经**真的** HTTP handler 读回来,钉住三件只有端到端
// 才成立、也正是主人要的事:
//
// - **后端重启后仍能查回历史轮次的图**(本切片的内核):换一套「进程」(新连接、
// 新 store、空内存的发布器),图仍从库里读得回来;
// - **最近一轮优先内存、miss 才落盘**:内存里有更新的那轮时,省略 turnId 不回落盘旧轮;
// - **白名单外会话既不落盘也查不到**:隔离渠道会话连图都不建,`turns` 恒空、`graph` 恒 null。
//
// 参数翻译与语义在 `admin_flow_graph_test.go`(v0.71 那批)与本文件互补。
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"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/runtime/trace"
)
// flowGraphResponseDTO 是 v0.73 响应体的线上投影(含新增三键)。
type flowGraphResponseDTO struct {
SessionID string `json:"sessionId"`
TurnID string `json:"turnId"`
Source string `json:"source"`
Graph *struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Nodes []struct {
Kind string `json:"kind"`
} `json:"nodes"`
} `json:"graph"`
Turns []struct {
TurnID string `json:"turnId"`
CreatedAt string `json:"createdAt"`
} `json:"turns"`
}
func decodeFlowGraphResp(t *testing.T, body []byte) flowGraphResponseDTO {
t.Helper()
var out flowGraphResponseDTO
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode flow graph data: %v (body=%s)", err, body)
}
return out
}
// openFlowGraphStore 打开一个真实的 SQLite 文件库、跑迁移 0022,返回连接与存储句柄。
// caller 可显式 Close 连接以模拟进程退出(`t.Cleanup` 会兜底再关一次,无害)。
func openFlowGraphStore(t *testing.T, path string) (*sql.DB, *flowgraph.SQLStore) {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, path, gwTestLogger())
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil {
_ = conn.Close()
t.Fatalf("migrate: %v", err)
}
store, err := flowgraph.NewStore(flowgraph.Config{DB: conn, Log: gwTestLogger()})
if err != nil {
_ = conn.Close()
t.Fatalf("new flowgraph store: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn, store
}
// flowPublisherWithStore 造一个把每轮收尾落进 store 的发布器(生产那条路径)。
func flowPublisherWithStore(store *flowgraph.SQLStore, allow func(string) bool) *loop.FlowPublisher {
return loop.NewFlowPublisher(func(string, any) {}, allow,
loop.WithRoundRecorder(store), loop.WithFlowLogger(gwTestLogger()))
}
// runFlowRound 让发布器跑完一整轮(起轮 → 一个 llm span 起止 → 收轮)。
func runFlowRound(p *loop.FlowPublisher, session, input, output string) {
p.StartRound(session, input)
sp := &trace.Span{ID: "span-llm", Name: "llm.call", Kind: trace.SpanKindLLM, StartedAt: time.Now()}
p.SpanStarted(session, sp)
sp.Finish(trace.SpanStatusOK)
p.SpanFinished(session, sp)
p.EndRound(session, output)
}
// mkFlowGraph 造一轮最小图,供直接写库(不经发布器)的用例使用。
func mkFlowGraph(session string, round int) *trace.FlowGraph {
return &trace.FlowGraph{
ID: session + "-" + itoa(round),
SessionID: session,
Nodes: []trace.FlowNode{
{ID: "input", Label: "你好", Kind: "input", Status: trace.FlowStatusDone},
{ID: "output", Label: "回复", Kind: "output", Status: trace.FlowStatusDone},
},
Edges: []trace.FlowEdge{{From: "input", To: "output"}},
}
}
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)
}
// flowURL 拼出该端点的 URL(带可选 turnId)。
func flowURL(ts *httptest.Server, session, turn string) string {
u := ts.URL + "/api/v1/admin/flow/graph?sessionId=" + session
if turn != "" {
u += "&turnId=" + turn
}
return u
}
// TestAdminFlowGraph_PersistedHistoryReadBack_正常:省略 turnId 回内存最近一轮
// (source live),带 turnId 回该历史轮(source persisted),且 turns 列全三轮、最新在前。
func TestAdminFlowGraph_PersistedHistoryReadBack_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
pub := flowPublisherWithStore(store, func(string) bool { return true })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for _, io := range []struct{ in, out string }{
{"第一轮", "答一"}, {"第二轮", "答二"}, {"第三轮", "答三"},
} {
runFlowRound(pub, "web", io.in, io.out)
}
resp, body := doReq(t, http.MethodGet, flowURL(ts, "web", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("latest status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
latest := decodeFlowGraphResp(t, body)
if latest.Source != "live" {
t.Fatalf("最新一轮 source = %q, want live(先查内存)", latest.Source)
}
if latest.TurnID != "web-3" {
t.Fatalf("最新一轮 turnId = %q, want web-3", latest.TurnID)
}
if len(latest.Turns) != 3 {
t.Fatalf("turns = %d, want 3(每轮一行,不是每 span 一行)", len(latest.Turns))
}
for i, want := range []string{"web-3", "web-2", "web-1"} {
if latest.Turns[i].TurnID != want {
t.Fatalf("turns[%d] = %q, want %q(最新在前)", i, latest.Turns[i].TurnID, want)
}
}
resp, body = doReq(t, http.MethodGet, flowURL(ts, "web", "web-1"), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("history status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
hist := decodeFlowGraphResp(t, body)
if hist.Source != "persisted" {
t.Fatalf("历史轮 source = %q, want persisted(内存里没有 web-1)", hist.Source)
}
if hist.Graph == nil || hist.Graph.ID != "web-1" {
t.Fatalf("历史轮 graph = %+v, want web-1", hist.Graph)
}
kinds := map[string]bool{}
for _, n := range hist.Graph.Nodes {
kinds[n.Kind] = true
}
if !kinds["input"] || !kinds["output"] {
t.Fatalf("历史轮图缺主干端点: %+v", hist.Graph.Nodes)
}
}
// TestAdminFlowGraph_SurvivesRestart_正常 是本切片的**内核**(主人要求):后端重启后
// 仍能查回历史轮次。换一套「进程」(新连接 + 新 store + **空内存**的新发布器),
// 唯一能拿到图的方式就是从库读回。
func TestAdminFlowGraph_SurvivesRestart_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
// 第一个「进程」:跑两轮,图进库。
conn1, store1 := openFlowGraphStore(t, path)
pub1 := flowPublisherWithStore(store1, func(string) bool { return true })
_, ts1, _ := buildEnv(t, cfgDefault(), Deps{Flow: pub1, FlowGraphs: store1})
runFlowRound(pub1, "web", "第一轮", "答一")
runFlowRound(pub1, "web", "第二轮", "答二")
ts1.Close()
_ = conn1.Close() // 模拟进程退出
// 第二个「进程」:空内存的新发布器 + 同一个文件库的新连接 / 新 store。
_, store2 := openFlowGraphStore(t, path)
pub2 := flowPublisherWithStore(store2, func(string) bool { return true })
_, ts2, jwt2 := buildEnv(t, cfgDefault(), Deps{Flow: pub2, FlowGraphs: store2})
tok := signAny(t, jwt2, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, flowURL(ts2, "web", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("after restart status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
got := decodeFlowGraphResp(t, body)
if got.Graph == nil {
t.Fatalf("重启后 graph = null,但该会话刚跑完两轮并已落盘(这正是主人要的「本地数据」): %s", body)
}
if got.Source != "persisted" || got.TurnID != "web-2" {
t.Fatalf("重启后 source/turnId = %q/%q, want persisted/web-2", got.Source, got.TurnID)
}
// 更早那一轮也能按 turnId 取回。
resp, body = doReq(t, http.MethodGet, flowURL(ts2, "web", "web-1"), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("restart history status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
hist := decodeFlowGraphResp(t, body)
if hist.Graph == nil || hist.Graph.ID != "web-1" {
t.Fatalf("重启后 web-1 = %+v, want web-1", hist.Graph)
}
}
// TestAdminFlowGraph_LatestPrefersMemory_正常 钉住「最新一轮永远最新」:内存里有更新的
// 那轮(web-2)而库里的最新只有 web-1 时,省略 turnId **必须**回内存那轮。
func TestAdminFlowGraph_LatestPrefersMemory_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
if err := store.RecordRound(mkFlowGraph("web", 1)); err != nil {
t.Fatalf("seed persisted round: %v", err)
}
// 发布器**不挂 recorder**:内存跑两轮(web-2),但不再落盘 ⇒ 库里仍旧只有 web-1。
pub := loop.NewFlowPublisher(func(string, any) {}, func(string) bool { return true })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
runFlowRound(pub, "web", "第一轮", "答一")
runFlowRound(pub, "web", "第二轮", "答二")
resp, body := doReq(t, http.MethodGet, flowURL(ts, "web", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
got := decodeFlowGraphResp(t, body)
if got.Source != "live" || got.TurnID != "web-2" {
t.Fatalf("source/turnId = %q/%q, want live/web-2(省略 turnId 时先查内存,绝不回落盘的旧轮)",
got.Source, got.TurnID)
}
}
// TestAdminFlowGraph_OutsideWhitelistNotPersisted_正常 钉住白名单复用:发布会话范围外
// 的会话(隔离渠道会话)连图都不建 ⇒ 既不落盘、也查不到,`turns` 恒空。
func TestAdminFlowGraph_OutsideWhitelistNotPersisted_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
pub := flowPublisherWithStore(store, func(session string) bool { return session == "web" })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
runFlowRound(pub, "sess-im-stranger", "陌生人说话", "回应")
runFlowRound(pub, "web", "主人说话", "回应")
resp, body := doReq(t, http.MethodGet, flowURL(ts, "sess-im-stranger", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("stranger status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
stranger := decodeFlowGraphResp(t, body)
if stranger.Graph != nil {
t.Fatalf("白名单外会话不该有图,却拿到 %+v", stranger.Graph)
}
if stranger.Source != "none" {
t.Fatalf("source = %q, want none", stranger.Source)
}
if len(stranger.Turns) != 0 {
t.Fatalf("白名单外会话不该落盘(turns=%+v)", stranger.Turns)
}
// 反向:白名单内的会话照常可查、有落盘轮(保证上面的空不是端点整体坏了)。
_, body = doReq(t, http.MethodGet, flowURL(ts, "web", ""), nil, tok)
if inScope := decodeFlowGraphResp(t, body); inScope.Graph == nil || len(inScope.Turns) != 1 {
t.Fatalf("白名单内会话应有图且有一轮落盘: %s", body)
}
}
// TestAdminFlowGraph_UnknownTurnIsHonestNull_边界 钉住「该 turnId 不存在」是诚实空态:
// 200 + `graph:null` + `source:"none"`,绝不用别的轮次顶替。
func TestAdminFlowGraph_UnknownTurnIsHonestNull_边界(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
pub := flowPublisherWithStore(store, func(string) bool { return true })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
runFlowRound(pub, "web", "第一轮", "答一")
resp, body := doReq(t, http.MethodGet, flowURL(ts, "web", "web-999"), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
got := decodeFlowGraphResp(t, body)
if got.Graph != nil {
t.Fatalf("不存在的轮次 graph = %+v, want null", got.Graph)
}
if got.Source != "none" || got.TurnID != "" {
t.Fatalf("source/turnId = %q/%q, want none/\"\"", got.Source, got.TurnID)
}
// `graph` 键必须恒在(客户端靠它区分「没有图」与「字段缺失」)。
var envelope map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &envelope); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if _, present := envelope["graph"]; !present {
t.Fatalf("`graph` 键必须恒在(空时是 null): %s", body)
}
}
// TestAdminFlowGraph_EmptyTurnsSerialiseAsArray_边界 钉住线上空形状:没有落盘轮次时
// `turns` 是 `[]`(客户端不必在 null 与空数组之间分叉)。
func TestAdminFlowGraph_EmptyTurnsSerialiseAsArray_边界(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
pub := flowPublisherWithStore(store, func(string) bool { return true })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, flowURL(ts, "web", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if !strings.Contains(string(body), `"turns":[]`) {
t.Fatalf("空 turns 必须序列化为 [] 而非 null: %s", body)
}
}
+20
View File
@@ -14,6 +14,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"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/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
@@ -305,3 +306,22 @@ type FlowGraphSource interface {
// (从未跑过带 span 的轮次,或本就不在发布会话范围内)时返回 nil。
LastGraph(sessionID string) *trace.FlowGraph
}
// FlowGraphStore 是**已落盘轮次图**的只读出口(契约 v0.73 的
// `GET /api/v1/admin/flow/graph` 的 `?turnId=` 与 `turns[]`;design/19 §4 的 P3
// 「多轮历史回放」)。生产实现是 `internal/runtime/flowgraph` 的 `*SQLStore`,
// 组合根把**同一个**句柄交给发布器(写入面:EndRound 每轮落一条)与本查询面。
//
// 它刻意只声明三个**读**方法:写入(RecordRound)与保留期清扫(Sweep)是发布器与
// 组合根的职责,管理查询面拿不到、也不该拿到它们(最小权限)。
//
// 为 nil 时端点回退到**只有内存**的行为(v0.71 口径):`turns[]` 为空、`?turnId=`
// 只能命中内存里那一轮。内存与落盘**都没接线**时才回 500 `flow_unavailable`。
type FlowGraphStore interface {
// Graph 按 (sessionID, turnID) 取回一轮的图;该轮不存在时返回 (nil, nil)。
Graph(ctx context.Context, sessionID, turnID string) (*trace.FlowGraph, error)
// Latest 取该会话最近一轮已落盘的图;没有已落盘的轮次时返回 (nil, nil)。
Latest(ctx context.Context, sessionID string) (*trace.FlowGraph, error)
// Turns 返回该会话已落盘轮次的摘要(最新在前),供面板选历史轮。
Turns(ctx context.Context, sessionID string, limit int) ([]flowgraph.Turn, error)
}
+5
View File
@@ -160,4 +160,9 @@ type Deps struct {
// 可选;为 nil 时端点诚实作答 500 flow_unavailable,绝不回
// graph:null(那会把「图源没接线」伪装成「这一轮确实没有图」)。
Flow FlowGraphSource
// FlowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;design/19 §4 的 P3
// 「多轮历史回放」)。组合根注入的是发布器**每轮收尾落一条**用的同一个
// `*flowgraph.SQLStore`。可选;为 nil 时端点回退到只有内存的行为
// (turns 为空、turnId 只能命中内存里那一轮),内存与落盘都没接线才 500。
FlowGraphs FlowGraphStore
}
+4
View File
@@ -103,6 +103,9 @@ type Server struct {
// flow 是「最近一轮链路图」的只读出口(见 Deps.Flow / FlowGraphSource)。
// 为 nil 时 GET /api/v1/admin/flow/graph 回 500 flow_unavailable。
flow FlowGraphSource
// flowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;见 Deps.FlowGraphs)。
// 为 nil 时该端点回退到只有内存的行为(turns 为空、turnId 只能命中内存那一轮)。
flowGraphs FlowGraphStore
// envResolver 解析会话每轮的 loop 环境(其角色)。见
// persona_env.go / design/22 §4.5。
envResolver turnEnvResolver
@@ -156,6 +159,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
log: sourcedLog(deps.Log),
maxParallelTurns: deps.MaxParallelTurns,
flow: deps.Flow,
flowGraphs: deps.FlowGraphs,
}
// 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner
// 的入口(REST、WS、语音)以同样方式解析会话角色(design/22 §4.5),