From 1c7ceb1f4ad2e7ba89649b99aad2a3473067b866 Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Mon, 22 Sep 2025 22:33:31 +0530 Subject: [PATCH 01/13] feat: implement simulate workflow mechanism --- shared.go | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/shared.go b/shared.go index 22926b16..862199bd 100755 --- a/shared.go +++ b/shared.go @@ -33846,3 +33846,203 @@ func GetDockerClient() (*dockerclient.Client, string, error) { return cli, dockerApiVersion, err } + +func HandleSimulateWorkflow(resp http.ResponseWriter, request *http.Request) { + cors := HandleCors(resp, request) + if cors { + return + } + + // This is temporary, and we should also allow anonymous demo execution + _, userErr := HandleApiAuthentication(resp, request) + if userErr != nil { + log.Printf("[WARNING] Api authentication failed in simulate Workflow: %s", userErr) + resp.WriteHeader(401) + resp.Write([]byte(`{"success": false}`)) + return + } + + location := strings.Split(request.URL.String(), "/") + var workflowId string + if location[1] == "api" { + if len(location) <= 4 { + log.Printf("[WARNING] Path too short: %d", len(location)) + resp.WriteHeader(400) + resp.Write([]byte(`{"success": false, "reason": "Workflow path not found"}`)) + return + } + + workflowId = location[4] + } + + if len(workflowId) != 36 { + log.Printf("[WARNING] Bad workflow ID: %s", workflowId) + resp.WriteHeader(400) + resp.Write([]byte(`{"success": false, "reason": "Bad workflow ID"}`)) + return + } + + log.Printf("[INFO] Starting workflow simulation for ID: %s", workflowId) + + ctx := GetContext(request) + workflow, err := GetWorkflow(ctx, workflowId, true) + if err != nil { + log.Printf("[WARNING] Failed getting workflow %s for simulation: %s", workflowId, err) + resp.WriteHeader(400) + resp.Write([]byte(`{"success": false, "reason": "Workflow not found"}`)) + return + } + + if !workflow.Public && workflow.Sharing != "public" { + log.Printf("[INFO] Workflow %s is not public, but allowing demo simulation anyway", workflowId) + } + + finalExecution := simulateWorkflowExecutionNew(ctx, workflow) + + response, err := json.Marshal(finalExecution) + if err != nil { + log.Printf("[ERROR] Failed to marshal demo execution: %s", err) + resp.WriteHeader(500) + resp.Write([]byte(`{"success": false, "reason": "Failed to serialize demo execution"}`)) + return + } + + log.Printf("[DEBUG] Returning complete execution with %d results", len(finalExecution.Results)) + resp.WriteHeader(200) + resp.Write(response) +} + +// implementation focusing on proper node traversal and realistic output +func simulateWorkflowExecutionNew(ctx context.Context, workflow *Workflow) WorkflowExecution { + workflowExecution := WorkflowExecution{ + Type: "workflow", + Status: "EXECUTING", + Start: workflow.Start, + WorkflowId: workflow.ID, + Result: "", + StartedAt: int64(time.Now().Unix()), + Workflow: *workflow, + ExecutionSource: "default", + } + + executionOrder := getNodeExecutionOrder(*workflow) + if len(executionOrder) == 0 { + log.Printf("[WARNING] No executable nodes found in workflow %s", workflowExecution.WorkflowId) + workflowExecution.Status = "FINISHED" + workflowExecution.CompletedAt = int64(time.Now().Unix()) + return workflowExecution + } + + var allResults []ActionResult + + for i, node := range executionOrder { + log.Printf("[INFO] DEMO: Executing node %d: %s (%s)", i+1, node.Label, node.AppName) + + demoData := generateDemoDataForNode(node.AppName, node.Name) + + actionResult := ActionResult{ + ExecutionId: workflowExecution.ExecutionId, + Action: node, + Result: demoData, + Status: "SUCCESS", + StartedAt: int64(time.Now().Unix() - int64(len(allResults)*2)), + CompletedAt: int64(time.Now().Unix() - int64(len(allResults)*2) + 1), + } + + allResults = append(allResults, actionResult) + } + + workflowExecution.Results = allResults + workflowExecution.Status = "FINISHED" + + if len(allResults) > 0 { + workflowExecution.LastNode = allResults[len(allResults)-1].Action.ID + workflowExecution.Result = allResults[len(allResults)-1].Result + } + + workflowExecution.CompletedAt = int64(time.Now().Unix()) + + return workflowExecution +} + +// Helper function to determine execution order based on workflow.Branches connections +func getNodeExecutionOrder(workflow Workflow) []Action { + + var executionOrder []Action + visited := make(map[string]bool) + actionMap := make(map[string]Action) + + for _, action := range workflow.Actions { + actionMap[action.ID] = action + } + + for _, trigger := range workflow.Triggers { + triggerAction := Action{ + ID: trigger.ID, + Label: trigger.Label, + AppName: trigger.AppName, + Name: trigger.Name, + } + executionOrder = append(executionOrder, triggerAction) + visited[trigger.ID] = true + } + + var startActionID string + for _, action := range workflow.Actions { + if action.IsStartNode { + startActionID = action.ID + break + } + } + + if startActionID == "" { + return executionOrder + } + + if startAction, exists := actionMap[startActionID]; exists { + executionOrder = append(executionOrder, startAction) + visited[startActionID] = true + } + + for { + foundNew := false + + for _, branch := range workflow.Branches { + if visited[branch.SourceID] && !visited[branch.DestinationID] { + if action, exists := actionMap[branch.DestinationID]; exists { + executionOrder = append(executionOrder, action) + visited[branch.DestinationID] = true + foundNew = true + } + } + } + + if !foundNew { + break + } + } + + for _, action := range workflow.Actions { + if !visited[action.ID] { + log.Printf("[INFO] DEMO: SKIPPING disconnected action: %s (not connected to main workflow)", action.Label) + } + } + + return executionOrder +} + +// Demo data generation functions for workflow simulation +func generateDemoDataForNode(appName string, actionName string) string { + + switch strings.ToLower(appName) { + case "wazuh": + return generateWazuhDemoData(actionName) + case "jira": + return generateJiraDemoData(actionName) + case "slack": + return generateSlackDemoData(actionName) + default: + return fmt.Sprintf(`{"app": "%s", "action": "%s", "status": "completed", "demo": true, "timestamp": "%s"}`, + appName, actionName, time.Now().Format("2006-01-02T15:04:05Z")) + } +} \ No newline at end of file From b84fd690f5f44ed36097d0d0661e5ba4c726ece8 Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Mon, 22 Sep 2025 22:35:48 +0530 Subject: [PATCH 02/13] added demo json data for wazuh, jira and slack --- blobs.go | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/blobs.go b/blobs.go index 3db13808..6f20c58a 100644 --- a/blobs.go +++ b/blobs.go @@ -869,6 +869,118 @@ func GetAllAppCategories() []AppCategory { return categories } +// Wazuh demo responses based on action type +func generateWazuhDemoData(actionName string) string { + responses := map[string]string{ + "get_alerts": `{ + "alerts": [ + {"id": "12345", "severity": "High", "source_ip": "192.168.1.100", "description": "Suspicious login attempt", "timestamp": "2025-09-22T10:30:00Z"}, + {"id": "12346", "severity": "Medium", "source_ip": "10.0.0.45", "description": "Failed authentication", "timestamp": "2025-09-22T10:28:15Z"}, + {"id": "12347", "severity": "Critical", "source_ip": "203.0.113.45", "description": "Potential malware detected", "timestamp": "2025-09-22T10:25:30Z"} + ], + "total_count": 3, + "status": "success" + }`, + "query_logs": `{ + "logs": [ + {"timestamp": "2025-09-22T10:30:00Z", "level": "WARNING", "message": "Multiple failed login attempts from 192.168.1.100", "agent": "web-server-01"}, + {"timestamp": "2025-09-22T10:28:15Z", "level": "ERROR", "message": "Authentication failure for user 'admin'", "agent": "db-server-02"} + ], + "query_time": "0.245s", + "status": "completed" + }`, + "get_agent_status": `{ + "agents": [ + {"id": "001", "name": "web-server-01", "status": "active", "last_seen": "2025-09-22T10:30:00Z"}, + {"id": "002", "name": "db-server-02", "status": "active", "last_seen": "2025-09-22T10:29:45Z"}, + {"id": "003", "name": "file-server-01", "status": "disconnected", "last_seen": "2025-09-22T09:15:22Z"} + ], + "total_agents": 3, + "active_agents": 2 + }`, + } + + if response, exists := responses[strings.ToLower(actionName)]; exists { + return response + } + return `{"alert_id": "DEMO-12345", "severity": "High", "source_ip": "192.168.1.100", "description": "Security alert detected", "status": "active"}` +} + +// Jira demo responses based on action type +func generateJiraDemoData(actionName string) string { + responses := map[string]string{ + "create_issue": `{ + "id": "10042", + "key": "SEC-123", + "self": "https://company.atlassian.net/rest/api/3/issue/10042" + }`, + "update_issue": `{ + "key": "SEC-123", + "id": "10001", + "fields": { + "status": {"name": "In Progress", "id": "3"}, + "assignee": {"displayName": "John Doe", "emailAddress": "john.doe@company.com"}, + "updated": "2025-09-22T10:35:00.000+0000" + }, + "status": "updated" + }`, + "get_issue": `{ + "key": "SEC-123", + "id": "10001", + "fields": { + "summary": "Security Alert: Suspicious Activity Detected", + "description": "Multiple failed login attempts detected from IP 192.168.1.100", + "status": {"name": "Open", "id": "1"}, + "priority": {"name": "High", "id": "2"}, + "assignee": {"displayName": "Security Team", "emailAddress": "security@company.com"}, + "created": "2025-09-22T10:30:00.000+0000" + } + }`, + "add_comment": `{ + "id": "10100", + "body": "Automated comment: Security alert has been processed and ticket created.", + "author": {"displayName": "Shuffle Automation", "emailAddress": "automation@company.com"}, + "created": "2025-09-22T10:35:00.000+0000", + "status": "added" + }`, + } + + if response, exists := responses[strings.ToLower(actionName)]; exists { + return response + } + return `{"ticket_id": "DEMO-123", "key": "DEMO-123", "status": "Created", "assignee": "security-team", "summary": "Demo ticket created"}` +} + +// Slack demo responses +func generateSlackDemoData(actionName string) string { + responses := map[string]string{ + "send_message": `{ + "ok": true, + "channel": "C1234567890", + "ts": "1695384900.123456", + "message": { + "text": "High priority incident detected. Jira ticket SEC-123 created and assigned to security team.", + "user": "U0123456789", + "ts": "1695384900.123456" + } + }`, + "create_channel": `{ + "ok": true, + "channel": { + "id": "C9876543210", + "name": "incident-2025-09-22", + "created": 1695384900, + "creator": "U0123456789" + } + }`, + } + + if response, exists := responses[strings.ToLower(actionName)]; exists { + return response + } + return `{"ok": true, "channel": "C1234567890", "ts": "1695384900.123456", "status": "message_sent"}` +} + // Simple check func AllowedImportPath() string { return strings.Join([]string{"github.com", "shuffle", "shuffle-shared"}, "/") From 9b9f51b37fb3830603edfa8a963093ece173d241 Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Mon, 22 Sep 2025 23:31:39 +0530 Subject: [PATCH 03/13] fixing some indentation mess --- blobs.go | 210 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 106 insertions(+), 104 deletions(-) diff --git a/blobs.go b/blobs.go index 6f20c58a..3e53a875 100644 --- a/blobs.go +++ b/blobs.go @@ -5,26 +5,27 @@ This file is for blobs that we use throughout Shuffle in many locations. If we w */ import ( - "context" - "encoding/json" + "os" "errors" + "strings" + "context" "fmt" "log" - "os" - "strings" + "encoding/json" uuid "github.com/satori/go.uuid" ) + // These are just specific examples for specific cases // FIXME: Should these be loaded from public workflows? // I kind of think so ~ // That means each algorithm needs to be written as if-statements to // replace a specific part of a workflow :thinking: -// Should workflows be written as YAML and be text-editable? +// Should workflows be written as YAML and be text-editable? func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction CategoryAction) (Workflow, error) { - actionType := categoryAction.Label + actionType := categoryAction.Label appNames := categoryAction.AppName if len(orgId) == 0 { @@ -37,7 +38,7 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca } // If-else with specific rules per workflow - // Make sure it uses workflow -> copies data, as + // Make sure it uses workflow -> copies data, as startActionId := uuid.NewV4().String() startTriggerId := workflow.ID if len(startTriggerId) == 0 { @@ -51,41 +52,41 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca triggerEnv = "onprem" envs, err := GetEnvironments(ctx, orgId) - if err == nil { + if err == nil { for _, env := range envs { if env.Default { actionEnv = env.Name break } } - } else { + } else { actionEnv = "Shuffle" } } if parsedActiontype == "correlate_categories" { defaultWorkflow := Workflow{ - Name: actionType, + Name: actionType, Description: "Correlates Datastore categories in Shuffle. The point is to graph data", - OrgId: orgId, - Start: startActionId, + OrgId: orgId, + Start: startActionId, Actions: []Action{ Action{ - ID: startActionId, - Name: "repeat_back_to_me", - AppName: "Shuffle Tools", - AppVersion: "1.2.0", + ID: startActionId, + Name: "repeat_back_to_me", + AppName: "Shuffle Tools", + AppVersion: "1.2.0", Environment: actionEnv, - Label: "Start", - IsStartNode: true, + Label: "Start", + IsStartNode: true, Position: Position{ X: 250, Y: 0, }, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ - Name: "call", - Value: "Some code here hello", + Name: "call", + Value: "Some code here hello", Multiline: true, }, }, @@ -130,7 +131,7 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca } defaultWorkflow := Workflow{ - Name: actionType, + Name: actionType, Description: "List tickets from different systems and ingest them", OrgId: orgId, Start: startActionId, @@ -145,7 +146,7 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca ID: startActionId, AppVersion: "1.0.0", Environment: actionEnv, - Label: currentAction.Value, + Label: currentAction.Value, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ Name: "app_name", @@ -153,8 +154,8 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca }, currentAction, WorkflowAppActionParameter{ - Name: "fields", - Value: "", + Name: "fields", + Value: "", Multiline: true, }, }, @@ -162,19 +163,19 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca }, Triggers: []Trigger{ Trigger{ - ID: startTriggerId, - Name: "Schedule", + ID: startTriggerId, + Name: "Schedule", TriggerType: "SCHEDULE", - Label: "Ingest tickets", + Label: "Ingest tickets", Environment: triggerEnv, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ Name: "cron", - Value: "0 0 * * *", + Value: "0 0 * * *", }, WorkflowAppActionParameter{ Name: "execution_argument", - Value: "Automatically configured by Shuffle", + Value: "Automatically configured by Shuffle", }, }, }, @@ -201,28 +202,28 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca ID: startActionId, AppVersion: "1.0.0", Environment: actionEnv, - Label: "Ingest Ticket from Webhook", + Label: "Ingest Ticket from Webhook", Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ - Name: "source_data", - Value: "$exec", + Name: "source_data", + Value: "$exec", Multiline: true, }, WorkflowAppActionParameter{ - Name: "standard", + Name: "standard", Description: "The standard to use from https://github.com/Shuffle/standards/tree/main", - Value: "OCSF", - Multiline: false, + Value: "OCSF", + Multiline: false, }, }, }, }, Triggers: []Trigger{ Trigger{ - ID: startTriggerId, - Name: "Webhook", + ID: startTriggerId, + Name: "Webhook", TriggerType: "WEBHOOK", - Label: "Ingest", + Label: "Ingest", Environment: triggerEnv, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ @@ -231,19 +232,19 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca }, WorkflowAppActionParameter{ Name: "tmp", - Value: "", + Value: "", }, WorkflowAppActionParameter{ Name: "auth_header", - Value: "", + Value: "", }, WorkflowAppActionParameter{ Name: "custom_response_body", - Value: "", + Value: "", }, WorkflowAppActionParameter{ Name: "await_response", - Value: "", + Value: "", }, }, }, @@ -326,7 +327,7 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca secondActionId := uuid.NewV4().String() defaultWorkflow := Workflow{ - Name: actionType, + Name: actionType, Description: "Monitor threatlists and ingest regularly", OrgId: orgId, Start: startActionId, @@ -334,84 +335,84 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca Tags: []string{"ingest", "feeds", "automatic"}, Actions: []Action{ Action{ - Name: "GET", - AppID: "HTTP", - AppName: "HTTP", - ID: startActionId, - AppVersion: "1.4.0", + Name: "GET", + AppID: "HTTP", + AppName: "HTTP", + ID: startActionId, + AppVersion: "1.4.0", Environment: actionEnv, - Label: "Get threatlist URLs", + Label: "Get threatlist URLs", Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ Name: "url", Value: "$shuffle_cache.threatlist_urls.value.#", }, WorkflowAppActionParameter{ - Name: "headers", + Name: "headers", Multiline: true, - Value: "", + Value: "", }, }, }, Action{ - Name: "execute_python", - AppID: "Shuffle Tools", - AppName: "Shuffle Tools", - ID: secondActionId, - AppVersion: "1.2.0", + Name: "execute_python", + AppID: "Shuffle Tools", + AppName: "Shuffle Tools", + ID: secondActionId, + AppVersion: "1.2.0", Environment: actionEnv, - Label: "Ingest IOCs", + Label: "Ingest IOCs", Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ - Name: "code", + Name: "code", Multiline: true, - Required: true, - Value: getIocIngestionScript(), + Required: true, + Value: getIocIngestionScript(), }, }, }, }, Triggers: []Trigger{ Trigger{ - ID: startTriggerId, - Name: "Schedule", + ID: startTriggerId, + Name: "Schedule", TriggerType: "SCHEDULE", - Label: "Pull threatlist URLs", + Label: "Pull threatlist URLs", Environment: triggerEnv, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ Name: "cron", - Value: "0 0 * * *", + Value: "0 0 * * *", }, WorkflowAppActionParameter{ Name: "execution_argument", - Value: "Automatically configured by Shuffle", + Value: "Automatically configured by Shuffle", }, }, }, }, Branches: []Branch{ Branch{ - SourceID: startTriggerId, + SourceID: startTriggerId, DestinationID: startActionId, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), }, Branch{ - SourceID: startActionId, + SourceID: startActionId, DestinationID: secondActionId, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), Conditions: []Condition{ Condition{ Source: WorkflowAppActionParameter{ - Name: "source", + Name: "source", Value: "{{ $get_threatlist_urls | size }}", }, Condition: WorkflowAppActionParameter{ - Name: "condition", + Name: "condition", Value: "larger than", }, Destination: WorkflowAppActionParameter{ - Name: "destination", + Name: "destination", Value: "0", }, }, @@ -425,18 +426,18 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca workflow.OrgId = orgId /* - if len(workflow.WorkflowVariables) == 0 { - workflow.WorkflowVariables = defaultWorkflow.WorkflowVariables - } + if len(workflow.WorkflowVariables) == 0 { + workflow.WorkflowVariables = defaultWorkflow.WorkflowVariables + } - if len(workflow.Actions) == 0 { - workflow.Actions = defaultWorkflow.Actions - } + if len(workflow.Actions) == 0 { + workflow.Actions = defaultWorkflow.Actions + } - // Rules specific to this one - if len(workflow.Triggers) == 0 { - workflow.Triggers = defaultWorkflow.Triggers - } + // Rules specific to this one + if len(workflow.Triggers) == 0 { + workflow.Triggers = defaultWorkflow.Triggers + } */ // Get the item with key "threatlist_urls" from datastore @@ -453,7 +454,7 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca log.Printf("[ERROR] Failed to marshal threatlist URLs: %s", err) } else { key := CacheKeyData{ - Key: "threatlist_urls", + Key: "threatlist_urls", Value: fmt.Sprintf(`%s`, string(jsonMarshalled)), OrgId: orgId, } @@ -485,21 +486,21 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca // Pre-defining it with a startnode that does nothing workflow.Actions = []Action{ Action{ - ID: startActionId, - Name: "repeat_back_to_me", - AppName: "Shuffle Tools", - AppVersion: "1.2.0", + ID: startActionId, + Name: "repeat_back_to_me", + AppName: "Shuffle Tools", + AppVersion: "1.2.0", Environment: actionEnv, - Label: "Start", - IsStartNode: true, + Label: "Start", + IsStartNode: true, Position: Position{ X: 250, Y: 0, }, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ - Name: "call", - Value: "", + Name: "call", + Value: "", Multiline: true, }, }, @@ -507,11 +508,11 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca } // Point from trigger(s) to startnode (repeater) - for _, trigger := range workflow.Triggers { + for _, trigger := range workflow.Triggers { newBranch := Branch{ - SourceID: trigger.ID, + SourceID: trigger.ID, DestinationID: workflow.Start, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), } workflow.Branches = append(workflow.Branches, newBranch) @@ -523,14 +524,15 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca newAction.Parameters = append([]WorkflowAppActionParameter(nil), actionTemplate.Parameters...) // Positioning - newAction.Position.X = positionAddition * float64(appIndex) + newAction.Position.X = positionAddition*float64(appIndex) newAction.Position.Y = positionAddition + // Point from startnode to current one newBranch := Branch{ - SourceID: workflow.Start, + SourceID: workflow.Start, DestinationID: newAction.ID, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), } workflow.Branches = append(workflow.Branches, newBranch) @@ -569,16 +571,16 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca Y: startYPosition, } - startXPosition += positionAddition + startXPosition += positionAddition } for actionIndex, _ := range workflow.Actions { workflow.Actions[actionIndex].Position = Position{ X: startXPosition, - Y: startYPosition, + Y: startYPosition, } - startXPosition += positionAddition + startXPosition += positionAddition } } @@ -607,9 +609,9 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca } newBranch := Branch{ - SourceID: sourceId, + SourceID: sourceId, DestinationID: destId, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), } workflow.Branches = append(workflow.Branches, newBranch) @@ -635,9 +637,9 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca log.Printf("Missing branch: %s", action.ID) // Create a branch from the previous action to this one workflow.Branches = append(workflow.Branches, Branch{ - SourceID: workflow.Actions[actionIndex-1].ID, + SourceID: workflow.Actions[actionIndex-1].ID, DestinationID: action.ID, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), }) } } From 88b88b647dacb9336ad4608f57c8801a7638ac5b Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Mon, 22 Sep 2025 23:38:50 +0530 Subject: [PATCH 04/13] Merge branch 'new-test-llm' of github.com:satti-hari-krishna-reddy/shuffle-shared into new-test-llm From 3caca96f887fa8eefb4711c31c1bb14b99efa849 Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Wed, 22 Oct 2025 10:41:49 +0530 Subject: [PATCH 05/13] added a new version of edit workflow --- ai.go | 449 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 448 insertions(+), 1 deletion(-) diff --git a/ai.go b/ai.go index 78ca7f9c..236c0a34 100644 --- a/ai.go +++ b/ai.go @@ -10602,6 +10602,8 @@ func HandleWorkflowGenerationResponse(resp http.ResponseWriter, request *http.Re return } + + output, err := generateWorkflowJson(ctx, input, user, workflow) if err != nil { reason := err.Error() @@ -10805,7 +10807,8 @@ func HandleEditWorkflowWithLLM(resp http.ResponseWriter, request *http.Request) return } - output, err := editWorkflowWithLLM(ctx, workflow, user, editRequest) + output, err := editWorkflowWithLLMV2(ctx, workflow, user, editRequest) + if err != nil { reason := err.Error() if strings.HasPrefix(reason, "AI rejected the task: ") { @@ -10821,6 +10824,8 @@ func HandleEditWorkflowWithLLM(resp http.ResponseWriter, request *http.Request) return } + + if project.Environment == "cloud" { IncrementCache(ctx, user.ActiveOrg.Id, "ai_executions", 1) log.Printf("[AUDIT] Incremented AI usage count for org %s (%s)", user.ActiveOrg.Name, user.ActiveOrg.Id) @@ -10840,6 +10845,448 @@ func HandleEditWorkflowWithLLM(resp http.ResponseWriter, request *http.Request) resp.Write(workflowJson) } +// func newGeneratePrototypeWorkflow() *Workflow { +// return &Workflow{ +// ID: uuid.NewV4().String(), +// Actions: []Action{ +// { +// ID: uuid.NewV4().String(), +// AppName: "Prototype", +// Label: "Generate Prototype", +// Parameters: []WorkflowAppActionParameter{}, +// }, +// }, +// Triggers: []Trigger{}, +// Branches: []Branch{}, +// } +// } + +func editWorkflowWithLLMV2(ctx context.Context, workflow *Workflow, user User, input WorkflowEditAIRequest) (*Workflow, error) { + + // we are going to split this into multiple stages where + // stage 1 is going to be a intent classifier + + // at this stage it breaks down what should be done to full the user request + + // 1. Identify the intent + // intent := classifyIntent(input) + + // // 2. Extract relevant entities + // entities := extractEntities(input) + + // // 3. Determine the required actions + // actions := determineActions(intent, entities) + + // lets start by identifying the multiple intents + + intents, err := classifyMultipleIntents(input.Query) + + if err != nil { + return nil, err + } + + for _, task := range intents.Tasks { + log.Printf("[DEBUG] Detected intent: %s on target node: %s from source text: %s", task.Intent, task.TargetNode, task.SourceText) + // for now lets imagine there are seperate fucntions to handle each intent so lets focus for now on ADD_NODE intent + switch task.Intent { + case "ADD_NODE": + workflow, err = handleAddNodeTask(ctx, workflow, task, input.Environment, user) + if err != nil { + return workflow, err + } + case "REMOVE_NODE": + // workflow, err = handleRemoveNodeTask(ctx, workflow, task, input.Environment, user) + // if err != nil { + // return workflow, err + // } + } + } + + return workflow, nil +} + +func handleAddNodeTask(ctx context.Context, workflow *Workflow, task WorkflowIntentTask, environment string, user User) (*Workflow, error) { + + systemMessage := `Generate a single JSON node (action or trigger) for Shuffle workflow based on user request. + +CRITICAL RULES: +- Use real API endpoints and standard HTTP methods (GET/POST/PUT/DELETE/PATCH) +- Never leave "url" field empty - use actual API base URLs like https://api.vendor.com +- For unknown APIs, use standard patterns: https://api.vendor.com/v1 or https://vendor.com/api +- For on-premise systems, use templates: https:///api/v1 + +node in shuffle comes in two types: Trigger and Action, Trigger is the starting point of workflow and action is the step that performs some operation +Here is an example format of an action + +ADDING A NEW APP ACTION or TRIGGER + If the user says: + - Add a step to send an email after this + - Insert a new action before X + - Add an enrichment step between trigger and Slack + + Some important notes: + when adding a new app action, keep in mind that: + Each app and action in the workflow represents a real API call. When modifying actions or adding new ones: + - Use public OpenAPI specs or common API conventions + - Accurately infer the correct method, endpoint, headers, and parameters + - Avoid guessing random fields, stick to what’s real or well-known + - If you're unsure of an API detail, **make an educated guess using real-world patterns.** + - You must never leave the "url" field empty. + + If you know the official base URL, use it directly + If you're unsure, guess using common formats like: + + https://api.vendor.com/v1 + https://vendor.com/api or + https://api.vendor.com + + Also when ever you use the base url make sure you include it as is, for example if a vendor base url according to their open api spec or public doc is like this "https://api.vendor.com/v1" or any other variation, just use the base url as is and do not change it in any way + You are allowed to use your training to approximate well-known APIs + Do **not** leave the field out or null under any circumstance + + example "url": "https://slack.com/api" + + The only two times where the url can be less relevant is when you are using the "Shuffle Tools" app and its actions like "execute_python" or "run_ssh_command" even in these cases provide something like this "url": "https://shuffle.io" + The other case is when the api server is actually running on premises where the url is not known in advance, for example fortigate firewall or Classic Active Directory (AD), in those case you can use template urls like "url": "https:///api/v2", "url": "https:///api/v1" + But apart from these cases most of the platforms are in the cloud and you can find the base url in their documentation or OpenAPI spec, so you can use that as the url. + + Here is the format for adding a new action: + Action format + + "action" :{ + "id": "sample-id", // do not stress about this, the system will generate a unique ID for you + "app_name": "string", // e.g., "Jira" + "action_name": "custom_action", // always keep as "custom_action" except for the Shuffle Tools app where it can be "execute_python" or "run_ssh_command" + + TRIGGER FORMAT: + { + "trigger": { + "app_name": "Webhook|Schedule", + "label": "unique_label_name", + "parameters": [ + {"name": "url|cron", "value": "webhook_url|cron_expression"} + ] + } + } + +SPECIAL CASES: +- Shuffle Tools app: use "execute_python" or "run_ssh_command" as action_name, url: "https://shuffle.io" +- Reference previous steps: use $exec.field_name or $label_name.field_name + +Return ONLY the JSON object, no explanations. +` + + finalContentOutput, err := RunAiQuery(systemMessage, task.SourceText) + if err != nil { + return nil, err + } + + var nodeResponse AddNodeResponse + err = json.Unmarshal([]byte(finalContentOutput), &nodeResponse) + if err != nil { + return nil, fmt.Errorf("failed to parse add node response: %s", err) + } + + var newWorkflowApp WorkflowApp + var newTriggerApp Trigger + + // Use the parsed response + if nodeResponse.Trigger != nil { + TriggerImage := GetTriggerData(nodeResponse.Trigger.AppName) + switch strings.ToLower(nodeResponse.Trigger.AppName) { + case "webhook": + ID := uuid.NewV4().String() + webhookURL := fmt.Sprintf("https://shuffler.io/api/v1/hooks/webhook_%s", ID) + if project.Environment != "cloud" { + if len(os.Getenv("BASE_URL")) > 0 { + webhookURL = fmt.Sprintf("%s/api/v1/hooks/webhook_%s", os.Getenv("BASE_URL"), ID) + } else if len(os.Getenv("SHUFFLE_CLOUDRUN_URL")) > 0 { + webhookURL = fmt.Sprintf("%s/api/v1/hooks/webhook_%s", os.Getenv("SHUFFLE_CLOUDRUN_URL"), ID) + } else { + port := os.Getenv("PORT") + if len(port) == 0 { + port = "5001" + } + webhookURL = fmt.Sprintf("http://localhost:%s/api/v1/hooks/webhook_%s", port, ID) + } + } + + newTriggerApp = Trigger{ + AppName: "Webhook", + AppVersion: "1.0.0", + Label: nodeResponse.Trigger.Label, + TriggerType: "WEBHOOK", + ID: ID, + Description: "Custom HTTP input trigger", + LargeImage: TriggerImage, + Environment: environment, + Status: "uninitialized", + Parameters: []WorkflowAppActionParameter{ + {Name: "url", Value: webhookURL}, + {Name: "tmp", Value: ""}, + {Name: "auth_headers", Value: ""}, + {Name: "custom_response_body", Value: ""}, + {Name: "await_response", Value: "v1"}, + }, + } + case "schedule": + ScheduleValue := "*/25 * * * *" + if len(nodeResponse.Trigger.Params) != 0 { + ScheduleValue = nodeResponse.Trigger.Params[0].Value + } + newTriggerApp = Trigger{ + AppName: "Schedule", + AppVersion: "1.0.0", + Label: nodeResponse.Trigger.Label, + TriggerType: "SCHEDULE", + ID: uuid.NewV4().String(), + Description: "Schedule time trigger", + LargeImage: TriggerImage, + Environment: environment, + Status: "uninitialized", + Parameters: []WorkflowAppActionParameter{ + {Name: "cron", Value: ScheduleValue}, + {Name: "execution_argument", Value: ""}, + }, + } + + default: + return nil, fmt.Errorf("unsupported trigger app name: %s", nodeResponse.Trigger.AppName) + } + } else if nodeResponse.Action != nil { + // Handle action + newActionItem := nodeResponse.Action + AppName := strings.TrimSpace(newActionItem.AppName) + if AppName != "" { + foundApps, err := FindWorkflowAppByName(ctx, newActionItem.AppName) + if err == nil && len(foundApps) > 0 { + newWorkflowApp = foundApps[0] + } else { + // Fallback to Algolia search for public apps + algoliaApp, err := HandleAlgoliaAppSearch(ctx, newActionItem.AppName) + if err == nil && len(algoliaApp.ObjectID) > 0 { + // Get the actual app from Algolia result + discoveredApp := &WorkflowApp{} + standalone := os.Getenv("STANDALONE") == "true" + if standalone { + discoveredApp, err = GetSingulApp("", algoliaApp.ObjectID) + } else { + discoveredApp, err = GetApp(ctx, algoliaApp.ObjectID, user, false) + } + if err == nil { + newWorkflowApp = *discoveredApp + } + } + } + } + } + + // Now add the created trigger/action to the workflow and handle branches + if nodeResponse.Trigger != nil { + // Calculate trigger position based on existing nodes + triggerX := -312.6988673793812 // Default position + triggerY := 190.6413454035773 + + // If we have existing triggers, position before the first one + if len(workflow.Triggers) > 0 { + triggerX = workflow.Triggers[0].Position.X - 437.0 + } else if len(workflow.Actions) > 0 { + // If no triggers but actions exist, position before first action + triggerX = workflow.Actions[0].Position.X - 437.0 + } + // If no nodes exist, use default position + + newTriggerApp.Position = Position{ + X: triggerX, + Y: triggerY, + } + + // Add trigger to the workflow + workflow.Triggers = append(workflow.Triggers, newTriggerApp) + + // Create branch from trigger to first action (if actions exist) + if len(workflow.Actions) > 0 { + firstAction := workflow.Actions[0] + newBranch := Branch{ + ID: uuid.NewV4().String(), + SourceID: newTriggerApp.ID, + DestinationID: firstAction.ID, + } + workflow.Branches = append(workflow.Branches, newBranch) + + // Update workflow start if needed + if workflow.Start == "" { + workflow.Start = firstAction.ID + } + } + + } else if nodeResponse.Action != nil { + // Create new action from the AI response and found app + var newAction Action + + if newWorkflowApp.Name != "" { + // Use found app + newAction = Action{ + ID: uuid.NewV4().String(), + AppName: newWorkflowApp.Name, + AppVersion: newWorkflowApp.AppVersion, + Label: nodeResponse.Action.Label, + Name: nodeResponse.Action.ActionName, + Environment: environment, + IsValid: true, + IsStartNode: false, + } + } else { + // Fallback - create basic HTTP action + newAction = Action{ + ID: uuid.NewV4().String(), + AppName: nodeResponse.Action.AppName, + AppVersion: "1.0.0", + Label: nodeResponse.Action.Label, + Name: nodeResponse.Action.ActionName, + Environment: environment, + IsValid: true, + IsStartNode: false, + } + } + + // Add parameters from AI response + var parameters []WorkflowAppActionParameter + for _, param := range nodeResponse.Action.Params { + parameters = append(parameters, WorkflowAppActionParameter{ + Name: param.Name, + Value: param.Value, + }) + } + newAction.Parameters = parameters + + // Calculate action position - add at the end + actionX := -312.6988673793812 // Default position + actionY := 190.6413454035773 + + // Position after the rightmost existing node + if len(workflow.Actions) > 0 { + actionX = workflow.Actions[len(workflow.Actions)-1].Position.X + 437.0 + } else if len(workflow.Triggers) > 0 { + actionX = workflow.Triggers[len(workflow.Triggers)-1].Position.X + 437.0 + newAction.IsStartNode = true + } else { + newAction.IsStartNode = true + } + + newAction.Position = Position{ + X: actionX, + Y: actionY, + } + + // Add action to workflow + workflow.Actions = append(workflow.Actions, newAction) + + // Create branch connecting to new action + actionCount := len(workflow.Actions) + var sourceID string + + if actionCount > 1 { + // Connect from previous action + sourceID = workflow.Actions[actionCount-2].ID + } else if len(workflow.Triggers) > 0 { + // Connect from last trigger + sourceID = workflow.Triggers[len(workflow.Triggers)-1].ID + workflow.Start = newAction.ID + } else { + // First action in empty workflow + workflow.Start = newAction.ID + } + + if sourceID != "" { + workflow.Branches = append(workflow.Branches, Branch{ + ID: uuid.NewV4().String(), + SourceID: sourceID, + DestinationID: newAction.ID, + }) + } + } + + return workflow, nil +} + +func handleRemoveNodeTask(ctx context.Context, workflow *Workflow, task WorkflowIntentTask, environment string, user User) (*Workflow, error) { + // Implement logic to remove a node from the workflow based on the task details + return workflow, nil +} + +func classifyMultipleIntents(userInput string) (*WorkflowIntentResponse, error) { + + systemMessage := `You are a Senior Workflow Analyst and intent classifier. You operate within the Shuffle security automation platform. Your SOLE mission is to deconstruct a user's natural language request into a precise, ordered list of machine-readable tasks. + You are a part of workflow editor system, Here a workflow in shuffle is basically a set of nodes where each node will perform an action and these are represented in json format, Now users will want to edit this existing workflow will type what they want to edit in the natural language and given the complexity of editing workflow with LLM's we splitted this into multiple stages where you being stage 1 here in this stage we decide what set of steps we exactly need to do edit workflow + You must classify the user's request into one or more of the following atomic intents. These are the only tools at your disposal. + We have the following atomic intents: + 1) ADD_NODE: Use when the user wants to add a new action or step to the workflow. + 2) MODIFY_ACTION_PARAMETER: Use when the user wants to change a specific setting, field, or value within an existing node. + 3) REMOVE_NODE: Use when the user wants to delete an existing action or step from the workflow. + 4) ADD_CONDITION: Use when the user introduces "if-then" logic, a check, or a decision point. This is a special form of adding a node. In our workflow format sometimes we don't want the logic to go to the next node, just like we have circuit breaker to stop the flow of electric current. Keep in mind this is only useful for cases where we can allow it based on single field like if some_field < condition> some_field if this is true only then proceed to next step then yes this is the one we need + 5) REMOVE_CONDITION: Use when the user wants to delete an existing condition from the workflow. + 6) NO_ACTION_NEEDED: Use when the user's request is too ambiguous or does not require any changes to the workflow. + + You MUST respond with ONLY a single, valid JSON object containing a "tasks" array. The tasks must be in chronological order. + Here is the format + + { + "intent": "INTENT_NAME", + "target_node": "User's description of the node being targeted.", + "source_text": "The exact part of the user's prompt that this task corresponds to." + } + + Example in Action: + User Request: "Change the Jira ticket's priority to 'Highest' and then add a step to send the ticket URL to the '#security-alerts' Slack channel." + + { + "tasks": [ + { + "intent": "MODIFY_ACTION_PARAMETER", + "target_node": "Jira ticket", + "source_text": "Change the Jira ticket's priority to 'Highest'" + }, + { + "intent": "ADD_NODE", + "target_node": null, + "source_text": "add a step to send the ticket URL to the '#security-alerts' Slack channel" + } + ] + } + + ` + + // Implement the logic to call the LLM with the system prompt and user input + finalContentOutput, err := RunAiQuery(systemMessage, userInput) + if err != nil { + log.Printf("[ERROR] Failed to run AI query in generateWorkflowJson: %s", err) + return nil, err + } + + if len(finalContentOutput) == 0 { + return nil, errors.New("AI response is empty") + } + + finalContentOutput = strings.TrimSpace(finalContentOutput) + finalContentOutput = strings.TrimPrefix(finalContentOutput, "```json") + finalContentOutput = strings.TrimPrefix(finalContentOutput, "```") + finalContentOutput = strings.TrimSuffix(finalContentOutput, "```") + finalContentOutput = strings.TrimSpace(finalContentOutput) + + log.Printf("[DEBUG] classifyMultipleIntents LLM output: %s", finalContentOutput) + + // Parse the JSON response into our struct + var intentResponse WorkflowIntentResponse + err = json.Unmarshal([]byte(finalContentOutput), &intentResponse) + if err != nil { + log.Printf("[ERROR] Failed to unmarshal intent classification response: %s", err) + return nil, fmt.Errorf("failed to parse AI intent response: %s", err) + } + + return &intentResponse, nil +} + func runSupportLLMAssistant(ctx context.Context, input QueryInput, user User) (string, string, error) { apiKey := os.Getenv("OPENAI_API_KEY") From 24ab52a9d6646cc9f1c3060073bac0ab63ab097e Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Wed, 22 Oct 2025 10:42:26 +0530 Subject: [PATCH 06/13] added structs to store workflow edit tasks --- structs.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/structs.go b/structs.go index 16b4b67e..de01b854 100755 --- a/structs.go +++ b/structs.go @@ -4652,6 +4652,17 @@ type AIConditionValue struct { Value string `json:"value"` } +// Structs for AI intent classification response +type WorkflowIntentResponse struct { + Tasks []WorkflowIntentTask `json:"tasks"` +} + +type WorkflowIntentTask struct { + Intent string `json:"intent"` + TargetNode *string `json:"target_node"` // Pointer to allow null values + SourceText string `json:"source_text"` +} + type AppCategoryItem struct { AppName string `json:"app_name"` Categories []string `json:"categories"` @@ -4715,6 +4726,12 @@ type AIConfig struct { Status string `json:"status" datastore:"status"` } +// New struct for ADD_NODE LLM response (singular keys) +type AddNodeResponse struct { + Trigger *AITriggerItem `json:"trigger,omitempty"` + Action *AIActionItem `json:"action,omitempty"` +} + // EDR and Audit Log Monitoring Structs type AuditLogEntry struct { Timestamp time.Time `json:"timestamp"` From 132a2aaacb5f1e5dc83f09900f53f11b67716751 Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Tue, 2 Dec 2025 17:26:13 +0530 Subject: [PATCH 07/13] added insert after and before to workflow intent --- structs.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/structs.go b/structs.go index de01b854..7d3dd834 100755 --- a/structs.go +++ b/structs.go @@ -4658,9 +4658,11 @@ type WorkflowIntentResponse struct { } type WorkflowIntentTask struct { - Intent string `json:"intent"` - TargetNode *string `json:"target_node"` // Pointer to allow null values - SourceText string `json:"source_text"` + Intent string `json:"intent"` + TargetNode *string `json:"target_node"` // Which node to target (for MODIFY/REMOVE) + SourceText string `json:"source_text"` + InsertAfter *string `json:"insert_after"` // For ADD_NODE: insert after this node label + InsertBefore *string `json:"insert_before"` // For ADD_NODE: insert before this node label } type AppCategoryItem struct { From 9da4dab2cb1a13e7c6aecb8168ea5b80ef0b92db Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Tue, 2 Dec 2025 18:07:53 +0530 Subject: [PATCH 08/13] expand workflow edit intent handlers and improve logging - Pass workflow context to classifyMultipleIntents for better intent classification --- ai.go | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/ai.go b/ai.go index 236c0a34..22a4fecf 100644 --- a/ai.go +++ b/ai.go @@ -10865,29 +10865,18 @@ func editWorkflowWithLLMV2(ctx context.Context, workflow *Workflow, user User, i // we are going to split this into multiple stages where // stage 1 is going to be a intent classifier - // at this stage it breaks down what should be done to full the user request - - // 1. Identify the intent - // intent := classifyIntent(input) - - // // 2. Extract relevant entities - // entities := extractEntities(input) - - // // 3. Determine the required actions - // actions := determineActions(intent, entities) - // lets start by identifying the multiple intents - intents, err := classifyMultipleIntents(input.Query) + intents, err := classifyMultipleIntents(input.Query, workflow) if err != nil { return nil, err } for _, task := range intents.Tasks { - log.Printf("[DEBUG] Detected intent: %s on target node: %s from source text: %s", task.Intent, task.TargetNode, task.SourceText) - // for now lets imagine there are seperate fucntions to handle each intent so lets focus for now on ADD_NODE intent + log.Printf("[DEBUG] Detected intent: %s on target node: %v from source text: %s", task.Intent, task.TargetNode, task.SourceText) + switch task.Intent { case "ADD_NODE": workflow, err = handleAddNodeTask(ctx, workflow, task, input.Environment, user) @@ -10895,10 +10884,29 @@ func editWorkflowWithLLMV2(ctx context.Context, workflow *Workflow, user User, i return workflow, err } case "REMOVE_NODE": - // workflow, err = handleRemoveNodeTask(ctx, workflow, task, input.Environment, user) - // if err != nil { - // return workflow, err - // } + workflow, err = handleRemoveNodeTask(workflow, task) + if err != nil { + log.Printf("[WARN] REMOVE_NODE failed for workflow %s: %s", workflow.ID, err) + } + case "ADD_CONDITION": + workflow, err = handleAddConditionTask(workflow, task) + if err != nil { + log.Printf("[WARN] ADD_CONDITION failed for workflow %s: %s", workflow.ID, err) + } + case "REMOVE_CONDITION": + workflow, err = handleRemoveConditionTask(workflow, task) + if err != nil { + log.Printf("[WARN] REMOVE_CONDITION failed for workflow %s: %s", workflow.ID, err) + } + case "MODIFY_ACTION_PARAMETER": + workflow, err = handleModifyParameterTask(workflow, task) + if err != nil { + log.Printf("[WARN] MODIFY_ACTION_PARAMETER failed for workflow %s: %s", workflow.ID, err) + } + case "NO_ACTION_NEEDED": + log.Printf("[DEBUG] No action needed for workflow %s", workflow.ID) + default: + log.Printf("[WARN] Unknown intent '%s' for workflow %s", task.Intent, workflow.ID) } } From 62866b70ece6fec6e1002b543dd716b9f16a8979 Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Tue, 2 Dec 2025 18:20:41 +0530 Subject: [PATCH 09/13] improve LLM workflow editing with better error handling and node removal --- ai.go | 873 ++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 763 insertions(+), 110 deletions(-) diff --git a/ai.go b/ai.go index 22a4fecf..3864abb3 100644 --- a/ai.go +++ b/ai.go @@ -10881,7 +10881,7 @@ func editWorkflowWithLLMV2(ctx context.Context, workflow *Workflow, user User, i case "ADD_NODE": workflow, err = handleAddNodeTask(ctx, workflow, task, input.Environment, user) if err != nil { - return workflow, err + log.Printf("[WARN] ADD_NODE failed for workflow %s: %s", workflow.ID, err) } case "REMOVE_NODE": workflow, err = handleRemoveNodeTask(workflow, task) @@ -11062,18 +11062,23 @@ Return ONLY the JSON object, no explanations. return nil, fmt.Errorf("unsupported trigger app name: %s", nodeResponse.Trigger.AppName) } } else if nodeResponse.Action != nil { - // Handle action + // Handle action - find the app using same approach as generateWorkflowJson newActionItem := nodeResponse.Action - AppName := strings.TrimSpace(newActionItem.AppName) - if AppName != "" { - foundApps, err := FindWorkflowAppByName(ctx, newActionItem.AppName) + appName := strings.TrimSpace(newActionItem.AppName) + foundApp := false + + if appName != "" { + // 1) First try local DB search + foundApps, err := FindWorkflowAppByName(ctx, appName) if err == nil && len(foundApps) > 0 { newWorkflowApp = foundApps[0] - } else { - // Fallback to Algolia search for public apps - algoliaApp, err := HandleAlgoliaAppSearch(ctx, newActionItem.AppName) + foundApp = true + } + + // 2) Fallback to Algolia search for public apps + if !foundApp { + algoliaApp, err := HandleAlgoliaAppSearch(ctx, appName) if err == nil && len(algoliaApp.ObjectID) > 0 { - // Get the actual app from Algolia result discoveredApp := &WorkflowApp{} standalone := os.Getenv("STANDALONE") == "true" if standalone { @@ -11081,11 +11086,17 @@ Return ONLY the JSON object, no explanations. } else { discoveredApp, err = GetApp(ctx, algoliaApp.ObjectID, user, false) } - if err == nil { + if err == nil && discoveredApp != nil { newWorkflowApp = *discoveredApp + foundApp = true } } } + + if !foundApp { + log.Printf("[WARN] ADD_NODE failed - App not found: '%s' for workflow %s. Workflow unchanged.", appName, workflow.ID) + return workflow, nil + } } } @@ -11130,32 +11141,16 @@ Return ONLY the JSON object, no explanations. } else if nodeResponse.Action != nil { // Create new action from the AI response and found app - var newAction Action - - if newWorkflowApp.Name != "" { - // Use found app - newAction = Action{ - ID: uuid.NewV4().String(), - AppName: newWorkflowApp.Name, - AppVersion: newWorkflowApp.AppVersion, - Label: nodeResponse.Action.Label, - Name: nodeResponse.Action.ActionName, - Environment: environment, - IsValid: true, - IsStartNode: false, - } - } else { - // Fallback - create basic HTTP action - newAction = Action{ - ID: uuid.NewV4().String(), - AppName: nodeResponse.Action.AppName, - AppVersion: "1.0.0", - Label: nodeResponse.Action.Label, - Name: nodeResponse.Action.ActionName, - Environment: environment, - IsValid: true, - IsStartNode: false, - } + // Note: If app wasn't found, we already returned early above + newAction := Action{ + ID: uuid.NewV4().String(), + AppName: newWorkflowApp.Name, + AppVersion: newWorkflowApp.AppVersion, + Label: nodeResponse.Action.Label, + Name: nodeResponse.Action.ActionName, + Environment: environment, + IsValid: true, + IsStartNode: false, } // Add parameters from AI response @@ -11168,52 +11163,139 @@ Return ONLY the JSON object, no explanations. } newAction.Parameters = parameters - // Calculate action position - add at the end - actionX := -312.6988673793812 // Default position - actionY := 190.6413454035773 + // Find insert position based on task.InsertAfter or task.InsertBefore + insertAfterID, insertBeforeID := findInsertPositionByLabel(workflow, task) - // Position after the rightmost existing node - if len(workflow.Actions) > 0 { - actionX = workflow.Actions[len(workflow.Actions)-1].Position.X + 437.0 - } else if len(workflow.Triggers) > 0 { - actionX = workflow.Triggers[len(workflow.Triggers)-1].Position.X + 437.0 - newAction.IsStartNode = true - } else { - newAction.IsStartNode = true + // Insert action at the correct position + workflow = insertActionAtPosition(workflow, newAction, insertAfterID, insertBeforeID) + } + + return workflow, nil +} + +func handleRemoveNodeTask(workflow *Workflow, task WorkflowIntentTask) (*Workflow, error) { + if task.TargetNode == nil || *task.TargetNode == "" { + return workflow, fmt.Errorf("no target_node specified for REMOVE_NODE") + } + + targetLabel := strings.ToLower(*task.TargetNode) + log.Printf("[DEBUG] Attempting to remove node with label: %s", targetLabel) + + // Try to find and remove from actions first + actionRemoved, actionID := removeActionByLabel(workflow, targetLabel) + if actionRemoved { + log.Printf("[INFO] Removed action with ID: %s", actionID) + fixBranchesAfterRemoval(workflow, actionID) + return workflow, nil + } + + // Try to find and remove from triggers + triggerRemoved, triggerID := removeTriggerByLabel(workflow, targetLabel) + if triggerRemoved { + log.Printf("[INFO] Removed trigger with ID: %s", triggerID) + fixBranchesAfterRemoval(workflow, triggerID) + return workflow, nil + } + + return workflow, fmt.Errorf("node not found with label: %s", *task.TargetNode) +} + +func removeActionByLabel(workflow *Workflow, targetLabel string) (removed bool, removedID string) { + for i, action := range workflow.Actions { + actionLabel := strings.ToLower(action.Label) + actionAppName := strings.ToLower(action.AppName) + + if actionLabel == targetLabel || actionAppName == targetLabel || + strings.Contains(actionLabel, targetLabel) || strings.Contains(targetLabel, actionLabel) || + strings.Contains(actionAppName, targetLabel) || strings.Contains(targetLabel, actionAppName) { + + removedID = action.ID + + // Handle start node + if workflow.Start == removedID { + for _, branch := range workflow.Branches { + if branch.SourceID == removedID { + workflow.Start = branch.DestinationID + for j := range workflow.Actions { + if workflow.Actions[j].ID == branch.DestinationID { + workflow.Actions[j].IsStartNode = true + break + } + } + break + } + } + if workflow.Start == removedID { + workflow.Start = "" + } + } + + workflow.Actions = append(workflow.Actions[:i], workflow.Actions[i+1:]...) + return true, removedID } + } + return false, "" +} + +func removeTriggerByLabel(workflow *Workflow, targetLabel string) (removed bool, removedID string) { + for i, trigger := range workflow.Triggers { + triggerLabel := strings.ToLower(trigger.Label) + triggerAppName := strings.ToLower(trigger.AppName) - newAction.Position = Position{ - X: actionX, - Y: actionY, + if triggerLabel == targetLabel || triggerAppName == targetLabel || + strings.Contains(triggerLabel, targetLabel) || strings.Contains(targetLabel, triggerLabel) || + strings.Contains(triggerAppName, targetLabel) || strings.Contains(targetLabel, triggerAppName) { + + removedID = trigger.ID + workflow.Triggers = append(workflow.Triggers[:i], workflow.Triggers[i+1:]...) + return true, removedID } + } + return false, "" +} - // Add action to workflow - workflow.Actions = append(workflow.Actions, newAction) +func fixBranchesAfterRemoval(workflow *Workflow, removedID string) { + var incomingSourceIDs []string + var outgoingDestIDs []string - // Create branch connecting to new action - actionCount := len(workflow.Actions) - var sourceID string + for _, branch := range workflow.Branches { + if branch.DestinationID == removedID { + incomingSourceIDs = append(incomingSourceIDs, branch.SourceID) + } + if branch.SourceID == removedID { + outgoingDestIDs = append(outgoingDestIDs, branch.DestinationID) + } + } - if actionCount > 1 { - // Connect from previous action - sourceID = workflow.Actions[actionCount-2].ID - } else if len(workflow.Triggers) > 0 { - // Connect from last trigger - sourceID = workflow.Triggers[len(workflow.Triggers)-1].ID - workflow.Start = newAction.ID - } else { - // First action in empty workflow - workflow.Start = newAction.ID + // Remove branches involving removed node + var newBranches []Branch + for _, branch := range workflow.Branches { + if branch.SourceID != removedID && branch.DestinationID != removedID { + newBranches = append(newBranches, branch) } + } + workflow.Branches = newBranches - if sourceID != "" { - workflow.Branches = append(workflow.Branches, Branch{ - ID: uuid.NewV4().String(), - SourceID: sourceID, - DestinationID: newAction.ID, - }) + // Reconnect: A -> Removed -> B becomes A -> B + for _, sourceID := range incomingSourceIDs { + for _, destID := range outgoingDestIDs { + exists := false + for _, branch := range workflow.Branches { + if branch.SourceID == sourceID && branch.DestinationID == destID { + exists = true + break + } + } + if !exists { + workflow.Branches = append(workflow.Branches, Branch{ + ID: uuid.NewV4().String(), + SourceID: sourceID, + DestinationID: destID, + }) + } } } +} return workflow, nil } @@ -11223,52 +11305,624 @@ func handleRemoveNodeTask(ctx context.Context, workflow *Workflow, task Workflow return workflow, nil } -func classifyMultipleIntents(userInput string) (*WorkflowIntentResponse, error) { + // Find destination node ID if specified + var destID string + if task.InsertBefore != nil && *task.InsertBefore != "" { + destID = findNodeIDByLabel(workflow, strings.ToLower(*task.InsertBefore)) + } + + // Find the branch to add condition to + branchIndex := -1 + for i, branch := range workflow.Branches { + if branch.SourceID == sourceID { + if destID == "" || branch.DestinationID == destID { + branchIndex = i + break + } + } + } - systemMessage := `You are a Senior Workflow Analyst and intent classifier. You operate within the Shuffle security automation platform. Your SOLE mission is to deconstruct a user's natural language request into a precise, ordered list of machine-readable tasks. - You are a part of workflow editor system, Here a workflow in shuffle is basically a set of nodes where each node will perform an action and these are represented in json format, Now users will want to edit this existing workflow will type what they want to edit in the natural language and given the complexity of editing workflow with LLM's we splitted this into multiple stages where you being stage 1 here in this stage we decide what set of steps we exactly need to do edit workflow - You must classify the user's request into one or more of the following atomic intents. These are the only tools at your disposal. - We have the following atomic intents: - 1) ADD_NODE: Use when the user wants to add a new action or step to the workflow. - 2) MODIFY_ACTION_PARAMETER: Use when the user wants to change a specific setting, field, or value within an existing node. - 3) REMOVE_NODE: Use when the user wants to delete an existing action or step from the workflow. - 4) ADD_CONDITION: Use when the user introduces "if-then" logic, a check, or a decision point. This is a special form of adding a node. In our workflow format sometimes we don't want the logic to go to the next node, just like we have circuit breaker to stop the flow of electric current. Keep in mind this is only useful for cases where we can allow it based on single field like if some_field < condition> some_field if this is true only then proceed to next step then yes this is the one we need - 5) REMOVE_CONDITION: Use when the user wants to delete an existing condition from the workflow. - 6) NO_ACTION_NEEDED: Use when the user's request is too ambiguous or does not require any changes to the workflow. + if branchIndex == -1 { + log.Printf("[WARN] ADD_CONDITION: no branch found from '%s', skipping", sourceLabel) + return workflow, nil + } - You MUST respond with ONLY a single, valid JSON object containing a "tasks" array. The tasks must be in chronological order. - Here is the format + // Build workflow context for LLM + var workflowContext strings.Builder + workflowContext.WriteString("Available nodes in workflow:\n") + for _, trigger := range workflow.Triggers { + workflowContext.WriteString(fmt.Sprintf("- Trigger: %s (label: %s) - use $exec to reference\n", trigger.AppName, trigger.Label)) + } + for _, action := range workflow.Actions { + workflowContext.WriteString(fmt.Sprintf("- Action: %s (label: %s) - use $%s to reference\n", action.AppName, action.Label, action.Label)) + } - { - "intent": "INTENT_NAME", - "target_node": "User's description of the node being targeted.", - "source_text": "The exact part of the user's prompt that this task corresponds to." - } + // LLM prompt to parse the condition from user request + systemMessage := `You parse condition requests for Shuffle workflows. - Example in Action: - User Request: "Change the Jira ticket's priority to 'Highest' and then add a step to send the ticket URL to the '#security-alerts' Slack channel." +Conditions control flow between nodes. If condition is false, downstream actions are skipped. - { - "tasks": [ - { - "intent": "MODIFY_ACTION_PARAMETER", - "target_node": "Jira ticket", - "source_text": "Change the Jira ticket's priority to 'Highest'" - }, - { - "intent": "ADD_NODE", - "target_node": null, - "source_text": "add a step to send the ticket URL to the '#security-alerts' Slack channel" - } - ] - } +AVAILABLE CONDITION TYPES: +- equals: exact match +- doesnotequal: not equal +- startswith: string starts with value +- endswith: string ends with value +- contains: string contains value +- containsanyof: contains any of comma-separated values +- largerthan: numeric greater than +- lessthan: numeric less than +- isempty: check if empty + +REFERENCING VALUES: +- For triggers: use $exec.field_name (e.g., $exec.severity, $exec.status) +- For actions: use $label_name.field_name (e.g., $http_1.status, $jira_1.success) + +Return ONLY valid JSON: +{ + "source_value": "$exec.field_name or $label.field", + "condition_type": "equals", + "destination_value": "expected_value" +} + +Examples: +- "only if status is success" -> {"source_value": "$exec.status", "condition_type": "equals", "destination_value": "success"} +- "when severity contains critical" -> {"source_value": "$exec.severity", "condition_type": "contains", "destination_value": "critical"} +- "if count is greater than 10" -> {"source_value": "$http_1.count", "condition_type": "largerthan", "destination_value": "10"}` - ` + userPrompt := fmt.Sprintf("%s\n\nUser request: \"%s\"\n\nParse the condition and return JSON.", workflowContext.String(), task.SourceText) - // Implement the logic to call the LLM with the system prompt and user input - finalContentOutput, err := RunAiQuery(systemMessage, userInput) + llmOutput, err := RunAiQuery(systemMessage, userPrompt) if err != nil { - log.Printf("[ERROR] Failed to run AI query in generateWorkflowJson: %s", err) + log.Printf("[WARN] ADD_CONDITION: LLM call failed: %s, skipping", err) + return workflow, nil + } + + // Clean and parse response + llmOutput = strings.TrimSpace(llmOutput) + llmOutput = strings.TrimPrefix(llmOutput, "```json") + llmOutput = strings.TrimPrefix(llmOutput, "```") + llmOutput = strings.TrimSuffix(llmOutput, "```") + llmOutput = strings.TrimSpace(llmOutput) + + var conditionResponse struct { + SourceValue string `json:"source_value"` + ConditionType string `json:"condition_type"` + DestinationValue string `json:"destination_value"` + } + + err = json.Unmarshal([]byte(llmOutput), &conditionResponse) + if err != nil { + log.Printf("[WARN] ADD_CONDITION: failed to parse LLM response: %s, skipping", err) + return workflow, nil + } + + // Validate condition type + validConditions := map[string]bool{ + "equals": true, "doesnotequal": true, + "startswith": true, "endswith": true, + "contains": true, "containsanyof": true, + "largerthan": true, "lessthan": true, + "isempty": true, + } + if !validConditions[strings.ToLower(conditionResponse.ConditionType)] { + log.Printf("[WARN] ADD_CONDITION: invalid condition type '%s', defaulting to 'equals'", conditionResponse.ConditionType) + conditionResponse.ConditionType = "equals" + } + + // Create the condition + condition := Condition{ + Source: WorkflowAppActionParameter{ + ID: uuid.NewV4().String(), + Name: "source", + Variant: "STATIC_VALUE", + Value: conditionResponse.SourceValue, + }, + Condition: WorkflowAppActionParameter{ + ID: uuid.NewV4().String(), + Name: "condition", + Value: strings.ToLower(conditionResponse.ConditionType), + }, + Destination: WorkflowAppActionParameter{ + ID: uuid.NewV4().String(), + Name: "destination", + Variant: "STATIC_VALUE", + Value: conditionResponse.DestinationValue, + }, + } + + workflow.Branches[branchIndex].Conditions = append(workflow.Branches[branchIndex].Conditions, condition) + workflow.Branches[branchIndex].HasError = false + log.Printf("[INFO] Added condition '%s %s %s' to branch from %s", + conditionResponse.SourceValue, conditionResponse.ConditionType, conditionResponse.DestinationValue, sourceLabel) + + return workflow, nil +} + +func handleRemoveConditionTask(workflow *Workflow, task WorkflowIntentTask) (*Workflow, error) { + if task.TargetNode == nil || *task.TargetNode == "" { + return workflow, fmt.Errorf("no target_node specified for REMOVE_CONDITION") + } + + sourceLabel := strings.ToLower(*task.TargetNode) + sourceID := findNodeIDByLabel(workflow, sourceLabel) + if sourceID == "" { + return workflow, fmt.Errorf("source node not found: %s", sourceLabel) + } + + // Find destination if specified + var destID string + if task.InsertBefore != nil && *task.InsertBefore != "" { + destID = findNodeIDByLabel(workflow, strings.ToLower(*task.InsertBefore)) + } + + // Find and clear conditions on matching branches + removed := false + for i, branch := range workflow.Branches { + if branch.SourceID == sourceID { + if destID == "" || branch.DestinationID == destID { + if len(workflow.Branches[i].Conditions) > 0 { + workflow.Branches[i].Conditions = []Condition{} + removed = true + log.Printf("[INFO] Removed conditions from branch at index %d", i) + } + } + } + } + + if !removed { + return workflow, fmt.Errorf("no conditions found to remove from %s", sourceLabel) + } + + return workflow, nil +} + +func handleModifyParameterTask(workflow *Workflow, task WorkflowIntentTask) (*Workflow, error) { + if task.TargetNode == nil || *task.TargetNode == "" { + log.Printf("[WARN] MODIFY_ACTION_PARAMETER: no target_node specified, skipping") + return workflow, nil + } + + targetLabel := strings.ToLower(*task.TargetNode) + + var actionParams *[]WorkflowAppActionParameter + var nodeName string + var nodeLabel string + + // First check actions + for i, action := range workflow.Actions { + actionLabel := strings.ToLower(action.Label) + actionAppName := strings.ToLower(action.AppName) + if actionLabel == targetLabel || actionAppName == targetLabel || + strings.Contains(actionLabel, targetLabel) || strings.Contains(targetLabel, actionLabel) || + strings.Contains(actionAppName, targetLabel) || strings.Contains(targetLabel, actionAppName) { + actionParams = &workflow.Actions[i].Parameters + nodeName = action.AppName + nodeLabel = action.Label + break + } + } + + // If not found in actions, check triggers + if actionParams == nil { + for i, trigger := range workflow.Triggers { + triggerLabel := strings.ToLower(trigger.Label) + triggerAppName := strings.ToLower(trigger.AppName) + if triggerLabel == targetLabel || triggerAppName == targetLabel || + strings.Contains(triggerLabel, targetLabel) || strings.Contains(targetLabel, triggerLabel) || + strings.Contains(triggerAppName, targetLabel) || strings.Contains(targetLabel, triggerAppName) { + actionParams = &workflow.Triggers[i].Parameters + nodeName = trigger.AppName + nodeLabel = trigger.Label + break + } + } + } + + if actionParams == nil { + log.Printf("[WARN] MODIFY_ACTION_PARAMETER: node '%s' not found in workflow %s, skipping", *task.TargetNode, workflow.ID) + return workflow, nil + } + + var paramsInfo strings.Builder + for _, param := range *actionParams { + paramsInfo.WriteString(fmt.Sprintf("- %s: %s\n", param.Name, param.Value)) + } + + systemMessage := `You modify workflow action parameters based on user requests. + +Given the user's request and current parameters, determine which parameter(s) to modify. + +Return ONLY valid JSON: +{ + "modifications": [ + {"param_name": "parameter_name", "new_value": "new_value_here"} + ] +} + +Rules: +- Only modify parameters that exist in the current parameters list +- If modifying JSON in body param, return the complete updated JSON string +- If user wants to reference another node's output, use format: $node_label.field_name +- If user doesn't specify a value, make a reasonable guess or skip +- Return empty modifications array if unclear what to change` + + userPrompt := fmt.Sprintf(`User request: "%s" + +Node: %s (%s) +Current parameters: +%s +Return the JSON with modifications.`, task.SourceText, nodeLabel, nodeName, paramsInfo.String()) + + llmOutput, err := RunAiQuery(systemMessage, userPrompt) + if err != nil { + log.Printf("[WARN] MODIFY_ACTION_PARAMETER: LLM call failed for node '%s': %s, skipping", nodeLabel, err) + return workflow, nil + } + + // Clean and parse response + llmOutput = strings.TrimSpace(llmOutput) + llmOutput = strings.TrimPrefix(llmOutput, "```json") + llmOutput = strings.TrimPrefix(llmOutput, "```") + llmOutput = strings.TrimSuffix(llmOutput, "```") + llmOutput = strings.TrimSpace(llmOutput) + + var response struct { + Modifications []struct { + ParamName string `json:"param_name"` + NewValue string `json:"new_value"` + } `json:"modifications"` + } + + err = json.Unmarshal([]byte(llmOutput), &response) + if err != nil { + log.Printf("[WARN] MODIFY_ACTION_PARAMETER: failed to parse LLM response for node '%s': %s, skipping", nodeLabel, err) + return workflow, nil + } + + // Silent fail: no modifications identified + if len(response.Modifications) == 0 { + log.Printf("[WARN] MODIFY_ACTION_PARAMETER: no modifications identified for node '%s', skipping", nodeLabel) + return workflow, nil + } + + // Apply modifications + for _, mod := range response.Modifications { + for i, param := range *actionParams { + if strings.EqualFold(param.Name, mod.ParamName) { + (*actionParams)[i].Value = mod.NewValue + log.Printf("[INFO] Modified param '%s' on node '%s'", mod.ParamName, nodeLabel) + break + } + } + } + + return workflow, nil +} + +func findNodeIDByLabel(workflow *Workflow, targetLabel string) string { + for _, trigger := range workflow.Triggers { + triggerLabel := strings.ToLower(trigger.Label) + triggerAppName := strings.ToLower(trigger.AppName) + if triggerLabel == targetLabel || triggerAppName == targetLabel || + strings.Contains(triggerLabel, targetLabel) || strings.Contains(targetLabel, triggerLabel) || + strings.Contains(triggerAppName, targetLabel) || strings.Contains(targetLabel, triggerAppName) { + return trigger.ID + } + } + for _, action := range workflow.Actions { + actionLabel := strings.ToLower(action.Label) + actionAppName := strings.ToLower(action.AppName) + if actionLabel == targetLabel || actionAppName == targetLabel || + strings.Contains(actionLabel, targetLabel) || strings.Contains(targetLabel, actionLabel) || + strings.Contains(actionAppName, targetLabel) || strings.Contains(targetLabel, actionAppName) { + return action.ID + } + } + return "" +} + +func findInsertPositionByLabel(workflow *Workflow, task WorkflowIntentTask) (insertAfterID, insertBeforeID string) { + labelToID := make(map[string]string) + + for _, trigger := range workflow.Triggers { + labelToID[strings.ToLower(trigger.Label)] = trigger.ID + labelToID[strings.ToLower(trigger.AppName)] = trigger.ID + } + for _, action := range workflow.Actions { + labelToID[strings.ToLower(action.Label)] = action.ID + labelToID[strings.ToLower(action.AppName)] = action.ID + } + + if task.InsertAfter != nil && *task.InsertAfter != "" { + searchLabel := strings.ToLower(*task.InsertAfter) + if id, ok := labelToID[searchLabel]; ok { + insertAfterID = id + } else { + for label, id := range labelToID { + if strings.Contains(label, searchLabel) || strings.Contains(searchLabel, label) { + insertAfterID = id + break + } + } + } + } + + if task.InsertBefore != nil && *task.InsertBefore != "" { + searchLabel := strings.ToLower(*task.InsertBefore) + if id, ok := labelToID[searchLabel]; ok { + insertBeforeID = id + } else { + for label, id := range labelToID { + if strings.Contains(label, searchLabel) || strings.Contains(searchLabel, label) { + insertBeforeID = id + break + } + } + } + } + + return insertAfterID, insertBeforeID +} + +// insertActionAtPosition inserts an action at the correct position and fixes branches +func insertActionAtPosition(workflow *Workflow, newAction Action, insertAfterID, insertBeforeID string) *Workflow { + const nodeSpacing = 437.0 + defaultX := -312.6988673793812 + defaultY := 190.6413454035773 + + // Case 1: Insert after a specific node + if insertAfterID != "" { + sourcePos := findNodePositionByID(workflow, insertAfterID) + if sourcePos != nil { + newAction.Position = Position{X: sourcePos.X + nodeSpacing, Y: sourcePos.Y} + + for i, branch := range workflow.Branches { + if branch.SourceID == insertAfterID { + oldDestID := branch.DestinationID + workflow.Branches[i].DestinationID = newAction.ID + workflow.Branches = append(workflow.Branches, Branch{ + ID: uuid.NewV4().String(), SourceID: newAction.ID, DestinationID: oldDestID, + }) + workflow.Actions = append(workflow.Actions, newAction) + return workflow + } + } + workflow.Branches = append(workflow.Branches, Branch{ + ID: uuid.NewV4().String(), SourceID: insertAfterID, DestinationID: newAction.ID, + }) + workflow.Actions = append(workflow.Actions, newAction) + return workflow + } + } + + // Case 2: Insert before a specific node + if insertBeforeID != "" { + destPos := findNodePositionByID(workflow, insertBeforeID) + if destPos != nil { + newAction.Position = Position{X: destPos.X - nodeSpacing, Y: destPos.Y} + + for i, branch := range workflow.Branches { + if branch.DestinationID == insertBeforeID { + workflow.Branches[i].DestinationID = newAction.ID + workflow.Branches = append(workflow.Branches, Branch{ + ID: uuid.NewV4().String(), SourceID: newAction.ID, DestinationID: insertBeforeID, + }) + workflow.Actions = append(workflow.Actions, newAction) + return workflow + } + } + + if workflow.Start == insertBeforeID { + workflow.Start = newAction.ID + newAction.IsStartNode = true + workflow.Branches = append(workflow.Branches, Branch{ + ID: uuid.NewV4().String(), SourceID: newAction.ID, DestinationID: insertBeforeID, + }) + for _, trigger := range workflow.Triggers { + workflow.Branches = append(workflow.Branches, Branch{ + ID: uuid.NewV4().String(), SourceID: trigger.ID, DestinationID: newAction.ID, + }) + } + } + workflow.Actions = append(workflow.Actions, newAction) + return workflow + } + } + + // Case 3: No position specified - add at the end + if len(workflow.Actions) > 0 { + lastAction := workflow.Actions[len(workflow.Actions)-1] + newAction.Position = Position{X: lastAction.Position.X + nodeSpacing, Y: lastAction.Position.Y} + workflow.Branches = append(workflow.Branches, Branch{ + ID: uuid.NewV4().String(), SourceID: lastAction.ID, DestinationID: newAction.ID, + }) + } else if len(workflow.Triggers) > 0 { + lastTrigger := workflow.Triggers[len(workflow.Triggers)-1] + newAction.Position = Position{X: lastTrigger.Position.X + nodeSpacing, Y: lastTrigger.Position.Y} + newAction.IsStartNode = true + workflow.Start = newAction.ID + workflow.Branches = append(workflow.Branches, Branch{ + ID: uuid.NewV4().String(), SourceID: lastTrigger.ID, DestinationID: newAction.ID, + }) + } else { + newAction.Position = Position{X: defaultX, Y: defaultY} + newAction.IsStartNode = true + workflow.Start = newAction.ID + } + + workflow.Actions = append(workflow.Actions, newAction) + return workflow +} + +// findNodePositionByID finds a node's position by ID +func findNodePositionByID(workflow *Workflow, nodeID string) *Position { + for _, trigger := range workflow.Triggers { + if trigger.ID == nodeID { + return &Position{X: trigger.Position.X, Y: trigger.Position.Y} + } + } + for _, action := range workflow.Actions { + if action.ID == nodeID { + return &Position{X: action.Position.X, Y: action.Position.Y} + } + } + return nil +} + +// buildWorkflowStructureText creates a simple text representation of the workflow +func buildWorkflowStructureText(workflow *Workflow) string { + if workflow == nil { + return "Empty workflow (no nodes)" + } + + var lines []string + connections := make(map[string][]string) + for _, branch := range workflow.Branches { + connections[branch.SourceID] = append(connections[branch.SourceID], branch.DestinationID) + } + + nodeInfo := make(map[string]string) + + for i, trigger := range workflow.Triggers { + label := trigger.Label + if label == "" { + label = fmt.Sprintf("trigger_%d", i) + } + info := fmt.Sprintf("%s (%s)", label, trigger.AppName) + nodeInfo[trigger.ID] = info + lines = append(lines, fmt.Sprintf("[TRIGGER] %s", info)) + } + + actionMap := make(map[string]Action) + for _, action := range workflow.Actions { + actionMap[action.ID] = action + } + + visited := make(map[string]bool) + var orderedActions []Action + startID := workflow.Start + if startID == "" && len(workflow.Actions) > 0 { + startID = workflow.Actions[0].ID + } + + queue := []string{startID} + for len(queue) > 0 { + currentID := queue[0] + queue = queue[1:] + if visited[currentID] { + continue + } + visited[currentID] = true + if action, ok := actionMap[currentID]; ok { + orderedActions = append(orderedActions, action) + } + for _, destID := range connections[currentID] { + if !visited[destID] { + queue = append(queue, destID) + } + } + } + + for _, action := range workflow.Actions { + if !visited[action.ID] { + orderedActions = append(orderedActions, action) + } + } + + for i, action := range orderedActions { + label := action.Label + if label == "" { + label = fmt.Sprintf("action_%d", i) + } + info := fmt.Sprintf("%s (%s)", label, action.AppName) + nodeInfo[action.ID] = info + lines = append(lines, fmt.Sprintf("[ACTION %d] %s", i+1, info)) + } + + if len(connections) > 0 { + lines = append(lines, "\nConnections:") + for sourceID, destIDs := range connections { + sourceName := nodeInfo[sourceID] + if sourceName == "" { + sourceName = sourceID + } + for _, destID := range destIDs { + destName := nodeInfo[destID] + if destName == "" { + destName = destID + } + lines = append(lines, fmt.Sprintf(" %s -> %s", sourceName, destName)) + } + } + } + + if len(lines) == 0 { + return "Empty workflow (no nodes)" + } + return strings.Join(lines, "\n") +} + +func classifyMultipleIntents(userInput string, workflow *Workflow) (*WorkflowIntentResponse, error) { + workflowStructure := buildWorkflowStructureText(workflow) + + systemMessage := `You are a Workflow Intent Classifier for Shuffle, a security automation platform. + +Your job: Parse the user's edit request into atomic tasks, and determine WHERE each change should happen. + +AVAILABLE INTENTS: +1) ADD_NODE - Add a new action/step. Specify insert_after OR insert_before. +2) MODIFY_ACTION_PARAMETER - Change a setting/value in an existing node. Specify target_node. +3) REMOVE_NODE - Delete an existing node. Specify target_node. +4) ADD_CONDITION - Add if-then logic between two nodes. +5) REMOVE_CONDITION - Remove a condition. +6) NO_ACTION_NEEDED - Request is unclear or needs no changes. + +OUTPUT FORMAT (JSON only): +{ + "tasks": [ + { + "intent": "INTENT_NAME", + "target_node": "label of existing node (for MODIFY/REMOVE)", + "source_text": "exact part of user request for this task", + "insert_after": "label of node to insert AFTER (for ADD_NODE)", + "insert_before": "label of node to insert BEFORE (for ADD_NODE)" + } + ] +} + +RULES FOR ADD_NODE: +- "add X after Y" -> insert_after: "Y_label" +- "add X before Y" -> insert_before: "Y_label" +- "add X at the end" -> insert_after: last node's label +- If position unclear, default to insert_after the last node + +RULES FOR REMOVE_NODE: +- "remove the Slack step" -> target_node: "slack_notify" (match the label) +- "delete the Jira action" -> target_node: "jira_create_ticket" + +RULES FOR ADD_CONDITION: +- "only proceed to Slack if Jira succeeds" -> target_node: "jira_label", insert_before: "slack_label" +- "add condition after X" -> target_node: "X_label" (condition on outgoing branch) +- target_node = source node, insert_before = destination node + +RULES FOR REMOVE_CONDITION: +- "remove condition from Jira to Slack" -> target_node: "jira_label", insert_before: "slack_label" +- "remove condition after X" -> target_node: "X_label" + +TASK ORDERING (CRITICAL): +Tasks are executed in array order. Order them so dependencies are satisfied: +- ADD_NODE before any task that references the new node +- ADD_NODE before ADD_CONDITION on that node's branch +- REMOVE_CONDITION before REMOVE_NODE (remove conditions first) +- Generally: create before modify/condition, modify before remove + +Use EXACT labels from the workflow structure. Match by app name if user doesn't use exact labels.` + + userPrompt := fmt.Sprintf("CURRENT WORKFLOW:\n%s\n\nUSER REQUEST: \"%s\"\n\nReturn ONLY the JSON.", workflowStructure, userInput) + + finalContentOutput, err := RunAiQuery(systemMessage, userPrompt) + if err != nil { + log.Printf("[ERROR] Failed to run AI query in classifyMultipleIntents: %s", err) return nil, err } @@ -11284,7 +11938,6 @@ func classifyMultipleIntents(userInput string) (*WorkflowIntentResponse, error) log.Printf("[DEBUG] classifyMultipleIntents LLM output: %s", finalContentOutput) - // Parse the JSON response into our struct var intentResponse WorkflowIntentResponse err = json.Unmarshal([]byte(finalContentOutput), &intentResponse) if err != nil { From 696a18ca9cb84f2c01f2c72e90ed460c5077657b Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Tue, 2 Dec 2025 18:30:22 +0530 Subject: [PATCH 10/13] clean up formatting and implement add condition task handler --- ai.go | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/ai.go b/ai.go index 3864abb3..ebf0b189 100644 --- a/ai.go +++ b/ai.go @@ -10807,7 +10807,7 @@ func HandleEditWorkflowWithLLM(resp http.ResponseWriter, request *http.Request) return } - output, err := editWorkflowWithLLMV2(ctx, workflow, user, editRequest) + output, err := editWorkflowWithLLMV2(ctx, workflow, user, editRequest) if err != nil { reason := err.Error() @@ -11297,13 +11297,23 @@ func fixBranchesAfterRemoval(workflow *Workflow, removedID string) { } } - return workflow, nil -} +func handleAddConditionTask(workflow *Workflow, task WorkflowIntentTask) (*Workflow, error) { + // Silent fail: no target node specified + if task.TargetNode == nil || *task.TargetNode == "" { + log.Printf("[WARN] ADD_CONDITION: no target_node (source node) specified, skipping") + return workflow, nil + } -func handleRemoveNodeTask(ctx context.Context, workflow *Workflow, task WorkflowIntentTask, environment string, user User) (*Workflow, error) { - // Implement logic to remove a node from the workflow based on the task details - return workflow, nil -} + // target_node = source node (where condition checks from) + // insert_before = destination node (where flow goes if condition passes) + sourceLabel := strings.ToLower(*task.TargetNode) + + // Find source node ID + sourceID := findNodeIDByLabel(workflow, sourceLabel) + if sourceID == "" { + log.Printf("[WARN] ADD_CONDITION: source node '%s' not found, skipping", sourceLabel) + return workflow, nil + } // Find destination node ID if specified var destID string From 5db63e140be3addda53d86b93bb62348ef9aca1e Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Wed, 3 Dec 2025 14:10:10 +0530 Subject: [PATCH 11/13] Revert "fixing some indentation mess" This reverts commit 9b9f51b37fb3830603edfa8a963093ece173d241. --- blobs.go | 210 +++++++++++++++++++++++++++---------------------------- 1 file changed, 104 insertions(+), 106 deletions(-) diff --git a/blobs.go b/blobs.go index 3e53a875..6f20c58a 100644 --- a/blobs.go +++ b/blobs.go @@ -5,27 +5,26 @@ This file is for blobs that we use throughout Shuffle in many locations. If we w */ import ( - "os" - "errors" - "strings" "context" + "encoding/json" + "errors" "fmt" "log" - "encoding/json" + "os" + "strings" uuid "github.com/satori/go.uuid" ) - // These are just specific examples for specific cases // FIXME: Should these be loaded from public workflows? // I kind of think so ~ // That means each algorithm needs to be written as if-statements to // replace a specific part of a workflow :thinking: -// Should workflows be written as YAML and be text-editable? +// Should workflows be written as YAML and be text-editable? func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction CategoryAction) (Workflow, error) { - actionType := categoryAction.Label + actionType := categoryAction.Label appNames := categoryAction.AppName if len(orgId) == 0 { @@ -38,7 +37,7 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca } // If-else with specific rules per workflow - // Make sure it uses workflow -> copies data, as + // Make sure it uses workflow -> copies data, as startActionId := uuid.NewV4().String() startTriggerId := workflow.ID if len(startTriggerId) == 0 { @@ -52,41 +51,41 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca triggerEnv = "onprem" envs, err := GetEnvironments(ctx, orgId) - if err == nil { + if err == nil { for _, env := range envs { if env.Default { actionEnv = env.Name break } } - } else { + } else { actionEnv = "Shuffle" } } if parsedActiontype == "correlate_categories" { defaultWorkflow := Workflow{ - Name: actionType, + Name: actionType, Description: "Correlates Datastore categories in Shuffle. The point is to graph data", - OrgId: orgId, - Start: startActionId, + OrgId: orgId, + Start: startActionId, Actions: []Action{ Action{ - ID: startActionId, - Name: "repeat_back_to_me", - AppName: "Shuffle Tools", - AppVersion: "1.2.0", + ID: startActionId, + Name: "repeat_back_to_me", + AppName: "Shuffle Tools", + AppVersion: "1.2.0", Environment: actionEnv, - Label: "Start", - IsStartNode: true, + Label: "Start", + IsStartNode: true, Position: Position{ X: 250, Y: 0, }, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ - Name: "call", - Value: "Some code here hello", + Name: "call", + Value: "Some code here hello", Multiline: true, }, }, @@ -131,7 +130,7 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca } defaultWorkflow := Workflow{ - Name: actionType, + Name: actionType, Description: "List tickets from different systems and ingest them", OrgId: orgId, Start: startActionId, @@ -146,7 +145,7 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca ID: startActionId, AppVersion: "1.0.0", Environment: actionEnv, - Label: currentAction.Value, + Label: currentAction.Value, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ Name: "app_name", @@ -154,8 +153,8 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca }, currentAction, WorkflowAppActionParameter{ - Name: "fields", - Value: "", + Name: "fields", + Value: "", Multiline: true, }, }, @@ -163,19 +162,19 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca }, Triggers: []Trigger{ Trigger{ - ID: startTriggerId, - Name: "Schedule", + ID: startTriggerId, + Name: "Schedule", TriggerType: "SCHEDULE", - Label: "Ingest tickets", + Label: "Ingest tickets", Environment: triggerEnv, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ Name: "cron", - Value: "0 0 * * *", + Value: "0 0 * * *", }, WorkflowAppActionParameter{ Name: "execution_argument", - Value: "Automatically configured by Shuffle", + Value: "Automatically configured by Shuffle", }, }, }, @@ -202,28 +201,28 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca ID: startActionId, AppVersion: "1.0.0", Environment: actionEnv, - Label: "Ingest Ticket from Webhook", + Label: "Ingest Ticket from Webhook", Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ - Name: "source_data", - Value: "$exec", + Name: "source_data", + Value: "$exec", Multiline: true, }, WorkflowAppActionParameter{ - Name: "standard", + Name: "standard", Description: "The standard to use from https://github.com/Shuffle/standards/tree/main", - Value: "OCSF", - Multiline: false, + Value: "OCSF", + Multiline: false, }, }, }, }, Triggers: []Trigger{ Trigger{ - ID: startTriggerId, - Name: "Webhook", + ID: startTriggerId, + Name: "Webhook", TriggerType: "WEBHOOK", - Label: "Ingest", + Label: "Ingest", Environment: triggerEnv, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ @@ -232,19 +231,19 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca }, WorkflowAppActionParameter{ Name: "tmp", - Value: "", + Value: "", }, WorkflowAppActionParameter{ Name: "auth_header", - Value: "", + Value: "", }, WorkflowAppActionParameter{ Name: "custom_response_body", - Value: "", + Value: "", }, WorkflowAppActionParameter{ Name: "await_response", - Value: "", + Value: "", }, }, }, @@ -327,7 +326,7 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca secondActionId := uuid.NewV4().String() defaultWorkflow := Workflow{ - Name: actionType, + Name: actionType, Description: "Monitor threatlists and ingest regularly", OrgId: orgId, Start: startActionId, @@ -335,84 +334,84 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca Tags: []string{"ingest", "feeds", "automatic"}, Actions: []Action{ Action{ - Name: "GET", - AppID: "HTTP", - AppName: "HTTP", - ID: startActionId, - AppVersion: "1.4.0", + Name: "GET", + AppID: "HTTP", + AppName: "HTTP", + ID: startActionId, + AppVersion: "1.4.0", Environment: actionEnv, - Label: "Get threatlist URLs", + Label: "Get threatlist URLs", Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ Name: "url", Value: "$shuffle_cache.threatlist_urls.value.#", }, WorkflowAppActionParameter{ - Name: "headers", + Name: "headers", Multiline: true, - Value: "", + Value: "", }, }, }, Action{ - Name: "execute_python", - AppID: "Shuffle Tools", - AppName: "Shuffle Tools", - ID: secondActionId, - AppVersion: "1.2.0", + Name: "execute_python", + AppID: "Shuffle Tools", + AppName: "Shuffle Tools", + ID: secondActionId, + AppVersion: "1.2.0", Environment: actionEnv, - Label: "Ingest IOCs", + Label: "Ingest IOCs", Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ - Name: "code", + Name: "code", Multiline: true, - Required: true, - Value: getIocIngestionScript(), + Required: true, + Value: getIocIngestionScript(), }, }, }, }, Triggers: []Trigger{ Trigger{ - ID: startTriggerId, - Name: "Schedule", + ID: startTriggerId, + Name: "Schedule", TriggerType: "SCHEDULE", - Label: "Pull threatlist URLs", + Label: "Pull threatlist URLs", Environment: triggerEnv, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ Name: "cron", - Value: "0 0 * * *", + Value: "0 0 * * *", }, WorkflowAppActionParameter{ Name: "execution_argument", - Value: "Automatically configured by Shuffle", + Value: "Automatically configured by Shuffle", }, }, }, }, Branches: []Branch{ Branch{ - SourceID: startTriggerId, + SourceID: startTriggerId, DestinationID: startActionId, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), }, Branch{ - SourceID: startActionId, + SourceID: startActionId, DestinationID: secondActionId, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), Conditions: []Condition{ Condition{ Source: WorkflowAppActionParameter{ - Name: "source", + Name: "source", Value: "{{ $get_threatlist_urls | size }}", }, Condition: WorkflowAppActionParameter{ - Name: "condition", + Name: "condition", Value: "larger than", }, Destination: WorkflowAppActionParameter{ - Name: "destination", + Name: "destination", Value: "0", }, }, @@ -426,18 +425,18 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca workflow.OrgId = orgId /* - if len(workflow.WorkflowVariables) == 0 { - workflow.WorkflowVariables = defaultWorkflow.WorkflowVariables - } + if len(workflow.WorkflowVariables) == 0 { + workflow.WorkflowVariables = defaultWorkflow.WorkflowVariables + } - if len(workflow.Actions) == 0 { - workflow.Actions = defaultWorkflow.Actions - } + if len(workflow.Actions) == 0 { + workflow.Actions = defaultWorkflow.Actions + } - // Rules specific to this one - if len(workflow.Triggers) == 0 { - workflow.Triggers = defaultWorkflow.Triggers - } + // Rules specific to this one + if len(workflow.Triggers) == 0 { + workflow.Triggers = defaultWorkflow.Triggers + } */ // Get the item with key "threatlist_urls" from datastore @@ -454,7 +453,7 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca log.Printf("[ERROR] Failed to marshal threatlist URLs: %s", err) } else { key := CacheKeyData{ - Key: "threatlist_urls", + Key: "threatlist_urls", Value: fmt.Sprintf(`%s`, string(jsonMarshalled)), OrgId: orgId, } @@ -486,21 +485,21 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca // Pre-defining it with a startnode that does nothing workflow.Actions = []Action{ Action{ - ID: startActionId, - Name: "repeat_back_to_me", - AppName: "Shuffle Tools", - AppVersion: "1.2.0", + ID: startActionId, + Name: "repeat_back_to_me", + AppName: "Shuffle Tools", + AppVersion: "1.2.0", Environment: actionEnv, - Label: "Start", - IsStartNode: true, + Label: "Start", + IsStartNode: true, Position: Position{ X: 250, Y: 0, }, Parameters: []WorkflowAppActionParameter{ WorkflowAppActionParameter{ - Name: "call", - Value: "", + Name: "call", + Value: "", Multiline: true, }, }, @@ -508,11 +507,11 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca } // Point from trigger(s) to startnode (repeater) - for _, trigger := range workflow.Triggers { + for _, trigger := range workflow.Triggers { newBranch := Branch{ - SourceID: trigger.ID, + SourceID: trigger.ID, DestinationID: workflow.Start, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), } workflow.Branches = append(workflow.Branches, newBranch) @@ -524,15 +523,14 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca newAction.Parameters = append([]WorkflowAppActionParameter(nil), actionTemplate.Parameters...) // Positioning - newAction.Position.X = positionAddition*float64(appIndex) + newAction.Position.X = positionAddition * float64(appIndex) newAction.Position.Y = positionAddition - // Point from startnode to current one newBranch := Branch{ - SourceID: workflow.Start, + SourceID: workflow.Start, DestinationID: newAction.ID, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), } workflow.Branches = append(workflow.Branches, newBranch) @@ -571,16 +569,16 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca Y: startYPosition, } - startXPosition += positionAddition + startXPosition += positionAddition } for actionIndex, _ := range workflow.Actions { workflow.Actions[actionIndex].Position = Position{ X: startXPosition, - Y: startYPosition, + Y: startYPosition, } - startXPosition += positionAddition + startXPosition += positionAddition } } @@ -609,9 +607,9 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca } newBranch := Branch{ - SourceID: sourceId, + SourceID: sourceId, DestinationID: destId, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), } workflow.Branches = append(workflow.Branches, newBranch) @@ -637,9 +635,9 @@ func GetDefaultWorkflowByType(workflow Workflow, orgId string, categoryAction Ca log.Printf("Missing branch: %s", action.ID) // Create a branch from the previous action to this one workflow.Branches = append(workflow.Branches, Branch{ - SourceID: workflow.Actions[actionIndex-1].ID, + SourceID: workflow.Actions[actionIndex-1].ID, DestinationID: action.ID, - ID: uuid.NewV4().String(), + ID: uuid.NewV4().String(), }) } } From 9004d4e237fcc7da37e62c13a8fb872690d8e6ca Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Wed, 3 Dec 2025 14:11:39 +0530 Subject: [PATCH 12/13] Revert "added demo json data for wazuh, jira and slack" This reverts commit b84fd690f5f44ed36097d0d0661e5ba4c726ece8. --- blobs.go | 112 ------------------------------------------------------- 1 file changed, 112 deletions(-) diff --git a/blobs.go b/blobs.go index 6f20c58a..3db13808 100644 --- a/blobs.go +++ b/blobs.go @@ -869,118 +869,6 @@ func GetAllAppCategories() []AppCategory { return categories } -// Wazuh demo responses based on action type -func generateWazuhDemoData(actionName string) string { - responses := map[string]string{ - "get_alerts": `{ - "alerts": [ - {"id": "12345", "severity": "High", "source_ip": "192.168.1.100", "description": "Suspicious login attempt", "timestamp": "2025-09-22T10:30:00Z"}, - {"id": "12346", "severity": "Medium", "source_ip": "10.0.0.45", "description": "Failed authentication", "timestamp": "2025-09-22T10:28:15Z"}, - {"id": "12347", "severity": "Critical", "source_ip": "203.0.113.45", "description": "Potential malware detected", "timestamp": "2025-09-22T10:25:30Z"} - ], - "total_count": 3, - "status": "success" - }`, - "query_logs": `{ - "logs": [ - {"timestamp": "2025-09-22T10:30:00Z", "level": "WARNING", "message": "Multiple failed login attempts from 192.168.1.100", "agent": "web-server-01"}, - {"timestamp": "2025-09-22T10:28:15Z", "level": "ERROR", "message": "Authentication failure for user 'admin'", "agent": "db-server-02"} - ], - "query_time": "0.245s", - "status": "completed" - }`, - "get_agent_status": `{ - "agents": [ - {"id": "001", "name": "web-server-01", "status": "active", "last_seen": "2025-09-22T10:30:00Z"}, - {"id": "002", "name": "db-server-02", "status": "active", "last_seen": "2025-09-22T10:29:45Z"}, - {"id": "003", "name": "file-server-01", "status": "disconnected", "last_seen": "2025-09-22T09:15:22Z"} - ], - "total_agents": 3, - "active_agents": 2 - }`, - } - - if response, exists := responses[strings.ToLower(actionName)]; exists { - return response - } - return `{"alert_id": "DEMO-12345", "severity": "High", "source_ip": "192.168.1.100", "description": "Security alert detected", "status": "active"}` -} - -// Jira demo responses based on action type -func generateJiraDemoData(actionName string) string { - responses := map[string]string{ - "create_issue": `{ - "id": "10042", - "key": "SEC-123", - "self": "https://company.atlassian.net/rest/api/3/issue/10042" - }`, - "update_issue": `{ - "key": "SEC-123", - "id": "10001", - "fields": { - "status": {"name": "In Progress", "id": "3"}, - "assignee": {"displayName": "John Doe", "emailAddress": "john.doe@company.com"}, - "updated": "2025-09-22T10:35:00.000+0000" - }, - "status": "updated" - }`, - "get_issue": `{ - "key": "SEC-123", - "id": "10001", - "fields": { - "summary": "Security Alert: Suspicious Activity Detected", - "description": "Multiple failed login attempts detected from IP 192.168.1.100", - "status": {"name": "Open", "id": "1"}, - "priority": {"name": "High", "id": "2"}, - "assignee": {"displayName": "Security Team", "emailAddress": "security@company.com"}, - "created": "2025-09-22T10:30:00.000+0000" - } - }`, - "add_comment": `{ - "id": "10100", - "body": "Automated comment: Security alert has been processed and ticket created.", - "author": {"displayName": "Shuffle Automation", "emailAddress": "automation@company.com"}, - "created": "2025-09-22T10:35:00.000+0000", - "status": "added" - }`, - } - - if response, exists := responses[strings.ToLower(actionName)]; exists { - return response - } - return `{"ticket_id": "DEMO-123", "key": "DEMO-123", "status": "Created", "assignee": "security-team", "summary": "Demo ticket created"}` -} - -// Slack demo responses -func generateSlackDemoData(actionName string) string { - responses := map[string]string{ - "send_message": `{ - "ok": true, - "channel": "C1234567890", - "ts": "1695384900.123456", - "message": { - "text": "High priority incident detected. Jira ticket SEC-123 created and assigned to security team.", - "user": "U0123456789", - "ts": "1695384900.123456" - } - }`, - "create_channel": `{ - "ok": true, - "channel": { - "id": "C9876543210", - "name": "incident-2025-09-22", - "created": 1695384900, - "creator": "U0123456789" - } - }`, - } - - if response, exists := responses[strings.ToLower(actionName)]; exists { - return response - } - return `{"ok": true, "channel": "C1234567890", "ts": "1695384900.123456", "status": "message_sent"}` -} - // Simple check func AllowedImportPath() string { return strings.Join([]string{"github.com", "shuffle", "shuffle-shared"}, "/") From 29856c358d9eb3e72995fbfcab5cff78d3033ec6 Mon Sep 17 00:00:00 2001 From: Hari Krishna Date: Wed, 3 Dec 2025 14:11:49 +0530 Subject: [PATCH 13/13] Revert "feat: implement simulate workflow mechanism" This reverts commit 1c7ceb1f4ad2e7ba89649b99aad2a3473067b866. --- shared.go | 200 ------------------------------------------------------ 1 file changed, 200 deletions(-) diff --git a/shared.go b/shared.go index 862199bd..22926b16 100755 --- a/shared.go +++ b/shared.go @@ -33846,203 +33846,3 @@ func GetDockerClient() (*dockerclient.Client, string, error) { return cli, dockerApiVersion, err } - -func HandleSimulateWorkflow(resp http.ResponseWriter, request *http.Request) { - cors := HandleCors(resp, request) - if cors { - return - } - - // This is temporary, and we should also allow anonymous demo execution - _, userErr := HandleApiAuthentication(resp, request) - if userErr != nil { - log.Printf("[WARNING] Api authentication failed in simulate Workflow: %s", userErr) - resp.WriteHeader(401) - resp.Write([]byte(`{"success": false}`)) - return - } - - location := strings.Split(request.URL.String(), "/") - var workflowId string - if location[1] == "api" { - if len(location) <= 4 { - log.Printf("[WARNING] Path too short: %d", len(location)) - resp.WriteHeader(400) - resp.Write([]byte(`{"success": false, "reason": "Workflow path not found"}`)) - return - } - - workflowId = location[4] - } - - if len(workflowId) != 36 { - log.Printf("[WARNING] Bad workflow ID: %s", workflowId) - resp.WriteHeader(400) - resp.Write([]byte(`{"success": false, "reason": "Bad workflow ID"}`)) - return - } - - log.Printf("[INFO] Starting workflow simulation for ID: %s", workflowId) - - ctx := GetContext(request) - workflow, err := GetWorkflow(ctx, workflowId, true) - if err != nil { - log.Printf("[WARNING] Failed getting workflow %s for simulation: %s", workflowId, err) - resp.WriteHeader(400) - resp.Write([]byte(`{"success": false, "reason": "Workflow not found"}`)) - return - } - - if !workflow.Public && workflow.Sharing != "public" { - log.Printf("[INFO] Workflow %s is not public, but allowing demo simulation anyway", workflowId) - } - - finalExecution := simulateWorkflowExecutionNew(ctx, workflow) - - response, err := json.Marshal(finalExecution) - if err != nil { - log.Printf("[ERROR] Failed to marshal demo execution: %s", err) - resp.WriteHeader(500) - resp.Write([]byte(`{"success": false, "reason": "Failed to serialize demo execution"}`)) - return - } - - log.Printf("[DEBUG] Returning complete execution with %d results", len(finalExecution.Results)) - resp.WriteHeader(200) - resp.Write(response) -} - -// implementation focusing on proper node traversal and realistic output -func simulateWorkflowExecutionNew(ctx context.Context, workflow *Workflow) WorkflowExecution { - workflowExecution := WorkflowExecution{ - Type: "workflow", - Status: "EXECUTING", - Start: workflow.Start, - WorkflowId: workflow.ID, - Result: "", - StartedAt: int64(time.Now().Unix()), - Workflow: *workflow, - ExecutionSource: "default", - } - - executionOrder := getNodeExecutionOrder(*workflow) - if len(executionOrder) == 0 { - log.Printf("[WARNING] No executable nodes found in workflow %s", workflowExecution.WorkflowId) - workflowExecution.Status = "FINISHED" - workflowExecution.CompletedAt = int64(time.Now().Unix()) - return workflowExecution - } - - var allResults []ActionResult - - for i, node := range executionOrder { - log.Printf("[INFO] DEMO: Executing node %d: %s (%s)", i+1, node.Label, node.AppName) - - demoData := generateDemoDataForNode(node.AppName, node.Name) - - actionResult := ActionResult{ - ExecutionId: workflowExecution.ExecutionId, - Action: node, - Result: demoData, - Status: "SUCCESS", - StartedAt: int64(time.Now().Unix() - int64(len(allResults)*2)), - CompletedAt: int64(time.Now().Unix() - int64(len(allResults)*2) + 1), - } - - allResults = append(allResults, actionResult) - } - - workflowExecution.Results = allResults - workflowExecution.Status = "FINISHED" - - if len(allResults) > 0 { - workflowExecution.LastNode = allResults[len(allResults)-1].Action.ID - workflowExecution.Result = allResults[len(allResults)-1].Result - } - - workflowExecution.CompletedAt = int64(time.Now().Unix()) - - return workflowExecution -} - -// Helper function to determine execution order based on workflow.Branches connections -func getNodeExecutionOrder(workflow Workflow) []Action { - - var executionOrder []Action - visited := make(map[string]bool) - actionMap := make(map[string]Action) - - for _, action := range workflow.Actions { - actionMap[action.ID] = action - } - - for _, trigger := range workflow.Triggers { - triggerAction := Action{ - ID: trigger.ID, - Label: trigger.Label, - AppName: trigger.AppName, - Name: trigger.Name, - } - executionOrder = append(executionOrder, triggerAction) - visited[trigger.ID] = true - } - - var startActionID string - for _, action := range workflow.Actions { - if action.IsStartNode { - startActionID = action.ID - break - } - } - - if startActionID == "" { - return executionOrder - } - - if startAction, exists := actionMap[startActionID]; exists { - executionOrder = append(executionOrder, startAction) - visited[startActionID] = true - } - - for { - foundNew := false - - for _, branch := range workflow.Branches { - if visited[branch.SourceID] && !visited[branch.DestinationID] { - if action, exists := actionMap[branch.DestinationID]; exists { - executionOrder = append(executionOrder, action) - visited[branch.DestinationID] = true - foundNew = true - } - } - } - - if !foundNew { - break - } - } - - for _, action := range workflow.Actions { - if !visited[action.ID] { - log.Printf("[INFO] DEMO: SKIPPING disconnected action: %s (not connected to main workflow)", action.Label) - } - } - - return executionOrder -} - -// Demo data generation functions for workflow simulation -func generateDemoDataForNode(appName string, actionName string) string { - - switch strings.ToLower(appName) { - case "wazuh": - return generateWazuhDemoData(actionName) - case "jira": - return generateJiraDemoData(actionName) - case "slack": - return generateSlackDemoData(actionName) - default: - return fmt.Sprintf(`{"app": "%s", "action": "%s", "status": "completed", "demo": true, "timestamp": "%s"}`, - appName, actionName, time.Now().Format("2006-01-02T15:04:05Z")) - } -} \ No newline at end of file