diff --git a/apps/cli/package.json b/apps/cli/package.json index ab41c484..7c027b29 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@tm/core": "*", + "@tm/workflow-engine": "*", "boxen": "^7.1.1", "chalk": "5.6.2", "cli-table3": "^0.6.5", diff --git a/apps/cli/src/commands/auth.command.ts b/apps/cli/src/commands/auth.command.ts index c79ff6bb..be10b038 100644 --- a/apps/cli/src/commands/auth.command.ts +++ b/apps/cli/src/commands/auth.command.ts @@ -494,17 +494,6 @@ export class AuthCommand extends Command { /** * Static method to register this command on an existing program - * This is for gradual migration - allows commands.js to use this - */ - static registerOn(program: Command): Command { - const authCommand = new AuthCommand(); - program.addCommand(authCommand); - return authCommand; - } - - /** - * Alternative registration that returns the command for chaining - * Can also configure the command name if needed */ static register(program: Command, name?: string): AuthCommand { const authCommand = new AuthCommand(name); diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts new file mode 100644 index 00000000..55ae30a6 --- /dev/null +++ b/apps/cli/src/commands/index.ts @@ -0,0 +1,38 @@ +/** + * Command registry - exports all CLI commands for central registration + */ + +import type { Command } from 'commander'; +import { ListTasksCommand } from './list.command.js'; +import { AuthCommand } from './auth.command.js'; +import WorkflowCommand from './workflow.command.js'; + +// Define interface for command classes that can register themselves +export interface CommandRegistrar { + register(program: Command, name?: string): any; +} + +// Future commands can be added here as they're created +// The pattern is: each command exports a class with a static register(program: Command, name?: string) method + +/** + * Auto-register all exported commands that implement the CommandRegistrar interface + */ +export function registerAllCommands(program: Command): void { + // Get all exports from this module + const commands = [ + ListTasksCommand, + AuthCommand, + WorkflowCommand + // Add new commands here as they're imported above + ]; + + commands.forEach((CommandClass) => { + if ( + 'register' in CommandClass && + typeof CommandClass.register === 'function' + ) { + CommandClass.register(program); + } + }); +} diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index f62eb2d5..1a0dda34 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -315,17 +315,6 @@ export class ListTasksCommand extends Command { /** * Static method to register this command on an existing program - * This is for gradual migration - allows commands.js to use this - */ - static registerOn(program: Command): Command { - const listCommand = new ListTasksCommand(); - program.addCommand(listCommand); - return listCommand; - } - - /** - * Alternative registration that returns the command for chaining - * Can also configure the command name if needed */ static register(program: Command, name?: string): ListTasksCommand { const listCommand = new ListTasksCommand(name); diff --git a/apps/cli/src/commands/workflow.command.ts b/apps/cli/src/commands/workflow.command.ts new file mode 100644 index 00000000..c4e74d34 --- /dev/null +++ b/apps/cli/src/commands/workflow.command.ts @@ -0,0 +1,58 @@ +/** + * @fileoverview Workflow Command + * Main workflow command with subcommands + */ + +import { Command } from 'commander'; +import { + WorkflowStartCommand, + WorkflowListCommand, + WorkflowStopCommand, + WorkflowStatusCommand +} from './workflow/index.js'; + +/** + * WorkflowCommand - Main workflow command with subcommands + */ +export class WorkflowCommand extends Command { + constructor(name?: string) { + super(name || 'workflow'); + + this.description('Manage task execution workflows with git worktrees and Claude Code') + .alias('wf'); + + // Register subcommands + this.addSubcommands(); + } + + private addSubcommands(): void { + // Start workflow + WorkflowStartCommand.register(this); + + // List workflows + WorkflowListCommand.register(this); + + // Stop workflow + WorkflowStopCommand.register(this); + + // Show workflow status + WorkflowStatusCommand.register(this); + + // Alias commands for convenience + this.addCommand(new WorkflowStartCommand('run')); // tm workflow run + this.addCommand(new WorkflowStopCommand('kill')); // tm workflow kill + this.addCommand(new WorkflowStatusCommand('info')); // tm workflow info + } + + /** + * Static method to register this command on an existing program + */ + static register(program: Command, name?: string): WorkflowCommand { + const workflowCommand = new WorkflowCommand(name); + program.addCommand(workflowCommand); + return workflowCommand; + } + +} + +export default WorkflowCommand; \ No newline at end of file diff --git a/apps/cli/src/commands/workflow/index.ts b/apps/cli/src/commands/workflow/index.ts new file mode 100644 index 00000000..8c39c247 --- /dev/null +++ b/apps/cli/src/commands/workflow/index.ts @@ -0,0 +1,9 @@ +/** + * @fileoverview Workflow Commands + * Exports for all workflow-related CLI commands + */ + +export * from './workflow-start.command.js'; +export * from './workflow-list.command.js'; +export * from './workflow-stop.command.js'; +export * from './workflow-status.command.js'; \ No newline at end of file diff --git a/apps/cli/src/commands/workflow/workflow-list.command.ts b/apps/cli/src/commands/workflow/workflow-list.command.ts new file mode 100644 index 00000000..704fb330 --- /dev/null +++ b/apps/cli/src/commands/workflow/workflow-list.command.ts @@ -0,0 +1,253 @@ +/** + * @fileoverview Workflow List Command + * List active and recent workflow executions + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import path from 'node:path'; +import { + TaskExecutionManager, + type TaskExecutionManagerConfig, + type WorkflowExecutionContext +} from '@tm/workflow-engine'; +import * as ui from '../../utils/ui.js'; + +export interface WorkflowListOptions { + project?: string; + status?: string; + format?: 'text' | 'json' | 'compact'; + worktreeBase?: string; + claude?: string; + all?: boolean; +} + +/** + * WorkflowListCommand - List workflow executions + */ +export class WorkflowListCommand extends Command { + private workflowManager?: TaskExecutionManager; + + constructor(name?: string) { + super(name || 'list'); + + this.description('List active and recent workflow executions') + .alias('ls') + .option('-p, --project ', 'Project root directory', process.cwd()) + .option('-s, --status ', 'Filter by status (running, completed, failed, etc.)') + .option('-f, --format ', 'Output format (text, json, compact)', 'text') + .option('--worktree-base ', 'Base directory for worktrees', '../task-worktrees') + .option('--claude ', 'Claude Code executable path', 'claude') + .option('--all', 'Show all workflows including completed ones') + .action(async (options: WorkflowListOptions) => { + await this.executeCommand(options); + }); + } + + private async executeCommand(options: WorkflowListOptions): Promise { + try { + // Initialize workflow manager + await this.initializeWorkflowManager(options); + + // Get workflows + let workflows = this.workflowManager!.listWorkflows(); + + // Apply status filter + if (options.status) { + workflows = workflows.filter(w => w.status === options.status); + } + + // Apply active filter (default behavior) + if (!options.all) { + workflows = workflows.filter(w => + ['pending', 'initializing', 'running', 'paused'].includes(w.status) + ); + } + + // Display results + this.displayResults(workflows, options); + + } catch (error: any) { + ui.displayError(error.message || 'Failed to list workflows'); + process.exit(1); + } + } + + private async initializeWorkflowManager(options: WorkflowListOptions): Promise { + if (!this.workflowManager) { + const projectRoot = options.project || process.cwd(); + const worktreeBase = path.resolve(projectRoot, options.worktreeBase || '../task-worktrees'); + + const config: TaskExecutionManagerConfig = { + projectRoot, + maxConcurrent: 5, + defaultTimeout: 60, + worktreeBase, + claudeExecutable: options.claude || 'claude', + debug: false + }; + + this.workflowManager = new TaskExecutionManager(config); + await this.workflowManager.initialize(); + } + } + + private displayResults(workflows: WorkflowExecutionContext[], options: WorkflowListOptions): void { + switch (options.format) { + case 'json': + this.displayJson(workflows); + break; + case 'compact': + this.displayCompact(workflows); + break; + case 'text': + default: + this.displayText(workflows); + break; + } + } + + private displayJson(workflows: WorkflowExecutionContext[]): void { + console.log(JSON.stringify({ + workflows: workflows.map(w => ({ + workflowId: `workflow-${w.taskId}`, + taskId: w.taskId, + taskTitle: w.taskTitle, + status: w.status, + worktreePath: w.worktreePath, + branchName: w.branchName, + processId: w.processId, + startedAt: w.startedAt, + lastActivity: w.lastActivity, + metadata: w.metadata + })), + total: workflows.length, + timestamp: new Date().toISOString() + }, null, 2)); + } + + private displayCompact(workflows: WorkflowExecutionContext[]): void { + if (workflows.length === 0) { + console.log(chalk.gray('No workflows found')); + return; + } + + workflows.forEach(workflow => { + const workflowId = `workflow-${workflow.taskId}`; + const statusDisplay = this.getStatusDisplay(workflow.status); + const duration = this.formatDuration(workflow.startedAt, workflow.lastActivity); + + console.log( + `${chalk.cyan(workflowId)} ${statusDisplay} ${workflow.taskTitle} ${chalk.gray(`(${duration})`)}` + ); + }); + } + + private displayText(workflows: WorkflowExecutionContext[]): void { + ui.displayBanner('Active Workflows'); + + if (workflows.length === 0) { + ui.displayWarning('No workflows found'); + console.log(); + console.log(chalk.blue('πŸ’‘ Start a new workflow with:')); + console.log(` ${chalk.cyan('tm workflow start ')}`); + return; + } + + // Statistics + console.log(chalk.blue.bold('\nπŸ“Š Statistics:\n')); + const statusCounts = this.getStatusCounts(workflows); + Object.entries(statusCounts).forEach(([status, count]) => { + console.log(` ${this.getStatusDisplay(status)}: ${chalk.cyan(count)}`); + }); + + // Workflows table + console.log(chalk.blue.bold(`\nπŸ”„ Workflows (${workflows.length}):\n`)); + + const tableData = workflows.map(workflow => { + const workflowId = `workflow-${workflow.taskId}`; + const duration = this.formatDuration(workflow.startedAt, workflow.lastActivity); + + return [ + chalk.cyan(workflowId), + chalk.yellow(workflow.taskId), + workflow.taskTitle.substring(0, 30) + (workflow.taskTitle.length > 30 ? '...' : ''), + this.getStatusDisplay(workflow.status), + workflow.processId ? chalk.green(workflow.processId.toString()) : chalk.gray('N/A'), + chalk.gray(duration), + chalk.gray(path.basename(workflow.worktreePath)) + ]; + }); + + console.log(ui.createTable( + ['Workflow ID', 'Task ID', 'Task Title', 'Status', 'PID', 'Duration', 'Worktree'], + tableData + )); + + // Running workflows actions + const runningWorkflows = workflows.filter(w => w.status === 'running'); + if (runningWorkflows.length > 0) { + console.log(chalk.blue.bold('\nπŸš€ Quick Actions:\n')); + runningWorkflows.slice(0, 3).forEach(workflow => { + const workflowId = `workflow-${workflow.taskId}`; + console.log(` β€’ Attach to ${chalk.cyan(workflowId)}: ${chalk.gray(`tm workflow attach ${workflowId}`)}`); + }); + + if (runningWorkflows.length > 3) { + console.log(` ${chalk.gray(`... and ${runningWorkflows.length - 3} more`)}`); + } + } + } + + private getStatusDisplay(status: string): string { + const statusMap = { + pending: { icon: '⏳', color: chalk.yellow }, + initializing: { icon: 'πŸ”„', color: chalk.blue }, + running: { icon: 'πŸš€', color: chalk.green }, + paused: { icon: '⏸️', color: chalk.orange }, + completed: { icon: 'βœ…', color: chalk.green }, + failed: { icon: '❌', color: chalk.red }, + cancelled: { icon: 'πŸ›‘', color: chalk.gray }, + timeout: { icon: '⏰', color: chalk.red } + }; + + const statusInfo = statusMap[status as keyof typeof statusMap] || { icon: '❓', color: chalk.white }; + return `${statusInfo.icon} ${statusInfo.color(status)}`; + } + + private getStatusCounts(workflows: WorkflowExecutionContext[]): Record { + const counts: Record = {}; + + workflows.forEach(workflow => { + counts[workflow.status] = (counts[workflow.status] || 0) + 1; + }); + + return counts; + } + + private formatDuration(start: Date, end: Date): string { + const diff = end.getTime() - start.getTime(); + const minutes = Math.floor(diff / (1000 * 60)); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } else if (minutes > 0) { + return `${minutes}m`; + } else { + return '<1m'; + } + } + + async cleanup(): Promise { + if (this.workflowManager) { + this.workflowManager.removeAllListeners(); + } + } + + static register(program: Command, name?: string): WorkflowListCommand { + const command = new WorkflowListCommand(name); + program.addCommand(command); + return command; + } +} \ No newline at end of file diff --git a/apps/cli/src/commands/workflow/workflow-start.command.ts b/apps/cli/src/commands/workflow/workflow-start.command.ts new file mode 100644 index 00000000..e770f68c --- /dev/null +++ b/apps/cli/src/commands/workflow/workflow-start.command.ts @@ -0,0 +1,239 @@ +/** + * @fileoverview Workflow Start Command + * Start task execution in isolated worktree with Claude Code process + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import path from 'node:path'; +import { + createTaskMasterCore, + type TaskMasterCore +} from '@tm/core'; +import { + TaskExecutionManager, + type TaskExecutionManagerConfig +} from '@tm/workflow-engine'; +import * as ui from '../../utils/ui.js'; + +export interface WorkflowStartOptions { + project?: string; + branch?: string; + timeout?: number; + worktreeBase?: string; + claude?: string; + debug?: boolean; + env?: string; +} + +/** + * WorkflowStartCommand - Start task execution workflow + */ +export class WorkflowStartCommand extends Command { + private tmCore?: TaskMasterCore; + private workflowManager?: TaskExecutionManager; + + constructor(name?: string) { + super(name || 'start'); + + this.description('Start task execution in isolated worktree') + .argument('', 'Task ID to execute') + .option('-p, --project ', 'Project root directory', process.cwd()) + .option('-b, --branch ', 'Custom branch name for worktree') + .option('-t, --timeout ', 'Execution timeout in minutes', '60') + .option('--worktree-base ', 'Base directory for worktrees', '../task-worktrees') + .option('--claude ', 'Claude Code executable path', 'claude') + .option('--debug', 'Enable debug logging') + .option('--env ', 'Environment variables (KEY=VALUE,KEY2=VALUE2)') + .action(async (taskId: string, options: WorkflowStartOptions) => { + await this.executeCommand(taskId, options); + }); + } + + private async executeCommand(taskId: string, options: WorkflowStartOptions): Promise { + try { + // Initialize components + await this.initializeCore(options.project || process.cwd()); + await this.initializeWorkflowManager(options); + + // Get task details + const task = await this.getTask(taskId); + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + + // Check if task already has active workflow + const existingWorkflow = this.workflowManager!.getWorkflowByTaskId(taskId); + if (existingWorkflow) { + ui.displayWarning(`Task ${taskId} already has an active workflow`); + console.log(`Workflow ID: ${chalk.cyan('workflow-' + taskId)}`); + console.log(`Status: ${this.getStatusDisplay(existingWorkflow.status)}`); + console.log(`Worktree: ${chalk.gray(existingWorkflow.worktreePath)}`); + return; + } + + // Parse environment variables + const env = this.parseEnvironmentVariables(options.env); + + // Display task info + ui.displayBanner(`Starting Workflow for Task ${taskId}`); + console.log(`${chalk.blue('Task:')} ${task.title}`); + console.log(`${chalk.blue('Description:')} ${task.description}`); + + if (task.dependencies?.length) { + console.log(`${chalk.blue('Dependencies:')} ${task.dependencies.join(', ')}`); + } + + console.log(`${chalk.blue('Priority:')} ${task.priority || 'normal'}`); + console.log(); + + // Start workflow + ui.displaySpinner('Creating worktree and starting Claude Code process...'); + + const workflowId = await this.workflowManager!.startTaskExecution(task, { + branchName: options.branch, + timeout: parseInt(options.timeout || '60'), + env + }); + + const workflow = this.workflowManager!.getWorkflowStatus(workflowId); + + ui.displaySuccess('Workflow started successfully!'); + console.log(); + console.log(`${chalk.green('βœ“')} Workflow ID: ${chalk.cyan(workflowId)}`); + console.log(`${chalk.green('βœ“')} Worktree: ${chalk.gray(workflow?.worktreePath)}`); + console.log(`${chalk.green('βœ“')} Branch: ${chalk.gray(workflow?.branchName)}`); + console.log(`${chalk.green('βœ“')} Process ID: ${chalk.gray(workflow?.processId)}`); + console.log(); + + // Display next steps + console.log(chalk.blue.bold('πŸ“‹ Next Steps:')); + console.log(` β€’ Monitor: ${chalk.cyan(`tm workflow status ${workflowId}`)}`); + console.log(` β€’ Attach: ${chalk.cyan(`tm workflow attach ${workflowId}`)}`); + console.log(` β€’ Stop: ${chalk.cyan(`tm workflow stop ${workflowId}`)}`); + console.log(); + + // Setup event listeners for real-time updates + this.setupEventListeners(); + + } catch (error: any) { + ui.displayError(error.message || 'Failed to start workflow'); + + if (options.debug && error.stack) { + console.error(chalk.gray(error.stack)); + } + + process.exit(1); + } + } + + private async initializeCore(projectRoot: string): Promise { + if (!this.tmCore) { + this.tmCore = await createTaskMasterCore({ projectPath: projectRoot }); + } + } + + private async initializeWorkflowManager(options: WorkflowStartOptions): Promise { + if (!this.workflowManager) { + const projectRoot = options.project || process.cwd(); + const worktreeBase = path.resolve(projectRoot, options.worktreeBase || '../task-worktrees'); + + const config: TaskExecutionManagerConfig = { + projectRoot, + maxConcurrent: 5, + defaultTimeout: parseInt(options.timeout || '60'), + worktreeBase, + claudeExecutable: options.claude || 'claude', + debug: options.debug || false + }; + + this.workflowManager = new TaskExecutionManager(config); + await this.workflowManager.initialize(); + } + } + + private async getTask(taskId: string) { + if (!this.tmCore) { + throw new Error('TaskMasterCore not initialized'); + } + + const result = await this.tmCore.getTaskList({}); + return result.tasks.find(task => task.id === taskId); + } + + private parseEnvironmentVariables(envString?: string): Record | undefined { + if (!envString) return undefined; + + const env: Record = {}; + + for (const pair of envString.split(',')) { + const [key, ...valueParts] = pair.trim().split('='); + if (key && valueParts.length > 0) { + env[key] = valueParts.join('='); + } + } + + return Object.keys(env).length > 0 ? env : undefined; + } + + private getStatusDisplay(status: string): string { + const colors = { + pending: chalk.yellow, + initializing: chalk.blue, + running: chalk.green, + paused: chalk.orange, + completed: chalk.green, + failed: chalk.red, + cancelled: chalk.gray, + timeout: chalk.red + }; + + const color = colors[status as keyof typeof colors] || chalk.white; + return color(status); + } + + private setupEventListeners(): void { + if (!this.workflowManager) return; + + this.workflowManager.on('workflow.started', (event) => { + console.log(`${chalk.green('πŸš€')} Workflow started: ${event.workflowId}`); + }); + + this.workflowManager.on('process.output', (event) => { + if (event.data?.stream === 'stdout') { + console.log(`${chalk.blue('[OUT]')} ${event.data.data.trim()}`); + } else if (event.data?.stream === 'stderr') { + console.log(`${chalk.red('[ERR]')} ${event.data.data.trim()}`); + } + }); + + this.workflowManager.on('workflow.completed', (event) => { + console.log(`${chalk.green('βœ…')} Workflow completed: ${event.workflowId}`); + }); + + this.workflowManager.on('workflow.failed', (event) => { + console.log(`${chalk.red('❌')} Workflow failed: ${event.workflowId}`); + if (event.error) { + console.log(`${chalk.red('Error:')} ${event.error.message}`); + } + }); + } + + async cleanup(): Promise { + if (this.workflowManager) { + // Don't cleanup workflows, just disconnect + this.workflowManager.removeAllListeners(); + } + + if (this.tmCore) { + await this.tmCore.close(); + this.tmCore = undefined; + } + } + + static register(program: Command, name?: string): WorkflowStartCommand { + const command = new WorkflowStartCommand(name); + program.addCommand(command); + return command; + } +} \ No newline at end of file diff --git a/apps/cli/src/commands/workflow/workflow-status.command.ts b/apps/cli/src/commands/workflow/workflow-status.command.ts new file mode 100644 index 00000000..48bf686f --- /dev/null +++ b/apps/cli/src/commands/workflow/workflow-status.command.ts @@ -0,0 +1,339 @@ +/** + * @fileoverview Workflow Status Command + * Show detailed status of a specific workflow + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import path from 'node:path'; +import { + TaskExecutionManager, + type TaskExecutionManagerConfig +} from '@tm/workflow-engine'; +import * as ui from '../../utils/ui.js'; + +export interface WorkflowStatusOptions { + project?: string; + worktreeBase?: string; + claude?: string; + watch?: boolean; + format?: 'text' | 'json'; +} + +/** + * WorkflowStatusCommand - Show workflow execution status + */ +export class WorkflowStatusCommand extends Command { + private workflowManager?: TaskExecutionManager; + + constructor(name?: string) { + super(name || 'status'); + + this.description('Show detailed status of a workflow execution') + .argument('', 'Workflow ID or task ID to check') + .option('-p, --project ', 'Project root directory', process.cwd()) + .option('--worktree-base ', 'Base directory for worktrees', '../task-worktrees') + .option('--claude ', 'Claude Code executable path', 'claude') + .option('-w, --watch', 'Watch for status changes (refresh every 2 seconds)') + .option('-f, --format ', 'Output format (text, json)', 'text') + .action(async (workflowId: string, options: WorkflowStatusOptions) => { + await this.executeCommand(workflowId, options); + }); + } + + private async executeCommand(workflowId: string, options: WorkflowStatusOptions): Promise { + try { + // Initialize workflow manager + await this.initializeWorkflowManager(options); + + if (options.watch) { + await this.watchWorkflowStatus(workflowId, options); + } else { + await this.showWorkflowStatus(workflowId, options); + } + + } catch (error: any) { + ui.displayError(error.message || 'Failed to get workflow status'); + process.exit(1); + } + } + + private async initializeWorkflowManager(options: WorkflowStatusOptions): Promise { + if (!this.workflowManager) { + const projectRoot = options.project || process.cwd(); + const worktreeBase = path.resolve(projectRoot, options.worktreeBase || '../task-worktrees'); + + const config: TaskExecutionManagerConfig = { + projectRoot, + maxConcurrent: 5, + defaultTimeout: 60, + worktreeBase, + claudeExecutable: options.claude || 'claude', + debug: false + }; + + this.workflowManager = new TaskExecutionManager(config); + await this.workflowManager.initialize(); + } + } + + private async showWorkflowStatus(workflowId: string, options: WorkflowStatusOptions): Promise { + // Try to find workflow by ID or task ID + let workflow = this.workflowManager!.getWorkflowStatus(workflowId); + + if (!workflow) { + // Try as task ID + workflow = this.workflowManager!.getWorkflowByTaskId(workflowId); + } + + if (!workflow) { + throw new Error(`Workflow not found: ${workflowId}`); + } + + if (options.format === 'json') { + this.displayJsonStatus(workflow); + } else { + this.displayTextStatus(workflow); + } + } + + private async watchWorkflowStatus(workflowId: string, options: WorkflowStatusOptions): Promise { + console.log(chalk.blue.bold('πŸ‘€ Watching workflow status (Press Ctrl+C to exit)\n')); + + let lastStatus = ''; + let updateCount = 0; + + const updateStatus = async () => { + try { + // Clear screen and move cursor to top + if (updateCount > 0) { + process.stdout.write('\x1b[2J\x1b[0f'); + } + + let workflow = this.workflowManager!.getWorkflowStatus(workflowId); + + if (!workflow) { + workflow = this.workflowManager!.getWorkflowByTaskId(workflowId); + } + + if (!workflow) { + console.log(chalk.red(`Workflow not found: ${workflowId}`)); + return; + } + + // Display header with timestamp + console.log(chalk.blue.bold('πŸ‘€ Watching Workflow Status')); + console.log(chalk.gray(`Last updated: ${new Date().toLocaleTimeString()}\n`)); + + this.displayTextStatus(workflow); + + // Check if workflow has ended + if (['completed', 'failed', 'cancelled', 'timeout'].includes(workflow.status)) { + console.log(chalk.yellow('\n⚠️ Workflow has ended. Stopping watch mode.')); + return; + } + + updateCount++; + + } catch (error) { + console.error(chalk.red('Error updating status:'), error); + } + }; + + // Initial display + await updateStatus(); + + // Setup interval for updates + const interval = setInterval(updateStatus, 2000); + + // Handle Ctrl+C + process.on('SIGINT', () => { + clearInterval(interval); + console.log(chalk.yellow('\nπŸ‘‹ Stopped watching workflow status')); + process.exit(0); + }); + + // Keep the process alive + await new Promise(() => {}); + } + + private displayJsonStatus(workflow: any): void { + const status = { + workflowId: `workflow-${workflow.taskId}`, + taskId: workflow.taskId, + taskTitle: workflow.taskTitle, + taskDescription: workflow.taskDescription, + status: workflow.status, + worktreePath: workflow.worktreePath, + branchName: workflow.branchName, + processId: workflow.processId, + startedAt: workflow.startedAt, + lastActivity: workflow.lastActivity, + duration: this.calculateDuration(workflow.startedAt, workflow.lastActivity), + metadata: workflow.metadata + }; + + console.log(JSON.stringify(status, null, 2)); + } + + private displayTextStatus(workflow: any): void { + const workflowId = `workflow-${workflow.taskId}`; + const duration = this.formatDuration(workflow.startedAt, workflow.lastActivity); + + ui.displayBanner(`Workflow Status: ${workflowId}`); + + // Basic information + console.log(chalk.blue.bold('\nπŸ“‹ Basic Information:\n')); + console.log(` Workflow ID: ${chalk.cyan(workflowId)}`); + console.log(` Task ID: ${chalk.cyan(workflow.taskId)}`); + console.log(` Task Title: ${workflow.taskTitle}`); + console.log(` Status: ${this.getStatusDisplay(workflow.status)}`); + console.log(` Duration: ${chalk.gray(duration)}`); + + // Task details + if (workflow.taskDescription) { + console.log(chalk.blue.bold('\nπŸ“ Task Details:\n')); + console.log(` ${workflow.taskDescription}`); + } + + // Process information + console.log(chalk.blue.bold('\nβš™οΈ Process Information:\n')); + console.log(` Process ID: ${workflow.processId ? chalk.green(workflow.processId) : chalk.gray('N/A')}`); + console.log(` Worktree: ${chalk.gray(workflow.worktreePath)}`); + console.log(` Branch: ${chalk.gray(workflow.branchName)}`); + + // Timing information + console.log(chalk.blue.bold('\n⏰ Timing:\n')); + console.log(` Started: ${chalk.gray(workflow.startedAt.toLocaleString())}`); + console.log(` Last Activity: ${chalk.gray(workflow.lastActivity.toLocaleString())}`); + + // Metadata + if (workflow.metadata && Object.keys(workflow.metadata).length > 0) { + console.log(chalk.blue.bold('\nπŸ”– Metadata:\n')); + Object.entries(workflow.metadata).forEach(([key, value]) => { + console.log(` ${key}: ${chalk.gray(String(value))}`); + }); + } + + // Status-specific information + this.displayStatusSpecificInfo(workflow); + + // Actions + this.displayAvailableActions(workflow); + } + + private displayStatusSpecificInfo(workflow: any): void { + const workflowId = `workflow-${workflow.taskId}`; + + switch (workflow.status) { + case 'running': + console.log(chalk.blue.bold('\nπŸš€ Running Status:\n')); + console.log(` ${chalk.green('●')} Process is actively executing`); + console.log(` ${chalk.blue('β„Ή')} Monitor output with: ${chalk.cyan(`tm workflow attach ${workflowId}`)}`); + break; + + case 'paused': + console.log(chalk.blue.bold('\n⏸️ Paused Status:\n')); + console.log(` ${chalk.yellow('●')} Workflow is paused`); + console.log(` ${chalk.blue('β„Ή')} Resume with: ${chalk.cyan(`tm workflow resume ${workflowId}`)}`); + break; + + case 'completed': + console.log(chalk.blue.bold('\nβœ… Completed Status:\n')); + console.log(` ${chalk.green('●')} Workflow completed successfully`); + console.log(` ${chalk.blue('β„Ή')} Resources have been cleaned up`); + break; + + case 'failed': + console.log(chalk.blue.bold('\n❌ Failed Status:\n')); + console.log(` ${chalk.red('●')} Workflow execution failed`); + console.log(` ${chalk.blue('β„Ή')} Check logs for error details`); + break; + + case 'initializing': + console.log(chalk.blue.bold('\nπŸ”„ Initializing Status:\n')); + console.log(` ${chalk.blue('●')} Setting up worktree and process`); + console.log(` ${chalk.blue('β„Ή')} This should complete shortly`); + break; + } + } + + private displayAvailableActions(workflow: any): void { + const workflowId = `workflow-${workflow.taskId}`; + console.log(chalk.blue.bold('\n🎯 Available Actions:\n')); + + switch (workflow.status) { + case 'running': + console.log(` β€’ Attach: ${chalk.cyan(`tm workflow attach ${workflowId}`)}`); + console.log(` β€’ Pause: ${chalk.cyan(`tm workflow pause ${workflowId}`)}`); + console.log(` β€’ Stop: ${chalk.cyan(`tm workflow stop ${workflowId}`)}`); + break; + + case 'paused': + console.log(` β€’ Resume: ${chalk.cyan(`tm workflow resume ${workflowId}`)}`); + console.log(` β€’ Stop: ${chalk.cyan(`tm workflow stop ${workflowId}`)}`); + break; + + case 'pending': + case 'initializing': + console.log(` β€’ Stop: ${chalk.cyan(`tm workflow stop ${workflowId}`)}`); + break; + + case 'completed': + case 'failed': + case 'cancelled': + console.log(` β€’ View logs: ${chalk.cyan(`tm workflow logs ${workflowId}`)}`); + console.log(` β€’ Start new: ${chalk.cyan(`tm workflow start ${workflow.taskId}`)}`); + break; + } + + console.log(` β€’ List all: ${chalk.cyan('tm workflow list')}`); + } + + private getStatusDisplay(status: string): string { + const statusMap = { + pending: { icon: '⏳', color: chalk.yellow }, + initializing: { icon: 'πŸ”„', color: chalk.blue }, + running: { icon: 'πŸš€', color: chalk.green }, + paused: { icon: '⏸️', color: chalk.orange }, + completed: { icon: 'βœ…', color: chalk.green }, + failed: { icon: '❌', color: chalk.red }, + cancelled: { icon: 'πŸ›‘', color: chalk.gray }, + timeout: { icon: '⏰', color: chalk.red } + }; + + const statusInfo = statusMap[status as keyof typeof statusMap] || { icon: '❓', color: chalk.white }; + return `${statusInfo.icon} ${statusInfo.color(status)}`; + } + + private formatDuration(start: Date, end: Date): string { + const diff = end.getTime() - start.getTime(); + const minutes = Math.floor(diff / (1000 * 60)); + const hours = Math.floor(minutes / 60); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } else { + return `${seconds}s`; + } + } + + private calculateDuration(start: Date, end: Date): number { + return Math.floor((end.getTime() - start.getTime()) / 1000); + } + + async cleanup(): Promise { + if (this.workflowManager) { + this.workflowManager.removeAllListeners(); + } + } + + static register(program: Command, name?: string): WorkflowStatusCommand { + const command = new WorkflowStatusCommand(name); + program.addCommand(command); + return command; + } +} \ No newline at end of file diff --git a/apps/cli/src/commands/workflow/workflow-stop.command.ts b/apps/cli/src/commands/workflow/workflow-stop.command.ts new file mode 100644 index 00000000..dac8d40f --- /dev/null +++ b/apps/cli/src/commands/workflow/workflow-stop.command.ts @@ -0,0 +1,260 @@ +/** + * @fileoverview Workflow Stop Command + * Stop and clean up workflow execution + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import path from 'node:path'; +import { + TaskExecutionManager, + type TaskExecutionManagerConfig +} from '@tm/workflow-engine'; +import * as ui from '../../utils/ui.js'; + +export interface WorkflowStopOptions { + project?: string; + worktreeBase?: string; + claude?: string; + force?: boolean; + all?: boolean; +} + +/** + * WorkflowStopCommand - Stop workflow execution + */ +export class WorkflowStopCommand extends Command { + private workflowManager?: TaskExecutionManager; + + constructor(name?: string) { + super(name || 'stop'); + + this.description('Stop workflow execution and clean up resources') + .argument('[workflow-id]', 'Workflow ID to stop (or task ID)') + .option('-p, --project ', 'Project root directory', process.cwd()) + .option( + '--worktree-base ', + 'Base directory for worktrees', + '../task-worktrees' + ) + .option('--claude ', 'Claude Code executable path', 'claude') + .option('-f, --force', 'Force stop (kill process immediately)') + .option('--all', 'Stop all running workflows') + .action( + async ( + workflowId: string | undefined, + options: WorkflowStopOptions + ) => { + await this.executeCommand(workflowId, options); + } + ); + } + + private async executeCommand( + workflowId: string | undefined, + options: WorkflowStopOptions + ): Promise { + try { + // Initialize workflow manager + await this.initializeWorkflowManager(options); + + if (options.all) { + await this.stopAllWorkflows(options); + } else if (workflowId) { + await this.stopSingleWorkflow(workflowId, options); + } else { + ui.displayError('Please specify a workflow ID or use --all flag'); + process.exit(1); + } + } catch (error: any) { + ui.displayError(error.message || 'Failed to stop workflow'); + process.exit(1); + } + } + + private async initializeWorkflowManager( + options: WorkflowStopOptions + ): Promise { + if (!this.workflowManager) { + const projectRoot = options.project || process.cwd(); + const worktreeBase = path.resolve( + projectRoot, + options.worktreeBase || '../task-worktrees' + ); + + const config: TaskExecutionManagerConfig = { + projectRoot, + maxConcurrent: 5, + defaultTimeout: 60, + worktreeBase, + claudeExecutable: options.claude || 'claude', + debug: false + }; + + this.workflowManager = new TaskExecutionManager(config); + await this.workflowManager.initialize(); + } + } + + private async stopSingleWorkflow( + workflowId: string, + options: WorkflowStopOptions + ): Promise { + // Try to find workflow by ID or task ID + let workflow = this.workflowManager!.getWorkflowStatus(workflowId); + + if (!workflow) { + // Try as task ID + workflow = this.workflowManager!.getWorkflowByTaskId(workflowId); + } + + if (!workflow) { + throw new Error(`Workflow not found: ${workflowId}`); + } + + const actualWorkflowId = `workflow-${workflow.taskId}`; + + // Display workflow info + console.log(chalk.blue.bold(`πŸ›‘ Stopping Workflow: ${actualWorkflowId}`)); + console.log(`${chalk.blue('Task:')} ${workflow.taskTitle}`); + console.log( + `${chalk.blue('Status:')} ${this.getStatusDisplay(workflow.status)}` + ); + console.log( + `${chalk.blue('Worktree:')} ${chalk.gray(workflow.worktreePath)}` + ); + + if (workflow.processId) { + console.log( + `${chalk.blue('Process ID:')} ${chalk.gray(workflow.processId)}` + ); + } + + console.log(); + + // Confirm if not forced + if (!options.force && ['running', 'paused'].includes(workflow.status)) { + const shouldProceed = await ui.confirm( + `Are you sure you want to stop this ${workflow.status} workflow?` + ); + + if (!shouldProceed) { + console.log(chalk.gray('Operation cancelled')); + return; + } + } + + // Stop the workflow + ui.displaySpinner('Stopping workflow and cleaning up resources...'); + + await this.workflowManager!.stopTaskExecution( + actualWorkflowId, + options.force + ); + + ui.displaySuccess('Workflow stopped successfully!'); + console.log(); + console.log(`${chalk.green('βœ“')} Process terminated`); + console.log(`${chalk.green('βœ“')} Worktree cleaned up`); + console.log(`${chalk.green('βœ“')} State updated`); + } + + private async stopAllWorkflows(options: WorkflowStopOptions): Promise { + const workflows = this.workflowManager!.listWorkflows(); + const activeWorkflows = workflows.filter((w) => + ['pending', 'initializing', 'running', 'paused'].includes(w.status) + ); + + if (activeWorkflows.length === 0) { + ui.displayWarning('No active workflows to stop'); + return; + } + + console.log( + chalk.blue.bold(`πŸ›‘ Stopping ${activeWorkflows.length} Active Workflows`) + ); + console.log(); + + // List workflows to be stopped + activeWorkflows.forEach((workflow) => { + console.log( + ` β€’ ${chalk.cyan(`workflow-${workflow.taskId}`)} - ${workflow.taskTitle} ${this.getStatusDisplay(workflow.status)}` + ); + }); + console.log(); + + // Confirm if not forced + if (!options.force) { + const shouldProceed = await ui.confirm( + `Are you sure you want to stop all ${activeWorkflows.length} active workflows?` + ); + + if (!shouldProceed) { + console.log(chalk.gray('Operation cancelled')); + return; + } + } + + // Stop all workflows + ui.displaySpinner('Stopping all workflows...'); + + let stopped = 0; + let failed = 0; + + for (const workflow of activeWorkflows) { + try { + const workflowId = `workflow-${workflow.taskId}`; + await this.workflowManager!.stopTaskExecution( + workflowId, + options.force + ); + stopped++; + } catch (error) { + console.error( + `${chalk.red('βœ—')} Failed to stop workflow ${workflow.taskId}: ${error}` + ); + failed++; + } + } + + console.log(); + if (stopped > 0) { + ui.displaySuccess(`Successfully stopped ${stopped} workflows`); + } + + if (failed > 0) { + ui.displayWarning(`Failed to stop ${failed} workflows`); + } + } + + private getStatusDisplay(status: string): string { + const statusMap = { + pending: { icon: '⏳', color: chalk.yellow }, + initializing: { icon: 'πŸ”„', color: chalk.blue }, + running: { icon: 'πŸš€', color: chalk.green }, + paused: { icon: '⏸️', color: chalk.hex('#FFA500') }, + completed: { icon: 'βœ…', color: chalk.green }, + failed: { icon: '❌', color: chalk.red }, + cancelled: { icon: 'πŸ›‘', color: chalk.gray }, + timeout: { icon: '⏰', color: chalk.red } + }; + + const statusInfo = statusMap[status as keyof typeof statusMap] || { + icon: '❓', + color: chalk.white + }; + return `${statusInfo.icon} ${statusInfo.color(status)}`; + } + + async cleanup(): Promise { + if (this.workflowManager) { + this.workflowManager.removeAllListeners(); + } + } + + static register(program: Command, name?: string): WorkflowStopCommand { + const command = new WorkflowStopCommand(name); + program.addCommand(command); + return command; + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 420a1716..6d201e2d 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -6,8 +6,12 @@ // Commands export { ListTasksCommand } from './commands/list.command.js'; export { AuthCommand } from './commands/auth.command.js'; +export { WorkflowCommand } from './commands/workflow.command.js'; export { ContextCommand } from './commands/context.command.js'; +// Command registry +export { registerAllCommands } from './commands/index.js'; + // UI utilities (for other commands to use) export * as ui from './utils/ui.js'; diff --git a/apps/cli/src/utils/ui.ts b/apps/cli/src/utils/ui.ts index 4912a519..94b8b66d 100644 --- a/apps/cli/src/utils/ui.ts +++ b/apps/cli/src/utils/ui.ts @@ -324,3 +324,61 @@ export function createTaskTable( return table.toString(); } + + +/** + * Display a spinner with message (mock implementation) + */ +export function displaySpinner(message: string): void { + console.log(chalk.blue('◐'), chalk.gray(message)); +} + +/** + * Simple confirmation prompt + */ +export async function confirm(message: string): Promise { + // For now, return true. In a real implementation, use inquirer + console.log(chalk.yellow('?'), chalk.white(message), chalk.gray('(y/n)')); + + // Mock implementation - in production this would use inquirer + return new Promise((resolve) => { + process.stdin.once('data', (data) => { + const answer = data.toString().trim().toLowerCase(); + resolve(answer === 'y' || answer === 'yes'); + }); + process.stdin.resume(); + }); +} + +/** + * Create a generic table + */ +export function createTable(headers: string[], rows: string[][]): string { + const table = new Table({ + head: headers.map(h => chalk.blue.bold(h)), + style: { + head: [], + border: ['gray'] + }, + chars: { + 'top': '─', + 'top-mid': '┬', + 'top-left': 'β”Œ', + 'top-right': '┐', + 'bottom': '─', + 'bottom-mid': 'β”΄', + 'bottom-left': 'β””', + 'bottom-right': 'β”˜', + 'left': 'β”‚', + 'left-mid': 'β”œ', + 'mid': '─', + 'mid-mid': 'β”Ό', + 'right': 'β”‚', + 'right-mid': '─', + 'middle': 'β”‚' + } + }); + + rows.forEach(row => table.push(row)); + return table.toString(); +} diff --git a/package-lock.json b/package-lock.json index 2d87122b..68464a5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,6 +96,7 @@ "license": "MIT", "dependencies": { "@tm/core": "*", + "@tm/workflow-engine": "*", "boxen": "^7.1.1", "chalk": "5.6.2", "cli-table3": "^0.6.5", @@ -10311,6 +10312,10 @@ "resolved": "packages/tm-core", "link": true }, + "node_modules/@tm/workflow-engine": { + "resolved": "packages/workflow-engine", + "link": true + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -31082,7 +31087,9 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@supabase/supabase-js": "^2.57.4", + "@supabase/supabase-js": "^2.57.0", + "@tm/workflow-engine": "*", + "chalk": "^5.3.0", "zod": "^3.22.4" }, "devDependencies": { @@ -31108,6 +31115,42 @@ "dependencies": { "undici-types": "~6.21.0" } + }, + "packages/tm-core/node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "packages/workflow-engine": { + "name": "@tm/workflow-engine", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@tm/core": "*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsup": "^8.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } + }, + "packages/workflow-engine/node_modules/@types/node": { + "version": "22.18.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz", + "integrity": "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/workflow-engine/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 8754bb52..dcbd1a55 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,13 @@ "scripts": { "build": "npm run build:packages && tsup", "dev": "npm run build:packages && npm link && (npm run dev:packages & tsup --watch --onSuccess 'echo Build complete && npm link')", - "dev:packages": "(cd packages/tm-core && npm run dev) & (cd apps/cli && npm run dev) & wait", + "dev:packages": "(cd packages/tm-core && npm run dev) & (cd packages/workflow-engine && npm run dev) & (cd apps/cli && npm run dev) & wait", "dev:core": "cd packages/tm-core && npm run dev", + "dev:workflow": "cd packages/workflow-engine && npm run dev", "dev:cli": "cd apps/cli && npm run dev", - "build:packages": "npm run build:build-config && npm run build:core && npm run build:cli", - "build:build-config": "cd packages/build-config && npm run build", + "build:packages": "npm run build:core && npm run build:workflow && npm run build:cli", "build:core": "cd packages/tm-core && npm run build", + "build:workflow": "cd packages/workflow-engine && npm run build", "build:cli": "cd apps/cli && npm run build", "typecheck": "npm run typecheck:core && npm run typecheck:cli", "typecheck:core": "cd packages/tm-core && npm run typecheck", diff --git a/packages/tm-core/package.json b/packages/tm-core/package.json index ba5eb44c..fed98cd5 100644 --- a/packages/tm-core/package.json +++ b/packages/tm-core/package.json @@ -49,8 +49,15 @@ }, "./utils": { "types": "./src/utils/index.ts", - "import": "./dist/utils/index.js" - } + "import": "./dist/utils/index.js", + "require": "./dist/utils/index.js" + }, + "./workflow": { + "types": "./src/workflow/index.ts", + "import": "./dist/workflow/index.js", + "require": "./dist/workflow/index.js" + }, + "./package.json": "./package.json" }, "scripts": { "build": "tsup", @@ -66,7 +73,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@supabase/supabase-js": "^2.57.4", + "@supabase/supabase-js": "^2.57.0", + "@tm/workflow-engine": "*", + "chalk": "^5.3.0", "zod": "^3.22.4" }, "devDependencies": { diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index 3cc46c67..7d24cdb3 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -55,3 +55,7 @@ export { // Re-export logger export { getLogger, createLogger, setGlobalLogger } from './logger/index.js'; + +// Re-export workflow +export { WorkflowService, type WorkflowServiceConfig } from './workflow/index.js'; +export type * from './workflow/index.js'; diff --git a/packages/tm-core/src/task-master-core.ts b/packages/tm-core/src/task-master-core.ts index 93b662ff..b2ad8359 100644 --- a/packages/tm-core/src/task-master-core.ts +++ b/packages/tm-core/src/task-master-core.ts @@ -16,6 +16,10 @@ import type { TaskFilter, StorageType } from './types/index.js'; +import { + WorkflowService, + type WorkflowServiceConfig +} from './workflow/index.js'; /** * Options for creating TaskMasterCore instance @@ -23,6 +27,7 @@ import type { export interface TaskMasterCoreOptions { projectPath: string; configuration?: Partial; + workflow?: Partial; } /** @@ -38,6 +43,7 @@ export type { GetTaskListOptions } from './services/task-service.js'; export class TaskMasterCore { private configManager: ConfigManager; private taskService: TaskService; + private workflowService: WorkflowService; /** * Create and initialize a new TaskMasterCore instance @@ -60,6 +66,7 @@ export class TaskMasterCore { // Services will be initialized in the initialize() method this.configManager = null as any; this.taskService = null as any; + this.workflowService = null as any; } /** @@ -86,6 +93,28 @@ export class TaskMasterCore { // Create task service this.taskService = new TaskService(this.configManager); await this.taskService.initialize(); + + // Create workflow service + const workflowConfig: WorkflowServiceConfig = { + projectRoot: options.projectPath, + ...options.workflow + }; + + // Pass task retrieval function to workflow service + this.workflowService = new WorkflowService( + workflowConfig, + async (taskId: string) => { + const task = await this.getTask(taskId); + if (!task) { + throw new TaskMasterError( + `Task ${taskId} not found`, + ERROR_CODES.TASK_NOT_FOUND + ); + } + return task; + } + ); + await this.workflowService.initialize(); } catch (error) { throw new TaskMasterError( 'Failed to initialize TaskMasterCore', @@ -175,11 +204,21 @@ export class TaskMasterCore { await this.configManager.setActiveTag(tag); } + /** + * Get workflow service for workflow operations + */ + get workflow(): WorkflowService { + return this.workflowService; + } + /** * Close and cleanup resources */ async close(): Promise { // TaskService handles storage cleanup internally + if (this.workflowService) { + await this.workflowService.dispose(); + } } } diff --git a/packages/tm-core/src/workflow/index.ts b/packages/tm-core/src/workflow/index.ts new file mode 100644 index 00000000..258fe830 --- /dev/null +++ b/packages/tm-core/src/workflow/index.ts @@ -0,0 +1,17 @@ +/** + * @fileoverview Workflow Module + * Public exports for workflow functionality + */ + +export { WorkflowService, type WorkflowServiceConfig } from './workflow-service.js'; + +// Re-export workflow engine types for convenience +export type { + WorkflowExecutionContext, + WorkflowStatus, + WorkflowEvent, + WorkflowEventType, + WorkflowProcess, + ProcessStatus, + WorktreeInfo +} from '@tm/workflow-engine'; \ No newline at end of file diff --git a/packages/tm-core/src/workflow/workflow-service.ts b/packages/tm-core/src/workflow/workflow-service.ts new file mode 100644 index 00000000..6cdb56d6 --- /dev/null +++ b/packages/tm-core/src/workflow/workflow-service.ts @@ -0,0 +1,218 @@ +/** + * @fileoverview Workflow Service + * Integrates workflow engine into Task Master Core + */ + +import { + TaskExecutionManager, + type TaskExecutionManagerConfig, + type WorkflowExecutionContext +} from '@tm/workflow-engine'; +import type { Task } from '../types/index.js'; +import { TaskMasterError } from '../errors/index.js'; + +export interface WorkflowServiceConfig { + /** Project root directory */ + projectRoot: string; + /** Maximum number of concurrent workflows */ + maxConcurrent?: number; + /** Default timeout for workflow execution (minutes) */ + defaultTimeout?: number; + /** Base directory for worktrees */ + worktreeBase?: string; + /** Claude Code executable path */ + claudeExecutable?: string; + /** Enable debug logging */ + debug?: boolean; +} + +/** + * WorkflowService provides Task Master workflow capabilities through core + */ +export class WorkflowService { + private workflowEngine: TaskExecutionManager; + + constructor( + config: WorkflowServiceConfig, + private getTask: (taskId: string) => Promise + ) { + + const engineConfig: TaskExecutionManagerConfig = { + projectRoot: config.projectRoot, + maxConcurrent: config.maxConcurrent || 5, + defaultTimeout: config.defaultTimeout || 60, + worktreeBase: + config.worktreeBase || + require('path').join(config.projectRoot, '..', 'task-worktrees'), + claudeExecutable: config.claudeExecutable || 'claude', + debug: config.debug || false + }; + + this.workflowEngine = new TaskExecutionManager(engineConfig); + } + + /** + * Initialize the workflow service + */ + async initialize(): Promise { + await this.workflowEngine.initialize(); + } + + /** + * Start a workflow for a task + */ + async start( + taskId: string, + options?: { + branchName?: string; + timeout?: number; + env?: Record; + } + ): Promise { + try { + // Get task from core + const task = await this.getTask(taskId); + + // Start workflow using engine + return await this.workflowEngine.startTaskExecution(task, options); + } catch (error) { + throw new TaskMasterError( + `Failed to start workflow for task ${taskId}`, + 'WORKFLOW_START_FAILED', + error instanceof Error ? error : undefined + ); + } + } + + /** + * Stop a workflow + */ + async stop(workflowId: string, force = false): Promise { + try { + await this.workflowEngine.stopTaskExecution(workflowId, force); + } catch (error) { + throw new TaskMasterError( + `Failed to stop workflow ${workflowId}`, + 'WORKFLOW_STOP_FAILED', + error instanceof Error ? error : undefined + ); + } + } + + /** + * Pause a workflow + */ + async pause(workflowId: string): Promise { + try { + await this.workflowEngine.pauseTaskExecution(workflowId); + } catch (error) { + throw new TaskMasterError( + `Failed to pause workflow ${workflowId}`, + 'WORKFLOW_PAUSE_FAILED', + error instanceof Error ? error : undefined + ); + } + } + + /** + * Resume a paused workflow + */ + async resume(workflowId: string): Promise { + try { + await this.workflowEngine.resumeTaskExecution(workflowId); + } catch (error) { + throw new TaskMasterError( + `Failed to resume workflow ${workflowId}`, + 'WORKFLOW_RESUME_FAILED', + error instanceof Error ? error : undefined + ); + } + } + + /** + * Get workflow status + */ + getStatus(workflowId: string): WorkflowExecutionContext | undefined { + return this.workflowEngine.getWorkflowStatus(workflowId); + } + + /** + * Get workflow by task ID + */ + getByTaskId(taskId: string): WorkflowExecutionContext | undefined { + return this.workflowEngine.getWorkflowByTaskId(taskId); + } + + /** + * List all workflows + */ + list(): WorkflowExecutionContext[] { + return this.workflowEngine.listWorkflows(); + } + + /** + * List active workflows + */ + listActive(): WorkflowExecutionContext[] { + return this.workflowEngine.listActiveWorkflows(); + } + + /** + * Send input to a running workflow + */ + async sendInput(workflowId: string, input: string): Promise { + try { + await this.workflowEngine.sendInputToWorkflow(workflowId, input); + } catch (error) { + throw new TaskMasterError( + `Failed to send input to workflow ${workflowId}`, + 'WORKFLOW_INPUT_FAILED', + error instanceof Error ? error : undefined + ); + } + } + + /** + * Clean up all workflows + */ + async cleanup(force = false): Promise { + try { + await this.workflowEngine.cleanup(force); + } catch (error) { + throw new TaskMasterError( + 'Failed to cleanup workflows', + 'WORKFLOW_CLEANUP_FAILED', + error instanceof Error ? error : undefined + ); + } + } + + /** + * Subscribe to workflow events + */ + on(event: string, listener: (...args: any[]) => void): void { + this.workflowEngine.on(event, listener); + } + + /** + * Unsubscribe from workflow events + */ + off(event: string, listener: (...args: any[]) => void): void { + this.workflowEngine.off(event, listener); + } + + /** + * Get workflow engine instance (for advanced usage) + */ + getEngine(): TaskExecutionManager { + return this.workflowEngine; + } + + /** + * Dispose of the workflow service + */ + async dispose(): Promise { + await this.cleanup(true); + this.workflowEngine.removeAllListeners(); + } +} \ No newline at end of file diff --git a/packages/tm-core/tsup.config.ts b/packages/tm-core/tsup.config.ts index 944237a6..65dd5561 100644 --- a/packages/tm-core/tsup.config.ts +++ b/packages/tm-core/tsup.config.ts @@ -1,24 +1,53 @@ import { defineConfig } from 'tsup'; -import { libraryConfig, mergeConfig } from '@tm/build-config'; +import { dotenvLoad } from 'dotenv-mono'; +dotenvLoad(); -export default defineConfig( - mergeConfig(libraryConfig, { - entry: { - index: 'src/index.ts', - 'auth/index': 'src/auth/index.ts', - 'config/index': 'src/config/index.ts', - 'services/index': 'src/services/index.ts', - 'logger/index': 'src/logger/index.ts', - 'interfaces/index': 'src/interfaces/index.ts', - 'types/index': 'src/types/index.ts', - 'providers/index': 'src/providers/index.ts', - 'storage/index': 'src/storage/index.ts', - 'parser/index': 'src/parser/index.ts', - 'utils/index': 'src/utils/index.ts', - 'errors/index': 'src/errors/index.ts' - }, - tsconfig: './tsconfig.json', - outDir: 'dist', - external: ['zod', '@supabase/supabase-js'] - }) -); +// Get all TM_PUBLIC_* env variables for build-time injection +const getBuildTimeEnvs = () => { + const envs: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('TM_PUBLIC_')) { + // Return the actual value, not JSON.stringify'd + envs[key] = value || ''; + } + } + return envs; +}; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'auth/index': 'src/auth/index.ts', + 'config/index': 'src/config/index.ts', + 'errors/index': 'src/errors/index.ts', + 'interfaces/index': 'src/interfaces/index.ts', + 'logger/index': 'src/logger/index.ts', + 'parser/index': 'src/parser/index.ts', + 'providers/index': 'src/providers/index.ts', + 'services/index': 'src/services/index.ts', + 'storage/index': 'src/storage/index.ts', + 'types/index': 'src/types/index.ts', + 'utils/index': 'src/utils/index.ts', + 'workflow/index': 'src/workflow/index.ts' + }, + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + clean: true, + splitting: false, + treeshake: true, + minify: false, + target: 'es2022', + tsconfig: './tsconfig.json', + outDir: 'dist', + // Replace process.env.TM_PUBLIC_* with actual values at build time + env: getBuildTimeEnvs(), + // Auto-external all dependencies from package.json + external: [ + // External all node_modules - everything not starting with . or / + /^[^./]/ + ], + esbuildOptions(options) { + options.conditions = ['module']; + } +}); diff --git a/packages/workflow-engine/README.md b/packages/workflow-engine/README.md new file mode 100644 index 00000000..f8992c66 --- /dev/null +++ b/packages/workflow-engine/README.md @@ -0,0 +1,371 @@ +# @tm/workflow-engine + +Enhanced Task Master workflow execution engine with git worktree isolation and Claude Code process management. + +## Overview + +The Workflow Engine extends Task Master with advanced execution capabilities: + +- **Git Worktree Isolation**: Each task runs in its own isolated worktree +- **Process Sandboxing**: Spawns dedicated Claude Code processes for task execution +- **Real-time Monitoring**: Track workflow progress and process output +- **State Management**: Persistent workflow state across sessions +- **Parallel Execution**: Run multiple tasks concurrently with resource limits + +## Architecture + +``` +TaskExecutionManager +β”œβ”€β”€ WorktreeManager # Git worktree lifecycle +β”œβ”€β”€ ProcessSandbox # Claude Code process management +└── WorkflowStateManager # Persistent state tracking +``` + +## Quick Start + +```typescript +import { TaskExecutionManager } from '@tm/workflow-engine'; + +const manager = new TaskExecutionManager({ + projectRoot: '/path/to/project', + worktreeBase: '/path/to/worktrees', + claudeExecutable: 'claude', + maxConcurrent: 3, + defaultTimeout: 60, + debug: true +}); + +await manager.initialize(); + +// Start task execution +const workflowId = await manager.startTaskExecution({ + id: '1.2', + title: 'Implement authentication', + description: 'Add JWT-based auth system', + status: 'pending', + priority: 'high' +}); + +// Monitor workflow +const workflow = manager.getWorkflowStatus(workflowId); +console.log(`Status: ${workflow.status}`); + +// Stop when complete +await manager.stopTaskExecution(workflowId); +``` + +## CLI Integration + +```bash +# Start workflow +tm workflow start 1.2 + +# List active workflows +tm workflow list + +# Check status +tm workflow status workflow-1.2-1234567890-abc123 + +# Stop workflow +tm workflow stop workflow-1.2-1234567890-abc123 +``` + +## VS Code Extension + +The workflow engine integrates with the Task Master VS Code extension to provide: + +- **Workflow Tree View**: Visual workflow management +- **Process Monitoring**: Real-time output streaming +- **Worktree Navigation**: Quick access to isolated workspaces +- **Status Indicators**: Visual workflow state tracking + +## Core Components + +### TaskExecutionManager + +Orchestrates complete workflow lifecycle: + +```typescript +// Event-driven workflow management +manager.on('workflow.started', (event) => { + console.log(`Started: ${event.workflowId}`); +}); + +manager.on('process.output', (event) => { + console.log(`[${event.data.stream}]: ${event.data.data}`); +}); +``` + +### WorktreeManager + +Manages git worktree operations: + +```typescript +import { WorktreeManager } from '@tm/workflow-engine'; + +const manager = new WorktreeManager({ + worktreeBase: './worktrees', + projectRoot: process.cwd(), + autoCleanup: true +}); + +// Create isolated workspace +const worktree = await manager.createWorktree('task-1.2'); +console.log(`Created: ${worktree.path}`); + +// List all worktrees +const worktrees = await manager.listWorktrees(); + +// Cleanup +await manager.removeWorktree('task-1.2'); +``` + +### ProcessSandbox + +Spawns and manages Claude Code processes: + +```typescript +import { ProcessSandbox } from '@tm/workflow-engine'; + +const sandbox = new ProcessSandbox({ + claudeExecutable: 'claude', + defaultTimeout: 30, + debug: true +}); + +// Start isolated process +const process = await sandbox.startProcess( + 'workflow-123', + 'task-1.2', + 'Implement user authentication with JWT tokens', + { cwd: '/path/to/worktree' } +); + +// Send input +await sandbox.sendInput('workflow-123', 'npm test'); + +// Monitor output +sandbox.on('process.output', (event) => { + console.log(event.data.data); +}); +``` + +### WorkflowStateManager + +Persistent workflow state management: + +```typescript +import { WorkflowStateManager } from '@tm/workflow-engine'; + +const stateManager = new WorkflowStateManager({ + projectRoot: process.cwd() +}); + +await stateManager.loadState(); + +// Register workflow +const workflowId = await stateManager.registerWorkflow({ + taskId: '1.2', + taskTitle: 'Authentication', + // ... other context +}); + +// Update status +await stateManager.updateWorkflowStatus(workflowId, 'running'); + +// Query workflows +const running = stateManager.listWorkflowsByStatus('running'); +``` + +## Configuration + +### Environment Variables + +- `TASKMASTER_WORKFLOW_DEBUG`: Enable debug logging +- `TASKMASTER_CLAUDE_PATH`: Custom Claude Code executable path +- `TASKMASTER_WORKTREE_BASE`: Base directory for worktrees +- `TASKMASTER_MAX_CONCURRENT`: Maximum concurrent workflows + +### Config Object + +```typescript +interface TaskExecutionManagerConfig { + projectRoot: string; // Project root directory + worktreeBase: string; // Worktree base path + claudeExecutable: string; // Claude executable + maxConcurrent: number; // Concurrent limit + defaultTimeout: number; // Timeout (minutes) + debug: boolean; // Debug logging +} +``` + +## Workflow States + +| State | Description | +|-------|-------------| +| `pending` | Created but not started | +| `initializing` | Setting up worktree/process | +| `running` | Active execution | +| `paused` | Temporarily stopped | +| `completed` | Successfully finished | +| `failed` | Error occurred | +| `cancelled` | User cancelled | +| `timeout` | Exceeded time limit | + +## Events + +The workflow engine emits events for real-time monitoring: + +```typescript +// Workflow lifecycle +manager.on('workflow.started', (event) => {}); +manager.on('workflow.completed', (event) => {}); +manager.on('workflow.failed', (event) => {}); + +// Process events +manager.on('process.started', (event) => {}); +manager.on('process.output', (event) => {}); +manager.on('process.stopped', (event) => {}); + +// Worktree events +manager.on('worktree.created', (event) => {}); +manager.on('worktree.deleted', (event) => {}); +``` + +## Error Handling + +The workflow engine provides specialized error types: + +```typescript +import { + WorkflowError, + WorktreeError, + ProcessError, + MaxConcurrentWorkflowsError +} from '@tm/workflow-engine'; + +try { + await manager.startTaskExecution(task); +} catch (error) { + if (error instanceof MaxConcurrentWorkflowsError) { + console.log('Too many concurrent workflows'); + } else if (error instanceof WorktreeError) { + console.log('Worktree operation failed'); + } +} +``` + +## Development + +```bash +# Install dependencies +npm install + +# Build package +npm run build + +# Run tests +npm test + +# Development mode +npm run dev +``` + +## Integration Examples + +### With Task Master Core + +```typescript +import { createTaskMasterCore } from '@tm/core'; +import { TaskExecutionManager } from '@tm/workflow-engine'; + +const core = await createTaskMasterCore({ projectPath: '.' }); +const workflows = new TaskExecutionManager({ /*...*/ }); + +// Get task from core +const tasks = await core.getTaskList({}); +const task = tasks.tasks.find(t => t.id === '1.2'); + +// Execute with workflow engine +if (task) { + const workflowId = await workflows.startTaskExecution(task); +} +``` + +### With VS Code Extension + +```typescript +import { WorkflowProvider } from './workflow-provider'; + +// Register tree view +const provider = new WorkflowProvider(context); +vscode.window.createTreeView('taskmaster.workflows', { + treeDataProvider: provider +}); + +// Register commands +vscode.commands.registerCommand('taskmaster.workflow.start', + async (taskId) => { + await provider.startWorkflow(taskId); + } +); +``` + +## Troubleshooting + +### Common Issues + +1. **Worktree Creation Fails** + ```bash + # Check git version (requires 2.5+) + git --version + + # Verify project is git repository + git status + ``` + +2. **Claude Code Not Found** + ```bash + # Check Claude installation + which claude + + # Set custom path + export TASKMASTER_CLAUDE_PATH=/path/to/claude + ``` + +3. **Permission Errors** + ```bash + # Check worktree directory permissions + chmod -R 755 ./worktrees + ``` + +### Debug Mode + +Enable debug logging for troubleshooting: + +```typescript +const manager = new TaskExecutionManager({ + // ... other config + debug: true +}); +``` + +Or via environment: + +```bash +export TASKMASTER_WORKFLOW_DEBUG=true +tm workflow start 1.2 +``` + +## Roadmap + +- [ ] Process resource monitoring (CPU, memory) +- [ ] Workflow templates and presets +- [ ] Integration with CI/CD pipelines +- [ ] Workflow scheduling and queueing +- [ ] Multi-machine workflow distribution +- [ ] Advanced debugging and profiling tools + +## License + +MIT WITH Commons-Clause \ No newline at end of file diff --git a/packages/workflow-engine/package.json b/packages/workflow-engine/package.json new file mode 100644 index 00000000..5d48e1db --- /dev/null +++ b/packages/workflow-engine/package.json @@ -0,0 +1,56 @@ +{ + "name": "@tm/workflow-engine", + "version": "0.1.0", + "description": "Task Master workflow execution engine with git worktree and process management", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./task-execution": { + "import": "./dist/task-execution/index.js", + "types": "./dist/task-execution/index.d.ts" + }, + "./worktree": { + "import": "./dist/worktree/index.js", + "types": "./dist/worktree/index.d.ts" + }, + "./process": { + "import": "./dist/process/index.js", + "types": "./dist/process/index.d.ts" + }, + "./state": { + "import": "./dist/state/index.js", + "types": "./dist/state/index.d.ts" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest", + "test:watch": "vitest --watch", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@tm/core": "*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsup": "^8.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "files": ["dist"], + "keywords": [ + "task-master", + "workflow", + "git-worktree", + "process-management", + "claude-code" + ], + "author": "Task Master AI Team", + "license": "MIT" +} diff --git a/packages/workflow-engine/src/errors/index.ts b/packages/workflow-engine/src/errors/index.ts new file mode 100644 index 00000000..09c26e02 --- /dev/null +++ b/packages/workflow-engine/src/errors/index.ts @@ -0,0 +1,6 @@ +/** + * @fileoverview Workflow Engine Errors + * Public error exports + */ + +export * from './workflow.errors.js'; \ No newline at end of file diff --git a/packages/workflow-engine/src/errors/workflow.errors.ts b/packages/workflow-engine/src/errors/workflow.errors.ts new file mode 100644 index 00000000..66bc6922 --- /dev/null +++ b/packages/workflow-engine/src/errors/workflow.errors.ts @@ -0,0 +1,59 @@ +/** + * @fileoverview Workflow Engine Errors + * Custom error classes for workflow operations + */ + +export class WorkflowError extends Error { + constructor( + message: string, + public code: string, + public workflowId?: string, + public taskId?: string, + public cause?: Error + ) { + super(message); + this.name = 'WorkflowError'; + } +} + +export class WorktreeError extends WorkflowError { + constructor(message: string, public path?: string, cause?: Error) { + super(message, 'WORKTREE_ERROR', undefined, undefined, cause); + this.name = 'WorktreeError'; + } +} + +export class ProcessError extends WorkflowError { + constructor(message: string, public pid?: number, cause?: Error) { + super(message, 'PROCESS_ERROR', undefined, undefined, cause); + this.name = 'ProcessError'; + } +} + +export class WorkflowTimeoutError extends WorkflowError { + constructor(workflowId: string, timeoutMinutes: number) { + super( + `Workflow ${workflowId} timed out after ${timeoutMinutes} minutes`, + 'WORKFLOW_TIMEOUT', + workflowId + ); + this.name = 'WorkflowTimeoutError'; + } +} + +export class WorkflowNotFoundError extends WorkflowError { + constructor(workflowId: string) { + super(`Workflow ${workflowId} not found`, 'WORKFLOW_NOT_FOUND', workflowId); + this.name = 'WorkflowNotFoundError'; + } +} + +export class MaxConcurrentWorkflowsError extends WorkflowError { + constructor(maxConcurrent: number) { + super( + `Maximum concurrent workflows (${maxConcurrent}) reached`, + 'MAX_CONCURRENT_WORKFLOWS' + ); + this.name = 'MaxConcurrentWorkflowsError'; + } +} \ No newline at end of file diff --git a/packages/workflow-engine/src/index.ts b/packages/workflow-engine/src/index.ts new file mode 100644 index 00000000..e826e913 --- /dev/null +++ b/packages/workflow-engine/src/index.ts @@ -0,0 +1,19 @@ +/** + * @fileoverview Workflow Engine + * Main entry point for the Task Master workflow execution engine + */ + +// Core task execution +export * from './task-execution/index.js'; + +// Component managers +export * from './worktree/index.js'; +export * from './process/index.js'; +export * from './state/index.js'; + +// Types and errors +export * from './types/index.js'; +export * from './errors/index.js'; + +// Convenience exports +export { TaskExecutionManager as WorkflowEngine } from './task-execution/index.js'; \ No newline at end of file diff --git a/packages/workflow-engine/src/process/index.ts b/packages/workflow-engine/src/process/index.ts new file mode 100644 index 00000000..4df0d6d8 --- /dev/null +++ b/packages/workflow-engine/src/process/index.ts @@ -0,0 +1,6 @@ +/** + * @fileoverview Process Management + * Public exports for process operations + */ + +export * from './process-sandbox.js'; \ No newline at end of file diff --git a/packages/workflow-engine/src/process/process-sandbox.ts b/packages/workflow-engine/src/process/process-sandbox.ts new file mode 100644 index 00000000..5166b6a5 --- /dev/null +++ b/packages/workflow-engine/src/process/process-sandbox.ts @@ -0,0 +1,378 @@ +/** + * @fileoverview Process Sandbox + * Manages Claude Code process execution in isolated environments + */ + +import { spawn, ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import type { + WorkflowProcess, + WorkflowEvent, + WorkflowEventType +} from '../types/workflow.types.js'; +import { ProcessError } from '../errors/workflow.errors.js'; + +export interface ProcessSandboxConfig { + /** Claude Code executable path */ + claudeExecutable: string; + /** Default timeout for processes (minutes) */ + defaultTimeout: number; + /** Environment variables to pass to processes */ + environment?: Record; + /** Enable debug output */ + debug: boolean; +} + +export interface ProcessOptions { + /** Working directory for the process */ + cwd: string; + /** Environment variables (merged with config) */ + env?: Record; + /** Timeout in minutes (overrides default) */ + timeout?: number; + /** Additional Claude Code arguments */ + args?: string[]; +} + +/** + * ProcessSandbox manages Claude Code process lifecycle + * Single responsibility: Process spawning, monitoring, and cleanup + */ +export class ProcessSandbox extends EventEmitter { + private config: ProcessSandboxConfig; + private activeProcesses = new Map(); + private childProcesses = new Map(); + private timeouts = new Map(); + + constructor(config: ProcessSandboxConfig) { + super(); + this.config = config; + this.setupCleanupHandlers(); + } + + /** + * Start a Claude Code process for task execution + */ + async startProcess( + workflowId: string, + taskId: string, + taskPrompt: string, + options: ProcessOptions + ): Promise { + if (this.activeProcesses.has(workflowId)) { + throw new ProcessError( + `Process already running for workflow ${workflowId}` + ); + } + + // Prepare command and arguments + const args = [ + '-p', // Print mode for non-interactive execution + taskPrompt, + ...(options.args || []) + ]; + + // Prepare environment + const env = { + ...process.env, + ...this.config.environment, + ...options.env, + // Ensure task context is available + TASKMASTER_WORKFLOW_ID: workflowId, + TASKMASTER_TASK_ID: taskId + }; + + try { + // Spawn Claude Code process + const childProcess = spawn(this.config.claudeExecutable, args, { + cwd: options.cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const workflowProcess: WorkflowProcess = { + pid: childProcess.pid!, + command: this.config.claudeExecutable, + args, + cwd: options.cwd, + env, + startedAt: new Date(), + status: 'starting' + }; + + // Store process references + this.activeProcesses.set(workflowId, workflowProcess); + this.childProcesses.set(workflowId, childProcess); + + // Setup process event handlers + this.setupProcessHandlers(workflowId, taskId, childProcess); + + // Setup timeout if specified + const timeoutMinutes = options.timeout || this.config.defaultTimeout; + if (timeoutMinutes > 0) { + this.setupProcessTimeout(workflowId, timeoutMinutes); + } + + // Emit process started event + this.emitEvent('process.started', workflowId, taskId, { + pid: workflowProcess.pid, + command: workflowProcess.command + }); + + workflowProcess.status = 'running'; + return workflowProcess; + } catch (error) { + throw new ProcessError( + `Failed to start process for workflow ${workflowId}`, + undefined, + error as Error + ); + } + } + + /** + * Stop a running process + */ + async stopProcess(workflowId: string, force = false): Promise { + const process = this.activeProcesses.get(workflowId); + const childProcess = this.childProcesses.get(workflowId); + + if (!process || !childProcess) { + throw new ProcessError( + `No running process found for workflow ${workflowId}` + ); + } + + try { + // Clear timeout + const timeout = this.timeouts.get(workflowId); + if (timeout) { + clearTimeout(timeout); + this.timeouts.delete(workflowId); + } + + // Kill the process + if (force) { + childProcess.kill('SIGKILL'); + } else { + childProcess.kill('SIGTERM'); + + // Give it 5 seconds to gracefully exit, then force kill + setTimeout(() => { + if (!childProcess.killed) { + childProcess.kill('SIGKILL'); + } + }, 5000); + } + + process.status = 'stopped'; + + // Emit process stopped event + this.emitEvent('process.stopped', workflowId, process.pid.toString(), { + pid: process.pid, + forced: force + }); + } catch (error) { + throw new ProcessError( + `Failed to stop process for workflow ${workflowId}`, + process.pid, + error as Error + ); + } + } + + /** + * Send input to a running process + */ + async sendInput(workflowId: string, input: string): Promise { + const childProcess = this.childProcesses.get(workflowId); + if (!childProcess) { + throw new ProcessError( + `No running process found for workflow ${workflowId}` + ); + } + + try { + childProcess.stdin?.write(input); + childProcess.stdin?.write('\n'); + } catch (error) { + throw new ProcessError( + `Failed to send input to process for workflow ${workflowId}`, + childProcess.pid, + error as Error + ); + } + } + + /** + * Get process information + */ + getProcess(workflowId: string): WorkflowProcess | undefined { + return this.activeProcesses.get(workflowId); + } + + /** + * List all active processes + */ + listProcesses(): WorkflowProcess[] { + return Array.from(this.activeProcesses.values()); + } + + /** + * Check if a process is running + */ + isProcessRunning(workflowId: string): boolean { + const process = this.activeProcesses.get(workflowId); + return process?.status === 'running' || process?.status === 'starting'; + } + + /** + * Clean up all processes + */ + async cleanupAll(force = false): Promise { + const workflowIds = Array.from(this.activeProcesses.keys()); + + await Promise.all( + workflowIds.map(async (workflowId) => { + try { + await this.stopProcess(workflowId, force); + } catch (error) { + console.error( + `Failed to cleanup process for workflow ${workflowId}:`, + error + ); + } + }) + ); + } + + /** + * Setup process event handlers + */ + private setupProcessHandlers( + workflowId: string, + taskId: string, + childProcess: ChildProcess + ): void { + const process = this.activeProcesses.get(workflowId); + if (!process) return; + + // Handle stdout + childProcess.stdout?.on('data', (data) => { + const output = data.toString(); + if (this.config.debug) { + console.log(`[${workflowId}] STDOUT:`, output); + } + + this.emitEvent('process.output', workflowId, taskId, { + stream: 'stdout', + data: output + }); + }); + + // Handle stderr + childProcess.stderr?.on('data', (data) => { + const output = data.toString(); + if (this.config.debug) { + console.error(`[${workflowId}] STDERR:`, output); + } + + this.emitEvent('process.output', workflowId, taskId, { + stream: 'stderr', + data: output + }); + }); + + // Handle process exit + childProcess.on('exit', (code, signal) => { + process.status = code === 0 ? 'stopped' : 'crashed'; + + this.emitEvent('process.stopped', workflowId, taskId, { + pid: process.pid, + exitCode: code, + signal + }); + + // Cleanup + this.activeProcesses.delete(workflowId); + this.childProcesses.delete(workflowId); + + const timeout = this.timeouts.get(workflowId); + if (timeout) { + clearTimeout(timeout); + this.timeouts.delete(workflowId); + } + }); + + // Handle process errors + childProcess.on('error', (error) => { + process.status = 'crashed'; + + this.emitEvent('process.error', workflowId, taskId, undefined, error); + + // Cleanup + this.activeProcesses.delete(workflowId); + this.childProcesses.delete(workflowId); + }); + } + + /** + * Setup process timeout + */ + private setupProcessTimeout( + workflowId: string, + timeoutMinutes: number + ): void { + const timeout = setTimeout( + async () => { + console.warn(`Process timeout reached for workflow ${workflowId}`); + + try { + await this.stopProcess(workflowId, true); + } catch (error) { + console.error('Failed to stop timed out process:', error); + } + }, + timeoutMinutes * 60 * 1000 + ); + + this.timeouts.set(workflowId, timeout); + } + + /** + * Emit workflow event + */ + private emitEvent( + type: WorkflowEventType, + workflowId: string, + taskId: string, + data?: any, + error?: Error + ): void { + const event: WorkflowEvent = { + type, + workflowId, + taskId, + timestamp: new Date(), + data, + error + }; + + this.emit('event', event); + this.emit(type, event); + } + + /** + * Setup cleanup handlers for graceful shutdown + */ + private setupCleanupHandlers(): void { + const cleanup = () => { + console.log('Cleaning up processes...'); + this.cleanupAll(true).catch(console.error); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + process.on('exit', cleanup); + } +} diff --git a/packages/workflow-engine/src/state/index.ts b/packages/workflow-engine/src/state/index.ts new file mode 100644 index 00000000..424405b7 --- /dev/null +++ b/packages/workflow-engine/src/state/index.ts @@ -0,0 +1,6 @@ +/** + * @fileoverview State Management + * Public exports for workflow state operations + */ + +export * from './workflow-state-manager.js'; \ No newline at end of file diff --git a/packages/workflow-engine/src/state/workflow-state-manager.ts b/packages/workflow-engine/src/state/workflow-state-manager.ts new file mode 100644 index 00000000..6a59cd7e --- /dev/null +++ b/packages/workflow-engine/src/state/workflow-state-manager.ts @@ -0,0 +1,320 @@ +/** + * @fileoverview Workflow State Manager + * Extends tm-core RuntimeStateManager with workflow tracking capabilities + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + WorkflowExecutionContext, + WorkflowStatus, + WorkflowEvent +} from '../types/workflow.types.js'; +import { WorkflowError } from '../errors/workflow.errors.js'; + +export interface WorkflowStateConfig { + /** Project root directory */ + projectRoot: string; + /** Custom state directory (defaults to .taskmaster) */ + stateDir?: string; +} + +export interface WorkflowRegistryEntry { + /** Workflow ID */ + workflowId: string; + /** Task ID being executed */ + taskId: string; + /** Workflow status */ + status: WorkflowStatus; + /** Worktree path */ + worktreePath: string; + /** Process ID if running */ + processId?: number; + /** Start timestamp */ + startedAt: string; + /** Last activity timestamp */ + lastActivity: string; + /** Branch name */ + branchName: string; + /** Additional metadata */ + metadata?: Record; +} + +/** + * WorkflowStateManager manages workflow execution state + * Extends the concept of RuntimeStateManager to track active workflows globally + */ +export class WorkflowStateManager { + private config: WorkflowStateConfig; + private stateFilePath: string; + private activeWorkflows = new Map(); + + constructor(config: WorkflowStateConfig) { + this.config = config; + const stateDir = config.stateDir || '.taskmaster'; + this.stateFilePath = path.join(config.projectRoot, stateDir, 'workflows.json'); + } + + /** + * Load workflow state from disk + */ + async loadState(): Promise { + try { + const stateData = await fs.readFile(this.stateFilePath, 'utf-8'); + const registry = JSON.parse(stateData) as Record; + + // Convert registry entries to WorkflowExecutionContext + for (const [workflowId, entry] of Object.entries(registry)) { + const context: WorkflowExecutionContext = { + taskId: entry.taskId, + taskTitle: `Task ${entry.taskId}`, // Will be updated when task details are loaded + taskDescription: '', + projectRoot: this.config.projectRoot, + worktreePath: entry.worktreePath, + branchName: entry.branchName, + processId: entry.processId, + startedAt: new Date(entry.startedAt), + status: entry.status, + lastActivity: new Date(entry.lastActivity), + metadata: entry.metadata + }; + + this.activeWorkflows.set(workflowId, context); + } + + } catch (error: any) { + if (error.code === 'ENOENT') { + // Workflows file doesn't exist, start with empty state + console.debug('No workflows.json found, starting with empty state'); + return; + } + + console.warn('Failed to load workflow state:', error.message); + } + } + + /** + * Save workflow state to disk + */ + async saveState(): Promise { + const stateDir = path.dirname(this.stateFilePath); + + try { + await fs.mkdir(stateDir, { recursive: true }); + + // Convert contexts to registry entries + const registry: Record = {}; + + for (const [workflowId, context] of this.activeWorkflows.entries()) { + registry[workflowId] = { + workflowId, + taskId: context.taskId, + status: context.status, + worktreePath: context.worktreePath, + processId: context.processId, + startedAt: context.startedAt.toISOString(), + lastActivity: context.lastActivity.toISOString(), + branchName: context.branchName, + metadata: context.metadata + }; + } + + await fs.writeFile( + this.stateFilePath, + JSON.stringify(registry, null, 2), + 'utf-8' + ); + + } catch (error) { + throw new WorkflowError( + 'Failed to save workflow state', + 'WORKFLOW_STATE_SAVE_ERROR', + undefined, + undefined, + error as Error + ); + } + } + + /** + * Register a new workflow + */ + async registerWorkflow(context: WorkflowExecutionContext): Promise { + const workflowId = this.generateWorkflowId(context.taskId); + + this.activeWorkflows.set(workflowId, { + ...context, + lastActivity: new Date() + }); + + await this.saveState(); + return workflowId; + } + + /** + * Update workflow context + */ + async updateWorkflow( + workflowId: string, + updates: Partial + ): Promise { + const existing = this.activeWorkflows.get(workflowId); + if (!existing) { + throw new WorkflowError( + `Workflow ${workflowId} not found`, + 'WORKFLOW_NOT_FOUND', + workflowId + ); + } + + const updated = { + ...existing, + ...updates, + lastActivity: new Date() + }; + + this.activeWorkflows.set(workflowId, updated); + await this.saveState(); + } + + /** + * Update workflow status + */ + async updateWorkflowStatus(workflowId: string, status: WorkflowStatus): Promise { + await this.updateWorkflow(workflowId, { status }); + } + + /** + * Unregister a workflow (remove from state) + */ + async unregisterWorkflow(workflowId: string): Promise { + if (!this.activeWorkflows.has(workflowId)) { + throw new WorkflowError( + `Workflow ${workflowId} not found`, + 'WORKFLOW_NOT_FOUND', + workflowId + ); + } + + this.activeWorkflows.delete(workflowId); + await this.saveState(); + } + + /** + * Get workflow context by ID + */ + getWorkflow(workflowId: string): WorkflowExecutionContext | undefined { + return this.activeWorkflows.get(workflowId); + } + + /** + * Get workflow by task ID + */ + getWorkflowByTaskId(taskId: string): WorkflowExecutionContext | undefined { + for (const context of this.activeWorkflows.values()) { + if (context.taskId === taskId) { + return context; + } + } + return undefined; + } + + /** + * List all active workflows + */ + listWorkflows(): WorkflowExecutionContext[] { + return Array.from(this.activeWorkflows.values()); + } + + /** + * List workflows by status + */ + listWorkflowsByStatus(status: WorkflowStatus): WorkflowExecutionContext[] { + return this.listWorkflows().filter(w => w.status === status); + } + + /** + * Get running workflows count + */ + getRunningCount(): number { + return this.listWorkflowsByStatus('running').length; + } + + /** + * Check if a task has an active workflow + */ + hasActiveWorkflow(taskId: string): boolean { + return this.getWorkflowByTaskId(taskId) !== undefined; + } + + /** + * Clean up completed/failed workflows older than specified time + */ + async cleanupOldWorkflows(olderThanHours = 24): Promise { + const cutoffTime = new Date(Date.now() - (olderThanHours * 60 * 60 * 1000)); + let cleaned = 0; + + for (const [workflowId, context] of this.activeWorkflows.entries()) { + const isOld = context.lastActivity < cutoffTime; + const isFinished = ['completed', 'failed', 'cancelled', 'timeout'].includes(context.status); + + if (isOld && isFinished) { + this.activeWorkflows.delete(workflowId); + cleaned++; + } + } + + if (cleaned > 0) { + await this.saveState(); + } + + return cleaned; + } + + /** + * Clear all workflow state + */ + async clearState(): Promise { + try { + await fs.unlink(this.stateFilePath); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + this.activeWorkflows.clear(); + } + + /** + * Record workflow event (for audit trail) + */ + async recordEvent(event: WorkflowEvent): Promise { + // Update workflow last activity + const workflow = this.activeWorkflows.get(event.workflowId); + if (workflow) { + workflow.lastActivity = event.timestamp; + await this.saveState(); + } + + // Optional: Could extend to maintain event log file + if (process.env.TASKMASTER_DEBUG) { + console.log('Workflow Event:', { + type: event.type, + workflowId: event.workflowId, + taskId: event.taskId, + timestamp: event.timestamp.toISOString(), + data: event.data + }); + } + } + + /** + * Generate unique workflow ID + */ + private generateWorkflowId(taskId: string): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `workflow-${taskId}-${timestamp}-${random}`; + } +} \ No newline at end of file diff --git a/packages/workflow-engine/src/task-execution/index.ts b/packages/workflow-engine/src/task-execution/index.ts new file mode 100644 index 00000000..8bf7fcde --- /dev/null +++ b/packages/workflow-engine/src/task-execution/index.ts @@ -0,0 +1,6 @@ +/** + * @fileoverview Task Execution Management + * Public exports for task execution operations + */ + +export * from './task-execution-manager.js'; \ No newline at end of file diff --git a/packages/workflow-engine/src/task-execution/task-execution-manager.ts b/packages/workflow-engine/src/task-execution/task-execution-manager.ts new file mode 100644 index 00000000..b1d1d4e8 --- /dev/null +++ b/packages/workflow-engine/src/task-execution/task-execution-manager.ts @@ -0,0 +1,433 @@ +/** + * @fileoverview Task Execution Manager + * Orchestrates the complete task execution workflow using worktrees and processes + */ + +import { EventEmitter } from 'node:events'; +import path from 'node:path'; +import type { Task } from '@tm/core'; +import { + WorktreeManager, + type WorktreeManagerConfig +} from '../worktree/worktree-manager.js'; +import { + ProcessSandbox, + type ProcessSandboxConfig +} from '../process/process-sandbox.js'; +import { + WorkflowStateManager, + type WorkflowStateConfig +} from '../state/workflow-state-manager.js'; +import type { + WorkflowConfig, + WorkflowExecutionContext, + WorkflowStatus, + WorkflowEvent +} from '../types/workflow.types.js'; +import { + WorkflowError, + WorkflowNotFoundError, + MaxConcurrentWorkflowsError, + WorkflowTimeoutError +} from '../errors/workflow.errors.js'; + +export interface TaskExecutionManagerConfig extends WorkflowConfig { + /** Project root directory */ + projectRoot: string; +} + +/** + * TaskExecutionManager orchestrates the complete task execution workflow + * Coordinates worktree creation, process spawning, and state management + */ +export class TaskExecutionManager extends EventEmitter { + private config: TaskExecutionManagerConfig; + private worktreeManager: WorktreeManager; + private processSandbox: ProcessSandbox; + private stateManager: WorkflowStateManager; + private initialized = false; + + constructor(config: TaskExecutionManagerConfig) { + super(); + this.config = config; + + // Initialize component managers + const worktreeConfig: WorktreeManagerConfig = { + worktreeBase: config.worktreeBase, + projectRoot: config.projectRoot, + autoCleanup: true + }; + + const processConfig: ProcessSandboxConfig = { + claudeExecutable: config.claudeExecutable, + defaultTimeout: config.defaultTimeout, + debug: config.debug + }; + + const stateConfig: WorkflowStateConfig = { + projectRoot: config.projectRoot + }; + + this.worktreeManager = new WorktreeManager(worktreeConfig); + this.processSandbox = new ProcessSandbox(processConfig); + this.stateManager = new WorkflowStateManager(stateConfig); + + // Forward events from components + this.processSandbox.on('event', (event: WorkflowEvent) => { + this.stateManager.recordEvent(event); + this.emit('event', event); + }); + } + + /** + * Initialize the task execution manager + */ + async initialize(): Promise { + if (this.initialized) return; + + await this.stateManager.loadState(); + + // Clean up any stale workflows + await this.cleanupStaleWorkflows(); + + this.initialized = true; + } + + /** + * Start task execution workflow + */ + async startTaskExecution( + task: Task, + options?: { + branchName?: string; + timeout?: number; + env?: Record; + } + ): Promise { + if (!this.initialized) { + await this.initialize(); + } + + // Check concurrent workflow limit + const runningCount = this.stateManager.getRunningCount(); + if (runningCount >= this.config.maxConcurrent) { + throw new MaxConcurrentWorkflowsError(this.config.maxConcurrent); + } + + // Check if task already has an active workflow + if (this.stateManager.hasActiveWorkflow(task.id)) { + throw new WorkflowError( + `Task ${task.id} already has an active workflow`, + 'TASK_ALREADY_EXECUTING', + undefined, + task.id + ); + } + + try { + // Create worktree + const worktreeInfo = await this.worktreeManager.createWorktree( + task.id, + options?.branchName + ); + + // Prepare task context + const context: WorkflowExecutionContext = { + taskId: task.id, + taskTitle: task.title, + taskDescription: task.description, + taskDetails: task.details, + projectRoot: this.config.projectRoot, + worktreePath: worktreeInfo.path, + branchName: worktreeInfo.branch, + startedAt: new Date(), + status: 'initializing', + lastActivity: new Date(), + metadata: { + priority: task.priority, + dependencies: task.dependencies + } + }; + + // Register workflow + const workflowId = await this.stateManager.registerWorkflow(context); + + try { + // Prepare task prompt for Claude Code + const taskPrompt = this.generateTaskPrompt(task); + + // Start Claude Code process + const process = await this.processSandbox.startProcess( + workflowId, + task.id, + taskPrompt, + { + cwd: worktreeInfo.path, + timeout: options?.timeout, + env: options?.env + } + ); + + // Update workflow with process information + await this.stateManager.updateWorkflow(workflowId, { + processId: process.pid, + status: 'running' + }); + + // Emit workflow started event + this.emitEvent('workflow.started', workflowId, task.id, { + worktreePath: worktreeInfo.path, + processId: process.pid + }); + + return workflowId; + } catch (error) { + // Clean up worktree if process failed to start + await this.worktreeManager.removeWorktree(task.id, true); + await this.stateManager.unregisterWorkflow(workflowId); + throw error; + } + } catch (error) { + throw new WorkflowError( + `Failed to start task execution for ${task.id}`, + 'TASK_EXECUTION_START_ERROR', + undefined, + task.id, + error as Error + ); + } + } + + /** + * Stop task execution workflow + */ + async stopTaskExecution(workflowId: string, force = false): Promise { + const workflow = this.stateManager.getWorkflow(workflowId); + if (!workflow) { + throw new WorkflowNotFoundError(workflowId); + } + + try { + // Stop the process if running + if (this.processSandbox.isProcessRunning(workflowId)) { + await this.processSandbox.stopProcess(workflowId, force); + } + + // Update workflow status + const status: WorkflowStatus = force ? 'cancelled' : 'completed'; + await this.stateManager.updateWorkflowStatus(workflowId, status); + + // Clean up worktree + await this.worktreeManager.removeWorktree(workflow.taskId, force); + + // Emit workflow stopped event + this.emitEvent('workflow.completed', workflowId, workflow.taskId, { + status, + forced: force + }); + + // Unregister workflow + await this.stateManager.unregisterWorkflow(workflowId); + } catch (error) { + throw new WorkflowError( + `Failed to stop workflow ${workflowId}`, + 'WORKFLOW_STOP_ERROR', + workflowId, + workflow.taskId, + error as Error + ); + } + } + + /** + * Pause task execution + */ + async pauseTaskExecution(workflowId: string): Promise { + const workflow = this.stateManager.getWorkflow(workflowId); + if (!workflow) { + throw new WorkflowNotFoundError(workflowId); + } + + if (workflow.status !== 'running') { + throw new WorkflowError( + `Cannot pause workflow ${workflowId} - not currently running`, + 'WORKFLOW_NOT_RUNNING', + workflowId, + workflow.taskId + ); + } + + // For now, we'll just mark as paused - in the future could implement + // process suspension or other pause mechanisms + await this.stateManager.updateWorkflowStatus(workflowId, 'paused'); + + this.emitEvent('workflow.paused', workflowId, workflow.taskId); + } + + /** + * Resume paused task execution + */ + async resumeTaskExecution(workflowId: string): Promise { + const workflow = this.stateManager.getWorkflow(workflowId); + if (!workflow) { + throw new WorkflowNotFoundError(workflowId); + } + + if (workflow.status !== 'paused') { + throw new WorkflowError( + `Cannot resume workflow ${workflowId} - not currently paused`, + 'WORKFLOW_NOT_PAUSED', + workflowId, + workflow.taskId + ); + } + + await this.stateManager.updateWorkflowStatus(workflowId, 'running'); + + this.emitEvent('workflow.resumed', workflowId, workflow.taskId); + } + + /** + * Get workflow status + */ + getWorkflowStatus(workflowId: string): WorkflowExecutionContext | undefined { + return this.stateManager.getWorkflow(workflowId); + } + + /** + * Get workflow by task ID + */ + getWorkflowByTaskId(taskId: string): WorkflowExecutionContext | undefined { + return this.stateManager.getWorkflowByTaskId(taskId); + } + + /** + * List all workflows + */ + listWorkflows(): WorkflowExecutionContext[] { + return this.stateManager.listWorkflows(); + } + + /** + * List active workflows + */ + listActiveWorkflows(): WorkflowExecutionContext[] { + return this.stateManager.listWorkflowsByStatus('running'); + } + + /** + * Send input to a running workflow + */ + async sendInputToWorkflow(workflowId: string, input: string): Promise { + const workflow = this.stateManager.getWorkflow(workflowId); + if (!workflow) { + throw new WorkflowNotFoundError(workflowId); + } + + if (!this.processSandbox.isProcessRunning(workflowId)) { + throw new WorkflowError( + `Cannot send input to workflow ${workflowId} - process not running`, + 'PROCESS_NOT_RUNNING', + workflowId, + workflow.taskId + ); + } + + await this.processSandbox.sendInput(workflowId, input); + } + + /** + * Clean up all workflows + */ + async cleanup(force = false): Promise { + // Stop all processes + await this.processSandbox.cleanupAll(force); + + // Clean up all worktrees + await this.worktreeManager.cleanupAll(force); + + // Clear workflow state + await this.stateManager.clearState(); + } + + /** + * Generate task prompt for Claude Code + */ + private generateTaskPrompt(task: Task): string { + const prompt = [ + `Work on Task ${task.id}: ${task.title}`, + '', + `Description: ${task.description}` + ]; + + if (task.details) { + prompt.push('', `Details: ${task.details}`); + } + + if (task.testStrategy) { + prompt.push('', `Test Strategy: ${task.testStrategy}`); + } + + if (task.dependencies?.length) { + prompt.push('', `Dependencies: ${task.dependencies.join(', ')}`); + } + + prompt.push( + '', + 'Please implement this task following the project conventions and best practices.', + 'When complete, update the task status appropriately using the available Task Master commands.' + ); + + return prompt.join('\n'); + } + + /** + * Clean up stale workflows from previous sessions + */ + private async cleanupStaleWorkflows(): Promise { + const workflows = this.stateManager.listWorkflows(); + + for (const workflow of workflows) { + const isStale = + workflow.status === 'running' && + !this.processSandbox.isProcessRunning(`workflow-${workflow.taskId}`); + + if (isStale) { + console.log(`Cleaning up stale workflow for task ${workflow.taskId}`); + + try { + await this.stateManager.updateWorkflowStatus( + `workflow-${workflow.taskId}`, + 'failed' + ); + + // Try to clean up worktree + await this.worktreeManager.removeWorktree(workflow.taskId, true); + } catch (error) { + console.error(`Failed to cleanup stale workflow:`, error); + } + } + } + } + + /** + * Emit workflow event + */ + private emitEvent( + type: string, + workflowId: string, + taskId: string, + data?: any + ): void { + const event: WorkflowEvent = { + type: type as any, + workflowId, + taskId, + timestamp: new Date(), + data + }; + + this.emit('event', event); + this.emit(type, event); + } +} diff --git a/packages/workflow-engine/src/types/index.ts b/packages/workflow-engine/src/types/index.ts new file mode 100644 index 00000000..c8704dab --- /dev/null +++ b/packages/workflow-engine/src/types/index.ts @@ -0,0 +1,6 @@ +/** + * @fileoverview Workflow Engine Types + * Public type exports + */ + +export * from './workflow.types.js'; \ No newline at end of file diff --git a/packages/workflow-engine/src/types/workflow.types.ts b/packages/workflow-engine/src/types/workflow.types.ts new file mode 100644 index 00000000..39de0bff --- /dev/null +++ b/packages/workflow-engine/src/types/workflow.types.ts @@ -0,0 +1,119 @@ +/** + * @fileoverview Workflow Engine Types + * Core types for workflow execution system + */ + +export interface WorkflowConfig { + /** Maximum number of concurrent workflows */ + maxConcurrent: number; + /** Default timeout for workflow execution (minutes) */ + defaultTimeout: number; + /** Base directory for worktrees */ + worktreeBase: string; + /** Claude Code executable path */ + claudeExecutable: string; + /** Enable debug logging */ + debug: boolean; +} + +export interface WorkflowExecutionContext { + /** Task ID being executed */ + taskId: string; + /** Task title for display */ + taskTitle: string; + /** Full task description */ + taskDescription: string; + /** Task implementation details */ + taskDetails?: string; + /** Project root path */ + projectRoot: string; + /** Worktree path */ + worktreePath: string; + /** Branch name for this workflow */ + branchName: string; + /** Process ID of running Claude Code */ + processId?: number; + /** Workflow start time */ + startedAt: Date; + /** Workflow status */ + status: WorkflowStatus; + /** Last activity timestamp */ + lastActivity: Date; + /** Execution metadata */ + metadata?: Record; +} + +export type WorkflowStatus = + | 'pending' // Created but not started + | 'initializing' // Setting up worktree/process + | 'running' // Active execution + | 'paused' // Temporarily stopped + | 'completed' // Successfully finished + | 'failed' // Error occurred + | 'cancelled' // User cancelled + | 'timeout'; // Exceeded time limit + +export interface WorkflowEvent { + type: WorkflowEventType; + workflowId: string; + taskId: string; + timestamp: Date; + data?: any; + error?: Error; +} + +export type WorkflowEventType = + | 'workflow.created' + | 'workflow.started' + | 'workflow.paused' + | 'workflow.resumed' + | 'workflow.completed' + | 'workflow.failed' + | 'workflow.cancelled' + | 'worktree.created' + | 'worktree.deleted' + | 'process.started' + | 'process.stopped' + | 'process.output' + | 'process.error'; + +export interface WorkflowProcess { + /** Process ID */ + pid: number; + /** Command that was executed */ + command: string; + /** Command arguments */ + args: string[]; + /** Working directory */ + cwd: string; + /** Environment variables */ + env?: Record; + /** Process start time */ + startedAt: Date; + /** Process status */ + status: ProcessStatus; +} + +export type ProcessStatus = + | 'starting' + | 'running' + | 'stopped' + | 'crashed' + | 'killed'; + +export interface WorktreeInfo { + /** Worktree path */ + path: string; + /** Branch name */ + branch: string; + /** Creation timestamp */ + createdAt: Date; + /** Associated task ID */ + taskId: string; + /** Git commit hash */ + commit?: string; + /** Worktree lock status */ + locked: boolean; + /** Lock reason if applicable */ + lockReason?: string; +} \ No newline at end of file diff --git a/packages/workflow-engine/src/worktree/index.ts b/packages/workflow-engine/src/worktree/index.ts new file mode 100644 index 00000000..7fb3e7f5 --- /dev/null +++ b/packages/workflow-engine/src/worktree/index.ts @@ -0,0 +1,6 @@ +/** + * @fileoverview Worktree Management + * Public exports for worktree operations + */ + +export * from './worktree-manager.js'; \ No newline at end of file diff --git a/packages/workflow-engine/src/worktree/worktree-manager.ts b/packages/workflow-engine/src/worktree/worktree-manager.ts new file mode 100644 index 00000000..fc2c9f0d --- /dev/null +++ b/packages/workflow-engine/src/worktree/worktree-manager.ts @@ -0,0 +1,351 @@ +/** + * @fileoverview Worktree Manager + * Manages git worktree lifecycle for task execution + */ + +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { WorktreeInfo } from '../types/workflow.types.js'; +import { WorktreeError } from '../errors/workflow.errors.js'; + +export interface WorktreeManagerConfig { + /** Base directory for all worktrees */ + worktreeBase: string; + /** Project root directory */ + projectRoot: string; + /** Auto-cleanup on process exit */ + autoCleanup: boolean; +} + +/** + * WorktreeManager handles git worktree operations + * Single responsibility: Git worktree lifecycle management + */ +export class WorktreeManager { + private config: WorktreeManagerConfig; + private activeWorktrees = new Map(); + + constructor(config: WorktreeManagerConfig) { + this.config = config; + + if (config.autoCleanup) { + this.setupCleanupHandlers(); + } + } + + /** + * Create a new worktree for task execution + */ + async createWorktree(taskId: string, branchName?: string): Promise { + const sanitizedTaskId = this.sanitizeTaskId(taskId); + const worktreePath = path.join(this.config.worktreeBase, `task-${sanitizedTaskId}`); + + // Ensure base directory exists + await fs.mkdir(this.config.worktreeBase, { recursive: true }); + + // Generate unique branch name if not provided + const branch = branchName || `task/${sanitizedTaskId}-${Date.now()}`; + + try { + // Check if worktree path already exists + if (await this.pathExists(worktreePath)) { + throw new WorktreeError(`Worktree path already exists: ${worktreePath}`); + } + + // Create the worktree + await this.executeGitCommand(['worktree', 'add', '-b', branch, worktreePath], { + cwd: this.config.projectRoot + }); + + const worktreeInfo: WorktreeInfo = { + path: worktreePath, + branch, + createdAt: new Date(), + taskId, + locked: false + }; + + // Get commit hash + try { + const commit = await this.executeGitCommand(['rev-parse', 'HEAD'], { + cwd: worktreePath + }); + worktreeInfo.commit = commit.trim(); + } catch (error) { + console.warn('Failed to get commit hash for worktree:', error); + } + + this.activeWorktrees.set(taskId, worktreeInfo); + return worktreeInfo; + + } catch (error) { + throw new WorktreeError( + `Failed to create worktree for task ${taskId}`, + worktreePath, + error as Error + ); + } + } + + /** + * Remove a worktree and clean up + */ + async removeWorktree(taskId: string, force = false): Promise { + const worktreeInfo = this.activeWorktrees.get(taskId); + if (!worktreeInfo) { + throw new WorktreeError(`No active worktree found for task ${taskId}`); + } + + try { + // Remove the worktree + const args = ['worktree', 'remove', worktreeInfo.path]; + if (force) { + args.push('--force'); + } + + await this.executeGitCommand(args, { + cwd: this.config.projectRoot + }); + + // Remove branch if it's a task-specific branch + if (worktreeInfo.branch.startsWith('task/')) { + try { + await this.executeGitCommand(['branch', '-D', worktreeInfo.branch], { + cwd: this.config.projectRoot + }); + } catch (error) { + console.warn(`Failed to delete branch ${worktreeInfo.branch}:`, error); + } + } + + this.activeWorktrees.delete(taskId); + + } catch (error) { + throw new WorktreeError( + `Failed to remove worktree for task ${taskId}`, + worktreeInfo.path, + error as Error + ); + } + } + + /** + * List all active worktrees for this project + */ + async listWorktrees(): Promise { + try { + const output = await this.executeGitCommand(['worktree', 'list', '--porcelain'], { + cwd: this.config.projectRoot + }); + + const worktrees: WorktreeInfo[] = []; + const lines = output.trim().split('\n'); + + let currentWorktree: Partial = {}; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + if (currentWorktree.path) { + // Complete previous worktree + worktrees.push(this.completeWorktreeInfo(currentWorktree)); + } + currentWorktree = { path: line.substring(9) }; + } else if (line.startsWith('HEAD ')) { + currentWorktree.commit = line.substring(5); + } else if (line.startsWith('branch ')) { + currentWorktree.branch = line.substring(7).replace('refs/heads/', ''); + } else if (line === 'locked') { + currentWorktree.locked = true; + } else if (line.startsWith('locked ')) { + currentWorktree.locked = true; + currentWorktree.lockReason = line.substring(7); + } + } + + // Add the last worktree + if (currentWorktree.path) { + worktrees.push(this.completeWorktreeInfo(currentWorktree)); + } + + // Filter to only our task worktrees + return worktrees.filter(wt => + wt.path.startsWith(this.config.worktreeBase) && + wt.branch?.startsWith('task/') + ); + + } catch (error) { + throw new WorktreeError('Failed to list worktrees', undefined, error as Error); + } + } + + /** + * Get worktree info for a specific task + */ + getWorktreeInfo(taskId: string): WorktreeInfo | undefined { + return this.activeWorktrees.get(taskId); + } + + /** + * Lock a worktree to prevent cleanup + */ + async lockWorktree(taskId: string, reason?: string): Promise { + const worktreeInfo = this.activeWorktrees.get(taskId); + if (!worktreeInfo) { + throw new WorktreeError(`No active worktree found for task ${taskId}`); + } + + try { + const args = ['worktree', 'lock', worktreeInfo.path]; + if (reason) { + args.push('--reason', reason); + } + + await this.executeGitCommand(args, { + cwd: this.config.projectRoot + }); + + worktreeInfo.locked = true; + worktreeInfo.lockReason = reason; + + } catch (error) { + throw new WorktreeError( + `Failed to lock worktree for task ${taskId}`, + worktreeInfo.path, + error as Error + ); + } + } + + /** + * Unlock a worktree + */ + async unlockWorktree(taskId: string): Promise { + const worktreeInfo = this.activeWorktrees.get(taskId); + if (!worktreeInfo) { + throw new WorktreeError(`No active worktree found for task ${taskId}`); + } + + try { + await this.executeGitCommand(['worktree', 'unlock', worktreeInfo.path], { + cwd: this.config.projectRoot + }); + + worktreeInfo.locked = false; + delete worktreeInfo.lockReason; + + } catch (error) { + throw new WorktreeError( + `Failed to unlock worktree for task ${taskId}`, + worktreeInfo.path, + error as Error + ); + } + } + + /** + * Clean up all task-related worktrees + */ + async cleanupAll(force = false): Promise { + const worktrees = await this.listWorktrees(); + + for (const worktree of worktrees) { + if (worktree.taskId) { + try { + await this.removeWorktree(worktree.taskId, force); + } catch (error) { + console.error(`Failed to cleanup worktree for task ${worktree.taskId}:`, error); + } + } + } + } + + /** + * Execute git command and return output + */ + private async executeGitCommand( + args: string[], + options: { cwd: string } + ): Promise { + return new Promise((resolve, reject) => { + const git = spawn('git', args, { + cwd: options.cwd, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + git.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + git.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + git.on('close', (code) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(`Git command failed (${code}): ${stderr || stdout}`)); + } + }); + + git.on('error', (error) => { + reject(error); + }); + }); + } + + /** + * Sanitize task ID for use in filesystem paths + */ + private sanitizeTaskId(taskId: string): string { + return taskId.replace(/[^a-zA-Z0-9.-]/g, '-'); + } + + /** + * Check if path exists + */ + private async pathExists(path: string): Promise { + try { + await fs.access(path); + return true; + } catch { + return false; + } + } + + /** + * Complete worktree info with defaults + */ + private completeWorktreeInfo(partial: Partial): WorktreeInfo { + const branch = partial.branch || 'unknown'; + const taskIdMatch = branch.match(/^task\/(.+?)-/); + + return { + path: partial.path || '', + branch, + createdAt: partial.createdAt || new Date(), + taskId: taskIdMatch?.[1] || partial.taskId || 'unknown', + commit: partial.commit, + locked: partial.locked || false, + lockReason: partial.lockReason + }; + } + + /** + * Setup cleanup handlers for graceful shutdown + */ + private setupCleanupHandlers(): void { + const cleanup = () => { + console.log('Cleaning up worktrees...'); + this.cleanupAll(true).catch(console.error); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + process.on('exit', cleanup); + } +} \ No newline at end of file diff --git a/packages/workflow-engine/tsconfig.json b/packages/workflow-engine/tsconfig.json new file mode 100644 index 00000000..812b505d --- /dev/null +++ b/packages/workflow-engine/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "dist", + "node_modules", + "**/*.test.ts", + "**/*.spec.ts" + ] +} \ No newline at end of file diff --git a/packages/workflow-engine/tsup.config.ts b/packages/workflow-engine/tsup.config.ts new file mode 100644 index 00000000..a5783c9f --- /dev/null +++ b/packages/workflow-engine/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: [ + 'src/index.ts', + 'src/task-execution/index.ts', + 'src/worktree/index.ts', + 'src/process/index.ts', + 'src/state/index.ts' + ], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + splitting: false, + treeshake: true +}); \ No newline at end of file diff --git a/packages/workflow-engine/vitest.config.ts b/packages/workflow-engine/vitest.config.ts new file mode 100644 index 00000000..263a8fd9 --- /dev/null +++ b/packages/workflow-engine/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + '**/*.d.ts', + '**/*.test.ts', + '**/*.spec.ts' + ] + } + } +}); \ No newline at end of file diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index baf818c9..fa398f83 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -15,8 +15,8 @@ import search from '@inquirer/search'; import ora from 'ora'; // Import ora import { log, readJSON } from './utils.js'; -// Import new commands from @tm/cli -import { ListTasksCommand, AuthCommand, ContextCommand } from '@tm/cli'; +// Import command registry from @tm/cli +import { registerAllCommands } from '@tm/cli'; import { parsePRD, @@ -1737,17 +1737,9 @@ function registerCommands(programInstance) { }); }); - // NEW: Register the new list command from @tm/cli - // This command handles all its own configuration and logic - ListTasksCommand.registerOn(programInstance); - - // Register the auth command from @tm/cli - // Handles authentication with tryhamster.com - AuthCommand.registerOn(programInstance); - - // Register the context command from @tm/cli - // Manages workspace context (org/brief selection) - ContextCommand.registerOn(programInstance); + // Register all commands from @tm/cli using the command registry + // This automatically registers ListTasksCommand, AuthCommand, and any future commands + registerAllCommands(programInstance); // expand command programInstance