Forráskód Böngészése

add workflow commands to arp_cli

david 5 órája
szülő
commit
7151d949cf
6 módosított fájl, 2352 hozzáadás és 0 törlés
  1. 222 0
      arp_agent/README.MD
  2. 1 0
      arp_cli/cmd/root.go
  3. 1034 0
      arp_cli/cmd/workflow.go
  4. 652 0
      arp_cli/cmd/workflow_test.go
  5. 442 0
      arp_cli/integration_test.go
  6. 1 0
      arp_cli/main.go

+ 222 - 0
arp_agent/README.MD

@@ -11,6 +11,7 @@
 - [How It Works](#how-it-works)
 - [MCP Communication](#mcp-communication)
 - [Available MCP Tools](#available-mcp-tools)
+- [Workflows](#workflows)
 - [Programmatic Usage](#programmatic-usage)
 - [Testing](#testing)
 
@@ -251,6 +252,227 @@ result = mcp_client.call_tool("mutate", {
 
 ---
 
+## Workflows
+
+The ARP platform supports **workflows** - configurable, DAG-based process automation that coordinates tasks across agents and users. Workflows enable you to define multi-step processes with dependencies, parallel execution, and automatic task creation.
+
+### Workflow Concepts
+
+| Concept | Description |
+|---------|-------------|
+| **WorkflowTemplate** | Admin-defined workflow definition (JSON DAG structure) |
+| **WorkflowInstance** | A running instance of a workflow template |
+| **WorkflowNode** | A single step in a workflow instance (maps to a Task) |
+| **WorkflowEdge** | Dependency relationship between nodes |
+
+### Node Types
+
+| Type | Description |
+|------|-------------|
+| `task` | Creates and assigns a task to a user |
+| `condition` | Conditional branching based on workflow context |
+| `parallel` | Fork into multiple concurrent branches |
+| `join` | Wait for multiple branches to complete |
+| `trigger` | External event trigger (webhook, schedule, etc.) |
+
+### Node Status Lifecycle
+
+```
+pending → ready → running → completed
+                  └── failed → (retry or abort)
+                  └── skipped
+```
+
+- **pending**: Waiting for dependencies to complete
+- **ready**: All dependencies satisfied, ready to execute
+- **running**: Currently executing (task created)
+- **completed**: Successfully finished
+- **failed**: Execution failed (may retry)
+- **skipped**: Conditionally bypassed
+
+### Workflow Definition Format
+
+Workflows are defined as JSON DAGs (Directed Acyclic Graphs):
+
+```json
+{
+  "nodes": {
+    "start": {
+      "type": "task",
+      "title": "Initial Review",
+      "content": "Review the submitted request",
+      "assignee": "reviewer@example.com",
+      "dependsOn": []
+    },
+    "parallel_analysis": {
+      "type": "parallel",
+      "title": "Parallel Analysis",
+      "content": "Run multiple analyses in parallel",
+      "dependsOn": ["start"]
+    },
+    "technical_review": {
+      "type": "task",
+      "title": "Technical Review",
+      "content": "Review technical aspects",
+      "assignee": "tech@example.com",
+      "dependsOn": ["parallel_analysis"]
+    },
+    "business_review": {
+      "type": "task",
+      "title": "Business Review",
+      "content": "Review business impact",
+      "assignee": "business@example.com",
+      "dependsOn": ["parallel_analysis"]
+    },
+    "join_reviews": {
+      "type": "join",
+      "title": "Join Reviews",
+      "content": "Wait for all reviews to complete",
+      "dependsOn": ["technical_review", "business_review"]
+    },
+    "final_approval": {
+      "type": "task",
+      "title": "Final Approval",
+      "content": "Make final decision",
+      "assignee": "approver@example.com",
+      "dependsOn": ["join_reviews"]
+    }
+  }
+}
+```
+
+### Creating Workflows via MCP
+
+Use the `mutate` tool to create workflow templates and start instances:
+
+```python
+# Create a workflow template
+result = mcp_client.call_tool("mutate", {
+    "mutation": """
+        mutation CreateWorkflowTemplate($input: NewWorkflowTemplate!) {
+            createWorkflowTemplate(input: $input) {
+                id
+                name
+                isActive
+            }
+        }
+    """,
+    "variables": {
+        "input": {
+            "name": "Approval Process",
+            "description": "Multi-step approval workflow",
+            "definition": '{"nodes": {...}}',
+            "isActive": True
+        }
+    }
+})
+
+# Start a workflow instance
+result = mcp_client.call_tool("mutate", {
+    "mutation": """
+        mutation StartWorkflow($templateId: ID!, $input: StartWorkflowInput!) {
+            startWorkflow(templateId: $templateId, input: $input) {
+                id
+                status
+                createdAt
+            }
+        }
+    """,
+    "variables": {
+        "templateId": "1",
+        "input": {
+            "serviceId": "5",
+            "context": '{"requestId": "REQ-123"}'
+        }
+    }
+})
+```
+
+### Querying Workflow State
+
+```python
+# List all workflow templates
+result = mcp_client.call_tool("query", {
+    "query": """
+        {
+            workflowTemplates {
+                id
+                name
+                description
+                isActive
+            }
+        }
+    """
+})
+
+# Get workflow instance status
+result = mcp_client.call_tool("query", {
+    "query": """
+        query WorkflowInstance($id: ID!) {
+            workflowInstance(id: $id) {
+                id
+                status
+                context
+                service { id name }
+                template { name }
+            }
+        }
+    """,
+    "variables": {"id": "1"}
+})
+```
+
+### Workflow Execution Flow
+
+1. **Template Created**: Admin defines workflow structure
+2. **Instance Started**: Workflow instance created from template
+3. **Root Nodes Execute**: Nodes with no dependencies create tasks
+4. **Dependencies Resolve**: As tasks complete, downstream nodes become ready
+5. **Parallel Branches**: Multiple branches execute concurrently
+6. **Join Points**: Wait for all incoming branches to complete
+7. **Completion**: Workflow marked complete when all nodes finish
+
+### Agent Interaction with Workflows
+
+Agents can interact with workflows through MCP tools:
+
+- **Query** workflow templates and instances to understand current state
+- **Create** workflow templates for new processes
+- **Start** workflow instances when triggered by events
+- **Update** task status to progress workflow nodes
+- **Monitor** workflow completion and handle failures
+
+Example agent workflow handling:
+
+```python
+# Agent receives task completion event
+# Check if task is part of a workflow
+result = mcp_client.call_tool("query", {
+    "query": """
+        query TaskWorkflow($taskId: ID!) {
+            task(id: $taskId) {
+                id
+                title
+                workflowNodes {
+                    id
+                    workflowInstance {
+                        id
+                        status
+                        template { name }
+                    }
+                }
+            }
+        }
+    """,
+    "variables": {"taskId": task_id}
+})
+
+# If task is part of workflow, check downstream nodes
+# Agent can proactively notify next assignees or take actions
+```
+
+---
+
 ## Programmatic Usage
 
 ### Using MCPClient Directly

+ 1 - 0
arp_cli/cmd/root.go

@@ -85,6 +85,7 @@ Start by running 'arp_cli login' to authenticate with your ARP server.`,
 			MessageCommand(),
 			RoleCommand(),
 			PermissionCommand(),
+			WorkflowCommand(),
 		},
 		Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
 			// Set default output format in context if needed

+ 1034 - 0
arp_cli/cmd/workflow.go

@@ -0,0 +1,1034 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/olekukonko/tablewriter"
+	"github.com/urfave/cli/v3"
+)
+
+// WorkflowCommand returns the workflow command
+func WorkflowCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "workflow",
+		Usage: "Manage workflows",
+		Description: `Manage ARP workflows. Workflows are automated processes defined by templates.
+
+Workflow templates define the structure of automated workflows as DAGs (Directed Acyclic Graphs).
+Workflow instances are running executions of templates.
+Workflow nodes represent individual steps in a running workflow.`,
+		Commands: []*cli.Command{
+			// WorkflowTemplate commands
+			{
+				Name:  "template",
+				Usage: "Manage workflow templates",
+				Commands: []*cli.Command{
+					{
+						Name:    "list",
+						Aliases: []string{"ls"},
+						Usage:   "List all workflow templates",
+						Flags: []cli.Flag{
+							&cli.BoolFlag{
+								Name:    "json",
+								Aliases: []string{"j"},
+								Usage:   "Output as JSON",
+							},
+						},
+						Action: workflowTemplateList,
+					},
+					{
+						Name:  "get",
+						Usage: "Get a workflow template by ID",
+						Flags: []cli.Flag{
+							&cli.StringFlag{
+								Name:     "id",
+								Aliases:  []string{"i"},
+								Usage:    "Template ID",
+								Required: true,
+							},
+							&cli.BoolFlag{
+								Name:    "json",
+								Aliases: []string{"j"},
+								Usage:   "Output as JSON",
+							},
+						},
+						Action: workflowTemplateGet,
+					},
+					{
+						Name:   "create",
+						Usage:  "Create a new workflow template",
+						Action: workflowTemplateCreate,
+						Flags: []cli.Flag{
+							&cli.StringFlag{
+								Name:    "name",
+								Aliases: []string{"n"},
+								Usage:   "Template name",
+							},
+							&cli.StringFlag{
+								Name:    "description",
+								Aliases: []string{"d"},
+								Usage:   "Template description",
+							},
+							&cli.StringFlag{
+								Name:    "definition",
+								Aliases: []string{"f"},
+								Usage:   "Workflow definition (JSON string or @filename)",
+							},
+							&cli.BoolFlag{
+								Name:  "active",
+								Usage: "Set template as active",
+								Value: true,
+							},
+						},
+					},
+					{
+						Name:   "update",
+						Usage:  "Update a workflow template",
+						Action: workflowTemplateUpdate,
+						Flags: []cli.Flag{
+							&cli.StringFlag{
+								Name:     "id",
+								Aliases:  []string{"i"},
+								Usage:    "Template ID",
+								Required: true,
+							},
+							&cli.StringFlag{
+								Name:    "name",
+								Aliases: []string{"n"},
+								Usage:   "Template name",
+							},
+							&cli.StringFlag{
+								Name:    "description",
+								Aliases: []string{"d"},
+								Usage:   "Template description",
+							},
+							&cli.StringFlag{
+								Name:    "definition",
+								Aliases: []string{"f"},
+								Usage:   "Workflow definition (JSON string or @filename)",
+							},
+							&cli.BoolFlag{
+								Name:  "active",
+								Usage: "Set template as active",
+							},
+						},
+					},
+					{
+						Name:   "delete",
+						Usage:  "Delete a workflow template",
+						Action: workflowTemplateDelete,
+						Flags: []cli.Flag{
+							&cli.StringFlag{
+								Name:     "id",
+								Aliases:  []string{"i"},
+								Usage:    "Template ID",
+								Required: true,
+							},
+							&cli.BoolFlag{
+								Name:    "yes",
+								Aliases: []string{"y"},
+								Usage:   "Skip confirmation",
+							},
+						},
+					},
+				},
+			},
+			// WorkflowInstance commands
+			{
+				Name:  "instance",
+				Usage: "Manage workflow instances",
+				Commands: []*cli.Command{
+					{
+						Name:    "list",
+						Aliases: []string{"ls"},
+						Usage:   "List all workflow instances",
+						Flags: []cli.Flag{
+							&cli.BoolFlag{
+								Name:    "json",
+								Aliases: []string{"j"},
+								Usage:   "Output as JSON",
+							},
+						},
+						Action: workflowInstanceList,
+					},
+					{
+						Name:  "get",
+						Usage: "Get a workflow instance by ID",
+						Flags: []cli.Flag{
+							&cli.StringFlag{
+								Name:     "id",
+								Aliases:  []string{"i"},
+								Usage:    "Instance ID",
+								Required: true,
+							},
+							&cli.BoolFlag{
+								Name:    "json",
+								Aliases: []string{"j"},
+								Usage:   "Output as JSON",
+							},
+						},
+						Action: workflowInstanceGet,
+					},
+					{
+						Name:   "start",
+						Usage:  "Start a new workflow instance from a template",
+						Action: workflowInstanceStart,
+						Flags: []cli.Flag{
+							&cli.StringFlag{
+								Name:     "template",
+								Aliases:  []string{"t"},
+								Usage:    "Template ID",
+								Required: true,
+							},
+							&cli.StringFlag{
+								Name:    "service",
+								Aliases: []string{"s"},
+								Usage:   "Service ID to associate with the workflow",
+							},
+							&cli.StringFlag{
+								Name:    "context",
+								Aliases: []string{"c"},
+								Usage:   "Initial workflow context (JSON string)",
+							},
+						},
+					},
+					{
+						Name:   "cancel",
+						Usage:  "Cancel a running workflow instance",
+						Action: workflowInstanceCancel,
+						Flags: []cli.Flag{
+							&cli.StringFlag{
+								Name:     "id",
+								Aliases:  []string{"i"},
+								Usage:    "Instance ID",
+								Required: true,
+							},
+						},
+					},
+				},
+			},
+			// WorkflowNode commands
+			{
+				Name:  "node",
+				Usage: "Manage workflow nodes",
+				Commands: []*cli.Command{
+					{
+						Name:    "list",
+						Aliases: []string{"ls"},
+						Usage:   "List all nodes for a workflow instance",
+						Flags: []cli.Flag{
+							&cli.StringFlag{
+								Name:     "instance",
+								Aliases:  []string{"i"},
+								Usage:    "Instance ID",
+								Required: true,
+							},
+							&cli.BoolFlag{
+								Name:    "json",
+								Aliases: []string{"j"},
+								Usage:   "Output as JSON",
+							},
+						},
+						Action: workflowNodeList,
+					},
+					{
+						Name:  "get",
+						Usage: "Get a workflow node by ID",
+						Flags: []cli.Flag{
+							&cli.StringFlag{
+								Name:     "id",
+								Aliases:  []string{"i"},
+								Usage:    "Node ID",
+								Required: true,
+							},
+							&cli.BoolFlag{
+								Name:    "json",
+								Aliases: []string{"j"},
+								Usage:   "Output as JSON",
+							},
+						},
+						Action: workflowNodeGet,
+					},
+					{
+						Name:   "retry",
+						Usage:  "Retry a failed workflow node",
+						Action: workflowNodeRetry,
+						Flags: []cli.Flag{
+							&cli.StringFlag{
+								Name:     "id",
+								Aliases:  []string{"i"},
+								Usage:    "Node ID",
+								Required: true,
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+// WorkflowTemplate represents a workflow template
+type WorkflowTemplate struct {
+	ID          string `json:"id"`
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	Definition  string `json:"definition"`
+	IsActive    bool   `json:"isActive"`
+	CreatedBy   *User  `json:"createdBy"`
+	CreatedAt   string `json:"createdAt"`
+	UpdatedAt   string `json:"updatedAt"`
+}
+
+// WorkflowInstance represents a running workflow instance
+type WorkflowInstance struct {
+	ID          string            `json:"id"`
+	Template    *WorkflowTemplate `json:"template"`
+	Status      string            `json:"status"`
+	Context     string            `json:"context"`
+	Service     *Service          `json:"service"`
+	CreatedAt   string            `json:"createdAt"`
+	UpdatedAt   string            `json:"updatedAt"`
+	CompletedAt *string           `json:"completedAt"`
+}
+
+// WorkflowNode represents a node in a workflow instance
+type WorkflowNode struct {
+	ID          string  `json:"id"`
+	NodeKey     string  `json:"nodeKey"`
+	NodeType    string  `json:"nodeType"`
+	Status      string  `json:"status"`
+	Task        *Task   `json:"task"`
+	InputData   string  `json:"inputData"`
+	OutputData  string  `json:"outputData"`
+	RetryCount  int     `json:"retryCount"`
+	CreatedAt   string  `json:"createdAt"`
+	UpdatedAt   string  `json:"updatedAt"`
+	StartedAt   *string `json:"startedAt"`
+	CompletedAt *string `json:"completedAt"`
+}
+
+// WorkflowTemplate CRUD operations
+
+func workflowTemplateList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	query := `query WorkflowTemplates { workflowTemplates { id name description definition isActive createdBy { id email } createdAt updatedAt } }`
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		WorkflowTemplates []WorkflowTemplate `json:"workflowTemplates"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.WorkflowTemplates)
+	}
+
+	if len(result.WorkflowTemplates) == 0 {
+		fmt.Println("No workflow templates found.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Name", "Description", "Active", "Created By", "Created At"})
+
+	for _, t := range result.WorkflowTemplates {
+		desc := t.Description
+		if len(desc) > 30 {
+			desc = desc[:27] + "..."
+		}
+		createdBy := ""
+		if t.CreatedBy != nil {
+			createdBy = t.CreatedBy.Email
+		}
+		active := "yes"
+		if !t.IsActive {
+			active = "no"
+		}
+		table.Append([]string{t.ID, t.Name, desc, active, createdBy, t.CreatedAt})
+	}
+
+	table.Render()
+	return nil
+}
+
+func workflowTemplateGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := `query WorkflowTemplate($id: ID!) { workflowTemplate(id: $id) { id name description definition isActive createdBy { id email } createdAt updatedAt } }`
+
+	resp, err := c.Query(query, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		WorkflowTemplate *WorkflowTemplate `json:"workflowTemplate"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.WorkflowTemplate == nil {
+		return fmt.Errorf("workflow template not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.WorkflowTemplate)
+	}
+
+	t := result.WorkflowTemplate
+	fmt.Printf("ID: %s\n", t.ID)
+	fmt.Printf("Name: %s\n", t.Name)
+	fmt.Printf("Description: %s\n", t.Description)
+	fmt.Printf("Active: %v\n", t.IsActive)
+	if t.CreatedBy != nil {
+		fmt.Printf("Created By: %s\n", t.CreatedBy.Email)
+	}
+	fmt.Printf("Created At: %s\n", t.CreatedAt)
+	fmt.Printf("Updated At: %s\n", t.UpdatedAt)
+	fmt.Printf("\nDefinition:\n%s\n", t.Definition)
+
+	return nil
+}
+
+func workflowTemplateCreate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	name := cmd.String("name")
+	description := cmd.String("description")
+	definition := cmd.String("definition")
+	isActive := cmd.Bool("active")
+
+	if name == "" {
+		prompt := &survey.Input{Message: "Template name:"}
+		if err := survey.AskOne(prompt, &name, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if description == "" {
+		prompt := &survey.Input{Message: "Description (optional):"}
+		survey.AskOne(prompt, &description)
+	}
+
+	if definition == "" {
+		prompt := &survey.Multiline{Message: "Workflow definition (JSON):"}
+		if err := survey.AskOne(prompt, &definition, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	// Handle file input for definition
+	definition = ReadFileOrString(definition)
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation CreateWorkflowTemplate($input: NewWorkflowTemplate!) { createWorkflowTemplate(input: $input) { id name description definition isActive createdBy { id email } createdAt updatedAt } }`
+
+	input := map[string]interface{}{
+		"name":        name,
+		"description": description,
+		"definition":  definition,
+		"isActive":    isActive,
+	}
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		CreateWorkflowTemplate *WorkflowTemplate `json:"createWorkflowTemplate"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.CreateWorkflowTemplate == nil {
+		return fmt.Errorf("failed to create workflow template")
+	}
+
+	fmt.Printf("Workflow template created successfully!\n")
+	fmt.Printf("ID: %s\n", result.CreateWorkflowTemplate.ID)
+	fmt.Printf("Name: %s\n", result.CreateWorkflowTemplate.Name)
+
+	return nil
+}
+
+func workflowTemplateUpdate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	name := cmd.String("name")
+	description := cmd.String("description")
+	definition := cmd.String("definition")
+
+	// Check if active flag was explicitly set
+	var isActive *bool
+	if cmd.IsSet("active") {
+		val := cmd.Bool("active")
+		isActive = &val
+	}
+
+	if name == "" && description == "" && definition == "" && isActive == nil {
+		fmt.Println("No updates provided. Use flags to specify what to update.")
+		return nil
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	input := make(map[string]interface{})
+	if name != "" {
+		input["name"] = name
+	}
+	if description != "" {
+		input["description"] = description
+	}
+	if definition != "" {
+		input["definition"] = ReadFileOrString(definition)
+	}
+	if isActive != nil {
+		input["isActive"] = *isActive
+	}
+
+	mutation := `mutation UpdateWorkflowTemplate($id: ID!, $input: UpdateWorkflowTemplateInput!) { updateWorkflowTemplate(id: $id, input: $input) { id name description definition isActive createdAt updatedAt } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		UpdateWorkflowTemplate *WorkflowTemplate `json:"updateWorkflowTemplate"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.UpdateWorkflowTemplate == nil {
+		return fmt.Errorf("workflow template not found")
+	}
+
+	fmt.Printf("Workflow template updated successfully!\n")
+	fmt.Printf("ID: %s\n", result.UpdateWorkflowTemplate.ID)
+	fmt.Printf("Name: %s\n", result.UpdateWorkflowTemplate.Name)
+
+	return nil
+}
+
+func workflowTemplateDelete(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	skipConfirm := cmd.Bool("yes")
+
+	if !skipConfirm {
+		confirm := false
+		prompt := &survey.Confirm{
+			Message: fmt.Sprintf("Are you sure you want to delete workflow template %s?", id),
+			Default: false,
+		}
+		if err := survey.AskOne(prompt, &confirm); err != nil {
+			return err
+		}
+		if !confirm {
+			fmt.Println("Deletion cancelled.")
+			return nil
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation DeleteWorkflowTemplate($id: ID!) { deleteWorkflowTemplate(id: $id) }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		DeleteWorkflowTemplate bool `json:"deleteWorkflowTemplate"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.DeleteWorkflowTemplate {
+		fmt.Printf("Workflow template %s deleted successfully.\n", id)
+	} else {
+		fmt.Printf("Failed to delete workflow template %s.\n", id)
+	}
+
+	return nil
+}
+
+// WorkflowInstance operations
+
+func workflowInstanceList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	query := `query WorkflowInstances { workflowInstances { id template { id name } status context service { id name } createdAt updatedAt completedAt } }`
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		WorkflowInstances []WorkflowInstance `json:"workflowInstances"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.WorkflowInstances)
+	}
+
+	if len(result.WorkflowInstances) == 0 {
+		fmt.Println("No workflow instances found.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Template", "Status", "Service", "Created At"})
+
+	for _, i := range result.WorkflowInstances {
+		templateName := ""
+		if i.Template != nil {
+			templateName = i.Template.Name
+		}
+		serviceName := ""
+		if i.Service != nil {
+			serviceName = i.Service.Name
+		}
+		table.Append([]string{i.ID, templateName, i.Status, serviceName, i.CreatedAt})
+	}
+
+	table.Render()
+	return nil
+}
+
+func workflowInstanceGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := `query WorkflowInstance($id: ID!) { workflowInstance(id: $id) { id template { id name description } status context service { id name } createdAt updatedAt completedAt } }`
+
+	resp, err := c.Query(query, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		WorkflowInstance *WorkflowInstance `json:"workflowInstance"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.WorkflowInstance == nil {
+		return fmt.Errorf("workflow instance not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.WorkflowInstance)
+	}
+
+	i := result.WorkflowInstance
+	fmt.Printf("ID: %s\n", i.ID)
+	if i.Template != nil {
+		fmt.Printf("Template: %s (%s)\n", i.Template.Name, i.Template.ID)
+	}
+	fmt.Printf("Status: %s\n", i.Status)
+	if i.Service != nil {
+		fmt.Printf("Service: %s (%s)\n", i.Service.Name, i.Service.ID)
+	}
+	fmt.Printf("Created At: %s\n", i.CreatedAt)
+	fmt.Printf("Updated At: %s\n", i.UpdatedAt)
+	if i.CompletedAt != nil {
+		fmt.Printf("Completed At: %s\n", *i.CompletedAt)
+	}
+	if i.Context != "" {
+		fmt.Printf("\nContext:\n%s\n", i.Context)
+	}
+
+	return nil
+}
+
+func workflowInstanceStart(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	templateID := cmd.String("template")
+	serviceID := cmd.String("service")
+	contextJSON := cmd.String("context")
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation StartWorkflow($templateId: ID!, $input: StartWorkflowInput!) { startWorkflow(templateId: $templateId, input: $input) { id template { id name } status context service { id name } createdAt } }`
+
+	input := make(map[string]interface{})
+	if serviceID != "" {
+		input["serviceId"] = serviceID
+	}
+	if contextJSON != "" {
+		input["context"] = contextJSON
+	}
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"templateId": templateID, "input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		StartWorkflow *WorkflowInstance `json:"startWorkflow"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.StartWorkflow == nil {
+		return fmt.Errorf("failed to start workflow")
+	}
+
+	fmt.Printf("Workflow started successfully!\n")
+	fmt.Printf("Instance ID: %s\n", result.StartWorkflow.ID)
+	if result.StartWorkflow.Template != nil {
+		fmt.Printf("Template: %s\n", result.StartWorkflow.Template.Name)
+	}
+	fmt.Printf("Status: %s\n", result.StartWorkflow.Status)
+
+	return nil
+}
+
+func workflowInstanceCancel(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation CancelWorkflow($id: ID!) { cancelWorkflow(id: $id) { id status completedAt } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		CancelWorkflow *WorkflowInstance `json:"cancelWorkflow"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.CancelWorkflow == nil {
+		return fmt.Errorf("workflow instance not found")
+	}
+
+	fmt.Printf("Workflow cancelled successfully!\n")
+	fmt.Printf("Instance ID: %s\n", result.CancelWorkflow.ID)
+	fmt.Printf("Status: %s\n", result.CancelWorkflow.Status)
+
+	return nil
+}
+
+// WorkflowNode operations
+
+func workflowNodeList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	instanceID := cmd.String("instance")
+	query := `query WorkflowInstance($id: ID!) { workflowInstance(id: $id) { id status nodes: workflowNodes { id nodeKey nodeType status task { id title } retryCount createdAt startedAt completedAt } } }`
+
+	resp, err := c.Query(query, map[string]interface{}{"id": instanceID})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		WorkflowInstance *struct {
+			ID     string         `json:"id"`
+			Status string         `json:"status"`
+			Nodes  []WorkflowNode `json:"nodes"`
+		} `json:"workflowInstance"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.WorkflowInstance == nil {
+		return fmt.Errorf("workflow instance not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.WorkflowInstance.Nodes)
+	}
+
+	if len(result.WorkflowInstance.Nodes) == 0 {
+		fmt.Println("No nodes found for this workflow instance.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Key", "Type", "Status", "Task", "Retries"})
+
+	for _, n := range result.WorkflowInstance.Nodes {
+		taskTitle := ""
+		if n.Task != nil {
+			taskTitle = n.Task.Title
+			if len(taskTitle) > 30 {
+				taskTitle = taskTitle[:27] + "..."
+			}
+		}
+		table.Append([]string{n.ID, n.NodeKey, n.NodeType, n.Status, taskTitle, fmt.Sprintf("%d", n.RetryCount)})
+	}
+
+	table.Render()
+	return nil
+}
+
+func workflowNodeGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := `query WorkflowNode($id: ID!) { workflowNode(id: $id) { id nodeKey nodeType status task { id title } inputData outputData retryCount createdAt updatedAt startedAt completedAt } }`
+
+	// Note: This assumes a workflowNode query exists. If not, we need to fetch via instance
+	// For now, let's use a workaround by fetching the instance and finding the node
+	query = `query WorkflowInstances { workflowInstances { id nodes: workflowNodes { id nodeKey nodeType status task { id title content } inputData outputData retryCount createdAt updatedAt startedAt completedAt } } }`
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		WorkflowInstances []struct {
+			ID    string         `json:"id"`
+			Nodes []WorkflowNode `json:"nodes"`
+		} `json:"workflowInstances"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	// Find the node
+	var node *WorkflowNode
+	for _, instance := range result.WorkflowInstances {
+		for _, n := range instance.Nodes {
+			if n.ID == id {
+				node = &n
+				break
+			}
+		}
+		if node != nil {
+			break
+		}
+	}
+
+	if node == nil {
+		return fmt.Errorf("workflow node not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(node)
+	}
+
+	fmt.Printf("ID: %s\n", node.ID)
+	fmt.Printf("Key: %s\n", node.NodeKey)
+	fmt.Printf("Type: %s\n", node.NodeType)
+	fmt.Printf("Status: %s\n", node.Status)
+	fmt.Printf("Retry Count: %d\n", node.RetryCount)
+	if node.Task != nil {
+		fmt.Printf("Task: %s (%s)\n", node.Task.Title, node.Task.ID)
+	}
+	fmt.Printf("Created At: %s\n", node.CreatedAt)
+	fmt.Printf("Updated At: %s\n", node.UpdatedAt)
+	if node.StartedAt != nil {
+		fmt.Printf("Started At: %s\n", *node.StartedAt)
+	}
+	if node.CompletedAt != nil {
+		fmt.Printf("Completed At: %s\n", *node.CompletedAt)
+	}
+	if node.InputData != "" {
+		fmt.Printf("\nInput Data:\n%s\n", node.InputData)
+	}
+	if node.OutputData != "" {
+		fmt.Printf("\nOutput Data:\n%s\n", node.OutputData)
+	}
+
+	return nil
+}
+
+func workflowNodeRetry(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation RetryWorkflowNode($nodeId: ID!) { retryWorkflowNode(nodeId: $nodeId) { id nodeKey status retryCount } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"nodeId": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		RetryWorkflowNode *WorkflowNode `json:"retryWorkflowNode"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.RetryWorkflowNode == nil {
+		return fmt.Errorf("workflow node not found")
+	}
+
+	fmt.Printf("Workflow node retry initiated!\n")
+	fmt.Printf("Node ID: %s\n", result.RetryWorkflowNode.ID)
+	fmt.Printf("Key: %s\n", result.RetryWorkflowNode.NodeKey)
+	fmt.Printf("Status: %s\n", result.RetryWorkflowNode.Status)
+	fmt.Printf("Retry Count: %d\n", result.RetryWorkflowNode.RetryCount)
+
+	return nil
+}
+
+// ReadFileOrString reads file content if input starts with @, otherwise returns as-is
+func ReadFileOrString(input string) string {
+	if strings.HasPrefix(input, "@") {
+		filename := input[1:]
+		content, err := os.ReadFile(filename)
+		if err != nil {
+			return input // Return original if file can't be read
+		}
+		return string(content)
+	}
+	return input
+}

+ 652 - 0
arp_cli/cmd/workflow_test.go

@@ -0,0 +1,652 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"testing"
+
+	"github.com/urfave/cli/v3"
+)
+
+// TestWorkflowCommandStructure tests that the workflow command is properly structured
+func TestWorkflowCommandStructure(t *testing.T) {
+	cmd := WorkflowCommand()
+
+	if cmd.Name != "workflow" {
+		t.Errorf("Expected command name 'workflow', got '%s'", cmd.Name)
+	}
+
+	if cmd.Usage == "" {
+		t.Error("Command usage should not be empty")
+	}
+
+	// Check subcommands exist
+	expectedSubcmds := []string{"template", "instance", "node"}
+	subcmdMap := make(map[string]bool)
+	for _, subcmd := range cmd.Commands {
+		subcmdMap[subcmd.Name] = true
+	}
+
+	for _, expected := range expectedSubcmds {
+		if !subcmdMap[expected] {
+			t.Errorf("Missing expected subcommand: %s", expected)
+		}
+	}
+}
+
+// TestWorkflowTemplateSubcommands tests the template subcommand structure
+func TestWorkflowTemplateSubcommands(t *testing.T) {
+	cmd := WorkflowCommand()
+
+	var templateCmd *cli.Command
+	for _, c := range cmd.Commands {
+		if c.Name == "template" {
+			templateCmd = c
+			break
+		}
+	}
+
+	if templateCmd == nil {
+		t.Fatal("template subcommand not found")
+	}
+
+	expectedActions := []string{"list", "get", "create", "update", "delete"}
+	actionMap := make(map[string]bool)
+	for _, action := range templateCmd.Commands {
+		actionMap[action.Name] = true
+	}
+
+	for _, expected := range expectedActions {
+		if !actionMap[expected] {
+			t.Errorf("Missing template action: %s", expected)
+		}
+	}
+}
+
+// TestWorkflowInstanceSubcommands tests the instance subcommand structure
+func TestWorkflowInstanceSubcommands(t *testing.T) {
+	cmd := WorkflowCommand()
+
+	var instanceCmd *cli.Command
+	for _, c := range cmd.Commands {
+		if c.Name == "instance" {
+			instanceCmd = c
+			break
+		}
+	}
+
+	if instanceCmd == nil {
+		t.Fatal("instance subcommand not found")
+	}
+
+	expectedActions := []string{"list", "get", "start", "cancel"}
+	actionMap := make(map[string]bool)
+	for _, action := range instanceCmd.Commands {
+		actionMap[action.Name] = true
+	}
+
+	for _, expected := range expectedActions {
+		if !actionMap[expected] {
+			t.Errorf("Missing instance action: %s", expected)
+		}
+	}
+}
+
+// TestWorkflowNodeSubcommands tests the node subcommand structure
+func TestWorkflowNodeSubcommands(t *testing.T) {
+	cmd := WorkflowCommand()
+
+	var nodeCmd *cli.Command
+	for _, c := range cmd.Commands {
+		if c.Name == "node" {
+			nodeCmd = c
+			break
+		}
+	}
+
+	if nodeCmd == nil {
+		t.Fatal("node subcommand not found")
+	}
+
+	expectedActions := []string{"list", "get", "retry"}
+	actionMap := make(map[string]bool)
+	for _, action := range nodeCmd.Commands {
+		actionMap[action.Name] = true
+	}
+
+	for _, expected := range expectedActions {
+		if !actionMap[expected] {
+			t.Errorf("Missing node action: %s", expected)
+		}
+	}
+}
+
+// TestWorkflowTemplateFlags tests that template command flags are properly defined
+func TestWorkflowTemplateFlags(t *testing.T) {
+	cmd := WorkflowCommand()
+
+	var templateCmd *cli.Command
+	for _, c := range cmd.Commands {
+		if c.Name == "template" {
+			templateCmd = c
+			break
+		}
+	}
+
+	// Test create command flags
+	var createCmd *cli.Command
+	for _, c := range templateCmd.Commands {
+		if c.Name == "create" {
+			createCmd = c
+			break
+		}
+	}
+
+	flagNames := make(map[string]bool)
+	for _, flag := range createCmd.Flags {
+		flagNames[flag.Names()[0]] = true
+	}
+
+	expectedFlags := []string{"name", "description", "definition", "active"}
+	for _, expected := range expectedFlags {
+		if !flagNames[expected] {
+			t.Errorf("Missing create flag: %s", expected)
+		}
+	}
+
+	// Test that id flag is required for get command
+	var getCmd *cli.Command
+	for _, c := range templateCmd.Commands {
+		if c.Name == "get" {
+			getCmd = c
+			break
+		}
+	}
+
+	var idFlag *cli.StringFlag
+	for _, flag := range getCmd.Flags {
+		if flag.Names()[0] == "id" {
+			idFlag = flag.(*cli.StringFlag)
+			break
+		}
+	}
+
+	if idFlag == nil {
+		t.Error("Missing id flag on get command")
+	} else if !idFlag.Required {
+		t.Error("id flag should be required on get command")
+	}
+}
+
+// TestWorkflowInstanceFlags tests that instance command flags are properly defined
+func TestWorkflowInstanceFlags(t *testing.T) {
+	cmd := WorkflowCommand()
+
+	var instanceCmd *cli.Command
+	for _, c := range cmd.Commands {
+		if c.Name == "instance" {
+			instanceCmd = c
+			break
+		}
+	}
+
+	// Test start command flags
+	var startCmd *cli.Command
+	for _, c := range instanceCmd.Commands {
+		if c.Name == "start" {
+			startCmd = c
+			break
+		}
+	}
+
+	flagNames := make(map[string]bool)
+	for _, flag := range startCmd.Flags {
+		flagNames[flag.Names()[0]] = true
+	}
+
+	expectedFlags := []string{"template", "service", "context"}
+	for _, expected := range expectedFlags {
+		if !flagNames[expected] {
+			t.Errorf("Missing start flag: %s", expected)
+		}
+	}
+
+	// Test that template flag is required
+	var templateFlag *cli.StringFlag
+	for _, flag := range startCmd.Flags {
+		if flag.Names()[0] == "template" {
+			templateFlag = flag.(*cli.StringFlag)
+			break
+		}
+	}
+
+	if templateFlag == nil {
+		t.Error("Missing template flag on start command")
+	} else if !templateFlag.Required {
+		t.Error("template flag should be required on start command")
+	}
+}
+
+// TestWorkflowNodeFlags tests that node command flags are properly defined
+func TestWorkflowNodeFlags(t *testing.T) {
+	cmd := WorkflowCommand()
+
+	var nodeCmd *cli.Command
+	for _, c := range cmd.Commands {
+		if c.Name == "node" {
+			nodeCmd = c
+			break
+		}
+	}
+
+	// Test list command flags
+	var listCmd *cli.Command
+	for _, c := range nodeCmd.Commands {
+		if c.Name == "list" {
+			listCmd = c
+			break
+		}
+	}
+
+	var instanceFlag *cli.StringFlag
+	for _, flag := range listCmd.Flags {
+		if flag.Names()[0] == "instance" {
+			instanceFlag = flag.(*cli.StringFlag)
+			break
+		}
+	}
+
+	if instanceFlag == nil {
+		t.Error("Missing instance flag on node list command")
+	} else if !instanceFlag.Required {
+		t.Error("instance flag should be required on node list command")
+	}
+}
+
+// TestReadFileOrString tests the helper function for reading files
+func TestReadFileOrString(t *testing.T) {
+	// Test with regular string (no @ prefix)
+	input := "hello world"
+	result := ReadFileOrString(input)
+	if result != input {
+		t.Errorf("Expected '%s', got '%s'", input, result)
+	}
+
+	// Test with non-existent file (should return original)
+	input = "@nonexistent_file.json"
+	result = ReadFileOrString(input)
+	if result != input {
+		t.Errorf("Expected original input for non-existent file, got '%s'", result)
+	}
+
+	// Test with actual file
+	tmpFile, err := os.CreateTemp("", "test_*.json")
+	if err != nil {
+		t.Fatalf("Failed to create temp file: %v", err)
+	}
+	defer os.Remove(tmpFile.Name())
+
+	testContent := `{"test": "content"}`
+	if _, err := tmpFile.WriteString(testContent); err != nil {
+		t.Fatalf("Failed to write to temp file: %v", err)
+	}
+	tmpFile.Close()
+
+	result = ReadFileOrString("@" + tmpFile.Name())
+	if result != testContent {
+		t.Errorf("Expected file content '%s', got '%s'", testContent, result)
+	}
+}
+
+// TestWorkflowTemplateJSON tests JSON marshaling of WorkflowTemplate
+func TestWorkflowTemplateJSON(t *testing.T) {
+	template := WorkflowTemplate{
+		ID:          "1",
+		Name:        "Test Template",
+		Description: "A test template",
+		Definition:  `{"nodes": []}`,
+		IsActive:    true,
+		CreatedBy:   &User{ID: "1", Email: "test@example.com"},
+		CreatedAt:   "2024-01-01T00:00:00Z",
+		UpdatedAt:   "2024-01-01T00:00:00Z",
+	}
+
+	data, err := json.Marshal(template)
+	if err != nil {
+		t.Fatalf("Failed to marshal WorkflowTemplate: %v", err)
+	}
+
+	var unmarshaled WorkflowTemplate
+	if err := json.Unmarshal(data, &unmarshaled); err != nil {
+		t.Fatalf("Failed to unmarshal WorkflowTemplate: %v", err)
+	}
+
+	if unmarshaled.ID != template.ID {
+		t.Errorf("Expected ID '%s', got '%s'", template.ID, unmarshaled.ID)
+	}
+	if unmarshaled.Name != template.Name {
+		t.Errorf("Expected Name '%s', got '%s'", template.Name, unmarshaled.Name)
+	}
+	if unmarshaled.IsActive != template.IsActive {
+		t.Errorf("Expected IsActive %v, got %v", template.IsActive, unmarshaled.IsActive)
+	}
+}
+
+// TestWorkflowInstanceJSON tests JSON marshaling of WorkflowInstance
+func TestWorkflowInstanceJSON(t *testing.T) {
+	completedAt := "2024-01-02T00:00:00Z"
+	instance := WorkflowInstance{
+		ID:          "1",
+		Template:    &WorkflowTemplate{ID: "1", Name: "Test"},
+		Status:      "running",
+		Context:     `{"key": "value"}`,
+		Service:     &Service{ID: "1", Name: "Test Service"},
+		CreatedAt:   "2024-01-01T00:00:00Z",
+		UpdatedAt:   "2024-01-01T00:00:00Z",
+		CompletedAt: &completedAt,
+	}
+
+	data, err := json.Marshal(instance)
+	if err != nil {
+		t.Fatalf("Failed to marshal WorkflowInstance: %v", err)
+	}
+
+	var unmarshaled WorkflowInstance
+	if err := json.Unmarshal(data, &unmarshaled); err != nil {
+		t.Fatalf("Failed to unmarshal WorkflowInstance: %v", err)
+	}
+
+	if unmarshaled.ID != instance.ID {
+		t.Errorf("Expected ID '%s', got '%s'", instance.ID, unmarshaled.ID)
+	}
+	if unmarshaled.Status != instance.Status {
+		t.Errorf("Expected Status '%s', got '%s'", instance.Status, unmarshaled.Status)
+	}
+	if unmarshaled.CompletedAt == nil || *unmarshaled.CompletedAt != completedAt {
+		t.Errorf("Expected CompletedAt '%s', got '%v'", completedAt, unmarshaled.CompletedAt)
+	}
+}
+
+// TestWorkflowNodeJSON tests JSON marshaling of WorkflowNode
+func TestWorkflowNodeJSON(t *testing.T) {
+	startedAt := "2024-01-01T01:00:00Z"
+	node := WorkflowNode{
+		ID:          "1",
+		NodeKey:     "node_1",
+		NodeType:    "task",
+		Status:      "running",
+		Task:        &Task{ID: "1", Title: "Test Task"},
+		InputData:   `{"input": "data"}`,
+		OutputData:  `{"output": "data"}`,
+		RetryCount:  2,
+		CreatedAt:   "2024-01-01T00:00:00Z",
+		UpdatedAt:   "2024-01-01T00:00:00Z",
+		StartedAt:   &startedAt,
+		CompletedAt: nil,
+	}
+
+	data, err := json.Marshal(node)
+	if err != nil {
+		t.Fatalf("Failed to marshal WorkflowNode: %v", err)
+	}
+
+	var unmarshaled WorkflowNode
+	if err := json.Unmarshal(data, &unmarshaled); err != nil {
+		t.Fatalf("Failed to unmarshal WorkflowNode: %v", err)
+	}
+
+	if unmarshaled.ID != node.ID {
+		t.Errorf("Expected ID '%s', got '%s'", node.ID, unmarshaled.ID)
+	}
+	if unmarshaled.NodeKey != node.NodeKey {
+		t.Errorf("Expected NodeKey '%s', got '%s'", node.NodeKey, unmarshaled.NodeKey)
+	}
+	if unmarshaled.RetryCount != node.RetryCount {
+		t.Errorf("Expected RetryCount %d, got %d", node.RetryCount, unmarshaled.RetryCount)
+	}
+}
+
+// TestWorkflowTemplateListJSONFlag tests the JSON flag on template list
+func TestWorkflowTemplateListJSONFlag(t *testing.T) {
+	cmd := WorkflowCommand()
+
+	var templateCmd *cli.Command
+	for _, c := range cmd.Commands {
+		if c.Name == "template" {
+			templateCmd = c
+			break
+		}
+	}
+
+	var listCmd *cli.Command
+	for _, c := range templateCmd.Commands {
+		if c.Name == "list" {
+			listCmd = c
+			break
+		}
+	}
+
+	var jsonFlag *cli.BoolFlag
+	for _, flag := range listCmd.Flags {
+		if flag.Names()[0] == "json" {
+			jsonFlag = flag.(*cli.BoolFlag)
+			break
+		}
+	}
+
+	if jsonFlag == nil {
+		t.Error("Missing json flag on template list command")
+	}
+}
+
+// TestWorkflowCommandInRoot tests that WorkflowCommand is registered in RootCommand
+func TestWorkflowCommandInRoot(t *testing.T) {
+	rootCmd := RootCommand()
+
+	found := false
+	for _, cmd := range rootCmd.Commands {
+		if cmd.Name == "workflow" {
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		t.Error("WorkflowCommand not found in RootCommand")
+	}
+}
+
+// TestWorkflowCommandHelp tests that help text is available
+func TestWorkflowCommandHelp(t *testing.T) {
+	cmd := WorkflowCommand()
+
+	if cmd.Description == "" {
+		t.Error("WorkflowCommand should have a description")
+	}
+
+	// Test that subcommands have descriptions
+	for _, subcmd := range cmd.Commands {
+		if subcmd.Usage == "" {
+			t.Errorf("Subcommand '%s' should have usage text", subcmd.Name)
+		}
+	}
+}
+
+// BenchmarkWorkflowCommand benchmarks the command creation
+func BenchmarkWorkflowCommand(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		_ = WorkflowCommand()
+	}
+}
+
+// TestWorkflowTemplateRequiredFlags tests that required flags are enforced
+func TestWorkflowTemplateRequiredFlags(t *testing.T) {
+	tests := []struct {
+		name       string
+		action     string
+		shouldHave []string
+	}{
+		{"get requires id", "get", []string{"id"}},
+		{"delete requires id", "delete", []string{"id"}},
+		{"update requires id", "update", []string{"id"}},
+	}
+
+	cmd := WorkflowCommand()
+
+	var templateCmd *cli.Command
+	for _, c := range cmd.Commands {
+		if c.Name == "template" {
+			templateCmd = c
+			break
+		}
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var actionCmd *cli.Command
+			for _, c := range templateCmd.Commands {
+				if c.Name == tt.action {
+					actionCmd = c
+					break
+				}
+			}
+
+			if actionCmd == nil {
+				t.Fatalf("Action '%s' not found", tt.action)
+			}
+
+			for _, requiredFlag := range tt.shouldHave {
+				found := false
+				for _, flag := range actionCmd.Flags {
+					if flag.Names()[0] == requiredFlag {
+						if strFlag, ok := flag.(*cli.StringFlag); ok {
+							if strFlag.Required {
+								found = true
+							}
+						}
+						break
+					}
+				}
+				if !found {
+					t.Errorf("Flag '%s' should be required for action '%s'", requiredFlag, tt.action)
+				}
+			}
+		})
+	}
+}
+
+// TestWorkflowInstanceRequiredFlags tests that required flags are enforced
+func TestWorkflowInstanceRequiredFlags(t *testing.T) {
+	tests := []struct {
+		name       string
+		action     string
+		shouldHave []string
+	}{
+		{"get requires id", "get", []string{"id"}},
+		{"start requires template", "start", []string{"template"}},
+		{"cancel requires id", "cancel", []string{"id"}},
+	}
+
+	cmd := WorkflowCommand()
+
+	var instanceCmd *cli.Command
+	for _, c := range cmd.Commands {
+		if c.Name == "instance" {
+			instanceCmd = c
+			break
+		}
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var actionCmd *cli.Command
+			for _, c := range instanceCmd.Commands {
+				if c.Name == tt.action {
+					actionCmd = c
+					break
+				}
+			}
+
+			if actionCmd == nil {
+				t.Fatalf("Action '%s' not found", tt.action)
+			}
+
+			for _, requiredFlag := range tt.shouldHave {
+				found := false
+				for _, flag := range actionCmd.Flags {
+					if flag.Names()[0] == requiredFlag {
+						if strFlag, ok := flag.(*cli.StringFlag); ok {
+							if strFlag.Required {
+								found = true
+							}
+						}
+						break
+					}
+				}
+				if !found {
+					t.Errorf("Flag '%s' should be required for action '%s'", requiredFlag, tt.action)
+				}
+			}
+		})
+	}
+}
+
+// TestWorkflowNodeRequiredFlags tests that required flags are enforced
+func TestWorkflowNodeRequiredFlags(t *testing.T) {
+	tests := []struct {
+		name       string
+		action     string
+		shouldHave []string
+	}{
+		{"list requires instance", "list", []string{"instance"}},
+		{"get requires id", "get", []string{"id"}},
+		{"retry requires id", "retry", []string{"id"}},
+	}
+
+	cmd := WorkflowCommand()
+
+	var nodeCmd *cli.Command
+	for _, c := range cmd.Commands {
+		if c.Name == "node" {
+			nodeCmd = c
+			break
+		}
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var actionCmd *cli.Command
+			for _, c := range nodeCmd.Commands {
+				if c.Name == tt.action {
+					actionCmd = c
+					break
+				}
+			}
+
+			if actionCmd == nil {
+				t.Fatalf("Action '%s' not found", tt.action)
+			}
+
+			for _, requiredFlag := range tt.shouldHave {
+				found := false
+				for _, flag := range actionCmd.Flags {
+					if flag.Names()[0] == requiredFlag {
+						if strFlag, ok := flag.(*cli.StringFlag); ok {
+							if strFlag.Required {
+								found = true
+							}
+						}
+						break
+					}
+				}
+				if !found {
+					t.Errorf("Flag '%s' should be required for action '%s'", requiredFlag, tt.action)
+				}
+			}
+		})
+	}
+}
+
+// Mock context for testing command actions
+var _ = context.Background()

+ 442 - 0
arp_cli/integration_test.go

@@ -0,0 +1,442 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"gogs.dmsc.dev/arp/arp_cli/cmd"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/urfave/cli/v3"
+)
+
+// Integration tests for arp_cli workflow commands
+// These tests require a running ARP server and test the full CLI stack
+
+var (
+	testServerURL string
+	testToken     string
+	testUserID    string
+)
+
+// TestMain sets up the integration test environment
+func TestMain(m *testing.M) {
+	// Check for test server URL
+	testServerURL = os.Getenv("ARP_TEST_URL")
+	if testServerURL == "" {
+		testServerURL = "http://localhost:8080/query"
+	}
+
+	// Run tests
+	code := m.Run()
+	os.Exit(code)
+}
+
+// setupTestAuth creates a test configuration with authentication
+func setupTestAuth(t *testing.T) {
+	t.Helper()
+
+	// Create test config
+	cfg := &config.Config{
+		ServerURL: testServerURL,
+		Token:     testToken,
+	}
+
+	if err := config.Save(cfg); err != nil {
+		t.Fatalf("Failed to save test config: %v", err)
+	}
+}
+
+// TestIntegration_WorkflowTemplateLifecycle tests the full lifecycle of workflow templates
+func TestIntegration_WorkflowTemplateLifecycle(t *testing.T) {
+	if testing.Short() {
+		t.Skip("Skipping integration test in short mode")
+	}
+
+	// This test would require a running server with authentication
+	// For now, we'll test the command structure and flag parsing
+
+	app := buildTestApp()
+
+	// Test 1: List templates (should work even with no auth for structure test)
+	t.Run("TemplateList", func(t *testing.T) {
+		args := []string{"arp_cli", "workflow", "template", "list", "--json"}
+		err := app.Run(context.Background(), args)
+		// We expect an error about missing auth, but the command structure should be valid
+		if err != nil && !strings.Contains(err.Error(), "not authenticated") {
+			t.Logf("Expected auth error or success, got: %v", err)
+		}
+	})
+
+	// Test 2: Get template with missing ID
+	t.Run("TemplateGetMissingID", func(t *testing.T) {
+		args := []string{"arp_cli", "workflow", "template", "get"}
+		err := app.Run(context.Background(), args)
+		if err == nil {
+			t.Error("Expected error for missing required flag")
+		}
+	})
+
+	// Test 3: Create template with flags
+	t.Run("TemplateCreateFlags", func(t *testing.T) {
+		args := []string{
+			"arp_cli", "workflow", "template", "create",
+			"--name", "Test Template",
+			"--description", "A test workflow template",
+			"--definition", `{"nodes": [], "edges": []}`,
+		}
+		err := app.Run(context.Background(), args)
+		// We expect an error about missing auth
+		if err != nil && !strings.Contains(err.Error(), "not authenticated") {
+			t.Logf("Expected auth error or success, got: %v", err)
+		}
+	})
+}
+
+// TestIntegration_WorkflowInstanceLifecycle tests the full lifecycle of workflow instances
+func TestIntegration_WorkflowInstanceLifecycle(t *testing.T) {
+	if testing.Short() {
+		t.Skip("Skipping integration test in short mode")
+	}
+
+	app := buildTestApp()
+
+	// Test 1: List instances
+	t.Run("InstanceList", func(t *testing.T) {
+		args := []string{"arp_cli", "workflow", "instance", "list", "--json"}
+		err := app.Run(context.Background(), args)
+		if err != nil && !strings.Contains(err.Error(), "not authenticated") {
+			t.Logf("Expected auth error or success, got: %v", err)
+		}
+	})
+
+	// Test 2: Start instance with missing template
+	t.Run("InstanceStartMissingTemplate", func(t *testing.T) {
+		args := []string{"arp_cli", "workflow", "instance", "start"}
+		err := app.Run(context.Background(), args)
+		if err == nil {
+			t.Error("Expected error for missing required flag")
+		}
+	})
+
+	// Test 3: Cancel instance with missing ID
+	t.Run("InstanceCancelMissingID", func(t *testing.T) {
+		args := []string{"arp_cli", "workflow", "instance", "cancel"}
+		err := app.Run(context.Background(), args)
+		if err == nil {
+			t.Error("Expected error for missing required flag")
+		}
+	})
+}
+
+// TestIntegration_WorkflowNodeLifecycle tests the full lifecycle of workflow nodes
+func TestIntegration_WorkflowNodeLifecycle(t *testing.T) {
+	if testing.Short() {
+		t.Skip("Skipping integration test in short mode")
+	}
+
+	app := buildTestApp()
+
+	// Test 1: List nodes with missing instance
+	t.Run("NodeListMissingInstance", func(t *testing.T) {
+		args := []string{"arp_cli", "workflow", "node", "list"}
+		err := app.Run(context.Background(), args)
+		if err == nil {
+			t.Error("Expected error for missing required flag")
+		}
+	})
+
+	// Test 2: Get node with missing ID
+	t.Run("NodeGetMissingID", func(t *testing.T) {
+		args := []string{"arp_cli", "workflow", "node", "get"}
+		err := app.Run(context.Background(), args)
+		if err == nil {
+			t.Error("Expected error for missing required flag")
+		}
+	})
+
+	// Test 3: Retry node with missing ID
+	t.Run("NodeRetryMissingID", func(t *testing.T) {
+		args := []string{"arp_cli", "workflow", "node", "retry"}
+		err := app.Run(context.Background(), args)
+		if err == nil {
+			t.Error("Expected error for missing required flag")
+		}
+	})
+}
+
+// TestIntegration_CommandHelp tests that help is available for all workflow commands
+func TestIntegration_CommandHelp(t *testing.T) {
+	app := buildTestApp()
+
+	tests := []struct {
+		name string
+		args []string
+	}{
+		{"workflow help", []string{"arp_cli", "workflow", "--help"}},
+		{"template help", []string{"arp_cli", "workflow", "template", "--help"}},
+		{"instance help", []string{"arp_cli", "workflow", "instance", "--help"}},
+		{"node help", []string{"arp_cli", "workflow", "node", "--help"}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := app.Run(context.Background(), tt.args)
+			if err != nil {
+				t.Errorf("Help command failed: %v", err)
+			}
+		})
+	}
+}
+
+// TestIntegration_JSONOutput tests JSON output format
+func TestIntegration_JSONOutput(t *testing.T) {
+	if testing.Short() {
+		t.Skip("Skipping integration test in short mode")
+	}
+
+	app := buildTestApp()
+
+	// Test that JSON flag is accepted
+	t.Run("TemplateListJSON", func(t *testing.T) {
+		args := []string{"arp_cli", "workflow", "template", "list", "--json"}
+		err := app.Run(context.Background(), args)
+		// We expect an error about missing auth
+		if err != nil && !strings.Contains(err.Error(), "not authenticated") {
+			t.Logf("Expected auth error or success, got: %v", err)
+		}
+	})
+
+	t.Run("InstanceListJSON", func(t *testing.T) {
+		args := []string{"arp_cli", "workflow", "instance", "list", "--json"}
+		err := app.Run(context.Background(), args)
+		if err != nil && !strings.Contains(err.Error(), "not authenticated") {
+			t.Logf("Expected auth error or success, got: %v", err)
+		}
+	})
+}
+
+// TestIntegration_FileInput tests file input for workflow definitions
+func TestIntegration_FileInput(t *testing.T) {
+	// Create a temporary file with workflow definition
+	tmpFile, err := os.CreateTemp("", "workflow_*.json")
+	if err != nil {
+		t.Fatalf("Failed to create temp file: %v", err)
+	}
+	defer os.Remove(tmpFile.Name())
+
+	definition := `{
+		"nodes": [
+			{"id": "start", "type": "start"},
+			{"id": "task1", "type": "task"},
+			{"id": "end", "type": "end"}
+		],
+		"edges": [
+			{"from": "start", "to": "task1"},
+			{"from": "task1", "to": "end"}
+		]
+	}`
+
+	if _, err := tmpFile.WriteString(definition); err != nil {
+		t.Fatalf("Failed to write to temp file: %v", err)
+	}
+	tmpFile.Close()
+
+	// Test that readFileOrString works correctly
+	result := cmd.ReadFileOrString("@" + tmpFile.Name())
+	if result != definition {
+		t.Errorf("File content mismatch.\nExpected: %s\nGot: %s", definition, result)
+	}
+}
+
+// TestIntegration_CommandAliases tests command aliases
+func TestIntegration_CommandAliases(t *testing.T) {
+	app := buildTestApp()
+
+	tests := []struct {
+		name string
+		args []string
+	}{
+		{"template list alias", []string{"arp_cli", "workflow", "template", "ls"}},
+		{"instance list alias", []string{"arp_cli", "workflow", "instance", "ls"}},
+		{"node list alias", []string{"arp_cli", "workflow", "node", "ls"}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := app.Run(context.Background(), tt.args)
+			// We expect an error about missing auth
+			if err != nil && !strings.Contains(err.Error(), "not authenticated") {
+				t.Logf("Expected auth error or success, got: %v", err)
+			}
+		})
+	}
+}
+
+// TestIntegration_MultipleFlags tests commands with multiple flags
+func TestIntegration_MultipleFlags(t *testing.T) {
+	if testing.Short() {
+		t.Skip("Skipping integration test in short mode")
+	}
+
+	app := buildTestApp()
+
+	t.Run("StartWithAllFlags", func(t *testing.T) {
+		args := []string{
+			"arp_cli", "workflow", "instance", "start",
+			"--template", "template-123",
+			"--service", "service-456",
+			"--context", `{"key": "value"}`,
+		}
+		err := app.Run(context.Background(), args)
+		// We expect an error about missing auth
+		if err != nil && !strings.Contains(err.Error(), "not authenticated") {
+			t.Logf("Expected auth error or success, got: %v", err)
+		}
+	})
+
+	t.Run("CreateWithAllFlags", func(t *testing.T) {
+		args := []string{
+			"arp_cli", "workflow", "template", "create",
+			"--name", "Test Workflow",
+			"--description", "Test Description",
+			"--definition", `{"nodes": []}`,
+			"--active",
+		}
+		err := app.Run(context.Background(), args)
+		if err != nil && !strings.Contains(err.Error(), "not authenticated") {
+			t.Logf("Expected auth error or success, got: %v", err)
+		}
+	})
+}
+
+// TestIntegration_ErrorHandling tests error handling for required flags
+func TestIntegration_ErrorHandling(t *testing.T) {
+	app := buildTestApp()
+
+	tests := []struct {
+		name        string
+		args        []string
+		expectError bool
+	}{
+		{"missing template id", []string{"arp_cli", "workflow", "template", "get"}, true},
+		{"missing instance id", []string{"arp_cli", "workflow", "instance", "get"}, true},
+		{"missing node id", []string{"arp_cli", "workflow", "node", "get"}, true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := app.Run(context.Background(), tt.args)
+			hasError := err != nil
+			if hasError != tt.expectError {
+				t.Errorf("Expected error=%v, got error=%v (%v)", tt.expectError, hasError, err)
+			}
+		})
+	}
+}
+
+// TestIntegration_WorkflowDefinitionValidation tests workflow definition handling
+func TestIntegration_WorkflowDefinitionValidation(t *testing.T) {
+	// Test various workflow definition formats
+	tests := []struct {
+		name       string
+		definition string
+		valid      bool
+	}{
+		{
+			name:       "empty definition",
+			definition: `{}`,
+			valid:      true,
+		},
+		{
+			name:       "simple DAG",
+			definition: `{"nodes": [{"id": "a"}], "edges": []}`,
+			valid:      true,
+		},
+		{
+			name:       "complex DAG",
+			definition: `{"nodes": [{"id": "start", "type": "start"}, {"id": "end", "type": "end"}], "edges": [{"from": "start", "to": "end"}]}`,
+			valid:      true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Verify the definition is valid JSON
+			var data interface{}
+			err := json.Unmarshal([]byte(tt.definition), &data)
+			if tt.valid && err != nil {
+				t.Errorf("Expected valid JSON, got error: %v", err)
+			}
+			if !tt.valid && err == nil {
+				t.Error("Expected invalid JSON, but parsing succeeded")
+			}
+		})
+	}
+}
+
+// buildTestApp creates a CLI app for testing
+func buildTestApp() *cli.Command {
+	return &cli.Command{
+		Name:     "arp_cli",
+		Usage:    "Test CLI",
+		Commands: []*cli.Command{cmd.WorkflowCommand()},
+	}
+}
+
+// Helper function to check if a string contains a substring
+func containsString(s, substr string) bool {
+	return strings.Contains(s, substr)
+}
+
+// BenchmarkWorkflowCommand benchmarks the workflow command execution
+func BenchmarkWorkflowCommand(b *testing.B) {
+	app := buildTestApp()
+	args := []string{"arp_cli", "workflow", "--help"}
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		_ = app.Run(context.Background(), args)
+	}
+}
+
+// BenchmarkWorkflowTemplateList benchmarks the template list command
+func BenchmarkWorkflowTemplateList(b *testing.B) {
+	app := buildTestApp()
+	args := []string{"arp_cli", "workflow", "template", "list", "--json"}
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		_ = app.Run(context.Background(), args)
+	}
+}
+
+// Mock test for context handling
+func TestContextHandling(t *testing.T) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	app := buildTestApp()
+	args := []string{"arp_cli", "workflow", "template", "list"}
+
+	// The command should respect context cancellation
+	done := make(chan error, 1)
+	go func() {
+		done <- app.Run(ctx, args)
+	}()
+
+	select {
+	case <-ctx.Done():
+		t.Log("Context cancelled")
+	case err := <-done:
+		if err != nil {
+			t.Logf("Command completed with error: %v", err)
+		}
+	case <-time.After(10 * time.Second):
+		t.Error("Command took too long")
+	}
+}

+ 1 - 0
arp_cli/main.go

@@ -85,6 +85,7 @@ Then use the various commands to manage your ARP data.`,
 			cmd.MessageCommand(),
 			cmd.RoleCommand(),
 			cmd.PermissionCommand(),
+			cmd.WorkflowCommand(),
 		},
 		Before: func(ctx context.Context, command *cli.Command) (context.Context, error) {
 			// Store global flags in context for later use