Refactor trace node management to support multiple parents and enhance type handling
- Updated the TraceNode structure to allow multiple parent IDs, facilitating implicit joins in trace management. - Enhanced the TraceNodeOption to include a type field for better categorization of nodes. - Refactored related methods in the trace manager and node handling to accommodate the new parent structure and type field. - Improved test coverage for trace node creation and retrieval, ensuring robust handling of the new multi-parent functionality.
This commit is contained in:
parent
30778bbf2f
commit
e781e8f591
18 changed files with 733 additions and 155 deletions
|
|
@ -37,14 +37,10 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
if trace != nil {
|
if trace != nil {
|
||||||
agentNode, _ = trace.Add(inputMessages, types.TraceNodeOption{
|
agentNode, _ = trace.Add(inputMessages, types.TraceNodeOption{
|
||||||
Label: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.label"), // "Assistant {{name}}"
|
Label: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.label"), // "Assistant {{name}}"
|
||||||
|
Type: "agent",
|
||||||
Icon: "assistant",
|
Icon: "assistant",
|
||||||
Description: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.description"), // "Assistant {{name}} is processing the request"
|
Description: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.description"), // "Assistant {{name}} is processing the request"
|
||||||
})
|
})
|
||||||
|
|
||||||
if agentNode != nil {
|
|
||||||
// Mark the node as complete when the function returns
|
|
||||||
defer agentNode.Complete()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full input messages with chat history
|
// Full input messages with chat history
|
||||||
|
|
@ -119,11 +115,11 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
map[string]any{"messages": completionMessages, "options": completionOptions},
|
map[string]any{"messages": completionMessages, "options": completionOptions},
|
||||||
types.TraceNodeOption{
|
types.TraceNodeOption{
|
||||||
Label: fmt.Sprintf(i18n.Tr(ast.ID, ctx.Locale, "llm.openai.stream.label"), conn.ID()), // "LLM %s"
|
Label: fmt.Sprintf(i18n.Tr(ast.ID, ctx.Locale, "llm.openai.stream.label"), conn.ID()), // "LLM %s"
|
||||||
Icon: "llm",
|
Type: "llm",
|
||||||
|
Icon: "psychology",
|
||||||
Description: fmt.Sprintf(i18n.Tr(ast.ID, ctx.Locale, "llm.openai.stream.description"), conn.ID()), // "LLM %s is processing the request"
|
Description: fmt.Sprintf(i18n.Tr(ast.ID, ctx.Locale, "llm.openai.stream.description"), conn.ID()), // "LLM %s is processing the request"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create LLM instance with connector and options
|
// Create LLM instance with connector and options
|
||||||
|
|
|
||||||
|
|
@ -331,20 +331,35 @@ func TranslateGlobal(locale string, input any) any {
|
||||||
i18ns = map[string]I18n{}
|
i18ns = map[string]I18n{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try the exact locale first
|
||||||
i18n, has := i18ns[locale]
|
i18n, has := i18ns[locale]
|
||||||
if !has {
|
if has {
|
||||||
parts := strings.Split(locale, "-")
|
result := i18n.Parse(input)
|
||||||
if len(parts) > 1 {
|
// If the result is the same as input (not translated), try fallback
|
||||||
i18n, has = i18ns[parts[1]]
|
if result != input {
|
||||||
}
|
return result
|
||||||
if !has {
|
|
||||||
i18n, has = i18ns[parts[0]]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if has {
|
// Fallback logic: for "en-us", try "en"
|
||||||
return i18n.Parse(input)
|
parts := strings.Split(locale, "-")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
// Try the language code (e.g., "en" for "en-us")
|
||||||
|
if fallbackI18n, hasFallback := i18ns[parts[0]]; hasFallback {
|
||||||
|
result := fallbackI18n.Parse(input)
|
||||||
|
if result != input {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try the country code (e.g., "us" for "en-us")
|
||||||
|
if fallbackI18n, hasFallback := i18ns[parts[1]]; hasFallback {
|
||||||
|
result := fallbackI18n.Parse(input)
|
||||||
|
if result != input {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -733,6 +733,48 @@ func TestTranslateGlobal(t *testing.T) {
|
||||||
// Restore
|
// Restore
|
||||||
Locales["__global__"] = temp
|
Locales["__global__"] = temp
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("TranslateGlobal fallback from en-us to en", func(t *testing.T) {
|
||||||
|
// Create a scenario where en-us has limited messages, but en has more
|
||||||
|
// This simulates the real-world case: en-us locale exists with 3 messages,
|
||||||
|
// but en has 41 messages including llm.handlers.stream.info
|
||||||
|
|
||||||
|
// Create en-us with only a few messages
|
||||||
|
enUSMessages := map[string]any{
|
||||||
|
"button.ok": "OK (US)", // en-us specific
|
||||||
|
"app.name": "My App",
|
||||||
|
"app.version": "1.0",
|
||||||
|
}
|
||||||
|
Locales["__global__"]["en-us"] = I18n{
|
||||||
|
Locale: "en-us",
|
||||||
|
Messages: enUSMessages,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 1: Key exists in en-us - should use en-us
|
||||||
|
result := TranslateGlobal("en-us", "button.ok")
|
||||||
|
if result != "OK (US)" {
|
||||||
|
t.Errorf("Expected 'OK (US)' from en-us, got %v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Key does NOT exist in en-us but exists in en - should fallback to en
|
||||||
|
result = TranslateGlobal("en-us", "button.cancel")
|
||||||
|
if result != "Cancel" {
|
||||||
|
t.Errorf("Expected 'Cancel' (fallback from en-us to en), got %v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Built-in key that exists in en but not in en-us
|
||||||
|
result = TranslateGlobal("en-us", "llm.handlers.stream.info")
|
||||||
|
expected := "LLM Stream"
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Expected '%s' (fallback from en-us to en), got %v", expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Direct key (not template) also should fallback
|
||||||
|
result = TranslateGlobal("en-us", "{{llm.handlers.stream.info}}")
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("Expected '%s' (fallback from en-us to en with template), got %v", expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetLocalesIntegration tests GetLocales with real assistant data
|
// TestGetLocalesIntegration tests GetLocales with real assistant data
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ func DefaultStreamHandler(ctx *context.Context) context.StreamFunc {
|
||||||
return func(chunkType context.StreamChunkType, data []byte) int {
|
return func(chunkType context.StreamChunkType, data []byte) int {
|
||||||
trace, _ := ctx.Trace()
|
trace, _ := ctx.Trace()
|
||||||
if trace != nil {
|
if trace != nil {
|
||||||
trace.Info(i18n.T(ctx.Locale, "llm.handlers.stream.info"), map[string]any{"data": string(data)}) // "LLM Stream"
|
trace.Info(i18n.T(ctx.Locale, "llm.handlers.stream.info"), map[string]any{"data": string(data)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different chunk types
|
// Handle different chunk types
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ func prepareTestTrace(t *testing.T) *testTraceData {
|
||||||
// Create a memory space
|
// Create a memory space
|
||||||
space, err := manager.CreateSpace(types.TraceSpaceOption{
|
space, err := manager.CreateSpace(types.TraceSpaceOption{
|
||||||
Label: "Test Space",
|
Label: "Test Space",
|
||||||
|
Type: "memory",
|
||||||
|
Icon: "database",
|
||||||
Description: "A test memory space",
|
Description: "A test memory space",
|
||||||
})
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -78,6 +80,7 @@ func prepareTestTrace(t *testing.T) *testTraceData {
|
||||||
// Add first node
|
// Add first node
|
||||||
node1, err := manager.Add("test input 1", types.TraceNodeOption{
|
node1, err := manager.Add("test input 1", types.TraceNodeOption{
|
||||||
Label: "First Node",
|
Label: "First Node",
|
||||||
|
Type: "agent",
|
||||||
Icon: "icon1",
|
Icon: "icon1",
|
||||||
Description: "First test node",
|
Description: "First test node",
|
||||||
Metadata: map[string]any{"node_order": 1},
|
Metadata: map[string]any{"node_order": 1},
|
||||||
|
|
@ -94,6 +97,7 @@ func prepareTestTrace(t *testing.T) *testTraceData {
|
||||||
// Add second node
|
// Add second node
|
||||||
node2, err := manager.Add("test input 2", types.TraceNodeOption{
|
node2, err := manager.Add("test input 2", types.TraceNodeOption{
|
||||||
Label: "Second Node",
|
Label: "Second Node",
|
||||||
|
Type: "tool",
|
||||||
Icon: "icon2",
|
Icon: "icon2",
|
||||||
Description: "Second test node",
|
Description: "Second test node",
|
||||||
Metadata: map[string]any{"node_order": 2},
|
Metadata: map[string]any{"node_order": 2},
|
||||||
|
|
@ -110,6 +114,7 @@ func prepareTestTrace(t *testing.T) *testTraceData {
|
||||||
// Add third node
|
// Add third node
|
||||||
node3, err := manager.Add("test input 3", types.TraceNodeOption{
|
node3, err := manager.Add("test input 3", types.TraceNodeOption{
|
||||||
Label: "Third Node",
|
Label: "Third Node",
|
||||||
|
Type: "custom",
|
||||||
Icon: "icon3",
|
Icon: "icon3",
|
||||||
Description: "Third test node",
|
Description: "Third test node",
|
||||||
Metadata: map[string]any{"node_order": 3},
|
Metadata: map[string]any{"node_order": 3},
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ func TestGetNodes(t *testing.T) {
|
||||||
|
|
||||||
// Verify node structure and metadata
|
// Verify node structure and metadata
|
||||||
metadataFound := 0
|
metadataFound := 0
|
||||||
|
typeFound := 0
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
node, ok := n.(map[string]interface{})
|
node, ok := n.(map[string]interface{})
|
||||||
assert.True(t, ok, "Each node should be an object")
|
assert.True(t, ok, "Each node should be an object")
|
||||||
|
|
@ -61,6 +62,20 @@ func TestGetNodes(t *testing.T) {
|
||||||
assert.NotNil(t, node["status"], "Node should have status")
|
assert.NotNil(t, node["status"], "Node should have status")
|
||||||
assert.NotNil(t, node["created_at"], "Node should have created_at")
|
assert.NotNil(t, node["created_at"], "Node should have created_at")
|
||||||
|
|
||||||
|
// Verify type field is present
|
||||||
|
if node["type"] != nil {
|
||||||
|
nodeType, ok := node["type"].(string)
|
||||||
|
assert.True(t, ok, "Type should be a string")
|
||||||
|
assert.NotEmpty(t, nodeType, "Type should not be empty")
|
||||||
|
typeFound++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify parent_ids field (should be array or null)
|
||||||
|
if node["parent_ids"] != nil {
|
||||||
|
_, ok := node["parent_ids"].([]interface{})
|
||||||
|
assert.True(t, ok, "parent_ids should be an array")
|
||||||
|
}
|
||||||
|
|
||||||
// Check if metadata is present (should be for all our test nodes)
|
// Check if metadata is present (should be for all our test nodes)
|
||||||
if node["metadata"] != nil {
|
if node["metadata"] != nil {
|
||||||
metadata, ok := node["metadata"].(map[string]interface{})
|
metadata, ok := node["metadata"].(map[string]interface{})
|
||||||
|
|
@ -72,6 +87,8 @@ func TestGetNodes(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 3, typeFound, "All 3 nodes should have type field")
|
||||||
|
|
||||||
assert.Equal(t, 3, metadataFound, "All 3 nodes should have metadata with node_order")
|
assert.Equal(t, 3, metadataFound, "All 3 nodes should have metadata with node_order")
|
||||||
|
|
||||||
t.Logf("Retrieved %d nodes for trace %s (all with metadata)", count, data.TraceID)
|
t.Logf("Retrieved %d nodes for trace %s (all with metadata)", count, data.TraceID)
|
||||||
|
|
@ -107,9 +124,17 @@ func TestGetNodeByID(t *testing.T) {
|
||||||
// Verify response structure
|
// Verify response structure
|
||||||
assert.Equal(t, data.Node1ID, responseData["id"], "Node ID should match")
|
assert.Equal(t, data.Node1ID, responseData["id"], "Node ID should match")
|
||||||
assert.Equal(t, "First Node", responseData["label"], "Node label should match")
|
assert.Equal(t, "First Node", responseData["label"], "Node label should match")
|
||||||
|
assert.Equal(t, "agent", responseData["type"], "Node type should match")
|
||||||
assert.Equal(t, "icon1", responseData["icon"], "Node icon should match")
|
assert.Equal(t, "icon1", responseData["icon"], "Node icon should match")
|
||||||
assert.Equal(t, "First test node", responseData["description"], "Node description should match")
|
assert.Equal(t, "First test node", responseData["description"], "Node description should match")
|
||||||
|
|
||||||
|
// Verify parent_ids field (should be array or null)
|
||||||
|
if responseData["parent_ids"] != nil {
|
||||||
|
parentIDs, ok := responseData["parent_ids"].([]interface{})
|
||||||
|
assert.True(t, ok, "parent_ids should be an array")
|
||||||
|
t.Logf("Node has %d parent(s)", len(parentIDs))
|
||||||
|
}
|
||||||
|
|
||||||
// Verify metadata is present and correct
|
// Verify metadata is present and correct
|
||||||
assert.NotNil(t, responseData["metadata"], "Metadata should be present")
|
assert.NotNil(t, responseData["metadata"], "Metadata should be present")
|
||||||
metadata, ok := responseData["metadata"].(map[string]interface{})
|
metadata, ok := responseData["metadata"].(map[string]interface{})
|
||||||
|
|
@ -120,7 +145,7 @@ func TestGetNodeByID(t *testing.T) {
|
||||||
assert.NotNil(t, responseData["input"], "Input should be present")
|
assert.NotNil(t, responseData["input"], "Input should be present")
|
||||||
assert.NotNil(t, responseData["output"], "Output should be present")
|
assert.NotNil(t, responseData["output"], "Output should be present")
|
||||||
|
|
||||||
t.Logf("Retrieved node %s from trace %s with metadata: %+v", data.Node1ID, data.TraceID, metadata)
|
t.Logf("Retrieved node %s (type: %s) from trace %s with metadata: %+v", data.Node1ID, responseData["type"], data.TraceID, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetNodeByIDNotFound tests getting a non-existent node
|
// TestGetNodeByIDNotFound tests getting a non-existent node
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ func TestGetSpaces(t *testing.T) {
|
||||||
// Create additional spaces for this test
|
// Create additional spaces for this test
|
||||||
space1, err := data.Manager.CreateSpace(types.TraceSpaceOption{
|
space1, err := data.Manager.CreateSpace(types.TraceSpaceOption{
|
||||||
Label: "Memory Space",
|
Label: "Memory Space",
|
||||||
|
Type: "memory",
|
||||||
Icon: "memory",
|
Icon: "memory",
|
||||||
Description: "Test memory space",
|
Description: "Test memory space",
|
||||||
})
|
})
|
||||||
|
|
@ -25,6 +26,7 @@ func TestGetSpaces(t *testing.T) {
|
||||||
|
|
||||||
space2, err := data.Manager.CreateSpace(types.TraceSpaceOption{
|
space2, err := data.Manager.CreateSpace(types.TraceSpaceOption{
|
||||||
Label: "Cache Space",
|
Label: "Cache Space",
|
||||||
|
Type: "cache",
|
||||||
Icon: "cache",
|
Icon: "cache",
|
||||||
Description: "Test cache space",
|
Description: "Test cache space",
|
||||||
})
|
})
|
||||||
|
|
@ -64,6 +66,7 @@ func TestGetSpaces(t *testing.T) {
|
||||||
|
|
||||||
// Verify space metadata (should not include data field)
|
// Verify space metadata (should not include data field)
|
||||||
spaceLabels := make(map[string]bool)
|
spaceLabels := make(map[string]bool)
|
||||||
|
typeFound := 0
|
||||||
for _, s := range spaces {
|
for _, s := range spaces {
|
||||||
space := s.(map[string]any)
|
space := s.(map[string]any)
|
||||||
assert.NotNil(t, space["id"])
|
assert.NotNil(t, space["id"])
|
||||||
|
|
@ -71,9 +74,20 @@ func TestGetSpaces(t *testing.T) {
|
||||||
assert.NotNil(t, space["created_at"])
|
assert.NotNil(t, space["created_at"])
|
||||||
assert.NotNil(t, space["updated_at"])
|
assert.NotNil(t, space["updated_at"])
|
||||||
assert.Nil(t, space["data"]) // Should NOT include key-value data
|
assert.Nil(t, space["data"]) // Should NOT include key-value data
|
||||||
|
|
||||||
|
// Verify type field is present
|
||||||
|
if space["type"] != nil {
|
||||||
|
spaceType, ok := space["type"].(string)
|
||||||
|
assert.True(t, ok, "Type should be a string")
|
||||||
|
assert.NotEmpty(t, spaceType, "Type should not be empty")
|
||||||
|
typeFound++
|
||||||
|
}
|
||||||
|
|
||||||
spaceLabels[space["label"].(string)] = true
|
spaceLabels[space["label"].(string)] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, typeFound, 2, "At least 2 spaces should have type field")
|
||||||
|
|
||||||
assert.True(t, spaceLabels["Memory Space"])
|
assert.True(t, spaceLabels["Memory Space"])
|
||||||
assert.True(t, spaceLabels["Cache Space"])
|
assert.True(t, spaceLabels["Cache Space"])
|
||||||
|
|
||||||
|
|
@ -88,9 +102,10 @@ func TestGetSpaceByID(t *testing.T) {
|
||||||
// Create a space with specific data
|
// Create a space with specific data
|
||||||
space, err := data.Manager.CreateSpace(types.TraceSpaceOption{
|
space, err := data.Manager.CreateSpace(types.TraceSpaceOption{
|
||||||
Label: "Detailed Space",
|
Label: "Detailed Space",
|
||||||
|
Type: "detailed",
|
||||||
Icon: "memory",
|
Icon: "memory",
|
||||||
Description: "Space with detailed data",
|
Description: "Space with detailed data",
|
||||||
Metadata: map[string]any{"type": "cache"},
|
Metadata: map[string]any{"cache_enabled": true},
|
||||||
})
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -122,6 +137,7 @@ func TestGetSpaceByID(t *testing.T) {
|
||||||
// Verify space metadata
|
// Verify space metadata
|
||||||
assert.Equal(t, space.ID, result["id"])
|
assert.Equal(t, space.ID, result["id"])
|
||||||
assert.Equal(t, "Detailed Space", result["label"])
|
assert.Equal(t, "Detailed Space", result["label"])
|
||||||
|
assert.Equal(t, "detailed", result["type"])
|
||||||
assert.Equal(t, "memory", result["icon"])
|
assert.Equal(t, "memory", result["icon"])
|
||||||
assert.Equal(t, "Space with detailed data", result["description"])
|
assert.Equal(t, "Space with detailed data", result["description"])
|
||||||
assert.NotNil(t, result["created_at"])
|
assert.NotNil(t, result["created_at"])
|
||||||
|
|
@ -129,7 +145,7 @@ func TestGetSpaceByID(t *testing.T) {
|
||||||
|
|
||||||
// Verify metadata
|
// Verify metadata
|
||||||
metadata := result["metadata"].(map[string]any)
|
metadata := result["metadata"].(map[string]any)
|
||||||
assert.Equal(t, "cache", metadata["type"])
|
assert.Equal(t, true, metadata["cache_enabled"])
|
||||||
|
|
||||||
// Verify key-value data
|
// Verify key-value data
|
||||||
spaceData := result["data"].(map[string]any)
|
spaceData := result["data"].(map[string]any)
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,9 @@ func GetNodes(c *gin.Context) {
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
nodeInfo := gin.H{
|
nodeInfo := gin.H{
|
||||||
"id": node.ID,
|
"id": node.ID,
|
||||||
"parent_id": node.ParentID,
|
"parent_ids": node.ParentIDs,
|
||||||
"label": node.Label,
|
"label": node.Label,
|
||||||
|
"type": node.Type,
|
||||||
"icon": node.Icon,
|
"icon": node.Icon,
|
||||||
"description": node.Description,
|
"description": node.Description,
|
||||||
"status": node.Status,
|
"status": node.Status,
|
||||||
|
|
@ -124,8 +125,9 @@ func GetNode(c *gin.Context) {
|
||||||
// Prepare detailed node response
|
// Prepare detailed node response
|
||||||
nodeData := gin.H{
|
nodeData := gin.H{
|
||||||
"id": node.ID,
|
"id": node.ID,
|
||||||
"parent_id": node.ParentID,
|
"parent_ids": node.ParentIDs,
|
||||||
"label": node.Label,
|
"label": node.Label,
|
||||||
|
"type": node.Type,
|
||||||
"icon": node.Icon,
|
"icon": node.Icon,
|
||||||
"description": node.Description,
|
"description": node.Description,
|
||||||
"status": node.Status,
|
"status": node.Status,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ func GetSpaces(c *gin.Context) {
|
||||||
spaceInfo := gin.H{
|
spaceInfo := gin.H{
|
||||||
"id": space.ID,
|
"id": space.ID,
|
||||||
"label": space.Label,
|
"label": space.Label,
|
||||||
|
"type": space.Type,
|
||||||
"icon": space.Icon,
|
"icon": space.Icon,
|
||||||
"description": space.Description,
|
"description": space.Description,
|
||||||
"ttl": space.TTL,
|
"ttl": space.TTL,
|
||||||
|
|
@ -122,6 +123,7 @@ func GetSpace(c *gin.Context) {
|
||||||
responseData := gin.H{
|
responseData := gin.H{
|
||||||
"id": spaceData.ID,
|
"id": spaceData.ID,
|
||||||
"label": spaceData.Label,
|
"label": spaceData.Label,
|
||||||
|
"type": spaceData.Type,
|
||||||
"icon": spaceData.Icon,
|
"icon": spaceData.Icon,
|
||||||
"description": spaceData.Description,
|
"description": spaceData.Description,
|
||||||
"ttl": spaceData.TTL,
|
"ttl": spaceData.TTL,
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,19 @@ func parseTraceNodeOption(obj *v8go.Object) types.TraceNodeOption {
|
||||||
if labelVal, err := obj.Get("label"); err == nil && !labelVal.IsNullOrUndefined() {
|
if labelVal, err := obj.Get("label"); err == nil && !labelVal.IsNullOrUndefined() {
|
||||||
option.Label = labelVal.String()
|
option.Label = labelVal.String()
|
||||||
}
|
}
|
||||||
|
if typeVal, err := obj.Get("type"); err == nil && !typeVal.IsNullOrUndefined() {
|
||||||
|
option.Type = typeVal.String()
|
||||||
|
}
|
||||||
if iconVal, err := obj.Get("icon"); err == nil && !iconVal.IsNullOrUndefined() {
|
if iconVal, err := obj.Get("icon"); err == nil && !iconVal.IsNullOrUndefined() {
|
||||||
option.Icon = iconVal.String()
|
option.Icon = iconVal.String()
|
||||||
}
|
}
|
||||||
if descVal, err := obj.Get("description"); err == nil && !descVal.IsNullOrUndefined() {
|
if descVal, err := obj.Get("description"); err == nil && !descVal.IsNullOrUndefined() {
|
||||||
option.Description = descVal.String()
|
option.Description = descVal.String()
|
||||||
}
|
}
|
||||||
|
if autoCompleteVal, err := obj.Get("autoCompleteParent"); err == nil && !autoCompleteVal.IsNullOrUndefined() {
|
||||||
|
boolVal := autoCompleteVal.Boolean()
|
||||||
|
option.AutoCompleteParent = &boolVal
|
||||||
|
}
|
||||||
return option
|
return option
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -443,6 +443,9 @@ func traceCreateSpaceMethod(iso *v8go.Isolate, manager types.Manager) *v8go.Func
|
||||||
if labelVal, err := optionObj.Get("label"); err == nil && !labelVal.IsNullOrUndefined() {
|
if labelVal, err := optionObj.Get("label"); err == nil && !labelVal.IsNullOrUndefined() {
|
||||||
option.Label = labelVal.String()
|
option.Label = labelVal.String()
|
||||||
}
|
}
|
||||||
|
if typeVal, err := optionObj.Get("type"); err == nil && !typeVal.IsNullOrUndefined() {
|
||||||
|
option.Type = typeVal.String()
|
||||||
|
}
|
||||||
if iconVal, err := optionObj.Get("icon"); err == nil && !iconVal.IsNullOrUndefined() {
|
if iconVal, err := optionObj.Get("icon"); err == nil && !iconVal.IsNullOrUndefined() {
|
||||||
option.Icon = iconVal.String()
|
option.Icon = iconVal.String()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,10 @@ import (
|
||||||
// Only stores IDs of children instead of full child nodes
|
// Only stores IDs of children instead of full child nodes
|
||||||
type persistNode struct {
|
type persistNode struct {
|
||||||
ID string `json:"ID"`
|
ID string `json:"ID"`
|
||||||
ParentID string `json:"ParentID"`
|
ParentIDs []string `json:"ParentIDs,omitempty"`
|
||||||
ChildrenIDs []string `json:"ChildrenIDs,omitempty"`
|
ChildrenIDs []string `json:"ChildrenIDs,omitempty"`
|
||||||
Label string `json:"Label,omitempty"`
|
Label string `json:"Label,omitempty"`
|
||||||
|
Type string `json:"Type,omitempty"`
|
||||||
Icon string `json:"Icon,omitempty"`
|
Icon string `json:"Icon,omitempty"`
|
||||||
Description string `json:"Description,omitempty"`
|
Description string `json:"Description,omitempty"`
|
||||||
Metadata map[string]any `json:"Metadata,omitempty"`
|
Metadata map[string]any `json:"Metadata,omitempty"`
|
||||||
|
|
@ -51,9 +52,10 @@ func toPersistNode(node *types.TraceNode) *persistNode {
|
||||||
|
|
||||||
return &persistNode{
|
return &persistNode{
|
||||||
ID: node.ID,
|
ID: node.ID,
|
||||||
ParentID: node.ParentID,
|
ParentIDs: node.ParentIDs,
|
||||||
ChildrenIDs: childrenIDs,
|
ChildrenIDs: childrenIDs,
|
||||||
Label: node.Label,
|
Label: node.Label,
|
||||||
|
Type: node.Type,
|
||||||
Icon: node.Icon,
|
Icon: node.Icon,
|
||||||
Description: node.Description,
|
Description: node.Description,
|
||||||
Metadata: node.Metadata,
|
Metadata: node.Metadata,
|
||||||
|
|
@ -74,11 +76,12 @@ func fromPersistNode(pn *persistNode) *types.TraceNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &types.TraceNode{
|
return &types.TraceNode{
|
||||||
ID: pn.ID,
|
ID: pn.ID,
|
||||||
ParentID: pn.ParentID,
|
ParentIDs: pn.ParentIDs,
|
||||||
Children: nil, // Children will be loaded separately if needed
|
Children: nil, // Children will be loaded separately if needed
|
||||||
TraceNodeOption: types.TraceNodeOption{
|
TraceNodeOption: types.TraceNodeOption{
|
||||||
Label: pn.Label,
|
Label: pn.Label,
|
||||||
|
Type: pn.Type,
|
||||||
Icon: pn.Icon,
|
Icon: pn.Icon,
|
||||||
Description: pn.Description,
|
Description: pn.Description,
|
||||||
Metadata: pn.Metadata,
|
Metadata: pn.Metadata,
|
||||||
|
|
@ -125,8 +128,12 @@ func New(basePath string) (*Driver, error) {
|
||||||
// Format: {basePath}/{YYYYMMDD}/{traceID}/
|
// Format: {basePath}/{YYYYMMDD}/{traceID}/
|
||||||
func (d *Driver) getTracePath(traceID string) string {
|
func (d *Driver) getTracePath(traceID string) string {
|
||||||
// Extract date prefix from traceID (first 8 digits)
|
// Extract date prefix from traceID (first 8 digits)
|
||||||
datePrefix := traceID[:8]
|
// If traceID is short, use "others" as prefix
|
||||||
return filepath.Join(d.basePath, datePrefix, traceID)
|
prefix := "others"
|
||||||
|
if len(traceID) >= 8 {
|
||||||
|
prefix = traceID[:8]
|
||||||
|
}
|
||||||
|
return filepath.Join(d.basePath, prefix, traceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureTraceDir creates the trace directory if it doesn't exist
|
// ensureTraceDir creates the trace directory if it doesn't exist
|
||||||
|
|
@ -250,7 +257,7 @@ func (d *Driver) LoadTrace(ctx context.Context, traceID string) (*types.TraceNod
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find root node ID (node with empty ParentID) by checking each file
|
// Find root node ID (node with empty ParentIDs) by checking each file
|
||||||
var rootNodeID string
|
var rootNodeID string
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||||
|
|
@ -269,7 +276,7 @@ func (d *Driver) LoadTrace(ctx context.Context, traceID string) (*types.TraceNod
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if pn.ParentID == "" {
|
if len(pn.ParentIDs) == 0 {
|
||||||
rootNodeID = nodeID
|
rootNodeID = nodeID
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
159
trace/manager.go
159
trace/manager.go
|
|
@ -100,77 +100,6 @@ func (m *manager) checkContext() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCancellation marks nodes and trace as cancelled (called when context is done)
|
|
||||||
func (m *manager) handleCancellation() {
|
|
||||||
// Mark as completed first - this will trigger state worker to exit
|
|
||||||
// IMPORTANT: Must mark completed before any state queries to prevent deadlock
|
|
||||||
if !m.stateMarkCompleted() {
|
|
||||||
return // Already completed
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UnixMilli()
|
|
||||||
|
|
||||||
// Get current nodes (state worker will process this before exiting)
|
|
||||||
nodes := m.stateGetCurrentNodes()
|
|
||||||
|
|
||||||
// Mark only running/pending nodes as cancelled
|
|
||||||
for _, node := range nodes {
|
|
||||||
if node.Status == types.StatusRunning || node.Status == types.StatusPending {
|
|
||||||
node.Status = types.StatusCancelled
|
|
||||||
node.EndTime = now
|
|
||||||
node.UpdatedAt = now
|
|
||||||
|
|
||||||
// Save node with background context (ignore errors)
|
|
||||||
_ = m.driver.SaveNode(context.Background(), m.traceID, node)
|
|
||||||
|
|
||||||
// Broadcast cancelled event
|
|
||||||
m.addUpdateAndBroadcast(&types.TraceUpdate{
|
|
||||||
Type: types.UpdateTypeNodeFailed,
|
|
||||||
TraceID: m.traceID,
|
|
||||||
NodeID: node.ID,
|
|
||||||
Timestamp: now,
|
|
||||||
Data: &types.NodeFailedData{
|
|
||||||
NodeID: node.ID,
|
|
||||||
Status: types.CompleteStatusCancelled,
|
|
||||||
EndTime: now,
|
|
||||||
Duration: now - node.StartTime, // Already in milliseconds
|
|
||||||
Error: "context cancelled",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update trace status
|
|
||||||
m.stateSetTraceStatus(types.TraceStatusCancelled)
|
|
||||||
|
|
||||||
// Calculate total duration
|
|
||||||
totalDuration := int64(0)
|
|
||||||
rootNode := m.stateGetRoot()
|
|
||||||
if rootNode != nil && rootNode.CreatedAt > 0 {
|
|
||||||
totalDuration = now - rootNode.CreatedAt // Already in milliseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast trace cancelled event (this will be processed even after state worker starts draining)
|
|
||||||
m.addUpdateAndBroadcast(&types.TraceUpdate{
|
|
||||||
Type: types.UpdateTypeComplete,
|
|
||||||
TraceID: m.traceID,
|
|
||||||
Timestamp: now,
|
|
||||||
Data: &types.TraceCompleteData{
|
|
||||||
TraceID: m.traceID,
|
|
||||||
Status: types.TraceStatusCancelled,
|
|
||||||
TotalDuration: totalDuration,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// newNode creates a node instance that broadcasts events (for external use)
|
|
||||||
func (m *manager) newNode(data *types.TraceNode) types.Node {
|
|
||||||
return &node{
|
|
||||||
manager: m,
|
|
||||||
data: data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add creates next sequential node - auto-joins if currently in parallel state
|
// Add creates next sequential node - auto-joins if currently in parallel state
|
||||||
func (m *manager) Add(input types.TraceInput, option types.TraceNodeOption) (types.Node, error) {
|
func (m *manager) Add(input types.TraceInput, option types.TraceNodeOption) (types.Node, error) {
|
||||||
if err := m.checkContext(); err != nil {
|
if err := m.checkContext(); err != nil {
|
||||||
|
|
@ -186,7 +115,7 @@ func (m *manager) Add(input types.TraceInput, option types.TraceNodeOption) (typ
|
||||||
// Create root node
|
// Create root node
|
||||||
rootNode = &types.TraceNode{
|
rootNode = &types.TraceNode{
|
||||||
ID: genNodeID(),
|
ID: genNodeID(),
|
||||||
ParentID: "",
|
ParentIDs: []string{}, // Root has no parents
|
||||||
Children: []*types.TraceNode{},
|
Children: []*types.TraceNode{},
|
||||||
TraceNodeOption: option,
|
TraceNodeOption: option,
|
||||||
Status: types.StatusRunning,
|
Status: types.StatusRunning,
|
||||||
|
|
@ -222,33 +151,32 @@ func (m *manager) Add(input types.TraceInput, option types.TraceNodeOption) (typ
|
||||||
// Get current nodes
|
// Get current nodes
|
||||||
currentNodes := m.stateGetCurrentNodes()
|
currentNodes := m.stateGetCurrentNodes()
|
||||||
|
|
||||||
var parentNode *types.TraceNode
|
// Auto-complete parent nodes if enabled (default: true when nil)
|
||||||
if len(currentNodes) > 1 {
|
autoCompleteParent := option.AutoCompleteParent == nil || *option.AutoCompleteParent
|
||||||
// Auto-join: create join node
|
if autoCompleteParent {
|
||||||
parentNode = &types.TraceNode{
|
for _, current := range currentNodes {
|
||||||
ID: genNodeID(),
|
// Only auto-complete running or pending nodes
|
||||||
ParentID: currentNodes[0].ParentID,
|
if current.Status == types.StatusRunning || current.Status == types.StatusPending {
|
||||||
Children: []*types.TraceNode{},
|
// Use node.Complete() method to properly complete the node with broadcast
|
||||||
Status: types.StatusCompleted,
|
currentNodeInterface := &node{manager: m, data: current}
|
||||||
CreatedAt: now,
|
if err := currentNodeInterface.Complete(); err != nil {
|
||||||
StartTime: now,
|
// Log error but don't fail the operation
|
||||||
EndTime: now,
|
m.Error("Failed to auto-complete parent node %s: %v", current.ID, err)
|
||||||
UpdatedAt: now,
|
}
|
||||||
TraceNodeOption: types.TraceNodeOption{Label: "Join", Icon: "join"},
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save join node
|
// Collect parent IDs from all current nodes (supports implicit join)
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, parentNode); err != nil {
|
parentIDs := make([]string, 0, len(currentNodes))
|
||||||
return nil, err
|
for _, current := range currentNodes {
|
||||||
}
|
parentIDs = append(parentIDs, current.ID)
|
||||||
} else {
|
|
||||||
parentNode = currentNodes[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new node
|
// Create new node with multiple parents (implicit join)
|
||||||
newNodeData := &types.TraceNode{
|
newNodeData := &types.TraceNode{
|
||||||
ID: genNodeID(),
|
ID: genNodeID(),
|
||||||
ParentID: parentNode.ID,
|
ParentIDs: parentIDs, // Multiple parents for implicit join
|
||||||
Children: []*types.TraceNode{},
|
Children: []*types.TraceNode{},
|
||||||
TraceNodeOption: option,
|
TraceNodeOption: option,
|
||||||
Status: types.StatusRunning,
|
Status: types.StatusRunning,
|
||||||
|
|
@ -258,14 +186,17 @@ func (m *manager) Add(input types.TraceInput, option types.TraceNodeOption) (typ
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update parent's children
|
// Add to each parent's children
|
||||||
parentNode.Children = append(parentNode.Children, newNodeData)
|
for _, parent := range currentNodes {
|
||||||
|
parent.Children = append(parent.Children, newNodeData)
|
||||||
// Save nodes
|
if err := m.driver.SaveNode(m.ctx, m.traceID, parent); err != nil {
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, newNodeData); err != nil {
|
// Log error but continue
|
||||||
return nil, err
|
m.Error("Failed to update parent node %s: %v", parent.ID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, parentNode); err != nil {
|
|
||||||
|
// Save new node
|
||||||
|
if err := m.driver.SaveNode(m.ctx, m.traceID, newNodeData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,16 +234,40 @@ func (m *manager) Parallel(parallelInputs []types.TraceParallelInput) ([]types.N
|
||||||
|
|
||||||
// Get current nodes
|
// Get current nodes
|
||||||
currentNodes := m.stateGetCurrentNodes()
|
currentNodes := m.stateGetCurrentNodes()
|
||||||
|
|
||||||
|
// Auto-complete parent node if any option has AutoCompleteParent enabled (default: true when nil)
|
||||||
|
shouldAutoComplete := false
|
||||||
|
for _, input := range parallelInputs {
|
||||||
|
if input.Option.AutoCompleteParent == nil || *input.Option.AutoCompleteParent {
|
||||||
|
shouldAutoComplete = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldAutoComplete {
|
||||||
|
for _, current := range currentNodes {
|
||||||
|
// Only auto-complete running or pending nodes
|
||||||
|
if current.Status == types.StatusRunning || current.Status == types.StatusPending {
|
||||||
|
// Use node.Complete() method to properly complete the node with broadcast
|
||||||
|
currentNodeInterface := &node{manager: m, data: current}
|
||||||
|
if err := currentNodeInterface.Complete(); err != nil {
|
||||||
|
// Log error but don't fail the operation
|
||||||
|
m.Error("Failed to auto-complete parent node %s: %v", current.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
parentNode := currentNodes[0]
|
parentNode := currentNodes[0]
|
||||||
|
|
||||||
nodeData := make([]*types.TraceNode, 0, len(parallelInputs))
|
nodeData := make([]*types.TraceNode, 0, len(parallelInputs))
|
||||||
nodeInterfaces := make([]types.Node, 0, len(parallelInputs))
|
nodeInterfaces := make([]types.Node, 0, len(parallelInputs))
|
||||||
|
|
||||||
// Create multiple child nodes
|
// Create multiple child nodes with single parent
|
||||||
for _, input := range parallelInputs {
|
for _, input := range parallelInputs {
|
||||||
data := &types.TraceNode{
|
data := &types.TraceNode{
|
||||||
ID: genNodeID(),
|
ID: genNodeID(),
|
||||||
ParentID: parentNode.ID,
|
ParentIDs: []string{parentNode.ID}, // Single parent for parallel branches
|
||||||
Children: []*types.TraceNode{},
|
Children: []*types.TraceNode{},
|
||||||
TraceNodeOption: input.Option,
|
TraceNodeOption: input.Option,
|
||||||
Status: types.StatusRunning,
|
Status: types.StatusRunning,
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ func (n *node) Add(input types.TraceInput, option types.TraceNodeOption) (types.
|
||||||
// Create child node data
|
// Create child node data
|
||||||
childNodeData := &types.TraceNode{
|
childNodeData := &types.TraceNode{
|
||||||
ID: genNodeID(),
|
ID: genNodeID(),
|
||||||
ParentID: n.data.ID,
|
ParentIDs: []string{n.data.ID}, // Single parent
|
||||||
Children: []*types.TraceNode{},
|
Children: []*types.TraceNode{},
|
||||||
TraceNodeOption: option,
|
TraceNodeOption: option,
|
||||||
Status: types.StatusRunning,
|
Status: types.StatusRunning,
|
||||||
|
|
@ -108,7 +108,7 @@ func (n *node) Parallel(parallelInputs []types.TraceParallelInput) ([]types.Node
|
||||||
for _, input := range parallelInputs {
|
for _, input := range parallelInputs {
|
||||||
childNodeData := &types.TraceNode{
|
childNodeData := &types.TraceNode{
|
||||||
ID: genNodeID(),
|
ID: genNodeID(),
|
||||||
ParentID: n.data.ID,
|
ParentIDs: []string{n.data.ID}, // Single parent for parallel branches
|
||||||
Children: []*types.TraceNode{},
|
Children: []*types.TraceNode{},
|
||||||
TraceNodeOption: input.Option,
|
TraceNodeOption: input.Option,
|
||||||
Status: types.StatusRunning,
|
Status: types.StatusRunning,
|
||||||
|
|
@ -143,10 +143,18 @@ func (n *node) Parallel(parallelInputs []types.TraceParallelInput) ([]types.Node
|
||||||
func (n *node) Join(nodes []*types.TraceNode, input types.TraceInput, option types.TraceNodeOption) (types.Node, error) {
|
func (n *node) Join(nodes []*types.TraceNode, input types.TraceInput, option types.TraceNodeOption) (types.Node, error) {
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
// Create join node data
|
// Collect parent IDs from all nodes
|
||||||
|
parentIDs := make([]string, 0, len(nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node != nil {
|
||||||
|
parentIDs = append(parentIDs, node.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create join node data with multiple parents
|
||||||
joinNodeData := &types.TraceNode{
|
joinNodeData := &types.TraceNode{
|
||||||
ID: genNodeID(),
|
ID: genNodeID(),
|
||||||
ParentID: n.data.ID,
|
ParentIDs: parentIDs, // Multiple parents for explicit join
|
||||||
Children: []*types.TraceNode{},
|
Children: []*types.TraceNode{},
|
||||||
TraceNodeOption: option,
|
TraceNodeOption: option,
|
||||||
Status: types.StatusRunning,
|
Status: types.StatusRunning,
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,10 @@ import (
|
||||||
// Only stores IDs of children instead of full child nodes
|
// Only stores IDs of children instead of full child nodes
|
||||||
type persistNode struct {
|
type persistNode struct {
|
||||||
ID string `json:"ID"`
|
ID string `json:"ID"`
|
||||||
ParentID string `json:"ParentID"`
|
ParentIDs []string `json:"ParentIDs,omitempty"`
|
||||||
ChildrenIDs []string `json:"ChildrenIDs,omitempty"`
|
ChildrenIDs []string `json:"ChildrenIDs,omitempty"`
|
||||||
Label string `json:"Label,omitempty"`
|
Label string `json:"Label,omitempty"`
|
||||||
|
Type string `json:"Type,omitempty"`
|
||||||
Icon string `json:"Icon,omitempty"`
|
Icon string `json:"Icon,omitempty"`
|
||||||
Description string `json:"Description,omitempty"`
|
Description string `json:"Description,omitempty"`
|
||||||
Metadata map[string]any `json:"Metadata,omitempty"`
|
Metadata map[string]any `json:"Metadata,omitempty"`
|
||||||
|
|
@ -50,9 +51,10 @@ func toPersistNode(node *types.TraceNode) *persistNode {
|
||||||
|
|
||||||
return &persistNode{
|
return &persistNode{
|
||||||
ID: node.ID,
|
ID: node.ID,
|
||||||
ParentID: node.ParentID,
|
ParentIDs: node.ParentIDs,
|
||||||
ChildrenIDs: childrenIDs,
|
ChildrenIDs: childrenIDs,
|
||||||
Label: node.Label,
|
Label: node.Label,
|
||||||
|
Type: node.Type,
|
||||||
Icon: node.Icon,
|
Icon: node.Icon,
|
||||||
Description: node.Description,
|
Description: node.Description,
|
||||||
Metadata: node.Metadata,
|
Metadata: node.Metadata,
|
||||||
|
|
@ -73,11 +75,12 @@ func fromPersistNode(pn *persistNode) *types.TraceNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &types.TraceNode{
|
return &types.TraceNode{
|
||||||
ID: pn.ID,
|
ID: pn.ID,
|
||||||
ParentID: pn.ParentID,
|
ParentIDs: pn.ParentIDs,
|
||||||
Children: nil, // Children will be loaded separately if needed
|
Children: nil, // Children will be loaded separately if needed
|
||||||
TraceNodeOption: types.TraceNodeOption{
|
TraceNodeOption: types.TraceNodeOption{
|
||||||
Label: pn.Label,
|
Label: pn.Label,
|
||||||
|
Type: pn.Type,
|
||||||
Icon: pn.Icon,
|
Icon: pn.Icon,
|
||||||
Description: pn.Description,
|
Description: pn.Description,
|
||||||
Metadata: pn.Metadata,
|
Metadata: pn.Metadata,
|
||||||
|
|
@ -253,7 +256,7 @@ func (d *Driver) LoadTrace(ctx context.Context, traceID string) (*types.TraceNod
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find root node ID (node with empty ParentID) by checking each node
|
// Find root node ID (node with empty ParentIDs) by checking each node
|
||||||
var rootNodeID string
|
var rootNodeID string
|
||||||
for _, key := range nodeKeys {
|
for _, key := range nodeKeys {
|
||||||
// Extract node ID from key (format: prefix:traceID:nodes:nodeID)
|
// Extract node ID from key (format: prefix:traceID:nodes:nodeID)
|
||||||
|
|
@ -279,7 +282,7 @@ func (d *Driver) LoadTrace(ctx context.Context, traceID string) (*types.TraceNod
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if pn.ParentID == "" {
|
if len(pn.ParentIDs) == 0 {
|
||||||
rootNodeID = nodeID
|
rootNodeID = nodeID
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
473
trace/trace_autocomplete_test.go
Normal file
473
trace/trace_autocomplete_test.go
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
package trace_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/yaoapp/yao/trace"
|
||||||
|
"github.com/yaoapp/yao/trace/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAutoCompleteParentDefault tests the default behavior where parent nodes are auto-completed
|
||||||
|
func TestAutoCompleteParentDefault(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer trace.Release(traceID)
|
||||||
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
|
// Add first node
|
||||||
|
node1, err := manager.Add("step1", types.TraceNodeOption{
|
||||||
|
Label: "Step 1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify node1 is running
|
||||||
|
currentNodes, err := manager.GetCurrentNodes()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, currentNodes, 1)
|
||||||
|
assert.Equal(t, types.StatusRunning, currentNodes[0].Status)
|
||||||
|
|
||||||
|
// Add second node - node1 should be auto-completed
|
||||||
|
node2, err := manager.Add("step2", types.TraceNodeOption{
|
||||||
|
Label: "Step 2",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify node1 is now completed
|
||||||
|
node1Data, err := manager.GetNodeByID(node1.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusCompleted, node1Data.Status)
|
||||||
|
|
||||||
|
// Verify node2 is running
|
||||||
|
currentNodes, err = manager.GetCurrentNodes()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, currentNodes, 1)
|
||||||
|
assert.Equal(t, node2.ID(), currentNodes[0].ID)
|
||||||
|
assert.Equal(t, types.StatusRunning, currentNodes[0].Status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoCompleteParentDisabled tests disabling auto-complete behavior
|
||||||
|
func TestAutoCompleteParentDisabled(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer trace.Release(traceID)
|
||||||
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
|
// Add first node
|
||||||
|
node1, err := manager.Add("step1", types.TraceNodeOption{
|
||||||
|
Label: "Step 1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Add second node with auto-complete disabled
|
||||||
|
falseVal := false
|
||||||
|
node2, err := manager.Add("step2", types.TraceNodeOption{
|
||||||
|
Label: "Step 2",
|
||||||
|
AutoCompleteParent: &falseVal,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify node1 is still running (not auto-completed)
|
||||||
|
node1Data, err := manager.GetNodeByID(node1.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusRunning, node1Data.Status)
|
||||||
|
|
||||||
|
// Verify node2 is running
|
||||||
|
node2Data, err := manager.GetNodeByID(node2.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusRunning, node2Data.Status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoCompleteParentParallel tests auto-complete with parallel nodes
|
||||||
|
func TestAutoCompleteParentParallel(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer trace.Release(traceID)
|
||||||
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
|
// Add root node
|
||||||
|
rootNode, err := manager.Add("root", types.TraceNodeOption{
|
||||||
|
Label: "Root",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create parallel nodes
|
||||||
|
parallelNodes, err := manager.Parallel([]types.TraceParallelInput{
|
||||||
|
{Input: "task1", Option: types.TraceNodeOption{Label: "Task 1"}},
|
||||||
|
{Input: "task2", Option: types.TraceNodeOption{Label: "Task 2"}},
|
||||||
|
{Input: "task3", Option: types.TraceNodeOption{Label: "Task 3"}},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, parallelNodes, 3)
|
||||||
|
|
||||||
|
// Root node should be auto-completed
|
||||||
|
rootData, err := manager.GetNodeByID(rootNode.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusCompleted, rootData.Status)
|
||||||
|
|
||||||
|
// All parallel nodes should be running
|
||||||
|
for _, node := range parallelNodes {
|
||||||
|
nodeData, err := manager.GetNodeByID(node.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusRunning, nodeData.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a merge node - all parallel nodes should be auto-completed
|
||||||
|
mergeNode, err := manager.Add("merge", types.TraceNodeOption{
|
||||||
|
Label: "Merge",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// All parallel nodes should now be completed
|
||||||
|
for _, node := range parallelNodes {
|
||||||
|
nodeData, err := manager.GetNodeByID(node.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusCompleted, nodeData.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge node should be running
|
||||||
|
mergeData, err := manager.GetNodeByID(mergeNode.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusRunning, mergeData.Status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoCompleteParentConcurrent tests auto-complete with concurrent operations
|
||||||
|
func TestAutoCompleteParentConcurrent(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer trace.Release(traceID)
|
||||||
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
|
// Add root node
|
||||||
|
_, err = manager.Add("root", types.TraceNodeOption{Label: "Root"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create parallel nodes
|
||||||
|
parallelNodes, err := manager.Parallel([]types.TraceParallelInput{
|
||||||
|
{Input: "task1", Option: types.TraceNodeOption{Label: "Task 1"}},
|
||||||
|
{Input: "task2", Option: types.TraceNodeOption{Label: "Task 2"}},
|
||||||
|
{Input: "task3", Option: types.TraceNodeOption{Label: "Task 3"}},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Complete all parallel nodes (simulating concurrent work)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, pNode := range parallelNodes {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(node types.Node) {
|
||||||
|
defer wg.Done()
|
||||||
|
// Small delay to simulate real work
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
err := node.Complete(map[string]any{"done": true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}(pNode)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// All parallel nodes should be completed
|
||||||
|
for _, node := range parallelNodes {
|
||||||
|
nodeData, err := manager.GetNodeByID(node.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusCompleted, nodeData.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a merge node - should work correctly with all parents completed
|
||||||
|
mergeNode, err := manager.Add("merge", types.TraceNodeOption{
|
||||||
|
Label: "Merge Results",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify merge node is running
|
||||||
|
mergeData, err := manager.GetNodeByID(mergeNode.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusRunning, mergeData.Status)
|
||||||
|
|
||||||
|
// Verify merge node has all parallel nodes as parents
|
||||||
|
assert.Len(t, mergeData.ParentIDs, 3)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoCompleteParentWithFailedNode tests that failed nodes are not auto-completed
|
||||||
|
func TestAutoCompleteParentWithFailedNode(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer trace.Release(traceID)
|
||||||
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
|
// Add first node
|
||||||
|
node1, err := manager.Add("step1", types.TraceNodeOption{
|
||||||
|
Label: "Step 1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Fail node1
|
||||||
|
err = node1.Fail(assert.AnError)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify node1 is failed
|
||||||
|
node1Data, err := manager.GetNodeByID(node1.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusFailed, node1Data.Status)
|
||||||
|
|
||||||
|
// Add second node with auto-complete enabled
|
||||||
|
node2, err := manager.Add("step2", types.TraceNodeOption{
|
||||||
|
Label: "Step 2",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// node1 should still be failed (not auto-completed to success)
|
||||||
|
node1Data, err = manager.GetNodeByID(node1.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusFailed, node1Data.Status)
|
||||||
|
|
||||||
|
// node2 should be running
|
||||||
|
node2Data, err := manager.GetNodeByID(node2.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusRunning, node2Data.Status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoCompleteParentWithCompletedNode tests that already completed nodes are not affected
|
||||||
|
func TestAutoCompleteParentWithCompletedNode(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer trace.Release(traceID)
|
||||||
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
|
// Add first node
|
||||||
|
node1, err := manager.Add("step1", types.TraceNodeOption{
|
||||||
|
Label: "Step 1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Complete node1 manually with output
|
||||||
|
err = node1.Complete(map[string]any{"result": "manual"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify node1 is completed with output
|
||||||
|
node1Data, err := manager.GetNodeByID(node1.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusCompleted, node1Data.Status)
|
||||||
|
assert.Equal(t, map[string]any{"result": "manual"}, node1Data.Output)
|
||||||
|
|
||||||
|
// Add second node with auto-complete enabled
|
||||||
|
node2, err := manager.Add("step2", types.TraceNodeOption{
|
||||||
|
Label: "Step 2",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// node1 should still be completed with same output
|
||||||
|
node1Data, err = manager.GetNodeByID(node1.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusCompleted, node1Data.Status)
|
||||||
|
assert.Equal(t, map[string]any{"result": "manual"}, node1Data.Output)
|
||||||
|
|
||||||
|
// node2 should be running
|
||||||
|
node2Data, err := manager.GetNodeByID(node2.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusRunning, node2Data.Status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoCompleteParentEvents tests that auto-complete generates proper events
|
||||||
|
func TestAutoCompleteParentEvents(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer trace.Release(traceID)
|
||||||
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
updates, err := manager.Subscribe()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Collect updates in background
|
||||||
|
var receivedUpdates []*types.TraceUpdate
|
||||||
|
var updatesMu sync.Mutex
|
||||||
|
done := make(chan bool)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
timeout := time.After(2 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case update, ok := <-updates:
|
||||||
|
if !ok {
|
||||||
|
done <- true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updatesMu.Lock()
|
||||||
|
receivedUpdates = append(receivedUpdates, update)
|
||||||
|
updatesMu.Unlock()
|
||||||
|
case <-timeout:
|
||||||
|
done <- true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Add first node
|
||||||
|
node1, err := manager.Add("step1", types.TraceNodeOption{
|
||||||
|
Label: "Step 1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Small delay
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Add second node - should trigger auto-complete of node1
|
||||||
|
_, err = manager.Add("step2", types.TraceNodeOption{
|
||||||
|
Label: "Step 2",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait a bit for events to be processed
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Wait for goroutine to finish (with timeout)
|
||||||
|
<-done
|
||||||
|
|
||||||
|
// Verify we received auto-complete event for node1
|
||||||
|
updatesMu.Lock()
|
||||||
|
defer updatesMu.Unlock()
|
||||||
|
|
||||||
|
var foundNode1Start bool
|
||||||
|
var foundNode1Complete bool
|
||||||
|
var foundNode2Start bool
|
||||||
|
|
||||||
|
for _, update := range receivedUpdates {
|
||||||
|
switch update.Type {
|
||||||
|
case types.UpdateTypeNodeStart:
|
||||||
|
if data, ok := update.Data.(*types.NodeStartData); ok && data.Node != nil {
|
||||||
|
if data.Node.ID == node1.ID() {
|
||||||
|
foundNode1Start = true
|
||||||
|
} else if data.Node.Label == "Step 2" {
|
||||||
|
foundNode2Start = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case types.UpdateTypeNodeComplete:
|
||||||
|
if data, ok := update.Data.(*types.NodeCompleteData); ok {
|
||||||
|
if data.NodeID == node1.ID() {
|
||||||
|
foundNode1Complete = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, foundNode1Start, "Should receive node_start event for node1")
|
||||||
|
assert.True(t, foundNode1Complete, "Should receive node_complete event for node1 (auto-completed)")
|
||||||
|
assert.True(t, foundNode2Start, "Should receive node_start event for node2")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoCompleteParentSequentialChain tests a long sequential chain
|
||||||
|
func TestAutoCompleteParentSequentialChain(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer trace.Release(traceID)
|
||||||
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
|
// Create a chain of 10 nodes
|
||||||
|
nodeCount := 10
|
||||||
|
nodes := make([]types.Node, nodeCount)
|
||||||
|
|
||||||
|
for i := 0; i < nodeCount; i++ {
|
||||||
|
node, err := manager.Add("input", types.TraceNodeOption{
|
||||||
|
Label: "Step " + string(rune('0'+i)),
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
nodes[i] = node
|
||||||
|
|
||||||
|
// All previous nodes should be completed
|
||||||
|
for j := 0; j < i; j++ {
|
||||||
|
nodeData, err := manager.GetNodeByID(nodes[j].ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusCompleted, nodeData.Status,
|
||||||
|
"Node %d should be completed when node %d is added", j, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current node should be running
|
||||||
|
currentData, err := manager.GetNodeByID(node.ID())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, types.StatusRunning, currentData.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all nodes
|
||||||
|
allNodes, err := manager.GetAllNodes()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, allNodes, nodeCount)
|
||||||
|
|
||||||
|
// All except the last should be completed
|
||||||
|
completedCount := 0
|
||||||
|
runningCount := 0
|
||||||
|
for _, nodeData := range allNodes {
|
||||||
|
if nodeData.Status == types.StatusCompleted {
|
||||||
|
completedCount++
|
||||||
|
} else if nodeData.Status == types.StatusRunning {
|
||||||
|
runningCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, nodeCount-1, completedCount, "Should have %d completed nodes", nodeCount-1)
|
||||||
|
assert.Equal(t, 1, runningCount, "Should have 1 running node")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,7 +47,11 @@ func TestTraceNew(t *testing.T) {
|
||||||
assert.Nil(t, root)
|
assert.Nil(t, root)
|
||||||
|
|
||||||
// Add first node - this should become the root
|
// Add first node - this should become the root
|
||||||
node, err := manager.Add("test input", types.TraceNodeOption{Label: "First Node", Icon: "test"})
|
node, err := manager.Add("test input", types.TraceNodeOption{
|
||||||
|
Label: "First Node",
|
||||||
|
Type: "test",
|
||||||
|
Icon: "test",
|
||||||
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, node)
|
assert.NotNil(t, node)
|
||||||
|
|
||||||
|
|
@ -56,6 +60,7 @@ func TestTraceNew(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, root)
|
assert.NotNil(t, root)
|
||||||
assert.Equal(t, "First Node", root.Label)
|
assert.Equal(t, "First Node", root.Label)
|
||||||
|
assert.Equal(t, "test", root.Type)
|
||||||
assert.Equal(t, "test", root.Icon)
|
assert.Equal(t, "test", root.Icon)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -109,10 +114,18 @@ func TestTraceLoadFromStorage(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Add some data
|
// Add some data
|
||||||
_, err = manager.Add("test", types.TraceNodeOption{Label: "Test"})
|
_, err = manager.Add("test", types.TraceNodeOption{
|
||||||
|
Label: "Test",
|
||||||
|
Type: "test_node",
|
||||||
|
Icon: "node",
|
||||||
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
space, err := manager.CreateSpace(types.TraceSpaceOption{Label: "Test Space"})
|
space, err := manager.CreateSpace(types.TraceSpaceOption{
|
||||||
|
Label: "Test Space",
|
||||||
|
Type: "test_space",
|
||||||
|
Icon: "space",
|
||||||
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = manager.SetSpaceValue(space.ID, "key", "value")
|
err = manager.SetSpaceValue(space.ID, "key", "value")
|
||||||
|
|
@ -225,7 +238,10 @@ func TestContextCancellation(t *testing.T) {
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
// Operations should fail with context error
|
// Operations should fail with context error
|
||||||
_, err = manager.Add("test", types.TraceNodeOption{Label: "Test"})
|
_, err = manager.Add("test", types.TraceNodeOption{
|
||||||
|
Label: "Test",
|
||||||
|
Type: "test",
|
||||||
|
})
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,15 +37,18 @@ const (
|
||||||
|
|
||||||
// TraceNodeOption defines options for creating a node
|
// TraceNodeOption defines options for creating a node
|
||||||
type TraceNodeOption struct {
|
type TraceNodeOption struct {
|
||||||
Label string `json:"label"` // Display label in UI
|
Label string `json:"label"` // Display label in UI
|
||||||
Icon string `json:"icon"` // Icon identifier
|
Type string `json:"type"` // Node type identifier
|
||||||
Description string `json:"description"` // Node description
|
Icon string `json:"icon"` // Icon identifier
|
||||||
Metadata map[string]any `json:"metadata,omitempty"` // Additional metadata
|
Description string `json:"description"` // Node description
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"` // Additional metadata
|
||||||
|
AutoCompleteParent *bool `json:"auto_complete_parent,omitempty"` // Auto-complete parent node(s) when this node is created (nil = default true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TraceSpaceOption defines options for creating a space
|
// TraceSpaceOption defines options for creating a space
|
||||||
type TraceSpaceOption struct {
|
type TraceSpaceOption struct {
|
||||||
Label string `json:"label"` // Display label in UI
|
Label string `json:"label"` // Display label in UI
|
||||||
|
Type string `json:"type"` // Space type identifier
|
||||||
Icon string `json:"icon"` // Icon identifier
|
Icon string `json:"icon"` // Icon identifier
|
||||||
Description string `json:"description"` // Space description
|
Description string `json:"description"` // Space description
|
||||||
TTL int64 `json:"ttl"` // Time to live in seconds (0 = no expiration) - for display/record only
|
TTL int64 `json:"ttl"` // Time to live in seconds (0 = no expiration) - for display/record only
|
||||||
|
|
@ -54,9 +57,9 @@ type TraceSpaceOption struct {
|
||||||
|
|
||||||
// TraceNode the trace node implementation
|
// TraceNode the trace node implementation
|
||||||
type TraceNode struct {
|
type TraceNode struct {
|
||||||
ID string `json:"id"` // Node ID
|
ID string `json:"id"` // Node ID
|
||||||
ParentID string `json:"parent_id"` // Parent node ID
|
ParentIDs []string `json:"parent_ids"` // Parent node IDs (supports multiple parents for implicit join)
|
||||||
Children []*TraceNode `json:"children"` // Child nodes (for tree structure)
|
Children []*TraceNode `json:"children"` // Child nodes (for tree structure)
|
||||||
TraceNodeOption `json:",inline"` // Embedded option fields (Label, Icon, Description, Metadata)
|
TraceNodeOption `json:",inline"` // Embedded option fields (Label, Icon, Description, Metadata)
|
||||||
Status NodeStatus `json:"status"` // Node status (pending, running, completed, failed, skipped)
|
Status NodeStatus `json:"status"` // Node status (pending, running, completed, failed, skipped)
|
||||||
Input TraceInput `json:"input,omitempty"` // Node input data
|
Input TraceInput `json:"input,omitempty"` // Node input data
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue