feat(flow,web): 全链路可视化 P2 — 泳道/并行分支 + 节点活动状态 + 节点实时日志
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 后端 trace/graph.go(395→470):泳道感知游标 —— parents 记住被跳过容器(state_transition)的父链、laneLast 按泳道分别的末节点游标;泳道首节点挂 subagent 根、后续步骤在本泳道内串链;**子代理根不再推进主干游标**(否则 spawn 之后的主干节点会被算成最后一条分支的步骤);node kind 抽具名常量。+ graph_lane_test.go(并行双泳道断言精确边集/嵌套泳道/主干游标不回归/laneOf 环保护)
- 前端新增 flow/{tone,lanes,layout,NodeDetail,LaneSummary}:分组=沿入边反向 BFS 取最近 kind=subagent 祖先(纯 edges,故无需新增 wire 字段);布局=主干沿用 P1 最长路径分层 + 每泳道一条独立横带(带内最长路径深度、逐带下推 y 不重叠、横向滚动);Flow.tsx/FlowGraph.tsx 加泳道框/虚线泳道边/运行闪烁(既有 mu-blink)/选中高亮/图例
- 实时日志:选中态按 node id 保存,随每帧 graph_update 重新 find → 自动显示追加后的 log[];空时区分「运行中(事件随 span 结束回填)」与「未记录事件」,不合成事件
- 未新增 wire 字段 → 无契约变化(因此未动 lib/types.ts / wsParse.ts);<FlowGraphView> 旧签名兼容,Agents/Backthink 页不受影响
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + npm run build(177 模块) + 逻辑测试 lanes 40 / channelStatus 45 / enums 47 / messageSync 54
- ⚠️ 已核实、未伪造的缺口:生产路径当前产不出子代理数据 —— Scheduler.Spawn 全仓仅测试调用方(0 生产)、编排包无 bus 订阅入口、无「派子代理」工具/capability → 泳道要真的亮需先接线;Span.Record 同样无生产调用方 → log[] 恒空(UI 已诚实显示空态)
This commit is contained in:
AskaEth committed 2026-10-03 19:21:10 +08:00
1 parent 49d12d9a64
commit 2c100fe3d9
10 files changed
+1532 -338

No files matched your search

+97 -22
View File
@@ -30,6 +30,28 @@ const (
// collapsed to a safe, dashboard-friendly prefix.
const maxSummaryRunes = 80
// Node kinds the builder emits. They are named constants because the builder
// itself branches on the subagent kind when it resolves a node's lane
// (design/19 §2: parallel sub-agent branches render as separate swimlanes).
const (
nodeKindLLM = "llm"
nodeKindTool = "tool"
nodeKindSubagent = "subagent"
nodeKindMemory = "memory"
nodeKindGuardrail = "guardrail"
nodeKindOrchestrate = "orchestration"
)
// maxLaneWalk bounds how far the builder walks a span's parent chain when it
// resolves the lane (nearest enclosing subagent node) of a new node. Span
// parents form a tree, so the bound only guards against malformed input — it
// keeps the walk finite without a magic number in the loop (project rule §10).
const maxLaneWalk = 64
// trunkLane is the lane key of the main trunk: the lane of every span that has no
// sub-agent ancestor. It is a named constant so the map lookups read as intent.
const trunkLane = ""
// FlowNode is one node in a flow graph. Status/SpanKind mirror the frontier.
type FlowNode struct {
ID string `json:"id"`
@@ -82,17 +104,27 @@ type RunGraph struct {
order []string
edges map[string]bool
edgeOrder []FlowEdge
last string
started time.Time
ended time.Time
// 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),
session: session,
nodes: make(map[string]*FlowNode),
edges: make(map[string]bool),
parents: make(map[string]string),
laneLast: make(map[string]string),
}
}
@@ -106,17 +138,21 @@ func (g *RunGraph) Start(round int, now time.Time) {
g.order = nil
g.edges = make(map[string]bool)
g.edgeOrder = nil
g.last = ""
g.parents = make(map[string]string)
g.laneLast = make(map[string]string)
}
// Upsert creates or merges a node and wires its incoming edge. The edge source
// is the node's ParentID when that parent is already a node, otherwise the most
// recently added node — so a round assembles as a left-to-right trunk even when
// the parent container (a state_transition span) is omitted from the graph.
// Upsert creates or merges a node and wires its incoming edge. The edge source is
// the node's ParentID when that parent is already a node; otherwise the span
// belongs to a lane (subagent branch) or the main trunk and is chained after the
// last node of that same lane — so a lane stays a coherent step chain even while
// several lanes are in flight, and the first node of a lane hangs off its
// subagent root (design/19 §2: 泳道内展示该子代理的步骤链).
func (g *RunGraph) Upsert(spec NodeSpec) {
if spec.ID == "" {
return
}
g.rememberParent(spec.ID, spec.ParentID)
if existing := g.nodes[spec.ID]; existing != nil {
g.merge(existing, spec)
return
@@ -136,16 +172,51 @@ func (g *RunGraph) Upsert(spec NodeSpec) {
g.nodes[spec.ID] = n
g.order = append(g.order, spec.ID)
from := g.last
lane := g.laneOf(spec.ParentID)
from := g.laneLast[lane]
if spec.ParentID != "" {
if _, ok := g.nodes[spec.ParentID]; ok {
from = spec.ParentID
}
}
if from == "" {
from = lane
}
if from != "" && from != spec.ID {
g.addEdge(from, spec.ID)
}
g.last = spec.ID
// A sub-agent root is drawn on the trunk it hangs off, but it does not become
// the trunk's continuation point: the main chain resumes where the branch left
// it (the spawn node). Without this, every node after a spawn would look like a
// step of the last branch (the frontend derives lanes from the edges). The
// first trunk node still advances the cursor, so a graph built without an input
// seed cannot orphan its later trunk nodes.
if lane != "" || n.Kind != nodeKindSubagent || g.laneLast[trunkLane] == "" {
g.laneLast[lane] = spec.ID
}
}
// rememberParent records a span's parent link even when the span is not rendered
// as a node, so laneOf can walk through skipped containers.
func (g *RunGraph) rememberParent(id, parent string) {
if id == "" {
return
}
g.parents[id] = parent
}
// laneOf returns the id of the nearest enclosing subagent node for a span's
// parent chain, or "" when the chain belongs to the main trunk. Only nodes
// already in the graph can be a lane root, so a lane resolves as soon as its
// first step is folded in.
func (g *RunGraph) laneOf(parentID string) string {
for id, hops := parentID, 0; id != "" && hops < maxLaneWalk; hops++ {
if n, ok := g.nodes[id]; ok && n.Kind == nodeKindSubagent {
return id
}
id = g.parents[id]
}
return ""
}
// merge updates an existing node in place (a running placeholder being finalized
@@ -213,10 +284,12 @@ func (g *RunGraph) SeedOutput(text string) string {
}
// AddSpan folds a finished span record into the graph as a node with its final
// status. State-transition spans are the step container and are omitted.
// status. State-transition spans are the step container and are omitted — but
// their parent link is remembered so lane resolution can walk through them.
func (g *RunGraph) AddSpan(rec SpanRecord) {
kind := kindForSpan(rec.Kind)
if kind == "" {
g.rememberParent(rec.ID, rec.ParentID)
return
}
g.Upsert(NodeSpec{
@@ -234,10 +307,12 @@ func (g *RunGraph) AddSpan(rec SpanRecord) {
// AddSpanStart registers a running placeholder for a span that just began, so a
// node appears as "running" before it finishes. State-transition spans are
// omitted, matching AddSpan. It reports whether a node was created.
// omitted (their parent link is still remembered for lane resolution), matching
// AddSpan. It reports whether a node was created.
func (g *RunGraph) AddSpanStart(id, name string, kind SpanKind, parent string, at time.Time) bool {
nodeKind := kindForSpan(kind)
if nodeKind == "" {
g.rememberParent(id, parent)
return false
}
g.Upsert(NodeSpec{
@@ -272,17 +347,17 @@ func (g *RunGraph) Snapshot() *FlowGraph {
func kindForSpan(k SpanKind) string {
switch k {
case SpanKindLLM:
return "llm"
return nodeKindLLM
case SpanKindTool:
return "tool"
return nodeKindTool
case SpanKindSubagent:
return "subagent"
return nodeKindSubagent
case SpanKindMemory:
return "memory"
return nodeKindMemory
case SpanKindGuardrail:
return "guardrail"
return nodeKindGuardrail
case SpanKindOrchestration:
return "orchestration"
return nodeKindOrchestrate
default:
return ""
}
@@ -290,7 +365,7 @@ func kindForSpan(k SpanKind) string {
// labelForSpan renders a node label from a finished span.
func labelForSpan(rec SpanRecord) string {
if kind := kindForSpan(rec.Kind); kind == "tool" {
if kind := kindForSpan(rec.Kind); kind == nodeKindTool {
return strings.TrimPrefix(rec.Name, "tool.")
}
if rec.Kind == SpanKindLLM {
+155
View File
@@ -0,0 +1,155 @@
package trace
import (
"fmt"
"sort"
"strings"
"testing"
"time"
)
// edgeSet renders a graph's edges as a sorted "from>to" set, so a test can assert
// the exact wiring (an extra cross-lane edge fails even though the wanted edges
// are present).
func edgeSet(g *FlowGraph) []string {
out := make([]string, 0, len(g.Edges))
for _, e := range g.Edges {
out = append(out, e.From+">"+e.To)
}
sort.Strings(out)
return out
}
// assertEdges asserts the graph's edges match want exactly.
func assertEdges(t *testing.T, g *FlowGraph, want ...string) {
t.Helper()
sort.Strings(want)
got := edgeSet(g)
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("edges = %v, want %v", got, want)
}
}
// TestBuildFlowGraphParallelLanesStaySeparate folds two sub-agent lanes whose
// spans interleave (A's second step arrives after B started) and asserts the
// wiring stays per-lane: each lane chains from its own root through its own
// steps, with no edge crossing lanes (design/19 §2 泳道).
func TestBuildFlowGraphParallelLanesStaySeparate(t *testing.T) {
spans := []SpanRecord{
spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("t1", "step1", "tool.spawn", SpanKindTool, SpanStatusOK, nil),
spanRec("subA", "t1", "subagent.a", SpanKindSubagent, SpanStatusOK, nil),
spanRec("stA", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llmA", "stA", "llm.call", SpanKindLLM, SpanStatusOK, nil),
// Lane B starts before lane A's second step finishes.
spanRec("subB", "t1", "subagent.b", SpanKindSubagent, SpanStatusOK, nil),
spanRec("stB", "subB", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llmB", "stB", "llm.call", SpanKindLLM, SpanStatusOK, nil),
spanRec("stA2", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("toolA", "stA2", "tool.search", SpanKindTool, SpanStatusOK, nil),
// The main loop continues on its own step after the spawn tool call.
spanRec("st2", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llm2", "st2", "llm.call", SpanKindLLM, SpanStatusOK, nil),
}
g := graphFor(spans, "q", "a")
assertEdges(t, g,
"input>t1",
"t1>subA",
"t1>subB",
"subA>llmA",
"llmA>toolA",
"subB>llmB",
// The trunk resumes from the spawn node, not from a sub-agent's branch.
"t1>llm2",
"llm2>output",
)
}
// TestBuildFlowGraphNestedSubagentLane folds a sub-agent spawned inside another
// sub-agent and asserts the nested lane chains off its own root while staying
// chained inside the parent lane.
func TestBuildFlowGraphNestedSubagentLane(t *testing.T) {
spans := []SpanRecord{
spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("t1", "step1", "tool.spawn", SpanKindTool, SpanStatusOK, nil),
spanRec("subA", "t1", "subagent.a", SpanKindSubagent, SpanStatusOK, nil),
spanRec("stA", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llmA", "stA", "llm.call", SpanKindLLM, SpanStatusOK, nil),
spanRec("stN", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("subN", "stN", "subagent.nested", SpanKindSubagent, SpanStatusOK, nil),
spanRec("stN2", "subN", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llmN", "stN2", "llm.call", SpanKindLLM, SpanStatusOK, nil),
}
g := graphFor(spans, "q", "a")
assertEdges(t, g,
"input>t1",
"t1>subA",
"subA>llmA",
// The nested sub-agent is a step of lane A...
"llmA>subN",
// ...and its own steps hang off it.
"subN>llmN",
// The trunk resumes from the spawn node even though the round ended inside
// a nested branch.
"t1>output",
)
if got := nodeByID(g, "subN").Kind; got != nodeKindSubagent {
t.Fatalf("nested node kind = %q, want %q", got, nodeKindSubagent)
}
}
// TestBuildFlowGraphTrunkCursorUnchanged asserts the lane-aware cursor degrades to
// the plain left-to-right trunk when no sub-agent is in flight (no P1 regression).
func TestBuildFlowGraphTrunkCursorUnchanged(t *testing.T) {
spans := []SpanRecord{
spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llm1", "step1", "llm.call", SpanKindLLM, SpanStatusOK, nil),
spanRec("step2", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llm2", "step2", "llm.call", SpanKindLLM, SpanStatusOK, nil),
spanRec("step3", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("tool2", "step3", "tool.echo", SpanKindTool, SpanStatusOK, nil),
}
g := graphFor(spans, "q", "a")
assertEdges(t, g,
"input>llm1",
"llm1>llm2",
"llm2>tool2",
"tool2>output",
)
}
// TestLaneOfWalkBound asserts a malformed (over-deep or cyclic) parent chain
// cannot spin laneOf forever: past the walk bound the node simply lands on the
// trunk, while a shallow chain still resolves to its sub-agent root.
func TestLaneOfWalkBound(t *testing.T) {
g := NewRunGraph("web")
g.Start(1, time.Now())
g.AddSpan(spanRec("sub", "", "subagent.a", SpanKindSubagent, SpanStatusOK, nil))
g.rememberParent("near", "sub")
if got := g.laneOf("near"); got != "sub" {
t.Fatalf("laneOf near = %q, want sub", got)
}
prev := "sub"
for i := 0; i <= maxLaneWalk+5; i++ {
id := fmt.Sprintf("st%d", i)
g.rememberParent(id, prev)
prev = id
}
if got := g.laneOf(prev); got != "" {
t.Fatalf("laneOf over the walk bound = %q, want \"\"", got)
}
g.rememberParent("cycA", "cycB")
g.rememberParent("cycB", "cycA")
if got := g.laneOf("cycA"); got != "" {
t.Fatalf("laneOf cycle = %q, want \"\"", got)
}
}