Merge remote-tracking branch 'origin/main' into implement-planning/speckits

This commit is contained in:
SuperComboGamer
2025-12-17 21:40:42 -05:00
126 changed files with 16113 additions and 1069 deletions

View File

@@ -20,9 +20,18 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { createAutoModeOptions } from "../lib/sdk-options.js";
import { isAbortError, classifyError } from "../lib/error-handler.js";
import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js";
import type { Feature } from "./feature-loader.js";
import {
getFeatureDir,
getFeaturesDir,
getAutomakerDir,
getWorktreesDir,
} from "../lib/automaker-paths.js";
const execAsync = promisify(exec);
// Planning mode types for spec-driven development
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
interface ParsedTask {
@@ -278,27 +287,12 @@ function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
};
}
interface Feature {
id: string;
category: string;
description: string;
steps?: string[];
status: string;
priority?: number;
spec?: string;
model?: string; // Model to use for this feature
imagePaths?: Array<
| string
| {
path: string;
filename?: string;
mimeType?: string;
[key: string]: unknown;
}
>;
// Feature type is imported from feature-loader.js
// Extended type with planning fields for local use
interface FeatureWithPlanning extends Feature {
planningMode?: PlanningMode;
planSpec?: PlanSpec;
requirePlanApproval?: boolean; // If true, pause for user approval before implementation
requirePlanApproval?: boolean;
}
interface RunningFeature {
@@ -444,13 +438,18 @@ export class AutoModeService {
/**
* Execute a single feature
* @param projectPath - The main project path
* @param featureId - The feature ID to execute
* @param useWorktrees - Whether to use worktrees for isolation
* @param isAutoMode - Whether this is running in auto mode
* @param providedWorktreePath - Optional: use this worktree path instead of creating a new one
*/
async executeFeature(
projectPath: string,
featureId: string,
useWorktrees = true,
useWorktrees = false,
isAutoMode = false,
customPrompt?: string
providedWorktreePath?: string
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
@@ -460,8 +459,29 @@ export class AutoModeService {
const branchName = `feature/${featureId}`;
let worktreePath: string | null = null;
// Setup worktree if enabled
if (useWorktrees) {
// Use provided worktree path if given, otherwise setup new worktree if enabled
if (providedWorktreePath) {
// Resolve to absolute path - critical for cross-platform compatibility
// On Windows, relative paths or paths with forward slashes may not work correctly with cwd
// On all platforms, absolute paths ensure commands execute in the correct directory
try {
// Resolve relative paths relative to projectPath, absolute paths as-is
const resolvedPath = path.isAbsolute(providedWorktreePath)
? path.resolve(providedWorktreePath)
: path.resolve(projectPath, providedWorktreePath);
// Verify the path exists before using it
await fs.access(resolvedPath);
worktreePath = resolvedPath;
console.log(`[AutoMode] Using provided worktree path (resolved): ${worktreePath}`);
} catch (error) {
console.error(`[AutoMode] Provided worktree path invalid or doesn't exist: ${providedWorktreePath}`, error);
// Fall through to create new worktree or use project path
}
}
if (!worktreePath && useWorktrees) {
// No specific worktree provided, create a new one for this feature
worktreePath = await this.setupWorktree(
projectPath,
featureId,
@@ -469,7 +489,8 @@ export class AutoModeService {
);
}
const workDir = worktreePath || projectPath;
// Ensure workDir is always an absolute path for cross-platform compatibility
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
this.runningFeatures.set(featureId, {
featureId,
@@ -611,16 +632,11 @@ export class AutoModeService {
async resumeFeature(
projectPath: string,
featureId: string,
useWorktrees = true
useWorktrees = false
): Promise<void> {
// Check if context exists
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
// Check if context exists in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
let hasContext = false;
try {
@@ -652,7 +668,8 @@ export class AutoModeService {
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[]
imagePaths?: string[],
providedWorktreePath?: string
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
@@ -660,33 +677,35 @@ export class AutoModeService {
const abortController = new AbortController();
// Check if worktree exists
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath;
// Use the provided worktreePath (from the feature's assigned branch)
// Fall back to project path if not provided
let workDir = path.resolve(projectPath);
let worktreePath: string | null = null;
try {
await fs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree, use project path
if (providedWorktreePath) {
try {
// Resolve to absolute path - critical for cross-platform compatibility
// On Windows, relative paths or paths with forward slashes may not work correctly with cwd
// On all platforms, absolute paths ensure commands execute in the correct directory
const resolvedPath = path.isAbsolute(providedWorktreePath)
? path.resolve(providedWorktreePath)
: path.resolve(projectPath, providedWorktreePath);
await fs.access(resolvedPath);
workDir = resolvedPath;
worktreePath = resolvedPath;
} catch {
// Worktree path provided but doesn't exist, use project path
console.log(`[AutoMode] Provided worktreePath doesn't exist: ${providedWorktreePath}, using project path`);
}
}
// Load feature info for context
const feature = await this.loadFeature(projectPath, featureId);
// Load previous agent output if it exists
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
let previousContext = "";
try {
previousContext = await fs.readFile(contextPath, "utf-8");
@@ -719,8 +738,8 @@ Address the follow-up instructions above. Review the previous work and make the
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath: workDir !== projectPath ? worktreePath : null,
branchName: `feature/${featureId}`,
worktreePath,
branchName: worktreePath ? path.basename(worktreePath) : null,
abortController,
isAutoMode: false,
startTime: Date.now(),
@@ -749,13 +768,8 @@ Address the follow-up instructions above. Review the previous work and make the
// Copy follow-up images to feature folder
const copiedImagePaths: string[] = [];
if (imagePaths && imagePaths.length > 0) {
const featureImagesDir = path.join(
projectPath,
".automaker",
"features",
featureId,
"images"
);
const featureDirForImages = getFeatureDir(projectPath, featureId);
const featureImagesDir = path.join(featureDirForImages, "images");
await fs.mkdir(featureImagesDir, { recursive: true });
@@ -768,15 +782,8 @@ Address the follow-up instructions above. Review the previous work and make the
// Copy the image
await fs.copyFile(imagePath, destPath);
// Store the relative path (like FeatureLoader does)
const relativePath = path.join(
".automaker",
"features",
featureId,
"images",
filename
);
copiedImagePaths.push(relativePath);
// Store the absolute path (external storage uses absolute paths)
copiedImagePaths.push(destPath);
} catch (error) {
console.error(
`[AutoMode] Failed to copy follow-up image ${imagePath}:`,
@@ -811,13 +818,8 @@ Address the follow-up instructions above. Review the previous work and make the
// Save updated feature.json with new images
if (copiedImagePaths.length > 0 && feature) {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
const featureDirForSave = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDirForSave, "feature.json");
try {
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
@@ -828,6 +830,7 @@ Address the follow-up instructions above. Review the previous work and make the
// Use fullPrompt (already built above) with model and all images
// Note: Follow-ups skip planning mode - they continue from previous work
// Pass previousContext so the history is preserved in the output file
await this.runAgent(
workDir,
featureId,
@@ -838,6 +841,7 @@ Address the follow-up instructions above. Review the previous work and make the
{
projectPath,
planningMode: 'skip', // Follow-ups don't require approval
previousContent: previousContext || undefined,
}
);
@@ -874,12 +878,8 @@ Address the follow-up instructions above. Review the previous work and make the
projectPath: string,
featureId: string
): Promise<boolean> {
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
// Worktrees are in project dir
const worktreePath = path.join(projectPath, ".worktrees", featureId);
let workDir = projectPath;
try {
@@ -938,24 +938,36 @@ Address the follow-up instructions above. Review the previous work and make the
/**
* Commit feature changes
* @param projectPath - The main project path
* @param featureId - The feature ID to commit
* @param providedWorktreePath - Optional: the worktree path where the feature's changes are located
*/
async commitFeature(
projectPath: string,
featureId: string
featureId: string,
providedWorktreePath?: string
): Promise<string | null> {
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath;
try {
await fs.access(worktreePath);
workDir = worktreePath;
} catch {
// No worktree
// Use the provided worktree path if given
if (providedWorktreePath) {
try {
await fs.access(providedWorktreePath);
workDir = providedWorktreePath;
console.log(`[AutoMode] Committing in provided worktree: ${workDir}`);
} catch {
console.log(`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`);
}
} else {
// Fallback: try to find worktree at legacy location
const legacyWorktreePath = path.join(projectPath, ".worktrees", featureId);
try {
await fs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`);
} catch {
console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`);
}
}
try {
@@ -1006,13 +1018,9 @@ Address the follow-up instructions above. Review the previous work and make the
projectPath: string,
featureId: string
): Promise<boolean> {
const contextPath = path.join(
projectPath,
".automaker",
"features",
featureId,
"agent-output.md"
);
// Context is stored in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, "agent-output.md");
try {
await fs.access(contextPath);
@@ -1085,13 +1093,10 @@ Format your response as a structured markdown document.`;
}
}
// Save analysis
const analysisPath = path.join(
projectPath,
".automaker",
"project-analysis.md"
);
await fs.mkdir(path.dirname(analysisPath), { recursive: true });
// Save analysis to .automaker directory
const automakerDir = getAutomakerDir(projectPath);
const analysisPath = path.join(automakerDir, "project-analysis.md");
await fs.mkdir(automakerDir, { recursive: true });
await fs.writeFile(analysisPath, analysisResult);
this.emitAutoModeEvent("auto_mode_feature_complete", {
@@ -1294,20 +1299,82 @@ Format your response as a structured markdown document.`;
// Private helpers
/**
* Find an existing worktree for a given branch by checking git worktree list
*/
private async findExistingWorktreeForBranch(
projectPath: string,
branchName: string
): Promise<string | null> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: projectPath,
});
const lines = stdout.split("\n");
let currentPath: string | null = null;
let currentBranch: string | null = null;
for (const line of lines) {
if (line.startsWith("worktree ")) {
currentPath = line.slice(9);
} else if (line.startsWith("branch ")) {
currentBranch = line.slice(7).replace("refs/heads/", "");
} else if (line === "" && currentPath && currentBranch) {
// End of a worktree entry
if (currentBranch === branchName) {
// Resolve to absolute path - git may return relative paths
// On Windows, this is critical for cwd to work correctly
// On all platforms, absolute paths ensure consistent behavior
const resolvedPath = path.isAbsolute(currentPath)
? path.resolve(currentPath)
: path.resolve(projectPath, currentPath);
return resolvedPath;
}
currentPath = null;
currentBranch = null;
}
}
// Check the last entry (if file doesn't end with newline)
if (currentPath && currentBranch && currentBranch === branchName) {
// Resolve to absolute path for cross-platform compatibility
const resolvedPath = path.isAbsolute(currentPath)
? path.resolve(currentPath)
: path.resolve(projectPath, currentPath);
return resolvedPath;
}
return null;
} catch {
return null;
}
}
private async setupWorktree(
projectPath: string,
featureId: string,
branchName: string
): Promise<string> {
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");
// First, check if git already has a worktree for this branch (anywhere)
const existingWorktree = await this.findExistingWorktreeForBranch(projectPath, branchName);
if (existingWorktree) {
// Path is already resolved to absolute in findExistingWorktreeForBranch
console.log(`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`);
return existingWorktree;
}
// Git worktrees stay in project directory
const worktreesDir = path.join(projectPath, ".worktrees");
const worktreePath = path.join(worktreesDir, featureId);
await fs.mkdir(worktreesDir, { recursive: true });
// Check if worktree already exists
// Check if worktree directory already exists (might not be linked to branch)
try {
await fs.access(worktreePath);
return worktreePath;
// Return absolute path for cross-platform compatibility
return path.resolve(worktreePath);
} catch {
// Create new worktree
}
@@ -1324,26 +1391,22 @@ Format your response as a structured markdown document.`;
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, {
cwd: projectPath,
});
// Return absolute path for cross-platform compatibility
return path.resolve(worktreePath);
} catch (error) {
// Worktree creation failed, fall back to direct execution
console.error(`[AutoMode] Worktree creation failed:`, error);
return projectPath;
return path.resolve(projectPath);
}
return worktreePath;
}
private async loadFeature(
projectPath: string,
featureId: string
): Promise<Feature | null> {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
// Features are stored in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, "feature.json");
try {
const data = await fs.readFile(featurePath, "utf-8");
@@ -1358,13 +1421,9 @@ Format your response as a structured markdown document.`;
featureId: string,
status: string
): Promise<void> {
const featurePath = path.join(
projectPath,
".automaker",
"features",
featureId,
"feature.json"
);
// Features are stored in .automaker directory
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, "feature.json");
try {
const data = await fs.readFile(featurePath, "utf-8");
@@ -1430,12 +1489,15 @@ Format your response as a structured markdown document.`;
}
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
const featuresDir = path.join(projectPath, ".automaker", "features");
// Features are stored in .automaker directory
const featuresDir = getFeaturesDir(projectPath);
try {
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const features: Feature[] = [];
const allFeatures: Feature[] = [];
const pendingFeatures: Feature[] = [];
// Load all features (for dependency checking)
for (const entry of entries) {
if (entry.isDirectory()) {
const featurePath = path.join(
@@ -1446,12 +1508,15 @@ Format your response as a structured markdown document.`;
try {
const data = await fs.readFile(featurePath, "utf-8");
const feature = JSON.parse(data);
allFeatures.push(feature);
// Track pending features separately
if (
feature.status === "pending" ||
feature.status === "ready" ||
feature.status === "backlog"
) {
features.push(feature);
pendingFeatures.push(feature);
}
} catch {
// Skip invalid features
@@ -1459,8 +1524,15 @@ Format your response as a structured markdown document.`;
}
}
// Sort by priority
return features.sort((a, b) => (a.priority || 999) - (b.priority || 999));
// Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Filter to only features with satisfied dependencies
const readyFeatures = orderedFeatures.filter(feature =>
areDependenciesSatisfied(feature, allFeatures)
);
return readyFeatures;
} catch {
return [];
}
@@ -1562,7 +1634,22 @@ Implement this feature by:
4. Add or update tests as needed
5. Ensure the code follows existing patterns and conventions
When done, summarize what you implemented and any notes for the developer.`;
When done, wrap your final summary in <summary> tags like this:
<summary>
## Summary: [Feature Title]
### Changes Implemented
- [List of changes made]
### Files Modified
- [List of files]
### Notes for Developer
- [Any important notes]
</summary>
This helps parse your summary correctly in the output logs.`;
return prompt;
}
@@ -1578,10 +1665,13 @@ When done, summarize what you implemented and any notes for the developer.`;
projectPath?: string;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
previousContent?: string;
}
): Promise<void> {
const projectPath = options?.projectPath || workDir;
const planningMode = options?.planningMode || 'skip';
const previousContent = options?.previousContent;
// Check if this planning mode can generate a spec/plan that needs approval
// - spec and full always generate specs
// - lite only generates approval-ready content when requirePlanApproval is true
@@ -1591,6 +1681,64 @@ When done, summarize what you implemented and any notes for the developer.`;
(planningMode === 'lite' && options?.requirePlanApproval === true);
const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true;
// CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set
// This prevents actual API calls during automated testing
if (process.env.AUTOMAKER_MOCK_AGENT === "true") {
console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`);
// Simulate some work being done
await this.sleep(500);
// Emit mock progress events to simulate agent activity
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
content: "Mock agent: Analyzing the codebase...",
});
await this.sleep(300);
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
content: "Mock agent: Implementing the feature...",
});
await this.sleep(300);
// Create a mock file with "yellow" content as requested in the test
const mockFilePath = path.join(workDir, "yellow.txt");
await fs.writeFile(mockFilePath, "yellow");
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
content: "Mock agent: Created yellow.txt file with content 'yellow'",
});
await this.sleep(200);
// Save mock agent output
const configProjectPath = this.config?.projectPath || workDir;
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
const mockOutput = `# Mock Agent Output
## Summary
This is a mock agent response for CI/CD testing.
## Changes Made
- Created \`yellow.txt\` with content "yellow"
## Notes
This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
`;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, mockOutput);
console.log(`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`);
return;
}
// Build SDK options using centralized configuration for feature implementation
const sdkOptions = createAutoModeOptions({
cwd: workDir,
@@ -1633,20 +1781,56 @@ When done, summarize what you implemented and any notes for the developer.`;
// Execute via provider
const stream = provider.executeQuery(executeOptions);
let responseText = "";
// Initialize with previous content if this is a follow-up, with a separator
let responseText = previousContent
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
: "";
let specDetected = false;
const outputPath = path.join(
workDir,
".automaker",
"features",
featureId,
"agent-output.md"
);
// Agent output goes to .automaker directory
// Note: We use the original projectPath here (from config), not workDir
// because workDir might be a worktree path
const configProjectPath = this.config?.projectPath || workDir;
const featureDirForOutput = getFeatureDir(configProjectPath, featureId);
const outputPath = path.join(featureDirForOutput, "agent-output.md");
// Incremental file writing state
let writeTimeout: ReturnType<typeof setTimeout> | null = null;
const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms
// Helper to write current responseText to file
const writeToFile = async (): Promise<void> => {
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, responseText);
} catch (error) {
// Log but don't crash - file write errors shouldn't stop execution
console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error);
}
};
// Debounced write - schedules a write after WRITE_DEBOUNCE_MS
const scheduleWrite = (): void => {
if (writeTimeout) {
clearTimeout(writeTimeout);
}
writeTimeout = setTimeout(() => {
writeToFile();
}, WRITE_DEBOUNCE_MS);
};
streamLoop: for await (const msg of stream) {
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
// Add separator before new text if we already have content and it doesn't end with newlines
if (responseText.length > 0 && !responseText.endsWith('\n\n')) {
if (responseText.endsWith('\n')) {
responseText += '\n';
} else {
responseText += '\n\n';
}
}
responseText += block.text || "";
// Check for authentication errors in the response
@@ -1662,6 +1846,9 @@ When done, summarize what you implemented and any notes for the developer.`;
);
}
// Schedule incremental file write (debounced)
scheduleWrite();
// Check for [SPEC_GENERATED] marker in planning modes (spec or full)
if (planningModeRequiresApproval && !specDetected && responseText.includes('[SPEC_GENERATED]')) {
specDetected = true;
@@ -2056,28 +2243,41 @@ Implement all the changes described in the plan above.`;
});
}
} else if (block.type === "tool_use") {
// Emit event for real-time UI
this.emitAutoModeEvent("auto_mode_tool", {
featureId,
tool: block.name,
input: block.input,
});
// Also add to file output for persistence
if (responseText.length > 0 && !responseText.endsWith('\n')) {
responseText += '\n';
}
responseText += `\n🔧 Tool: ${block.name}\n`;
if (block.input) {
responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`;
}
scheduleWrite();
}
}
} else if (msg.type === "error") {
// Handle error messages
throw new Error(msg.error || "Unknown error");
} else if (msg.type === "result" && msg.subtype === "success") {
responseText = msg.result || responseText;
// Don't replace responseText - the accumulated content is the full history
// The msg.result is just a summary which would lose all tool use details
// Just ensure final write happens
scheduleWrite();
}
}
// Save agent output
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, responseText);
} catch {
// May fail if directory doesn't exist
// Clear any pending timeout and do a final write to ensure all content is saved
if (writeTimeout) {
clearTimeout(writeTimeout);
}
// Final write - ensure all accumulated content is saved
await writeToFile();
}
private async executeFeatureWithContext(

View File

@@ -0,0 +1,460 @@
/**
* Dev Server Service
*
* Manages multiple development server processes for git worktrees.
* Each worktree can have its own dev server running on a unique port.
*
* Developers should configure their projects to use the PORT environment variable.
*/
import { spawn, execSync, type ChildProcess } from "child_process";
import { existsSync } from "fs";
import path from "path";
import net from "net";
export interface DevServerInfo {
worktreePath: string;
port: number;
url: string;
process: ChildProcess | null;
startedAt: Date;
}
// Port allocation starts at 3001 to avoid conflicts with common dev ports
const BASE_PORT = 3001;
const MAX_PORT = 3099; // Safety limit
class DevServerService {
private runningServers: Map<string, DevServerInfo> = new Map();
private allocatedPorts: Set<number> = new Set();
/**
* Check if a port is available (not in use by system or by us)
*/
private async isPortAvailable(port: number): Promise<boolean> {
// First check if we've already allocated it
if (this.allocatedPorts.has(port)) {
return false;
}
// Then check if the system has it in use
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => resolve(false));
server.once("listening", () => {
server.close();
resolve(true);
});
server.listen(port, "127.0.0.1");
});
}
/**
* Kill any process running on the given port
*/
private killProcessOnPort(port: number): void {
try {
if (process.platform === "win32") {
// Windows: find and kill process on port
const result = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf-8" });
const lines = result.trim().split("\n");
const pids = new Set<string>();
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && pid !== "0") {
pids.add(pid);
}
}
for (const pid of pids) {
try {
execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" });
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
} catch {
// Process may have already exited
}
}
} else {
// macOS/Linux: use lsof to find and kill process
try {
const result = execSync(`lsof -ti:${port}`, { encoding: "utf-8" });
const pids = result.trim().split("\n").filter(Boolean);
for (const pid of pids) {
try {
execSync(`kill -9 ${pid}`, { stdio: "ignore" });
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
} catch {
// Process may have already exited
}
}
} catch {
// No process found on port, which is fine
}
}
} catch (error) {
// Ignore errors - port might not have any process
console.log(`[DevServerService] No process to kill on port ${port}`);
}
}
/**
* Find the next available port, killing any process on it first
*/
private async findAvailablePort(): Promise<number> {
let port = BASE_PORT;
while (port <= MAX_PORT) {
// Skip ports we've already allocated internally
if (this.allocatedPorts.has(port)) {
port++;
continue;
}
// Force kill any process on this port before checking availability
// This ensures we can claim the port even if something stale is holding it
this.killProcessOnPort(port);
// Small delay to let the port be released
await new Promise((resolve) => setTimeout(resolve, 100));
// Now check if it's available
if (await this.isPortAvailable(port)) {
return port;
}
port++;
}
throw new Error(`No available ports found between ${BASE_PORT} and ${MAX_PORT}`);
}
/**
* Detect the package manager used in a directory
*/
private detectPackageManager(
dir: string
): "npm" | "yarn" | "pnpm" | "bun" | null {
if (existsSync(path.join(dir, "bun.lockb"))) return "bun";
if (existsSync(path.join(dir, "pnpm-lock.yaml"))) return "pnpm";
if (existsSync(path.join(dir, "yarn.lock"))) return "yarn";
if (existsSync(path.join(dir, "package-lock.json"))) return "npm";
if (existsSync(path.join(dir, "package.json"))) return "npm"; // Default
return null;
}
/**
* Get the dev script command for a directory
*/
private getDevCommand(dir: string): { cmd: string; args: string[] } | null {
const pm = this.detectPackageManager(dir);
if (!pm) return null;
switch (pm) {
case "bun":
return { cmd: "bun", args: ["run", "dev"] };
case "pnpm":
return { cmd: "pnpm", args: ["run", "dev"] };
case "yarn":
return { cmd: "yarn", args: ["dev"] };
case "npm":
default:
return { cmd: "npm", args: ["run", "dev"] };
}
}
/**
* Start a dev server for a worktree
*/
async startDevServer(
projectPath: string,
worktreePath: string
): Promise<{
success: boolean;
result?: {
worktreePath: string;
port: number;
url: string;
message: string;
};
error?: string;
}> {
// Check if already running
if (this.runningServers.has(worktreePath)) {
const existing = this.runningServers.get(worktreePath)!;
return {
success: true,
result: {
worktreePath: existing.worktreePath,
port: existing.port,
url: existing.url,
message: `Dev server already running on port ${existing.port}`,
},
};
}
// Verify the worktree exists
if (!existsSync(worktreePath)) {
return {
success: false,
error: `Worktree path does not exist: ${worktreePath}`,
};
}
// Check for package.json
const packageJsonPath = path.join(worktreePath, "package.json");
if (!existsSync(packageJsonPath)) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Get dev command
const devCommand = this.getDevCommand(worktreePath);
if (!devCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
// Find available port
let port: number;
try {
port = await this.findAvailablePort();
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Port allocation failed",
};
}
// Reserve the port (port was already force-killed in findAvailablePort)
this.allocatedPorts.add(port);
// Also kill common related ports (livereload uses 35729 by default)
// Some dev servers use fixed ports for HMR/livereload regardless of main port
const commonRelatedPorts = [35729, 35730, 35731];
for (const relatedPort of commonRelatedPorts) {
this.killProcessOnPort(relatedPort);
}
// Small delay to ensure related ports are freed
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(
`[DevServerService] Starting dev server on port ${port}`
);
console.log(
`[DevServerService] Working directory (cwd): ${worktreePath}`
);
console.log(
`[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(" ")} with PORT=${port}`
);
// Spawn the dev process with PORT environment variable
const env = {
...process.env,
PORT: String(port),
};
const devProcess = spawn(devCommand.cmd, devCommand.args, {
cwd: worktreePath,
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
// Track if process failed early using object to work around TypeScript narrowing
const status = { error: null as string | null, exited: false };
// Log output for debugging
if (devProcess.stdout) {
devProcess.stdout.on("data", (data: Buffer) => {
console.log(`[DevServer:${port}] ${data.toString().trim()}`);
});
}
if (devProcess.stderr) {
devProcess.stderr.on("data", (data: Buffer) => {
const msg = data.toString().trim();
console.error(`[DevServer:${port}] ${msg}`);
});
}
devProcess.on("error", (error) => {
console.error(`[DevServerService] Process error:`, error);
status.error = error.message;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
});
devProcess.on("exit", (code) => {
console.log(
`[DevServerService] Process for ${worktreePath} exited with code ${code}`
);
status.exited = true;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
});
// Wait a moment to see if the process fails immediately
await new Promise((resolve) => setTimeout(resolve, 500));
if (status.error) {
return {
success: false,
error: `Failed to start dev server: ${status.error}`,
};
}
if (status.exited) {
return {
success: false,
error: `Dev server process exited immediately. Check server logs for details.`,
};
}
const serverInfo: DevServerInfo = {
worktreePath,
port,
url: `http://localhost:${port}`,
process: devProcess,
startedAt: new Date(),
};
this.runningServers.set(worktreePath, serverInfo);
return {
success: true,
result: {
worktreePath,
port,
url: `http://localhost:${port}`,
message: `Dev server started on port ${port}`,
},
};
}
/**
* Stop a dev server for a worktree
*/
async stopDevServer(worktreePath: string): Promise<{
success: boolean;
result?: { worktreePath: string; message: string };
error?: string;
}> {
const server = this.runningServers.get(worktreePath);
// If we don't have a record of this server, it may have crashed/exited on its own
// Return success so the frontend can clear its state
if (!server) {
console.log(`[DevServerService] No server record for ${worktreePath}, may have already stopped`);
return {
success: true,
result: {
worktreePath,
message: `Dev server already stopped`,
},
};
}
console.log(`[DevServerService] Stopping dev server for ${worktreePath}`);
// Kill the process
if (server.process && !server.process.killed) {
server.process.kill("SIGTERM");
}
// Free the port
this.allocatedPorts.delete(server.port);
this.runningServers.delete(worktreePath);
return {
success: true,
result: {
worktreePath,
message: `Stopped dev server on port ${server.port}`,
},
};
}
/**
* List all running dev servers
*/
listDevServers(): {
success: boolean;
result: {
servers: Array<{
worktreePath: string;
port: number;
url: string;
}>;
};
} {
const servers = Array.from(this.runningServers.values()).map((s) => ({
worktreePath: s.worktreePath,
port: s.port,
url: s.url,
}));
return {
success: true,
result: { servers },
};
}
/**
* Check if a worktree has a running dev server
*/
isRunning(worktreePath: string): boolean {
return this.runningServers.has(worktreePath);
}
/**
* Get info for a specific worktree's dev server
*/
getServerInfo(worktreePath: string): DevServerInfo | undefined {
return this.runningServers.get(worktreePath);
}
/**
* Get all allocated ports
*/
getAllocatedPorts(): number[] {
return Array.from(this.allocatedPorts);
}
/**
* Stop all running dev servers (for cleanup)
*/
async stopAll(): Promise<void> {
console.log(`[DevServerService] Stopping all ${this.runningServers.size} dev servers`);
for (const [worktreePath] of this.runningServers) {
await this.stopDevServer(worktreePath);
}
}
}
// Singleton instance
let devServerServiceInstance: DevServerService | null = null;
export function getDevServerService(): DevServerService {
if (!devServerServiceInstance) {
devServerServiceInstance = new DevServerService();
}
return devServerServiceInstance;
}
// Cleanup on process exit
process.on("SIGTERM", async () => {
if (devServerServiceInstance) {
await devServerServiceInstance.stopAll();
}
});
process.on("SIGINT", async () => {
if (devServerServiceInstance) {
await devServerServiceInstance.stopAll();
}
});

View File

@@ -5,6 +5,12 @@
import path from "path";
import fs from "fs/promises";
import {
getFeaturesDir,
getFeatureDir,
getFeatureImagesDir,
ensureAutomakerDir,
} from "../lib/automaker-paths.js";
export interface Feature {
id: string;
@@ -13,6 +19,10 @@ export interface Feature {
steps?: string[];
passes?: boolean;
priority?: number;
status?: string;
dependencies?: string[];
spec?: string;
model?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
status?: string;
model?: string;
@@ -41,14 +51,14 @@ export class FeatureLoader {
* Get the features directory path
*/
getFeaturesDir(projectPath: string): string {
return path.join(projectPath, ".automaker", "features");
return getFeaturesDir(projectPath);
}
/**
* Get the images directory path for a feature
*/
getFeatureImagesDir(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "images");
return getFeatureImagesDir(projectPath, featureId);
}
/**
@@ -75,15 +85,15 @@ export class FeatureLoader {
for (const oldPath of oldPathSet) {
if (!newPathSet.has(oldPath)) {
try {
const fullPath = path.isAbsolute(oldPath)
? oldPath
: path.join(projectPath, oldPath);
await fs.unlink(fullPath);
// Paths are now absolute
await fs.unlink(oldPath);
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
} catch (error) {
// Ignore errors when deleting (file may already be gone)
console.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
console.warn(
`[FeatureLoader] Failed to delete image: ${oldPath}`,
error
);
}
}
}
@@ -96,7 +106,9 @@ export class FeatureLoader {
projectPath: string,
featureId: string,
imagePaths?: Array<string | { path: string; [key: string]: unknown }>
): Promise<Array<string | { path: string; [key: string]: unknown }> | undefined> {
): Promise<
Array<string | { path: string; [key: string]: unknown }> | undefined
> {
if (!imagePaths || imagePaths.length === 0) {
return imagePaths;
}
@@ -104,13 +116,15 @@ export class FeatureLoader {
const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId);
await fs.mkdir(featureImagesDir, { recursive: true });
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> = [];
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> =
[];
for (const imagePath of imagePaths) {
try {
const originalPath = typeof imagePath === "string" ? imagePath : imagePath.path;
const originalPath =
typeof imagePath === "string" ? imagePath : imagePath.path;
// Skip if already in feature directory
// Skip if already in feature directory (already absolute path in external storage)
if (originalPath.includes(`/features/${featureId}/images/`)) {
updatedPaths.push(imagePath);
continue;
@@ -125,18 +139,21 @@ export class FeatureLoader {
try {
await fs.access(fullOriginalPath);
} catch {
console.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`);
console.warn(
`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`
);
continue;
}
// Get filename and create new path
// Get filename and create new path in external storage
const filename = path.basename(originalPath);
const newPath = path.join(featureImagesDir, filename);
const relativePath = `.automaker/features/${featureId}/images/${filename}`;
// Copy the file
await fs.copyFile(fullOriginalPath, newPath);
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${relativePath}`);
console.log(
`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`
);
// Try to delete the original temp file
try {
@@ -145,11 +162,11 @@ export class FeatureLoader {
// Ignore errors when deleting temp file
}
// Update the path in the result
// Update the path in the result (use absolute path)
if (typeof imagePath === "string") {
updatedPaths.push(relativePath);
updatedPaths.push(newPath);
} else {
updatedPaths.push({ ...imagePath, path: relativePath });
updatedPaths.push({ ...imagePath, path: newPath });
}
} catch (error) {
console.error(`[FeatureLoader] Failed to migrate image:`, error);
@@ -165,7 +182,7 @@ export class FeatureLoader {
* Get the path to a specific feature folder
*/
getFeatureDir(projectPath: string, featureId: string): string {
return path.join(this.getFeaturesDir(projectPath), featureId);
return getFeatureDir(projectPath, featureId);
}
/**
@@ -267,7 +284,10 @@ export class FeatureLoader {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
console.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
console.error(
`[FeatureLoader] Failed to get feature ${featureId}:`,
error
);
throw error;
}
}
@@ -275,14 +295,16 @@ export class FeatureLoader {
/**
* Create a new feature
*/
async create(projectPath: string, featureData: Partial<Feature>): Promise<Feature> {
async create(
projectPath: string,
featureData: Partial<Feature>
): Promise<Feature> {
const featureId = featureData.id || this.generateFeatureId();
const featureDir = this.getFeatureDir(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
// Ensure features directory exists
const featuresDir = this.getFeaturesDir(projectPath);
await fs.mkdir(featuresDir, { recursive: true });
// Ensure automaker directory exists
await ensureAutomakerDir(projectPath);
// Create feature directory
await fs.mkdir(featureDir, { recursive: true });
@@ -304,7 +326,11 @@ export class FeatureLoader {
};
// Write feature.json
await fs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), "utf-8");
await fs.writeFile(
featureJsonPath,
JSON.stringify(feature, null, 2),
"utf-8"
);
console.log(`[FeatureLoader] Created feature ${featureId}`);
return feature;
@@ -345,7 +371,9 @@ export class FeatureLoader {
const updatedFeature: Feature = {
...feature,
...updates,
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
...(updatedImagePaths !== undefined
? { imagePaths: updatedImagePaths }
: {}),
};
// Write back to file
@@ -370,7 +398,10 @@ export class FeatureLoader {
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
return true;
} catch (error) {
console.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
console.error(
`[FeatureLoader] Failed to delete feature ${featureId}:`,
error
);
return false;
}
}
@@ -416,7 +447,10 @@ export class FeatureLoader {
/**
* Delete agent output for a feature
*/
async deleteAgentOutput(projectPath: string, featureId: string): Promise<void> {
async deleteAgentOutput(
projectPath: string,
featureId: string
): Promise<void> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.unlink(agentOutputPath);