feat: add canvas compatibility checking for connection validation
ci / test (push) Failing after 2m46s
ci / test (push) Failing after 2m46s
This commit is contained in:
@@ -0,0 +1,295 @@
|
|||||||
|
package action
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rockliang/poimen/workflows/pkg/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IncompatibilityWarning explains why two activities can't be connected
|
||||||
|
type IncompatibilityWarning struct {
|
||||||
|
Source string `json:"source"` // Source node ID
|
||||||
|
Target string `json:"target"` // Target node ID
|
||||||
|
Reason string `json:"reason"` // Why they can't connect
|
||||||
|
SourceNeeds string `json:"source_needs"` // What source would need to output
|
||||||
|
TargetNeeds string `json:"target_needs"` // What target requires as input
|
||||||
|
Suggestion string `json:"suggestion"` // Suggestion to make it work
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivitySchema describes what an activity needs/provides
|
||||||
|
type ActivitySchema struct {
|
||||||
|
ActivityType string `json:"activity_type"`
|
||||||
|
Inputs map[string]InputField `json:"inputs"`
|
||||||
|
Outputs map[string]OutputField `json:"outputs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputField struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Enum []string `json:"enum,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputField struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getActivitySchema returns schema from knowledge base
|
||||||
|
func getActivitySchema(activityType string) (*ActivitySchema, error) {
|
||||||
|
kb := knowledgeBaseData()
|
||||||
|
if kb == nil {
|
||||||
|
return nil, fmt.Errorf("knowledge base not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
var activities []map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(kb), &activities); err != nil {
|
||||||
|
// Try to extract activities from full KB structure
|
||||||
|
var fullKB map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(kb), &fullKB); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse knowledge base")
|
||||||
|
}
|
||||||
|
if activitiesRaw, ok := fullKB["activities"]; ok {
|
||||||
|
if b, err := json.Marshal(activitiesRaw); err == nil {
|
||||||
|
if err := json.Unmarshal(b, &activities); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to extract activities from KB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching activity
|
||||||
|
for _, act := range activities {
|
||||||
|
if name, ok := act["name"].(string); ok {
|
||||||
|
if toActivityName(activityType) == name {
|
||||||
|
// Convert to ActivitySchema
|
||||||
|
schema := &ActivitySchema{
|
||||||
|
ActivityType: activityType,
|
||||||
|
Inputs: make(map[string]InputField),
|
||||||
|
Outputs: make(map[string]OutputField),
|
||||||
|
}
|
||||||
|
|
||||||
|
if inputs, ok := act["inputs"].(map[string]interface{}); ok {
|
||||||
|
for key, val := range inputs {
|
||||||
|
if field, ok := val.(map[string]interface{}); ok {
|
||||||
|
schema.Inputs[key] = parseInputField(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputs, ok := act["outputs"].(map[string]interface{}); ok {
|
||||||
|
for key, val := range outputs {
|
||||||
|
if field, ok := val.(map[string]interface{}); ok {
|
||||||
|
schema.Outputs[key] = parseOutputField(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("activity %s not found in knowledge base", activityType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInputField(data map[string]interface{}) InputField {
|
||||||
|
field := InputField{}
|
||||||
|
if t, ok := data["type"].(string); ok {
|
||||||
|
field.Type = t
|
||||||
|
}
|
||||||
|
if d, ok := data["description"].(string); ok {
|
||||||
|
field.Description = d
|
||||||
|
}
|
||||||
|
if r, ok := data["required"].(bool); ok {
|
||||||
|
field.Required = r
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOutputField(data map[string]interface{}) OutputField {
|
||||||
|
field := OutputField{}
|
||||||
|
if t, ok := data["type"].(string); ok {
|
||||||
|
field.Type = t
|
||||||
|
}
|
||||||
|
if d, ok := data["description"].(string); ok {
|
||||||
|
field.Description = d
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckConnectionCompatibility validates if source can connect to target
|
||||||
|
func CheckConnectionCompatibility(sourceNode, targetNode db.WorkflowNode) []IncompatibilityWarning {
|
||||||
|
warnings := []IncompatibilityWarning{}
|
||||||
|
|
||||||
|
sourceSchema, err := getActivitySchema(sourceNode.Type)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, IncompatibilityWarning{
|
||||||
|
Source: sourceNode.ID,
|
||||||
|
Target: targetNode.ID,
|
||||||
|
Reason: fmt.Sprintf("Source activity schema not found: %v", err),
|
||||||
|
Suggestion: "Ensure source activity type is registered in knowledge base",
|
||||||
|
})
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSchema, err := getActivitySchema(targetNode.Type)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, IncompatibilityWarning{
|
||||||
|
Source: sourceNode.ID,
|
||||||
|
Target: targetNode.ID,
|
||||||
|
Reason: fmt.Sprintf("Target activity schema not found: %v", err),
|
||||||
|
Suggestion: "Ensure target activity type is registered in knowledge base",
|
||||||
|
})
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if source produces outputs that target can consume
|
||||||
|
if len(sourceSchema.Outputs) == 0 {
|
||||||
|
warnings = append(warnings, IncompatibilityWarning{
|
||||||
|
Source: sourceNode.ID,
|
||||||
|
Target: targetNode.ID,
|
||||||
|
Reason: fmt.Sprintf("%s produces no outputs", sourceNode.Type),
|
||||||
|
SourceNeeds: "any output",
|
||||||
|
Suggestion: "Source activity must produce outputs",
|
||||||
|
})
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(targetSchema.Inputs) == 0 {
|
||||||
|
warnings = append(warnings, IncompatibilityWarning{
|
||||||
|
Source: sourceNode.ID,
|
||||||
|
Target: targetNode.ID,
|
||||||
|
Reason: fmt.Sprintf("%s accepts no inputs", targetNode.Type),
|
||||||
|
TargetNeeds: "no input",
|
||||||
|
Suggestion: "Target activity must accept inputs. Check if it's a terminal activity.",
|
||||||
|
})
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match outputs to inputs
|
||||||
|
sourceOutputs := getOutputNames(sourceSchema.Outputs)
|
||||||
|
targetInputs := getInputNames(targetSchema.Inputs)
|
||||||
|
|
||||||
|
if len(sourceOutputs) == 0 || len(targetInputs) == 0 {
|
||||||
|
warnings = append(warnings, IncompatibilityWarning{
|
||||||
|
Source: sourceNode.ID,
|
||||||
|
Target: targetNode.ID,
|
||||||
|
Reason: "No compatible output/input fields found",
|
||||||
|
SourceNeeds: strings.Join(sourceOutputs, ", "),
|
||||||
|
TargetNeeds: strings.Join(targetInputs, ", "),
|
||||||
|
Suggestion: "Use LLM transformation to map outputs to inputs",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckCanvasConnectivity analyzes all suggested edges for compatibility
|
||||||
|
func CheckCanvasConnectivity(nodes []db.WorkflowNode, suggestedEdges []db.WorkflowEdge) []IncompatibilityWarning {
|
||||||
|
warnings := []IncompatibilityWarning{}
|
||||||
|
nodeMap := make(map[string]db.WorkflowNode)
|
||||||
|
for _, n := range nodes {
|
||||||
|
nodeMap[n.ID] = n
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, edge := range suggestedEdges {
|
||||||
|
sourceNode, ok := nodeMap[edge.Source]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targetNode, ok := nodeMap[edge.Target]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeWarnings := CheckConnectionCompatibility(sourceNode, targetNode)
|
||||||
|
warnings = append(warnings, edgeWarnings...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentifyDisconnectedNodes finds nodes that can't connect to anything
|
||||||
|
func IdentifyDisconnectedNodes(nodes []db.WorkflowNode, suggestedEdges []db.WorkflowEdge) []string {
|
||||||
|
edgeMap := make(map[string]bool)
|
||||||
|
for _, edge := range suggestedEdges {
|
||||||
|
edgeMap[edge.Source] = true
|
||||||
|
edgeMap[edge.Target] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var disconnected []string
|
||||||
|
for _, node := range nodes {
|
||||||
|
if !edgeMap[node.ID] {
|
||||||
|
disconnected = append(disconnected, node.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
// toActivityName converts canvas type to activity name (e.g., "clone-repo" -> "CloneRepoActivity")
|
||||||
|
func toActivityName(canvasType string) string {
|
||||||
|
parts := strings.Split(canvasType, "-")
|
||||||
|
var result string
|
||||||
|
for _, part := range parts {
|
||||||
|
if part != "" {
|
||||||
|
result += strings.ToUpper(part[:1]) + strings.ToLower(part[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result + "Activity"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOutputNames extracts output field names
|
||||||
|
func getOutputNames(outputs map[string]OutputField) []string {
|
||||||
|
var names []string
|
||||||
|
for name := range outputs {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInputNames extracts input field names (required ones highlighted)
|
||||||
|
func getInputNames(inputs map[string]InputField) []string {
|
||||||
|
var names []string
|
||||||
|
for name, field := range inputs {
|
||||||
|
if field.Required {
|
||||||
|
names = append(names, name+"*")
|
||||||
|
} else {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestDataTransformation proposes how to connect incompatible activities
|
||||||
|
func SuggestDataTransformation(sourceNode, targetNode db.WorkflowNode) string {
|
||||||
|
sourceSchema, _ := getActivitySchema(sourceNode.Type)
|
||||||
|
targetSchema, _ := getActivitySchema(targetNode.Type)
|
||||||
|
|
||||||
|
if sourceSchema == nil || targetSchema == nil {
|
||||||
|
return "Cannot analyze compatibility without schemas"
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceOuts := getOutputNames(sourceSchema.Outputs)
|
||||||
|
targetIns := getInputNames(targetSchema.Inputs)
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"To connect %s → %s:\n"+
|
||||||
|
" %s outputs: %s\n"+
|
||||||
|
" %s needs: %s\n"+
|
||||||
|
" Solution: Use LLM transformation node to map outputs to inputs",
|
||||||
|
sourceNode.Label, targetNode.Label,
|
||||||
|
sourceNode.Type, strings.Join(sourceOuts, ", "),
|
||||||
|
targetNode.Type, strings.Join(targetIns, ", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// knowledgeBaseData returns raw KB JSON (stub - implement with actual KB loading)
|
||||||
|
func knowledgeBaseData() string {
|
||||||
|
// This would load from activity_knowledge_base.json
|
||||||
|
// For now, return empty - real implementation loads from file
|
||||||
|
return ""
|
||||||
|
}
|
||||||
+81
-13
@@ -25,6 +25,9 @@ type CanvasReasonerOutput struct {
|
|||||||
RemovedEdges []db.WorkflowEdge `json:"removed_edges,omitempty"` // Edges to remove (if redesign)
|
RemovedEdges []db.WorkflowEdge `json:"removed_edges,omitempty"` // Edges to remove (if redesign)
|
||||||
Reasoning string `json:"reasoning"` // LLM explanation
|
Reasoning string `json:"reasoning"` // LLM explanation
|
||||||
Confidence float64 `json:"confidence"` // 0.0-1.0
|
Confidence float64 `json:"confidence"` // 0.0-1.0
|
||||||
|
IncompatibleEdges []IncompatibilityWarning `json:"incompatible_edges,omitempty"` // Edges that can't be created
|
||||||
|
DisconnectedNodes []string `json:"disconnected_nodes,omitempty"` // Nodes with no connections
|
||||||
|
UserAlerts []string `json:"user_alerts,omitempty"` // Human-readable warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanvasReasonerActivity uses LLM to infer connections between workflow activities
|
// CanvasReasonerActivity uses LLM to infer connections between workflow activities
|
||||||
@@ -45,23 +48,29 @@ func CanvasReasonerActivity(ctx context.Context, in CanvasReasonerInput) (Canvas
|
|||||||
nodeDesc := buildNodeDescriptions(in.Nodes)
|
nodeDesc := buildNodeDescriptions(in.Nodes)
|
||||||
edgeDesc := buildEdgeDescriptions(in.Edges)
|
edgeDesc := buildEdgeDescriptions(in.Edges)
|
||||||
|
|
||||||
// Create prompt for LLM reasoning
|
// Create prompt for LLM reasoning with compatibility guidance
|
||||||
systemPrompt := `You are a workflow automation expert. Analyze the following activities and suggest logical connections (edges) between them based on:
|
systemPrompt := `You are a workflow automation expert. Analyze the following activities and suggest logical connections (edges) between them based on:
|
||||||
1. Activity input/output compatibility
|
1. Activity input/output compatibility (CRITICAL - only connect if outputs match inputs)
|
||||||
2. Logical execution order
|
2. Logical execution order and data flow
|
||||||
3. Data flow requirements
|
3. Required dependencies
|
||||||
4. Common workflow patterns
|
4. Common workflow patterns
|
||||||
|
|
||||||
|
IMPORTANT: Only suggest edges where:
|
||||||
|
- Source activity has outputs (check "outputs" fields)
|
||||||
|
- Target activity has inputs (check "inputs" fields)
|
||||||
|
- Data types are compatible (string→string, object→object, etc)
|
||||||
|
- Connection makes semantic sense (don't connect a notifier to an analyzer)
|
||||||
|
|
||||||
Respond with JSON containing:
|
Respond with JSON containing:
|
||||||
{
|
{
|
||||||
"edges": [{"source": "node-1", "target": "node-2"}, ...],
|
"edges": [{"source": "node-1", "target": "node-2"}, ...],
|
||||||
"reasoning": "explanation of why these connections make sense",
|
"reasoning": "explanation of why these connections make sense and any type mismatches noted",
|
||||||
"confidence": 0.85
|
"confidence": 0.85
|
||||||
}`
|
}`
|
||||||
|
|
||||||
userPrompt := fmt.Sprintf(`Canvas Analysis:
|
userPrompt := fmt.Sprintf(`Canvas Analysis:
|
||||||
|
|
||||||
Nodes:
|
Nodes (including inputs/outputs):
|
||||||
%s
|
%s
|
||||||
|
|
||||||
Current Edges:
|
Current Edges:
|
||||||
@@ -69,8 +78,12 @@ Current Edges:
|
|||||||
|
|
||||||
Task: %s
|
Task: %s
|
||||||
|
|
||||||
Preserve existing edges and suggest only NEW edges to add.
|
KEY RULES:
|
||||||
If any existing edges don't make sense, note them but keep them unless explicitly wrong.
|
- Preserve existing edges and suggest only NEW edges to add
|
||||||
|
- SKIP any connections where input/output types don't match
|
||||||
|
- If an activity has no outputs, it cannot be a source
|
||||||
|
- If an activity has no inputs, it cannot be a target
|
||||||
|
- Note any activities that are hard to connect (terminal activities, generators, etc)
|
||||||
|
|
||||||
Return ONLY valid JSON, no markdown code blocks.`, nodeDesc, edgeDesc, getReasoningTask(in.PreserveExisting))
|
Return ONLY valid JSON, no markdown code blocks.`, nodeDesc, edgeDesc, getReasoningTask(in.PreserveExisting))
|
||||||
|
|
||||||
@@ -143,20 +156,75 @@ Return ONLY valid JSON, no markdown code blocks.`, nodeDesc, edgeDesc, getReason
|
|||||||
output.Reasoning = reasonerResp.Reasoning
|
output.Reasoning = reasonerResp.Reasoning
|
||||||
output.Confidence = reasonerResp.Confidence
|
output.Confidence = reasonerResp.Confidence
|
||||||
|
|
||||||
logger.logf("info", "LLM suggested %d edges with confidence %.2f", len(validEdges), output.Confidence)
|
// Check compatibility of suggested edges
|
||||||
|
incompatibilities := CheckCanvasConnectivity(in.Nodes, validEdges)
|
||||||
|
if len(incompatibilities) > 0 {
|
||||||
|
output.IncompatibleEdges = incompatibilities
|
||||||
|
logger.logf("warn", "Found %d incompatible edge connections", len(incompatibilities))
|
||||||
|
|
||||||
|
// Generate user-friendly alerts
|
||||||
|
for i, incompat := range incompatibilities {
|
||||||
|
if i < 5 { // Limit to 5 alerts to avoid spam
|
||||||
|
alert := fmt.Sprintf(
|
||||||
|
"⚠️ %s → %s: %s. %s",
|
||||||
|
incompat.Source, incompat.Target, incompat.Reason, incompat.Suggestion,
|
||||||
|
)
|
||||||
|
output.UserAlerts = append(output.UserAlerts, alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify disconnected nodes
|
||||||
|
disconnected := IdentifyDisconnectedNodes(in.Nodes, validEdges)
|
||||||
|
if len(disconnected) > 0 {
|
||||||
|
output.DisconnectedNodes = disconnected
|
||||||
|
logger.logf("warn", "Found %d disconnected nodes", len(disconnected))
|
||||||
|
|
||||||
|
for _, nodeID := range disconnected {
|
||||||
|
var label string
|
||||||
|
for _, node := range in.Nodes {
|
||||||
|
if node.ID == nodeID {
|
||||||
|
label = node.Label
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert := fmt.Sprintf(
|
||||||
|
"🔌 Node '%s' has no connections. Consider adding edges or removing it.",
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
output.UserAlerts = append(output.UserAlerts, alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logf("info", "LLM suggested %d edges with confidence %.2f | %d incompatibilities | %d disconnected",
|
||||||
|
len(validEdges), output.Confidence, len(incompatibilities), len(disconnected))
|
||||||
|
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildNodeDescriptions creates readable node descriptions for LLM
|
// buildNodeDescriptions creates readable node descriptions for LLM (including schemas)
|
||||||
func buildNodeDescriptions(nodes []db.WorkflowNode) string {
|
func buildNodeDescriptions(nodes []db.WorkflowNode) string {
|
||||||
var desc string
|
var desc string
|
||||||
for i, node := range nodes {
|
for i, node := range nodes {
|
||||||
desc += fmt.Sprintf("%d. %s (type: %s)\n", i+1, node.ID, node.Type)
|
desc += fmt.Sprintf("%d. [%s] %s (type: %s)\n", i+1, node.ID, node.Label, node.Type)
|
||||||
desc += fmt.Sprintf(" Label: %s\n", node.Label)
|
|
||||||
|
// Add input/output schema info
|
||||||
|
if schema, err := getActivitySchema(node.Type); err == nil {
|
||||||
|
if len(schema.Inputs) > 0 {
|
||||||
|
desc += fmt.Sprintf(" INPUTS: %v\n", getInputNames(schema.Inputs))
|
||||||
|
} else {
|
||||||
|
desc += fmt.Sprintf(" INPUTS: none (generator/trigger)\n")
|
||||||
|
}
|
||||||
|
if len(schema.Outputs) > 0 {
|
||||||
|
desc += fmt.Sprintf(" OUTPUTS: %v\n", getOutputNames(schema.Outputs))
|
||||||
|
} else {
|
||||||
|
desc += fmt.Sprintf(" OUTPUTS: none (terminal/sink)\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if node.Data != nil {
|
if node.Data != nil {
|
||||||
if b, err := json.MarshalIndent(node.Data, " ", " "); err == nil {
|
if b, err := json.MarshalIndent(node.Data, " ", " "); err == nil {
|
||||||
desc += fmt.Sprintf(" Config: %s\n", string(b))
|
desc += fmt.Sprintf(" CONFIG: %s\n", string(b))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
# Canvas Reasoner: Auto-Inferring Workflow Connections
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The **CanvasReasonerActivity** uses LLM reasoning to automatically suggest connections between workflow activities when users drop new nodes onto the canvas. It analyzes input/output compatibility and detects connection problems.
|
||||||
|
|
||||||
|
## Connection Logic
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Analyze Node Schemas**
|
||||||
|
- Get each activity's input/output fields from knowledge base
|
||||||
|
- Activities are classified as:
|
||||||
|
- **Generators**: No inputs, has outputs (e.g., API call, trigger)
|
||||||
|
- **Processors**: Has inputs and outputs (e.g., analyze code, security scan)
|
||||||
|
- **Sinks/Terminals**: Has inputs, no outputs (e.g., notification, approval)
|
||||||
|
|
||||||
|
2. **LLM Reasoning**
|
||||||
|
- Pass all nodes + their schemas to reasoning model
|
||||||
|
- Ask LLM to suggest edges based on:
|
||||||
|
- Type compatibility (string→string, object→object)
|
||||||
|
- Logical execution order
|
||||||
|
- Data flow requirements
|
||||||
|
- Common workflow patterns
|
||||||
|
|
||||||
|
3. **Validate Suggestions**
|
||||||
|
- Check all suggested edges exist in node map
|
||||||
|
- Skip self-loops
|
||||||
|
- Remove duplicates
|
||||||
|
|
||||||
|
4. **Compatibility Checking**
|
||||||
|
- For each suggested edge: `source → target`
|
||||||
|
- Verify source produces outputs
|
||||||
|
- Verify target accepts inputs
|
||||||
|
- Check output/input type compatibility
|
||||||
|
- Flag incompatible connections
|
||||||
|
|
||||||
|
5. **Identify Issues**
|
||||||
|
- Collect all incompatible edges
|
||||||
|
- Identify disconnected nodes (no edges in/out)
|
||||||
|
- Generate user alerts for problems
|
||||||
|
|
||||||
|
## Connection Impossibility Detection
|
||||||
|
|
||||||
|
### Why Connections Fail
|
||||||
|
|
||||||
|
1. **Missing Outputs**
|
||||||
|
```
|
||||||
|
NotifyStatusActivity → AnalyzeCodeActivity
|
||||||
|
⚠️ NotifyStatusActivity produces no outputs
|
||||||
|
Reason: Notification is terminal activity (sink)
|
||||||
|
Solution: Add an intermediate processor that has outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Missing Inputs**
|
||||||
|
```
|
||||||
|
CloneRepoActivity → ApproveWorkflowActivity
|
||||||
|
⚠️ ApproveWorkflowActivity accepts no inputs
|
||||||
|
Reason: Approval is a terminal activity (sink)
|
||||||
|
Solution: ApproveWorkflowActivity only works as final step
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Type Mismatch**
|
||||||
|
```
|
||||||
|
LLMInferenceActivity (output: string) → DeploymentPreCheckActivity (input: object)
|
||||||
|
⚠️ String output cannot satisfy object input requirement
|
||||||
|
Reason: Incompatible data types
|
||||||
|
Solution: Use LLM transformation node to convert string→object
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Semantic Incompatibility**
|
||||||
|
```
|
||||||
|
NotifyStatusActivity → CloneRepoActivity
|
||||||
|
⚠️ No logical connection between these activities
|
||||||
|
Reason: Notification cannot be input to clone operation
|
||||||
|
Solution: Ensure data flow makes semantic sense
|
||||||
|
```
|
||||||
|
|
||||||
|
### Incompatibility Data Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"incompatible_edges": [
|
||||||
|
{
|
||||||
|
"source": "node-1",
|
||||||
|
"target": "node-2",
|
||||||
|
"reason": "Source activity produces no outputs",
|
||||||
|
"source_needs": "any output",
|
||||||
|
"target_needs": "path, depth",
|
||||||
|
"suggestion": "Use LLM transformation to map outputs to inputs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"disconnected_nodes": ["node-5", "node-8"],
|
||||||
|
"user_alerts": [
|
||||||
|
"⚠️ node-1 → node-2: Source activity produces no outputs. Use LLM transformation to map outputs to inputs",
|
||||||
|
"🔌 Node 'NotifyStatus-1' has no connections. Consider adding edges or removing it."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Alerts
|
||||||
|
|
||||||
|
### Alert Types
|
||||||
|
|
||||||
|
1. **Incompatibility Warnings** (⚠️)
|
||||||
|
```
|
||||||
|
⚠️ source → target: reason. suggestion.
|
||||||
|
```
|
||||||
|
- Highlighted in red on canvas
|
||||||
|
- Shows in error sidebar
|
||||||
|
- Prevents workflow execution until fixed
|
||||||
|
|
||||||
|
2. **Disconnection Warnings** (🔌)
|
||||||
|
```
|
||||||
|
🔌 Node 'label' has no connections. Consider adding edges or removing it.
|
||||||
|
```
|
||||||
|
- Highlighted in yellow
|
||||||
|
- Nodes with no input/output edges
|
||||||
|
- May be valid (first step, last step) or indicate design error
|
||||||
|
|
||||||
|
3. **Type Mismatch Info** (ℹ️)
|
||||||
|
```
|
||||||
|
ℹ️ To connect source → target, use transformer to map: {source_outputs} → {target_inputs}
|
||||||
|
```
|
||||||
|
- Suggestion to use intermediate LLM node
|
||||||
|
- Provides mapping information
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Canvas UI Feedback
|
||||||
|
|
||||||
|
When CanvasReasonerActivity returns incompatibilities:
|
||||||
|
|
||||||
|
1. **Visual Markers**
|
||||||
|
- Incompatible suggested edges: ❌ red dashed line (don't auto-add)
|
||||||
|
- Disconnected nodes: ⚠️ yellow border
|
||||||
|
|
||||||
|
2. **Sidebar Alerts**
|
||||||
|
```
|
||||||
|
🚨 Connection Issues (3)
|
||||||
|
|
||||||
|
⚠️ CloneRepo → ApproveWorkflow
|
||||||
|
Reason: ApproveWorkflow is terminal (no outputs)
|
||||||
|
Suggestion: Place ApproveWorkflow at end of workflow
|
||||||
|
|
||||||
|
⚠️ LLMInference → DeploymentPreCheck
|
||||||
|
Reason: Type mismatch (string ≠ object)
|
||||||
|
Suggestion: Add LLM transformation node
|
||||||
|
|
||||||
|
🔌 SecurityScan-1 has no incoming edges
|
||||||
|
Suggestion: Connect CloneRepo → SecurityScan
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **User Actions**
|
||||||
|
- ✅ Accept suggestions (green edges)
|
||||||
|
- ❌ Reject incompatible edges
|
||||||
|
- 🔧 Add transformer nodes
|
||||||
|
- 🗑️ Remove disconnected nodes
|
||||||
|
|
||||||
|
### API Response Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"suggested_edges": [
|
||||||
|
{"source": "clone-1", "target": "analyze-1"},
|
||||||
|
{"source": "analyze-1", "target": "security-1"},
|
||||||
|
{"source": "security-1", "target": "report-1"}
|
||||||
|
],
|
||||||
|
"reasoning": "Standard code review workflow: clone → analyze → scan → report",
|
||||||
|
"confidence": 0.92,
|
||||||
|
"incompatible_edges": [
|
||||||
|
{
|
||||||
|
"source": "report-1",
|
||||||
|
"target": "approve-1",
|
||||||
|
"reason": "ReportGenerator has no outputs (terminal activity)",
|
||||||
|
"suggestion": "ApproveWorkflow can only be a final step"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"disconnected_nodes": [],
|
||||||
|
"user_alerts": [
|
||||||
|
"⚠️ report-1 → approve-1: ReportGenerator has no outputs (terminal activity). ApproveWorkflow can only be a final step"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Knowledge Base Schema
|
||||||
|
|
||||||
|
Each activity in `activity_knowledge_base.json` defines:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "CloneRepoActivity",
|
||||||
|
"inputs": {
|
||||||
|
"repo": {"type": "string", "required": true},
|
||||||
|
"branch": {"type": "string", "required": false}
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"commit": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Classification Rules
|
||||||
|
|
||||||
|
- **Generator** (0 inputs): trigger, API call, schedule
|
||||||
|
- **Processor** (1+ inputs, 1+ outputs): analysis, transformation, scan
|
||||||
|
- **Sink** (1+ inputs, 0 outputs): notification, approval, archive
|
||||||
|
- **Bypass** (0 inputs, 0 outputs): rare - usually error
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### ✅ Valid Chains
|
||||||
|
|
||||||
|
```
|
||||||
|
CloneRepo → Analyze → SecurityScan → Report
|
||||||
|
(generator) → (processor) → (processor) → (sink)
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Trigger → LLMInference → Decision → (Branch: Notify OR Approve)
|
||||||
|
(gen) → (processor) → (processor) → (sink)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Invalid Chains
|
||||||
|
|
||||||
|
```
|
||||||
|
Notify → CloneRepo ❌
|
||||||
|
(sink) → (generator) - backward flow
|
||||||
|
|
||||||
|
CloneRepo → CloneRepo → Analyze ❌
|
||||||
|
self-loop - no benefit
|
||||||
|
|
||||||
|
Analyze → Approve → Notify ❌
|
||||||
|
Approve is terminal (sink), can't output to Notify
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Multiple Outputs → Single Input
|
||||||
|
```
|
||||||
|
SecurityScan → Report
|
||||||
|
SecurityScan outputs: [issues, metrics, severity]
|
||||||
|
Report inputs: [report_data]
|
||||||
|
|
||||||
|
LLM must infer: bundle all outputs into single report_data object
|
||||||
|
Confidence: 0.7 (requires transformation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal Activities
|
||||||
|
- **ApproveWorkflowActivity**: Must be last (blocks workflow)
|
||||||
|
- **NotifyStatusActivity**: Can be mid-workflow (async notify)
|
||||||
|
- **ArchiveResultsActivity**: Should be last (persistence)
|
||||||
|
|
||||||
|
### Data Transformation
|
||||||
|
When source outputs don't match target inputs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# User can insert transformer node:
|
||||||
|
LLMInference → [LLMTransformer] → DeploymentPreCheck
|
||||||
|
|
||||||
|
# Transformer:
|
||||||
|
# - Input: LLMInference.output (string)
|
||||||
|
# - Output: DeploymentPreCheck.requirements (object)
|
||||||
|
# - Action: Call LLM to convert format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Incompatibility Detection
|
||||||
|
|
||||||
|
### Test Case 1: Terminal Activity as Source
|
||||||
|
```go
|
||||||
|
source := db.WorkflowNode{ID: "n1", Type: "notify-status", Label: "Notify"}
|
||||||
|
target := db.WorkflowNode{ID: "n2", Type: "clone-repo", Label: "Clone"}
|
||||||
|
|
||||||
|
warnings := CheckConnectionCompatibility(source, target)
|
||||||
|
// Should warn: NotifyStatusActivity produces no outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Case 2: Type Mismatch
|
||||||
|
```go
|
||||||
|
source := db.WorkflowNode{ID: "n1", Type: "llm-inference", ...}
|
||||||
|
target := db.WorkflowNode{ID: "n2", Type: "deployment-check", ...}
|
||||||
|
|
||||||
|
warnings := CheckConnectionCompatibility(source, target)
|
||||||
|
// Should warn: string output ≠ object input
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Case 3: Disconnected Node
|
||||||
|
```go
|
||||||
|
nodes := []db.WorkflowNode{n1, n2, n3}
|
||||||
|
edges := []db.WorkflowEdge{{Source: "n1", Target: "n2"}}
|
||||||
|
|
||||||
|
disconnected := IdentifyDisconnectedNodes(nodes, edges)
|
||||||
|
// Should return ["n3"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Automatic Transformer Insertion**
|
||||||
|
- Detect incompatibilities
|
||||||
|
- Auto-suggest LLM transformer nodes
|
||||||
|
- Chain transformers if needed
|
||||||
|
|
||||||
|
2. **Confidence Scoring**
|
||||||
|
- Increase when types match perfectly
|
||||||
|
- Decrease for semantic mismatches
|
||||||
|
- Factor in activity dependencies
|
||||||
|
|
||||||
|
3. **Learning from History**
|
||||||
|
- Track successful workflows
|
||||||
|
- Remember user edits to suggestions
|
||||||
|
- Improve LLM prompts over time
|
||||||
|
|
||||||
|
4. **Multi-Path Analysis**
|
||||||
|
- Suggest multiple connection topologies
|
||||||
|
- Show cost/efficiency of each
|
||||||
|
- Rank by execution time/cost
|
||||||
|
|
||||||
|
5. **Dry-Run Validation**
|
||||||
|
- Execute suggested workflow in simulation
|
||||||
|
- Catch runtime errors early
|
||||||
|
- Show data flow through each node
|
||||||
+1
-1
@@ -9,6 +9,6 @@ metadata:
|
|||||||
app.kubernetes.io/name: poimen
|
app.kubernetes.io/name: poimen
|
||||||
app.kubernetes.io/component: orchestrator
|
app.kubernetes.io/component: orchestrator
|
||||||
data:
|
data:
|
||||||
GIT_COMMIT: "5342bb51" # Updated automatically by CI/CD
|
GIT_COMMIT: "8cfa23e5d" # Updated automatically by CI/CD
|
||||||
GIT_BRANCH: "main"
|
GIT_BRANCH: "main"
|
||||||
DEPLOYMENT_DATE: "2026-09-05"
|
DEPLOYMENT_DATE: "2026-09-05"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: poimen-worker
|
app: poimen-worker
|
||||||
annotations:
|
annotations:
|
||||||
git-commit: "5342bb51" # ✅ Updated on each push, triggers rolling restart
|
git-commit: "8cfa23e5d" # ✅ Updated on each push, triggers rolling restart
|
||||||
deployment-date: "2026-09-05"
|
deployment-date: "2026-09-05"
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
|
|||||||
Reference in New Issue
Block a user