mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge remote-tracking branch 'origin/main' into implement-planning/speckits
This commit is contained in:
@@ -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(
|
||||
|
||||
460
apps/server/src/services/dev-server-service.ts
Normal file
460
apps/server/src/services/dev-server-service.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user