wip
This commit is contained in:
@@ -61,6 +61,11 @@
|
||||
"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": {
|
||||
@@ -78,6 +83,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.57.0",
|
||||
"@tm/workflow-engine": "*",
|
||||
"chalk": "^5.3.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { ERROR_CODES, TaskMasterError } from './errors/task-master-error.js';
|
||||
import type { IConfiguration } from './interfaces/configuration.interface.js';
|
||||
import type { Task, TaskStatus, TaskFilter } from './types/index.js';
|
||||
import { WorkflowService, type WorkflowServiceConfig } from './workflow/index.js';
|
||||
|
||||
/**
|
||||
* Options for creating TaskMasterCore instance
|
||||
@@ -18,6 +19,7 @@ import type { Task, TaskStatus, TaskFilter } from './types/index.js';
|
||||
export interface TaskMasterCoreOptions {
|
||||
projectPath: string;
|
||||
configuration?: Partial<IConfiguration>;
|
||||
workflow?: Partial<WorkflowServiceConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +35,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
|
||||
@@ -55,6 +58,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +85,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',
|
||||
@@ -170,11 +196,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<void> {
|
||||
// TaskService handles storage cleanup internally
|
||||
if (this.workflowService) {
|
||||
await this.workflowService.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
17
packages/tm-core/src/workflow/index.ts
Normal file
17
packages/tm-core/src/workflow/index.ts
Normal file
@@ -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';
|
||||
218
packages/tm-core/src/workflow/workflow-service.ts
Normal file
218
packages/tm-core/src/workflow/workflow-service.ts
Normal file
@@ -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<Task>
|
||||
) {
|
||||
|
||||
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<void> {
|
||||
await this.workflowEngine.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a workflow for a task
|
||||
*/
|
||||
async start(
|
||||
taskId: string,
|
||||
options?: {
|
||||
branchName?: string;
|
||||
timeout?: number;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.cleanup(true);
|
||||
this.workflowEngine.removeAllListeners();
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ export default defineConfig({
|
||||
'services/index': 'src/services/index.ts',
|
||||
'storage/index': 'src/storage/index.ts',
|
||||
'types/index': 'src/types/index.ts',
|
||||
'utils/index': 'src/utils/index.ts'
|
||||
'utils/index': 'src/utils/index.ts',
|
||||
'workflow/index': 'src/workflow/index.ts'
|
||||
},
|
||||
format: ['cjs', 'esm'],
|
||||
dts: true,
|
||||
|
||||
371
packages/workflow-engine/README.md
Normal file
371
packages/workflow-engine/README.md
Normal file
@@ -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
|
||||
56
packages/workflow-engine/package.json
Normal file
56
packages/workflow-engine/package.json
Normal file
@@ -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"
|
||||
}
|
||||
6
packages/workflow-engine/src/errors/index.ts
Normal file
6
packages/workflow-engine/src/errors/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Workflow Engine Errors
|
||||
* Public error exports
|
||||
*/
|
||||
|
||||
export * from './workflow.errors.js';
|
||||
59
packages/workflow-engine/src/errors/workflow.errors.ts
Normal file
59
packages/workflow-engine/src/errors/workflow.errors.ts
Normal file
@@ -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';
|
||||
}
|
||||
}
|
||||
19
packages/workflow-engine/src/index.ts
Normal file
19
packages/workflow-engine/src/index.ts
Normal file
@@ -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';
|
||||
6
packages/workflow-engine/src/process/index.ts
Normal file
6
packages/workflow-engine/src/process/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Process Management
|
||||
* Public exports for process operations
|
||||
*/
|
||||
|
||||
export * from './process-sandbox.js';
|
||||
378
packages/workflow-engine/src/process/process-sandbox.ts
Normal file
378
packages/workflow-engine/src/process/process-sandbox.ts
Normal file
@@ -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<string, string>;
|
||||
/** Enable debug output */
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
export interface ProcessOptions {
|
||||
/** Working directory for the process */
|
||||
cwd: string;
|
||||
/** Environment variables (merged with config) */
|
||||
env?: Record<string, string>;
|
||||
/** 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<string, WorkflowProcess>();
|
||||
private childProcesses = new Map<string, ChildProcess>();
|
||||
private timeouts = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
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<WorkflowProcess> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
6
packages/workflow-engine/src/state/index.ts
Normal file
6
packages/workflow-engine/src/state/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @fileoverview State Management
|
||||
* Public exports for workflow state operations
|
||||
*/
|
||||
|
||||
export * from './workflow-state-manager.js';
|
||||
320
packages/workflow-engine/src/state/workflow-state-manager.ts
Normal file
320
packages/workflow-engine/src/state/workflow-state-manager.ts
Normal file
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, WorkflowExecutionContext>();
|
||||
|
||||
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<void> {
|
||||
try {
|
||||
const stateData = await fs.readFile(this.stateFilePath, 'utf-8');
|
||||
const registry = JSON.parse(stateData) as Record<string, WorkflowRegistryEntry>;
|
||||
|
||||
// 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<void> {
|
||||
const stateDir = path.dirname(this.stateFilePath);
|
||||
|
||||
try {
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
|
||||
// Convert contexts to registry entries
|
||||
const registry: Record<string, WorkflowRegistryEntry> = {};
|
||||
|
||||
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<string> {
|
||||
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<WorkflowExecutionContext>
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
await this.updateWorkflow(workflowId, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a workflow (remove from state)
|
||||
*/
|
||||
async unregisterWorkflow(workflowId: string): Promise<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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}`;
|
||||
}
|
||||
}
|
||||
6
packages/workflow-engine/src/task-execution/index.ts
Normal file
6
packages/workflow-engine/src/task-execution/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Task Execution Management
|
||||
* Public exports for task execution operations
|
||||
*/
|
||||
|
||||
export * from './task-execution-manager.js';
|
||||
@@ -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<void> {
|
||||
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<string, string>;
|
||||
}
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
6
packages/workflow-engine/src/types/index.ts
Normal file
6
packages/workflow-engine/src/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Workflow Engine Types
|
||||
* Public type exports
|
||||
*/
|
||||
|
||||
export * from './workflow.types.js';
|
||||
119
packages/workflow-engine/src/types/workflow.types.ts
Normal file
119
packages/workflow-engine/src/types/workflow.types.ts
Normal file
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
/** 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;
|
||||
}
|
||||
6
packages/workflow-engine/src/worktree/index.ts
Normal file
6
packages/workflow-engine/src/worktree/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Worktree Management
|
||||
* Public exports for worktree operations
|
||||
*/
|
||||
|
||||
export * from './worktree-manager.js';
|
||||
351
packages/workflow-engine/src/worktree/worktree-manager.ts
Normal file
351
packages/workflow-engine/src/worktree/worktree-manager.ts
Normal file
@@ -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<string, WorktreeInfo>();
|
||||
|
||||
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<WorktreeInfo> {
|
||||
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<void> {
|
||||
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<WorktreeInfo[]> {
|
||||
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<WorktreeInfo> = {};
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await fs.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete worktree info with defaults
|
||||
*/
|
||||
private completeWorktreeInfo(partial: Partial<WorktreeInfo>): 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);
|
||||
}
|
||||
}
|
||||
19
packages/workflow-engine/tsconfig.json
Normal file
19
packages/workflow-engine/tsconfig.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
17
packages/workflow-engine/tsup.config.ts
Normal file
17
packages/workflow-engine/tsup.config.ts
Normal file
@@ -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
|
||||
});
|
||||
19
packages/workflow-engine/vitest.config.ts
Normal file
19
packages/workflow-engine/vitest.config.ts
Normal file
@@ -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'
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user