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.
This commit is contained in:
Test User
2025-12-27 23:57:15 -05:00
parent 4a708aa305
commit e9b366fa18
28 changed files with 2409 additions and 61 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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<void> => {
try {
const { projectPath, step } = req.body as {
projectPath: string;
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>;
};
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<PipelineStep> }
* 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<void> => {
try {
const { projectPath, stepId, updates } = req.body as {
projectPath: string;
stepId: string;
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>;
};
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) });
}
};
}

View File

@@ -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<void> {
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<typeof loadContextFiles>[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
*/

View File

@@ -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<void> {
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<T>(filePath: string, defaultValue: T): Promise<T> {
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<PipelineConfig> {
const configPath = getPipelineConfigPath(projectPath);
const config = await readJsonFile<PipelineConfig>(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<void> {
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<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
): Promise<PipelineStep> {
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<Omit<PipelineStep, 'id' | 'createdAt'>>
): Promise<PipelineStep> {
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<void> {
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<void> {
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<PipelineStep | null> {
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();

View File

@@ -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<BacklogPlanResult | null>(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)}
/>
) : (
<GraphView
@@ -1206,6 +1237,22 @@ export function BoardView() {
}}
/>
{/* Pipeline Settings Dialog */}
<PipelineSettingsDialog
open={showPipelineSettings}
onClose={() => 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 */}
<FollowUpDialog
open={showFollowUpDialog}

View File

@@ -1,14 +1,28 @@
import { Feature } from '@/store/app-store';
import type { Feature } from '@/store/app-store';
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
export type ColumnId = Feature['status'];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
export interface Column {
id: FeatureStatusWithPipeline;
title: string;
colorClass: string;
isPipelineStep?: boolean;
pipelineStepId?: string;
}
// Base columns (start)
const BASE_COLUMNS: Column[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
{
id: 'in_progress',
title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]',
},
];
// End columns (after pipeline)
const END_COLUMNS: Column[] = [
{
id: 'waiting_approval',
title: 'Waiting Approval',
@@ -20,3 +34,58 @@ export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
colorClass: 'bg-[var(--status-success)]',
},
];
// Static COLUMNS for backwards compatibility (no pipeline)
export const COLUMNS: Column[] = [...BASE_COLUMNS, ...END_COLUMNS];
/**
* Generate columns including pipeline steps
*/
export function getColumnsWithPipeline(pipelineConfig: PipelineConfig | null): Column[] {
const pipelineSteps = pipelineConfig?.steps || [];
if (pipelineSteps.length === 0) {
return COLUMNS;
}
// Sort steps by order
const sortedSteps = [...pipelineSteps].sort((a, b) => 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_', '');
}

View File

@@ -131,7 +131,7 @@ export function DependencyTreeDialog({
: 'bg-muted text-muted-foreground'
)}
>
{dep.status.replace(/_/g, ' ')}
{(dep.status || 'backlog').replace(/_/g, ' ')}
</span>
</div>
</div>
@@ -177,7 +177,7 @@ export function DependencyTreeDialog({
: 'bg-muted text-muted-foreground'
)}
>
{dependent.status.replace(/_/g, ' ')}
{(dependent.status || 'backlog').replace(/_/g, ' ')}
</span>
</div>
</div>

View File

@@ -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<void>;
}
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<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div
className={cn(
'w-3 h-8 rounded',
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
{/* Add Step Button */}
{!editingStep && (
<Button variant="outline" className="w-full" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-2" />
Add Pipeline Step
</Button>
)}
{/* Edit/Add Step Form */}
{editingStep && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
<div className="flex items-center justify-between">
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingStep(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Template Selector - only show for new steps */}
{!editingStep.id && (
<div className="space-y-2">
<Label>Start from Template</Label>
<Select
onValueChange={(templateId) => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
if (template) {
setEditingStep((prev) =>
prev
? {
...prev,
name: template.name,
instructions: template.instructions,
colorClass: template.colorClass,
}
: null
);
toast.success(`Loaded "${template.name}" template`);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a template (optional)" />
</SelectTrigger>
<SelectContent>
{STEP_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
template.colorClass.replace('/20', '')
)}
/>
{template.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select a pre-built template to populate the form, or create your own from
scratch.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="step-name">Step Name</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={editingStep.name}
onChange={(e) =>
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
editingStep.colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() =>
setEditingStep((prev) =>
prev ? { ...prev, colorClass: color.value } : null
)
}
title={color.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">Agent Instructions</Label>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleFileUpload}
>
<Upload className="h-3 w-3 mr-1" />
Load from .md file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step..."
value={editingStep.instructions}
onChange={(e) =>
setEditingStep((prev) =>
prev ? { ...prev, instructions: e.target.value } : null
)
}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingStep(null)}>
Cancel
</Button>
<Button onClick={handleSaveStep}>
{editingStep.id ? 'Update Step' : 'Add Step'}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingStep
? 'Save Step & Configuration'
: 'Save Configuration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -23,7 +23,8 @@ export function useBoardColumnFeatures({
}: UseBoardColumnFeaturesProps) {
// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {
const map: Record<ColumnId, Feature[]> = {
// Use a more flexible type to support dynamic pipeline statuses
const map: Record<string, Feature[]> = {
backlog: [],
in_progress: [],
waiting_approval: [],
@@ -76,31 +77,56 @@ export function useBoardColumnFeatures({
matchesWorktree = featureBranch === effectiveBranch;
}
// Use the feature's status (fallback to backlog for unknown statuses)
const status = f.status || 'backlog';
// IMPORTANT:
// Historically, we forced "running" features into in_progress so they never disappeared
// during stale reload windows. With pipelines, a feature can legitimately be running while
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
if (isRunning) {
// Only show running tasks if they match the current worktree
if (matchesWorktree) {
if (!matchesWorktree) return;
if (status.startsWith('pipeline_')) {
if (!map[status]) map[status] = [];
map[status].push(f);
return;
}
// If it's running and has a known non-backlog status, keep it in that status.
// Otherwise, fallback to in_progress as the "active work" column.
if (status !== 'backlog' && map[status]) {
map[status].push(f);
} else {
map.in_progress.push(f);
}
} else {
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
const status = f.status as ColumnId;
return;
}
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
map.backlog.push(f);
// Not running: place by status (and worktree filter)
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else if (status.startsWith('pipeline_')) {
// Handle pipeline statuses - initialize array if needed
if (matchesWorktree) {
if (!map[status]) {
map[status] = [];
}
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
map.backlog.push(f);
}
}
});
@@ -147,7 +173,7 @@ export function useBoardColumnFeatures({
const getColumnFeatures = useCallback(
(columnId: ColumnId) => {
return columnFeaturesMap[columnId];
return columnFeaturesMap[columnId] || [];
},
[columnFeaturesMap]
);

View File

@@ -203,6 +203,11 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// This ensures the feature card shows the "Approve Plan" button
console.log('[Board] Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === 'pipeline_step_started') {
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
// Reload so the card moves into the correct pipeline column immediately.
console.log('[Board] Pipeline step started, reloading features...');
loadFeatures();
} else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log('[Board] Feature error, reloading features...', event.error);

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
@@ -5,10 +6,11 @@ import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { FastForward, Lightbulb, Archive } from 'lucide-react';
import { FastForward, Lightbulb, Archive, Plus, Settings2 } from 'lucide-react';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { COLUMNS, ColumnId } from './constants';
import { getColumnsWithPipeline, type Column, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
interface KanbanBoardProps {
sensors: any;
@@ -49,6 +51,8 @@ interface KanbanBoardProps {
onShowSuggestions: () => void;
suggestionsCount: number;
onArchiveAllVerified: () => void;
pipelineConfig: PipelineConfig | null;
onOpenPipelineSettings?: () => void;
}
export function KanbanBoard({
@@ -82,13 +86,18 @@ export function KanbanBoard({
onShowSuggestions,
suggestionsCount,
onArchiveAllVerified,
pipelineConfig,
onOpenPipelineSettings,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
// Use responsive column widths based on window size
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
const { columnWidth, containerStyle } = useResponsiveKanban(COLUMNS.length);
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
return (
<div className="flex-1 overflow-x-hidden px-5 pb-4 relative" style={backgroundImageStyle}>
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
@@ -96,8 +105,8 @@ export function KanbanBoard({
onDragEnd={onDragEnd}
>
<div className="h-full py-1" style={containerStyle}>
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<KanbanColumn
key={column.id}
@@ -156,6 +165,28 @@ export function KanbanBoard({
</HotkeyButton>
)}
</div>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
>

View File

@@ -76,7 +76,10 @@ const priorityConfig = {
};
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
const config = statusConfig[data.status] || statusConfig.backlog;
// Handle pipeline statuses by treating them like in_progress
const status = data.status || 'backlog';
const statusKey = status.startsWith('pipeline_') ? 'in_progress' : status;
const config = statusConfig[statusKey as keyof typeof statusConfig] || statusConfig.backlog;
const StatusIcon = config.icon;
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;

View File

@@ -174,14 +174,12 @@ export function useResponsiveKanban(
// Calculate total board width for container sizing
const totalBoardWidth = columnWidth * columnCount + gap * (columnCount - 1);
// Container style to center content
// Use flex layout with justify-center to naturally center columns
// The parent container has px-4 padding which provides equal left/right margins
// Container style for horizontal scrolling support
const containerStyle: React.CSSProperties = {
display: 'flex',
gap: `${gap}px`,
height: '100%',
justifyContent: 'center',
width: 'max-content', // Expand to fit all columns, enabling horizontal scroll when needed
minHeight: '100%', // Ensure full height
};
return {

View File

@@ -1125,6 +1125,102 @@ export class HttpApiClient implements ElectronAPI {
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
},
};
// Pipeline API - custom workflow pipeline steps
pipeline = {
getConfig: (
projectPath: string
): Promise<{
success: boolean;
config?: {
version: 1;
steps: Array<{
id: string;
name: string;
order: number;
instructions: string;
colorClass: string;
createdAt: string;
updatedAt: string;
}>;
};
error?: string;
}> => this.post('/api/pipeline/config', { projectPath }),
saveConfig: (
projectPath: string,
config: {
version: 1;
steps: Array<{
id: string;
name: string;
order: number;
instructions: string;
colorClass: string;
createdAt: string;
updatedAt: string;
}>;
}
): Promise<{ success: boolean; error?: string }> =>
this.post('/api/pipeline/config/save', { projectPath, config }),
addStep: (
projectPath: string,
step: {
name: string;
order: number;
instructions: string;
colorClass: string;
}
): Promise<{
success: boolean;
step?: {
id: string;
name: string;
order: number;
instructions: string;
colorClass: string;
createdAt: string;
updatedAt: string;
};
error?: string;
}> => this.post('/api/pipeline/steps/add', { projectPath, step }),
updateStep: (
projectPath: string,
stepId: string,
updates: Partial<{
name: string;
order: number;
instructions: string;
colorClass: string;
}>
): Promise<{
success: boolean;
step?: {
id: string;
name: string;
order: number;
instructions: string;
colorClass: string;
createdAt: string;
updatedAt: string;
};
error?: string;
}> => this.post('/api/pipeline/steps/update', { projectPath, stepId, updates }),
deleteStep: (
projectPath: string,
stepId: string
): Promise<{ success: boolean; error?: string }> =>
this.post('/api/pipeline/steps/delete', { projectPath, stepId }),
reorderSteps: (
projectPath: string,
stepIds: string[]
): Promise<{ success: boolean; error?: string }> =>
this.post('/api/pipeline/steps/reorder', { projectPath, stepIds }),
};
}
// Singleton instance

View File

@@ -37,19 +37,13 @@ const STATIC_PORT = 3007;
// ============================================
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
// With sidebar expanded (288px): 1220 + 288 = 1508px
// With sidebar collapsed (64px): 1220 + 64 = 1284px
const COLUMN_MIN_WIDTH = 280;
const COLUMN_COUNT = 4;
const GAP_SIZE = 20;
const BOARD_PADDING = 40; // px-5 on both sides = 40px (matches gap between columns)
// Minimum window dimensions - reduced to allow smaller windows since kanban now supports horizontal scrolling
const SIDEBAR_EXPANDED = 288;
const SIDEBAR_COLLAPSED = 64;
const BOARD_CONTENT_MIN =
COLUMN_MIN_WIDTH * COLUMN_COUNT + GAP_SIZE * (COLUMN_COUNT - 1) + BOARD_PADDING;
const MIN_WIDTH_EXPANDED = BOARD_CONTENT_MIN + SIDEBAR_EXPANDED; // 1500px
const MIN_WIDTH_COLLAPSED = BOARD_CONTENT_MIN + SIDEBAR_COLLAPSED; // 1276px
const MIN_HEIGHT = 650; // Ensures sidebar content fits without scrolling
const MIN_WIDTH_EXPANDED = 800; // Reduced - horizontal scrolling handles overflow
const MIN_WIDTH_COLLAPSED = 600; // Reduced - horizontal scrolling handles overflow
const MIN_HEIGHT = 500; // Reduced to allow more flexibility
const DEFAULT_WIDTH = 1600;
const DEFAULT_HEIGHT = 950;
@@ -199,7 +193,7 @@ function validateBounds(bounds: WindowBounds): WindowBounds {
// Ensure minimum dimensions
return {
...bounds,
width: Math.max(bounds.width, MIN_WIDTH_EXPANDED),
width: Math.max(bounds.width, MIN_WIDTH_COLLAPSED),
height: Math.max(bounds.height, MIN_HEIGHT),
};
}
@@ -420,7 +414,7 @@ function createWindow(): void {
height: validBounds?.height ?? DEFAULT_HEIGHT,
x: validBounds?.x,
y: validBounds?.y,
minWidth: MIN_WIDTH_EXPANDED, // 1500px - ensures kanban columns fit with sidebar
minWidth: MIN_WIDTH_COLLAPSED, // Small minimum - horizontal scrolling handles overflow
minHeight: MIN_HEIGHT,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
@@ -673,15 +667,10 @@ ipcMain.handle('server:getUrl', async () => {
});
// Window management - update minimum width based on sidebar state
ipcMain.handle('window:updateMinWidth', (_, sidebarExpanded: boolean) => {
// Now uses a fixed small minimum since horizontal scrolling handles overflow
ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
const minWidth = sidebarExpanded ? MIN_WIDTH_EXPANDED : MIN_WIDTH_COLLAPSED;
mainWindow.setMinimumSize(minWidth, MIN_HEIGHT);
// If current width is below new minimum, resize window
const currentBounds = mainWindow.getBounds();
if (currentBounds.width < minWidth) {
mainWindow.setSize(minWidth, currentBounds.height);
}
// Always use the smaller minimum width - horizontal scrolling handles any overflow
mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT);
});

View File

@@ -7,6 +7,9 @@ import type {
AgentModel,
PlanningMode,
AIProfile,
FeatureStatusWithPipeline,
PipelineConfig,
PipelineStep,
} from '@automaker/types';
// Re-export ThemeMode for convenience
@@ -263,7 +266,7 @@ export interface Feature extends Omit<
category: string;
description: string;
steps: string[]; // Required in UI (not optional)
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
status: FeatureStatusWithPipeline;
images?: FeatureImage[]; // UI-specific base64 images
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
@@ -534,6 +537,9 @@ export interface AppState {
claudeRefreshInterval: number; // Refresh interval in seconds (default: 60)
claudeUsage: ClaudeUsage | null;
claudeUsageLastUpdated: number | null;
// Pipeline Configuration (per-project, keyed by project path)
pipelineConfigByProject: Record<string, PipelineConfig>;
}
// Claude Usage interface matching the server response
@@ -857,6 +863,21 @@ export interface AppActions {
} | null
) => void;
// Pipeline actions
setPipelineConfig: (projectPath: string, config: PipelineConfig) => void;
getPipelineConfig: (projectPath: string) => PipelineConfig | null;
addPipelineStep: (
projectPath: string,
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
) => PipelineStep;
updatePipelineStep: (
projectPath: string,
stepId: string,
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
) => void;
deletePipelineStep: (projectPath: string, stepId: string) => void;
reorderPipelineSteps: (projectPath: string, stepIds: string[]) => void;
// Reset
reset: () => void;
}
@@ -961,6 +982,10 @@ const initialState: AppState = {
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
pendingPlanApproval: null,
claudeRefreshInterval: 60,
claudeUsage: null,
claudeUsageLastUpdated: null,
pipelineConfigByProject: {},
};
export const useAppStore = create<AppState & AppActions>()(
@@ -2597,6 +2622,105 @@ export const useAppStore = create<AppState & AppActions>()(
claudeUsageLastUpdated: usage ? Date.now() : null,
}),
// Pipeline actions
setPipelineConfig: (projectPath, config) => {
set({
pipelineConfigByProject: {
...get().pipelineConfigByProject,
[projectPath]: config,
},
});
},
getPipelineConfig: (projectPath) => {
return get().pipelineConfigByProject[projectPath] || null;
},
addPipelineStep: (projectPath, step) => {
const config = get().pipelineConfigByProject[projectPath] || { version: 1, steps: [] };
const now = new Date().toISOString();
const newStep: PipelineStep = {
...step,
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
createdAt: now,
updatedAt: now,
};
const newSteps = [...config.steps, newStep].sort((a, b) => a.order - b.order);
newSteps.forEach((s, index) => {
s.order = index;
});
set({
pipelineConfigByProject: {
...get().pipelineConfigByProject,
[projectPath]: { ...config, steps: newSteps },
},
});
return newStep;
},
updatePipelineStep: (projectPath, stepId, updates) => {
const config = get().pipelineConfigByProject[projectPath];
if (!config) return;
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
if (stepIndex === -1) return;
const updatedSteps = [...config.steps];
updatedSteps[stepIndex] = {
...updatedSteps[stepIndex],
...updates,
updatedAt: new Date().toISOString(),
};
set({
pipelineConfigByProject: {
...get().pipelineConfigByProject,
[projectPath]: { ...config, steps: updatedSteps },
},
});
},
deletePipelineStep: (projectPath, stepId) => {
const config = get().pipelineConfigByProject[projectPath];
if (!config) return;
const newSteps = config.steps.filter((s) => s.id !== stepId);
newSteps.forEach((s, index) => {
s.order = index;
});
set({
pipelineConfigByProject: {
...get().pipelineConfigByProject,
[projectPath]: { ...config, steps: newSteps },
},
});
},
reorderPipelineSteps: (projectPath, stepIds) => {
const config = get().pipelineConfigByProject[projectPath];
if (!config) return;
const stepMap = new Map(config.steps.map((s) => [s.id, s]));
const reorderedSteps = stepIds
.map((id, index) => {
const step = stepMap.get(id);
if (!step) return null;
return { ...step, order: index, updatedAt: new Date().toISOString() };
})
.filter((s): s is PipelineStep => s !== null);
set({
pipelineConfigByProject: {
...get().pipelineConfigByProject,
[projectPath]: { ...config, steps: reorderedSteps },
},
});
},
// Reset
reset: () => set(initialState),
}),

View File

@@ -190,6 +190,24 @@ export type AutoModeEvent =
passes: boolean;
message: string;
}
| {
type: 'pipeline_step_started';
featureId: string;
projectPath?: string;
stepId: string;
stepName: string;
stepIndex: number;
totalSteps: number;
}
| {
type: 'pipeline_step_complete';
featureId: string;
projectPath?: string;
stepId: string;
stepName: string;
stepIndex: number;
totalSteps: number;
}
| {
type: 'auto_mode_error';
error: string;