feat(flow,web): 全链路可视化 P2 — 泳道/并行分支 + 节点活动状态 + 节点实时日志
- 后端 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:
1 parent
49d12d9a64
commit
2c100fe3d9
10 files changed
+1532
-338
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user