mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42: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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user