feat(gateway): 面 1 补齐 —— 消息读取面 toolCalls[](工具回看)
前提由 FV2 提供(图自 v0.77 起带每路 turnId、每路各落一行)。本片:
- 迁移 0023:flow_graphs 加「按路查询键」(写入时回填图的 TurnID),老行按缺席处理
- GET /api/v1/sessions/{id}/messages 加**可选** toolCalls[],形状严格照 Docs/api-client.md
v0.76 ① 已登记的:{name, status(ok|error|unknown), durationMs, argsSummary, resultSummary}
- 🔴 图取不到 ⇒ **字段整体缺席**(绝不回空数组冒充「没调过工具」)
- 一条批量 SQL(按页内 turnId 集合),禁 N+1;单条 ≤20 个工具、resultSummary ≤500 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
This commit is contained in:
1 parent
99a644d545
commit
c907a34510
10 files changed
+1429
-16
No files matched your search
@@ -3,8 +3,9 @@
|
||||
// 每次重载之后页面就没数据了」—— 在此之前图只活在 `loop.FlowPublisher` 的**内存**里,
|
||||
// 后端一重启、或该会话还没跑新轮次,就什么都查不到。
|
||||
//
|
||||
// 只做一件事:把**每一轮的收尾图**(`trace.FlowGraph`)连会话与轮次标识落进
|
||||
// `flow_graphs`(迁移 0022),并支持按轮次读回、按保留期清扫。
|
||||
// 只做两件事:把**每一路的收尾图**(`trace.FlowGraph`)连会话与轮次标识落进
|
||||
// `flow_graphs`(迁移 0022 / 0023),并支持按图 id 单轮读回、按**回合标识**批量读回
|
||||
// (`GraphsByTurnID`,消息面回看工具调用的读侧入口)、按保留期清扫。
|
||||
//
|
||||
// 三条职责边界(别在这里越界):
|
||||
//
|
||||
@@ -153,20 +154,34 @@ func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||
return logger.WithSource(l, flowgraphLogSource)
|
||||
}
|
||||
|
||||
// upsertRoundSQL 是落盘的唯一语句:列按迁移 0022 显式列出、值全部参数化。冲突键是
|
||||
// `(session_id, turn_id)`(迁移里的唯一索引承载)—— 同一会话同一轮的二次收尾是同轮
|
||||
// 覆盖(更新图与 updated_at,**不动 created_at**),而不是插出两行「同一轮」。
|
||||
// upsertRoundSQL 是落盘的唯一语句:列按迁移 0022 / 0023 显式列出、值全部参数化。
|
||||
// 冲突键是 `(session_id, turn_id)`(迁移里的唯一索引承载)—— 同一会话同一轮的二次
|
||||
// 收尾是同轮覆盖(更新图与 updated_at,**不动 created_at**),而不是插出两行
|
||||
// 「同一轮」。
|
||||
//
|
||||
// `lane_turn_id`(迁移 0023)与 `turn_id` **不是同一个东西**:前者是这一**路**的权威
|
||||
// 回合标识(`trace.FlowGraph.TurnID`,与 `chat_messages.turn_id` 同值同源),后者是
|
||||
// 图 id(`trace.FlowGraph.ID`,也是本表的冲突键)。落盘时两个都写,使「按消息的那一路
|
||||
// 查图」(`GraphsByTurnID`)是一次等值索引查询,而不是拿图 id 去反解。二次收尾同样
|
||||
// 更新它:同一条路的图又被收一次时,标识必须跟着走到最新的那一份。
|
||||
const upsertRoundSQL = `INSERT INTO flow_graphs
|
||||
(session_id, turn_id, graph_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
(session_id, turn_id, lane_turn_id, graph_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id, turn_id) DO UPDATE SET
|
||||
graph_json = excluded.graph_json,
|
||||
lane_turn_id = excluded.lane_turn_id,
|
||||
updated_at = excluded.updated_at`
|
||||
|
||||
// RecordRound 把一条**已收尾轮次**的图落盘(每轮一行)。它是发布器 `EndRound` 的落盘
|
||||
// 出口,因此签名与 `loop.RoundRecorder` 一致:**不接收 ctx、自己用一个有界的后台
|
||||
// context**(会话收尾路径不该被库拖住)。
|
||||
//
|
||||
// 它同时盖上这一路的**回合标识**(`g.TurnID` → `lane_turn_id`,迁移 0023),使消息面上
|
||||
// 「这条回复当时调了什么工具」能按**等值**查到这一轮的图(见 `GraphsByTurnID`)。
|
||||
// 标识为空时如实写空串:那一行因此按路查不到,读取侧让 `toolCalls` 整体缺席 ——
|
||||
// 这正是契约的语义,绝不用时间窗或正文去猜(`g.TurnID` 为空的两类情形见迁移 0023
|
||||
// 的注释:v0.77 之前的旧行与**当前**的单路回合)。
|
||||
//
|
||||
// 失败**上抛**给调用方(发布器记 WARN 后继续),绝不 panic、绝不改变图 —— 图是可视化
|
||||
// 资产,落盘失败不该影响会话本身。
|
||||
func (s *SQLStore) RecordRound(g *trace.FlowGraph) error {
|
||||
@@ -184,7 +199,7 @@ func (s *SQLStore) RecordRound(g *trace.FlowGraph) error {
|
||||
defer cancel()
|
||||
stamp := sqliteTimeStamp(s.now())
|
||||
if _, err := s.db.ExecContext(ctx, upsertRoundSQL,
|
||||
g.SessionID, g.ID, string(payload), stamp, stamp); err != nil {
|
||||
g.SessionID, g.ID, g.TurnID, string(payload), stamp, stamp); err != nil {
|
||||
return fmt.Errorf("flowgraph: 落轮次图(%s/%s): %w", g.SessionID, g.ID, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package flowgraph
|
||||
|
||||
// 本文件是**按路批量查图**的读侧入口(切片 CODE-TC;迁移 0023 的 `lane_turn_id`)。
|
||||
//
|
||||
// 它补的是契约 v0.76 ① `toolCalls[]` 一直缺的那一件东西:v0.77 起每路各落一行、图上
|
||||
// 带 `turnId`,但读侧只能按**图 id**(`turn_id` 列 = `trace.FlowGraph.ID`)单轮取,
|
||||
// 而消息面上的 `chat_messages.turn_id` 是**路标识**(`turn-<hex>`)—— 两者不同源,
|
||||
// 按图 id 查不到。本文件给出「一次把一页消息的路标识换成图」的那个批量入口。
|
||||
//
|
||||
// 三条口径:
|
||||
//
|
||||
// - **一次批量 SQL**:调用方给一页消息的路标识集合,本方法拼一条 `IN (...)` 查完,
|
||||
// 绝不按消息逐条查(N+1)。集合超过 `maxTurnIDParams` 才分块(见该常量)。
|
||||
// - **查不到就是查不到**:缺席的路标识不会出现在返回的 map 里,调用方据此让整个
|
||||
// `toolCalls` 字段**缺席**;本方法绝不回「空图」占位(那也是编造)。
|
||||
// - **只读**:不写库、不改图、不碰保留期。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maxTurnIDParams 是一次 `IN` 查询里最多绑定的路标识个数(分块上限)。
|
||||
//
|
||||
// 依据:① 上游 `GET /api/v1/sessions/{id}/messages` 的一页被 history 的
|
||||
// `maxPageLimit = 200`(`internal/runtime/history/store.go`)钳住,因此一页最多 200 个
|
||||
// 不同路标识 ⇒ **正常情况下只跑一次**(200 + 1 个绑定参数);② SQLite 的绑定参数上限
|
||||
// 在不同构建里是 999 或 32766,201 离两者都还有一个量级余量。分块只在日后下游放宽
|
||||
// 页大小时才生效,使「按页内集合批量查」不会某天突然撞上参数上限而让整页读接口报错。
|
||||
const maxTurnIDParams = 200
|
||||
|
||||
// GraphsByTurnID 按**路标识**批量取图:返回 `turnID → 该路的图` 的映射。
|
||||
//
|
||||
// `laneTurnIDs` 是消息行的 `turn_id` 集合(迁移 0018),本方法只认 `lane_turn_id`
|
||||
// (迁移 0023)—— 两者同源,因此这是一次等值索引查询(`idx_flow_graphs_lane_turn_id`)。
|
||||
//
|
||||
// 语义:
|
||||
// - 空/全空白的集合、空会话 ⇒ 返回**空 map**(不发查询)。空 map 是「没东西可查」,
|
||||
// 与「查了但没有」对调用方是同一件事:没有可用作回看依据的图。
|
||||
// - 查不到的路标识**不出现在 map 里**(不塞 `nil` 占位):调用方必须据此让
|
||||
// `toolCalls` 整体缺席,绝不回空数组。
|
||||
// - 重复的标识只查一次;结果 map 的键是**查询用的原值**。
|
||||
// - 一行 JSON 解不开 ⇒ 返回错误(与 `Graph` / `Latest` 同一口径:读不懂就如实报错,
|
||||
// 绝不把坏行当成「这一轮没有图」)。调用方决定是整页失败还是只放弃回看面。
|
||||
func (s *SQLStore) GraphsByTurnID(ctx context.Context, sessionID string, laneTurnIDs []string) (map[string]*trace.FlowGraph, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("flowgraph: query rounds by lane: %w", ErrNilDB)
|
||||
}
|
||||
ids := uniqueNonBlank(laneTurnIDs)
|
||||
if strings.TrimSpace(sessionID) == "" || len(ids) == 0 {
|
||||
return map[string]*trace.FlowGraph{}, nil
|
||||
}
|
||||
out := make(map[string]*trace.FlowGraph, len(ids))
|
||||
for start := 0; start < len(ids); start += maxTurnIDParams {
|
||||
end := start + maxTurnIDParams
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
if err := s.queryLaneChunk(ctx, sessionID, ids[start:end], out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// queryLaneChunk 查一块路标识并把解出来的图并进 out。
|
||||
func (s *SQLStore) queryLaneChunk(ctx context.Context, sessionID string, ids []string, out map[string]*trace.FlowGraph) error {
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, sessionID)
|
||||
placeholders := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
query := `SELECT lane_turn_id, graph_json FROM flow_graphs
|
||||
WHERE session_id = ? AND lane_turn_id IN (` + strings.Join(placeholders, ", ") + `)`
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("flowgraph: 按路批量查图(%s, %d 路): %w", sessionID, len(ids), err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
laneID string
|
||||
payload string
|
||||
)
|
||||
if err := rows.Scan(&laneID, &payload); err != nil {
|
||||
return fmt.Errorf("flowgraph: 读按路查图行(%s): %w", sessionID, err)
|
||||
}
|
||||
g, err := decodeGraph(sessionID, laneID, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 键取**行里那一列**而不是查询用的值:两者同值(查询就是按等值匹配),
|
||||
// 但用行值使 map 的键与「库里真实存了什么」永远一致。
|
||||
out[laneID] = g
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("flowgraph: 遍历按路查图行(%s): %w", sessionID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueNonBlank 去掉空白项与重复项,保持首次出现的顺序(查询参数因此稳定可复现)。
|
||||
func uniqueNonBlank(in []string) []string {
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, raw := range in {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package flowgraph
|
||||
|
||||
// 本文件钉住**按路查图**(切片 CODE-TC;迁移 0023 的 `lane_turn_id`)。它是契约
|
||||
// v0.76 ① 的 `toolCalls[]` 一直缺的那块读侧地基:消息行带的是**路标识**
|
||||
// (`chat_messages.turn_id`),图行必须能按**同一个值**查到,否则「回看这条回复当时
|
||||
// 调了什么工具」只能靠猜时间窗 —— 那是项目口径明令禁止的。
|
||||
//
|
||||
// 断言的是**库里的事实**(列里的值、查回来的行、索引在不在),不按日志文本断言,
|
||||
// 与 flowgraph_test.go / flowgraph_lane_test.go 同一口径。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// laneTurnMigrationID 是 0023 的迁移 id(与文件名逐字一致 —— 回填用例靠它把迁移
|
||||
// 记账行撤回,使 `MigrateUp` **真的**重新应用一次 0023)。
|
||||
const laneTurnMigrationID = "0023_flow_graphs_lane_turn_id"
|
||||
|
||||
// laneRoundGraph 造一条路的收尾图:图 id 与真建图器同形(`{会话}-{序号}[#{路}]`),
|
||||
// laneTurn 是这一路的权威回合标识(= 消息行 `chat_messages.turn_id` 的同值)。
|
||||
func laneRoundGraph(session, graphID, laneTurn string) *trace.FlowGraph {
|
||||
return &trace.FlowGraph{
|
||||
ID: graphID,
|
||||
SessionID: session,
|
||||
TurnID: laneTurn,
|
||||
Nodes: []trace.FlowNode{
|
||||
{ID: "input", Label: "问", Kind: "input", Status: trace.FlowStatusDone},
|
||||
{ID: "output", Label: "答", Kind: "output", Status: trace.FlowStatusDone},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordRound_WritesLaneTurnID_正常 钉住写入侧:落盘时把这一路的回合标识写进
|
||||
// `lane_turn_id`(迁移 0023),空标识如实写**空串而不是 NULL**(与 0018 的
|
||||
// `chat_messages.turn_id` 同一口径),且二次收尾同轮覆盖时标识跟着更新。
|
||||
func TestRecordRound_WritesLaneTurnID_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st, conn := newTestStore(t, nil)
|
||||
|
||||
if err := st.RecordRound(laneRoundGraph("web", "web-1#turn-a", "turn-a")); err != nil {
|
||||
t.Fatalf("RecordRound(带路标识): %v", err)
|
||||
}
|
||||
var (
|
||||
lane string
|
||||
isNul int
|
||||
)
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT lane_turn_id, lane_turn_id IS NULL FROM flow_graphs WHERE session_id = ? AND turn_id = ?`,
|
||||
"web", "web-1#turn-a").Scan(&lane, &isNul); err != nil {
|
||||
t.Fatalf("读 lane_turn_id: %v", err)
|
||||
}
|
||||
if lane != "turn-a" {
|
||||
t.Fatalf("lane_turn_id = %q, want turn-a", lane)
|
||||
}
|
||||
if isNul != 0 {
|
||||
t.Fatal("lane_turn_id 绝不允许为 NULL(旧的「没写」必须与「空」同值)")
|
||||
}
|
||||
|
||||
// 单路回合(图上没有路标识):如实写空串。
|
||||
if err := st.RecordRound(laneRoundGraph("web", "web-2", "")); err != nil {
|
||||
t.Fatalf("RecordRound(无路标识): %v", err)
|
||||
}
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT lane_turn_id FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-2").Scan(&lane); err != nil {
|
||||
t.Fatalf("读单路行的 lane_turn_id: %v", err)
|
||||
}
|
||||
if lane != "" {
|
||||
t.Fatalf("无路标识的行 lane_turn_id = %q, want 空串", lane)
|
||||
}
|
||||
|
||||
// 二次收尾(同一图 id):一行,且标识跟着更新到最新那份。
|
||||
if err := st.RecordRound(laneRoundGraph("web", "web-1#turn-a", "turn-a2")); err != nil {
|
||||
t.Fatalf("RecordRound(二次收尾): %v", err)
|
||||
}
|
||||
var rows int
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1#turn-a").Scan(&rows); err != nil {
|
||||
t.Fatalf("count rows: %v", err)
|
||||
}
|
||||
if rows != 1 {
|
||||
t.Fatalf("同一图 id 的行数 = %d,want 1(唯一键仍必须合并)", rows)
|
||||
}
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT lane_turn_id FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1#turn-a").Scan(&lane); err != nil {
|
||||
t.Fatalf("读二次收尾后的 lane_turn_id: %v", err)
|
||||
}
|
||||
if lane != "turn-a2" {
|
||||
t.Fatalf("二次收尾后 lane_turn_id = %q, want turn-a2", lane)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphsByTurnID_BatchAndMiss_正常 钉住读侧:一次批量查询把页内路标识换成图,
|
||||
// **取不到的标识不出现在映射里**(调用方据此让 `toolCalls` 整体缺席,绝不回空数组),
|
||||
// 且不跨会话串图。
|
||||
func TestGraphsByTurnID_BatchAndMiss_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st, _ := newTestStore(t, nil)
|
||||
|
||||
for _, g := range []*trace.FlowGraph{
|
||||
laneRoundGraph("web", "web-1#turn-a", "turn-a"),
|
||||
laneRoundGraph("web", "web-2#turn-b", "turn-b"),
|
||||
laneRoundGraph("sess-other", "sess-other-1#turn-c", "turn-c"),
|
||||
} {
|
||||
if err := st.RecordRound(g); err != nil {
|
||||
t.Fatalf("RecordRound(%s): %v", g.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := st.GraphsByTurnID(ctx, "web", []string{"turn-a", "turn-b", "turn-missing"})
|
||||
if err != nil {
|
||||
t.Fatalf("GraphsByTurnID: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("命中 %d 条,want 2(缺席的标识必须**不在**映射里): %v", len(got), keysOf(got))
|
||||
}
|
||||
if g := got["turn-a"]; g == nil || g.ID != "web-1#turn-a" || g.TurnID != "turn-a" {
|
||||
t.Fatalf("turn-a 的图 = %+v,want web-1#turn-a", g)
|
||||
}
|
||||
if _, ok := got["turn-missing"]; ok {
|
||||
t.Fatal("缺席的标识不得出现在映射里(那会让调用方以为查到了图)")
|
||||
}
|
||||
// 另一个会话的同名标识绝不串过来。
|
||||
other, err := st.GraphsByTurnID(ctx, "web", []string{"turn-c"})
|
||||
if err != nil {
|
||||
t.Fatalf("GraphsByTurnID(别的会话的路): %v", err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Fatalf("跨会话命中了 %v,want 空(session_id 必须参与过滤)", keysOf(other))
|
||||
}
|
||||
|
||||
// 空集合 / 全空白集合 / 空会话:都不发查询,返回空映射而不是错误。
|
||||
for name, ids := range map[string][]string{
|
||||
"nil 集合": nil,
|
||||
"空集合": {},
|
||||
"全空白": {"", " "},
|
||||
"空会话": {"turn-a"},
|
||||
} {
|
||||
session := "web"
|
||||
if name == "空会话" {
|
||||
session = " "
|
||||
}
|
||||
empty, err := st.GraphsByTurnID(ctx, session, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: err = %v, want nil", name, err)
|
||||
}
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("%s: 命中 %v,want 空", name, keysOf(empty))
|
||||
}
|
||||
}
|
||||
|
||||
// 重复标识只算一次,结果仍按原值可查。
|
||||
dedup, err := st.GraphsByTurnID(ctx, "web", []string{"turn-a", "turn-a", " turn-a "})
|
||||
if err != nil {
|
||||
t.Fatalf("GraphsByTurnID(重复标识): %v", err)
|
||||
}
|
||||
if len(dedup) != 1 || dedup["turn-a"] == nil {
|
||||
t.Fatalf("重复标识的结果 = %v,want 恰好 turn-a 一条", keysOf(dedup))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphsByTurnID_OverChunk_边界 钉住分块上限之外仍能查全:绑定参数集合一旦超过
|
||||
// 单次 `IN` 的上限就必须分块,否则整页读接口会因为参数上限报错。这里给 250 个标识
|
||||
// (> maxTurnIDParams)而只有 2 个真有图。
|
||||
func TestGraphsByTurnID_OverChunk_边界(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
st, _ := newTestStore(t, nil)
|
||||
|
||||
if err := st.RecordRound(laneRoundGraph("web", "web-1#turn-a", "turn-a")); err != nil {
|
||||
t.Fatalf("RecordRound: %v", err)
|
||||
}
|
||||
// 第 250 个标识故意放在最后一块里(分块写错时最容易漏掉末尾)。
|
||||
const total = 250
|
||||
ids := make([]string, 0, total)
|
||||
for i := 0; i < total-1; i++ {
|
||||
ids = append(ids, "turn-none-"+itoa(i))
|
||||
}
|
||||
ids = append(ids, "turn-a")
|
||||
|
||||
got, err := st.GraphsByTurnID(ctx, "web", ids)
|
||||
if err != nil {
|
||||
t.Fatalf("GraphsByTurnID(%d 个标识): %v", total, err)
|
||||
}
|
||||
if len(got) != 1 || got["turn-a"] == nil {
|
||||
t.Fatalf("命中 %v,want 恰好 turn-a(最后一块也必须被查到)", keysOf(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphsByTurnID_NilDB_异常 钉住「没有数据库」是明确的错误(ErrNilDB),与
|
||||
// Graph / Latest 同一口径 —— 绝不回一个「查不到任何图」的空映射去冒充「没有工具」。
|
||||
func TestGraphsByTurnID_NilDB_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
var nilStore *SQLStore
|
||||
for name, st := range map[string]*SQLStore{"nil 接收者": nilStore, "空存储": {}} {
|
||||
if _, err := st.GraphsByTurnID(context.Background(), "web", []string{"turn-a"}); !errors.Is(err, ErrNilDB) {
|
||||
t.Fatalf("%s: err = %v, want ErrNilDB", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate0023_BackfillLegacyRows_正常 钉住迁移 0023 对**老行**的处置:
|
||||
// - v0.77 起写入、图 JSON 里带 `$.turnId` 的行 ⇒ 就地回填,不必重跑任何轮次;
|
||||
// - 图里没有该键的行(v0.77 之前 / 单路回合)⇒ 如实留空串(查不到,`toolCalls` 缺席);
|
||||
// - JSON 被写坏的行 ⇒ 保持空串而**不让整次迁移失败**(一条坏行不该让后端起不来);
|
||||
// - 重复应用不覆盖已有值(`WHERE lane_turn_id = ”` 的幂等承诺)。
|
||||
//
|
||||
// 手法:把 0023 的列与记账行先撤回(等价于它的 down),插入老行,再跑一次真的
|
||||
// `MigrateUp`。这样验的是**嵌入的那份迁移文件**,而不是测试里抄的一份 SQL。
|
||||
func TestMigrate0023_BackfillLegacyRows_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
_, conn := newTestStore(t, nil)
|
||||
|
||||
for _, stmt := range []string{
|
||||
`DROP INDEX IF EXISTS idx_flow_graphs_lane_turn_id`,
|
||||
`ALTER TABLE flow_graphs DROP COLUMN lane_turn_id`,
|
||||
} {
|
||||
if _, err := conn.ExecContext(ctx, stmt); err != nil {
|
||||
t.Fatalf("撤回 0023 的结构(%s): %v", stmt, err)
|
||||
}
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, `DELETE FROM schema_migrations WHERE id = ?`, laneTurnMigrationID); err != nil {
|
||||
t.Fatalf("撤回 0023 的记账行: %v", err)
|
||||
}
|
||||
|
||||
legacy := []struct {
|
||||
graphID string
|
||||
graphRaw string
|
||||
wantLane string
|
||||
}{
|
||||
{"web-1#turn-a", `{"id":"web-1#turn-a","sessionId":"web","turnId":"turn-a"}`, "turn-a"},
|
||||
{"web-2", `{"id":"web-2","sessionId":"web"}`, ""},
|
||||
{"web-3", `{"id":"web-3","sessionId":"web","turnId":""}`, ""},
|
||||
{"web-4", `not json at all`, ""},
|
||||
}
|
||||
for _, row := range legacy {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO flow_graphs (session_id, turn_id, graph_json) VALUES (?, ?, ?)`,
|
||||
"web", row.graphID, row.graphRaw); err != nil {
|
||||
t.Fatalf("插入老行 %s: %v", row.graphID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.MigrateUp(ctx, conn, nil); err != nil {
|
||||
t.Fatalf("重新应用 0023: %v", err)
|
||||
}
|
||||
for _, row := range legacy {
|
||||
var lane string
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT lane_turn_id FROM flow_graphs WHERE session_id = ? AND turn_id = ?`,
|
||||
"web", row.graphID).Scan(&lane); err != nil {
|
||||
t.Fatalf("读回填后的 %s: %v", row.graphID, err)
|
||||
}
|
||||
if lane != row.wantLane {
|
||||
t.Fatalf("%s 的 lane_turn_id = %q, want %q", row.graphID, lane, row.wantLane)
|
||||
}
|
||||
}
|
||||
var idx int
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_flow_graphs_lane_turn_id'`).Scan(&idx); err != nil {
|
||||
t.Fatalf("查索引: %v", err)
|
||||
}
|
||||
if idx != 1 {
|
||||
t.Fatal("0023 必须建出 idx_flow_graphs_lane_turn_id(按路批量查图靠它)")
|
||||
}
|
||||
|
||||
// 下-上往返(开发库上最常见的动作:回滚一步再前滚):结构恢复,且回填结果与
|
||||
// 第一次**逐字相同** —— 回填的唯一依据是图 JSON,因此往返是幂等的,绝不会
|
||||
// 因为「跑过第二次」而丢标识或把值改坏。
|
||||
for _, stmt := range []string{
|
||||
`DROP INDEX IF EXISTS idx_flow_graphs_lane_turn_id`,
|
||||
`ALTER TABLE flow_graphs DROP COLUMN lane_turn_id`,
|
||||
} {
|
||||
if _, err := conn.ExecContext(ctx, stmt); err != nil {
|
||||
t.Fatalf("往返撤回(%s): %v", stmt, err)
|
||||
}
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, `DELETE FROM schema_migrations WHERE id = ?`, laneTurnMigrationID); err != nil {
|
||||
t.Fatalf("往返撤回记账行: %v", err)
|
||||
}
|
||||
if err := db.MigrateUp(ctx, conn, nil); err != nil {
|
||||
t.Fatalf("往返后再次应用 0023: %v", err)
|
||||
}
|
||||
for _, row := range legacy {
|
||||
var lane string
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT lane_turn_id FROM flow_graphs WHERE session_id = ? AND turn_id = ?`,
|
||||
"web", row.graphID).Scan(&lane); err != nil {
|
||||
t.Fatalf("往返后读 %s: %v", row.graphID, err)
|
||||
}
|
||||
if lane != row.wantLane {
|
||||
t.Fatalf("往返后 %s 的 lane_turn_id = %q, want %q(往返必须幂等)", row.graphID, lane, row.wantLane)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keysOf 把映射的键排序后列出(失败信息里要看清「到底命中了哪几个」)。
|
||||
func keysOf(m map[string]*trace.FlowGraph) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user