feat(flow): 实时全链路流程可视化 P1——trace→graph_update→前端全链路流程页(design/19)
后端: trace/graph.go(RunGraph 图构建器,kind/status 映射,父子优先连线,脱敏白名单+截断);
loop/flow.go(FlowPublisher 按会话增量发布 ws.graph_update,仅主人web会话); loop span
start/finish 折入图; run.go StartRound/EndRound; wire.go 组装+注入.
前端: Flow.tsx(全链路流程页:FlowGraphView+会话/节点/开始/耗时卡片+状态计数+节点点开日志);
FlowGraph 组件加 onNodeClick; types/wsParse 扩展; router/sections/AdminApp 注册 flow;
api-client.md §3.5 graph_update 契约.
验证: 后端 build/vet/test 全绿+flow_test.go 集成(span→graph→ws.graph_update主干); 前端 build 绿.
红线: 单二进制零新依赖; 工具参数/结果脱敏; 仅主人web会话; 身份靠actor元数据; 只读不虚构.
P2(并行子代理分支/泳道/节点实时日志)后置.
This commit is contained in:
1 parent
7a81071ec8
commit
44e7a5c2c5
18 files changed
+1126
-11
No files matched your search
@@ -0,0 +1,161 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// spanRec builds a finished SpanRecord with the essential fields for the builder.
|
||||
func spanRec(id, parent, name string, kind SpanKind, status SpanStatus, props map[string]any) SpanRecord {
|
||||
now := time.Now()
|
||||
return SpanRecord{
|
||||
ID: id,
|
||||
ParentID: parent,
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
Status: status,
|
||||
StartedAt: now.Add(-time.Second),
|
||||
EndedAt: now,
|
||||
Duration: time.Second,
|
||||
Props: props,
|
||||
}
|
||||
}
|
||||
|
||||
// graphFor builds a graph from the given finished spans plus input/output text.
|
||||
func graphFor(spans []SpanRecord, input, output string) *FlowGraph {
|
||||
g := NewRunGraph("web")
|
||||
g.Start(1, time.Now())
|
||||
g.SeedInput(input)
|
||||
for _, s := range spans {
|
||||
g.AddSpan(s)
|
||||
}
|
||||
g.SeedOutput(output)
|
||||
return g.Snapshot()
|
||||
}
|
||||
|
||||
func nodeByID(g *FlowGraph, id string) FlowNode {
|
||||
for _, n := range g.Nodes {
|
||||
if n.ID == id {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return FlowNode{}
|
||||
}
|
||||
|
||||
func hasEdge(g *FlowGraph, from, to string) bool {
|
||||
for _, e := range g.Edges {
|
||||
if e.From == from && e.To == to {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestBuildFlowGraphTrunk asserts the single-round main trunk assembles as
|
||||
// input→llm→tool→output with final "done" status on every node.
|
||||
func TestBuildFlowGraphTrunk(t *testing.T) {
|
||||
llm := spanRec("s1", "step1", "llm.call", SpanKindLLM, SpanStatusOK,
|
||||
map[string]any{"stop_reason": "tool_calls"})
|
||||
tool := spanRec("s2", "step1", "tool.echo", SpanKindTool, SpanStatusOK,
|
||||
map[string]any{"result": "hello world"})
|
||||
|
||||
g := graphFor([]SpanRecord{llm, tool}, "你好", "你好呀")
|
||||
|
||||
if g.SessionID != "web" {
|
||||
t.Fatalf("SessionID = %q, want web", g.SessionID)
|
||||
}
|
||||
if len(g.Nodes) != 4 {
|
||||
t.Fatalf("Nodes = %d, want 4 (input/llm/tool/output) -> %+v", len(g.Nodes), g.Nodes)
|
||||
}
|
||||
if nodeByID(g, "input").Kind != "input" {
|
||||
t.Fatalf("input node kind = %q", nodeByID(g, "input").Kind)
|
||||
}
|
||||
if nodeByID(g, "s1").Kind != "llm" || nodeByID(g, "s1").Status != FlowStatusDone {
|
||||
t.Fatalf("llm node was not folded: %+v", nodeByID(g, "s1"))
|
||||
}
|
||||
if nodeByID(g, "s2").Kind != "tool" || nodeByID(g, "s2").Status != FlowStatusDone {
|
||||
t.Fatalf("tool node was not folded: %+v", nodeByID(g, "s2"))
|
||||
}
|
||||
if !hasEdge(g, "input", "s1") {
|
||||
t.Fatalf("expected edge input->s1, got %+v", g.Edges)
|
||||
}
|
||||
if !hasEdge(g, "s1", "s2") {
|
||||
t.Fatalf("expected edge s1->s2, got %+v", g.Edges)
|
||||
}
|
||||
if !hasEdge(g, "s2", "output") {
|
||||
t.Fatalf("expected edge s2->output, got %+v", g.Edges)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFlowGraphErrorTool asserts a failed tool span finalizes the node as
|
||||
// "error" while the rest of the trunk stays built.
|
||||
func TestBuildFlowGraphErrorTool(t *testing.T) {
|
||||
llm := spanRec("s1", "step1", "llm.call", SpanKindLLM, SpanStatusOK, nil)
|
||||
tool := spanRec("s2", "step1", "tool.bad", SpanKindTool, SpanStatusError,
|
||||
map[string]any{"error": "boom", "error_kind": "execution"})
|
||||
|
||||
g := graphFor([]SpanRecord{llm, tool}, "q", "")
|
||||
if got := nodeByID(g, "s2").Status; got != FlowStatusError {
|
||||
t.Fatalf("tool status = %q, want %q", got, FlowStatusError)
|
||||
}
|
||||
if got := nodeByID(g, "s2").Kind; got != "tool" {
|
||||
t.Fatalf("tool kind = %q", got)
|
||||
}
|
||||
if nodeByID(g, "s2").Props["error_kind"] != "execution" {
|
||||
t.Fatalf("tool props missing error_kind: %+v", nodeByID(g, "s2").Props)
|
||||
}
|
||||
if !hasEdge(g, "s1", "s2") {
|
||||
t.Fatalf("expected edge s1->s2")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFlowGraphSkipsStateTransition asserts the step container span is not
|
||||
// rendered as a node and does not break the chain.
|
||||
func TestBuildFlowGraphSkipsStateTransition(t *testing.T) {
|
||||
step := spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil)
|
||||
llm := spanRec("s1", "step1", "llm.call", SpanKindLLM, SpanStatusOK, nil)
|
||||
|
||||
g := graphFor([]SpanRecord{step, llm}, "q", "a")
|
||||
for _, n := range g.Nodes {
|
||||
if n.ID == "step1" {
|
||||
t.Fatalf("state_transition span leaked as a node: %+v", n)
|
||||
}
|
||||
}
|
||||
if !hasEdge(g, "input", "s1") {
|
||||
t.Fatalf("expected edge input->s1 (through the skipped container), got %+v", g.Edges)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFlowGraphRunningAndDone asserts a node created at start (via
|
||||
// AddSpanStart) is "running", then becomes "done" once its span finishes.
|
||||
func TestBuildFlowGraphRunningAndDone(t *testing.T) {
|
||||
g := NewRunGraph("web")
|
||||
g.Start(1, time.Now())
|
||||
g.SeedInput("q")
|
||||
|
||||
g.AddSpanStart("s1", "llm.call", SpanKindLLM, "", time.Now())
|
||||
if got := nodeByID(g.Snapshot(), "s1").Status; got != FlowStatusRunning {
|
||||
t.Fatalf("start status = %q, want running", got)
|
||||
}
|
||||
|
||||
g.AddSpan(spanRec("s1", "", "llm.call", SpanKindLLM, SpanStatusOK, nil))
|
||||
if got := nodeByID(g.Snapshot(), "s1").Status; got != FlowStatusDone {
|
||||
t.Fatalf("finished status = %q, want done", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFlowGraphParentEdgePrioritizesParent asserts that when a node's parent
|
||||
// is already a node (e.g. a nested subagent span), the edge wires to that parent
|
||||
// instead of the linear chain.
|
||||
func TestBuildFlowGraphParentEdgePrioritizesParent(t *testing.T) {
|
||||
parent := spanRec("sub", "", "subagent.foo", SpanKindSubagent, SpanStatusOK, nil)
|
||||
child := spanRec("nested", "sub", "llm.call", SpanKindLLM, SpanStatusOK, nil)
|
||||
|
||||
g := graphFor([]SpanRecord{parent, child}, "q", "a")
|
||||
if !hasEdge(g, "sub", "nested") {
|
||||
t.Fatalf("expected edge sub->nested (parent priority), got %+v", g.Edges)
|
||||
}
|
||||
if nodeByID(g, "sub").Kind != "subagent" {
|
||||
t.Fatalf("subagent kind = %q", nodeByID(g, "sub").Kind)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user