mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
501 lines
16 KiB
JavaScript
501 lines
16 KiB
JavaScript
const path = require("path");
|
|
const fs = require("fs/promises");
|
|
|
|
/**
|
|
* Feature Loader - Handles loading and managing features from individual feature folders
|
|
* Each feature is stored in .automaker/features/{featureId}/feature.json
|
|
*/
|
|
class FeatureLoader {
|
|
/**
|
|
* Get the features directory path
|
|
*/
|
|
getFeaturesDir(projectPath) {
|
|
return path.join(projectPath, ".automaker", "features");
|
|
}
|
|
|
|
/**
|
|
* Get the path to a specific feature folder
|
|
*/
|
|
getFeatureDir(projectPath, featureId) {
|
|
return path.join(this.getFeaturesDir(projectPath), featureId);
|
|
}
|
|
|
|
/**
|
|
* Get the path to a feature's feature.json file
|
|
*/
|
|
getFeatureJsonPath(projectPath, featureId) {
|
|
return path.join(
|
|
this.getFeatureDir(projectPath, featureId),
|
|
"feature.json"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the path to a feature's agent-output.md file
|
|
*/
|
|
getAgentOutputPath(projectPath, featureId) {
|
|
return path.join(
|
|
this.getFeatureDir(projectPath, featureId),
|
|
"agent-output.md"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generate a new feature ID
|
|
*/
|
|
generateFeatureId() {
|
|
return `feature-${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.substring(2, 11)}`;
|
|
}
|
|
|
|
/**
|
|
* Ensure all image paths for a feature are stored within the feature directory
|
|
*/
|
|
async ensureFeatureImages(projectPath, featureId, feature) {
|
|
if (
|
|
!feature ||
|
|
!Array.isArray(feature.imagePaths) ||
|
|
feature.imagePaths.length === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const featureDir = this.getFeatureDir(projectPath, featureId);
|
|
const featureImagesDir = path.join(featureDir, "images");
|
|
await fs.mkdir(featureImagesDir, { recursive: true });
|
|
|
|
const updatedImagePaths = [];
|
|
|
|
for (const entry of feature.imagePaths) {
|
|
const isStringEntry = typeof entry === "string";
|
|
const currentPathValue = isStringEntry ? entry : entry.path;
|
|
|
|
if (!currentPathValue) {
|
|
updatedImagePaths.push(entry);
|
|
continue;
|
|
}
|
|
|
|
let resolvedCurrentPath = currentPathValue;
|
|
if (!path.isAbsolute(resolvedCurrentPath)) {
|
|
resolvedCurrentPath = path.join(projectPath, resolvedCurrentPath);
|
|
}
|
|
resolvedCurrentPath = path.normalize(resolvedCurrentPath);
|
|
|
|
// Skip if file doesn't exist
|
|
try {
|
|
await fs.access(resolvedCurrentPath);
|
|
} catch {
|
|
console.warn(
|
|
`[FeatureLoader] Image file missing for ${featureId}: ${resolvedCurrentPath}`
|
|
);
|
|
updatedImagePaths.push(entry);
|
|
continue;
|
|
}
|
|
|
|
const relativeToFeatureImages = path.relative(
|
|
featureImagesDir,
|
|
resolvedCurrentPath
|
|
);
|
|
const alreadyInFeatureDir =
|
|
relativeToFeatureImages === "" ||
|
|
(!relativeToFeatureImages.startsWith("..") &&
|
|
!path.isAbsolute(relativeToFeatureImages));
|
|
|
|
let finalPath = resolvedCurrentPath;
|
|
|
|
if (!alreadyInFeatureDir) {
|
|
const originalName = path.basename(resolvedCurrentPath);
|
|
let targetPath = path.join(featureImagesDir, originalName);
|
|
|
|
// Avoid overwriting files by appending a counter if needed
|
|
let counter = 1;
|
|
while (true) {
|
|
try {
|
|
await fs.access(targetPath);
|
|
const parsed = path.parse(originalName);
|
|
targetPath = path.join(
|
|
featureImagesDir,
|
|
`${parsed.name}-${counter}${parsed.ext}`
|
|
);
|
|
counter += 1;
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await fs.rename(resolvedCurrentPath, targetPath);
|
|
finalPath = targetPath;
|
|
} catch (error) {
|
|
console.warn(
|
|
`[FeatureLoader] Failed to move image ${resolvedCurrentPath}: ${error.message}`
|
|
);
|
|
updatedImagePaths.push(entry);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
updatedImagePaths.push(
|
|
isStringEntry ? finalPath : { ...entry, path: finalPath }
|
|
);
|
|
}
|
|
|
|
feature.imagePaths = updatedImagePaths;
|
|
}
|
|
|
|
/**
|
|
* Get all features for a project
|
|
*/
|
|
async getAll(projectPath) {
|
|
try {
|
|
const featuresDir = this.getFeaturesDir(projectPath);
|
|
|
|
// Check if features directory exists
|
|
try {
|
|
await fs.access(featuresDir);
|
|
} catch {
|
|
// Directory doesn't exist, return empty array
|
|
return [];
|
|
}
|
|
|
|
// Read all feature directories
|
|
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
|
|
const featureDirs = entries.filter((entry) => entry.isDirectory());
|
|
|
|
// Load each feature
|
|
const features = [];
|
|
for (const dir of featureDirs) {
|
|
const featureId = dir.name;
|
|
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
|
|
|
try {
|
|
// Read feature.json directly - handle ENOENT in catch block
|
|
// This avoids TOCTOU race condition from checking with fs.access first
|
|
const content = await fs.readFile(featureJsonPath, "utf-8");
|
|
const feature = JSON.parse(content);
|
|
|
|
// Validate that the feature has required fields
|
|
if (!feature.id) {
|
|
console.warn(
|
|
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
features.push(feature);
|
|
} catch (error) {
|
|
// Handle different error types appropriately
|
|
if (error.code === "ENOENT") {
|
|
// File doesn't exist - this is expected for incomplete feature directories
|
|
// Skip silently (feature.json not yet created or was removed)
|
|
continue;
|
|
} else if (error instanceof SyntaxError) {
|
|
// JSON parse error - log as warning since file exists but is malformed
|
|
console.warn(
|
|
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
|
|
);
|
|
} else {
|
|
// Other errors - log as error
|
|
console.error(
|
|
`[FeatureLoader] Failed to load feature ${featureId}:`,
|
|
error.message || error
|
|
);
|
|
}
|
|
// Continue loading other features
|
|
}
|
|
}
|
|
|
|
// Sort by creation order (feature IDs contain timestamp)
|
|
features.sort((a, b) => {
|
|
const aTime = a.id ? parseInt(a.id.split("-")[1] || "0") : 0;
|
|
const bTime = b.id ? parseInt(b.id.split("-")[1] || "0") : 0;
|
|
return aTime - bTime;
|
|
});
|
|
|
|
return features;
|
|
} catch (error) {
|
|
console.error("[FeatureLoader] Failed to get all features:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a single feature by ID
|
|
*/
|
|
async get(projectPath, featureId) {
|
|
try {
|
|
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
|
const content = await fs.readFile(featureJsonPath, "utf-8");
|
|
return JSON.parse(content);
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
console.error(
|
|
`[FeatureLoader] Failed to get feature ${featureId}:`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new feature
|
|
*/
|
|
async create(projectPath, featureData) {
|
|
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 });
|
|
|
|
// Create feature directory
|
|
await fs.mkdir(featureDir, { recursive: true });
|
|
|
|
// Ensure feature has an ID
|
|
const feature = { ...featureData, id: featureId };
|
|
|
|
// Move any uploaded images into the feature directory
|
|
await this.ensureFeatureImages(projectPath, featureId, feature);
|
|
|
|
// Write feature.json
|
|
await fs.writeFile(
|
|
featureJsonPath,
|
|
JSON.stringify(feature, null, 2),
|
|
"utf-8"
|
|
);
|
|
|
|
console.log(`[FeatureLoader] Created feature ${featureId}`);
|
|
return feature;
|
|
}
|
|
|
|
/**
|
|
* Update a feature (partial updates supported)
|
|
*/
|
|
async update(projectPath, featureId, updates) {
|
|
try {
|
|
const feature = await this.get(projectPath, featureId);
|
|
if (!feature) {
|
|
throw new Error(`Feature ${featureId} not found`);
|
|
}
|
|
|
|
// Merge updates
|
|
const updatedFeature = { ...feature, ...updates };
|
|
|
|
// Move any new images into the feature directory
|
|
await this.ensureFeatureImages(projectPath, featureId, updatedFeature);
|
|
|
|
// Write back to file
|
|
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
|
await fs.writeFile(
|
|
featureJsonPath,
|
|
JSON.stringify(updatedFeature, null, 2),
|
|
"utf-8"
|
|
);
|
|
|
|
console.log(`[FeatureLoader] Updated feature ${featureId}`);
|
|
return updatedFeature;
|
|
} catch (error) {
|
|
console.error(
|
|
`[FeatureLoader] Failed to update feature ${featureId}:`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a feature and its entire folder
|
|
*/
|
|
async delete(projectPath, featureId) {
|
|
try {
|
|
const featureDir = this.getFeatureDir(projectPath, featureId);
|
|
await fs.rm(featureDir, { recursive: true, force: true });
|
|
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") {
|
|
// Feature doesn't exist, that's fine
|
|
return;
|
|
}
|
|
console.error(
|
|
`[FeatureLoader] Failed to delete feature ${featureId}:`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get agent output for a feature
|
|
*/
|
|
async getAgentOutput(projectPath, featureId) {
|
|
try {
|
|
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
|
|
const content = await fs.readFile(agentOutputPath, "utf-8");
|
|
return content;
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
console.error(
|
|
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Legacy methods for backward compatibility (used by backend services)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Load all features for a project (legacy API)
|
|
* Features are stored in .automaker/features/{id}/feature.json
|
|
*/
|
|
async loadFeatures(projectPath) {
|
|
return await this.getAll(projectPath);
|
|
}
|
|
|
|
/**
|
|
* Update feature status (legacy API)
|
|
* Features are stored in .automaker/features/{id}/feature.json
|
|
* Creates the feature if it doesn't exist.
|
|
* @param {string} featureId - The ID of the feature to update
|
|
* @param {string} status - The new status
|
|
* @param {string} projectPath - Path to the project
|
|
* @param {Object} options - Options object for optional parameters
|
|
* @param {string} [options.summary] - Optional summary of what was done
|
|
* @param {string} [options.error] - Optional error message if feature errored
|
|
* @param {string} [options.description] - Optional detailed description
|
|
* @param {string} [options.category] - Optional category/phase
|
|
* @param {string[]} [options.steps] - Optional array of implementation steps
|
|
*/
|
|
async updateFeatureStatus(featureId, status, projectPath, options = {}) {
|
|
const { summary, error, description, category, steps } = options;
|
|
// Check if feature exists
|
|
const existingFeature = await this.get(projectPath, featureId);
|
|
|
|
if (!existingFeature) {
|
|
// Feature doesn't exist - create it with all required fields
|
|
console.log(`[FeatureLoader] Feature ${featureId} not found - creating new feature`);
|
|
const newFeature = {
|
|
id: featureId,
|
|
title: featureId.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '),
|
|
description: description || summary || '', // Use provided description, fall back to summary
|
|
category: category || "Uncategorized",
|
|
steps: steps || [],
|
|
status: status,
|
|
images: [],
|
|
imagePaths: [],
|
|
skipTests: false, // Auto-generated features should run tests by default
|
|
model: "sonnet",
|
|
thinkingLevel: "none",
|
|
summary: summary || description || '',
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
if (error !== undefined) {
|
|
newFeature.error = error;
|
|
}
|
|
await this.create(projectPath, newFeature);
|
|
console.log(
|
|
`[FeatureLoader] Created feature ${featureId}: status=${status}, category=${category || "Uncategorized"}, steps=${steps?.length || 0}${
|
|
summary ? `, summary="${summary}"` : ""
|
|
}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Feature exists - update it
|
|
const updates = { status };
|
|
if (summary !== undefined) {
|
|
updates.summary = summary;
|
|
// Also update description if it's empty or not set
|
|
if (!existingFeature.description) {
|
|
updates.description = summary;
|
|
}
|
|
}
|
|
if (description !== undefined) {
|
|
updates.description = description;
|
|
}
|
|
if (category !== undefined) {
|
|
updates.category = category;
|
|
}
|
|
if (steps !== undefined && Array.isArray(steps)) {
|
|
updates.steps = steps;
|
|
}
|
|
if (error !== undefined) {
|
|
updates.error = error;
|
|
} else {
|
|
// Clear error if not provided
|
|
if (existingFeature.error) {
|
|
updates.error = undefined;
|
|
}
|
|
}
|
|
|
|
// Ensure required fields exist (for features created before this fix)
|
|
if (!existingFeature.category && !updates.category) updates.category = "Uncategorized";
|
|
if (!existingFeature.steps && !updates.steps) updates.steps = [];
|
|
if (!existingFeature.images) updates.images = [];
|
|
if (!existingFeature.imagePaths) updates.imagePaths = [];
|
|
if (existingFeature.skipTests === undefined) updates.skipTests = false;
|
|
if (!existingFeature.model) updates.model = "sonnet";
|
|
if (!existingFeature.thinkingLevel) updates.thinkingLevel = "none";
|
|
|
|
await this.update(projectPath, featureId, updates);
|
|
console.log(
|
|
`[FeatureLoader] Updated feature ${featureId}: status=${status}${
|
|
category ? `, category="${category}"` : ""
|
|
}${steps ? `, steps=${steps.length}` : ""}${
|
|
summary ? `, summary="${summary}"` : ""
|
|
}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Select the next feature to implement
|
|
* Prioritizes: earlier features in the list that are not verified or waiting_approval
|
|
*/
|
|
selectNextFeature(features) {
|
|
// Find first feature that is in backlog or in_progress status
|
|
// Skip verified and waiting_approval (which needs user input)
|
|
return features.find(
|
|
(f) => f.status !== "verified" && f.status !== "waiting_approval"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update worktree info for a feature (legacy API)
|
|
* Features are stored in .automaker/features/{id}/feature.json
|
|
* @param {string} featureId - The ID of the feature to update
|
|
* @param {string} projectPath - Path to the project
|
|
* @param {string|null} worktreePath - Path to the worktree (null to clear)
|
|
* @param {string|null} branchName - Name of the feature branch (null to clear)
|
|
*/
|
|
async updateFeatureWorktree(
|
|
featureId,
|
|
projectPath,
|
|
worktreePath,
|
|
branchName
|
|
) {
|
|
const updates = {};
|
|
if (worktreePath) {
|
|
updates.worktreePath = worktreePath;
|
|
updates.branchName = branchName;
|
|
} else {
|
|
updates.worktreePath = null;
|
|
updates.branchName = null;
|
|
}
|
|
|
|
await this.update(projectPath, featureId, updates);
|
|
console.log(
|
|
`[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}`
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = new FeatureLoader();
|