From e9b366fa18f752575c3fb5c5a390bfc204bcd19f Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 27 Dec 2025 23:57:15 -0500 Subject: [PATCH] feat: implement pipeline feature for automated workflow steps - Introduced a new pipeline service to manage custom workflow steps that execute after a feature is marked "In Progress". - Added API endpoints for configuring, saving, adding, updating, deleting, and reordering pipeline steps. - Enhanced the UI to support pipeline settings, including a dialog for managing steps and integration with the Kanban board. - Updated the application state management to handle pipeline configurations per project. - Implemented dynamic column generation in the Kanban board to display pipeline steps between "In Progress" and "Waiting Approval". - Added documentation for the new pipeline feature, including usage instructions and configuration details. This feature allows for a more structured workflow, enabling automated processes such as code reviews and testing after feature implementation. --- CLAUDE.md | 172 ++++ apps/server/src/index.ts | 3 + apps/server/src/routes/pipeline/common.ts | 21 + apps/server/src/routes/pipeline/index.ts | 77 ++ .../src/routes/pipeline/routes/add-step.ts | 54 ++ .../src/routes/pipeline/routes/delete-step.ts | 42 + .../src/routes/pipeline/routes/get-config.ts | 35 + .../routes/pipeline/routes/reorder-steps.ts | 42 + .../src/routes/pipeline/routes/save-config.ts | 43 + .../src/routes/pipeline/routes/update-step.ts | 50 ++ apps/server/src/services/auto-mode-service.ts | 157 +++- apps/server/src/services/pipeline-service.ts | 320 ++++++++ apps/ui/src/components/views/board-view.tsx | 47 ++ .../components/views/board-view/constants.ts | 73 +- .../dialogs/dependency-tree-dialog.tsx | 4 +- .../dialogs/pipeline-settings-dialog.tsx | 736 ++++++++++++++++++ .../hooks/use-board-column-features.ts | 70 +- .../board-view/hooks/use-board-features.ts | 5 + .../views/board-view/kanban-board.tsx | 43 +- .../views/graph-view/components/task-node.tsx | 5 +- apps/ui/src/hooks/use-responsive-kanban.ts | 8 +- apps/ui/src/lib/http-api-client.ts | 96 +++ apps/ui/src/main.ts | 31 +- apps/ui/src/store/app-store.ts | 126 ++- apps/ui/src/types/electron.d.ts | 18 + docs/pipeline-feature.md | 156 ++++ libs/types/src/index.ts | 8 + libs/types/src/pipeline.ts | 28 + 28 files changed, 2409 insertions(+), 61 deletions(-) create mode 100644 CLAUDE.md create mode 100644 apps/server/src/routes/pipeline/common.ts create mode 100644 apps/server/src/routes/pipeline/index.ts create mode 100644 apps/server/src/routes/pipeline/routes/add-step.ts create mode 100644 apps/server/src/routes/pipeline/routes/delete-step.ts create mode 100644 apps/server/src/routes/pipeline/routes/get-config.ts create mode 100644 apps/server/src/routes/pipeline/routes/reorder-steps.ts create mode 100644 apps/server/src/routes/pipeline/routes/save-config.ts create mode 100644 apps/server/src/routes/pipeline/routes/update-step.ts create mode 100644 apps/server/src/services/pipeline-service.ts create mode 100644 apps/ui/src/components/views/board-view/dialogs/pipeline-settings-dialog.tsx create mode 100644 docs/pipeline-feature.md create mode 100644 libs/types/src/pipeline.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..40664601 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,172 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Automaker is an autonomous AI development studio built as an npm workspace monorepo. It provides a Kanban-based workflow where AI agents (powered by Claude Agent SDK) implement features in isolated git worktrees. + +## Common Commands + +```bash +# Development +npm run dev # Interactive launcher (choose web or electron) +npm run dev:web # Web browser mode (localhost:3007) +npm run dev:electron # Desktop app mode +npm run dev:electron:debug # Desktop with DevTools open + +# Building +npm run build # Build web application +npm run build:packages # Build all shared packages (required before other builds) +npm run build:electron # Build desktop app for current platform +npm run build:server # Build server only + +# Testing +npm run test # E2E tests (Playwright, headless) +npm run test:headed # E2E tests with browser visible +npm run test:server # Server unit tests (Vitest) +npm run test:packages # All shared package tests +npm run test:all # All tests (packages + server) + +# Single test file +npm run test:server -- tests/unit/specific.test.ts + +# Linting and formatting +npm run lint # ESLint +npm run format # Prettier write +npm run format:check # Prettier check +``` + +## Architecture + +### Monorepo Structure + +``` +automaker/ +├── apps/ +│ ├── ui/ # React + Vite + Electron frontend (port 3007) +│ └── server/ # Express + WebSocket backend (port 3008) +└── libs/ # Shared packages (@automaker/*) + ├── types/ # Core TypeScript definitions (no dependencies) + ├── utils/ # Logging, errors, image processing, context loading + ├── prompts/ # AI prompt templates + ├── platform/ # Path management, security, process spawning + ├── model-resolver/ # Claude model alias resolution + ├── dependency-resolver/ # Feature dependency ordering + └── git-utils/ # Git operations & worktree management +``` + +### Package Dependency Chain + +Packages can only depend on packages above them: + +``` +@automaker/types (no dependencies) + ↓ +@automaker/utils, @automaker/prompts, @automaker/platform, @automaker/model-resolver, @automaker/dependency-resolver + ↓ +@automaker/git-utils + ↓ +@automaker/server, @automaker/ui +``` + +### Key Technologies + +- **Frontend**: React 19, Vite 7, Electron 39, TanStack Router, Zustand 5, Tailwind CSS 4 +- **Backend**: Express 5, WebSocket (ws), Claude Agent SDK, node-pty +- **Testing**: Playwright (E2E), Vitest (unit) + +### Server Architecture + +The server (`apps/server/src/`) follows a modular pattern: + +- `routes/` - Express route handlers organized by feature (agent, features, auto-mode, worktree, etc.) +- `services/` - Business logic (AgentService, AutoModeService, FeatureLoader, TerminalService) +- `providers/` - AI provider abstraction (currently Claude via Claude Agent SDK) +- `lib/` - Utilities (events, auth, worktree metadata) + +### Frontend Architecture + +The UI (`apps/ui/src/`) uses: + +- `routes/` - TanStack Router file-based routing +- `components/views/` - Main view components (board, settings, terminal, etc.) +- `store/` - Zustand stores with persistence (app-store.ts, setup-store.ts) +- `hooks/` - Custom React hooks +- `lib/` - Utilities and API client + +## Data Storage + +### Per-Project Data (`.automaker/`) + +``` +.automaker/ +├── features/ # Feature JSON files and images +│ └── {featureId}/ +│ ├── feature.json +│ ├── agent-output.md +│ └── images/ +├── context/ # Context files for AI agents (CLAUDE.md, etc.) +├── settings.json # Project-specific settings +├── spec.md # Project specification +└── analysis.json # Project structure analysis +``` + +### Global Data (`DATA_DIR`, default `./data`) + +``` +data/ +├── settings.json # Global settings, profiles, shortcuts +├── credentials.json # API keys +├── sessions-metadata.json # Chat session metadata +└── agent-sessions/ # Conversation histories +``` + +## Import Conventions + +Always import from shared packages, never from old paths: + +```typescript +// ✅ Correct +import type { Feature, ExecuteOptions } from '@automaker/types'; +import { createLogger, classifyError } from '@automaker/utils'; +import { getEnhancementPrompt } from '@automaker/prompts'; +import { getFeatureDir, ensureAutomakerDir } from '@automaker/platform'; +import { resolveModelString } from '@automaker/model-resolver'; +import { resolveDependencies } from '@automaker/dependency-resolver'; +import { getGitRepositoryDiffs } from '@automaker/git-utils'; + +// ❌ Never import from old paths +import { Feature } from '../services/feature-loader'; // Wrong +import { createLogger } from '../lib/logger'; // Wrong +``` + +## Key Patterns + +### Event-Driven Architecture + +All server operations emit events that stream to the frontend via WebSocket. Events are created using `createEventEmitter()` from `lib/events.ts`. + +### Git Worktree Isolation + +Each feature executes in an isolated git worktree, created via `@automaker/git-utils`. This protects the main branch during AI agent execution. + +### Context Files + +Project-specific rules are stored in `.automaker/context/` and automatically loaded into agent prompts via `loadContextFiles()` from `@automaker/utils`. + +### Model Resolution + +Use `resolveModelString()` from `@automaker/model-resolver` to convert model aliases: + +- `haiku` → `claude-haiku-4-5` +- `sonnet` → `claude-sonnet-4-20250514` +- `opus` → `claude-opus-4-5-20251101` + +## Environment Variables + +- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth) +- `PORT` - Server port (default: 3008) +- `DATA_DIR` - Data storage directory (default: ./data) +- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory +- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 188e2883..72999c13 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -50,6 +50,8 @@ import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; import { cleanupStaleValidations } from './routes/github/routes/validation-common.js'; +import { createPipelineRoutes } from './routes/pipeline/index.js'; +import { pipelineService } from './services/pipeline-service.js'; // Load environment variables dotenv.config(); @@ -162,6 +164,7 @@ app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); +app.use('/api/pipeline', createPipelineRoutes(pipelineService)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/pipeline/common.ts b/apps/server/src/routes/pipeline/common.ts new file mode 100644 index 00000000..26aa3e10 --- /dev/null +++ b/apps/server/src/routes/pipeline/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for pipeline routes + * + * Provides logger and error handling utilities shared across all pipeline endpoints. + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +/** Logger instance for pipeline-related operations */ +export const logger = createLogger('Pipeline'); + +/** + * Extract user-friendly error message from error objects + */ +export { getErrorMessageShared as getErrorMessage }; + +/** + * Log error with automatic logger binding + */ +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/pipeline/index.ts b/apps/server/src/routes/pipeline/index.ts new file mode 100644 index 00000000..86880379 --- /dev/null +++ b/apps/server/src/routes/pipeline/index.ts @@ -0,0 +1,77 @@ +/** + * Pipeline routes - HTTP API for pipeline configuration management + * + * Provides endpoints for: + * - Getting pipeline configuration + * - Saving pipeline configuration + * - Adding, updating, deleting, and reordering pipeline steps + * + * All endpoints use handler factories that receive the PipelineService instance. + * Mounted at /api/pipeline in the main server. + */ + +import { Router } from 'express'; +import type { PipelineService } from '../../services/pipeline-service.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createGetConfigHandler } from './routes/get-config.js'; +import { createSaveConfigHandler } from './routes/save-config.js'; +import { createAddStepHandler } from './routes/add-step.js'; +import { createUpdateStepHandler } from './routes/update-step.js'; +import { createDeleteStepHandler } from './routes/delete-step.js'; +import { createReorderStepsHandler } from './routes/reorder-steps.js'; + +/** + * Create pipeline router with all endpoints + * + * Endpoints: + * - POST /config - Get pipeline configuration + * - POST /config/save - Save entire pipeline configuration + * - POST /steps/add - Add a new pipeline step + * - POST /steps/update - Update an existing pipeline step + * - POST /steps/delete - Delete a pipeline step + * - POST /steps/reorder - Reorder pipeline steps + * + * @param pipelineService - Instance of PipelineService for file I/O + * @returns Express Router configured with all pipeline endpoints + */ +export function createPipelineRoutes(pipelineService: PipelineService): Router { + const router = Router(); + + // Get pipeline configuration + router.post( + '/config', + validatePathParams('projectPath'), + createGetConfigHandler(pipelineService) + ); + + // Save entire pipeline configuration + router.post( + '/config/save', + validatePathParams('projectPath'), + createSaveConfigHandler(pipelineService) + ); + + // Pipeline step operations + router.post( + '/steps/add', + validatePathParams('projectPath'), + createAddStepHandler(pipelineService) + ); + router.post( + '/steps/update', + validatePathParams('projectPath'), + createUpdateStepHandler(pipelineService) + ); + router.post( + '/steps/delete', + validatePathParams('projectPath'), + createDeleteStepHandler(pipelineService) + ); + router.post( + '/steps/reorder', + validatePathParams('projectPath'), + createReorderStepsHandler(pipelineService) + ); + + return router; +} diff --git a/apps/server/src/routes/pipeline/routes/add-step.ts b/apps/server/src/routes/pipeline/routes/add-step.ts new file mode 100644 index 00000000..a9494e3e --- /dev/null +++ b/apps/server/src/routes/pipeline/routes/add-step.ts @@ -0,0 +1,54 @@ +/** + * POST /api/pipeline/steps/add - Add a new pipeline step + * + * Adds a new step to the pipeline configuration. + * + * Request body: { projectPath: string, step: { name, order, instructions, colorClass } } + * Response: { success: true, step: PipelineStep } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import type { PipelineStep } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createAddStepHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, step } = req.body as { + projectPath: string; + step: Omit; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!step) { + res.status(400).json({ success: false, error: 'step is required' }); + return; + } + + if (!step.name) { + res.status(400).json({ success: false, error: 'step.name is required' }); + return; + } + + if (step.instructions === undefined) { + res.status(400).json({ success: false, error: 'step.instructions is required' }); + return; + } + + const newStep = await pipelineService.addStep(projectPath, step); + + res.json({ + success: true, + step: newStep, + }); + } catch (error) { + logError(error, 'Add pipeline step failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/pipeline/routes/delete-step.ts b/apps/server/src/routes/pipeline/routes/delete-step.ts new file mode 100644 index 00000000..56b6c753 --- /dev/null +++ b/apps/server/src/routes/pipeline/routes/delete-step.ts @@ -0,0 +1,42 @@ +/** + * POST /api/pipeline/steps/delete - Delete a pipeline step + * + * Removes a step from the pipeline configuration. + * + * Request body: { projectPath: string, stepId: string } + * Response: { success: true } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createDeleteStepHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, stepId } = req.body as { + projectPath: string; + stepId: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!stepId) { + res.status(400).json({ success: false, error: 'stepId is required' }); + return; + } + + await pipelineService.deleteStep(projectPath, stepId); + + res.json({ + success: true, + }); + } catch (error) { + logError(error, 'Delete pipeline step failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/pipeline/routes/get-config.ts b/apps/server/src/routes/pipeline/routes/get-config.ts new file mode 100644 index 00000000..2e37735e --- /dev/null +++ b/apps/server/src/routes/pipeline/routes/get-config.ts @@ -0,0 +1,35 @@ +/** + * POST /api/pipeline/config - Get pipeline configuration + * + * Returns the pipeline configuration for a project. + * + * Request body: { projectPath: string } + * Response: { success: true, config: PipelineConfig } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createGetConfigHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const config = await pipelineService.getPipelineConfig(projectPath); + + res.json({ + success: true, + config, + }); + } catch (error) { + logError(error, 'Get pipeline config failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/pipeline/routes/reorder-steps.ts b/apps/server/src/routes/pipeline/routes/reorder-steps.ts new file mode 100644 index 00000000..d51be11c --- /dev/null +++ b/apps/server/src/routes/pipeline/routes/reorder-steps.ts @@ -0,0 +1,42 @@ +/** + * POST /api/pipeline/steps/reorder - Reorder pipeline steps + * + * Reorders the steps in the pipeline configuration. + * + * Request body: { projectPath: string, stepIds: string[] } + * Response: { success: true } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createReorderStepsHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, stepIds } = req.body as { + projectPath: string; + stepIds: string[]; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!stepIds || !Array.isArray(stepIds)) { + res.status(400).json({ success: false, error: 'stepIds array is required' }); + return; + } + + await pipelineService.reorderSteps(projectPath, stepIds); + + res.json({ + success: true, + }); + } catch (error) { + logError(error, 'Reorder pipeline steps failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/pipeline/routes/save-config.ts b/apps/server/src/routes/pipeline/routes/save-config.ts new file mode 100644 index 00000000..d414e5b0 --- /dev/null +++ b/apps/server/src/routes/pipeline/routes/save-config.ts @@ -0,0 +1,43 @@ +/** + * POST /api/pipeline/config/save - Save entire pipeline configuration + * + * Saves the complete pipeline configuration for a project. + * + * Request body: { projectPath: string, config: PipelineConfig } + * Response: { success: true } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import type { PipelineConfig } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSaveConfigHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, config } = req.body as { + projectPath: string; + config: PipelineConfig; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!config) { + res.status(400).json({ success: false, error: 'config is required' }); + return; + } + + await pipelineService.savePipelineConfig(projectPath, config); + + res.json({ + success: true, + }); + } catch (error) { + logError(error, 'Save pipeline config failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/pipeline/routes/update-step.ts b/apps/server/src/routes/pipeline/routes/update-step.ts new file mode 100644 index 00000000..22ad944d --- /dev/null +++ b/apps/server/src/routes/pipeline/routes/update-step.ts @@ -0,0 +1,50 @@ +/** + * POST /api/pipeline/steps/update - Update an existing pipeline step + * + * Updates a step in the pipeline configuration. + * + * Request body: { projectPath: string, stepId: string, updates: Partial } + * Response: { success: true, step: PipelineStep } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import type { PipelineStep } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createUpdateStepHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, stepId, updates } = req.body as { + projectPath: string; + stepId: string; + updates: Partial>; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!stepId) { + res.status(400).json({ success: false, error: 'stepId is required' }); + return; + } + + if (!updates || Object.keys(updates).length === 0) { + res.status(400).json({ success: false, error: 'updates is required' }); + return; + } + + const updatedStep = await pipelineService.updateStep(projectPath, stepId, updates); + + res.json({ + success: true, + step: updatedStep, + }); + } catch (error) { + logError(error, 'Update pipeline step failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 987554f2..557fc79c 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -10,7 +10,7 @@ */ import { ProviderFactory } from '../providers/provider-factory.js'; -import type { ExecuteOptions, Feature } from '@automaker/types'; +import type { ExecuteOptions, Feature, PipelineConfig, PipelineStep } from '@automaker/types'; import { buildPromptWithImages, isAbortError, @@ -32,6 +32,7 @@ import { } from '../lib/sdk-options.js'; import { FeatureLoader } from './feature-loader.js'; import type { SettingsService } from './settings-service.js'; +import { pipelineService, PipelineService } from './pipeline-service.js'; import { getAutoLoadClaudeMdSetting, getEnableSandboxModeSetting, @@ -631,6 +632,23 @@ export class AutoModeService { } ); + // Check for pipeline steps and execute them + const pipelineConfig = await pipelineService.getPipelineConfig(projectPath); + const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order); + + if (sortedSteps.length > 0) { + // Execute pipeline steps sequentially + await this.executePipelineSteps( + projectPath, + featureId, + feature, + sortedSteps, + workDir, + abortController, + autoLoadClaudeMd + ); + } + // Determine final status based on testing mode: // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) // - skipTests=true (manual verification): go to 'waiting_approval' for manual review @@ -674,6 +692,143 @@ export class AutoModeService { } } + /** + * Execute pipeline steps sequentially after initial feature implementation + */ + private async executePipelineSteps( + projectPath: string, + featureId: string, + feature: Feature, + steps: PipelineStep[], + workDir: string, + abortController: AbortController, + autoLoadClaudeMd: boolean + ): Promise { + console.log(`[AutoMode] Executing ${steps.length} pipeline step(s) for feature ${featureId}`); + + // Load context files once + const contextResult = await loadContextFiles({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + }); + const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); + + // Load previous agent output for context continuity + const featureDir = getFeatureDir(projectPath, featureId); + const contextPath = path.join(featureDir, 'agent-output.md'); + let previousContext = ''; + try { + previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; + } catch { + // No previous context + } + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const pipelineStatus = `pipeline_${step.id}`; + + // Update feature status to current pipeline step + await this.updateFeatureStatus(projectPath, featureId, pipelineStatus); + + this.emitAutoModeEvent('auto_mode_progress', { + featureId, + content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`, + projectPath, + }); + + this.emitAutoModeEvent('pipeline_step_started', { + featureId, + stepId: step.id, + stepName: step.name, + stepIndex: i, + totalSteps: steps.length, + projectPath, + }); + + // Build prompt for this pipeline step + const prompt = this.buildPipelineStepPrompt(step, feature, previousContext); + + // Get model from feature + const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); + + // Run the agent for this pipeline step + await this.runAgent( + workDir, + featureId, + prompt, + abortController, + projectPath, + undefined, // no images for pipeline steps + model, + { + projectPath, + planningMode: 'skip', // Pipeline steps don't need planning + requirePlanApproval: false, + previousContent: previousContext, + systemPrompt: contextFilesPrompt || undefined, + autoLoadClaudeMd, + } + ); + + // Load updated context for next step + try { + previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; + } catch { + // No context update + } + + this.emitAutoModeEvent('pipeline_step_complete', { + featureId, + stepId: step.id, + stepName: step.name, + stepIndex: i, + totalSteps: steps.length, + projectPath, + }); + + console.log( + `[AutoMode] Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}` + ); + } + + console.log(`[AutoMode] All pipeline steps completed for feature ${featureId}`); + } + + /** + * Build the prompt for a pipeline step + */ + private buildPipelineStepPrompt( + step: PipelineStep, + feature: Feature, + previousContext: string + ): string { + let prompt = `## Pipeline Step: ${step.name} + +This is an automated pipeline step following the initial feature implementation. + +### Feature Context +${this.buildFeaturePrompt(feature)} + +`; + + if (previousContext) { + prompt += `### Previous Work +The following is the output from the previous work on this feature: + +${previousContext} + +`; + } + + prompt += `### Pipeline Step Instructions +${step.instructions} + +### Task +Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`; + + return prompt; + } + /** * Stop a specific feature */ diff --git a/apps/server/src/services/pipeline-service.ts b/apps/server/src/services/pipeline-service.ts new file mode 100644 index 00000000..407f34ce --- /dev/null +++ b/apps/server/src/services/pipeline-service.ts @@ -0,0 +1,320 @@ +/** + * Pipeline Service - Handles reading/writing pipeline configuration + * + * Provides persistent storage for: + * - Pipeline configuration ({projectPath}/.automaker/pipeline.json) + */ + +import path from 'path'; +import { createLogger } from '@automaker/utils'; +import * as secureFs from '../lib/secure-fs.js'; +import { ensureAutomakerDir } from '@automaker/platform'; +import type { PipelineConfig, PipelineStep, FeatureStatusWithPipeline } from '@automaker/types'; + +const logger = createLogger('PipelineService'); + +// Default empty pipeline config +const DEFAULT_PIPELINE_CONFIG: PipelineConfig = { + version: 1, + steps: [], +}; + +/** + * Atomic file write - write to temp file then rename + */ +async function atomicWriteJson(filePath: string, data: unknown): Promise { + const tempPath = `${filePath}.tmp.${Date.now()}`; + const content = JSON.stringify(data, null, 2); + + try { + await secureFs.writeFile(tempPath, content, 'utf-8'); + await secureFs.rename(tempPath, filePath); + } catch (error) { + // Clean up temp file if it exists + try { + await secureFs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +/** + * Safely read JSON file with fallback to default + */ +async function readJsonFile(filePath: string, defaultValue: T): Promise { + try { + const content = (await secureFs.readFile(filePath, 'utf-8')) as string; + return JSON.parse(content) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return defaultValue; + } + logger.error(`Error reading ${filePath}:`, error); + return defaultValue; + } +} + +/** + * Generate a unique ID for pipeline steps + */ +function generateStepId(): string { + return `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`; +} + +/** + * Get the pipeline config file path for a project + */ +function getPipelineConfigPath(projectPath: string): string { + return path.join(projectPath, '.automaker', 'pipeline.json'); +} + +/** + * PipelineService - Manages pipeline configuration for workflow automation + * + * Handles reading and writing pipeline config to JSON files with atomic operations. + * Pipeline steps define custom columns that appear between "in_progress" and + * "waiting_approval/verified" columns in the kanban board. + */ +export class PipelineService { + /** + * Get pipeline configuration for a project + * + * @param projectPath - Absolute path to the project + * @returns Promise resolving to PipelineConfig (empty steps array if no config exists) + */ + async getPipelineConfig(projectPath: string): Promise { + const configPath = getPipelineConfigPath(projectPath); + const config = await readJsonFile(configPath, DEFAULT_PIPELINE_CONFIG); + + // Ensure version is set + return { + ...DEFAULT_PIPELINE_CONFIG, + ...config, + }; + } + + /** + * Save entire pipeline configuration + * + * @param projectPath - Absolute path to the project + * @param config - Complete PipelineConfig to save + */ + async savePipelineConfig(projectPath: string, config: PipelineConfig): Promise { + await ensureAutomakerDir(projectPath); + const configPath = getPipelineConfigPath(projectPath); + await atomicWriteJson(configPath, config); + logger.info(`Pipeline config saved for project: ${projectPath}`); + } + + /** + * Add a new pipeline step + * + * @param projectPath - Absolute path to the project + * @param step - Step data (without id, createdAt, updatedAt) + * @returns Promise resolving to the created PipelineStep + */ + async addStep( + projectPath: string, + step: Omit + ): Promise { + const config = await this.getPipelineConfig(projectPath); + const now = new Date().toISOString(); + + const newStep: PipelineStep = { + ...step, + id: generateStepId(), + createdAt: now, + updatedAt: now, + }; + + config.steps.push(newStep); + + // Normalize order values + config.steps.sort((a, b) => a.order - b.order); + config.steps.forEach((s, index) => { + s.order = index; + }); + + await this.savePipelineConfig(projectPath, config); + logger.info(`Pipeline step added: ${newStep.name} (${newStep.id})`); + + return newStep; + } + + /** + * Update an existing pipeline step + * + * @param projectPath - Absolute path to the project + * @param stepId - ID of the step to update + * @param updates - Partial step data to merge + */ + async updateStep( + projectPath: string, + stepId: string, + updates: Partial> + ): Promise { + const config = await this.getPipelineConfig(projectPath); + const stepIndex = config.steps.findIndex((s) => s.id === stepId); + + if (stepIndex === -1) { + throw new Error(`Pipeline step not found: ${stepId}`); + } + + config.steps[stepIndex] = { + ...config.steps[stepIndex], + ...updates, + updatedAt: new Date().toISOString(), + }; + + await this.savePipelineConfig(projectPath, config); + logger.info(`Pipeline step updated: ${stepId}`); + + return config.steps[stepIndex]; + } + + /** + * Delete a pipeline step + * + * @param projectPath - Absolute path to the project + * @param stepId - ID of the step to delete + */ + async deleteStep(projectPath: string, stepId: string): Promise { + const config = await this.getPipelineConfig(projectPath); + const stepIndex = config.steps.findIndex((s) => s.id === stepId); + + if (stepIndex === -1) { + throw new Error(`Pipeline step not found: ${stepId}`); + } + + config.steps.splice(stepIndex, 1); + + // Normalize order values after deletion + config.steps.forEach((s, index) => { + s.order = index; + }); + + await this.savePipelineConfig(projectPath, config); + logger.info(`Pipeline step deleted: ${stepId}`); + } + + /** + * Reorder pipeline steps + * + * @param projectPath - Absolute path to the project + * @param stepIds - Array of step IDs in the desired order + */ + async reorderSteps(projectPath: string, stepIds: string[]): Promise { + const config = await this.getPipelineConfig(projectPath); + + // Validate all step IDs exist + const existingIds = new Set(config.steps.map((s) => s.id)); + for (const id of stepIds) { + if (!existingIds.has(id)) { + throw new Error(`Pipeline step not found: ${id}`); + } + } + + // Create a map for quick lookup + const stepMap = new Map(config.steps.map((s) => [s.id, s])); + + // Reorder steps based on stepIds array + config.steps = stepIds.map((id, index) => { + const step = stepMap.get(id)!; + return { ...step, order: index, updatedAt: new Date().toISOString() }; + }); + + await this.savePipelineConfig(projectPath, config); + logger.info(`Pipeline steps reordered`); + } + + /** + * Get the next status in the pipeline flow + * + * Determines what status a feature should transition to based on current status. + * Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status + * + * @param currentStatus - Current feature status + * @param config - Pipeline configuration (or null if no pipeline) + * @param skipTests - Whether to skip tests (affects final status) + * @returns The next status in the pipeline flow + */ + getNextStatus( + currentStatus: FeatureStatusWithPipeline, + config: PipelineConfig | null, + skipTests: boolean + ): FeatureStatusWithPipeline { + const steps = config?.steps || []; + + // Sort steps by order + const sortedSteps = [...steps].sort((a, b) => a.order - b.order); + + // If no pipeline steps, use original logic + if (sortedSteps.length === 0) { + if (currentStatus === 'in_progress') { + return skipTests ? 'waiting_approval' : 'verified'; + } + return currentStatus; + } + + // Coming from in_progress -> go to first pipeline step + if (currentStatus === 'in_progress') { + return `pipeline_${sortedSteps[0].id}`; + } + + // Coming from a pipeline step -> go to next step or final status + if (currentStatus.startsWith('pipeline_')) { + const currentStepId = currentStatus.replace('pipeline_', ''); + const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId); + + if (currentIndex === -1) { + // Step not found, go to final status + return skipTests ? 'waiting_approval' : 'verified'; + } + + if (currentIndex < sortedSteps.length - 1) { + // Go to next step + return `pipeline_${sortedSteps[currentIndex + 1].id}`; + } + + // Last step completed, go to final status + return skipTests ? 'waiting_approval' : 'verified'; + } + + // For other statuses, don't change + return currentStatus; + } + + /** + * Get a specific pipeline step by ID + * + * @param projectPath - Absolute path to the project + * @param stepId - ID of the step to retrieve + * @returns The pipeline step or null if not found + */ + async getStep(projectPath: string, stepId: string): Promise { + const config = await this.getPipelineConfig(projectPath); + return config.steps.find((s) => s.id === stepId) || null; + } + + /** + * Check if a status is a pipeline status + */ + isPipelineStatus(status: FeatureStatusWithPipeline): boolean { + return status.startsWith('pipeline_'); + } + + /** + * Extract step ID from a pipeline status + */ + getStepIdFromStatus(status: FeatureStatusWithPipeline): string | null { + if (!this.isPipelineStatus(status)) { + return null; + } + return status.replace('pipeline_', ''); + } +} + +// Export singleton instance +export const pipelineService = new PipelineService(); diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 0541de9f..2c39c1fe 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -8,6 +8,7 @@ import { } from '@dnd-kit/core'; import { useAppStore, Feature } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; +import { getHttpApiClient } from '@/lib/http-api-client'; import type { AutoModeEvent } from '@/types/electron'; import type { BacklogPlanResult } from '@automaker/types'; import { pathsEqual } from '@/lib/utils'; @@ -36,6 +37,7 @@ import { FollowUpDialog, PlanApprovalDialog, } from './board-view/dialogs'; +import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog'; import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog'; import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog'; import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog'; @@ -85,7 +87,10 @@ export function BoardView() { enableDependencyBlocking, isPrimaryWorktreeBranch, getPrimaryWorktreeBranch, + setPipelineConfig, } = useAppStore(); + // Subscribe to pipelineConfigByProject to trigger re-renders when it changes + const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject); const shortcuts = useKeyboardShortcutsConfig(); const { features: hookFeatures, @@ -130,6 +135,9 @@ export function BoardView() { const [pendingBacklogPlan, setPendingBacklogPlan] = useState(null); const [isGeneratingPlan, setIsGeneratingPlan] = useState(false); + // Pipeline settings dialog state + const [showPipelineSettings, setShowPipelineSettings] = useState(false); + // Follow-up state hook const { showFollowUpDialog, @@ -201,6 +209,25 @@ export function BoardView() { setFeaturesWithContext, }); + // Load pipeline config when project changes + useEffect(() => { + if (!currentProject?.path) return; + + const loadPipelineConfig = async () => { + try { + const api = getHttpApiClient(); + const result = await api.pipeline.getConfig(currentProject.path); + if (result.success && result.config) { + setPipelineConfig(currentProject.path, result.config); + } + } catch (error) { + console.error('[Board] Failed to load pipeline config:', error); + } + }; + + loadPipelineConfig(); + }, [currentProject?.path, setPipelineConfig]); + // Auto mode hook const autoMode = useAutoMode(); // Get runningTasks from the hook (scoped to current project) @@ -1094,6 +1121,10 @@ export function BoardView() { onShowSuggestions={() => setShowSuggestionsDialog(true)} suggestionsCount={suggestionsCount} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} + pipelineConfig={ + currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null + } + onOpenPipelineSettings={() => setShowPipelineSettings(true)} /> ) : ( + {/* Pipeline Settings Dialog */} + setShowPipelineSettings(false)} + projectPath={currentProject.path} + pipelineConfig={pipelineConfigByProject[currentProject.path] || null} + onSave={async (config) => { + const api = getHttpApiClient(); + const result = await api.pipeline.saveConfig(currentProject.path, config); + if (!result.success) { + throw new Error(result.error || 'Failed to save pipeline config'); + } + setPipelineConfig(currentProject.path, config); + }} + /> + {/* Follow-Up Prompt Dialog */} a.order - b.order); + + // Convert pipeline steps to columns (filter out invalid steps) + const pipelineColumns: Column[] = sortedSteps + .filter((step) => step && step.id) // Only include valid steps with an id + .map((step) => ({ + id: `pipeline_${step.id}` as FeatureStatusWithPipeline, + title: step.name || 'Pipeline Step', + colorClass: step.colorClass || 'bg-[var(--status-in-progress)]', + isPipelineStep: true, + pipelineStepId: step.id, + })); + + return [...BASE_COLUMNS, ...pipelineColumns, ...END_COLUMNS]; +} + +/** + * Get the index where pipeline columns should be inserted + * (after in_progress, before waiting_approval) + */ +export function getPipelineInsertIndex(): number { + return BASE_COLUMNS.length; +} + +/** + * Check if a status is a pipeline status + */ +export function isPipelineStatus(status: string): boolean { + return status.startsWith('pipeline_'); +} + +/** + * Extract step ID from a pipeline status + */ +export function getStepIdFromStatus(status: string): string | null { + if (!isPipelineStatus(status)) { + return null; + } + return status.replace('pipeline_', ''); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/dependency-tree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/dependency-tree-dialog.tsx index 9e85b5f2..41afb787 100644 --- a/apps/ui/src/components/views/board-view/dialogs/dependency-tree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/dependency-tree-dialog.tsx @@ -131,7 +131,7 @@ export function DependencyTreeDialog({ : 'bg-muted text-muted-foreground' )} > - {dep.status.replace(/_/g, ' ')} + {(dep.status || 'backlog').replace(/_/g, ' ')} @@ -177,7 +177,7 @@ export function DependencyTreeDialog({ : 'bg-muted text-muted-foreground' )} > - {dependent.status.replace(/_/g, ' ')} + {(dependent.status || 'backlog').replace(/_/g, ' ')} diff --git a/apps/ui/src/components/views/board-view/dialogs/pipeline-settings-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/pipeline-settings-dialog.tsx new file mode 100644 index 00000000..fa79b543 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/pipeline-settings-dialog.tsx @@ -0,0 +1,736 @@ +import { useState, useRef, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react'; +import { toast } from 'sonner'; +import type { PipelineConfig, PipelineStep } from '@automaker/types'; +import { cn } from '@/lib/utils'; + +// Color options for pipeline columns +const COLOR_OPTIONS = [ + { value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' }, + { value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' }, + { value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' }, + { value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' }, + { value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' }, + { value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' }, + { value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' }, + { value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' }, + { value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' }, +]; + +// Pre-built step templates with well-designed prompts +const STEP_TEMPLATES = [ + { + id: 'code-review', + name: 'Code Review', + colorClass: 'bg-blue-500/20', + instructions: `## Code Review + +Please perform a thorough code review of the changes made in this feature. Focus on: + +### Code Quality +- **Readability**: Is the code easy to understand? Are variable/function names descriptive? +- **Maintainability**: Will this code be easy to modify in the future? +- **DRY Principle**: Is there any duplicated code that should be abstracted? +- **Single Responsibility**: Do functions and classes have a single, clear purpose? + +### Best Practices +- Follow established patterns and conventions used in the codebase +- Ensure proper error handling is in place +- Check for appropriate logging where needed +- Verify that magic numbers/strings are replaced with named constants + +### Performance +- Identify any potential performance bottlenecks +- Check for unnecessary re-renders (React) or redundant computations +- Ensure efficient data structures are used + +### Testing +- Verify that new code has appropriate test coverage +- Check that edge cases are handled + +### Action Required +After reviewing, make any necessary improvements directly. If you find issues: +1. Fix them immediately if they are straightforward +2. For complex issues, document them clearly with suggested solutions + +Provide a brief summary of changes made or issues found.`, + }, + { + id: 'security-review', + name: 'Security Review', + colorClass: 'bg-red-500/20', + instructions: `## Security Review + +Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas: + +### Input Validation & Sanitization +- Verify all user inputs are properly validated and sanitized +- Check for SQL injection vulnerabilities +- Check for XSS (Cross-Site Scripting) vulnerabilities +- Ensure proper encoding of output data + +### Authentication & Authorization +- Verify authentication checks are in place where needed +- Ensure authorization logic correctly restricts access +- Check for privilege escalation vulnerabilities +- Verify session management is secure + +### Data Protection +- Ensure sensitive data is not logged or exposed +- Check that secrets/credentials are not hardcoded +- Verify proper encryption is used for sensitive data +- Check for secure transmission of data (HTTPS, etc.) + +### Common Vulnerabilities (OWASP Top 10) +- Injection flaws +- Broken authentication +- Sensitive data exposure +- XML External Entities (XXE) +- Broken access control +- Security misconfiguration +- Cross-Site Scripting (XSS) +- Insecure deserialization +- Using components with known vulnerabilities +- Insufficient logging & monitoring + +### Action Required +1. Fix any security vulnerabilities immediately +2. For complex security issues, document them with severity levels +3. Add security-related comments where appropriate + +Provide a security assessment summary with any issues found and fixes applied.`, + }, + { + id: 'testing', + name: 'Testing', + colorClass: 'bg-green-500/20', + instructions: `## Testing Step + +Please ensure comprehensive test coverage for the changes made in this feature. + +### Unit Tests +- Write unit tests for all new functions and methods +- Ensure edge cases are covered +- Test error handling paths +- Aim for high code coverage on new code + +### Integration Tests +- Test interactions between components/modules +- Verify API endpoints work correctly +- Test database operations if applicable + +### Test Quality +- Tests should be readable and well-documented +- Each test should have a clear purpose +- Use descriptive test names that explain the scenario +- Follow the Arrange-Act-Assert pattern + +### Run Tests +After writing tests, run the full test suite and ensure: +1. All new tests pass +2. No existing tests are broken +3. Test coverage meets project standards + +Provide a summary of tests added and any issues found during testing.`, + }, + { + id: 'documentation', + name: 'Documentation', + colorClass: 'bg-amber-500/20', + instructions: `## Documentation Step + +Please ensure all changes are properly documented. + +### Code Documentation +- Add/update JSDoc or docstrings for new functions and classes +- Document complex algorithms or business logic +- Add inline comments for non-obvious code + +### API Documentation +- Document any new or modified API endpoints +- Include request/response examples +- Document error responses + +### README Updates +- Update README if new setup steps are required +- Document any new environment variables +- Update architecture diagrams if applicable + +### Changelog +- Document notable changes for the changelog +- Include breaking changes if any + +Provide a summary of documentation added or updated.`, + }, + { + id: 'optimization', + name: 'Performance Optimization', + colorClass: 'bg-cyan-500/20', + instructions: `## Performance Optimization Step + +Review and optimize the performance of the changes made in this feature. + +### Code Performance +- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.) +- Remove unnecessary computations or redundant operations +- Optimize loops and iterations +- Use appropriate data structures + +### Memory Usage +- Check for memory leaks +- Optimize memory-intensive operations +- Ensure proper cleanup of resources + +### Database/API +- Optimize database queries (add indexes, reduce N+1 queries) +- Implement caching where appropriate +- Batch API calls when possible + +### Frontend (if applicable) +- Minimize bundle size +- Optimize render performance +- Implement lazy loading where appropriate +- Use memoization for expensive computations + +### Action Required +1. Profile the code to identify bottlenecks +2. Apply optimizations +3. Measure improvements + +Provide a summary of optimizations applied and performance improvements achieved.`, + }, +]; + +// Helper to get template color class +const getTemplateColorClass = (templateId: string): string => { + const template = STEP_TEMPLATES.find((t) => t.id === templateId); + return template?.colorClass || COLOR_OPTIONS[0].value; +}; + +interface PipelineSettingsDialogProps { + open: boolean; + onClose: () => void; + projectPath: string; + pipelineConfig: PipelineConfig | null; + onSave: (config: PipelineConfig) => Promise; +} + +interface EditingStep { + id?: string; + name: string; + instructions: string; + colorClass: string; + order: number; +} + +export function PipelineSettingsDialog({ + open, + onClose, + projectPath, + pipelineConfig, + onSave, +}: PipelineSettingsDialogProps) { + // Filter and validate steps to ensure all required properties exist + const validateSteps = (steps: PipelineStep[] | undefined): PipelineStep[] => { + if (!Array.isArray(steps)) return []; + return steps.filter( + (step): step is PipelineStep => + step != null && + typeof step.id === 'string' && + typeof step.name === 'string' && + typeof step.instructions === 'string' + ); + }; + + const [steps, setSteps] = useState(() => validateSteps(pipelineConfig?.steps)); + const [editingStep, setEditingStep] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + + // Sync steps when dialog opens or pipelineConfig changes + useEffect(() => { + if (open) { + setSteps(validateSteps(pipelineConfig?.steps)); + } + }, [open, pipelineConfig]); + + const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const handleAddStep = () => { + setEditingStep({ + name: '', + instructions: '', + colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value, + order: steps.length, + }); + }; + + const handleEditStep = (step: PipelineStep) => { + setEditingStep({ + id: step.id, + name: step.name, + instructions: step.instructions, + colorClass: step.colorClass, + order: step.order, + }); + }; + + const handleDeleteStep = (stepId: string) => { + const newSteps = steps.filter((s) => s.id !== stepId); + // Reorder remaining steps + newSteps.forEach((s, index) => { + s.order = index; + }); + setSteps(newSteps); + }; + + const handleMoveStep = (stepId: string, direction: 'up' | 'down') => { + const stepIndex = sortedSteps.findIndex((s) => s.id === stepId); + if ( + (direction === 'up' && stepIndex === 0) || + (direction === 'down' && stepIndex === sortedSteps.length - 1) + ) { + return; + } + + const newSteps = [...sortedSteps]; + const targetIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1; + + // Swap orders + const temp = newSteps[stepIndex].order; + newSteps[stepIndex].order = newSteps[targetIndex].order; + newSteps[targetIndex].order = temp; + + setSteps(newSteps); + }; + + const handleFileUpload = () => { + fileInputRef.current?.click(); + }; + + const handleFileInputChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const content = await file.text(); + setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null)); + toast.success('Instructions loaded from file'); + } catch (error) { + toast.error('Failed to load file'); + } + + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleSaveStep = () => { + if (!editingStep) return; + + if (!editingStep.name.trim()) { + toast.error('Step name is required'); + return; + } + + if (!editingStep.instructions.trim()) { + toast.error('Step instructions are required'); + return; + } + + const now = new Date().toISOString(); + + if (editingStep.id) { + // Update existing step + setSteps((prev) => + prev.map((s) => + s.id === editingStep.id + ? { + ...s, + name: editingStep.name, + instructions: editingStep.instructions, + colorClass: editingStep.colorClass, + updatedAt: now, + } + : s + ) + ); + } else { + // Add new step + const newStep: PipelineStep = { + id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`, + name: editingStep.name, + instructions: editingStep.instructions, + colorClass: editingStep.colorClass, + order: steps.length, + createdAt: now, + updatedAt: now, + }; + setSteps((prev) => [...prev, newStep]); + } + + setEditingStep(null); + }; + + const handleSaveConfig = async () => { + setIsSubmitting(true); + try { + // If the user is currently editing a step and clicks "Save Configuration", + // include that step in the config (common expectation) instead of silently dropping it. + let effectiveSteps = steps; + if (editingStep) { + if (!editingStep.name.trim()) { + toast.error('Step name is required'); + return; + } + + if (!editingStep.instructions.trim()) { + toast.error('Step instructions are required'); + return; + } + + const now = new Date().toISOString(); + if (editingStep.id) { + // Update existing (or add if missing for some reason) + const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id); + if (existingIdx >= 0) { + effectiveSteps = effectiveSteps.map((s) => + s.id === editingStep.id + ? { + ...s, + name: editingStep.name, + instructions: editingStep.instructions, + colorClass: editingStep.colorClass, + updatedAt: now, + } + : s + ); + } else { + effectiveSteps = [ + ...effectiveSteps, + { + id: editingStep.id, + name: editingStep.name, + instructions: editingStep.instructions, + colorClass: editingStep.colorClass, + order: effectiveSteps.length, + createdAt: now, + updatedAt: now, + }, + ]; + } + } else { + // Add new step + effectiveSteps = [ + ...effectiveSteps, + { + id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`, + name: editingStep.name, + instructions: editingStep.instructions, + colorClass: editingStep.colorClass, + order: effectiveSteps.length, + createdAt: now, + updatedAt: now, + }, + ]; + } + + // Keep local UI state consistent with what we are saving. + setSteps(effectiveSteps); + setEditingStep(null); + } + + const sortedEffectiveSteps = [...effectiveSteps].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0) + ); + const config: PipelineConfig = { + version: 1, + steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })), + }; + await onSave(config); + toast.success('Pipeline configuration saved'); + onClose(); + } catch (error) { + toast.error('Failed to save pipeline configuration'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + !open && onClose()}> + + {/* Hidden file input for loading instructions from .md files */} + + + Pipeline Settings + + Configure custom pipeline steps that run after a feature completes "In Progress". Each + step will automatically prompt the agent with its instructions. + + + +
+ {/* Steps List */} + {sortedSteps.length > 0 ? ( +
+ {sortedSteps.map((step, index) => ( +
+
+ + +
+ +
+ +
+
{step.name || 'Unnamed Step'}
+
+ {(step.instructions || '').substring(0, 100)} + {(step.instructions || '').length > 100 ? '...' : ''} +
+
+ +
+ + +
+
+ ))} +
+ ) : ( +
+

No pipeline steps configured.

+

+ Add steps to create a custom workflow after features complete. +

+
+ )} + + {/* Add Step Button */} + {!editingStep && ( + + )} + + {/* Edit/Add Step Form */} + {editingStep && ( +
+
+

{editingStep.id ? 'Edit Step' : 'New Step'}

+ +
+ + {/* Template Selector - only show for new steps */} + {!editingStep.id && ( +
+ + +

+ Select a pre-built template to populate the form, or create your own from + scratch. +

+
+ )} + +
+ + + setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null)) + } + /> +
+ +
+ +
+ {COLOR_OPTIONS.map((color) => ( +
+
+ +
+
+ + +
+