mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge pull request #94 from AutoMaker-Org/app_spec_fixes
working on improving the app spec page
This commit is contained in:
54
README.md
54
README.md
@@ -1,6 +1,36 @@
|
||||
> **[!TIP]**
|
||||
>
|
||||
> **Learn more about Agentic Coding!**
|
||||
>
|
||||
> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
|
||||
>
|
||||
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/).
|
||||
|
||||
# Automaker
|
||||
|
||||
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
|
||||
**Stop typing code. Start directing AI agents.**
|
||||
|
||||
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
|
||||
|
||||
## What Makes Automaker Different?
|
||||
|
||||
Traditional development tools help you write code. Automaker helps you **orchestrate AI agents** to build entire features autonomously. Think of it as having a team of AI developers working for you—you define what needs to be built, and Automaker handles the implementation.
|
||||
|
||||
### The Workflow
|
||||
|
||||
1. **Add Features** - Describe features you want built (with text, images, or screenshots)
|
||||
2. **Move to "In Progress"** - Automaker automatically assigns an AI agent to implement the feature
|
||||
3. **Watch It Build** - See real-time progress as the agent writes code, runs tests, and makes changes
|
||||
4. **Review & Verify** - Review the changes, run tests, and approve when ready
|
||||
5. **Ship Faster** - Build entire applications in days, not weeks
|
||||
|
||||
### Powered by Claude Code
|
||||
|
||||
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
The future of software development is **agentic coding**—where developers become architects directing AI agents rather than manual coders. Automaker puts this future in your hands today, letting you experience what it's like to build software 10x faster with AI agents handling the implementation while you focus on architecture and business logic.
|
||||
|
||||
---
|
||||
|
||||
@@ -36,20 +66,22 @@ cd automaker
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Get your Claude Code OAuth token
|
||||
claude setup-token
|
||||
# ⚠️ This prints your token - don't share your screen!
|
||||
|
||||
# 4. Set the token and run
|
||||
export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..."
|
||||
npm run dev:electron
|
||||
# 3. Run Automaker (pick your mode)
|
||||
npm run dev
|
||||
# Then choose your run mode when prompted, or use specific commands below
|
||||
```
|
||||
|
||||
## How to Run
|
||||
|
||||
### Development Modes
|
||||
### Development Mode
|
||||
|
||||
Automaker can be run in several modes:
|
||||
Start Automaker in development mode:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will prompt you to choose your run mode, or you can specify a mode directly:
|
||||
|
||||
#### Electron Desktop App (Recommended)
|
||||
|
||||
@@ -72,8 +104,6 @@ npm run dev:electron:wsl:gpu
|
||||
```bash
|
||||
# Run in web browser (http://localhost:3007)
|
||||
npm run dev:web
|
||||
# or
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
|
||||
@@ -381,8 +381,6 @@ export function Sidebar() {
|
||||
toast.success("App specification created", {
|
||||
description: "Your project is now set up and ready to go!",
|
||||
});
|
||||
// Navigate to spec view to show the new spec
|
||||
setCurrentView("spec");
|
||||
} else if (event.type === "spec_regeneration_error") {
|
||||
setSpecCreatingForProject(null);
|
||||
toast.error("Failed to create specification", {
|
||||
|
||||
@@ -31,6 +31,8 @@ import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileBrowser } from "@/contexts/file-browser-context";
|
||||
|
||||
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
|
||||
|
||||
interface ValidationErrors {
|
||||
projectName?: boolean;
|
||||
workspaceDir?: boolean;
|
||||
@@ -80,6 +82,14 @@ export function NewProjectModal({
|
||||
// Fetch workspace directory when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// First, check localStorage for last used directory
|
||||
const lastUsedDir = localStorage.getItem(LAST_PROJECT_DIR_KEY);
|
||||
if (lastUsedDir) {
|
||||
setWorkspaceDir(lastUsedDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to server config if no saved directory
|
||||
setIsLoadingWorkspace(true);
|
||||
const httpClient = getHttpApiClient();
|
||||
httpClient.workspace
|
||||
@@ -201,6 +211,8 @@ export function NewProjectModal({
|
||||
});
|
||||
if (selectedPath) {
|
||||
setWorkspaceDir(selectedPath);
|
||||
// Save to localStorage for next time
|
||||
localStorage.setItem(LAST_PROJECT_DIR_KEY, selectedPath);
|
||||
// Clear any workspace error when a valid directory is selected
|
||||
if (errors.workspaceDir) {
|
||||
setErrors((prev) => ({ ...prev, workspaceDir: false }));
|
||||
|
||||
@@ -1554,6 +1554,13 @@ export function BoardView() {
|
||||
}
|
||||
});
|
||||
|
||||
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority
|
||||
map.backlog.sort((a, b) => {
|
||||
const aPriority = a.priority ?? 999; // Features without priority go last
|
||||
const bPriority = b.priority ?? 999;
|
||||
return aPriority - bPriority;
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [features, runningAutoTasks, searchQuery]);
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Brain,
|
||||
Flag,
|
||||
} from "lucide-react";
|
||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
@@ -89,6 +90,33 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
||||
return labels[level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats priority for display
|
||||
*/
|
||||
function formatPriority(priority: number | undefined): string | null {
|
||||
if (!priority) return null;
|
||||
const labels: Record<number, string> = {
|
||||
1: "High",
|
||||
2: "Medium",
|
||||
3: "Low",
|
||||
};
|
||||
return labels[priority] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets priority badge color classes
|
||||
*/
|
||||
function getPriorityBadgeClasses(priority: number | undefined): string {
|
||||
if (priority === 1) {
|
||||
return "bg-red-500/20 border border-red-500/50 text-red-400";
|
||||
} else if (priority === 2) {
|
||||
return "bg-yellow-500/20 border border-yellow-500/50 text-yellow-400";
|
||||
} else if (priority === 3) {
|
||||
return "bg-blue-500/20 border border-blue-500/50 text-blue-400";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
interface KanbanCardProps {
|
||||
feature: Feature;
|
||||
onEdit: () => void;
|
||||
@@ -198,6 +226,34 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
return () => clearInterval(interval);
|
||||
}, [feature.justFinishedAt, feature.status, currentTime]);
|
||||
|
||||
// Calculate priority badge position
|
||||
const priorityLabel = formatPriority(feature.priority);
|
||||
const hasPriority = !!priorityLabel;
|
||||
|
||||
// Calculate top position for badges (stacking vertically)
|
||||
const getBadgeTopPosition = (badgeIndex: number) => {
|
||||
return badgeIndex === 0
|
||||
? "top-2"
|
||||
: badgeIndex === 1
|
||||
? "top-8"
|
||||
: badgeIndex === 2
|
||||
? "top-14"
|
||||
: "top-20";
|
||||
};
|
||||
|
||||
// Determine badge positions (must be after isJustFinished is defined)
|
||||
let badgeIndex = 0;
|
||||
const priorityBadgeIndex = hasPriority ? badgeIndex++ : -1;
|
||||
const skipTestsBadgeIndex =
|
||||
feature.skipTests && !feature.error ? badgeIndex++ : -1;
|
||||
const errorBadgeIndex = feature.error ? badgeIndex++ : -1;
|
||||
const justFinishedBadgeIndex = isJustFinished ? badgeIndex++ : -1;
|
||||
const branchBadgeIndex =
|
||||
hasWorktree && !isCurrentAutoTask ? badgeIndex++ : -1;
|
||||
|
||||
// Total number of badges displayed
|
||||
const totalBadgeCount = badgeIndex;
|
||||
|
||||
// Load context file for in_progress, waiting_approval, and verified features
|
||||
useEffect(() => {
|
||||
const loadContext = async () => {
|
||||
@@ -353,12 +409,29 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
)}
|
||||
{/* Priority badge */}
|
||||
{hasPriority && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
|
||||
getBadgeTopPosition(priorityBadgeIndex),
|
||||
"left-2",
|
||||
getPriorityBadgeClasses(feature.priority)
|
||||
)}
|
||||
data-testid={`priority-badge-${feature.id}`}
|
||||
title={`Priority: ${priorityLabel}`}
|
||||
>
|
||||
<Flag className="w-3 h-3" />
|
||||
<span>{priorityLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Skip Tests indicator badge */}
|
||||
{feature.skipTests && !feature.error && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
|
||||
"top-2 left-2",
|
||||
getBadgeTopPosition(skipTestsBadgeIndex),
|
||||
"left-2",
|
||||
"bg-orange-500/20 border border-orange-500/50 text-orange-400"
|
||||
)}
|
||||
data-testid={`skip-tests-badge-${feature.id}`}
|
||||
@@ -373,7 +446,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
|
||||
"top-2 left-2",
|
||||
getBadgeTopPosition(errorBadgeIndex),
|
||||
"left-2",
|
||||
"bg-red-500/20 border border-red-500/50 text-red-400"
|
||||
)}
|
||||
data-testid={`error-badge-${feature.id}`}
|
||||
@@ -388,7 +462,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
|
||||
feature.skipTests ? "top-8 left-2" : "top-2 left-2",
|
||||
getBadgeTopPosition(justFinishedBadgeIndex),
|
||||
"left-2",
|
||||
"bg-green-500/20 border border-green-500/50 text-green-400 animate-pulse"
|
||||
)}
|
||||
data-testid={`just-finished-badge-${feature.id}`}
|
||||
@@ -407,10 +482,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
|
||||
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
|
||||
// Position below other badges if present, otherwise use normal position
|
||||
feature.error || feature.skipTests || isJustFinished
|
||||
? "top-8 left-2"
|
||||
: "top-2 left-2"
|
||||
getBadgeTopPosition(branchBadgeIndex),
|
||||
"left-2"
|
||||
)}
|
||||
data-testid={`branch-badge-${feature.id}`}
|
||||
>
|
||||
@@ -432,11 +505,11 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
className={cn(
|
||||
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
|
||||
// Add extra top padding when badges are present to prevent text overlap
|
||||
(feature.skipTests || feature.error || isJustFinished) && "pt-10",
|
||||
// Add even more top padding when both badges and branch are shown
|
||||
hasWorktree &&
|
||||
(feature.skipTests || feature.error || isJustFinished) &&
|
||||
"pt-14"
|
||||
// Calculate padding based on number of badges
|
||||
totalBadgeCount === 1 && "pt-10",
|
||||
totalBadgeCount === 2 && "pt-14",
|
||||
totalBadgeCount === 3 && "pt-20",
|
||||
totalBadgeCount >= 4 && "pt-24"
|
||||
)}
|
||||
>
|
||||
{isCurrentAutoTask && (
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
Loader2,
|
||||
FilePlus2,
|
||||
AlertCircle,
|
||||
ListPlus,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -47,12 +46,17 @@ export function SpecView() {
|
||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
||||
const [projectDefinition, setProjectDefinition] = useState("");
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [generateFeaturesOnRegenerate, setGenerateFeaturesOnRegenerate] =
|
||||
useState(true);
|
||||
const [analyzeProjectOnRegenerate, setAnalyzeProjectOnRegenerate] =
|
||||
useState(true);
|
||||
|
||||
// Create spec state
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [projectOverview, setProjectOverview] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [generateFeatures, setGenerateFeatures] = useState(true);
|
||||
const [analyzeProjectOnCreate, setAnalyzeProjectOnCreate] = useState(true);
|
||||
|
||||
// Generate features only state
|
||||
const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false);
|
||||
@@ -66,6 +70,7 @@ export function SpecView() {
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const statusCheckRef = useRef<boolean>(false);
|
||||
const stateRestoredRef = useRef<boolean>(false);
|
||||
const pendingStatusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load spec from file
|
||||
const loadSpec = useCallback(async () => {
|
||||
@@ -99,6 +104,26 @@ export function SpecView() {
|
||||
loadSpec();
|
||||
}, [loadSpec]);
|
||||
|
||||
// Reset all spec regeneration state when project changes
|
||||
useEffect(() => {
|
||||
// Clear all state when switching projects
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setIsGeneratingFeatures(false);
|
||||
setCurrentPhase("");
|
||||
setErrorMessage("");
|
||||
setLogs("");
|
||||
logsRef.current = "";
|
||||
stateRestoredRef.current = false;
|
||||
statusCheckRef.current = false;
|
||||
|
||||
// Clear any pending timeout
|
||||
if (pendingStatusTimeoutRef.current) {
|
||||
clearTimeout(pendingStatusTimeoutRef.current);
|
||||
pendingStatusTimeoutRef.current = null;
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
// Check if spec regeneration is running when component mounts or project changes
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
@@ -113,40 +138,44 @@ export function SpecView() {
|
||||
}
|
||||
|
||||
const status = await api.specRegeneration.status();
|
||||
console.log("[SpecView] Status check on mount:", status);
|
||||
console.log(
|
||||
"[SpecView] Status check on mount:",
|
||||
status,
|
||||
"for project:",
|
||||
currentProject.path
|
||||
);
|
||||
|
||||
if (status.success && status.isRunning) {
|
||||
// Something is running - restore state using backend's authoritative phase
|
||||
// Something is running globally, but we can't verify it's for this project
|
||||
// since the backend doesn't track projectPath in status
|
||||
// Tentatively show loader - events will confirm if it's for this project
|
||||
console.log(
|
||||
"[SpecView] Spec generation is running - restoring state",
|
||||
{ phase: status.currentPhase }
|
||||
"[SpecView] Spec generation is running globally. Tentatively showing loader, waiting for events to confirm project match."
|
||||
);
|
||||
|
||||
if (!stateRestoredRef.current) {
|
||||
setIsCreating(true);
|
||||
setIsRegenerating(true);
|
||||
stateRestoredRef.current = true;
|
||||
}
|
||||
|
||||
// Use the backend's currentPhase directly - single source of truth
|
||||
// Tentatively set state - events will confirm or clear it
|
||||
setIsCreating(true);
|
||||
setIsRegenerating(true);
|
||||
if (status.currentPhase) {
|
||||
setCurrentPhase(status.currentPhase);
|
||||
} else {
|
||||
setCurrentPhase("in progress");
|
||||
setCurrentPhase("initialization");
|
||||
}
|
||||
|
||||
// Add resume message to logs if needed
|
||||
if (!logsRef.current) {
|
||||
const resumeMessage =
|
||||
"[Status] Resumed monitoring existing spec generation process...\n";
|
||||
logsRef.current = resumeMessage;
|
||||
setLogs(resumeMessage);
|
||||
} else if (!logsRef.current.includes("Resumed monitoring")) {
|
||||
const resumeMessage =
|
||||
"\n[Status] Resumed monitoring existing spec generation process...\n";
|
||||
logsRef.current = logsRef.current + resumeMessage;
|
||||
setLogs(logsRef.current);
|
||||
// Set a timeout to clear state if no events arrive for this project within 3 seconds
|
||||
if (pendingStatusTimeoutRef.current) {
|
||||
clearTimeout(pendingStatusTimeoutRef.current);
|
||||
}
|
||||
pendingStatusTimeoutRef.current = setTimeout(() => {
|
||||
// If no events confirmed this is for current project, clear state
|
||||
console.log(
|
||||
"[SpecView] No events received for current project - clearing tentative state"
|
||||
);
|
||||
setIsCreating(false);
|
||||
setIsRegenerating(false);
|
||||
setCurrentPhase("");
|
||||
pendingStatusTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
} else if (status.success && !status.isRunning) {
|
||||
// Not running - clear all state
|
||||
setIsCreating(false);
|
||||
@@ -274,6 +303,8 @@ export function SpecView() {
|
||||
|
||||
// Subscribe to spec regeneration events
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
@@ -283,7 +314,9 @@ export function SpecView() {
|
||||
"[SpecView] Regeneration event:",
|
||||
event.type,
|
||||
"for project:",
|
||||
event.projectPath
|
||||
event.projectPath,
|
||||
"current project:",
|
||||
currentProject?.path
|
||||
);
|
||||
|
||||
// Only handle events for the current project
|
||||
@@ -292,7 +325,20 @@ export function SpecView() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending timeout since we received an event for this project
|
||||
if (pendingStatusTimeoutRef.current) {
|
||||
clearTimeout(pendingStatusTimeoutRef.current);
|
||||
pendingStatusTimeoutRef.current = null;
|
||||
console.log(
|
||||
"[SpecView] Event confirmed this is for current project - clearing timeout"
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === "spec_regeneration_progress") {
|
||||
// Ensure state is set when we receive events for this project
|
||||
setIsCreating(true);
|
||||
setIsRegenerating(true);
|
||||
|
||||
// Extract phase from content if present
|
||||
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
|
||||
if (phaseMatch) {
|
||||
@@ -475,7 +521,7 @@ export function SpecView() {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [loadSpec]);
|
||||
}, [currentProject?.path, loadSpec, errorMessage, currentPhase]);
|
||||
|
||||
// Save spec to file
|
||||
const saveSpec = async () => {
|
||||
@@ -505,12 +551,16 @@ export function SpecView() {
|
||||
if (!currentProject || !projectDefinition.trim()) return;
|
||||
|
||||
setIsRegenerating(true);
|
||||
setShowRegenerateDialog(false);
|
||||
setCurrentPhase("initialization");
|
||||
setErrorMessage("");
|
||||
// Reset logs when starting new regeneration
|
||||
logsRef.current = "";
|
||||
setLogs("");
|
||||
console.log("[SpecView] Starting spec regeneration");
|
||||
console.log(
|
||||
"[SpecView] Starting spec regeneration, generateFeatures:",
|
||||
generateFeaturesOnRegenerate
|
||||
);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) {
|
||||
@@ -520,7 +570,9 @@ export function SpecView() {
|
||||
}
|
||||
const result = await api.specRegeneration.generate(
|
||||
currentProject.path,
|
||||
projectDefinition.trim()
|
||||
projectDefinition.trim(),
|
||||
generateFeaturesOnRegenerate,
|
||||
analyzeProjectOnRegenerate
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -570,7 +622,8 @@ export function SpecView() {
|
||||
const result = await api.specRegeneration.create(
|
||||
currentProject.path,
|
||||
projectOverview.trim(),
|
||||
generateFeatures
|
||||
generateFeatures,
|
||||
analyzeProjectOnCreate
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -839,6 +892,33 @@ export function SpecView() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="analyze-project-create"
|
||||
checked={analyzeProjectOnCreate}
|
||||
onCheckedChange={(checked) =>
|
||||
setAnalyzeProjectOnCreate(checked === true)
|
||||
}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="analyze-project-create"
|
||||
className={`text-sm font-medium ${
|
||||
isCreating ? "" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
Analyze current project for additional context
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If checked, the agent will research your existing codebase
|
||||
to understand the tech stack. If unchecked, defaults to
|
||||
TanStack Start, Drizzle ORM, PostgreSQL, shadcn/ui, Tailwind
|
||||
CSS, and React.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="generate-features"
|
||||
@@ -1052,27 +1132,61 @@ export function SpecView() {
|
||||
disabled={isRegenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="analyze-project-regenerate"
|
||||
checked={analyzeProjectOnRegenerate}
|
||||
onCheckedChange={(checked) =>
|
||||
setAnalyzeProjectOnRegenerate(checked === true)
|
||||
}
|
||||
disabled={isRegenerating}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="analyze-project-regenerate"
|
||||
className={`text-sm font-medium ${
|
||||
isRegenerating ? "" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
Analyze current project for additional context
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If checked, the agent will research your existing codebase to
|
||||
understand the tech stack. If unchecked, defaults to TanStack
|
||||
Start, Drizzle ORM, PostgreSQL, shadcn/ui, Tailwind CSS, and
|
||||
React.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="generate-features-regenerate"
|
||||
checked={generateFeaturesOnRegenerate}
|
||||
onCheckedChange={(checked) =>
|
||||
setGenerateFeaturesOnRegenerate(checked === true)
|
||||
}
|
||||
disabled={isRegenerating}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="generate-features-regenerate"
|
||||
className={`text-sm font-medium ${
|
||||
isRegenerating ? "" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
Generate feature list
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically create features in the features folder from the
|
||||
implementation roadmap after the spec is regenerated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-between sm:justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGenerateFeatures}
|
||||
disabled={isRegenerating || isGeneratingFeatures}
|
||||
title="Generate features from the existing app_spec.txt without regenerating the spec"
|
||||
>
|
||||
{isGeneratingFeatures ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ListPlus className="w-4 h-4 mr-2" />
|
||||
Generate Features
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -147,11 +147,14 @@ export interface SpecRegenerationAPI {
|
||||
create: (
|
||||
projectPath: string,
|
||||
projectOverview: string,
|
||||
generateFeatures?: boolean
|
||||
generateFeatures?: boolean,
|
||||
analyzeProject?: boolean
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
generate: (
|
||||
projectPath: string,
|
||||
projectDefinition: string
|
||||
projectDefinition: string,
|
||||
generateFeatures?: boolean,
|
||||
analyzeProject?: boolean
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
generateFeatures: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
@@ -1850,7 +1853,11 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
generate: async (projectPath: string, projectDefinition: string) => {
|
||||
generate: async (
|
||||
projectPath: string,
|
||||
projectDefinition: string,
|
||||
generateFeatures = false
|
||||
) => {
|
||||
if (mockSpecRegenerationRunning) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -1859,10 +1866,16 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
|
||||
}
|
||||
|
||||
mockSpecRegenerationRunning = true;
|
||||
console.log(`[Mock] Regenerating spec for: ${projectPath}`);
|
||||
console.log(
|
||||
`[Mock] Regenerating spec for: ${projectPath}, generateFeatures: ${generateFeatures}`
|
||||
);
|
||||
|
||||
// Simulate async spec regeneration
|
||||
simulateSpecRegeneration(projectPath, projectDefinition);
|
||||
simulateSpecRegeneration(
|
||||
projectPath,
|
||||
projectDefinition,
|
||||
generateFeatures
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
@@ -2007,7 +2020,8 @@ async function simulateSpecCreation(
|
||||
|
||||
async function simulateSpecRegeneration(
|
||||
projectPath: string,
|
||||
projectDefinition: string
|
||||
projectDefinition: string,
|
||||
generateFeatures = false
|
||||
) {
|
||||
mockSpecRegenerationPhase = "initialization";
|
||||
emitSpecRegenerationEvent({
|
||||
@@ -2056,6 +2070,25 @@ async function simulateSpecRegeneration(
|
||||
</core_capabilities>
|
||||
</project_specification>`;
|
||||
|
||||
if (generateFeatures) {
|
||||
mockSpecRegenerationPhase = "spec_complete";
|
||||
emitSpecRegenerationEvent({
|
||||
type: "spec_regeneration_progress",
|
||||
content:
|
||||
"[Phase: spec_complete] Spec regenerated! Generating features...\n",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
mockSpecRegenerationTimeout = setTimeout(resolve, 500);
|
||||
});
|
||||
if (!mockSpecRegenerationRunning) return;
|
||||
|
||||
// Simulate feature generation
|
||||
await simulateFeatureGeneration(projectPath);
|
||||
if (!mockSpecRegenerationRunning) return;
|
||||
}
|
||||
|
||||
mockSpecRegenerationPhase = "complete";
|
||||
emitSpecRegenerationEvent({
|
||||
type: "spec_regeneration_complete",
|
||||
|
||||
@@ -581,17 +581,26 @@ export class HttpApiClient implements ElectronAPI {
|
||||
create: (
|
||||
projectPath: string,
|
||||
projectOverview: string,
|
||||
generateFeatures?: boolean
|
||||
generateFeatures?: boolean,
|
||||
analyzeProject?: boolean
|
||||
) =>
|
||||
this.post("/api/spec-regeneration/create", {
|
||||
projectPath,
|
||||
projectOverview,
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
}),
|
||||
generate: (projectPath: string, projectDefinition: string) =>
|
||||
generate: (
|
||||
projectPath: string,
|
||||
projectDefinition: string,
|
||||
generateFeatures?: boolean,
|
||||
analyzeProject?: boolean
|
||||
) =>
|
||||
this.post("/api/spec-regeneration/generate", {
|
||||
projectPath,
|
||||
projectDefinition,
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
}),
|
||||
generateFeatures: (projectPath: string) =>
|
||||
this.post("/api/spec-regeneration/generate-features", { projectPath }),
|
||||
|
||||
@@ -286,6 +286,7 @@ export interface Feature {
|
||||
model?: AgentModel; // Model to use for this feature (defaults to opus)
|
||||
thinkingLevel?: ThinkingLevel; // Thinking level for extended thinking (defaults to none)
|
||||
error?: string; // Error message if the agent errored during processing
|
||||
priority?: number; // Priority: 1 = high, 2 = medium, 3 = low
|
||||
// Worktree info - set when a feature is being worked on in an isolated git worktree
|
||||
worktreePath?: string; // Path to the worktree directory
|
||||
branchName?: string; // Name of the feature branch
|
||||
|
||||
7
apps/app/src/types/electron.d.ts
vendored
7
apps/app/src/types/electron.d.ts
vendored
@@ -266,7 +266,8 @@ export interface SpecRegenerationAPI {
|
||||
create: (
|
||||
projectPath: string,
|
||||
projectOverview: string,
|
||||
generateFeatures?: boolean
|
||||
generateFeatures?: boolean,
|
||||
analyzeProject?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -274,7 +275,9 @@ export interface SpecRegenerationAPI {
|
||||
|
||||
generate: (
|
||||
projectPath: string,
|
||||
projectDefinition: string
|
||||
projectDefinition: string,
|
||||
generateFeatures?: boolean,
|
||||
analyzeProject?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
|
||||
@@ -27,7 +27,6 @@ import { createGitRoutes } from "./routes/git.js";
|
||||
import { createSetupRoutes } from "./routes/setup.js";
|
||||
import { createSuggestionsRoutes } from "./routes/suggestions.js";
|
||||
import { createModelsRoutes } from "./routes/models.js";
|
||||
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
|
||||
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
||||
import { createWorkspaceRoutes } from "./routes/workspace.js";
|
||||
import { createTemplatesRoutes } from "./routes/templates.js";
|
||||
@@ -41,6 +40,7 @@ import { AgentService } from "./services/agent-service.js";
|
||||
import { FeatureLoader } from "./services/feature-loader.js";
|
||||
import { AutoModeService } from "./services/auto-mode-service.js";
|
||||
import { getTerminalService } from "./services/terminal-service.js";
|
||||
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
74
apps/server/src/lib/logger.ts
Normal file
74
apps/server/src/lib/logger.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Simple logger utility with log levels
|
||||
* Configure via LOG_LEVEL environment variable: error, warn, info, debug
|
||||
* Defaults to 'info' if not set
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
ERROR = 0,
|
||||
WARN = 1,
|
||||
INFO = 2,
|
||||
DEBUG = 3,
|
||||
}
|
||||
|
||||
const LOG_LEVEL_NAMES: Record<string, LogLevel> = {
|
||||
error: LogLevel.ERROR,
|
||||
warn: LogLevel.WARN,
|
||||
info: LogLevel.INFO,
|
||||
debug: LogLevel.DEBUG,
|
||||
};
|
||||
|
||||
let currentLogLevel: LogLevel = LogLevel.INFO;
|
||||
|
||||
// Initialize log level from environment variable
|
||||
const envLogLevel = process.env.LOG_LEVEL?.toLowerCase();
|
||||
if (envLogLevel && LOG_LEVEL_NAMES[envLogLevel] !== undefined) {
|
||||
currentLogLevel = LOG_LEVEL_NAMES[envLogLevel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger instance with a context prefix
|
||||
*/
|
||||
export function createLogger(context: string) {
|
||||
const prefix = `[${context}]`;
|
||||
|
||||
return {
|
||||
error: (...args: unknown[]): void => {
|
||||
if (currentLogLevel >= LogLevel.ERROR) {
|
||||
console.error(prefix, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
warn: (...args: unknown[]): void => {
|
||||
if (currentLogLevel >= LogLevel.WARN) {
|
||||
console.warn(prefix, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
info: (...args: unknown[]): void => {
|
||||
if (currentLogLevel >= LogLevel.INFO) {
|
||||
console.log(prefix, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
debug: (...args: unknown[]): void => {
|
||||
if (currentLogLevel >= LogLevel.DEBUG) {
|
||||
console.log(prefix, "[DEBUG]", ...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current log level
|
||||
*/
|
||||
export function getLogLevel(): LogLevel {
|
||||
return currentLogLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the log level programmatically (useful for testing)
|
||||
*/
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLogLevel = level;
|
||||
}
|
||||
73
apps/server/src/routes/app-spec/common.ts
Normal file
73
apps/server/src/routes/app-spec/common.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Common utilities and state management for spec regeneration
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
|
||||
// Shared state for tracking generation status
|
||||
export let isRunning = false;
|
||||
export let currentAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Set the running state and abort controller
|
||||
*/
|
||||
export function setRunningState(
|
||||
running: boolean,
|
||||
controller: AbortController | null = null
|
||||
): void {
|
||||
isRunning = running;
|
||||
currentAbortController = controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to log authentication status
|
||||
*/
|
||||
export function logAuthStatus(context: string): void {
|
||||
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
logger.info(`${context} - Auth Status:`);
|
||||
logger.info(
|
||||
` CLAUDE_CODE_OAUTH_TOKEN: ${
|
||||
hasOAuthToken
|
||||
? "SET (" +
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 20) +
|
||||
"...)"
|
||||
: "NOT SET"
|
||||
}`
|
||||
);
|
||||
logger.info(
|
||||
` ANTHROPIC_API_KEY: ${
|
||||
hasApiKey
|
||||
? "SET (" + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + "...)"
|
||||
: "NOT SET"
|
||||
}`
|
||||
);
|
||||
|
||||
if (!hasOAuthToken && !hasApiKey) {
|
||||
logger.warn("⚠️ WARNING: No authentication configured! SDK will fail.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error details consistently
|
||||
*/
|
||||
export function logError(error: unknown, context: string): void {
|
||||
logger.error(`❌ ${context}:`);
|
||||
logger.error("Error name:", (error as any)?.name);
|
||||
logger.error("Error message:", (error as Error)?.message);
|
||||
logger.error("Error stack:", (error as Error)?.stack);
|
||||
logger.error(
|
||||
"Full error object:",
|
||||
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message from error object
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Unknown error";
|
||||
}
|
||||
154
apps/server/src/routes/app-spec/generate-features-from-spec.ts
Normal file
154
apps/server/src/routes/app-spec/generate-features-from-spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Generate features from existing app_spec.txt
|
||||
*/
|
||||
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import { logAuthStatus } from "./common.js";
|
||||
import { parseAndCreateFeatures } from "./parse-and-create-features.js";
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
|
||||
export async function generateFeaturesFromSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController
|
||||
): Promise<void> {
|
||||
logger.debug("========== generateFeaturesFromSpec() started ==========");
|
||||
logger.debug("projectPath:", projectPath);
|
||||
|
||||
// Read existing spec
|
||||
const specPath = path.join(projectPath, ".automaker", "app_spec.txt");
|
||||
let spec: string;
|
||||
|
||||
logger.debug("Reading spec from:", specPath);
|
||||
|
||||
try {
|
||||
spec = await fs.readFile(specPath, "utf-8");
|
||||
logger.info(`Spec loaded successfully (${spec.length} chars)`);
|
||||
} catch (readError) {
|
||||
logger.error("❌ Failed to read spec file:", readError);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: "No project spec found. Generate spec first.",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = `Based on this project specification:
|
||||
|
||||
${spec}
|
||||
|
||||
Generate a prioritized list of implementable features. For each feature provide:
|
||||
|
||||
1. **id**: A unique lowercase-hyphenated identifier
|
||||
2. **title**: Short descriptive title
|
||||
3. **description**: What this feature does (2-3 sentences)
|
||||
4. **priority**: 1 (high), 2 (medium), or 3 (low)
|
||||
5. **complexity**: "simple", "moderate", or "complex"
|
||||
6. **dependencies**: Array of feature IDs this depends on (can be empty)
|
||||
|
||||
Format as JSON:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-id",
|
||||
"title": "Feature Title",
|
||||
"description": "What it does",
|
||||
"priority": 1,
|
||||
"complexity": "moderate",
|
||||
"dependencies": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generate 5-15 features that build on each other logically.`;
|
||||
|
||||
logger.debug("Prompt length:", `${prompt.length} chars`);
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: "Analyzing spec and generating features...\n",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
|
||||
const options: Options = {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
maxTurns: 5,
|
||||
cwd: projectPath,
|
||||
allowedTools: ["Read", "Glob"],
|
||||
permissionMode: "acceptEdits",
|
||||
abortController,
|
||||
};
|
||||
|
||||
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
|
||||
logger.info("Calling Claude Agent SDK query() for features...");
|
||||
|
||||
logAuthStatus("Right before SDK query() for features");
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
logger.debug("query() returned stream successfully");
|
||||
} catch (queryError) {
|
||||
logger.error("❌ query() threw an exception:");
|
||||
logger.error("Error:", queryError);
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
let responseText = "";
|
||||
let messageCount = 0;
|
||||
|
||||
logger.debug("Starting to iterate over feature stream...");
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
logger.debug(
|
||||
`Feature stream message #${messageCount}:`,
|
||||
JSON.stringify(
|
||||
{ type: msg.type, subtype: (msg as any).subtype },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText = block.text;
|
||||
logger.debug(
|
||||
`Feature text block received (${block.text.length} chars)`
|
||||
);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
logger.debug("Received success result for features");
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if ((msg as { type: string }).type === "error") {
|
||||
logger.error("❌ Received error message from feature stream:");
|
||||
logger.error("Error message:", JSON.stringify(msg, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
logger.error("❌ Error while iterating feature stream:");
|
||||
logger.error("Stream error:", streamError);
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
|
||||
logger.info(`Feature response length: ${responseText.length} chars`);
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
|
||||
logger.debug("========== generateFeaturesFromSpec() completed ==========");
|
||||
}
|
||||
203
apps/server/src/routes/app-spec/generate-spec.ts
Normal file
203
apps/server/src/routes/app-spec/generate-spec.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Generate app_spec.txt from project overview
|
||||
*/
|
||||
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { getAppSpecFormatInstruction } from "../../lib/app-spec-format.js";
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import { logAuthStatus } from "./common.js";
|
||||
import { generateFeaturesFromSpec } from "./generate-features-from-spec.js";
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
|
||||
export async function generateSpec(
|
||||
projectPath: string,
|
||||
projectOverview: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
generateFeatures?: boolean,
|
||||
analyzeProject?: boolean
|
||||
): Promise<void> {
|
||||
logger.debug("========== generateSpec() started ==========");
|
||||
logger.debug("projectPath:", projectPath);
|
||||
logger.debug("projectOverview length:", `${projectOverview.length} chars`);
|
||||
logger.debug("generateFeatures:", generateFeatures);
|
||||
logger.debug("analyzeProject:", analyzeProject);
|
||||
|
||||
// Build the prompt based on whether we should analyze the project
|
||||
let analysisInstructions = "";
|
||||
let techStackDefaults = "";
|
||||
|
||||
if (analyzeProject !== false) {
|
||||
// Default to true - analyze the project
|
||||
analysisInstructions = `Based on this overview, analyze the project directory (if it exists) and create a comprehensive specification. Use the Read, Glob, and Grep tools to explore the codebase and understand:
|
||||
- Existing technologies and frameworks
|
||||
- Project structure and architecture
|
||||
- Current features and capabilities
|
||||
- Code patterns and conventions`;
|
||||
} else {
|
||||
// Use default tech stack
|
||||
techStackDefaults = `Default Technology Stack:
|
||||
- Framework: TanStack Start (React-based full-stack framework)
|
||||
- Database: PostgreSQL with Drizzle ORM
|
||||
- UI Components: shadcn/ui
|
||||
- Styling: Tailwind CSS
|
||||
- Frontend: React
|
||||
|
||||
Use these technologies as the foundation for the specification.`;
|
||||
}
|
||||
|
||||
const prompt = `You are helping to define a software project specification.
|
||||
|
||||
IMPORTANT: Never ask for clarification or additional information. Use the information provided and make reasonable assumptions to create the best possible specification. If details are missing, infer them based on common patterns and best practices.
|
||||
|
||||
Project Overview:
|
||||
${projectOverview}
|
||||
|
||||
${techStackDefaults}
|
||||
|
||||
${analysisInstructions}
|
||||
|
||||
${getAppSpecFormatInstruction()}`;
|
||||
|
||||
logger.debug("Prompt length:", `${prompt.length} chars`);
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_progress",
|
||||
content: "Starting spec generation...\n",
|
||||
});
|
||||
|
||||
const options: Options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
maxTurns: 10,
|
||||
cwd: projectPath,
|
||||
allowedTools: ["Read", "Glob", "Grep"],
|
||||
permissionMode: "acceptEdits",
|
||||
abortController,
|
||||
};
|
||||
|
||||
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
|
||||
logger.info("Calling Claude Agent SDK query()...");
|
||||
|
||||
// Log auth status right before the SDK call
|
||||
logAuthStatus("Right before SDK query()");
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
logger.debug("query() returned stream successfully");
|
||||
} catch (queryError) {
|
||||
logger.error("❌ query() threw an exception:");
|
||||
logger.error("Error:", queryError);
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
let responseText = "";
|
||||
let messageCount = 0;
|
||||
|
||||
logger.debug("Starting to iterate over stream...");
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
logger.debug(
|
||||
`Stream message #${messageCount}:`,
|
||||
JSON.stringify(
|
||||
{ type: msg.type, subtype: (msg as any).subtype },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText += block.text;
|
||||
logger.debug(`Text block received (${block.text.length} chars)`);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
logger.debug("Tool use:", block.name);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_tool",
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
logger.debug("Received success result");
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if ((msg as { type: string }).type === "error") {
|
||||
logger.error("❌ Received error message from stream:");
|
||||
logger.error("Error message:", JSON.stringify(msg, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
logger.error("❌ Error while iterating stream:");
|
||||
logger.error("Stream error:", streamError);
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
|
||||
logger.info(`Response text length: ${responseText.length} chars`);
|
||||
|
||||
// Save spec
|
||||
const specDir = path.join(projectPath, ".automaker");
|
||||
const specPath = path.join(specDir, "app_spec.txt");
|
||||
|
||||
logger.info("Saving spec to:", specPath);
|
||||
|
||||
await fs.mkdir(specDir, { recursive: true });
|
||||
await fs.writeFile(specPath, responseText);
|
||||
|
||||
logger.info("Spec saved successfully");
|
||||
|
||||
// Emit spec completion event
|
||||
if (generateFeatures) {
|
||||
// If features will be generated, emit intermediate completion
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: "[Phase: spec_complete] Spec created! Generating features...\n",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else {
|
||||
// If no features, emit final completion
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_complete",
|
||||
message: "Spec regeneration complete!",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
// If generate features was requested, generate them from the spec
|
||||
if (generateFeatures) {
|
||||
logger.info("Starting feature generation from spec...");
|
||||
// Create a new abort controller for feature generation
|
||||
const featureAbortController = new AbortController();
|
||||
try {
|
||||
await generateFeaturesFromSpec(
|
||||
projectPath,
|
||||
events,
|
||||
featureAbortController
|
||||
);
|
||||
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
|
||||
} catch (featureError) {
|
||||
logger.error("Feature generation failed:", featureError);
|
||||
// Don't throw - spec generation succeeded, feature generation is optional
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: (featureError as Error).message || "Feature generation failed",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("========== generateSpec() completed ==========");
|
||||
}
|
||||
23
apps/server/src/routes/app-spec/index.ts
Normal file
23
apps/server/src/routes/app-spec/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Spec Regeneration routes - HTTP API for AI-powered spec generation
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { createCreateHandler } from "./routes/create.js";
|
||||
import { createGenerateHandler } from "./routes/generate.js";
|
||||
import { createGenerateFeaturesHandler } from "./routes/generate-features.js";
|
||||
import { createStopHandler } from "./routes/stop.js";
|
||||
import { createStatusHandler } from "./routes/status.js";
|
||||
|
||||
export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/create", createCreateHandler(events));
|
||||
router.post("/generate", createGenerateHandler(events));
|
||||
router.post("/generate-features", createGenerateFeaturesHandler(events));
|
||||
router.post("/stop", createStopHandler());
|
||||
router.get("/status", createStatusHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
83
apps/server/src/routes/app-spec/parse-and-create-features.ts
Normal file
83
apps/server/src/routes/app-spec/parse-and-create-features.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Parse agent response and create feature files
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
|
||||
export async function parseAndCreateFeatures(
|
||||
projectPath: string,
|
||||
content: string,
|
||||
events: EventEmitter
|
||||
): Promise<void> {
|
||||
logger.debug("========== parseAndCreateFeatures() started ==========");
|
||||
logger.debug("Content length:", `${content.length} chars`);
|
||||
|
||||
try {
|
||||
// Extract JSON from response
|
||||
logger.debug("Extracting JSON from response...");
|
||||
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
logger.error("❌ No valid JSON found in response");
|
||||
logger.error("Content preview:", content.substring(0, 500));
|
||||
throw new Error("No valid JSON found in response");
|
||||
}
|
||||
|
||||
logger.debug(`JSON match found (${jsonMatch[0].length} chars)`);
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
logger.info(`Parsed ${parsed.features?.length || 0} features`);
|
||||
|
||||
const featuresDir = path.join(projectPath, ".automaker", "features");
|
||||
await fs.mkdir(featuresDir, { recursive: true });
|
||||
|
||||
const createdFeatures: Array<{ id: string; title: string }> = [];
|
||||
|
||||
for (const feature of parsed.features) {
|
||||
logger.debug("Creating feature:", feature.id);
|
||||
const featureDir = path.join(featuresDir, feature.id);
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
|
||||
const featureData = {
|
||||
id: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
status: "backlog", // Features go to backlog - user must manually start them
|
||||
priority: feature.priority || 2,
|
||||
complexity: feature.complexity || "moderate",
|
||||
dependencies: feature.dependencies || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(featureDir, "feature.json"),
|
||||
JSON.stringify(featureData, null, 2)
|
||||
);
|
||||
|
||||
createdFeatures.push({ id: feature.id, title: feature.title });
|
||||
}
|
||||
|
||||
logger.info(`✓ Created ${createdFeatures.length} features successfully`);
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_complete",
|
||||
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("❌ parseAndCreateFeatures() failed:");
|
||||
logger.error("Error:", error);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: (error as Error).message,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug("========== parseAndCreateFeatures() completed ==========");
|
||||
}
|
||||
95
apps/server/src/routes/app-spec/routes/create.ts
Normal file
95
apps/server/src/routes/app-spec/routes/create.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* POST /create endpoint - Create project spec from overview
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { EventEmitter } from "../../../lib/events.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import {
|
||||
isRunning,
|
||||
setRunningState,
|
||||
logAuthStatus,
|
||||
logError,
|
||||
getErrorMessage,
|
||||
} from "../common.js";
|
||||
import { generateSpec } from "../generate-spec.js";
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
|
||||
export function createCreateHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info("========== /create endpoint called ==========");
|
||||
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
|
||||
|
||||
try {
|
||||
const { projectPath, projectOverview, generateFeatures, analyzeProject } =
|
||||
req.body as {
|
||||
projectPath: string;
|
||||
projectOverview: string;
|
||||
generateFeatures?: boolean;
|
||||
analyzeProject?: boolean;
|
||||
};
|
||||
|
||||
logger.debug("Parsed params:");
|
||||
logger.debug(" projectPath:", projectPath);
|
||||
logger.debug(
|
||||
" projectOverview length:",
|
||||
`${projectOverview?.length || 0} chars`
|
||||
);
|
||||
logger.debug(" generateFeatures:", generateFeatures);
|
||||
logger.debug(" analyzeProject:", analyzeProject);
|
||||
|
||||
if (!projectPath || !projectOverview) {
|
||||
logger.error("Missing required parameters");
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and projectOverview required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
logger.warn("Generation already running, rejecting request");
|
||||
res.json({ success: false, error: "Spec generation already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus("Before starting generation");
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
logger.info("Starting background generation task...");
|
||||
|
||||
// Start generation in background
|
||||
generateSpec(
|
||||
projectPath,
|
||||
projectOverview,
|
||||
events,
|
||||
abortController,
|
||||
generateFeatures,
|
||||
analyzeProject
|
||||
)
|
||||
.catch((error) => {
|
||||
logError(error, "Generation failed with error");
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: getErrorMessage(error),
|
||||
projectPath: projectPath,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info("Generation task finished (success or error)");
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"Returning success response (generation running in background)"
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error("❌ Route handler exception:");
|
||||
logger.error("Error:", error);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
70
apps/server/src/routes/app-spec/routes/generate-features.ts
Normal file
70
apps/server/src/routes/app-spec/routes/generate-features.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* POST /generate-features endpoint - Generate features from existing spec
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { EventEmitter } from "../../../lib/events.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import {
|
||||
isRunning,
|
||||
setRunningState,
|
||||
logAuthStatus,
|
||||
logError,
|
||||
getErrorMessage,
|
||||
} from "../common.js";
|
||||
import { generateFeaturesFromSpec } from "../generate-features-from-spec.js";
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
|
||||
export function createGenerateFeaturesHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info("========== /generate-features endpoint called ==========");
|
||||
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
|
||||
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
logger.debug("projectPath:", projectPath);
|
||||
|
||||
if (!projectPath) {
|
||||
logger.error("Missing projectPath parameter");
|
||||
res.status(400).json({ success: false, error: "projectPath required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
logger.warn("Generation already running, rejecting request");
|
||||
res.json({ success: false, error: "Generation already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus("Before starting feature generation");
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
logger.info("Starting background feature generation task...");
|
||||
|
||||
generateFeaturesFromSpec(projectPath, events, abortController)
|
||||
.catch((error) => {
|
||||
logError(error, "Feature generation failed with error");
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "features_error",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info("Feature generation task finished (success or error)");
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"Returning success response (generation running in background)"
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error("❌ Route handler exception:");
|
||||
logger.error("Error:", error);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
98
apps/server/src/routes/app-spec/routes/generate.ts
Normal file
98
apps/server/src/routes/app-spec/routes/generate.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* POST /generate endpoint - Generate spec from project definition
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { EventEmitter } from "../../../lib/events.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import {
|
||||
isRunning,
|
||||
setRunningState,
|
||||
logAuthStatus,
|
||||
logError,
|
||||
getErrorMessage,
|
||||
} from "../common.js";
|
||||
import { generateSpec } from "../generate-spec.js";
|
||||
|
||||
const logger = createLogger("SpecRegeneration");
|
||||
|
||||
export function createGenerateHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info("========== /generate endpoint called ==========");
|
||||
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
|
||||
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
projectDefinition,
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
} = req.body as {
|
||||
projectPath: string;
|
||||
projectDefinition: string;
|
||||
generateFeatures?: boolean;
|
||||
analyzeProject?: boolean;
|
||||
};
|
||||
|
||||
logger.debug("Parsed params:");
|
||||
logger.debug(" projectPath:", projectPath);
|
||||
logger.debug(
|
||||
" projectDefinition length:",
|
||||
`${projectDefinition?.length || 0} chars`
|
||||
);
|
||||
logger.debug(" generateFeatures:", generateFeatures);
|
||||
logger.debug(" analyzeProject:", analyzeProject);
|
||||
|
||||
if (!projectPath || !projectDefinition) {
|
||||
logger.error("Missing required parameters");
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and projectDefinition required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
logger.warn("Generation already running, rejecting request");
|
||||
res.json({ success: false, error: "Spec generation already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus("Before starting generation");
|
||||
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
logger.info("Starting background generation task...");
|
||||
|
||||
generateSpec(
|
||||
projectPath,
|
||||
projectDefinition,
|
||||
events,
|
||||
abortController,
|
||||
generateFeatures,
|
||||
analyzeProject
|
||||
)
|
||||
.catch((error) => {
|
||||
logError(error, "Generation failed with error");
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: getErrorMessage(error),
|
||||
projectPath: projectPath,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info("Generation task finished (success or error)");
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"Returning success response (generation running in background)"
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error("❌ Route handler exception:");
|
||||
logger.error("Error:", error);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
16
apps/server/src/routes/app-spec/routes/status.ts
Normal file
16
apps/server/src/routes/app-spec/routes/status.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* GET /status endpoint - Get generation status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { isRunning, getErrorMessage } from "../common.js";
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
res.json({ success: true, isRunning });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
24
apps/server/src/routes/app-spec/routes/stop.ts
Normal file
24
apps/server/src/routes/app-spec/routes/stop.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop generation
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
currentAbortController,
|
||||
setRunningState,
|
||||
getErrorMessage,
|
||||
} from "../common.js";
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
setRunningState(false, null);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,764 +0,0 @@
|
||||
/**
|
||||
* Spec Regeneration routes - HTTP API for AI-powered spec generation
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
import { getAppSpecFormatInstruction } from "../lib/app-spec-format.js";
|
||||
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
|
||||
// Helper to log authentication status
|
||||
function logAuthStatus(context: string): void {
|
||||
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
console.log(`[SpecRegeneration] ${context} - Auth Status:`);
|
||||
console.log(
|
||||
`[SpecRegeneration] CLAUDE_CODE_OAUTH_TOKEN: ${
|
||||
hasOAuthToken
|
||||
? "SET (" +
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 20) +
|
||||
"...)"
|
||||
: "NOT SET"
|
||||
}`
|
||||
);
|
||||
console.log(
|
||||
`[SpecRegeneration] ANTHROPIC_API_KEY: ${
|
||||
hasApiKey
|
||||
? "SET (" + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + "...)"
|
||||
: "NOT SET"
|
||||
}`
|
||||
);
|
||||
|
||||
if (!hasOAuthToken && !hasApiKey) {
|
||||
console.error(
|
||||
`[SpecRegeneration] ⚠️ WARNING: No authentication configured! SDK will fail.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
// Create project spec from overview
|
||||
router.post("/create", async (req: Request, res: Response) => {
|
||||
console.log(
|
||||
"[SpecRegeneration] ========== /create endpoint called =========="
|
||||
);
|
||||
console.log(
|
||||
"[SpecRegeneration] Request body:",
|
||||
JSON.stringify(req.body, null, 2)
|
||||
);
|
||||
|
||||
try {
|
||||
const { projectPath, projectOverview, generateFeatures } = req.body as {
|
||||
projectPath: string;
|
||||
projectOverview: string;
|
||||
generateFeatures?: boolean;
|
||||
};
|
||||
|
||||
console.log(`[SpecRegeneration] Parsed params:`);
|
||||
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
|
||||
console.log(
|
||||
`[SpecRegeneration] projectOverview length: ${
|
||||
projectOverview?.length || 0
|
||||
} chars`
|
||||
);
|
||||
console.log(`[SpecRegeneration] generateFeatures: ${generateFeatures}`);
|
||||
|
||||
if (!projectPath || !projectOverview) {
|
||||
console.error("[SpecRegeneration] Missing required parameters");
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and projectOverview required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
console.warn(
|
||||
"[SpecRegeneration] Generation already running, rejecting request"
|
||||
);
|
||||
res.json({ success: false, error: "Spec generation already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus("Before starting generation");
|
||||
|
||||
isRunning = true;
|
||||
currentAbortController = new AbortController();
|
||||
console.log("[SpecRegeneration] Starting background generation task...");
|
||||
|
||||
// Start generation in background
|
||||
generateSpec(
|
||||
projectPath,
|
||||
projectOverview,
|
||||
events,
|
||||
currentAbortController,
|
||||
generateFeatures
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[SpecRegeneration] ❌ Generation failed with error:");
|
||||
console.error("[SpecRegeneration] Error name:", error?.name);
|
||||
console.error("[SpecRegeneration] Error message:", error?.message);
|
||||
console.error("[SpecRegeneration] Error stack:", error?.stack);
|
||||
console.error(
|
||||
"[SpecRegeneration] Full error object:",
|
||||
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
|
||||
);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: error.message || String(error),
|
||||
projectPath: projectPath,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
console.log(
|
||||
"[SpecRegeneration] Generation task finished (success or error)"
|
||||
);
|
||||
isRunning = false;
|
||||
currentAbortController = null;
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[SpecRegeneration] Returning success response (generation running in background)"
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("[SpecRegeneration] ❌ Route handler exception:");
|
||||
console.error("[SpecRegeneration] Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate from project definition
|
||||
router.post("/generate", async (req: Request, res: Response) => {
|
||||
console.log(
|
||||
"[SpecRegeneration] ========== /generate endpoint called =========="
|
||||
);
|
||||
console.log(
|
||||
"[SpecRegeneration] Request body:",
|
||||
JSON.stringify(req.body, null, 2)
|
||||
);
|
||||
|
||||
try {
|
||||
const { projectPath, projectDefinition } = req.body as {
|
||||
projectPath: string;
|
||||
projectDefinition: string;
|
||||
};
|
||||
|
||||
console.log(`[SpecRegeneration] Parsed params:`);
|
||||
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
|
||||
console.log(
|
||||
`[SpecRegeneration] projectDefinition length: ${
|
||||
projectDefinition?.length || 0
|
||||
} chars`
|
||||
);
|
||||
|
||||
if (!projectPath || !projectDefinition) {
|
||||
console.error("[SpecRegeneration] Missing required parameters");
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and projectDefinition required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
console.warn(
|
||||
"[SpecRegeneration] Generation already running, rejecting request"
|
||||
);
|
||||
res.json({ success: false, error: "Spec generation already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus("Before starting generation");
|
||||
|
||||
isRunning = true;
|
||||
currentAbortController = new AbortController();
|
||||
console.log("[SpecRegeneration] Starting background generation task...");
|
||||
|
||||
generateSpec(
|
||||
projectPath,
|
||||
projectDefinition,
|
||||
events,
|
||||
currentAbortController,
|
||||
false
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[SpecRegeneration] ❌ Generation failed with error:");
|
||||
console.error("[SpecRegeneration] Error name:", error?.name);
|
||||
console.error("[SpecRegeneration] Error message:", error?.message);
|
||||
console.error("[SpecRegeneration] Error stack:", error?.stack);
|
||||
console.error(
|
||||
"[SpecRegeneration] Full error object:",
|
||||
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
|
||||
);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: error.message || String(error),
|
||||
projectPath: projectPath,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
console.log(
|
||||
"[SpecRegeneration] Generation task finished (success or error)"
|
||||
);
|
||||
isRunning = false;
|
||||
currentAbortController = null;
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[SpecRegeneration] Returning success response (generation running in background)"
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("[SpecRegeneration] ❌ Route handler exception:");
|
||||
console.error("[SpecRegeneration] Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate features from existing spec
|
||||
router.post("/generate-features", async (req: Request, res: Response) => {
|
||||
console.log(
|
||||
"[SpecRegeneration] ========== /generate-features endpoint called =========="
|
||||
);
|
||||
console.log(
|
||||
"[SpecRegeneration] Request body:",
|
||||
JSON.stringify(req.body, null, 2)
|
||||
);
|
||||
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
|
||||
|
||||
if (!projectPath) {
|
||||
console.error("[SpecRegeneration] Missing projectPath parameter");
|
||||
res.status(400).json({ success: false, error: "projectPath required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
console.warn(
|
||||
"[SpecRegeneration] Generation already running, rejecting request"
|
||||
);
|
||||
res.json({ success: false, error: "Generation already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
logAuthStatus("Before starting feature generation");
|
||||
|
||||
isRunning = true;
|
||||
currentAbortController = new AbortController();
|
||||
console.log(
|
||||
"[SpecRegeneration] Starting background feature generation task..."
|
||||
);
|
||||
|
||||
generateFeaturesFromSpec(projectPath, events, currentAbortController)
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[SpecRegeneration] ❌ Feature generation failed with error:"
|
||||
);
|
||||
console.error("[SpecRegeneration] Error name:", error?.name);
|
||||
console.error("[SpecRegeneration] Error message:", error?.message);
|
||||
console.error("[SpecRegeneration] Error stack:", error?.stack);
|
||||
console.error(
|
||||
"[SpecRegeneration] Full error object:",
|
||||
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
|
||||
);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "features_error",
|
||||
error: error.message || String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
console.log(
|
||||
"[SpecRegeneration] Feature generation task finished (success or error)"
|
||||
);
|
||||
isRunning = false;
|
||||
currentAbortController = null;
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[SpecRegeneration] Returning success response (generation running in background)"
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("[SpecRegeneration] ❌ Route handler exception:");
|
||||
console.error("[SpecRegeneration] Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop generation
|
||||
router.post("/stop", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
isRunning = false;
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get status
|
||||
router.get("/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ success: true, isRunning });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
async function generateSpec(
|
||||
projectPath: string,
|
||||
projectOverview: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
generateFeatures?: boolean
|
||||
) {
|
||||
console.log(
|
||||
"[SpecRegeneration] ========== generateSpec() started =========="
|
||||
);
|
||||
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
|
||||
console.log(
|
||||
`[SpecRegeneration] projectOverview length: ${projectOverview.length} chars`
|
||||
);
|
||||
console.log(`[SpecRegeneration] generateFeatures: ${generateFeatures}`);
|
||||
|
||||
const prompt = `You are helping to define a software project specification.
|
||||
|
||||
Project Overview:
|
||||
${projectOverview}
|
||||
|
||||
Based on this overview, analyze the project directory (if it exists) and create a comprehensive specification. Use the Read, Glob, and Grep tools to explore the codebase and understand:
|
||||
- Existing technologies and frameworks
|
||||
- Project structure and architecture
|
||||
- Current features and capabilities
|
||||
- Code patterns and conventions
|
||||
|
||||
${getAppSpecFormatInstruction()}`;
|
||||
|
||||
console.log(`[SpecRegeneration] Prompt length: ${prompt.length} chars`);
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_progress",
|
||||
content: "Starting spec generation...\n",
|
||||
});
|
||||
|
||||
const options: Options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
maxTurns: 10,
|
||||
cwd: projectPath,
|
||||
allowedTools: ["Read", "Glob", "Grep"],
|
||||
permissionMode: "acceptEdits",
|
||||
abortController,
|
||||
};
|
||||
|
||||
console.log(
|
||||
"[SpecRegeneration] SDK Options:",
|
||||
JSON.stringify(options, null, 2)
|
||||
);
|
||||
console.log("[SpecRegeneration] Calling Claude Agent SDK query()...");
|
||||
|
||||
// Log auth status right before the SDK call
|
||||
logAuthStatus("Right before SDK query()");
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
console.log("[SpecRegeneration] query() returned stream successfully");
|
||||
} catch (queryError) {
|
||||
console.error("[SpecRegeneration] ❌ query() threw an exception:");
|
||||
console.error("[SpecRegeneration] Error:", queryError);
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
let responseText = "";
|
||||
let messageCount = 0;
|
||||
|
||||
console.log("[SpecRegeneration] Starting to iterate over stream...");
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
console.log(
|
||||
`[SpecRegeneration] Stream message #${messageCount}:`,
|
||||
JSON.stringify(
|
||||
{ type: msg.type, subtype: (msg as any).subtype },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText = block.text;
|
||||
console.log(
|
||||
`[SpecRegeneration] Text block received (${block.text.length} chars)`
|
||||
);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
console.log(`[SpecRegeneration] Tool use: ${block.name}`);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_tool",
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
console.log("[SpecRegeneration] Received success result");
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if ((msg as { type: string }).type === "error") {
|
||||
console.error(
|
||||
"[SpecRegeneration] ❌ Received error message from stream:"
|
||||
);
|
||||
console.error(
|
||||
"[SpecRegeneration] Error message:",
|
||||
JSON.stringify(msg, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
console.error("[SpecRegeneration] ❌ Error while iterating stream:");
|
||||
console.error("[SpecRegeneration] Stream error:", streamError);
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SpecRegeneration] Stream iteration complete. Total messages: ${messageCount}`
|
||||
);
|
||||
console.log(
|
||||
`[SpecRegeneration] Response text length: ${responseText.length} chars`
|
||||
);
|
||||
|
||||
// Save spec
|
||||
const specDir = path.join(projectPath, ".automaker");
|
||||
const specPath = path.join(specDir, "app_spec.txt");
|
||||
|
||||
console.log(`[SpecRegeneration] Saving spec to: ${specPath}`);
|
||||
|
||||
await fs.mkdir(specDir, { recursive: true });
|
||||
await fs.writeFile(specPath, responseText);
|
||||
|
||||
console.log("[SpecRegeneration] Spec saved successfully");
|
||||
|
||||
// Emit spec completion event
|
||||
if (generateFeatures) {
|
||||
// If features will be generated, emit intermediate completion
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: "[Phase: spec_complete] Spec created! Generating features...\n",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else {
|
||||
// If no features, emit final completion
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_complete",
|
||||
message: "Spec regeneration complete!",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
// If generate features was requested, generate them from the spec
|
||||
if (generateFeatures) {
|
||||
console.log("[SpecRegeneration] Starting feature generation from spec...");
|
||||
// Create a new abort controller for feature generation
|
||||
const featureAbortController = new AbortController();
|
||||
try {
|
||||
await generateFeaturesFromSpec(
|
||||
projectPath,
|
||||
events,
|
||||
featureAbortController
|
||||
);
|
||||
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
|
||||
} catch (featureError) {
|
||||
console.error(
|
||||
"[SpecRegeneration] Feature generation failed:",
|
||||
featureError
|
||||
);
|
||||
// Don't throw - spec generation succeeded, feature generation is optional
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: (featureError as Error).message || "Feature generation failed",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[SpecRegeneration] ========== generateSpec() completed =========="
|
||||
);
|
||||
}
|
||||
|
||||
async function generateFeaturesFromSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController
|
||||
) {
|
||||
console.log(
|
||||
"[SpecRegeneration] ========== generateFeaturesFromSpec() started =========="
|
||||
);
|
||||
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
|
||||
|
||||
// Read existing spec
|
||||
const specPath = path.join(projectPath, ".automaker", "app_spec.txt");
|
||||
let spec: string;
|
||||
|
||||
console.log(`[SpecRegeneration] Reading spec from: ${specPath}`);
|
||||
|
||||
try {
|
||||
spec = await fs.readFile(specPath, "utf-8");
|
||||
console.log(
|
||||
`[SpecRegeneration] Spec loaded successfully (${spec.length} chars)`
|
||||
);
|
||||
} catch (readError) {
|
||||
console.error("[SpecRegeneration] ❌ Failed to read spec file:", readError);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: "No project spec found. Generate spec first.",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = `Based on this project specification:
|
||||
|
||||
${spec}
|
||||
|
||||
Generate a prioritized list of implementable features. For each feature provide:
|
||||
|
||||
1. **id**: A unique lowercase-hyphenated identifier
|
||||
2. **title**: Short descriptive title
|
||||
3. **description**: What this feature does (2-3 sentences)
|
||||
4. **priority**: 1 (high), 2 (medium), or 3 (low)
|
||||
5. **complexity**: "simple", "moderate", or "complex"
|
||||
6. **dependencies**: Array of feature IDs this depends on (can be empty)
|
||||
|
||||
Format as JSON:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-id",
|
||||
"title": "Feature Title",
|
||||
"description": "What it does",
|
||||
"priority": 1,
|
||||
"complexity": "moderate",
|
||||
"dependencies": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generate 5-15 features that build on each other logically.`;
|
||||
|
||||
console.log(`[SpecRegeneration] Prompt length: ${prompt.length} chars`);
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: "Analyzing spec and generating features...\n",
|
||||
projectPath: projectPath,
|
||||
});
|
||||
|
||||
const options: Options = {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
maxTurns: 5,
|
||||
cwd: projectPath,
|
||||
allowedTools: ["Read", "Glob"],
|
||||
permissionMode: "acceptEdits",
|
||||
abortController,
|
||||
};
|
||||
|
||||
console.log(
|
||||
"[SpecRegeneration] SDK Options:",
|
||||
JSON.stringify(options, null, 2)
|
||||
);
|
||||
console.log(
|
||||
"[SpecRegeneration] Calling Claude Agent SDK query() for features..."
|
||||
);
|
||||
|
||||
logAuthStatus("Right before SDK query() for features");
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
console.log("[SpecRegeneration] query() returned stream successfully");
|
||||
} catch (queryError) {
|
||||
console.error("[SpecRegeneration] ❌ query() threw an exception:");
|
||||
console.error("[SpecRegeneration] Error:", queryError);
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
let responseText = "";
|
||||
let messageCount = 0;
|
||||
|
||||
console.log("[SpecRegeneration] Starting to iterate over feature stream...");
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
console.log(
|
||||
`[SpecRegeneration] Feature stream message #${messageCount}:`,
|
||||
JSON.stringify(
|
||||
{ type: msg.type, subtype: (msg as any).subtype },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText = block.text;
|
||||
console.log(
|
||||
`[SpecRegeneration] Feature text block received (${block.text.length} chars)`
|
||||
);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_progress",
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && (msg as any).subtype === "success") {
|
||||
console.log("[SpecRegeneration] Received success result for features");
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if ((msg as { type: string }).type === "error") {
|
||||
console.error(
|
||||
"[SpecRegeneration] ❌ Received error message from feature stream:"
|
||||
);
|
||||
console.error(
|
||||
"[SpecRegeneration] Error message:",
|
||||
JSON.stringify(msg, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
console.error(
|
||||
"[SpecRegeneration] ❌ Error while iterating feature stream:"
|
||||
);
|
||||
console.error("[SpecRegeneration] Stream error:", streamError);
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SpecRegeneration] Feature stream complete. Total messages: ${messageCount}`
|
||||
);
|
||||
console.log(
|
||||
`[SpecRegeneration] Feature response length: ${responseText.length} chars`
|
||||
);
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
|
||||
console.log(
|
||||
"[SpecRegeneration] ========== generateFeaturesFromSpec() completed =========="
|
||||
);
|
||||
}
|
||||
|
||||
async function parseAndCreateFeatures(
|
||||
projectPath: string,
|
||||
content: string,
|
||||
events: EventEmitter
|
||||
) {
|
||||
console.log(
|
||||
"[SpecRegeneration] ========== parseAndCreateFeatures() started =========="
|
||||
);
|
||||
console.log(`[SpecRegeneration] Content length: ${content.length} chars`);
|
||||
|
||||
try {
|
||||
// Extract JSON from response
|
||||
console.log("[SpecRegeneration] Extracting JSON from response...");
|
||||
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
console.error("[SpecRegeneration] ❌ No valid JSON found in response");
|
||||
console.error(
|
||||
"[SpecRegeneration] Content preview:",
|
||||
content.substring(0, 500)
|
||||
);
|
||||
throw new Error("No valid JSON found in response");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SpecRegeneration] JSON match found (${jsonMatch[0].length} chars)`
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
console.log(
|
||||
`[SpecRegeneration] Parsed ${parsed.features?.length || 0} features`
|
||||
);
|
||||
|
||||
const featuresDir = path.join(projectPath, ".automaker", "features");
|
||||
await fs.mkdir(featuresDir, { recursive: true });
|
||||
|
||||
const createdFeatures: Array<{ id: string; title: string }> = [];
|
||||
|
||||
for (const feature of parsed.features) {
|
||||
console.log(`[SpecRegeneration] Creating feature: ${feature.id}`);
|
||||
const featureDir = path.join(featuresDir, feature.id);
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
|
||||
const featureData = {
|
||||
id: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
status: "backlog", // Features go to backlog - user must manually start them
|
||||
priority: feature.priority || 2,
|
||||
complexity: feature.complexity || "moderate",
|
||||
dependencies: feature.dependencies || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(featureDir, "feature.json"),
|
||||
JSON.stringify(featureData, null, 2)
|
||||
);
|
||||
|
||||
createdFeatures.push({ id: feature.id, title: feature.title });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SpecRegeneration] ✓ Created ${createdFeatures.length} features successfully`
|
||||
);
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_complete",
|
||||
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[SpecRegeneration] ❌ parseAndCreateFeatures() failed:");
|
||||
console.error("[SpecRegeneration] Error:", error);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_regeneration_error",
|
||||
error: (error as Error).message,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[SpecRegeneration] ========== parseAndCreateFeatures() completed =========="
|
||||
);
|
||||
}
|
||||
@@ -1129,7 +1129,7 @@ When done, summarize what you implemented and any notes for the developer.`;
|
||||
if (msg.type === "assistant" && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText = block.text || "";
|
||||
responseText += block.text || "";
|
||||
|
||||
// Check for authentication errors in the response
|
||||
if (
|
||||
|
||||
103
apps/server/tests/unit/routes/app-spec/common.test.ts
Normal file
103
apps/server/tests/unit/routes/app-spec/common.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
setRunningState,
|
||||
getErrorMessage,
|
||||
isRunning,
|
||||
currentAbortController,
|
||||
} from "@/routes/app-spec/common.js";
|
||||
|
||||
describe("app-spec/common.ts", () => {
|
||||
beforeEach(() => {
|
||||
// Reset state before each test
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
describe("setRunningState", () => {
|
||||
it("should set isRunning to true when running is true", () => {
|
||||
setRunningState(true);
|
||||
expect(isRunning).toBe(true);
|
||||
});
|
||||
|
||||
it("should set isRunning to false when running is false", () => {
|
||||
setRunningState(true);
|
||||
setRunningState(false);
|
||||
expect(isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it("should set currentAbortController when provided", () => {
|
||||
const controller = new AbortController();
|
||||
setRunningState(true, controller);
|
||||
expect(currentAbortController).toBe(controller);
|
||||
});
|
||||
|
||||
it("should set currentAbortController to null when not provided", () => {
|
||||
const controller = new AbortController();
|
||||
setRunningState(true, controller);
|
||||
setRunningState(false);
|
||||
expect(currentAbortController).toBe(null);
|
||||
});
|
||||
|
||||
it("should set currentAbortController to null when explicitly passed null", () => {
|
||||
const controller = new AbortController();
|
||||
setRunningState(true, controller);
|
||||
setRunningState(true, null);
|
||||
expect(currentAbortController).toBe(null);
|
||||
});
|
||||
|
||||
it("should update state multiple times correctly", () => {
|
||||
const controller1 = new AbortController();
|
||||
const controller2 = new AbortController();
|
||||
|
||||
setRunningState(true, controller1);
|
||||
expect(isRunning).toBe(true);
|
||||
expect(currentAbortController).toBe(controller1);
|
||||
|
||||
setRunningState(true, controller2);
|
||||
expect(isRunning).toBe(true);
|
||||
expect(currentAbortController).toBe(controller2);
|
||||
|
||||
setRunningState(false, null);
|
||||
expect(isRunning).toBe(false);
|
||||
expect(currentAbortController).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getErrorMessage", () => {
|
||||
it("should return message from Error instance", () => {
|
||||
const error = new Error("Test error message");
|
||||
expect(getErrorMessage(error)).toBe("Test error message");
|
||||
});
|
||||
|
||||
it("should return 'Unknown error' for non-Error objects", () => {
|
||||
expect(getErrorMessage("string error")).toBe("Unknown error");
|
||||
expect(getErrorMessage(123)).toBe("Unknown error");
|
||||
expect(getErrorMessage(null)).toBe("Unknown error");
|
||||
expect(getErrorMessage(undefined)).toBe("Unknown error");
|
||||
expect(getErrorMessage({})).toBe("Unknown error");
|
||||
expect(getErrorMessage([])).toBe("Unknown error");
|
||||
});
|
||||
|
||||
it("should return message from Error with empty message", () => {
|
||||
const error = new Error("");
|
||||
expect(getErrorMessage(error)).toBe("");
|
||||
});
|
||||
|
||||
it("should handle Error objects with custom properties", () => {
|
||||
const error = new Error("Base message");
|
||||
(error as any).customProp = "custom value";
|
||||
expect(getErrorMessage(error)).toBe("Base message");
|
||||
});
|
||||
|
||||
it("should handle Error objects created with different constructors", () => {
|
||||
class CustomError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CustomError";
|
||||
}
|
||||
}
|
||||
|
||||
const customError = new CustomError("Custom error message");
|
||||
expect(getErrorMessage(customError)).toBe("Custom error message");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("app-spec/parse-and-create-features.ts - JSON extraction", () => {
|
||||
// Test the JSON extraction regex pattern used in parseAndCreateFeatures
|
||||
const jsonExtractionPattern = /\{[\s\S]*"features"[\s\S]*\}/;
|
||||
|
||||
describe("JSON extraction regex", () => {
|
||||
it("should extract JSON with features array", () => {
|
||||
const content = `Here is the response:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-1",
|
||||
"title": "Test Feature",
|
||||
"description": "A test feature",
|
||||
"priority": 1,
|
||||
"complexity": "simple",
|
||||
"dependencies": []
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const match = content.match(jsonExtractionPattern);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toContain('"features"');
|
||||
expect(match![0]).toContain('"id": "feature-1"');
|
||||
});
|
||||
|
||||
it("should extract JSON with multiple features", () => {
|
||||
const content = `Some text before
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-1",
|
||||
"title": "Feature 1"
|
||||
},
|
||||
{
|
||||
"id": "feature-2",
|
||||
"title": "Feature 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
Some text after`;
|
||||
|
||||
const match = content.match(jsonExtractionPattern);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toContain('"features"');
|
||||
expect(match![0]).toContain('"feature-1"');
|
||||
expect(match![0]).toContain('"feature-2"');
|
||||
});
|
||||
|
||||
it("should extract JSON with nested objects and arrays", () => {
|
||||
const content = `Response:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-1",
|
||||
"dependencies": ["dep-1", "dep-2"],
|
||||
"metadata": {
|
||||
"tags": ["tag1", "tag2"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const match = content.match(jsonExtractionPattern);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toContain('"dependencies"');
|
||||
expect(match![0]).toContain('"dep-1"');
|
||||
});
|
||||
|
||||
it("should handle JSON with whitespace and newlines", () => {
|
||||
const content = `Text before
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-1",
|
||||
"title": "Feature",
|
||||
"description": "A feature\nwith newlines"
|
||||
}
|
||||
]
|
||||
}
|
||||
Text after`;
|
||||
|
||||
const match = content.match(jsonExtractionPattern);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toContain('"features"');
|
||||
});
|
||||
|
||||
it("should extract JSON when features array is empty", () => {
|
||||
const content = `Response:
|
||||
{
|
||||
"features": []
|
||||
}`;
|
||||
|
||||
const match = content.match(jsonExtractionPattern);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toContain('"features"');
|
||||
expect(match![0]).toContain("[]");
|
||||
});
|
||||
|
||||
it("should not match content without features key", () => {
|
||||
const content = `{
|
||||
"otherKey": "value"
|
||||
}`;
|
||||
|
||||
const match = content.match(jsonExtractionPattern);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("should not match content without JSON structure", () => {
|
||||
const content = "Just plain text with features mentioned";
|
||||
const match = content.match(jsonExtractionPattern);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("should extract JSON when features key appears multiple times", () => {
|
||||
const content = `Before:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-1",
|
||||
"title": "Feature"
|
||||
}
|
||||
]
|
||||
}
|
||||
After: The word "features" appears again`;
|
||||
|
||||
const match = content.match(jsonExtractionPattern);
|
||||
expect(match).not.toBeNull();
|
||||
// Should match from first { to last }
|
||||
expect(match![0]).toContain('"features"');
|
||||
});
|
||||
|
||||
it("should handle JSON with escaped quotes", () => {
|
||||
const content = `{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-1",
|
||||
"description": "A feature with \\"quotes\\""
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const match = content.match(jsonExtractionPattern);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toContain('"features"');
|
||||
});
|
||||
|
||||
it("should extract JSON with complex nested structure", () => {
|
||||
const content = `Response:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-1",
|
||||
"dependencies": [
|
||||
{
|
||||
"id": "dep-1",
|
||||
"type": "required"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"tags": ["tag1"],
|
||||
"notes": "Some notes"
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0"
|
||||
}
|
||||
}`;
|
||||
|
||||
const match = content.match(jsonExtractionPattern);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toContain('"features"');
|
||||
expect(match![0]).toContain('"metadata"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSON parsing validation", () => {
|
||||
it("should parse valid feature JSON structure", () => {
|
||||
const validJson = `{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-1",
|
||||
"title": "Test Feature",
|
||||
"description": "A test feature",
|
||||
"priority": 1,
|
||||
"complexity": "simple",
|
||||
"dependencies": []
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const parsed = JSON.parse(validJson);
|
||||
expect(parsed.features).toBeDefined();
|
||||
expect(Array.isArray(parsed.features)).toBe(true);
|
||||
expect(parsed.features.length).toBe(1);
|
||||
expect(parsed.features[0].id).toBe("feature-1");
|
||||
expect(parsed.features[0].title).toBe("Test Feature");
|
||||
});
|
||||
|
||||
it("should handle features with optional fields", () => {
|
||||
const jsonWithOptionalFields = `{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-1",
|
||||
"title": "Feature",
|
||||
"priority": 2,
|
||||
"complexity": "moderate"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const parsed = JSON.parse(jsonWithOptionalFields);
|
||||
expect(parsed.features[0].id).toBe("feature-1");
|
||||
expect(parsed.features[0].priority).toBe(2);
|
||||
// description and dependencies are optional
|
||||
expect(parsed.features[0].description).toBeUndefined();
|
||||
expect(parsed.features[0].dependencies).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle features with dependencies", () => {
|
||||
const jsonWithDeps = `{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-1",
|
||||
"title": "Feature 1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "feature-2",
|
||||
"title": "Feature 2",
|
||||
"dependencies": ["feature-1"]
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const parsed = JSON.parse(jsonWithDeps);
|
||||
expect(parsed.features[0].dependencies).toEqual([]);
|
||||
expect(parsed.features[1].dependencies).toEqual(["feature-1"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
582
docs/server/route-organization.md
Normal file
582
docs/server/route-organization.md
Normal file
@@ -0,0 +1,582 @@
|
||||
# Route Organization Pattern
|
||||
|
||||
This document describes the pattern used for organizing Express routes into modular, maintainable file structures. This pattern is exemplified by the `app-spec` route module and should be applied to other route modules for consistency and maintainability.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Directory Structure](#directory-structure)
|
||||
3. [File Organization Principles](#file-organization-principles)
|
||||
4. [File Types and Their Roles](#file-types-and-their-roles)
|
||||
5. [Implementation Guidelines](#implementation-guidelines)
|
||||
6. [Example: app-spec Module](#example-app-spec-module)
|
||||
7. [Migration Guide](#migration-guide)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The route organization pattern separates concerns into:
|
||||
|
||||
- **Route handlers** - Thin HTTP request/response handlers in `routes/` subdirectory
|
||||
- **Business logic** - Extracted into standalone function files
|
||||
- **Shared utilities** - Common functions and state in `common.ts`
|
||||
- **Route registration** - Centralized in `index.ts`
|
||||
|
||||
This pattern improves:
|
||||
|
||||
- **Maintainability** - Clear separation of concerns
|
||||
- **Testability** - Functions can be tested independently
|
||||
- **Reusability** - Business logic can be reused across routes
|
||||
- **Readability** - Smaller, focused files are easier to understand
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
routes/
|
||||
└── {module-name}/
|
||||
├── index.ts # Route registration & export
|
||||
├── common.ts # Shared utilities & state
|
||||
├── {business-function}.ts # Extracted business logic functions
|
||||
└── routes/
|
||||
├── {endpoint-name}.ts # Individual route handlers
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Example Structure
|
||||
|
||||
```
|
||||
routes/
|
||||
└── app-spec/
|
||||
├── index.ts # createSpecRegenerationRoutes()
|
||||
├── common.ts # Shared state, logging utilities
|
||||
├── generate-spec.ts # generateSpec() function
|
||||
├── generate-features-from-spec.ts # generateFeaturesFromSpec() function
|
||||
├── parse-and-create-features.ts # parseAndCreateFeatures() function
|
||||
└── routes/
|
||||
├── create.ts # POST /create handler
|
||||
├── generate.ts # POST /generate handler
|
||||
├── generate-features.ts # POST /generate-features handler
|
||||
├── status.ts # GET /status handler
|
||||
└── stop.ts # POST /stop handler
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Organization Principles
|
||||
|
||||
### 1. **Single Responsibility**
|
||||
|
||||
Each file should have one clear purpose:
|
||||
|
||||
- Route handlers handle HTTP concerns (request/response, validation)
|
||||
- Business logic files contain domain-specific operations
|
||||
- Common files contain shared utilities and state
|
||||
|
||||
### 2. **Separation of Concerns**
|
||||
|
||||
- **HTTP Layer** (`routes/*.ts`) - Request parsing, response formatting, status codes
|
||||
- **Business Logic** (`*.ts` in root) - Core functionality, domain operations
|
||||
- **Shared State** (`common.ts`) - Module-level state, cross-cutting utilities
|
||||
|
||||
### 3. **File Size Management**
|
||||
|
||||
- Extract functions when files exceed ~150-200 lines
|
||||
- Extract when a function is reusable across multiple routes
|
||||
- Extract when a function has complex logic that deserves its own file
|
||||
|
||||
### 4. **Naming Conventions**
|
||||
|
||||
- Route handlers: `{verb}-{resource}.ts` or `{action}.ts` (e.g., `create.ts`, `status.ts`)
|
||||
- Business logic: `{action}-{noun}.ts` or `{verb}-{noun}.ts` (e.g., `generate-spec.ts`)
|
||||
- Common utilities: Always `common.ts`
|
||||
|
||||
---
|
||||
|
||||
## File Types and Their Roles
|
||||
|
||||
### `index.ts` - Route Registration
|
||||
|
||||
**Purpose**: Central export point that creates and configures the Express router.
|
||||
|
||||
**Responsibilities**:
|
||||
|
||||
- Import route handler factories
|
||||
- Create Express Router instance
|
||||
- Register all routes
|
||||
- Export router creation function
|
||||
|
||||
**Pattern**:
|
||||
|
||||
```typescript
|
||||
import { Router } from "express";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { createCreateHandler } from "./routes/create.js";
|
||||
import { createGenerateHandler } from "./routes/generate.js";
|
||||
|
||||
export function create{Module}Routes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post("/create", createCreateHandler(events));
|
||||
router.get("/status", createStatusHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Function name: `create{Module}Routes`
|
||||
- Accepts dependencies (e.g., `EventEmitter`) as parameters
|
||||
- Returns configured Router instance
|
||||
- Route handlers are factory functions that accept dependencies
|
||||
|
||||
---
|
||||
|
||||
### `common.ts` - Shared Utilities & State
|
||||
|
||||
**Purpose**: Central location for shared state, utilities, and helper functions used across multiple route handlers and business logic files.
|
||||
|
||||
**Common Contents**:
|
||||
|
||||
- Module-level state (e.g., `isRunning`, `currentAbortController`)
|
||||
- State management functions (e.g., `setRunningState()`)
|
||||
- Logging utilities (e.g., `logAuthStatus()`, `logError()`)
|
||||
- Error handling utilities (e.g., `getErrorMessage()`)
|
||||
- Shared constants
|
||||
- Shared types/interfaces
|
||||
|
||||
**Pattern**:
|
||||
|
||||
```typescript
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
|
||||
const logger = createLogger("{ModuleName}");
|
||||
|
||||
// Shared state
|
||||
export let isRunning = false;
|
||||
export let currentAbortController: AbortController | null = null;
|
||||
|
||||
// State management
|
||||
export function setRunningState(
|
||||
running: boolean,
|
||||
controller: AbortController | null = null
|
||||
): void {
|
||||
isRunning = running;
|
||||
currentAbortController = controller;
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
export function logError(error: unknown, context: string): void {
|
||||
logger.error(`❌ ${context}:`, error);
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Unknown error";
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Export shared state as `let` variables (mutable state)
|
||||
- Provide setter functions for state management
|
||||
- Keep utilities focused and reusable
|
||||
- Use consistent logging patterns
|
||||
|
||||
---
|
||||
|
||||
### `routes/{endpoint-name}.ts` - Route Handlers
|
||||
|
||||
**Purpose**: Thin HTTP request/response handlers that validate input, call business logic, and format responses.
|
||||
|
||||
**Responsibilities**:
|
||||
|
||||
- Parse and validate request parameters
|
||||
- Check preconditions (e.g., `isRunning` state)
|
||||
- Call business logic functions
|
||||
- Handle errors and format responses
|
||||
- Manage background tasks (if applicable)
|
||||
|
||||
**Pattern**:
|
||||
|
||||
```typescript
|
||||
import type { Request, Response } from "express";
|
||||
import type { EventEmitter } from "../../../lib/events.js";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import {
|
||||
isRunning,
|
||||
setRunningState,
|
||||
logError,
|
||||
getErrorMessage,
|
||||
} from "../common.js";
|
||||
import { businessLogicFunction } from "../business-logic.js";
|
||||
|
||||
const logger = createLogger("{ModuleName}");
|
||||
|
||||
export function create{Action}Handler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info("========== /{endpoint} endpoint called ==========");
|
||||
|
||||
try {
|
||||
// 1. Parse and validate input
|
||||
const { param1, param2 } = req.body as { param1: string; param2?: number };
|
||||
|
||||
if (!param1) {
|
||||
res.status(400).json({ success: false, error: "param1 required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check preconditions
|
||||
if (isRunning) {
|
||||
res.json({ success: false, error: "Operation already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Set up state
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// 4. Call business logic (background if async)
|
||||
businessLogicFunction(param1, param2, events, abortController)
|
||||
.catch((error) => {
|
||||
logError(error, "Operation failed");
|
||||
events.emit("module:event", { type: "error", error: getErrorMessage(error) });
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
// 5. Return immediate response
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error("❌ Route handler exception:", error);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Factory function pattern: `create{Action}Handler(dependencies)`
|
||||
- Returns async Express handler function
|
||||
- Validate input early
|
||||
- Use shared utilities from `common.ts`
|
||||
- Handle errors consistently
|
||||
- For background tasks, return success immediately and handle completion asynchronously
|
||||
|
||||
---
|
||||
|
||||
### `{business-function}.ts` - Business Logic Files
|
||||
|
||||
**Purpose**: Standalone files containing complex business logic functions that can be reused across routes or extracted to reduce file size.
|
||||
|
||||
**When to Extract**:
|
||||
|
||||
- Function exceeds ~100-150 lines
|
||||
- Function is called from multiple route handlers
|
||||
- Function has complex logic that deserves its own file
|
||||
- Function can be tested independently
|
||||
|
||||
**Pattern**:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* {Brief description of what this function does}
|
||||
*/
|
||||
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { EventEmitter } from "../../lib/events.js";
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import { logAuthStatus } from "./common.js";
|
||||
import { anotherBusinessFunction } from "./another-business-function.js";
|
||||
|
||||
const logger = createLogger("{ModuleName}");
|
||||
|
||||
export async function businessLogicFunction(
|
||||
param1: string,
|
||||
param2: number,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController
|
||||
): Promise<void> {
|
||||
logger.debug("========== businessLogicFunction() started ==========");
|
||||
|
||||
try {
|
||||
// Business logic here
|
||||
// ...
|
||||
|
||||
// Can call other business logic functions
|
||||
await anotherBusinessFunction(param1, events, abortController);
|
||||
|
||||
logger.debug("========== businessLogicFunction() completed ==========");
|
||||
} catch (error) {
|
||||
logger.error("❌ businessLogicFunction() failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Export named functions (not default exports)
|
||||
- Include JSDoc comment at top
|
||||
- Import shared utilities from `common.ts`
|
||||
- Use consistent logging patterns
|
||||
- Can import and call other business logic functions
|
||||
- Handle errors and re-throw or emit events as appropriate
|
||||
|
||||
---
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### Step 1: Create Directory Structure
|
||||
|
||||
```bash
|
||||
mkdir -p routes/{module-name}/routes
|
||||
```
|
||||
|
||||
### Step 2: Create `common.ts`
|
||||
|
||||
Start with shared state and utilities:
|
||||
|
||||
- Module-level state variables
|
||||
- State management functions
|
||||
- Logging utilities
|
||||
- Error handling utilities
|
||||
|
||||
### Step 3: Extract Business Logic
|
||||
|
||||
Identify large functions or reusable logic:
|
||||
|
||||
- Functions > 150 lines → extract to separate file
|
||||
- Functions used by multiple routes → extract to separate file
|
||||
- Complex operations → extract to separate file
|
||||
|
||||
### Step 4: Create Route Handlers
|
||||
|
||||
For each endpoint:
|
||||
|
||||
- Create `routes/{endpoint-name}.ts`
|
||||
- Implement factory function pattern
|
||||
- Keep handlers thin (validation + call business logic)
|
||||
- Use utilities from `common.ts`
|
||||
|
||||
### Step 5: Create `index.ts`
|
||||
|
||||
- Import all route handler factories
|
||||
- Create router and register routes
|
||||
- Export router creation function
|
||||
|
||||
### Step 6: Register Module
|
||||
|
||||
In main routes file:
|
||||
|
||||
```typescript
|
||||
import { create{Module}Routes } from "./{module-name}/index.js";
|
||||
|
||||
app.use("/api/{module-name}", create{Module}Routes(events));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example: app-spec Module
|
||||
|
||||
The `app-spec` module demonstrates this pattern:
|
||||
|
||||
### File Breakdown
|
||||
|
||||
**`index.ts`** (24 lines)
|
||||
|
||||
- Creates router
|
||||
- Registers 5 endpoints
|
||||
- Exports `createSpecRegenerationRoutes()`
|
||||
|
||||
**`common.ts`** (74 lines)
|
||||
|
||||
- Shared state: `isRunning`, `currentAbortController`
|
||||
- State management: `setRunningState()`
|
||||
- Utilities: `logAuthStatus()`, `logError()`, `getErrorMessage()`
|
||||
|
||||
**`generate-spec.ts`** (204 lines)
|
||||
|
||||
- Extracted business logic for spec generation
|
||||
- Handles SDK calls, streaming, file I/O
|
||||
- Called by both `create.ts` and `generate.ts` routes
|
||||
|
||||
**`generate-features-from-spec.ts`** (155 lines)
|
||||
|
||||
- Extracted business logic for feature generation
|
||||
- Handles SDK calls and streaming
|
||||
- Calls `parseAndCreateFeatures()` for final step
|
||||
|
||||
**`parse-and-create-features.ts`** (84 lines)
|
||||
|
||||
- Extracted parsing and file creation logic
|
||||
- Called by `generate-features-from-spec.ts`
|
||||
|
||||
**`routes/create.ts`** (96 lines)
|
||||
|
||||
- Thin handler for POST /create
|
||||
- Validates input, checks state, calls `generateSpec()`
|
||||
|
||||
**`routes/generate.ts`** (99 lines)
|
||||
|
||||
- Thin handler for POST /generate
|
||||
- Similar to `create.ts` but different input parameter
|
||||
|
||||
**`routes/generate-features.ts`** (71 lines)
|
||||
|
||||
- Thin handler for POST /generate-features
|
||||
- Calls `generateFeaturesFromSpec()`
|
||||
|
||||
**`routes/status.ts`** (17 lines)
|
||||
|
||||
- Simple handler for GET /status
|
||||
- Returns current state
|
||||
|
||||
**`routes/stop.ts`** (25 lines)
|
||||
|
||||
- Simple handler for POST /stop
|
||||
- Aborts current operation
|
||||
|
||||
### Key Observations
|
||||
|
||||
1. **Route handlers are thin** - Most are 70-100 lines, focused on HTTP concerns
|
||||
2. **Business logic is extracted** - Complex operations in separate files
|
||||
3. **Shared utilities centralized** - Common functions in `common.ts`
|
||||
4. **Reusability** - `generateSpec()` used by both `create.ts` and `generate.ts`
|
||||
5. **Clear separation** - HTTP layer vs business logic vs shared utilities
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Migrating an Existing Route Module
|
||||
|
||||
1. **Analyze current structure**
|
||||
|
||||
- Identify all endpoints
|
||||
- Identify shared state/utilities
|
||||
- Identify large functions (>150 lines)
|
||||
|
||||
2. **Create directory structure**
|
||||
|
||||
```bash
|
||||
mkdir -p routes/{module-name}/routes
|
||||
```
|
||||
|
||||
3. **Extract common utilities**
|
||||
|
||||
- Move shared state to `common.ts`
|
||||
- Move utility functions to `common.ts`
|
||||
- Update imports in existing files
|
||||
|
||||
4. **Extract business logic**
|
||||
|
||||
- Identify functions to extract
|
||||
- Create `{function-name}.ts` files
|
||||
- Move logic, update imports
|
||||
|
||||
5. **Create route handlers**
|
||||
|
||||
- Create `routes/{endpoint-name}.ts` for each endpoint
|
||||
- Move HTTP handling logic
|
||||
- Keep handlers thin
|
||||
|
||||
6. **Create index.ts**
|
||||
|
||||
- Import route handlers
|
||||
- Register routes
|
||||
- Export router creation function
|
||||
|
||||
7. **Update main routes file**
|
||||
|
||||
- Import from new `index.ts`
|
||||
- Update route registration
|
||||
|
||||
8. **Test**
|
||||
- Verify all endpoints work
|
||||
- Check error handling
|
||||
- Verify shared state management
|
||||
|
||||
### Example Migration
|
||||
|
||||
**Before** (monolithic `routes.ts`):
|
||||
|
||||
```typescript
|
||||
// routes.ts - 500+ lines
|
||||
router.post("/create", async (req, res) => {
|
||||
// 200 lines of logic
|
||||
});
|
||||
|
||||
router.post("/generate", async (req, res) => {
|
||||
// 200 lines of similar logic
|
||||
});
|
||||
```
|
||||
|
||||
**After** (organized structure):
|
||||
|
||||
```typescript
|
||||
// routes/app-spec/index.ts
|
||||
export function createSpecRegenerationRoutes(events) {
|
||||
const router = Router();
|
||||
router.post("/create", createCreateHandler(events));
|
||||
router.post("/generate", createGenerateHandler(events));
|
||||
return router;
|
||||
}
|
||||
|
||||
// routes/app-spec/routes/create.ts - 96 lines
|
||||
export function createCreateHandler(events) {
|
||||
return async (req, res) => {
|
||||
// Thin handler, calls generateSpec()
|
||||
};
|
||||
}
|
||||
|
||||
// routes/app-spec/generate-spec.ts - 204 lines
|
||||
export async function generateSpec(...) {
|
||||
// Business logic extracted here
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Do
|
||||
|
||||
- Keep route handlers thin (< 150 lines)
|
||||
- Extract complex business logic to separate files
|
||||
- Centralize shared utilities in `common.ts`
|
||||
- Use factory function pattern for route handlers
|
||||
- Export named functions (not default exports)
|
||||
- Use consistent logging patterns
|
||||
- Handle errors consistently
|
||||
- Document complex functions with JSDoc
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
- Put business logic directly in route handlers
|
||||
- Duplicate utility functions across files
|
||||
- Create files with only one small function (< 20 lines)
|
||||
- Mix HTTP concerns with business logic
|
||||
- Use default exports for route handlers
|
||||
- Create deeply nested directory structures
|
||||
- Put route handlers in root of module directory
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The route organization pattern provides:
|
||||
|
||||
1. **Clear structure** - Easy to find and understand code
|
||||
2. **Separation of concerns** - HTTP, business logic, and utilities separated
|
||||
3. **Reusability** - Business logic can be shared across routes
|
||||
4. **Maintainability** - Smaller, focused files are easier to maintain
|
||||
5. **Testability** - Functions can be tested independently
|
||||
|
||||
Apply this pattern to all route modules for consistency and improved code quality.
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": true,
|
||||
"amount": 14
|
||||
},
|
||||
"auditLog": "/Users/webdevcody/Workspace/claude-quickstarts/autonomous-coding/generations/automaker/logs/.bca9a6447fd747ee10d232b47d6c5442658e2b34-audit.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1765139456329,
|
||||
"name": "/Users/webdevcody/Workspace/claude-quickstarts/autonomous-coding/generations/automaker/logs/mcp-puppeteer-2025-12-07.log",
|
||||
"hash": "bc94aff428bef4ee0318298404b592183f4191664fc0acee519ec9a995c543cc"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-12-07 15:30:56.370"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-12-07 15:30:56.371"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-12-07 15:52:16.721"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-12-07 15:52:16.722"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-12-07 16:06:03.450"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-12-07 16:06:03.451"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-12-07 16:27:18.491"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-12-07 16:27:18.492"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-12-07 16:37:07.299"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-12-07 16:37:07.300"}
|
||||
@@ -8,7 +8,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\"",
|
||||
"dev": "npm run dev --workspace=apps/app",
|
||||
"dev": "./init.sh",
|
||||
"dev:web": "npm run dev:web --workspace=apps/app",
|
||||
"dev:electron": "npm run dev:electron --workspace=apps/app",
|
||||
"dev:electron:debug": "npm run dev:electron:debug --workspace=apps/app",
|
||||
|
||||
Reference in New Issue
Block a user