fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 turn_id**,
其中一路的图彻底消失,且 A 路的工具被画在 B 路的图上。逐层定位:发布器 graphs 只按
sessionID 建键(后起的路整张顶掉前者)· 帧载荷基线的键集被既有测试精确断言为
{id,nodes,edges,sessionId,startedAt,endedAt}(无任何区分路的键)· 前端每会话单桶 ·
落盘 (session_id,turn_id) 唯一键两路相撞 · 冷启动 LastGraph 只有一张。
该 bug 早已公开登记(design/15 §2.5.10-1「本轮未解决」、契约 §6-19「仍**未修**」)。
【修法】
- 归属桶 {会话}[#{路}](loop.FlowOwnerKey,与 checkpoint 键同字符口径);路只来自
ParallelLegFrom(ctx) ⇒ 单路逐字不变(桶 = 会话 id),既有调用点语义零改动
- 图 id 的第二分量改用**建图序号**(每次 +1、永不复用):同一轮里可以有并行路 +
队首续跑的串行路,用轮次号会让两条串行路撞同一个 web-1
- 轮次号只在上一轮的路全部收尾后推进,并丢掉更早轮次的桶(否则内存随路数无界增长)
- 新增只读出口 LatestLanes ⇒ GET /admin/flow/graph 的 lanes[](最近一轮每一路),
graph 仍是「最新一路」= 向后兼容
- 同源洞一并修:子代理归属原取 AgentSpec.ParentSession(会话、不含路)⇒ 并行路里派的
子代理 span 被静默丢弃;新增 AgentSpec.FlowOwner 承载「会话#路」,ParentSession 继续
只承载会话(总线载荷 subagent_result.sessionId 不受影响)
- 前端:store 增 flowLanes(按 turnId 分格、按轮淘汰、硬上限 4)+ LaneTracks 泳道卡;
单路/未给 lanes 时渲染路径与改前逐字相同
【无迁移】turn_id 是自由文本、每路 id 唯一 ⇒ 0022 的唯一索引天然分两行(0023 仍空着)
【我的验收】① 隔离 worktree(30 文件)build/vet/linux/darwin 全过、69 包全绿 0 FAIL;
② 我亲手打红变异:让 FlowOwnerKey 忽略路 ⇒ TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther
FAIL;③ 前端独立复跑 npx tsc --noEmit + npm run build 通过(4.12s)
⚠️ 契约 v0.77 的登记在紧随其后的 docs(contract) 提交里(同一个工作时段内)。
This commit is contained in:
1 parent
c9144c1096
commit
89434eadef
30 files changed
+2039
-148
No files matched your search
@@ -42,15 +42,27 @@ type flowTurnDTO struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// flowLaneDTO 是「最近一轮里的一条路」(契约 v0.77 的 `lanes[]`):`turnId` 是**那一路**
|
||||
// 的权威回合标识(`graph.turnId`,design/15 §2.5.9),`graph` 是那一路的图快照
|
||||
// (与 `graph_update` 帧的 data / 本响应的 `graph` 逐字同形)。
|
||||
//
|
||||
// 真并行时同一会话同一轮有多条路,`graph` 只给出最新的一条(向后兼容:老客户端读它
|
||||
// 照旧可用),`lanes[]` 才是全貌 —— 并行子代理与并行路各自的图因此不再互相顶掉。
|
||||
type flowLaneDTO struct {
|
||||
TurnID string `json:"turnId"`
|
||||
Graph any `json:"graph"`
|
||||
}
|
||||
|
||||
// adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体
|
||||
// (v0.71 起;**v0.73 起新增 `turnId`/`source`/`turns` 三键**,纯增量)。
|
||||
// (v0.71 起;**v0.73 起新增 `turnId`/`source`/`turns` 三键**,**v0.77 起新增 `lanes`
|
||||
// 一键**,均为纯增量)。
|
||||
//
|
||||
// Graph 为 nil 表示「该轮确实没有图」——这是一个**诚实的空态**,不是错误:
|
||||
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b))。
|
||||
// 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与
|
||||
// 「这个键是空」。
|
||||
//
|
||||
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。
|
||||
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。Lanes 同此口径。
|
||||
type adminFlowGraphResponse struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
// TurnID 是**实际返回的那一轮**的标识(`graph.id`);没有图时为空串。
|
||||
@@ -61,6 +73,11 @@ type adminFlowGraphResponse struct {
|
||||
// (契约 §3.4.5 / §4.11(a))。字段以 `any` 承载,因为「没有图」(nil ⇒
|
||||
// 线上 JSON `null`)与「有图」的区分就是本字段的语义。
|
||||
Graph any `json:"graph"`
|
||||
// Lanes 是**最近一轮每一路**的图(契约 v0.77,纯增量;真并行才有意义的全貌)。
|
||||
// 它取内存里的实时图(`FlowGraphSource.LatestLanes`),因此与 `?turnId=` 无关 ——
|
||||
// 翻历史轮时 `lanes` 仍报「当前这一轮的各路」,而 `graph` 才是你指定的那一轮。
|
||||
// 图源未接线或该会话还没有图时是空数组(恒非 nil)。
|
||||
Lanes []flowLaneDTO `json:"lanes"`
|
||||
// Turns 是该会话**已落盘轮次**的摘要,最新在前(契约 v0.73)。
|
||||
Turns []flowTurnDTO `json:"turns"`
|
||||
}
|
||||
@@ -120,10 +137,31 @@ func (s *Server) adminFlowGraph(w http.ResponseWriter, r *http.Request) {
|
||||
TurnID: resolvedTurn,
|
||||
Source: source,
|
||||
Graph: graphAny,
|
||||
Lanes: s.flowLanes(sessionID),
|
||||
Turns: turns,
|
||||
})
|
||||
}
|
||||
|
||||
// flowLanes 取**最近一轮每一路**的图(契约 v0.77 的 `lanes[]`):真并行时同一会话
|
||||
// 同一轮有多条路,只回 `graph` 一棵会把其余路从面板上抹掉(design/15 §2.5.10-1)。
|
||||
//
|
||||
// 它恒返回非 nil 切片(没有路时是 `[]`),使客户端不必判空。图源未接线(`s.flow` 为
|
||||
// nil)时回空切片而不是错误:那时端点本就走「只有落盘」的形态(v0.73 口径),
|
||||
// `lanes` 缺席语义 = 「没有实时路」。
|
||||
func (s *Server) flowLanes(sessionID string) []flowLaneDTO {
|
||||
out := make([]flowLaneDTO, 0)
|
||||
if s.flow == nil {
|
||||
return out
|
||||
}
|
||||
for _, g := range s.flow.LatestLanes(sessionID) {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, flowLaneDTO{TurnID: g.TurnID, Graph: g})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveFlowGraph 解析「这一轮」的图:**先内存、miss 才落盘**(契约 v0.73 §4.11(b))。
|
||||
//
|
||||
// 判定顺序(三条都必要):
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package gateway
|
||||
|
||||
// 本文件钉住契约 v0.77 的**按路**形状(切片 FV2,修 design/15 §2.5.10-1 登记的那条
|
||||
// 「graph_update 互相顶掉」):
|
||||
//
|
||||
// - 帧 / 快照的图上多了 `turnId`(这张图属于哪一路的权威回合标识)与 `round`
|
||||
// (会话轮次号),两者都是**纯增量**(老客户端忽略未知键仍可用);
|
||||
// - `GET /api/v1/admin/flow/graph` 多了 `lanes[]`:真并行时同一会话同一轮有多条路,
|
||||
// 只回 `graph` 一棵会把其余路从面板上抹掉。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// startLane 让发布器开始**一条路**的一轮(起轮 + 一个在飞的工具 span),并把归属桶与
|
||||
// 该 span 交回调用方,供稍后收尾用。两条路必须**交错**起轮(B 在 A 在飞时起)——
|
||||
// 那才是真并行的现场:轮次号正是在「上一轮的路全部收尾」之后才推进的。
|
||||
func (e *flowTestEnv) startLane(t *testing.T, session, lane, input string) (string, *trace.Span) {
|
||||
t.Helper()
|
||||
owner := loop.FlowOwnerKey(session, lane)
|
||||
e.flow.StartRound(owner, input)
|
||||
sp := &trace.Span{ID: "span-" + lane, Name: "tool." + lane, Kind: trace.SpanKindTool, StartedAt: time.Now()}
|
||||
e.flow.SpanStarted(owner, sp)
|
||||
sp.Finish(trace.SpanStatusOK)
|
||||
e.flow.SpanFinished(owner, sp)
|
||||
return owner, sp
|
||||
}
|
||||
|
||||
// finishLane 收尾一条路(答复播种 output 节点并发布最终帧)。
|
||||
func (e *flowTestEnv) finishLane(t *testing.T, owner, output string) {
|
||||
t.Helper()
|
||||
e.flow.EndRound(owner, output)
|
||||
}
|
||||
|
||||
// flowLanesData 解码响应体里的 `lanes[]`(契约 v0.77)。缺键时回 nil(调用方据此判红)。
|
||||
func flowLanesData(t *testing.T, body []byte) []map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
env := decodeBody(t, body)
|
||||
var data struct {
|
||||
Lanes []map[string]json.RawMessage `json:"lanes"`
|
||||
}
|
||||
if err := json.Unmarshal(env["data"], &data); err != nil {
|
||||
t.Fatalf("decode lanes %s: %v", body, err)
|
||||
}
|
||||
return data.Lanes
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_ReturnsEveryLaneOfLatestRound 是本次快照侧的核心断言:
|
||||
// 真并行两路各自收轮之后打开页面,`lanes[]` 必须**每一路一条**、彼此不缺,
|
||||
// 且 `graph`(向后兼容的那一棵)是其中最新的一路。
|
||||
func TestAdminFlowGraph_ReturnsEveryLaneOfLatestRound(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(string) bool { return true })
|
||||
ownerA, _ := env.startLane(t, "web", "turn-a", "标记A")
|
||||
ownerB, _ := env.startLane(t, "web", "turn-b", "标记B")
|
||||
env.finishLane(t, ownerA, "答复A")
|
||||
env.finishLane(t, ownerB, "答复B")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
_, graph, present := flowGraphData(t, body)
|
||||
if !present || graph == nil {
|
||||
t.Fatalf("`graph` 必须恒在且非空(两路都收轮了):%s", body)
|
||||
}
|
||||
lanes := flowLanesData(t, body)
|
||||
if len(lanes) != 2 {
|
||||
t.Fatalf("lanes 条数 = %d,want 2(同一轮的两路各一条):%s", len(lanes), body)
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, lane := range lanes {
|
||||
var (
|
||||
turnID string
|
||||
gid string
|
||||
nodes []struct {
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
)
|
||||
_ = json.Unmarshal(lane["turnId"], &turnID)
|
||||
var inner map[string]json.RawMessage
|
||||
if err := json.Unmarshal(lane["graph"], &inner); err != nil {
|
||||
t.Fatalf("lanes[].graph 不是图对象:%v (%s)", err, body)
|
||||
}
|
||||
_ = json.Unmarshal(inner["id"], &gid)
|
||||
if err := json.Unmarshal(inner["nodes"], &nodes); err != nil {
|
||||
t.Fatalf("lanes[].graph.nodes: %v", err)
|
||||
}
|
||||
seen[turnID] = gid
|
||||
for _, n := range nodes {
|
||||
if n.Kind == "tool" && n.Label != turnID {
|
||||
t.Fatalf("路 %s 的图里出现了另一路的工具节点 %q(两路又混进同一张图了)", turnID, n.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"turn-a", "turn-b"} {
|
||||
if seen[want] == "" {
|
||||
t.Fatalf("lanes[] 里缺 %s 那一路:%v (%s)", want, seen, body)
|
||||
}
|
||||
}
|
||||
// graph(兼容字段)= 最新一路;两条路 id 必须不同(否则落盘会互相顶掉)。
|
||||
if seen["turn-a"] == seen["turn-b"] {
|
||||
t.Fatalf("两路拿到同一个图 id %q:%v", seen["turn-a"], seen)
|
||||
}
|
||||
var graphID string
|
||||
_ = json.Unmarshal(graph["id"], &graphID)
|
||||
if graphID != seen["turn-b"] {
|
||||
t.Fatalf("graph.id = %q,want 最新一路 %q(后起的那一路最新)", graphID, seen["turn-b"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_SingleLaneHasOneEntry 钉住单路口径:没有真并行时 `lanes[]` 是
|
||||
// 恰好一条(无路标识),`graph` 与它同源 —— 「每一路」在单路下就是那一路,
|
||||
// 不因为引入本键而多出一棵假的第二路。
|
||||
func TestAdminFlowGraph_SingleLaneHasOneEntry(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(string) bool { return true })
|
||||
env.runFinishedRound(t, "web")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
lanes := flowLanesData(t, body)
|
||||
if len(lanes) != 1 {
|
||||
t.Fatalf("单路的 lanes 条数 = %d,want 1:%s", len(lanes), body)
|
||||
}
|
||||
var turnID string
|
||||
_ = json.Unmarshal(lanes[0]["turnId"], &turnID)
|
||||
if turnID != "" {
|
||||
t.Fatalf("单路的 lanes[0].turnId = %q,want 空(没有路标识 ⇒ 客户端按会话那一张画)", turnID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_LanesEmptyOnEmptySession 钉住空态:该会话还没有图时 `lanes`
|
||||
// 是空数组(恒非 null)—— 与 `graph:null` 同一诚实口径,客户端不必判空。
|
||||
func TestAdminFlowGraph_LanesEmptyOnEmptySession(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(string) bool { return true })
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=sess-backthink", nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
lanes := flowLanesData(t, body)
|
||||
if lanes == nil {
|
||||
t.Fatalf("`lanes` 键必须恒在且是数组(空时是 []):%s", body)
|
||||
}
|
||||
if len(lanes) != 0 {
|
||||
t.Fatalf("没有图时会话的 lanes = %s,want []", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGraphDispatchLaneFields 钉住**帧载荷**上的两个新键与它们的缺席语义:
|
||||
// 有路时 `turnId` 在场且与图 id 同源,无路时该键**缺席**(老后端/单路的兼容口径),
|
||||
// `round` 则每次建图都在(客户端据此只保留最新一轮的泳道)。
|
||||
func TestFlowGraphDispatchLaneFields(t *testing.T) {
|
||||
laneGraph := trace.NewRunGraph("web")
|
||||
laneGraph.SetTurnID("turn-a")
|
||||
laneGraph.SetSeq(3)
|
||||
laneGraph.Start(1, time.Now())
|
||||
laneGraph.SeedInput("标记A")
|
||||
raw, err := json.Marshal(laneGraph.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var got map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
var id, turnID string
|
||||
var round int
|
||||
_ = json.Unmarshal(got["id"], &id)
|
||||
if _, ok := got["turnId"]; !ok {
|
||||
t.Fatalf("有路的帧缺 `turnId` 键(客户端分不出两条泳道):%s", raw)
|
||||
}
|
||||
_ = json.Unmarshal(got["turnId"], &turnID)
|
||||
_ = json.Unmarshal(got["round"], &round)
|
||||
if id != "web-3#turn-a" || turnID != "turn-a" {
|
||||
t.Fatalf("id/turnId = %q/%q,want web-3#turn-a / turn-a(同源)", id, turnID)
|
||||
}
|
||||
if round != 1 {
|
||||
t.Fatalf("round = %d,want 1(轮次号与会话级序号是两个量)", round)
|
||||
}
|
||||
|
||||
// 无路的图:`turnId` 必须**缺席**(omitempty),客户端据此回落到「会话那一张」。
|
||||
plain := trace.NewRunGraph("web")
|
||||
plain.Start(4, time.Now())
|
||||
plainRaw, err := json.Marshal(plain.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal plain: %v", err)
|
||||
}
|
||||
var plainGot map[string]json.RawMessage
|
||||
if err := json.Unmarshal(plainRaw, &plainGot); err != nil {
|
||||
t.Fatalf("decode plain: %v", err)
|
||||
}
|
||||
if _, ok := plainGot["turnId"]; ok {
|
||||
t.Fatalf("单路的帧不该带 `turnId` 键:%s", plainRaw)
|
||||
}
|
||||
var plainRound int
|
||||
_ = json.Unmarshal(plainGot["round"], &plainRound)
|
||||
if plainRound != 4 {
|
||||
t.Fatalf("单路 round = %d,want 4", plainRound)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGraphSourceLanesIsReadOnly 钉住 `lanes[]` 的只读性:补拉快照**不会**发布
|
||||
// 任何新帧、也不会改任何路的图(与 LastGraph 同一纪律)。
|
||||
func TestFlowGraphSourceLanesIsReadOnly(t *testing.T) {
|
||||
published := 0
|
||||
pub := loop.NewFlowPublisher(func(string, any) { published++ }, func(string) bool { return true })
|
||||
pub.StartRound(loop.FlowOwnerKey("web", "turn-a"), "标记A")
|
||||
before := published
|
||||
v := pub.LatestLanes("web")
|
||||
if len(v) == 0 {
|
||||
t.Fatal("LatestLanes 为空,但该会话刚起了一路")
|
||||
}
|
||||
if published != before {
|
||||
t.Fatalf("LatestLanes 发布了 %d 帧新图(只读查询绝不能造帧)", published-before)
|
||||
}
|
||||
// 快照是深拷贝:改它不能碰到发布器内部的图。
|
||||
v[0].Nodes = append(v[0].Nodes, trace.FlowNode{ID: "ghost", Kind: "input"})
|
||||
again := pub.LatestLanes("web")
|
||||
for _, g := range again {
|
||||
for _, n := range g.Nodes {
|
||||
if n.ID == "ghost" {
|
||||
t.Fatal("读者追加的节点漏进了发布器内部的图 ⇒ 不是深拷贝")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保证 flowTestEnv 的图形源仍实现 FlowGraphSource(v0.77 多了 LatestLanes)。
|
||||
var _ FlowGraphSource = (*loop.FlowPublisher)(nil)
|
||||
@@ -293,10 +293,10 @@ type ChannelEventStore interface {
|
||||
// 组合根把**同一个**发布器既交给 loop/orchestration(写入面:每帧 span
|
||||
// 发布 `ws.graph_update`)也交给网关(本查询面)。
|
||||
//
|
||||
// 它刻意只声明**一个读方法**:发布、建图、轮次计数与发布会话白名单都留在
|
||||
// 发布器自己手里,管理查询面拿不到、也不该拿到它们(最小权限)——因此
|
||||
// 只读查询**不可能**改变任何会话的实时图,也不可能把面板看不见的会话
|
||||
// 变成可查的(白名单外的会话从未建图,`LastGraph` 自然回 nil)。
|
||||
// 它刻意只声明**两个只读方法**(v0.77 起是两个:`LastGraph` + `LatestLanes`):
|
||||
// 发布、建图、轮次计数与发布会话白名单都留在发布器自己手里,管理查询面拿不到、
|
||||
// 也不该拿到它们(最小权限)——因此只读查询**不可能**改变任何会话的实时图,
|
||||
// 也不可能把面板看不见的会话变成可查的(白名单外的会话从未建图,两者自然回空)。
|
||||
//
|
||||
// 为 nil 时端点诚实作答 500 `flow_unavailable`,绝不回 `graph:null`
|
||||
// (那会把「图源没接线」伪装成「这一轮确实没有图」——
|
||||
@@ -305,6 +305,14 @@ type FlowGraphSource interface {
|
||||
// LastGraph 返回 sessionID 最近一轮图的一份只读快照;该会话还没有图
|
||||
// (从未跑过带 span 的轮次,或本就不在发布会话范围内)时返回 nil。
|
||||
LastGraph(sessionID string) *trace.FlowGraph
|
||||
// LatestLanes 返回 sessionID **最近一轮每一路**的图快照(契约 v0.77 的
|
||||
// `lanes[]`),按路的开始时间升序;没有图时返回空切片。
|
||||
//
|
||||
// 真并行时同一会话同一轮有多条路(design/15 §2.5.5),`LastGraph` 只给出
|
||||
// 其中最新的一条 —— 管理面板要靠本方法才看得全「两路各自的图」,否则
|
||||
// 后起的那一路会把前一路从面板上顶掉(design/15 §2.5.10-1)。它同样是
|
||||
// **只读**的:不发布、不改图、不动轮次计数。
|
||||
LatestLanes(sessionID string) []*trace.FlowGraph
|
||||
}
|
||||
|
||||
// FlowGraphStore 是**已落盘轮次图**的只读出口(契约 v0.73 的
|
||||
|
||||
@@ -94,9 +94,14 @@ func TestFlowGraphUpdateWireFields(t *testing.T) {
|
||||
}
|
||||
|
||||
// assertGraphKeys 钉住 parseGraph/Flow.tsx 所读的 FlowGraph 顶层键。
|
||||
//
|
||||
// v0.77 起多了两个键(纯增量):`round`(会话轮次号,客户端据此只保留最新一轮的
|
||||
// 泳道)与 `turnId`(这张图所属**路**的权威回合标识,真并行时两路各一个值)。
|
||||
// 前者在每次建图时必有;后者只在「这条路有权威标识」时出现(omitempty),
|
||||
// 因此本断言只钉它在场时可读,缺席语义由 TestFlowGraphDispatchLaneFields 钉住。
|
||||
func assertGraphKeys(t *testing.T, graph map[string]json.RawMessage) {
|
||||
t.Helper()
|
||||
want := []string{"id", "nodes", "edges", "sessionId", "startedAt", "endedAt"}
|
||||
want := []string{"id", "nodes", "edges", "sessionId", "round", "startedAt", "endedAt"}
|
||||
if got := sortedKeys(graph); !equalKeys(got, want) {
|
||||
t.Fatalf("graph keys = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user