package trace import ( "fmt" "time" ) // Flow graph wire types (DevDocs/design/19 §1). The JSON field names are // camelCase so the frontend's FlowGraph type and parseGraph read them directly. // Node status strings mirror the frontend AgentStatus keys so parseGraph and // FlowGraphView pass them through unchanged. // Node status labels kept as named constants so no bare string appears in the // mapping code (project rule §10). const ( // FlowStatusIdle is a node that has not started. FlowStatusIdle = "idle" // FlowStatusRunning is a node whose span is in flight. FlowStatusRunning = "running" // FlowStatusDone is a successfully completed node. FlowStatusDone = "done" // FlowStatusError is a node whose span finished with an error. FlowStatusError = "error" ) // FlowStatusForPhase 把工具执行的细粒度 phase(design/04 §1.1, // loop 随 tool_execution.end 帧一同记在 tool span 上)映射为流程图 // 节点状态:phase 取值与本包状态枚举(design/19 §1 统一枚举) // 同构(running/done/error)。未知 phase 返回空串,调用方回落到 // span 终态推导,因此缺 phase 的旧 span 不受影响。 func FlowStatusForPhase(phase string) string { switch phase { case FlowStatusRunning: return FlowStatusRunning case FlowStatusDone: return FlowStatusDone case FlowStatusError: return FlowStatusError default: return "" } } // FlowNode is one node in a flow graph. Status/SpanKind mirror the frontier. type FlowNode struct { ID string `json:"id"` Label string `json:"label"` Kind string `json:"kind,omitempty"` Status string `json:"status,omitempty"` StartedAt time.Time `json:"startedAt,omitempty"` EndedAt time.Time `json:"endedAt,omitempty"` Props map[string]any `json:"props,omitempty"` Log []Event `json:"log,omitempty"` } // FlowEdge is a directed edge between two nodes. type FlowEdge struct { From string `json:"from"` To string `json:"to"` } // FlowGraph is a live, single-round flow graph published as ws.graph_update. type FlowGraph struct { ID string `json:"id"` Nodes []FlowNode `json:"nodes"` Edges []FlowEdge `json:"edges"` // SessionID 是拥有这张图的会话(帧壳与落盘行的归属坐标)。 SessionID string `json:"sessionId,omitempty"` // TurnID 是这张图所属**路**的权威回合标识(design/15 §2.5.9):编排器给**每一路** // 都发(普通单路回合、队首续跑路、真并行路都有),因此它就是这张图属于哪一回合的 // 答案,也是落盘 `flow_graphs.lane_turn_id` 与 `chat_messages.turn_id` 的同值。 // **缺席**语义:这条路没有权威标识(未接编排器的调用方、后台思考、旧后端)—— // 客户端据此把图归到会话那一张(兼容口径),读侧据此让该轮的 `toolCalls` 整体 // 缺席;两侧都**绝不**臆造一个 id。 TurnID string `json:"turnId,omitempty"` // Round 是这张图所属的**会话轮次号**(每会话单调递增;同一轮的各条路共享它)。 // 客户端据此只保留最新一轮的泳道,避免旧轮的路留在页面上。 // **缺席**(0)语义:旧后端/未设置 —— 客户端按「每次帧都算同一轮」的保守口径处理。 Round int `json:"round,omitempty"` StartedAt time.Time `json:"startedAt,omitempty"` EndedAt time.Time `json:"endedAt,omitempty"` } // NodeSpec is the input to RunGraph.Upsert: the full description of a node plus // the parent id used to wire its incoming edge. type NodeSpec struct { ID string Label string Kind string Status string StartedAt time.Time EndedAt time.Time Props map[string]any Log []Event ParentID string } // RunGraph accumulates the spans of one round into a FlowGraph. It is a pure // structure: it knows nothing about the bus, sessions or the loop, so it is // unit-testable in isolation. A caller serializes access (see loop.FlowPublisher). type RunGraph struct { session string round int // seq 是本图的**会话级建图序号**(调用方每次建图递增、永不复用)。图 id 的第二 // 分量取它而不是轮次号:真并行时同一轮里的第二条路必须也拿到全新的 id,否则两路 // 撞同一个 `flow_graphs.turn_id`(唯一键),后收尾的那一路把前一路整行顶掉。 // 未经 SetSeq 建图的调用方(图构建器单测)保留 `{会话}-{轮次}` 的老形状。 seq int // turnID 是本图所属**路**的权威回合标识(design/15 §2.5.9)。空串表示这条路 // 没有权威标识(未接编排器 / 旧调用方),此时图上该键缺席。它有一个可见后果: // 图 id 的后缀取它(见 graphID),因此有权威标识的单路图也不再是 `{会话}-{序号}` // 而是 `{会话}-{序号}#{回合}` —— 落盘唯一键 `(session_id, turn_id)` 因此跨进程 // 不撞(序号只在进程内递增、重启归零,而回合标识每回合全新)。 turnID string nodes map[string]*FlowNode order []string edges map[string]bool edgeOrder []FlowEdge // parents remembers every span's parent link, including spans that are not // rendered as nodes (a state_transition step container), so a node's lane can // be resolved by walking through the skipped containers. parents map[string]string // laneLast is the per-lane cursor used to chain sequential steps: keyed by the // enclosing subagent node id, and by "" for the main trunk. One cursor per lane // (instead of one per graph) is what keeps two parallel sub-agent lanes from // cross-linking when their spans interleave. laneLast map[string]string started time.Time ended time.Time } // NewRunGraph allocates an empty graph for one session round. func NewRunGraph(session string) *RunGraph { return &RunGraph{ session: session, nodes: make(map[string]*FlowNode), edges: make(map[string]bool), parents: make(map[string]string), laneLast: make(map[string]string), } } // Start marks the round began at now and resets the node/edge sets so a reused // graph starts fresh for a new round. 路口标识不在重置之列:它是**本次建图**的 // 身份,由 SetTurnID 在 Start 之后立刻盖上(见 loop.FlowPublisher)。 func (g *RunGraph) Start(round int, now time.Time) { g.round = round g.started = now g.ended = time.Time{} g.nodes = make(map[string]*FlowNode) g.order = nil g.edges = make(map[string]bool) g.edgeOrder = nil g.parents = make(map[string]string) g.laneLast = make(map[string]string) } // SetTurnID 盖上这张图所属**路**的权威回合标识(design/15 §2.5.9)。空串是 // 合法入参(未接编排器的调用方),此时快照里该键缺席(omitempty),客户端回落 // 到「按会话归一张图」的兼容口径。 func (g *RunGraph) SetTurnID(turnID string) { g.turnID = turnID } // SetSeq 盖上这张图的**会话级建图序号**(见 RunGraph.seq)。未设置(0)时图 id // 退回 `{会话}-{轮次}` 的老形状,因此只调 Start 的既有调用方/单测逐字不变。 func (g *RunGraph) SetSeq(seq int) { g.seq = seq } // SeedInput creates the "input" node for the round. It returns the node id so a // caller can reference it. The label is redacted (design/19 §3), so a message that // happens to carry a credential cannot publish one. func (g *RunGraph) SeedInput(text string) string { g.Upsert(NodeSpec{ ID: "input", Label: RedactText(text), Kind: "input", Status: FlowStatusDone, StartedAt: g.started, }) return "input" } // SeedOutput creates the "output" node for the round, wired to the last node so // the trunk ends at the answer. The label is redacted like SeedInput's. func (g *RunGraph) SeedOutput(text string) string { g.ended = time.Now() g.Upsert(NodeSpec{ ID: "output", Label: RedactText(text), Kind: "output", Status: FlowStatusDone, StartedAt: g.ended, EndedAt: g.ended, }) return "output" } // laneIDSep 是图 id 里「会话-轮次」与「路标识」之间的分隔符。它与 `loop` 的 // flow 归属串(`{会话}#{路}`)以及 design/15 §2.5.9 的 checkpoint 归属键 // (`{sessionID}#{turnID}`)取同一个字符:**一条路**在流程图、checkpoint 与 // 落盘行三处因此是同一个拼接口径(loop 侧的同名常量见 loop/flow_branch.go)。 const laneIDSep = "#" // Snapshot returns a copy of the graph safe to encode and publish. 图 id 是 // `{会话}-{轮次}`;这张图属于一条**有权威回合标识的路**时追加 `#{回合标识}` —— // 真并行下同一会话同一轮的两路必须各有各的 id,否则落盘(`flow_graphs` 的 // `(session_id, turn_id)` 唯一键)会把其中一路整行顶掉、客户端也分不出两张图 // (design/15 §2.5.10-1);单路(编排器也给它盖了权威标识)同样带后缀,因此它的 // 图 id 跨进程不撞(见 RunGraph.turnID)。 func (g *RunGraph) Snapshot() *FlowGraph { nodes := make([]FlowNode, 0, len(g.order)) for _, id := range g.order { nodes = append(nodes, *g.nodes[id]) } return &FlowGraph{ ID: g.graphID(), Nodes: nodes, Edges: append([]FlowEdge(nil), g.edgeOrder...), SessionID: g.session, TurnID: g.turnID, Round: g.round, StartedAt: g.started, EndedAt: g.ended, } } // graphID 拼出这张图的 id:`{会话}-{序号}[#{权威回合标识}]`。序号取会话级建图序号 // (未设置时退回轮次号,见 RunGraph.seq),因此**同一轮里的每一条路也各有各的 id**; // 回合标识只在「这条路有权威标识」时追加(单路在有 ctx 通道时也有 —— 见 // loop.FlowPublisher.StartRoundWithTurnID)。 func (g *RunGraph) graphID() string { n := g.seq if n == 0 { n = g.round } base := fmt.Sprintf("%s-%d", g.session, n) if g.turnID == "" { return base } return base + laneIDSep + g.turnID } // Clone 深拷贝一份图,供**锁外**的读者使用(契约 v0.71 的 // `GET /api/v1/admin/flow/graph` 就近取材于 `loop.FlowPublisher.LastGraph`)。 // // 为什么必须深拷贝:`Snapshot` 只复制节点结构体,`Props` 映射与 `Log` 切片 // 仍与活图**共享底层存储**;而 `RunGraph.Upsert` 对同一个节点是**就地**合并 // (`merge` 往同一个 map 写键、往同一个切片追加)。发布路径在持有发布器 // 互斥锁时就把图编码掉了,因此共享无碍;但只读查询把图交出去之后还要在 // 锁外编码,与下一帧 span 的合并并发 ⇒ 会读到撕裂的 map(Go 里并发读写 // map 直接是致命错误)。深拷贝把这份共享切断,且只发生在人为的只读查询上, // 不在实时发布路径上,因此不影响每帧开销。 func (g *FlowGraph) Clone() *FlowGraph { if g == nil { return nil } out := &FlowGraph{ ID: g.ID, Nodes: make([]FlowNode, 0, len(g.Nodes)), Edges: append([]FlowEdge(nil), g.Edges...), SessionID: g.SessionID, TurnID: g.TurnID, Round: g.Round, StartedAt: g.StartedAt, EndedAt: g.EndedAt, } for _, n := range g.Nodes { cp := n if n.Props != nil { cp.Props = make(map[string]any, len(n.Props)) for k, v := range n.Props { cp.Props[k] = v } } if n.Log != nil { cp.Log = make([]Event, len(n.Log)) for i, e := range n.Log { ev := e if e.Props != nil { ev.Props = make(map[string]any, len(e.Props)) for k, v := range e.Props { ev.Props[k] = v } } cp.Log[i] = ev } } out.Nodes = append(out.Nodes, cp) } return out }