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 {