diff --git a/README.md b/README.md index d4b5ecb073..2738e52a84 100644 --- a/README.md +++ b/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: diff --git a/examples/workflows/hierarchical-project.yaml b/examples/workflows/hierarchical-project.yaml new file mode 100644 index 0000000000..92fc5b97fa --- /dev/null +++ b/examples/workflows/hierarchical-project.yaml @@ -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 \ No newline at end of file diff --git a/examples/workflows/web-app-development.yaml b/examples/workflows/web-app-development.yaml new file mode 100644 index 0000000000..4c95ce8d2e --- /dev/null +++ b/examples/workflows/web-app-development.yaml @@ -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 \ No newline at end of file diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 44937da235..b5657d78f3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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" diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 7a3fd21199..3c32a6f784 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -34,6 +34,7 @@ export const toolNames = [ "fetch_instructions", "codebase_search", "update_todo_list", + "execute_workflow", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/packages/types/src/workflow.ts b/packages/types/src/workflow.ts new file mode 100644 index 0000000000..3f3903d2b9 --- /dev/null +++ b/packages/types/src/workflow.ts @@ -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 + +/** + * 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 + +/** + * 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 + +/** + * 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 + currentStages: string[] // Currently active stages + completedStages: string[] + failedStages: string[] + startedAt: number + completedAt?: number + parentTaskId?: string // For hierarchical workflows + context: Record // Shared context between stages +} + +/** + * Workflow execution options + */ +export interface WorkflowExecutionOptions { + workflowId?: string // Resume existing workflow + parentTaskId?: string // For hierarchical execution + initialContext?: Record + checkpointInterval?: number // Auto-checkpoint every N stages +} + +/** + * Workflow execution result + */ +export interface WorkflowExecutionResult { + workflowId: string + status: WorkflowStageStatus + stages: WorkflowStageState[] + context: Record + 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 + } +} diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index ee3fa148b4..73fbd6cac6 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -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 diff --git a/src/core/tools/executeWorkflowTool.ts b/src/core/tools/executeWorkflowTool.ts new file mode 100644 index 0000000000..668728eda2 --- /dev/null +++ b/src/core/tools/executeWorkflowTool.ts @@ -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 + } +} diff --git a/src/core/workflow/WorkflowEngine.ts b/src/core/workflow/WorkflowEngine.ts new file mode 100644 index 0000000000..c5e242c5c7 --- /dev/null +++ b/src/core/workflow/WorkflowEngine.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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)) { + 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 { + 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 }) + }) + } + } +} diff --git a/src/core/workflow/WorkflowExecutor.ts b/src/core/workflow/WorkflowExecutor.ts new file mode 100644 index 0000000000..5b96cfa441 --- /dev/null +++ b/src/core/workflow/WorkflowExecutor.ts @@ -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 = 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 { + 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() + + // 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 { + 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 { + 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 { + 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 { + 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 { + 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(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 { + 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 +} diff --git a/src/core/workflow/WorkflowParser.ts b/src/core/workflow/WorkflowParser.ts new file mode 100644 index 0000000000..48c61ccfa7 --- /dev/null +++ b/src/core/workflow/WorkflowParser.ts @@ -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 { + 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 { + 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, + } + } +} diff --git a/src/core/workflow/WorkflowStateManager.ts b/src/core/workflow/WorkflowStateManager.ts new file mode 100644 index 0000000000..dd8dba7c53 --- /dev/null +++ b/src/core/workflow/WorkflowStateManager.ts @@ -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 = new Map() + private persistencePath: string + + constructor(globalStoragePath: string) { + super() + this.persistencePath = path.join(globalStoragePath, "workflows") + this.initializePersistence() + } + + /** + * Initialize persistence directory + */ + private async initializePersistence(): Promise { + 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, + ): 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): 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): 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 | undefined { + return this.states.get(workflowId)?.context + } + + /** + * Emit workflow event + */ + private emitEvent(type: WorkflowEventType, workflowId: string, data: Record): 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/core/workflow/__tests__/WorkflowEngine.spec.ts b/src/core/workflow/__tests__/WorkflowEngine.spec.ts new file mode 100644 index 0000000000..8eb4133df3 --- /dev/null +++ b/src/core/workflow/__tests__/WorkflowEngine.spec.ts @@ -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") + }) + }) +}) diff --git a/src/core/workflow/__tests__/WorkflowExecutor.spec.ts b/src/core/workflow/__tests__/WorkflowExecutor.spec.ts new file mode 100644 index 0000000000..4fbc80a37f --- /dev/null +++ b/src/core/workflow/__tests__/WorkflowExecutor.spec.ts @@ -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") + }) + }) +}) diff --git a/src/core/workflow/__tests__/WorkflowParser.spec.ts b/src/core/workflow/__tests__/WorkflowParser.spec.ts new file mode 100644 index 0000000000..6b94788e0a --- /dev/null +++ b/src/core/workflow/__tests__/WorkflowParser.spec.ts @@ -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"]) + }) + }) +}) diff --git a/src/core/workflow/__tests__/WorkflowStateManager.spec.ts b/src/core/workflow/__tests__/WorkflowStateManager.spec.ts new file mode 100644 index 0000000000..0e56f360c2 --- /dev/null +++ b/src/core/workflow/__tests__/WorkflowStateManager.spec.ts @@ -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((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((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((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((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"), + ) + }) + }) +}) diff --git a/src/core/workflow/index.ts b/src/core/workflow/index.ts new file mode 100644 index 0000000000..b119a3cc0a --- /dev/null +++ b/src/core/workflow/index.ts @@ -0,0 +1,4 @@ +export { WorkflowEngine } from "./WorkflowEngine" +export { WorkflowExecutor } from "./WorkflowExecutor" +export { WorkflowParser } from "./WorkflowParser" +export { WorkflowStateManager } from "./WorkflowStateManager" diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 67972243fe..9eb6c6e2d9 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -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, "use_regex" | "ignore_case" | "start_line" | "end_line">> } +export interface ExecuteWorkflowToolUse extends ToolUse { + name: "execute_workflow" + params: Partial, "path" | "workflow">> +} + // Define tool group configuration export type ToolGroupConfig = { tools: readonly string[] @@ -190,6 +196,7 @@ export const TOOL_DISPLAY_NAMES: Record = { 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 =