|
|
@@ -15,6 +15,7 @@ import (
|
|
|
"gogs.dmsc.dev/arp/graph/model"
|
|
|
"gogs.dmsc.dev/arp/logging"
|
|
|
"gogs.dmsc.dev/arp/models"
|
|
|
+ "gogs.dmsc.dev/arp/workflow"
|
|
|
)
|
|
|
|
|
|
// Login is the resolver for the login field.
|
|
|
@@ -649,7 +650,7 @@ func (r *mutationResolver) UpdateTask(ctx context.Context, id string, input mode
|
|
|
}
|
|
|
|
|
|
var existing models.Task
|
|
|
- if err := r.DB.Preload("CreatedBy").Preload("Assignee").First(&existing, taskID).Error; err != nil {
|
|
|
+ if err := r.DB.Preload("CreatedBy").Preload("Assignee").Preload("Status").First(&existing, taskID).Error; err != nil {
|
|
|
return nil, fmt.Errorf("task not found: %w", err)
|
|
|
}
|
|
|
|
|
|
@@ -710,6 +711,35 @@ func (r *mutationResolver) UpdateTask(ctx context.Context, id string, input mode
|
|
|
graphqlTask := convertTask(existing)
|
|
|
r.PublishTaskEvent(graphqlTask, existing.AssigneeID, "updated")
|
|
|
|
|
|
+ // Workflow integration: Check if task is associated with a workflow node
|
|
|
+ // Look up the workflow node by task ID
|
|
|
+ var workflowNode models.WorkflowNode
|
|
|
+ if err := r.DB.Where("task_id = ?", existing.ID).First(&workflowNode).Error; err == nil && workflowNode.ID > 0 {
|
|
|
+ // Get the workflow engine
|
|
|
+ workflowEngine := workflow.NewEngine(r.DB)
|
|
|
+
|
|
|
+ // Check if status changed to "done" (status code "done")
|
|
|
+ if input.StatusID != nil && *input.StatusID != "" {
|
|
|
+ var newStatus models.TaskStatus
|
|
|
+ if err := r.DB.First(&newStatus, existing.StatusID).Error; err == nil && newStatus.Code == "done" {
|
|
|
+ // Mark node as completed
|
|
|
+ if err := workflowEngine.MarkNodeCompleted(workflowNode.ID); err != nil {
|
|
|
+ fmt.Printf("ERROR: workflow_node_complete node_id=%d error=%v\n", workflowNode.ID, err)
|
|
|
+ }
|
|
|
+ } else if input.StatusID != nil && *input.StatusID != "" {
|
|
|
+ // Check for cancelled/failed status
|
|
|
+ if err := r.DB.First(&newStatus, existing.StatusID).Error; err == nil {
|
|
|
+ if newStatus.Code == "cancelled" || newStatus.Code == "failed" {
|
|
|
+ // Mark node as failed
|
|
|
+ if err := workflowEngine.MarkNodeFailed(workflowNode.ID, fmt.Sprintf("task status changed to %s", newStatus.Code)); err != nil {
|
|
|
+ fmt.Printf("ERROR: workflow_node_fail node_id=%d error=%v\n", workflowNode.ID, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
logging.LogMutation(ctx, "UPDATE", "TASK", existing.Title)
|
|
|
return graphqlTask, nil
|
|
|
}
|
|
|
@@ -951,6 +981,258 @@ func (r *mutationResolver) DeleteMessage(ctx context.Context, id string) (bool,
|
|
|
return result.RowsAffected > 0, nil
|
|
|
}
|
|
|
|
|
|
+// CreateWorkflowTemplate is the resolver for the createWorkflowTemplate field.
|
|
|
+func (r *mutationResolver) CreateWorkflowTemplate(ctx context.Context, input model.NewWorkflowTemplate) (*model.WorkflowTemplate, error) {
|
|
|
+ // Auth check
|
|
|
+ if !auth.IsAuthenticated(ctx) {
|
|
|
+ return nil, errors.New("unauthorized: authentication required")
|
|
|
+ }
|
|
|
+ if !auth.HasPermission(ctx, "workflow:create") {
|
|
|
+ return nil, errors.New("unauthorized: missing workflow:create permission")
|
|
|
+ }
|
|
|
+
|
|
|
+ currentUser, err := auth.CurrentUser(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to get current user: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var isActive bool
|
|
|
+ if input.IsActive != nil {
|
|
|
+ isActive = *input.IsActive
|
|
|
+ } else {
|
|
|
+ isActive = true
|
|
|
+ }
|
|
|
+
|
|
|
+ workflowTemplate := models.WorkflowTemplate{
|
|
|
+ Name: input.Name,
|
|
|
+ Description: func() string {
|
|
|
+ if input.Description != nil {
|
|
|
+ return *input.Description
|
|
|
+ }
|
|
|
+ return ""
|
|
|
+ }(),
|
|
|
+ Definition: input.Definition,
|
|
|
+ IsActive: isActive,
|
|
|
+ CreatedByID: currentUser.ID,
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := r.DB.Create(&workflowTemplate).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to create workflow template: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Reload with associations
|
|
|
+ r.DB.Preload("CreatedBy").First(&workflowTemplate, workflowTemplate.ID)
|
|
|
+
|
|
|
+ logging.LogMutation(ctx, "CREATE", "WORKFLOW_TEMPLATE", workflowTemplate.Name)
|
|
|
+ return convertWorkflowTemplate(workflowTemplate), nil
|
|
|
+}
|
|
|
+
|
|
|
+// UpdateWorkflowTemplate is the resolver for the updateWorkflowTemplate field.
|
|
|
+func (r *mutationResolver) UpdateWorkflowTemplate(ctx context.Context, id string, input model.UpdateWorkflowTemplateInput) (*model.WorkflowTemplate, error) {
|
|
|
+ // Auth check
|
|
|
+ if !auth.IsAuthenticated(ctx) {
|
|
|
+ return nil, errors.New("unauthorized: authentication required")
|
|
|
+ }
|
|
|
+ if !auth.HasPermission(ctx, "workflow:manage") {
|
|
|
+ return nil, errors.New("unauthorized: missing workflow:manage permission")
|
|
|
+ }
|
|
|
+
|
|
|
+ templateID, err := toID(id)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("invalid workflow template ID: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var existing models.WorkflowTemplate
|
|
|
+ if err := r.DB.First(&existing, templateID).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("workflow template not found: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if input.Name != nil {
|
|
|
+ existing.Name = *input.Name
|
|
|
+ }
|
|
|
+ if input.Description != nil {
|
|
|
+ existing.Description = *input.Description
|
|
|
+ }
|
|
|
+ if input.Definition != nil {
|
|
|
+ existing.Definition = *input.Definition
|
|
|
+ }
|
|
|
+ if input.IsActive != nil {
|
|
|
+ existing.IsActive = *input.IsActive
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := r.DB.Save(&existing).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to update workflow template: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Reload with associations for response
|
|
|
+ r.DB.Preload("CreatedBy").First(&existing, existing.ID)
|
|
|
+
|
|
|
+ logging.LogMutation(ctx, "UPDATE", "WORKFLOW_TEMPLATE", existing.Name)
|
|
|
+ return convertWorkflowTemplate(existing), nil
|
|
|
+}
|
|
|
+
|
|
|
+// DeleteWorkflowTemplate is the resolver for the deleteWorkflowTemplate field.
|
|
|
+func (r *mutationResolver) DeleteWorkflowTemplate(ctx context.Context, id string) (bool, error) {
|
|
|
+ // Auth check
|
|
|
+ if !auth.IsAuthenticated(ctx) {
|
|
|
+ return false, errors.New("unauthorized: authentication required")
|
|
|
+ }
|
|
|
+ if !auth.HasPermission(ctx, "workflow:manage") {
|
|
|
+ return false, errors.New("unauthorized: missing workflow:manage permission")
|
|
|
+ }
|
|
|
+
|
|
|
+ templateID, err := toID(id)
|
|
|
+ if err != nil {
|
|
|
+ return false, fmt.Errorf("invalid workflow template ID: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ result := r.DB.Delete(&models.WorkflowTemplate{}, templateID)
|
|
|
+ if result.Error != nil {
|
|
|
+ return false, fmt.Errorf("failed to delete workflow template: %w", result.Error)
|
|
|
+ }
|
|
|
+
|
|
|
+ logging.LogMutation(ctx, "DELETE", "WORKFLOW_TEMPLATE", id)
|
|
|
+ return result.RowsAffected > 0, nil
|
|
|
+}
|
|
|
+
|
|
|
+// StartWorkflow is the resolver for the startWorkflow field.
|
|
|
+func (r *mutationResolver) StartWorkflow(ctx context.Context, templateID string, input model.StartWorkflowInput) (*model.WorkflowInstance, error) {
|
|
|
+ // Auth check
|
|
|
+ if !auth.IsAuthenticated(ctx) {
|
|
|
+ return nil, errors.New("unauthorized: authentication required")
|
|
|
+ }
|
|
|
+ if !auth.HasPermission(ctx, "workflow:start") {
|
|
|
+ return nil, errors.New("unauthorized: missing workflow:start permission")
|
|
|
+ }
|
|
|
+
|
|
|
+ templateIDUint, err := toID(templateID)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("invalid workflow template ID: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var template models.WorkflowTemplate
|
|
|
+ if err := r.DB.First(&template, templateIDUint).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("workflow template not found: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Parse the workflow definition (JSON DAG)
|
|
|
+ workflowEngine := workflow.NewEngine(r.DB)
|
|
|
+ instance, nodes, err := workflowEngine.CreateInstance(template, workflow.StartWorkflowInput{
|
|
|
+ ServiceID: func() *uint {
|
|
|
+ if input.ServiceID != nil {
|
|
|
+ id, _ := toID(*input.ServiceID)
|
|
|
+ return &id
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ }(),
|
|
|
+ Context: func() string {
|
|
|
+ if input.Context != nil {
|
|
|
+ return *input.Context
|
|
|
+ }
|
|
|
+ return ""
|
|
|
+ }(),
|
|
|
+ })
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to create workflow instance: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Save the workflow instance
|
|
|
+ if err := r.DB.Create(&instance).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to save workflow instance: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Save the workflow nodes
|
|
|
+ for i := range nodes {
|
|
|
+ nodes[i].WorkflowInstanceID = instance.ID
|
|
|
+ if err := r.DB.Create(&nodes[i]).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to save workflow node: %w", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Reload with associations
|
|
|
+ r.DB.Preload("WorkflowTemplate").Preload("Service").First(&instance, instance.ID)
|
|
|
+
|
|
|
+ logging.LogMutation(ctx, "START_WORKFLOW", "WORKFLOW_INSTANCE", fmt.Sprintf("template=%s", template.Name))
|
|
|
+ return convertWorkflowInstance(instance), nil
|
|
|
+}
|
|
|
+
|
|
|
+// CancelWorkflow is the resolver for the cancelWorkflow field.
|
|
|
+func (r *mutationResolver) CancelWorkflow(ctx context.Context, id string) (*model.WorkflowInstance, error) {
|
|
|
+ // Auth check
|
|
|
+ if !auth.IsAuthenticated(ctx) {
|
|
|
+ return nil, errors.New("unauthorized: authentication required")
|
|
|
+ }
|
|
|
+ if !auth.HasPermission(ctx, "workflow:manage") {
|
|
|
+ return nil, errors.New("unauthorized: missing workflow:manage permission")
|
|
|
+ }
|
|
|
+
|
|
|
+ instanceID, err := toID(id)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("invalid workflow instance ID: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var instance models.WorkflowInstance
|
|
|
+ if err := r.DB.First(&instance, instanceID).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("workflow instance not found: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ instance.Status = "failed"
|
|
|
+ now := time.Now()
|
|
|
+ instance.CompletedAt = &now
|
|
|
+
|
|
|
+ if err := r.DB.Save(&instance).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to cancel workflow: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Update all running nodes to failed
|
|
|
+ r.DB.Model(&models.WorkflowNode{}).
|
|
|
+ Where("workflow_instance_id = ? AND status = ?", instanceID, "running").
|
|
|
+ Update("status", "failed")
|
|
|
+
|
|
|
+ // Reload with associations for response
|
|
|
+ r.DB.Preload("WorkflowTemplate").Preload("Service").First(&instance, instance.ID)
|
|
|
+
|
|
|
+ logging.LogMutation(ctx, "CANCEL_WORKFLOW", "WORKFLOW_INSTANCE", id)
|
|
|
+ return convertWorkflowInstance(instance), nil
|
|
|
+}
|
|
|
+
|
|
|
+// RetryWorkflowNode is the resolver for the retryWorkflowNode field.
|
|
|
+func (r *mutationResolver) RetryWorkflowNode(ctx context.Context, nodeID string) (*model.WorkflowNode, error) {
|
|
|
+ // Auth check
|
|
|
+ if !auth.IsAuthenticated(ctx) {
|
|
|
+ return nil, errors.New("unauthorized: authentication required")
|
|
|
+ }
|
|
|
+ if !auth.HasPermission(ctx, "workflow:intervene") {
|
|
|
+ return nil, errors.New("unauthorized: missing workflow:intervene permission")
|
|
|
+ }
|
|
|
+
|
|
|
+ nodeIDUint, err := toID(nodeID)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("invalid workflow node ID: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var node models.WorkflowNode
|
|
|
+ if err := r.DB.Preload("WorkflowInstance").First(&node, nodeIDUint).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("workflow node not found: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Reset node status to pending and clear task association
|
|
|
+ node.Status = "pending"
|
|
|
+ node.TaskID = nil
|
|
|
+ node.RetryCount++
|
|
|
+ node.OutputData = ""
|
|
|
+
|
|
|
+ if err := r.DB.Save(&node).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to retry workflow node: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Reload with task for response
|
|
|
+ r.DB.Preload("Task").First(&node, node.ID)
|
|
|
+
|
|
|
+ logging.LogMutation(ctx, "RETRY_NODE", "WORKFLOW_NODE", nodeID)
|
|
|
+ return convertWorkflowNode(node), nil
|
|
|
+}
|
|
|
+
|
|
|
// Users is the resolver for the users field.
|
|
|
func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
|
|
|
// Auth check
|
|
|
@@ -1239,6 +1521,90 @@ func (r *queryResolver) Message(ctx context.Context, id string) (*model.Message,
|
|
|
return convertMessage(message), nil
|
|
|
}
|
|
|
|
|
|
+// WorkflowTemplates is the resolver for the workflowTemplates field.
|
|
|
+func (r *queryResolver) WorkflowTemplates(ctx context.Context) ([]*model.WorkflowTemplate, error) {
|
|
|
+ // Auth check
|
|
|
+ if !auth.IsAuthenticated(ctx) {
|
|
|
+ return nil, errors.New("unauthorized: authentication required")
|
|
|
+ }
|
|
|
+ if !auth.HasPermission(ctx, "workflow:view") {
|
|
|
+ return nil, errors.New("unauthorized: missing workflow:view permission")
|
|
|
+ }
|
|
|
+
|
|
|
+ var templates []models.WorkflowTemplate
|
|
|
+ if err := r.DB.Preload("CreatedBy").Find(&templates).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to fetch workflow templates: %w", err)
|
|
|
+ }
|
|
|
+ logging.LogQuery(ctx, "WORKFLOW_TEMPLATES", "all")
|
|
|
+ return convertWorkflowTemplates(templates), nil
|
|
|
+}
|
|
|
+
|
|
|
+// WorkflowTemplate is the resolver for the workflowTemplate field.
|
|
|
+func (r *queryResolver) WorkflowTemplate(ctx context.Context, id string) (*model.WorkflowTemplate, error) {
|
|
|
+ // Auth check
|
|
|
+ if !auth.IsAuthenticated(ctx) {
|
|
|
+ return nil, errors.New("unauthorized: authentication required")
|
|
|
+ }
|
|
|
+ if !auth.HasPermission(ctx, "workflow:view") {
|
|
|
+ return nil, errors.New("unauthorized: missing workflow:view permission")
|
|
|
+ }
|
|
|
+
|
|
|
+ templateID, err := toID(id)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("invalid workflow template ID: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var template models.WorkflowTemplate
|
|
|
+ if err := r.DB.Preload("CreatedBy").First(&template, templateID).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("workflow template not found: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ logging.LogQuery(ctx, "WORKFLOW_TEMPLATE", id)
|
|
|
+ return convertWorkflowTemplate(template), nil
|
|
|
+}
|
|
|
+
|
|
|
+// WorkflowInstances is the resolver for the workflowInstances field.
|
|
|
+func (r *queryResolver) WorkflowInstances(ctx context.Context) ([]*model.WorkflowInstance, error) {
|
|
|
+ // Auth check
|
|
|
+ if !auth.IsAuthenticated(ctx) {
|
|
|
+ return nil, errors.New("unauthorized: authentication required")
|
|
|
+ }
|
|
|
+ if !auth.HasPermission(ctx, "workflow:view") {
|
|
|
+ return nil, errors.New("unauthorized: missing workflow:view permission")
|
|
|
+ }
|
|
|
+
|
|
|
+ var instances []models.WorkflowInstance
|
|
|
+ if err := r.DB.Preload("WorkflowTemplate").Preload("Service").Find(&instances).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to fetch workflow instances: %w", err)
|
|
|
+ }
|
|
|
+ logging.LogQuery(ctx, "WORKFLOW_INSTANCES", "all")
|
|
|
+ return convertWorkflowInstances(instances), nil
|
|
|
+}
|
|
|
+
|
|
|
+// WorkflowInstance is the resolver for the workflowInstance field.
|
|
|
+func (r *queryResolver) WorkflowInstance(ctx context.Context, id string) (*model.WorkflowInstance, error) {
|
|
|
+ // Auth check
|
|
|
+ if !auth.IsAuthenticated(ctx) {
|
|
|
+ return nil, errors.New("unauthorized: authentication required")
|
|
|
+ }
|
|
|
+ if !auth.HasPermission(ctx, "workflow:view") {
|
|
|
+ return nil, errors.New("unauthorized: missing workflow:view permission")
|
|
|
+ }
|
|
|
+
|
|
|
+ instanceID, err := toID(id)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("invalid workflow instance ID: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var instance models.WorkflowInstance
|
|
|
+ if err := r.DB.Preload("WorkflowTemplate").Preload("Service").First(&instance, instanceID).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("workflow instance not found: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ logging.LogQuery(ctx, "WORKFLOW_INSTANCE", id)
|
|
|
+ return convertWorkflowInstance(instance), nil
|
|
|
+}
|
|
|
+
|
|
|
// TaskCreated is the resolver for the taskCreated field.
|
|
|
// Users only receive events for tasks where they are the assignee.
|
|
|
func (r *subscriptionResolver) TaskCreated(ctx context.Context) (<-chan *model.Task, error) {
|