mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
342ee70fb4
commit
f7f724c84e
18 changed files with 3507 additions and 0 deletions
31
README.md
31
README.md
|
|
@ -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:
|
||||
|
|
|
|||
273
examples/workflows/hierarchical-project.yaml
Normal file
273
examples/workflows/hierarchical-project.yaml
Normal 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
|
||||
155
examples/workflows/web-app-development.yaml
Normal file
155
examples/workflows/web-app-development.yaml
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export const toolNames = [
|
|||
"fetch_instructions",
|
||||
"codebase_search",
|
||||
"update_todo_list",
|
||||
"execute_workflow",
|
||||
] as const
|
||||
|
||||
export const toolNamesSchema = z.enum(toolNames)
|
||||
|
|
|
|||
167
packages/types/src/workflow.ts
Normal file
167
packages/types/src/workflow.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
148
src/core/tools/executeWorkflowTool.ts
Normal file
148
src/core/tools/executeWorkflowTool.ts
Normal 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
|
||||
}
|
||||
}
|
||||
446
src/core/workflow/WorkflowEngine.ts
Normal file
446
src/core/workflow/WorkflowEngine.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
459
src/core/workflow/WorkflowExecutor.ts
Normal file
459
src/core/workflow/WorkflowExecutor.ts
Normal 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>
|
||||
}
|
||||
296
src/core/workflow/WorkflowParser.ts
Normal file
296
src/core/workflow/WorkflowParser.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
331
src/core/workflow/WorkflowStateManager.ts
Normal file
331
src/core/workflow/WorkflowStateManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
319
src/core/workflow/__tests__/WorkflowEngine.spec.ts
Normal file
319
src/core/workflow/__tests__/WorkflowEngine.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
254
src/core/workflow/__tests__/WorkflowExecutor.spec.ts
Normal file
254
src/core/workflow/__tests__/WorkflowExecutor.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
250
src/core/workflow/__tests__/WorkflowParser.spec.ts
Normal file
250
src/core/workflow/__tests__/WorkflowParser.spec.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
})
|
||||
358
src/core/workflow/__tests__/WorkflowStateManager.spec.ts
Normal file
358
src/core/workflow/__tests__/WorkflowStateManager.spec.ts
Normal 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"),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
4
src/core/workflow/index.ts
Normal file
4
src/core/workflow/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { WorkflowEngine } from "./WorkflowEngine"
|
||||
export { WorkflowExecutor } from "./WorkflowExecutor"
|
||||
export { WorkflowParser } from "./WorkflowParser"
|
||||
export { WorkflowStateManager } from "./WorkflowStateManager"
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue