feat: implement hierarchical and hybrid workflow engine

- Add comprehensive workflow types and schemas in packages/types/src/workflow.ts
- Implement WorkflowParser for YAML validation and parsing
- Create WorkflowStateManager for persistent state management
- Build WorkflowExecutor with support for parallel execution and retries
- Implement WorkflowEngine as main integration point with VS Code
- Add execute_workflow tool for triggering workflows from tasks
- Support hierarchical orchestration with parent-child task relationships
- Enable hybrid workflows combining fixed rules with dynamic decisions
- Include comprehensive test coverage for all components
- Add detailed documentation and example workflows
- Update main README with workflow feature description

Fixes #6298
This commit is contained in:
Roo Code 2025-07-28 16:00:29 +00:00
parent 342ee70fb4
commit f7f724c84e
18 changed files with 3507 additions and 0 deletions

View file

@ -97,6 +97,37 @@ Roo Code comes with powerful [tools](https://docs.roocode.com/basic-usage/how-to
MCP extends Roo Code's capabilities by allowing you to add unlimited custom tools. Integrate with external APIs, connect to databases, or create specialized development tools - MCP provides the framework to expand Roo Code's functionality to meet your specific needs.
### Workflow Engine
Roo Code includes a powerful [workflow engine](src/core/workflow/README.md) that enables:
- **Hierarchical Orchestration:** Multi-level AI agent management where high-level orchestrators delegate to specialized agents
- **Hybrid Workflows:** Combine deterministic rules with dynamic AI-driven decisions
- **YAML Configuration:** Define complex workflows using simple YAML files
- **State Management:** Persistent workflow state tracking with automatic recovery
- **Parallel Execution:** Run multiple stages concurrently for faster completion
Example workflow:
```yaml
name: "Web App Development"
agents:
architect:
mode: architect
description: "Technical architect for system design"
developer:
mode: code
description: "Full-stack developer"
workflow:
- name: design
agent: architect
task: "Design the application architecture"
on_success: implement
- name: implement
agent: developer
task: "Implement the designed architecture"
```
### Customization
Make Roo Code work your way with:

View file

@ -0,0 +1,273 @@
name: "Hierarchical E-commerce Platform Development"
description: "Multi-level orchestration workflow for building a complete e-commerce platform"
version: "1.0.0"
# High-level orchestrators and specialized agents
agents:
# Executive level
- id: cto
mode: orchestrator
description: "Chief Technology Officer - makes high-level architectural decisions"
# Department leads (middle management)
- id: frontend_lead
mode: orchestrator
description: "Frontend team lead - coordinates UI/UX development"
- id: backend_lead
mode: orchestrator
description: "Backend team lead - coordinates API and services"
- id: infrastructure_lead
mode: orchestrator
description: "Infrastructure lead - manages deployment and scaling"
# Individual contributors
- id: ui_developer
mode: code
description: "Implements user interface components"
- id: ux_designer
mode: architect
description: "Designs user experience and workflows"
- id: api_developer
mode: code
description: "Develops REST/GraphQL APIs"
- id: database_engineer
mode: code
description: "Designs and optimizes database schemas"
- id: devops_engineer
mode: code
description: "Sets up CI/CD and infrastructure"
- id: security_engineer
mode: debug
description: "Implements security measures and audits"
- id: qa_engineer
mode: test
description: "Ensures quality through comprehensive testing"
workflow:
# Executive planning
- name: strategic_planning
agent: cto
description: "Define overall architecture and delegate to department leads"
next_steps:
- frontend_team_planning
- backend_team_planning
- infrastructure_planning
strategy: orchestrate
timeout: 2400 # 40 minutes
# Department-level planning (parallel)
- name: frontend_team_planning
agent: frontend_lead
description: "Plan frontend architecture and assign tasks"
next_steps:
- ui_component_development
- ux_design_phase
- frontend_state_management
strategy: orchestrate
parallel: true
- name: backend_team_planning
agent: backend_lead
description: "Plan backend services and assign tasks"
next_steps:
- api_development
- database_design
- authentication_service
- payment_integration
strategy: orchestrate
parallel: true
- name: infrastructure_planning
agent: infrastructure_lead
description: "Plan deployment strategy and infrastructure"
next_steps:
- ci_cd_setup
- kubernetes_configuration
- monitoring_setup
strategy: orchestrate
parallel: true
# Frontend team tasks
- name: ux_design_phase
agent: ux_designer
description: "Create wireframes and user flow diagrams"
on_success: ui_component_development
retry_count: 1
- name: ui_component_development
agent: ui_developer
description: "Build reusable UI components"
on_success: frontend_integration
on_failure: frontend_team_review
- name: frontend_state_management
agent: ui_developer
description: "Implement Redux/MobX state management"
on_success: frontend_integration
parallel: true
# Backend team tasks
- name: database_design
agent: database_engineer
description: "Design normalized database schema"
on_success: api_development
- name: api_development
agent: api_developer
description: "Develop RESTful API endpoints"
on_success: api_testing
on_failure: backend_team_review
retry_count: 2
- name: authentication_service
agent: api_developer
description: "Implement JWT authentication"
on_success: security_audit
parallel: true
- name: payment_integration
agent: api_developer
description: "Integrate payment gateway"
on_success: payment_testing
parallel: true
# Infrastructure tasks
- name: ci_cd_setup
agent: devops_engineer
description: "Configure GitHub Actions/Jenkins pipeline"
on_success: deployment_ready
- name: kubernetes_configuration
agent: devops_engineer
description: "Set up K8s clusters and deployments"
on_success: deployment_ready
parallel: true
- name: monitoring_setup
agent: devops_engineer
description: "Configure Prometheus and Grafana"
on_success: deployment_ready
parallel: true
# Integration and testing phases
- name: frontend_integration
agent: frontend_lead
description: "Integrate all frontend components"
on_success: frontend_testing
on_failure: frontend_team_planning
- name: frontend_team_review
agent: frontend_lead
description: "Review and reassign frontend tasks"
next_steps:
- ui_component_development
- frontend_state_management
strategy: orchestrate
- name: backend_team_review
agent: backend_lead
description: "Review and reassign backend tasks"
next_steps:
- api_development
- database_design
strategy: orchestrate
- name: api_testing
agent: qa_engineer
description: "Test API endpoints and performance"
on_success: backend_integration
on_failure: api_development
- name: payment_testing
agent: qa_engineer
description: "Test payment flow end-to-end"
on_success: backend_integration
on_failure: payment_integration
- name: frontend_testing
agent: qa_engineer
description: "Run UI tests and accessibility checks"
on_success: full_integration
on_failure: frontend_integration
- name: backend_integration
agent: backend_lead
description: "Integrate all backend services"
on_success: full_integration
on_failure: backend_team_planning
- name: security_audit
agent: security_engineer
description: "Perform security vulnerability assessment"
on_success: security_fixes
on_failure: security_fixes
- name: security_fixes
agent: security_engineer
description: "Fix identified security issues"
on_success: full_integration
retry_count: 3
# Final integration and deployment
- name: full_integration
agent: cto
description: "Oversee full system integration"
next_steps:
- system_testing
- performance_testing
- deployment_preparation
strategy: orchestrate
- name: system_testing
agent: qa_engineer
description: "Comprehensive end-to-end testing"
on_success: deployment_ready
on_failure: cto_review
- name: performance_testing
agent: qa_engineer
description: "Load testing and optimization"
on_success: deployment_ready
on_failure: performance_optimization
parallel: true
- name: performance_optimization
agent: infrastructure_lead
description: "Optimize system performance"
next_steps:
- api_development
- database_design
- kubernetes_configuration
strategy: orchestrate
- name: deployment_preparation
agent: infrastructure_lead
description: "Prepare production deployment"
on_success: deployment_ready
- name: deployment_ready
agent: cto
description: "Final review and deployment approval"
on_success: end
- name: cto_review
agent: cto
description: "Executive review of blockers"
next_steps:
- frontend_team_planning
- backend_team_planning
- infrastructure_planning
strategy: orchestrate
# Workflow configuration
max_parallel_stages: 5 # Allow more parallel execution
default_timeout: 7200 # 2 hours for complex tasks
enable_checkpoints: true

View file

@ -0,0 +1,155 @@
name: "Web App Development Workflow"
description: "A comprehensive workflow for developing a web application with frontend and backend components"
version: "1.0.0"
# Define all participating agents
agents:
- id: project_manager
mode: architect
description: "Analyzes requirements and creates project plan"
- id: dev_team_lead
mode: orchestrator
description: "Coordinates development tasks and makes architectural decisions"
- id: frontend_dev
mode: code
description: "Implements frontend features using React/Vue/Angular"
- id: backend_dev
mode: code
description: "Implements backend API and business logic"
- id: code_reviewer
mode: pr-reviewer
description: "Reviews code quality, patterns, and standards"
- id: tester
mode: test
description: "Writes and executes tests"
- id: debugger
mode: debug
description: "Investigates and fixes issues"
# Define the workflow stages
workflow:
# Initial planning phase
- name: requirement_analysis
agent: project_manager
description: "Analyze project requirements and create initial plan"
on_success: development_planning
strategy: fixed
timeout: 1800 # 30 minutes
# Development planning with dynamic orchestration
- name: development_planning
agent: dev_team_lead
description: "Break down tasks and decide development approach"
next_steps:
- frontend_development
- backend_development
- database_design
strategy: orchestrate # AI decides which tasks to execute
# Parallel development tasks
- name: frontend_development
agent: frontend_dev
description: "Implement user interface components"
on_success: frontend_review
on_failure: frontend_debugging
retry_count: 2
parallel: true # Can run in parallel with backend
- name: backend_development
agent: backend_dev
description: "Implement API endpoints and business logic"
on_success: backend_review
on_failure: backend_debugging
retry_count: 2
parallel: true
- name: database_design
agent: backend_dev
description: "Design and implement database schema"
on_success: integration_planning
parallel: true
# Code review stages
- name: frontend_review
agent: code_reviewer
description: "Review frontend code for quality and standards"
on_success: frontend_testing
on_failure: frontend_development # Send back for fixes
- name: backend_review
agent: code_reviewer
description: "Review backend code for quality and standards"
on_success: backend_testing
on_failure: backend_development
# Testing stages
- name: frontend_testing
agent: tester
description: "Write and run frontend unit and integration tests"
on_success: integration_planning
on_failure: frontend_debugging
- name: backend_testing
agent: tester
description: "Write and run backend unit and API tests"
on_success: integration_planning
on_failure: backend_debugging
# Debugging stages (only executed on failure)
- name: frontend_debugging
agent: debugger
description: "Debug and fix frontend issues"
on_success: frontend_review
on_failure: escalate_to_lead
- name: backend_debugging
agent: debugger
description: "Debug and fix backend issues"
on_success: backend_review
on_failure: escalate_to_lead
# Integration and deployment planning
- name: integration_planning
agent: dev_team_lead
description: "Plan integration of all components"
next_steps:
- integration_testing
- deployment_preparation
strategy: orchestrate
- name: integration_testing
agent: tester
description: "Test integrated system end-to-end"
on_success: deployment_preparation
on_failure: integration_debugging
- name: integration_debugging
agent: debugger
description: "Fix integration issues"
on_success: integration_testing
retry_count: 3
- name: deployment_preparation
agent: dev_team_lead
description: "Prepare for deployment"
on_success: end
# Escalation path
- name: escalate_to_lead
agent: dev_team_lead
description: "Handle complex issues that couldn't be resolved"
next_steps:
- frontend_development
- backend_development
strategy: orchestrate
# Global workflow settings
max_parallel_stages: 3
default_timeout: 3600 # 1 hour default
enable_checkpoints: true

View file

@ -21,3 +21,4 @@ export * from "./tool.js"
export * from "./type-fu.js"
export * from "./vscode.js"
export * from "./todo.js"
export * from "./workflow.js"

View file

@ -34,6 +34,7 @@ export const toolNames = [
"fetch_instructions",
"codebase_search",
"update_todo_list",
"execute_workflow",
] as const
export const toolNamesSchema = z.enum(toolNames)

View file

@ -0,0 +1,167 @@
import { z } from "zod"
/**
* Custom error class for workflow configuration validation
*/
export class WorkflowConfigValidationError extends Error {
constructor(message: string) {
super(message)
this.name = "WorkflowConfigValidationError"
}
}
/**
* Workflow transition strategies
*/
export const WORKFLOW_STRATEGIES = {
FIXED: "fixed",
ORCHESTRATE: "orchestrate",
} as const
export type WorkflowStrategy = (typeof WORKFLOW_STRATEGIES)[keyof typeof WORKFLOW_STRATEGIES]
/**
* Workflow stage status
*/
export const WORKFLOW_STAGE_STATUS = {
PENDING: "pending",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
FAILED: "failed",
SKIPPED: "skipped",
} as const
export type WorkflowStageStatus = (typeof WORKFLOW_STAGE_STATUS)[keyof typeof WORKFLOW_STAGE_STATUS]
/**
* Agent definition referencing a mode
*/
export const workflowAgentSchema = z.object({
id: z.string().min(1, "Agent ID is required"),
mode: z.string().min(1, "Mode slug is required"),
description: z.string().optional(),
})
export type WorkflowAgent = z.infer<typeof workflowAgentSchema>
/**
* Workflow stage definition
*/
export const workflowStageSchema = z.object({
name: z.string().min(1, "Stage name is required"),
agent: z.string().min(1, "Agent ID is required"),
description: z.string().optional(),
// For fixed transitions
on_success: z.string().optional(),
on_failure: z.string().optional(),
// For dynamic orchestration
next_steps: z.array(z.string()).optional(),
strategy: z.enum([WORKFLOW_STRATEGIES.FIXED, WORKFLOW_STRATEGIES.ORCHESTRATE]).optional(),
// Additional configuration
timeout: z.number().positive().optional(), // Timeout in seconds
retry_count: z.number().nonnegative().optional(),
parallel: z.boolean().optional(), // Can run in parallel with other stages
})
export type WorkflowStage = z.infer<typeof workflowStageSchema>
/**
* Complete workflow configuration
*/
export const workflowConfigSchema = z.object({
name: z.string().min(1, "Workflow name is required"),
description: z.string().optional(),
version: z.string().optional(),
agents: z.array(workflowAgentSchema).min(1, "At least one agent is required"),
workflow: z.array(workflowStageSchema).min(1, "At least one stage is required"),
// Global settings
max_parallel_stages: z.number().positive().optional(),
default_timeout: z.number().positive().optional(),
enable_checkpoints: z.boolean().optional(),
})
export type WorkflowConfig = z.infer<typeof workflowConfigSchema>
/**
* Runtime state for a workflow stage
*/
export interface WorkflowStageState {
name: string
status: WorkflowStageStatus
agent: string
startedAt?: number
completedAt?: number
result?: string
error?: string
retryCount: number
}
/**
* Runtime state for the entire workflow
*/
export interface WorkflowState {
id: string
name: string
status: WorkflowStageStatus
stages: Record<string, WorkflowStageState>
currentStages: string[] // Currently active stages
completedStages: string[]
failedStages: string[]
startedAt: number
completedAt?: number
parentTaskId?: string // For hierarchical workflows
context: Record<string, unknown> // Shared context between stages
}
/**
* Workflow execution options
*/
export interface WorkflowExecutionOptions {
workflowId?: string // Resume existing workflow
parentTaskId?: string // For hierarchical execution
initialContext?: Record<string, unknown>
checkpointInterval?: number // Auto-checkpoint every N stages
}
/**
* Workflow execution result
*/
export interface WorkflowExecutionResult {
workflowId: string
status: WorkflowStageStatus
stages: WorkflowStageState[]
context: Record<string, unknown>
duration: number
}
/**
* Workflow event types for monitoring
*/
export const WORKFLOW_EVENTS = {
WORKFLOW_STARTED: "workflow:started",
WORKFLOW_COMPLETED: "workflow:completed",
WORKFLOW_FAILED: "workflow:failed",
STAGE_STARTED: "stage:started",
STAGE_COMPLETED: "stage:completed",
STAGE_FAILED: "stage:failed",
STAGE_RETRYING: "stage:retrying",
ORCHESTRATOR_DECISION: "orchestrator:decision",
} as const
export type WorkflowEventType = (typeof WORKFLOW_EVENTS)[keyof typeof WORKFLOW_EVENTS]
/**
* Workflow event payload
*/
export interface WorkflowEvent {
type: WorkflowEventType
workflowId: string
timestamp: number
data: {
stageName?: string
agent?: string
decision?: string
error?: string
[key: string]: unknown
}
}

View file

@ -24,6 +24,7 @@ import { askFollowupQuestionTool } from "../tools/askFollowupQuestionTool"
import { switchModeTool } from "../tools/switchModeTool"
import { attemptCompletionTool } from "../tools/attemptCompletionTool"
import { newTaskTool } from "../tools/newTaskTool"
import { executeWorkflowTool } from "../tools/executeWorkflowTool"
import { checkpointSave } from "../checkpoints"
import { updateTodoListTool } from "../tools/updateTodoListTool"
@ -214,6 +215,8 @@ export async function presentAssistantMessage(cline: Task) {
const modeName = getModeBySlug(mode, customModes)?.name ?? mode
return `[${block.name} in ${modeName} mode: '${message}']`
}
case "execute_workflow":
return `[${block.name} for '${block.params.path || "inline workflow"}']`
}
}
@ -522,6 +525,9 @@ export async function presentAssistantMessage(cline: Task) {
askFinishSubTaskApproval,
)
break
case "execute_workflow":
await executeWorkflowTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
break
}
break

View file

@ -0,0 +1,148 @@
import * as path from "path"
import {
type ToolUse,
type AskApproval,
type HandleError,
type PushToolResult,
type RemoveClosingTag,
} from "../../shared/tools"
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { WorkflowParser } from "../workflow/WorkflowParser"
import { WorkflowEngine } from "../workflow/WorkflowEngine"
import { fileExistsAtPath } from "../../utils/fs"
import { getWorkspacePath } from "../../utils/path"
export async function executeWorkflowTool(
cline: Task,
block: ToolUse,
askApproval: AskApproval,
handleError: HandleError,
pushToolResult: PushToolResult,
removeClosingTag: RemoveClosingTag,
) {
const filePath: string | undefined = block.params.path
const workflowYaml: string | undefined = block.params.workflow
try {
if (block.partial) {
const partialMessage = JSON.stringify({
tool: "executeWorkflow",
path: removeClosingTag("path", filePath),
workflow: removeClosingTag("workflow", workflowYaml),
})
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
} else {
// Must have either path or workflow content
if (!filePath && !workflowYaml) {
cline.consecutiveMistakeCount++
cline.recordToolError("execute_workflow")
pushToolResult(
await cline.sayAndCreateMissingParamError(
"execute_workflow",
"path or workflow",
"Either 'path' to a workflow file or 'workflow' YAML content is required",
),
)
return
}
cline.consecutiveMistakeCount = 0
let workflowConfig
let displayPath = ""
// Parse workflow configuration
if (filePath) {
// Load from file
const workspacePath = getWorkspacePath()
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(workspacePath, filePath)
if (!(await fileExistsAtPath(absolutePath))) {
pushToolResult(formatResponse.toolError(`Workflow file not found: ${filePath}`))
return
}
displayPath = filePath
workflowConfig = await WorkflowParser.loadFromFile(absolutePath)
} else if (workflowYaml) {
// Parse from YAML content
displayPath = "inline workflow"
workflowConfig = WorkflowParser.parseYaml(workflowYaml)
}
if (!workflowConfig) {
pushToolResult(formatResponse.toolError("Failed to parse workflow configuration"))
return
}
// Show workflow details for approval
const toolMessage = JSON.stringify({
tool: "executeWorkflow",
workflow: workflowConfig.name,
description: workflowConfig.description,
agents: workflowConfig.agents.length,
stages: workflowConfig.workflow.length,
source: displayPath,
})
const didApprove = await askApproval("tool", toolMessage)
if (!didApprove) {
return
}
const provider = cline.providerRef.deref()
if (!provider) {
pushToolResult(formatResponse.toolError("Provider reference lost"))
return
}
// Create workflow engine
const workflowEngine = new WorkflowEngine(provider)
// Execute workflow with parent task context
const result = await workflowEngine.executeWorkflow(workflowConfig, {
parentTaskId: cline.taskId,
initialContext: {
parentTaskId: cline.taskId,
workingDirectory: cline.cwd,
},
})
// Format result
const resultMessage = `Workflow "${workflowConfig.name}" ${result.status}
Duration: ${Math.round(result.duration / 1000)}s
Completed stages: ${result.stages.filter((s) => s.status === "completed").length}/${result.stages.length}
Stage Results:
${result.stages
.map((stage) => {
const icon =
stage.status === "completed"
? "✅"
: stage.status === "failed"
? "❌"
: stage.status === "skipped"
? "⏭️"
: "⏸️"
return `${icon} ${stage.name}: ${stage.status}${stage.error ? ` - ${stage.error}` : ""}`
})
.join("\n")}
Final Context:
${JSON.stringify(result.context, null, 2)}`
pushToolResult(resultMessage)
// Clean up
workflowEngine.dispose()
}
} catch (error) {
await handleError("executing workflow", error)
return
}
}

View file

@ -0,0 +1,446 @@
import * as vscode from "vscode"
import * as path from "path"
import {
type WorkflowConfig,
type WorkflowExecutionOptions,
type WorkflowExecutionResult,
type WorkflowEvent,
} from "@roo-code/types"
import { ClineProvider } from "../webview/ClineProvider"
import { WorkflowParser } from "./WorkflowParser"
import { WorkflowExecutor } from "./WorkflowExecutor"
import { WorkflowStateManager } from "./WorkflowStateManager"
import { logger } from "../../utils/logging"
import { getWorkspacePath } from "../../utils/path"
/**
* Main workflow engine that integrates all workflow components
*/
export class WorkflowEngine {
private executor: WorkflowExecutor
private stateManager: WorkflowStateManager
private provider: ClineProvider
private globalStoragePath: string
private disposables: vscode.Disposable[] = []
constructor(provider: ClineProvider) {
this.provider = provider
this.globalStoragePath = provider.context.globalStorageUri.fsPath
// Initialize components
this.stateManager = new WorkflowStateManager(this.globalStoragePath)
this.executor = new WorkflowExecutor(provider, this.globalStoragePath)
// Set up event forwarding
this.executor.on("workflow:event", (event: WorkflowEvent) => {
this.handleWorkflowEvent(event)
})
// Register commands
this.registerCommands()
logger.info("WorkflowEngine initialized")
}
/**
* Register VS Code commands for workflow management
*/
private registerCommands(): void {
// Command to execute workflow from file
this.disposables.push(
vscode.commands.registerCommand("roo-cline.workflow.executeFromFile", async () => {
await this.executeWorkflowFromFile()
}),
)
// Command to create sample workflow
this.disposables.push(
vscode.commands.registerCommand("roo-cline.workflow.createSample", async () => {
await this.createSampleWorkflow()
}),
)
// Command to list workflows
this.disposables.push(
vscode.commands.registerCommand("roo-cline.workflow.list", async () => {
await this.showWorkflowList()
}),
)
// Command to stop workflow
this.disposables.push(
vscode.commands.registerCommand("roo-cline.workflow.stop", async (workflowId?: string) => {
await this.stopWorkflow(workflowId)
}),
)
}
/**
* Execute a workflow from a YAML file
*/
public async executeWorkflowFromFile(filePath?: string): Promise<WorkflowExecutionResult | undefined> {
try {
// If no file path provided, prompt user to select
if (!filePath) {
const fileUri = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
filters: {
"Workflow Files": ["yaml", "yml"],
"All Files": ["*"],
},
title: "Select Workflow File",
})
if (!fileUri || fileUri.length === 0) {
return undefined
}
filePath = fileUri[0].fsPath
}
// Parse workflow configuration
const config = await WorkflowParser.loadFromFile(filePath)
// Show workflow info and confirm execution
const proceed = await vscode.window.showInformationMessage(
`Execute workflow "${config.name}"?`,
{ modal: true, detail: config.description },
"Execute",
"Cancel",
)
if (proceed !== "Execute") {
return undefined
}
// Execute workflow
return await this.executeWorkflow(config)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Failed to execute workflow: ${errorMessage}`)
logger.error("Failed to execute workflow from file", { filePath, error: errorMessage })
return undefined
}
}
/**
* Execute a workflow configuration
*/
public async executeWorkflow(
config: WorkflowConfig,
options: WorkflowExecutionOptions = {},
): Promise<WorkflowExecutionResult> {
try {
// Show progress notification
return await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Executing workflow: ${config.name}`,
cancellable: true,
},
async (progress, token) => {
// Handle cancellation
token.onCancellationRequested(() => {
if (options.workflowId) {
this.executor.stopWorkflow(options.workflowId)
}
})
// Execute workflow
const result = await this.executor.executeWorkflow(config, options)
// Show completion message
const statusIcon = result.status === "completed" ? "✅" : "❌"
vscode.window.showInformationMessage(
`${statusIcon} Workflow "${config.name}" ${result.status} in ${Math.round(result.duration / 1000)}s`,
)
return result
},
)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Workflow execution failed: ${errorMessage}`)
throw error
}
}
/**
* Create and save a sample workflow file
*/
private async createSampleWorkflow(): Promise<void> {
try {
// Get workspace folder
const workspacePath = getWorkspacePath()
if (!workspacePath) {
vscode.window.showErrorMessage("No workspace folder open")
return
}
// Create sample workflow
const sampleWorkflow = WorkflowParser.createSampleWorkflow()
// Prompt for file name
const fileName = await vscode.window.showInputBox({
prompt: "Enter workflow file name",
value: "sample-workflow.yaml",
validateInput: (value) => {
if (!value.endsWith(".yaml") && !value.endsWith(".yml")) {
return "File must have .yaml or .yml extension"
}
return undefined
},
})
if (!fileName) {
return
}
// Save workflow file
const filePath = path.join(workspacePath, fileName)
await WorkflowParser.saveToFile(sampleWorkflow, filePath)
// Open the file
const document = await vscode.workspace.openTextDocument(filePath)
await vscode.window.showTextDocument(document)
vscode.window.showInformationMessage(`Sample workflow created: ${fileName}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Failed to create sample workflow: ${errorMessage}`)
}
}
/**
* Show list of workflows
*/
private async showWorkflowList(): Promise<void> {
try {
// Get all workflows
const workflowIds = await this.stateManager.listWorkflows()
const runningIds = this.executor.getRunningWorkflows()
if (workflowIds.length === 0) {
vscode.window.showInformationMessage("No workflows found")
return
}
// Create quick pick items
const items = await Promise.all(
workflowIds.map(async (id) => {
const state = await this.stateManager.loadState(id)
const isRunning = runningIds.includes(id)
const statusIcon = isRunning ? "🔄" : state?.status === "completed" ? "✅" : "❌"
return {
label: `${statusIcon} ${state?.name || id}`,
description: `Status: ${state?.status || "unknown"}`,
detail: state ? `Started: ${new Date(state.startedAt).toLocaleString()}` : undefined,
id,
state,
}
}),
)
// Show quick pick
const selected = await vscode.window.showQuickPick(items, {
placeHolder: "Select a workflow to view details",
})
if (selected && selected.state) {
// Show workflow details
const details = this.formatWorkflowDetails(selected.state)
const action = await vscode.window.showInformationMessage(
`Workflow: ${selected.state.name}`,
{ modal: true, detail: details },
selected.state.status === "in_progress" ? "Stop" : "Delete",
"Close",
)
if (action === "Stop") {
await this.stopWorkflow(selected.id)
} else if (action === "Delete") {
await this.stateManager.deleteState(selected.id)
vscode.window.showInformationMessage("Workflow deleted")
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Failed to list workflows: ${errorMessage}`)
}
}
/**
* Stop a running workflow
*/
private async stopWorkflow(workflowId?: string): Promise<void> {
try {
// If no ID provided, show list of running workflows
if (!workflowId) {
const runningIds = this.executor.getRunningWorkflows()
if (runningIds.length === 0) {
vscode.window.showInformationMessage("No running workflows")
return
}
const items = runningIds.map((id) => {
const state = this.executor.getWorkflowState(id)
return {
label: state?.name || id,
description: `Started: ${state ? new Date(state.startedAt).toLocaleString() : "unknown"}`,
id,
}
})
const selected = await vscode.window.showQuickPick(items, {
placeHolder: "Select workflow to stop",
})
if (!selected) {
return
}
workflowId = selected.id
}
// Confirm stop
const confirm = await vscode.window.showWarningMessage(
`Stop workflow "${workflowId}"?`,
{ modal: true },
"Stop",
"Cancel",
)
if (confirm === "Stop") {
await this.executor.stopWorkflow(workflowId)
vscode.window.showInformationMessage("Workflow stopped")
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
vscode.window.showErrorMessage(`Failed to stop workflow: ${errorMessage}`)
}
}
/**
* Format workflow details for display
*/
private formatWorkflowDetails(state: any): string {
const lines = [
`ID: ${state.id}`,
`Status: ${state.status}`,
`Started: ${new Date(state.startedAt).toLocaleString()}`,
]
if (state.completedAt) {
lines.push(`Completed: ${new Date(state.completedAt).toLocaleString()}`)
const duration = (state.completedAt - state.startedAt) / 1000
lines.push(`Duration: ${Math.round(duration)}s`)
}
lines.push("")
lines.push("Stages:")
for (const [name, stage] of Object.entries(state.stages as Record<string, any>)) {
const statusIcon =
stage.status === "completed"
? "✅"
: stage.status === "failed"
? "❌"
: stage.status === "in_progress"
? "🔄"
: "⏸️"
lines.push(` ${statusIcon} ${name} (${stage.agent})`)
if (stage.error) {
lines.push(` Error: ${stage.error}`)
}
}
if (state.completedStages.length > 0) {
lines.push("")
lines.push(`Completed: ${state.completedStages.length} stages`)
}
if (state.failedStages.length > 0) {
lines.push(`Failed: ${state.failedStages.length} stages`)
}
return lines.join("\n")
}
/**
* Handle workflow events
*/
private handleWorkflowEvent(event: WorkflowEvent): void {
// Log event
logger.info("Workflow event", {
type: event.type,
workflowId: event.workflowId,
timestamp: event.timestamp,
data: event.data,
})
// Show notifications for important events
switch (event.type) {
case "workflow:started":
vscode.window.showInformationMessage(`Workflow started: ${event.data.name}`)
break
case "workflow:completed":
vscode.window.showInformationMessage(
`✅ Workflow completed in ${Math.round((Number(event.data.duration) || 0) / 1000)}s`,
)
break
case "workflow:failed":
vscode.window.showErrorMessage("❌ Workflow failed")
break
case "stage:failed":
vscode.window.showWarningMessage(`Stage failed: ${event.data.stageName} - ${event.data.error}`)
break
}
// Forward event to webview if needed
// Note: We'll need to add this message type to the webview message types
// For now, we'll just log it
logger.debug("Workflow event for webview", { event })
}
/**
* Clean up old workflows
*/
public async cleanupOldWorkflows(daysToKeep: number = 30): Promise<void> {
try {
const count = await this.stateManager.cleanupOldWorkflows(daysToKeep)
logger.info(`Cleaned up ${count} old workflows`)
} catch (error) {
logger.error("Failed to cleanup old workflows", {
error: error instanceof Error ? error.message : String(error),
})
}
}
/**
* Dispose of resources
*/
public dispose(): void {
for (const disposable of this.disposables) {
disposable.dispose()
}
// Stop all running workflows
const runningIds = this.executor.getRunningWorkflows()
for (const id of runningIds) {
this.executor.stopWorkflow(id).catch((error) => {
logger.error("Failed to stop workflow during disposal", { id, error })
})
}
}
}

View file

@ -0,0 +1,459 @@
import { EventEmitter } from "events"
import * as crypto from "crypto"
import {
type WorkflowConfig,
type WorkflowState,
type WorkflowStage,
type WorkflowExecutionOptions,
type WorkflowExecutionResult,
type WorkflowStageStatus,
WORKFLOW_STAGE_STATUS,
WORKFLOW_STRATEGIES,
} from "@roo-code/types"
import { Task } from "../task/Task"
import { ClineProvider } from "../webview/ClineProvider"
import { WorkflowStateManager } from "./WorkflowStateManager"
import { logger } from "../../utils/logging"
import { getModeBySlug } from "../../shared/modes"
/**
* Executes workflows by coordinating agents and managing state
*/
export class WorkflowExecutor extends EventEmitter {
private stateManager: WorkflowStateManager
private provider: ClineProvider
private runningWorkflows: Map<string, WorkflowExecutionContext> = new Map()
constructor(provider: ClineProvider, globalStoragePath: string) {
super()
this.provider = provider
this.stateManager = new WorkflowStateManager(globalStoragePath)
// Forward state manager events
this.stateManager.on("workflow:event", (event) => {
this.emit("workflow:event", event)
})
}
/**
* Execute a workflow
*/
public async executeWorkflow(
config: WorkflowConfig,
options: WorkflowExecutionOptions = {},
): Promise<WorkflowExecutionResult> {
const workflowId = options.workflowId || crypto.randomUUID()
// Load existing state or create new
let state = await this.stateManager.loadState(workflowId)
if (!state) {
state = this.stateManager.createWorkflowState(
workflowId,
config.name,
options.parentTaskId,
options.initialContext,
)
}
// Create execution context
const context: WorkflowExecutionContext = {
config,
state,
options,
activeTasks: new Map(),
}
this.runningWorkflows.set(workflowId, context)
try {
// Start workflow execution
this.stateManager.updateWorkflowStatus(workflowId, WORKFLOW_STAGE_STATUS.IN_PROGRESS)
// Find initial stages (stages with no dependencies)
const initialStages = this.findInitialStages(config)
// Execute workflow
await this.executeStages(context, initialStages)
// Wait for all stages to complete
await this.waitForCompletion(context)
// Determine final status
const finalStatus =
state.failedStages.length > 0 ? WORKFLOW_STAGE_STATUS.FAILED : WORKFLOW_STAGE_STATUS.COMPLETED
this.stateManager.updateWorkflowStatus(workflowId, finalStatus)
return {
workflowId,
status: finalStatus,
stages: Object.values(state.stages),
context: state.context,
duration: (state.completedAt || Date.now()) - state.startedAt,
}
} catch (error) {
logger.error("Workflow execution failed", {
workflowId,
error: error instanceof Error ? error.message : String(error),
})
this.stateManager.updateWorkflowStatus(workflowId, WORKFLOW_STAGE_STATUS.FAILED)
throw error
} finally {
this.runningWorkflows.delete(workflowId)
}
}
/**
* Find stages that can be executed initially
*/
private findInitialStages(config: WorkflowConfig): WorkflowStage[] {
const stageNames = new Set(config.workflow.map((s) => s.name))
const targetedStages = new Set<string>()
// Find all stages that are targeted by transitions
for (const stage of config.workflow) {
if (stage.on_success && stage.on_success !== "end") {
targetedStages.add(stage.on_success)
}
if (stage.on_failure && stage.on_failure !== "end") {
targetedStages.add(stage.on_failure)
}
if (stage.next_steps) {
stage.next_steps.forEach((step) => {
if (step !== "end") {
targetedStages.add(step)
}
})
}
}
// Initial stages are those not targeted by any transition
return config.workflow.filter((stage) => !targetedStages.has(stage.name))
}
/**
* Execute a set of stages
*/
private async executeStages(context: WorkflowExecutionContext, stages: WorkflowStage[]): Promise<void> {
const { config, state, options } = context
// Group stages by parallel execution capability
const parallelStages = stages.filter((s) => s.parallel)
const sequentialStages = stages.filter((s) => !s.parallel)
// Execute parallel stages
if (parallelStages.length > 0) {
const maxParallel = config.max_parallel_stages || 5
const chunks = this.chunkArray(parallelStages, maxParallel)
for (const chunk of chunks) {
await Promise.all(chunk.map((stage) => this.executeStage(context, stage)))
}
}
// Execute sequential stages
for (const stage of sequentialStages) {
await this.executeStage(context, stage)
}
}
/**
* Execute a single stage
*/
private async executeStage(context: WorkflowExecutionContext, stage: WorkflowStage): Promise<void> {
const { config, state } = context
const workflowId = state.id
logger.info(`Executing workflow stage: ${stage.name}`, { workflowId, agent: stage.agent })
// Find agent configuration
const agentConfig = config.agents.find((a) => a.id === stage.agent)
if (!agentConfig) {
throw new Error(`Agent '${stage.agent}' not found in workflow configuration`)
}
// Validate mode exists
const mode = getModeBySlug(agentConfig.mode, await this.provider.getState().then((s) => s?.customModes))
if (!mode) {
throw new Error(`Mode '${agentConfig.mode}' not found for agent '${stage.agent}'`)
}
// Update stage state
this.stateManager.updateStageState(workflowId, stage.name, {
status: WORKFLOW_STAGE_STATUS.IN_PROGRESS,
agent: stage.agent,
})
try {
// Create task message with context
const taskMessage = this.createTaskMessage(stage, agentConfig, state.context)
// Switch to the required mode
await this.provider.handleModeSwitch(agentConfig.mode)
// Create and execute task
const task = await this.provider.initClineWithTask(
taskMessage,
undefined,
context.options.parentTaskId ? ({ taskId: context.options.parentTaskId } as Task) : undefined,
)
if (!task) {
throw new Error("Failed to create task for stage")
}
// Store task reference
context.activeTasks.set(stage.name, task)
// Wait for task completion
const result = await this.waitForTaskCompletion(task, stage.timeout || config.default_timeout)
// Update stage state with result
this.stateManager.updateStageState(workflowId, stage.name, {
status: WORKFLOW_STAGE_STATUS.COMPLETED,
result: result.summary,
completedAt: Date.now(),
})
// Update workflow context with stage results
this.stateManager.updateContext(workflowId, {
[`${stage.name}_result`]: result.summary,
[`${stage.name}_success`]: true,
})
// Determine next stages
await this.processStageTransition(context, stage, true)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(`Stage '${stage.name}' failed`, { workflowId, error: errorMessage })
// Update stage state with error
this.stateManager.updateStageState(workflowId, stage.name, {
status: WORKFLOW_STAGE_STATUS.FAILED,
error: errorMessage,
completedAt: Date.now(),
})
// Update workflow context
this.stateManager.updateContext(workflowId, {
[`${stage.name}_error`]: errorMessage,
[`${stage.name}_success`]: false,
})
// Handle retry logic
const currentRetryCount = state.stages[stage.name]?.retryCount || 0
if (currentRetryCount < (stage.retry_count || 0)) {
logger.info(`Retrying stage '${stage.name}' (attempt ${currentRetryCount + 1})`)
this.stateManager.updateStageState(workflowId, stage.name, {
retryCount: currentRetryCount + 1,
status: WORKFLOW_STAGE_STATUS.PENDING,
})
// Retry the stage
await this.executeStage(context, stage)
} else {
// Process failure transition
await this.processStageTransition(context, stage, false)
}
} finally {
// Clean up task reference
context.activeTasks.delete(stage.name)
}
}
/**
* Process stage transition based on strategy
*/
private async processStageTransition(
context: WorkflowExecutionContext,
stage: WorkflowStage,
success: boolean,
): Promise<void> {
const { config, state } = context
if (stage.strategy === WORKFLOW_STRATEGIES.ORCHESTRATE && success) {
// Let the orchestrator decide next steps
if (!stage.next_steps || stage.next_steps.length === 0) {
return
}
// For now, execute all next steps (in future, orchestrator will decide)
const nextStages = stage.next_steps
.filter((name) => name !== "end")
.map((name) => config.workflow.find((s) => s.name === name))
.filter((s): s is WorkflowStage => s !== undefined)
if (nextStages.length > 0) {
await this.executeStages(context, nextStages)
}
} else {
// Fixed transition
const nextStageName = success ? stage.on_success : stage.on_failure
if (!nextStageName || nextStageName === "end") {
return
}
const nextStage = config.workflow.find((s) => s.name === nextStageName)
if (nextStage) {
await this.executeStage(context, nextStage)
}
}
}
/**
* Create task message for stage execution
*/
private createTaskMessage(
stage: WorkflowStage,
agentConfig: { id: string; mode: string; description?: string },
context: Record<string, any>,
): string {
const contextInfo =
Object.keys(context).length > 0 ? `\n\nWorkflow Context:\n${JSON.stringify(context, null, 2)}` : ""
return `[Workflow Stage: ${stage.name}]
${stage.description || `Execute ${stage.name} stage`}
Agent: ${agentConfig.id} (${agentConfig.description || agentConfig.mode})
${contextInfo}
Please complete this stage and use attempt_completion to report the results.`
}
/**
* Wait for task completion
*/
private async waitForTaskCompletion(task: Task, timeout?: number): Promise<{ summary: string }> {
return new Promise((resolve, reject) => {
let timeoutHandle: NodeJS.Timeout | undefined
if (timeout) {
timeoutHandle = setTimeout(() => {
task.abortTask()
reject(new Error(`Task timed out after ${timeout} seconds`))
}, timeout * 1000)
}
const handleCompletion = (taskId: string, tokenUsage: any, toolUsage: any) => {
if (taskId === task.taskId) {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
// Get the last message as summary
const lastMessage = task.clineMessages[task.clineMessages.length - 1]
const summary = lastMessage?.text || "Task completed"
task.removeListener("taskCompleted", handleCompletion)
task.removeListener("taskAborted", handleAbort)
resolve({ summary })
}
}
const handleAbort = () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
task.removeListener("taskCompleted", handleCompletion)
task.removeListener("taskAborted", handleAbort)
reject(new Error("Task was aborted"))
}
task.on("taskCompleted", handleCompletion)
task.on("taskAborted", handleAbort)
})
}
/**
* Wait for all stages in workflow to complete
*/
private async waitForCompletion(context: WorkflowExecutionContext): Promise<void> {
const { state } = context
const checkInterval = 1000 // 1 second
return new Promise((resolve) => {
const checkCompletion = () => {
const allStagesProcessed = Object.values(state.stages).every(
(stage) =>
stage.status !== WORKFLOW_STAGE_STATUS.PENDING &&
stage.status !== WORKFLOW_STAGE_STATUS.IN_PROGRESS,
)
if (allStagesProcessed && state.currentStages.length === 0) {
resolve()
} else {
setTimeout(checkCompletion, checkInterval)
}
}
checkCompletion()
})
}
/**
* Chunk array into smaller arrays
*/
private chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = []
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size))
}
return chunks
}
/**
* Stop a running workflow
*/
public async stopWorkflow(workflowId: string): Promise<void> {
const context = this.runningWorkflows.get(workflowId)
if (!context) {
return
}
// Abort all active tasks
for (const [stageName, task] of context.activeTasks) {
logger.info(`Aborting task for stage '${stageName}'`)
await task.abortTask()
}
// Update workflow status
this.stateManager.updateWorkflowStatus(workflowId, WORKFLOW_STAGE_STATUS.FAILED)
// Remove from running workflows
this.runningWorkflows.delete(workflowId)
}
/**
* Get running workflows
*/
public getRunningWorkflows(): string[] {
return Array.from(this.runningWorkflows.keys())
}
/**
* Get workflow state
*/
public getWorkflowState(workflowId: string): WorkflowState | undefined {
return this.stateManager.getWorkflowState(workflowId)
}
}
/**
* Internal execution context
*/
interface WorkflowExecutionContext {
config: WorkflowConfig
state: WorkflowState
options: WorkflowExecutionOptions
activeTasks: Map<string, Task>
}

View file

@ -0,0 +1,296 @@
import * as yaml from "yaml"
import * as path from "path"
import * as fs from "fs/promises"
import {
type WorkflowConfig,
type WorkflowAgent,
workflowConfigSchema,
WorkflowConfigValidationError,
} from "@roo-code/types"
import { fileExistsAtPath } from "../../utils/fs"
import { logger } from "../../utils/logging"
/**
* Parser for workflow configuration files
*/
export class WorkflowParser {
/**
* Parse workflow configuration from YAML content
*/
public static parseYaml(yamlContent: string): WorkflowConfig {
try {
const parsed = yaml.parse(yamlContent)
return this.validateConfig(parsed)
} catch (error) {
if (error instanceof WorkflowConfigValidationError) {
throw error
}
throw new WorkflowConfigValidationError(
`Failed to parse workflow YAML: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
/**
* Load and parse workflow configuration from file
*/
public static async loadFromFile(filePath: string): Promise<WorkflowConfig> {
try {
const exists = await fileExistsAtPath(filePath)
if (!exists) {
throw new WorkflowConfigValidationError(`Workflow file not found: ${filePath}`)
}
const content = await fs.readFile(filePath, "utf-8")
return this.parseYaml(content)
} catch (error) {
if (error instanceof WorkflowConfigValidationError) {
throw error
}
throw new WorkflowConfigValidationError(
`Failed to load workflow from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
/**
* Validate workflow configuration against schema
*/
private static validateConfig(config: unknown): WorkflowConfig {
const result = workflowConfigSchema.safeParse(config)
if (!result.success) {
const errors = result.error.errors.map((err) => `${err.path.join(".")}: ${err.message}`).join(", ")
throw new WorkflowConfigValidationError(`Invalid workflow configuration: ${errors}`)
}
// Additional validation
const workflow = result.data
this.validateAgentReferences(workflow)
this.validateStageReferences(workflow)
this.validateTransitions(workflow)
return workflow
}
/**
* Validate that all agent references in stages exist
*/
private static validateAgentReferences(config: WorkflowConfig): void {
const agentIds = new Set(config.agents.map((agent) => agent.id))
for (const stage of config.workflow) {
if (!agentIds.has(stage.agent)) {
throw new WorkflowConfigValidationError(
`Stage '${stage.name}' references unknown agent '${stage.agent}'`,
)
}
}
}
/**
* Validate that all stage references in transitions exist
*/
private static validateStageReferences(config: WorkflowConfig): void {
const stageNames = new Set(config.workflow.map((stage) => stage.name))
stageNames.add("end") // Special end marker
for (const stage of config.workflow) {
// Check fixed transitions
if (stage.on_success && !stageNames.has(stage.on_success)) {
throw new WorkflowConfigValidationError(
`Stage '${stage.name}' has invalid on_success transition to '${stage.on_success}'`,
)
}
if (stage.on_failure && !stageNames.has(stage.on_failure)) {
throw new WorkflowConfigValidationError(
`Stage '${stage.name}' has invalid on_failure transition to '${stage.on_failure}'`,
)
}
// Check orchestration candidates
if (stage.next_steps) {
for (const nextStep of stage.next_steps) {
if (!stageNames.has(nextStep)) {
throw new WorkflowConfigValidationError(
`Stage '${stage.name}' has invalid next_step reference to '${nextStep}'`,
)
}
}
}
}
}
/**
* Validate transition logic
*/
private static validateTransitions(config: WorkflowConfig): void {
for (const stage of config.workflow) {
// Ensure stage has either fixed transition or orchestration strategy
if (stage.strategy === "orchestrate") {
if (!stage.next_steps || stage.next_steps.length === 0) {
throw new WorkflowConfigValidationError(
`Stage '${stage.name}' with orchestrate strategy must have next_steps defined`,
)
}
if (stage.on_success) {
throw new WorkflowConfigValidationError(
`Stage '${stage.name}' cannot have both orchestrate strategy and on_success transition`,
)
}
} else {
// Default is fixed strategy
if (!stage.on_success && !stage.on_failure) {
throw new WorkflowConfigValidationError(
`Stage '${stage.name}' with fixed strategy must have at least on_success or on_failure defined`,
)
}
if (stage.next_steps) {
throw new WorkflowConfigValidationError(
`Stage '${stage.name}' with fixed strategy cannot have next_steps defined`,
)
}
}
}
}
/**
* Convert workflow config to YAML string
*/
public static toYaml(config: WorkflowConfig): string {
return yaml.stringify(config, {
lineWidth: 0,
defaultStringType: "PLAIN",
defaultKeyType: "PLAIN",
})
}
/**
* Save workflow configuration to file
*/
public static async saveToFile(config: WorkflowConfig, filePath: string): Promise<void> {
try {
const yamlContent = this.toYaml(config)
await fs.writeFile(filePath, yamlContent, "utf-8")
logger.info(`Workflow configuration saved to ${filePath}`)
} catch (error) {
throw new WorkflowConfigValidationError(
`Failed to save workflow to ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
/**
* Create a sample workflow configuration
*/
public static createSampleWorkflow(): WorkflowConfig {
return {
name: "Web App Development Workflow",
description: "A sample workflow for developing a web application",
version: "1.0.0",
agents: [
{
id: "project_manager",
mode: "architect",
description: "Analyzes requirements and creates project plan",
},
{
id: "dev_team_lead",
mode: "orchestrator",
description: "Coordinates development tasks",
},
{
id: "frontend_dev",
mode: "code",
description: "Implements frontend features",
},
{
id: "backend_dev",
mode: "code",
description: "Implements backend features",
},
{
id: "code_reviewer",
mode: "pr-reviewer",
description: "Reviews code quality and standards",
},
{
id: "tester",
mode: "test",
description: "Writes and runs tests",
},
],
workflow: [
{
name: "requirement_analysis",
agent: "project_manager",
description: "Analyze requirements and create project plan",
on_success: "development_planning",
strategy: "fixed",
},
{
name: "development_planning",
agent: "dev_team_lead",
description: "Plan development tasks and assign to team",
next_steps: ["frontend_development", "backend_development"],
strategy: "orchestrate",
},
{
name: "frontend_development",
agent: "frontend_dev",
description: "Implement frontend features",
on_success: "frontend_review",
retry_count: 2,
},
{
name: "backend_development",
agent: "backend_dev",
description: "Implement backend features",
on_success: "backend_review",
retry_count: 2,
parallel: true,
},
{
name: "frontend_review",
agent: "code_reviewer",
description: "Review frontend code",
on_success: "frontend_testing",
on_failure: "frontend_development",
},
{
name: "backend_review",
agent: "code_reviewer",
description: "Review backend code",
on_success: "backend_testing",
on_failure: "backend_development",
},
{
name: "frontend_testing",
agent: "tester",
description: "Test frontend functionality",
on_success: "integration_planning",
on_failure: "frontend_development",
},
{
name: "backend_testing",
agent: "tester",
description: "Test backend functionality",
on_success: "integration_planning",
on_failure: "backend_development",
},
{
name: "integration_planning",
agent: "dev_team_lead",
description: "Plan integration and deployment",
on_success: "end",
},
],
max_parallel_stages: 2,
default_timeout: 3600,
enable_checkpoints: true,
}
}
}

View file

@ -0,0 +1,331 @@
import * as path from "path"
import * as fs from "fs/promises"
import { EventEmitter } from "events"
import {
type WorkflowState,
type WorkflowStageState,
type WorkflowStageStatus,
type WorkflowEvent,
type WorkflowEventType,
WORKFLOW_STAGE_STATUS,
WORKFLOW_EVENTS,
} from "@roo-code/types"
import { fileExistsAtPath } from "../../utils/fs"
import { logger } from "../../utils/logging"
import { safeWriteJson } from "../../utils/safeWriteJson"
/**
* Manages workflow execution state and persistence
*/
export class WorkflowStateManager extends EventEmitter {
private states: Map<string, WorkflowState> = new Map()
private persistencePath: string
constructor(globalStoragePath: string) {
super()
this.persistencePath = path.join(globalStoragePath, "workflows")
this.initializePersistence()
}
/**
* Initialize persistence directory
*/
private async initializePersistence(): Promise<void> {
try {
await fs.mkdir(this.persistencePath, { recursive: true })
} catch (error) {
logger.error("Failed to create workflow persistence directory", {
error: error instanceof Error ? error.message : String(error),
})
}
}
/**
* Create a new workflow state
*/
public createWorkflowState(
id: string,
name: string,
parentTaskId?: string,
initialContext?: Record<string, any>,
): WorkflowState {
const state: WorkflowState = {
id,
name,
status: WORKFLOW_STAGE_STATUS.PENDING,
stages: {},
currentStages: [],
completedStages: [],
failedStages: [],
startedAt: Date.now(),
parentTaskId,
context: initialContext || {},
}
this.states.set(id, state)
this.emitEvent(WORKFLOW_EVENTS.WORKFLOW_STARTED, id, { name })
this.persistState(id).catch((error) => {
logger.error("Failed to persist workflow state", { id, error })
})
return state
}
/**
* Get workflow state by ID
*/
public getWorkflowState(id: string): WorkflowState | undefined {
return this.states.get(id)
}
/**
* Update workflow status
*/
public updateWorkflowStatus(id: string, status: WorkflowStageStatus): void {
const state = this.states.get(id)
if (!state) {
logger.error("Workflow state not found", { id })
return
}
state.status = status
if (status === WORKFLOW_STAGE_STATUS.COMPLETED || status === WORKFLOW_STAGE_STATUS.FAILED) {
state.completedAt = Date.now()
const eventType =
status === WORKFLOW_STAGE_STATUS.COMPLETED
? WORKFLOW_EVENTS.WORKFLOW_COMPLETED
: WORKFLOW_EVENTS.WORKFLOW_FAILED
this.emitEvent(eventType, id, {
duration: state.completedAt - state.startedAt,
})
}
this.persistState(id).catch((error) => {
logger.error("Failed to persist workflow state", { id, error })
})
}
/**
* Create or update a stage state
*/
public updateStageState(workflowId: string, stageName: string, updates: Partial<WorkflowStageState>): void {
const workflow = this.states.get(workflowId)
if (!workflow) {
logger.error("Workflow state not found", { workflowId })
return
}
const currentState = workflow.stages[stageName] || {
name: stageName,
status: WORKFLOW_STAGE_STATUS.PENDING,
agent: updates.agent || "",
retryCount: 0,
}
// Update stage state
workflow.stages[stageName] = {
...currentState,
...updates,
}
// Handle status transitions
const newStatus = updates.status
if (newStatus) {
this.handleStageStatusChange(workflow, stageName, newStatus)
}
this.persistState(workflowId).catch((error) => {
logger.error("Failed to persist workflow state", { workflowId, error })
})
}
/**
* Handle stage status changes
*/
private handleStageStatusChange(workflow: WorkflowState, stageName: string, newStatus: WorkflowStageStatus): void {
const stage = workflow.stages[stageName]
switch (newStatus) {
case WORKFLOW_STAGE_STATUS.IN_PROGRESS:
if (!workflow.currentStages.includes(stageName)) {
workflow.currentStages.push(stageName)
}
stage.startedAt = Date.now()
this.emitEvent(WORKFLOW_EVENTS.STAGE_STARTED, workflow.id, {
stageName,
agent: stage.agent,
})
break
case WORKFLOW_STAGE_STATUS.COMPLETED:
workflow.currentStages = workflow.currentStages.filter((s) => s !== stageName)
if (!workflow.completedStages.includes(stageName)) {
workflow.completedStages.push(stageName)
}
stage.completedAt = Date.now()
this.emitEvent(WORKFLOW_EVENTS.STAGE_COMPLETED, workflow.id, {
stageName,
agent: stage.agent,
duration: stage.completedAt - (stage.startedAt || 0),
})
break
case WORKFLOW_STAGE_STATUS.FAILED:
workflow.currentStages = workflow.currentStages.filter((s) => s !== stageName)
if (!workflow.failedStages.includes(stageName)) {
workflow.failedStages.push(stageName)
}
stage.completedAt = Date.now()
this.emitEvent(WORKFLOW_EVENTS.STAGE_FAILED, workflow.id, {
stageName,
agent: stage.agent,
error: stage.error,
})
break
case WORKFLOW_STAGE_STATUS.SKIPPED:
workflow.currentStages = workflow.currentStages.filter((s) => s !== stageName)
break
}
}
/**
* Update workflow context
*/
public updateContext(workflowId: string, updates: Record<string, any>): void {
const workflow = this.states.get(workflowId)
if (!workflow) {
logger.error("Workflow state not found", { workflowId })
return
}
workflow.context = {
...workflow.context,
...updates,
}
this.persistState(workflowId).catch((error) => {
logger.error("Failed to persist workflow state", { workflowId, error })
})
}
/**
* Get workflow context
*/
public getContext(workflowId: string): Record<string, any> | undefined {
return this.states.get(workflowId)?.context
}
/**
* Emit workflow event
*/
private emitEvent(type: WorkflowEventType, workflowId: string, data: Record<string, any>): void {
const event: WorkflowEvent = {
type,
workflowId,
timestamp: Date.now(),
data,
}
this.emit("workflow:event", event)
logger.info("Workflow event", { type, workflowId, data })
}
/**
* Persist workflow state to disk
*/
private async persistState(workflowId: string): Promise<void> {
const state = this.states.get(workflowId)
if (!state) {
return
}
const filePath = path.join(this.persistencePath, `${workflowId}.json`)
await safeWriteJson(filePath, state)
}
/**
* Load workflow state from disk
*/
public async loadState(workflowId: string): Promise<WorkflowState | undefined> {
const filePath = path.join(this.persistencePath, `${workflowId}.json`)
if (!(await fileExistsAtPath(filePath))) {
return undefined
}
try {
const content = await fs.readFile(filePath, "utf-8")
const state = JSON.parse(content) as WorkflowState
this.states.set(workflowId, state)
return state
} catch (error) {
logger.error("Failed to load workflow state", {
workflowId,
error: error instanceof Error ? error.message : String(error),
})
return undefined
}
}
/**
* List all workflow IDs
*/
public async listWorkflows(): Promise<string[]> {
try {
const files = await fs.readdir(this.persistencePath)
return files.filter((file) => file.endsWith(".json")).map((file) => file.replace(".json", ""))
} catch (error) {
logger.error("Failed to list workflows", {
error: error instanceof Error ? error.message : String(error),
})
return []
}
}
/**
* Delete workflow state
*/
public async deleteState(workflowId: string): Promise<void> {
this.states.delete(workflowId)
const filePath = path.join(this.persistencePath, `${workflowId}.json`)
try {
await fs.unlink(filePath)
} catch (error) {
// File might not exist
logger.debug("Failed to delete workflow state file", { workflowId, error })
}
}
/**
* Get active workflows
*/
public getActiveWorkflows(): WorkflowState[] {
return Array.from(this.states.values()).filter((state) => state.status === WORKFLOW_STAGE_STATUS.IN_PROGRESS)
}
/**
* Clean up completed workflows older than specified days
*/
public async cleanupOldWorkflows(daysToKeep: number = 30): Promise<number> {
const cutoffTime = Date.now() - daysToKeep * 24 * 60 * 60 * 1000
let deletedCount = 0
const workflowIds = await this.listWorkflows()
for (const workflowId of workflowIds) {
const state = await this.loadState(workflowId)
if (state && state.completedAt && state.completedAt < cutoffTime) {
await this.deleteState(workflowId)
deletedCount++
}
}
logger.info(`Cleaned up ${deletedCount} old workflows`)
return deletedCount
}
}

View file

@ -0,0 +1,319 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import * as vscode from "vscode"
import { WorkflowEngine } from "../WorkflowEngine"
import { WorkflowExecutor } from "../WorkflowExecutor"
import { WorkflowParser } from "../WorkflowParser"
import { WorkflowConfig, WORKFLOW_STAGE_STATUS } from "@roo-code/types"
// Mock dependencies
vi.mock("vscode", () => ({
commands: {
registerCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
window: {
showErrorMessage: vi.fn(),
showInformationMessage: vi.fn(),
showWarningMessage: vi.fn(),
showOpenDialog: vi.fn(),
showInputBox: vi.fn(),
showQuickPick: vi.fn(),
withProgress: vi.fn(),
},
workspace: {
openTextDocument: vi.fn(),
},
ProgressLocation: {
Notification: 15,
},
}))
vi.mock("../WorkflowExecutor")
vi.mock("../WorkflowParser")
vi.mock("../WorkflowStateManager")
vi.mock("../../../utils/logging", () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}))
vi.mock("../../../utils/path", () => ({
getWorkspacePath: vi.fn().mockReturnValue("/test/workspace"),
}))
describe("WorkflowEngine", () => {
let engine: WorkflowEngine
let mockProvider: any
let mockExecutor: WorkflowExecutor
beforeEach(() => {
vi.clearAllMocks()
// Create mock provider
mockProvider = {
context: {
globalStorageUri: { fsPath: "/test/storage" },
},
handleModeSwitch: vi.fn(),
initClineWithTask: vi.fn(),
getState: vi.fn().mockResolvedValue({ customModes: [] }),
}
// Create mock executor
mockExecutor = {
executeWorkflow: vi.fn(),
stopWorkflow: vi.fn(),
getRunningWorkflows: vi.fn().mockReturnValue([]),
getWorkflowState: vi.fn(),
on: vi.fn(),
emit: vi.fn(),
} as any
vi.mocked(WorkflowExecutor).mockImplementation(() => mockExecutor)
// Mock vscode withProgress
vi.mocked(vscode.window.withProgress).mockImplementation(async (options, task) => {
return task({ report: vi.fn() }, { isCancellationRequested: false, onCancellationRequested: vi.fn() })
})
engine = new WorkflowEngine(mockProvider)
})
describe("constructor", () => {
it("should register commands", () => {
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
"roo-cline.workflow.executeFromFile",
expect.any(Function),
)
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
"roo-cline.workflow.createSample",
expect.any(Function),
)
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
"roo-cline.workflow.list",
expect.any(Function),
)
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
"roo-cline.workflow.stop",
expect.any(Function),
)
})
})
describe("executeWorkflow", () => {
it("should execute workflow from config", async () => {
const mockConfig: WorkflowConfig = {
name: "Test Workflow",
agents: [{ id: "agent1", mode: "code" }],
workflow: [{ name: "stage1", agent: "agent1", strategy: "fixed" }],
}
vi.mocked(mockExecutor.executeWorkflow).mockResolvedValue({
workflowId: "test-id",
status: WORKFLOW_STAGE_STATUS.COMPLETED,
stages: [],
context: {},
duration: 1000,
})
const result = await engine.executeWorkflow(mockConfig)
expect(result.workflowId).toBe("test-id")
expect(result.status).toBe(WORKFLOW_STAGE_STATUS.COMPLETED)
expect(mockExecutor.executeWorkflow).toHaveBeenCalledWith(mockConfig, {})
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
expect.stringContaining('✅ Workflow "Test Workflow" completed'),
)
})
it("should handle workflow execution errors", async () => {
const mockConfig: WorkflowConfig = {
name: "Test Workflow",
agents: [{ id: "agent1", mode: "code" }],
workflow: [{ name: "stage1", agent: "agent1", strategy: "fixed" }],
}
vi.mocked(mockExecutor.executeWorkflow).mockRejectedValue(new Error("Execution failed"))
await expect(engine.executeWorkflow(mockConfig)).rejects.toThrow("Execution failed")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Workflow execution failed: Execution failed")
})
it("should handle cancellation", async () => {
const mockConfig: WorkflowConfig = {
name: "Test Workflow",
agents: [{ id: "agent1", mode: "code" }],
workflow: [{ name: "stage1", agent: "agent1", strategy: "fixed" }],
}
let cancelCallback: (() => void) | undefined
vi.mocked(vscode.window.withProgress).mockImplementation(async (options, task) => {
const token = {
isCancellationRequested: false,
onCancellationRequested: (cb: () => void) => {
cancelCallback = cb
return { dispose: vi.fn() }
},
} as any
return task({ report: vi.fn() }, token)
})
vi.mocked(mockExecutor.executeWorkflow).mockResolvedValue({
workflowId: "test-id",
status: WORKFLOW_STAGE_STATUS.COMPLETED,
stages: [],
context: {},
duration: 1000,
})
await engine.executeWorkflow(mockConfig, { workflowId: "test-id" })
// Simulate cancellation
if (cancelCallback) {
cancelCallback()
expect(mockExecutor.stopWorkflow).toHaveBeenCalledWith("test-id")
}
})
})
describe("executeWorkflowFromFile", () => {
it("should show error for invalid file", async () => {
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue([{ fsPath: "/test/workflow.yaml" } as any])
vi.mocked(WorkflowParser.loadFromFile).mockRejectedValue(new Error("Invalid YAML"))
const result = await engine.executeWorkflowFromFile()
expect(result).toBeUndefined()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to execute workflow: Invalid YAML")
})
it("should handle user cancellation", async () => {
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue(undefined)
const result = await engine.executeWorkflowFromFile()
expect(result).toBeUndefined()
expect(mockExecutor.executeWorkflow).not.toHaveBeenCalled()
})
it("should execute workflow from selected file", async () => {
const mockConfig: WorkflowConfig = {
name: "Test Workflow",
description: "Test description",
agents: [{ id: "agent1", mode: "code" }],
workflow: [{ name: "stage1", agent: "agent1", strategy: "fixed" }],
}
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue([{ fsPath: "/test/workflow.yaml" } as any])
vi.mocked(WorkflowParser.loadFromFile).mockResolvedValue(mockConfig)
vi.mocked(vscode.window.showInformationMessage).mockResolvedValue("Execute" as any)
vi.mocked(mockExecutor.executeWorkflow).mockResolvedValue({
workflowId: "test-id",
status: WORKFLOW_STAGE_STATUS.COMPLETED,
stages: [],
context: {},
duration: 1000,
})
const result = await engine.executeWorkflowFromFile()
expect(result).toBeDefined()
expect(result?.workflowId).toBe("test-id")
expect(WorkflowParser.loadFromFile).toHaveBeenCalledWith("/test/workflow.yaml")
})
})
describe("cleanupOldWorkflows", () => {
it("should cleanup old workflows", async () => {
const mockStateManager = {
cleanupOldWorkflows: vi.fn().mockResolvedValue(5),
}
engine["stateManager"] = mockStateManager as any
await engine.cleanupOldWorkflows(30)
expect(mockStateManager.cleanupOldWorkflows).toHaveBeenCalledWith(30)
})
it("should handle cleanup errors", async () => {
const mockStateManager = {
cleanupOldWorkflows: vi.fn().mockRejectedValue(new Error("Cleanup failed")),
}
engine["stateManager"] = mockStateManager as any
// Should not throw
await expect(engine.cleanupOldWorkflows()).resolves.toBeUndefined()
})
})
describe("dispose", () => {
it("should dispose all resources", () => {
const mockDisposable = { dispose: vi.fn() }
engine["disposables"] = [mockDisposable as any]
vi.mocked(mockExecutor.getRunningWorkflows).mockReturnValue(["workflow1", "workflow2"])
vi.mocked(mockExecutor.stopWorkflow).mockResolvedValue(undefined)
engine.dispose()
expect(mockDisposable.dispose).toHaveBeenCalled()
expect(mockExecutor.stopWorkflow).toHaveBeenCalledWith("workflow1")
expect(mockExecutor.stopWorkflow).toHaveBeenCalledWith("workflow2")
})
})
describe("event handling", () => {
it("should handle workflow events", () => {
// Get the event handler registered in constructor
const onMock = vi.mocked(mockExecutor.on)
const eventHandler = onMock.mock.calls.find((call: any) => call[0] === "workflow:event")?.[1]
expect(eventHandler).toBeDefined()
if (!eventHandler) {
throw new Error("Event handler not found")
}
// Test workflow started event
eventHandler({
type: "workflow:started",
workflowId: "test",
timestamp: Date.now(),
data: { name: "Test Workflow" },
})
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("Workflow started: Test Workflow")
// Test workflow completed event
eventHandler({
type: "workflow:completed",
workflowId: "test",
timestamp: Date.now(),
data: { duration: 5000 },
})
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("✅ Workflow completed in 5s")
// Test workflow failed event
eventHandler({
type: "workflow:failed",
workflowId: "test",
timestamp: Date.now(),
data: {},
})
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("❌ Workflow failed")
// Test stage failed event
eventHandler({
type: "stage:failed",
workflowId: "test",
timestamp: Date.now(),
data: { stageName: "stage1", error: "Test error" },
})
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith("Stage failed: stage1 - Test error")
})
})
})

View file

@ -0,0 +1,254 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { EventEmitter } from "events"
import { WorkflowExecutor } from "../WorkflowExecutor"
import { WorkflowConfig, WorkflowState, WORKFLOW_STAGE_STATUS, WORKFLOW_EVENTS } from "@roo-code/types"
// Mock dependencies
vi.mock("../WorkflowStateManager", () => ({
WorkflowStateManager: vi.fn().mockImplementation(() => ({
createWorkflowState: vi.fn().mockReturnValue({
id: "test-workflow",
name: "Test Workflow",
status: WORKFLOW_STAGE_STATUS.PENDING,
stages: {},
currentStages: [],
completedStages: [],
failedStages: [],
startedAt: Date.now(),
context: {},
}),
loadState: vi.fn().mockResolvedValue(undefined),
updateWorkflowStatus: vi.fn(),
updateStageState: vi.fn(),
updateContext: vi.fn(),
getWorkflowState: vi.fn().mockReturnValue(undefined),
on: vi.fn(),
emit: vi.fn(),
})),
}))
vi.mock("../../utils/logging", () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}))
vi.mock("../../../shared/modes", () => ({
getModeBySlug: vi.fn().mockReturnValue({ slug: "code", name: "Code" }),
}))
describe("WorkflowExecutor", () => {
let executor: WorkflowExecutor
let mockProvider: any
let mockConfig: WorkflowConfig
beforeEach(() => {
vi.clearAllMocks()
// Create a minimal mock provider
mockProvider = {
handleModeSwitch: vi.fn().mockResolvedValue(undefined),
initClineWithTask: vi.fn(),
getState: vi.fn().mockResolvedValue({ customModes: [] }),
}
// Create mock config
mockConfig = {
name: "Test Workflow",
description: "Test workflow description",
agents: [
{ id: "agent1", mode: "code", description: "Agent 1 description" },
{ id: "agent2", mode: "architect", description: "Agent 2 description" },
],
workflow: [
{
name: "stage1",
agent: "agent1",
strategy: "fixed",
on_success: "stage2",
},
{
name: "stage2",
agent: "agent2",
strategy: "fixed",
},
],
}
executor = new WorkflowExecutor(mockProvider, "/test/storage")
})
describe("constructor", () => {
it("should create executor instance", () => {
expect(executor).toBeDefined()
expect(executor).toBeInstanceOf(EventEmitter)
})
})
describe("getRunningWorkflows", () => {
it("should return empty array initially", () => {
const running = executor.getRunningWorkflows()
expect(running).toEqual([])
})
})
describe("getWorkflowState", () => {
it("should return undefined for non-existent workflow", () => {
const state = executor.getWorkflowState("non-existent")
expect(state).toBeUndefined()
})
})
describe("stopWorkflow", () => {
it("should handle stopping non-existent workflow", async () => {
// Should not throw
await expect(executor.stopWorkflow("non-existent")).resolves.toBeUndefined()
})
})
describe("executeWorkflow", () => {
it("should validate agent configuration", async () => {
// Create a minimal mock executor that can actually run
const testExecutor = new WorkflowExecutor(mockProvider, "/test/storage")
const invalidConfig: WorkflowConfig = {
...mockConfig,
workflow: [
{
name: "stage1",
agent: "non-existent-agent",
strategy: "fixed",
},
],
}
await expect(testExecutor.executeWorkflow(invalidConfig)).rejects.toThrow(
"Agent 'non-existent-agent' not found",
)
})
it("should validate mode exists", async () => {
const { getModeBySlug } = await import("../../../shared/modes")
vi.mocked(getModeBySlug).mockReturnValueOnce(undefined)
// Create a minimal mock executor that can actually run
const testExecutor = new WorkflowExecutor(mockProvider, "/test/storage")
await expect(testExecutor.executeWorkflow(mockConfig)).rejects.toThrow(
"Mode 'code' not found for agent 'agent1'",
)
})
})
describe("workflow configuration", () => {
it("should support parallel stages", () => {
const parallelConfig: WorkflowConfig = {
...mockConfig,
workflow: [
{
name: "stage1",
agent: "agent1",
strategy: "fixed",
parallel: true,
},
{
name: "stage2",
agent: "agent2",
strategy: "fixed",
parallel: true,
},
],
}
expect(parallelConfig.workflow[0].parallel).toBe(true)
expect(parallelConfig.workflow[1].parallel).toBe(true)
})
it("should support orchestrate strategy", () => {
const orchestrateConfig: WorkflowConfig = {
...mockConfig,
workflow: [
{
name: "orchestrate-stage",
agent: "agent1",
strategy: "orchestrate",
next_steps: ["stage2", "stage3"],
},
],
}
expect(orchestrateConfig.workflow[0].strategy).toBe("orchestrate")
expect(orchestrateConfig.workflow[0].next_steps).toEqual(["stage2", "stage3"])
})
it("should support retry configuration", () => {
const retryConfig: WorkflowConfig = {
...mockConfig,
workflow: [
{
name: "retry-stage",
agent: "agent1",
strategy: "fixed",
retry_count: 3,
},
],
}
expect(retryConfig.workflow[0].retry_count).toBe(3)
})
it("should support timeout configuration", () => {
const timeoutConfig: WorkflowConfig = {
...mockConfig,
workflow: [
{
name: "timeout-stage",
agent: "agent1",
strategy: "fixed",
timeout: 30,
},
],
}
expect(timeoutConfig.workflow[0].timeout).toBe(30)
})
})
describe("workflow state structure", () => {
it("should have correct state structure", () => {
const state: WorkflowState = {
id: "test-workflow",
name: "Test Workflow",
status: WORKFLOW_STAGE_STATUS.PENDING,
stages: {},
currentStages: [],
completedStages: [],
failedStages: [],
startedAt: Date.now(),
context: {},
}
expect(state.id).toBe("test-workflow")
expect(state.status).toBe(WORKFLOW_STAGE_STATUS.PENDING)
expect(state.stages).toEqual({})
expect(state.currentStages).toEqual([])
expect(state.completedStages).toEqual([])
expect(state.failedStages).toEqual([])
})
})
describe("workflow events", () => {
it("should define all workflow event types", () => {
expect(WORKFLOW_EVENTS.WORKFLOW_STARTED).toBe("workflow:started")
expect(WORKFLOW_EVENTS.WORKFLOW_COMPLETED).toBe("workflow:completed")
expect(WORKFLOW_EVENTS.WORKFLOW_FAILED).toBe("workflow:failed")
expect(WORKFLOW_EVENTS.STAGE_STARTED).toBe("stage:started")
expect(WORKFLOW_EVENTS.STAGE_COMPLETED).toBe("stage:completed")
expect(WORKFLOW_EVENTS.STAGE_FAILED).toBe("stage:failed")
expect(WORKFLOW_EVENTS.STAGE_RETRYING).toBe("stage:retrying")
expect(WORKFLOW_EVENTS.ORCHESTRATOR_DECISION).toBe("orchestrator:decision")
})
})
})

View file

@ -0,0 +1,250 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import * as fs from "fs/promises"
import * as path from "path"
import { WorkflowParser } from "../WorkflowParser"
import { WorkflowConfigValidationError } from "@roo-code/types"
vi.mock("fs/promises")
vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockResolvedValue(true),
}))
describe("WorkflowParser", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("parseYaml", () => {
it("should parse valid workflow YAML", () => {
const yamlContent = `
name: Test Workflow
description: A test workflow
agents:
- id: agent1
mode: code
workflow:
- name: stage1
agent: agent1
on_success: end
`
const result = WorkflowParser.parseYaml(yamlContent)
expect(result.name).toBe("Test Workflow")
expect(result.agents).toHaveLength(1)
expect(result.workflow).toHaveLength(1)
})
it("should throw error for invalid YAML", () => {
const invalidYaml = `
name: Test
agents: [
invalid yaml
`
expect(() => WorkflowParser.parseYaml(invalidYaml)).toThrow(WorkflowConfigValidationError)
})
it("should validate agent references", () => {
const yamlContent = `
name: Test Workflow
agents:
- id: agent1
mode: code
workflow:
- name: stage1
agent: nonexistent_agent
on_success: end
`
expect(() => WorkflowParser.parseYaml(yamlContent)).toThrow(
"Stage 'stage1' references unknown agent 'nonexistent_agent'",
)
})
it("should validate stage references", () => {
const yamlContent = `
name: Test Workflow
agents:
- id: agent1
mode: code
workflow:
- name: stage1
agent: agent1
on_success: nonexistent_stage
`
expect(() => WorkflowParser.parseYaml(yamlContent)).toThrow(
"Stage 'stage1' has invalid on_success transition to 'nonexistent_stage'",
)
})
it("should validate orchestrate strategy", () => {
const yamlContent = `
name: Test Workflow
agents:
- id: agent1
mode: orchestrator
workflow:
- name: stage1
agent: agent1
strategy: orchestrate
`
expect(() => WorkflowParser.parseYaml(yamlContent)).toThrow(
"Stage 'stage1' with orchestrate strategy must have next_steps defined",
)
})
it("should validate fixed strategy", () => {
const yamlContent = `
name: Test Workflow
agents:
- id: agent1
mode: code
workflow:
- name: stage1
agent: agent1
next_steps: [stage2]
`
expect(() => WorkflowParser.parseYaml(yamlContent)).toThrow(
"Stage 'stage1' has invalid next_step reference to 'stage2'",
)
})
it("should allow 'end' as a valid transition target", () => {
const yamlContent = `
name: Test Workflow
agents:
- id: agent1
mode: code
workflow:
- name: stage1
agent: agent1
on_success: end
`
const result = WorkflowParser.parseYaml(yamlContent)
expect(result.workflow[0].on_success).toBe("end")
})
})
describe("loadFromFile", () => {
it("should load and parse workflow from file", async () => {
const mockContent = `
name: Test Workflow
agents:
- id: agent1
mode: code
workflow:
- name: stage1
agent: agent1
on_success: end
`
vi.mocked(fs.readFile).mockResolvedValue(mockContent)
const result = await WorkflowParser.loadFromFile("/test/workflow.yaml")
expect(result.name).toBe("Test Workflow")
expect(fs.readFile).toHaveBeenCalledWith("/test/workflow.yaml", "utf-8")
})
it("should throw error if file does not exist", async () => {
const { fileExistsAtPath } = await import("../../../utils/fs")
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
await expect(WorkflowParser.loadFromFile("/test/missing.yaml")).rejects.toThrow(
"Workflow file not found: /test/missing.yaml",
)
})
})
describe("toYaml", () => {
it("should convert workflow config to YAML", () => {
const config = {
name: "Test Workflow",
agents: [{ id: "agent1", mode: "code" }],
workflow: [
{
name: "stage1",
agent: "agent1",
on_success: "end",
},
],
}
const yaml = WorkflowParser.toYaml(config)
expect(yaml).toContain("name: Test Workflow")
expect(yaml).toContain("id: agent1")
expect(yaml).toContain("mode: code")
})
})
describe("createSampleWorkflow", () => {
it("should create a valid sample workflow", () => {
const sample = WorkflowParser.createSampleWorkflow()
expect(sample.name).toBe("Web App Development Workflow")
expect(sample.agents.length).toBeGreaterThan(0)
expect(sample.workflow.length).toBeGreaterThan(0)
// Validate the sample workflow
expect(() => WorkflowParser.parseYaml(WorkflowParser.toYaml(sample))).not.toThrow()
})
})
describe("complex workflow validation", () => {
it("should validate a complex hierarchical workflow", () => {
const complexWorkflow = `
name: Complex Workflow
agents:
- id: orchestrator1
mode: orchestrator
- id: orchestrator2
mode: orchestrator
- id: worker1
mode: code
- id: worker2
mode: code
- id: reviewer
mode: pr-reviewer
workflow:
- name: planning
agent: orchestrator1
strategy: orchestrate
next_steps: [frontend_work, backend_work]
- name: frontend_work
agent: orchestrator2
strategy: orchestrate
next_steps: [ui_development, state_management]
- name: backend_work
agent: worker2
on_success: backend_review
on_failure: planning
retry_count: 2
- name: ui_development
agent: worker1
on_success: frontend_review
parallel: true
- name: state_management
agent: worker1
on_success: frontend_review
parallel: true
- name: frontend_review
agent: reviewer
on_success: integration
on_failure: frontend_work
- name: backend_review
agent: reviewer
on_success: integration
on_failure: backend_work
- name: integration
agent: orchestrator1
on_success: end
`
const result = WorkflowParser.parseYaml(complexWorkflow)
expect(result.workflow).toHaveLength(8)
expect(result.workflow[0].strategy).toBe("orchestrate")
expect(result.workflow[0].next_steps).toEqual(["frontend_work", "backend_work"])
})
})
})

View file

@ -0,0 +1,358 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
import * as fs from "fs/promises"
import * as path from "path"
import { EventEmitter } from "events"
import { WorkflowStateManager } from "../WorkflowStateManager"
import { WORKFLOW_STAGE_STATUS, WORKFLOW_EVENTS } from "@roo-code/types"
vi.mock("fs/promises", () => ({
mkdir: vi.fn(),
readdir: vi.fn(),
readFile: vi.fn(),
unlink: vi.fn(),
}))
vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn(),
}))
vi.mock("../../../utils/safeWriteJson", () => ({
safeWriteJson: vi.fn(),
}))
describe("WorkflowStateManager", () => {
let stateManager: WorkflowStateManager
const mockGlobalStoragePath = "/test/storage"
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
stateManager = new WorkflowStateManager(mockGlobalStoragePath)
})
afterEach(() => {
stateManager.removeAllListeners()
})
describe("createWorkflowState", () => {
it("should create a new workflow state", () => {
const state = stateManager.createWorkflowState("test-id", "Test Workflow", "parent-123", {
foo: "bar",
})
expect(state.id).toBe("test-id")
expect(state.name).toBe("Test Workflow")
expect(state.status).toBe(WORKFLOW_STAGE_STATUS.PENDING)
expect(state.parentTaskId).toBe("parent-123")
expect(state.context).toEqual({ foo: "bar" })
expect(state.stages).toEqual({})
expect(state.currentStages).toEqual([])
expect(state.completedStages).toEqual([])
expect(state.failedStages).toEqual([])
})
it("should emit workflow started event", async () => {
const eventPromise = new Promise<void>((resolve) => {
stateManager.on("workflow:event", (event) => {
expect(event.type).toBe(WORKFLOW_EVENTS.WORKFLOW_STARTED)
expect(event.workflowId).toBe("test-id")
expect(event.data.name).toBe("Test Workflow")
resolve()
})
})
stateManager.createWorkflowState("test-id", "Test Workflow")
await eventPromise
})
})
describe("updateWorkflowStatus", () => {
beforeEach(() => {
stateManager.createWorkflowState("test-id", "Test Workflow")
})
it("should update workflow status to completed", () => {
stateManager.updateWorkflowStatus("test-id", WORKFLOW_STAGE_STATUS.COMPLETED)
const state = stateManager.getWorkflowState("test-id")
expect(state?.status).toBe(WORKFLOW_STAGE_STATUS.COMPLETED)
expect(state?.completedAt).toBeDefined()
})
it("should emit workflow completed event", async () => {
const eventPromise = new Promise<void>((resolve) => {
stateManager.on("workflow:event", (event) => {
if (event.type === WORKFLOW_EVENTS.WORKFLOW_COMPLETED) {
expect(event.workflowId).toBe("test-id")
expect(event.data.duration).toBeDefined()
resolve()
}
})
})
stateManager.updateWorkflowStatus("test-id", WORKFLOW_STAGE_STATUS.COMPLETED)
await eventPromise
})
it("should emit workflow failed event", async () => {
const eventPromise = new Promise<void>((resolve) => {
stateManager.on("workflow:event", (event) => {
if (event.type === WORKFLOW_EVENTS.WORKFLOW_FAILED) {
expect(event.workflowId).toBe("test-id")
resolve()
}
})
})
stateManager.updateWorkflowStatus("test-id", WORKFLOW_STAGE_STATUS.FAILED)
await eventPromise
})
})
describe("updateStageState", () => {
beforeEach(() => {
stateManager.createWorkflowState("test-id", "Test Workflow")
})
it("should create and update stage state", () => {
stateManager.updateStageState("test-id", "stage1", {
status: WORKFLOW_STAGE_STATUS.IN_PROGRESS,
agent: "agent1",
})
const state = stateManager.getWorkflowState("test-id")
const stage = state?.stages["stage1"]
expect(stage?.name).toBe("stage1")
expect(stage?.status).toBe(WORKFLOW_STAGE_STATUS.IN_PROGRESS)
expect(stage?.agent).toBe("agent1")
expect(stage?.startedAt).toBeDefined()
})
it("should handle stage completion", () => {
stateManager.updateStageState("test-id", "stage1", {
status: WORKFLOW_STAGE_STATUS.IN_PROGRESS,
agent: "agent1",
})
stateManager.updateStageState("test-id", "stage1", {
status: WORKFLOW_STAGE_STATUS.COMPLETED,
result: "Success",
})
const state = stateManager.getWorkflowState("test-id")
expect(state?.currentStages).not.toContain("stage1")
expect(state?.completedStages).toContain("stage1")
expect(state?.stages["stage1"].completedAt).toBeDefined()
})
it("should handle stage failure", () => {
stateManager.updateStageState("test-id", "stage1", {
status: WORKFLOW_STAGE_STATUS.IN_PROGRESS,
agent: "agent1",
})
stateManager.updateStageState("test-id", "stage1", {
status: WORKFLOW_STAGE_STATUS.FAILED,
error: "Test error",
})
const state = stateManager.getWorkflowState("test-id")
expect(state?.currentStages).not.toContain("stage1")
expect(state?.failedStages).toContain("stage1")
expect(state?.stages["stage1"].error).toBe("Test error")
})
it("should emit stage events", async () => {
let eventCount = 0
const expectedEvents = [WORKFLOW_EVENTS.STAGE_STARTED, WORKFLOW_EVENTS.STAGE_COMPLETED]
const eventPromise = new Promise<void>((resolve) => {
stateManager.on("workflow:event", (event) => {
if (expectedEvents.includes(event.type)) {
eventCount++
expect(event.data.stageName).toBe("stage1")
expect(event.data.agent).toBe("agent1")
if (eventCount === 2) {
resolve()
}
}
})
})
stateManager.updateStageState("test-id", "stage1", {
status: WORKFLOW_STAGE_STATUS.IN_PROGRESS,
agent: "agent1",
})
stateManager.updateStageState("test-id", "stage1", {
status: WORKFLOW_STAGE_STATUS.COMPLETED,
})
await eventPromise
})
})
describe("updateContext", () => {
beforeEach(() => {
stateManager.createWorkflowState("test-id", "Test Workflow", undefined, { initial: "value" })
})
it("should update workflow context", () => {
stateManager.updateContext("test-id", { foo: "bar", baz: 123 })
const context = stateManager.getContext("test-id")
expect(context).toEqual({
initial: "value",
foo: "bar",
baz: 123,
})
})
it("should merge context updates", () => {
stateManager.updateContext("test-id", { foo: "bar" })
stateManager.updateContext("test-id", { baz: 123 })
const context = stateManager.getContext("test-id")
expect(context).toEqual({
initial: "value",
foo: "bar",
baz: 123,
})
})
})
describe("persistence", () => {
it("should persist state to disk", async () => {
const { safeWriteJson } = await import("../../../utils/safeWriteJson")
const mockSafeWriteJson = vi.mocked(safeWriteJson)
const state = stateManager.createWorkflowState("test-id", "Test Workflow")
// Wait for async persistence
await new Promise((resolve) => setTimeout(resolve, 10))
expect(mockSafeWriteJson).toHaveBeenCalledWith(
path.join(mockGlobalStoragePath, "workflows", "test-id.json"),
expect.objectContaining({
id: "test-id",
name: "Test Workflow",
}),
)
})
it("should load state from disk", async () => {
const { fileExistsAtPath } = await import("../../../utils/fs")
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
const mockState = {
id: "test-id",
name: "Test Workflow",
status: WORKFLOW_STAGE_STATUS.IN_PROGRESS,
stages: {},
currentStages: [],
completedStages: [],
failedStages: [],
startedAt: Date.now(),
context: {},
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockState))
const loadedState = await stateManager.loadState("test-id")
expect(loadedState).toEqual(mockState)
expect(stateManager.getWorkflowState("test-id")).toEqual(mockState)
})
it("should return undefined if state file does not exist", async () => {
const { fileExistsAtPath } = await import("../../../utils/fs")
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
const state = await stateManager.loadState("test-id")
expect(state).toBeUndefined()
})
})
describe("listWorkflows", () => {
it("should list all workflow IDs", async () => {
const { readdir } = await import("fs/promises")
vi.mocked(readdir).mockResolvedValue(["workflow1.json", "workflow2.json", "not-a-workflow.txt"] as any)
const workflows = await stateManager.listWorkflows()
expect(workflows).toEqual(["workflow1", "workflow2"])
})
it("should handle readdir errors", async () => {
const { readdir } = await import("fs/promises")
vi.mocked(readdir).mockRejectedValue(new Error("Permission denied"))
const workflows = await stateManager.listWorkflows()
expect(workflows).toEqual([])
})
})
describe("deleteState", () => {
it("should delete workflow state", async () => {
const { unlink } = await import("fs/promises")
stateManager.createWorkflowState("test-id", "Test Workflow")
await stateManager.deleteState("test-id")
expect(stateManager.getWorkflowState("test-id")).toBeUndefined()
expect(unlink).toHaveBeenCalledWith(path.join(mockGlobalStoragePath, "workflows", "test-id.json"))
})
})
describe("getActiveWorkflows", () => {
it("should return only active workflows", () => {
stateManager.createWorkflowState("workflow1", "Workflow 1")
stateManager.createWorkflowState("workflow2", "Workflow 2")
stateManager.createWorkflowState("workflow3", "Workflow 3")
stateManager.updateWorkflowStatus("workflow1", WORKFLOW_STAGE_STATUS.IN_PROGRESS)
stateManager.updateWorkflowStatus("workflow2", WORKFLOW_STAGE_STATUS.COMPLETED)
stateManager.updateWorkflowStatus("workflow3", WORKFLOW_STAGE_STATUS.IN_PROGRESS)
const activeWorkflows = stateManager.getActiveWorkflows()
expect(activeWorkflows).toHaveLength(2)
expect(activeWorkflows.map((w) => w.id)).toEqual(["workflow1", "workflow3"])
})
})
describe("cleanupOldWorkflows", () => {
it("should delete old completed workflows", async () => {
const { fileExistsAtPath } = await import("../../../utils/fs")
const { readdir, readFile, unlink } = await import("fs/promises")
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
const oldDate = Date.now() - 40 * 24 * 60 * 60 * 1000 // 40 days ago
const recentDate = Date.now() - 10 * 24 * 60 * 60 * 1000 // 10 days ago
vi.mocked(readdir).mockResolvedValue(["old-workflow.json", "recent-workflow.json"] as any)
vi.mocked(readFile)
.mockResolvedValueOnce(
JSON.stringify({
id: "old-workflow",
completedAt: oldDate,
status: WORKFLOW_STAGE_STATUS.COMPLETED,
}) as any,
)
.mockResolvedValueOnce(
JSON.stringify({
id: "recent-workflow",
completedAt: recentDate,
status: WORKFLOW_STAGE_STATUS.COMPLETED,
}) as any,
)
const deletedCount = await stateManager.cleanupOldWorkflows(30)
expect(deletedCount).toBe(1)
expect(unlink).toHaveBeenCalledWith(path.join(mockGlobalStoragePath, "workflows", "old-workflow.json"))
expect(unlink).not.toHaveBeenCalledWith(
path.join(mockGlobalStoragePath, "workflows", "recent-workflow.json"),
)
})
})
})

View file

@ -0,0 +1,4 @@
export { WorkflowEngine } from "./WorkflowEngine"
export { WorkflowExecutor } from "./WorkflowExecutor"
export { WorkflowParser } from "./WorkflowParser"
export { WorkflowStateManager } from "./WorkflowStateManager"

View file

@ -65,6 +65,7 @@ export const toolParamNames = [
"query",
"args",
"todos",
"workflow",
] as const
export type ToolParamName = (typeof toolParamNames)[number]
@ -164,6 +165,11 @@ export interface SearchAndReplaceToolUse extends ToolUse {
Partial<Pick<Record<ToolParamName, string>, "use_regex" | "ignore_case" | "start_line" | "end_line">>
}
export interface ExecuteWorkflowToolUse extends ToolUse {
name: "execute_workflow"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "workflow">>
}
// Define tool group configuration
export type ToolGroupConfig = {
tools: readonly string[]
@ -190,6 +196,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
search_and_replace: "search and replace",
codebase_search: "codebase search",
update_todo_list: "update todo list",
execute_workflow: "execute workflow",
} as const
// Define available tool groups.
@ -229,6 +236,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [
"switch_mode",
"new_task",
"update_todo_list",
"execute_workflow",
] as const
export type DiffResult =