diff --git a/packages/core/src/application/ports/output/services/agent-deployment-service.interface.ts b/packages/core/src/application/ports/output/services/agent-deployment-service.interface.ts new file mode 100644 index 000000000..0e1999fe0 --- /dev/null +++ b/packages/core/src/application/ports/output/services/agent-deployment-service.interface.ts @@ -0,0 +1,53 @@ +/** + * Agent Deployment Service Interface + * + * Higher-level deployment service that uses the DevEnvironmentAgent + * to analyze repos before starting deployments. Wraps the low-level + * IDeploymentService with AI-driven analysis. + * + * Flow: + * 1. Analyze repo via DevEnvironmentAgent (cached per-repo) + * 2. If not deployable → return "not deployable" result + * 3. If deployable → run setup commands → start dev server + */ + +import type { DeploymentState } from '@/domain/generated/output.js'; +import type { DevEnvironmentAnalysis } from './dev-environment-agent.interface.js'; + +/** Result of an agent-driven deployment attempt. */ +export interface AgentDeployResult { + /** Whether the deployment was started successfully. */ + success: boolean; + + /** Error message if the deployment failed. */ + error?: string; + + /** Current deployment state (Booting on success). */ + state?: DeploymentState; + + /** The analysis result from the dev environment agent. */ + analysis?: DevEnvironmentAnalysis; +} + +/** + * Port interface for agent-driven deployments. + * + * Uses DevEnvironmentAgent to analyze the repo, then starts the + * deployment using the detected command. Handles repos that have + * nothing to deploy by returning a descriptive result. + */ +export interface IAgentDeploymentService { + /** + * Analyze a repository and start its dev environment if possible. + * + * @param targetId - Unique identifier for the deployment (featureId or repositoryPath) + * @param targetPath - Absolute filesystem path to the directory to deploy from + * @param options - Optional: force re-analysis by skipping cache + * @returns Result with success/error and the analysis + */ + deploy( + targetId: string, + targetPath: string, + options?: { skipCache?: boolean } + ): Promise; +} diff --git a/packages/core/src/application/ports/output/services/deployment-service.interface.ts b/packages/core/src/application/ports/output/services/deployment-service.interface.ts index 88da5ed56..2d0db9a96 100644 --- a/packages/core/src/application/ports/output/services/deployment-service.interface.ts +++ b/packages/core/src/application/ports/output/services/deployment-service.interface.ts @@ -32,6 +32,14 @@ export interface DeploymentStatus { url: string | null; } +/** Options for overriding the default dev script detection. */ +export interface DeploymentStartOptions { + /** Shell command to run instead of auto-detecting from package.json. */ + command?: string; + /** Working directory relative to targetPath (defaults to "."). */ + cwd?: string; +} + /** * Port interface for managing local dev server deployments. * @@ -49,10 +57,11 @@ export interface IDeploymentService { * * @param targetId - Unique identifier for the deployment target (featureId or repositoryId) * @param targetPath - Absolute filesystem path to the directory to run the dev server in + * @param options - Optional: override the auto-detected command * @returns The initial deployment state (always Booting on success) * @throws Error if no dev script is found in package.json or the process fails to spawn */ - start(targetId: string, targetPath: string): void; + start(targetId: string, targetPath: string, options?: DeploymentStartOptions): void; /** * Stop a running deployment gracefully. diff --git a/packages/core/src/application/ports/output/services/dev-environment-agent.interface.ts b/packages/core/src/application/ports/output/services/dev-environment-agent.interface.ts new file mode 100644 index 000000000..11b771614 --- /dev/null +++ b/packages/core/src/application/ports/output/services/dev-environment-agent.interface.ts @@ -0,0 +1,79 @@ +/** + * Dev Environment Agent Interface + * + * Output port for agent-based dev environment analysis. + * Uses an AI agent to analyze any repository (language-agnostic) and determine + * how to start a local development server. Supports per-repo caching for fast + * repeated invocations. + * + * Key behaviors: + * - Analyzes repo structure to detect dev server commands across all languages + * - Returns a "not deployable" result for repos with no server/UI to start + * - Caches analysis results per-repo for fast subsequent calls + */ + +/** Result of analyzing a repository for dev environment setup. */ +export interface DevEnvironmentAnalysis { + /** Whether this repo has a startable dev environment. */ + deployable: boolean; + + /** Human-readable explanation of what was detected or why it's not deployable. */ + reason: string; + + /** The shell command to start the dev server (null if not deployable). */ + command: string | null; + + /** Working directory relative to repo root (defaults to "."). */ + cwd: string; + + /** Expected port the dev server will listen on (null if unknown). */ + expectedPort: number | null; + + /** Detected language/framework (e.g., "node", "python", "go", "rust"). */ + language: string | null; + + /** Detected framework if any (e.g., "next.js", "django", "flask", "gin"). */ + framework: string | null; + + /** Setup commands to run before the dev command (e.g., "npm install"). */ + setupCommands: string[]; +} + +/** Options for the analyze method. */ +export interface DevEnvironmentAnalyzeOptions { + /** Skip the cache and force a fresh analysis. */ + skipCache?: boolean; +} + +/** + * Port interface for AI-driven dev environment analysis. + * + * Implementations use a structured agent caller to analyze repositories + * and determine how to start a local dev server. Results are cached + * per-repo (keyed by absolute path) for fast repeated calls. + */ +export interface IDevEnvironmentAgent { + /** + * Analyze a repository to determine how to start its dev environment. + * + * @param repositoryPath - Absolute filesystem path to the repository + * @param options - Optional configuration (e.g., skip cache) + * @returns Analysis result with command, language, and deployability info + */ + analyze( + repositoryPath: string, + options?: DevEnvironmentAnalyzeOptions + ): Promise; + + /** + * Clear the cached analysis for a specific repository. + * + * @param repositoryPath - Absolute filesystem path to the repository + */ + clearCache(repositoryPath: string): void; + + /** + * Clear all cached analyses. + */ + clearAllCaches(): void; +} diff --git a/packages/core/src/infrastructure/di/container.ts b/packages/core/src/infrastructure/di/container.ts index de931948f..c77709aa2 100644 --- a/packages/core/src/infrastructure/di/container.ts +++ b/packages/core/src/infrastructure/di/container.ts @@ -46,6 +46,10 @@ import type { IDaemonService } from '../../application/ports/output/services/dae import { DaemonPidService } from '../services/daemon/daemon-pid.service.js'; import type { IDeploymentService } from '../../application/ports/output/services/deployment-service.interface.js'; import { DeploymentService } from '../services/deployment/deployment.service.js'; +import type { IDevEnvironmentAgent } from '../../application/ports/output/services/dev-environment-agent.interface.js'; +import { DevEnvironmentAgentService } from '../services/deployment/dev-environment-agent.service.js'; +import type { IAgentDeploymentService } from '../../application/ports/output/services/agent-deployment-service.interface.js'; +import { AgentDeploymentService } from '../services/deployment/agent-deployment.service.js'; import { AttachmentStorageService } from '../services/attachment-storage.service.js'; // Agent infrastructure interfaces and implementations @@ -298,6 +302,25 @@ export async function initializeContainer(): Promise { }, }); + // Register agent-based deployment services + container.register('IDevEnvironmentAgent', { + useFactory: (c) => { + const caller = c.resolve('IStructuredAgentCaller'); + return new DevEnvironmentAgentService({ structuredAgentCaller: caller }); + }, + }); + + container.register('IAgentDeploymentService', { + useFactory: (c) => { + const devEnvAgent = c.resolve('IDevEnvironmentAgent'); + const deploySvc = c.resolve('IDeploymentService'); + return new AgentDeploymentService({ + devEnvironmentAgent: devEnvAgent, + deploymentService: deploySvc, + }); + }, + }); + container.register('ISpecInitializerService', { useFactory: () => new SpecInitializerService(), }); diff --git a/packages/core/src/infrastructure/services/deployment/agent-deployment.service.ts b/packages/core/src/infrastructure/services/deployment/agent-deployment.service.ts new file mode 100644 index 000000000..d0dee5970 --- /dev/null +++ b/packages/core/src/infrastructure/services/deployment/agent-deployment.service.ts @@ -0,0 +1,139 @@ +/** + * Agent Deployment Service + * + * Higher-level deployment orchestrator that uses the DevEnvironmentAgent + * to analyze repos before starting deployments. Replaces the simple + * "detect package.json scripts" approach with AI-driven analysis that + * supports any language/framework. + * + * Flow: + * 1. Analyze repo via DevEnvironmentAgent (cached per-repo for speed) + * 2. If not deployable → return descriptive reason + * 3. If deployable → run setup commands → start dev server via DeploymentService + */ + +import { exec } from 'node:child_process'; +import { join } from 'node:path'; +import type { + IAgentDeploymentService, + AgentDeployResult, +} from '@/application/ports/output/services/agent-deployment-service.interface.js'; +import type { IDevEnvironmentAgent } from '@/application/ports/output/services/dev-environment-agent.interface.js'; +import type { IDeploymentService } from '@/application/ports/output/services/deployment-service.interface.js'; +import { DeploymentState } from '@/domain/generated/output.js'; +import { createDeploymentLogger } from './deployment-logger.js'; + +const log = createDeploymentLogger('[AgentDeploymentService]'); + +/** Dependencies injectable for testing. */ +export interface AgentDeploymentServiceDeps { + devEnvironmentAgent: IDevEnvironmentAgent; + deploymentService: IDeploymentService; + execCommand?: (command: string, cwd: string) => Promise; +} + +function defaultExecCommand(command: string, cwd: string): Promise { + return new Promise((resolve, reject) => { + exec(command, { cwd }, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +type ResolvedDeps = Required; + +export class AgentDeploymentService implements IAgentDeploymentService { + private readonly deps: ResolvedDeps; + + constructor(deps: AgentDeploymentServiceDeps) { + this.deps = { + execCommand: defaultExecCommand, + ...deps, + }; + } + + async deploy( + targetId: string, + targetPath: string, + options?: { skipCache?: boolean } + ): Promise { + log.info(`deploy() called — targetId="${targetId}", targetPath="${targetPath}"`); + + // Step 1: Analyze via agent + let analysis; + try { + analysis = await this.deps.devEnvironmentAgent.analyze(targetPath, { + skipCache: options?.skipCache, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Analysis failed'; + log.error(`agent analysis failed: ${message}`); + return { success: false, error: message }; + } + + log.info(`analysis result — deployable=${analysis.deployable}, command=${analysis.command}`); + + // Step 2: Handle not-deployable repos + if (!analysis.deployable) { + log.info(`repo is not deployable: ${analysis.reason}`); + return { + success: false, + error: analysis.reason, + analysis, + }; + } + + // Validate: deployable but no command is an error + if (!analysis.command) { + log.error('analysis marked repo as deployable but provided no command'); + return { + success: false, + error: 'Analysis marked repo as deployable but provided no command', + analysis, + }; + } + + // Step 3: Run setup commands + const resolvedCwd = + analysis.cwd && analysis.cwd !== '.' ? join(targetPath, analysis.cwd) : targetPath; + + for (const setupCmd of analysis.setupCommands) { + log.info(`running setup command: "${setupCmd}" in "${resolvedCwd}"`); + try { + await this.deps.execCommand(setupCmd, resolvedCwd); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Setup failed'; + log.error(`setup command failed: ${message}`); + return { + success: false, + error: `Setup command failed: ${message}`, + analysis, + }; + } + } + + // Step 4: Start the dev server + try { + this.deps.deploymentService.start(targetId, targetPath, { + command: analysis.command, + cwd: analysis.cwd, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to start deployment'; + log.error(`deployment start failed: ${message}`); + return { + success: false, + error: message, + analysis, + }; + } + + log.info('deployment started successfully'); + return { + success: true, + state: DeploymentState.Booting, + analysis, + }; + } +} diff --git a/packages/core/src/infrastructure/services/deployment/deployment.service.ts b/packages/core/src/infrastructure/services/deployment/deployment.service.ts index 3fbc6c502..1ea82d08e 100644 --- a/packages/core/src/infrastructure/services/deployment/deployment.service.ts +++ b/packages/core/src/infrastructure/services/deployment/deployment.service.ts @@ -14,6 +14,7 @@ import { DeploymentState } from '@/domain/generated/output.js'; import type { IDeploymentService, DeploymentStatus, + DeploymentStartOptions, LogEntry, } from '@/application/ports/output/services/deployment-service.interface.js'; import { detectDevScript } from './detect-dev-script.js'; @@ -69,9 +70,15 @@ export class DeploymentService implements IDeploymentService { /** * Start a deployment for the given target. * If a deployment already exists for this target, it is stopped first. + * + * When options.command is provided, the command is executed directly via shell + * instead of auto-detecting from package.json. This enables agent-based + * deployment for any language/framework. */ - start(targetId: string, targetPath: string): void { - log.info(`start() called — targetId="${targetId}", targetPath="${targetPath}"`); + start(targetId: string, targetPath: string, options?: DeploymentStartOptions): void { + log.info( + `start() called — targetId="${targetId}", targetPath="${targetPath}", command=${options?.command ?? 'auto-detect'}` + ); // Stop any existing deployment for this target const existing = this.deployments.get(targetId); @@ -81,24 +88,37 @@ export class DeploymentService implements IDeploymentService { this.deployments.delete(targetId); } - // Detect the dev script - const detection = this.deps.detectDevScript(targetPath); - if (!detection.success) { - log.error(`Dev script detection failed: ${detection.error}`); - throw new Error(detection.error); - } + // Resolve the working directory + const cwd = options?.cwd && options.cwd !== '.' ? `${targetPath}/${options.cwd}` : targetPath; - // Build spawn args based on package manager - const { packageManager, scriptName, command } = detection; - const args = packageManager === 'npm' ? ['run', scriptName] : [scriptName]; + let spawnCommand: string; + let spawnArgs: string[]; - log.info( - `Spawning dev server: command="${command}", packageManager="${packageManager}", scriptName="${scriptName}", cwd="${targetPath}"` - ); + if (options?.command) { + // Agent-provided command — run directly via shell + const parts = options.command.split(/\s+/); + spawnCommand = parts[0]; + spawnArgs = parts.slice(1); + log.info(`Using agent-provided command: "${options.command}", cwd="${cwd}"`); + } else { + // Legacy auto-detection from package.json + const detection = this.deps.detectDevScript(targetPath); + if (!detection.success) { + log.error(`Dev script detection failed: ${detection.error}`); + throw new Error(detection.error); + } + + const { packageManager, scriptName, command } = detection; + spawnCommand = packageManager; + spawnArgs = packageManager === 'npm' ? ['run', scriptName] : [scriptName]; + log.info( + `Spawning dev server: command="${command}", packageManager="${packageManager}", scriptName="${scriptName}", cwd="${cwd}"` + ); + } - const child = this.deps.spawn(packageManager, args, { + const child = this.deps.spawn(spawnCommand, spawnArgs, { shell: true, - cwd: targetPath, + cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] as const, }); diff --git a/packages/core/src/infrastructure/services/deployment/dev-environment-agent.service.ts b/packages/core/src/infrastructure/services/deployment/dev-environment-agent.service.ts new file mode 100644 index 000000000..f8fa22965 --- /dev/null +++ b/packages/core/src/infrastructure/services/deployment/dev-environment-agent.service.ts @@ -0,0 +1,266 @@ +/** + * Dev Environment Agent Service + * + * AI-driven service that analyzes any repository to determine how to start + * a local dev environment. Language-agnostic — supports Node.js, Python, + * Go, Rust, Java, Ruby, and more. Uses structured agent calls to get + * typed analysis results. + * + * Features: + * - In-memory per-repo caching for fast repeated calls + * - Reads key config files (package.json, Cargo.toml, etc.) for context + * - Handles "not deployable" repos gracefully + * - Truncates large files to prevent prompt overflow + */ + +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { + IDevEnvironmentAgent, + DevEnvironmentAnalysis, + DevEnvironmentAnalyzeOptions, +} from '@/application/ports/output/services/dev-environment-agent.interface.js'; +import type { IStructuredAgentCaller } from '@/application/ports/output/agents/structured-agent-caller.interface.js'; +import { createDeploymentLogger } from './deployment-logger.js'; + +const log = createDeploymentLogger('[DevEnvironmentAgent]'); + +/** Max characters to include from any single config file. */ +const MAX_FILE_CONTENT_LENGTH = 4000; + +/** Config files to read and include in the prompt, in priority order. */ +const CONFIG_FILES = [ + 'package.json', + 'docker-compose.yml', + 'docker-compose.yaml', + 'Dockerfile', + 'Makefile', + 'Cargo.toml', + 'go.mod', + 'requirements.txt', + 'Pipfile', + 'pyproject.toml', + 'setup.py', + 'Gemfile', + 'build.gradle', + 'pom.xml', + 'mix.exs', + 'deno.json', + 'bun.lockb', +]; + +/** JSON schema for the structured agent response. */ +const ANALYSIS_SCHEMA = { + type: 'object', + properties: { + deployable: { + type: 'boolean', + description: + 'Whether this repository has a startable dev server or UI. False for libraries, scripts, data repos, etc.', + }, + reason: { + type: 'string', + description: 'Brief explanation of what was detected or why the repo is not deployable.', + }, + command: { + type: ['string', 'null'], + description: 'The shell command to start the dev server. Null if not deployable.', + }, + cwd: { + type: 'string', + description: + 'Working directory relative to repo root where the command should run. Use "." for repo root.', + }, + expectedPort: { + type: ['integer', 'null'], + description: 'Expected port the dev server will listen on. Null if unknown.', + }, + language: { + type: ['string', 'null'], + description: + 'Primary language/runtime (e.g., "node", "python", "go", "rust", "ruby", "java").', + }, + framework: { + type: ['string', 'null'], + description: + 'Detected framework if any (e.g., "next.js", "django", "flask", "gin", "rails").', + }, + setupCommands: { + type: 'array', + items: { type: 'string' }, + description: + 'Setup commands to run before the dev command (e.g., "npm install", "pip install -r requirements.txt").', + }, + }, + required: [ + 'deployable', + 'reason', + 'command', + 'cwd', + 'expectedPort', + 'language', + 'framework', + 'setupCommands', + ], + additionalProperties: false, +}; + +/** Dependencies injectable for testing. */ +export interface DevEnvironmentAgentDeps { + structuredAgentCaller: Pick; + readdir: (path: string) => string[]; + readFile: (path: string) => string; + existsSync: (path: string) => boolean; +} + +const defaultDeps: DevEnvironmentAgentDeps = { + structuredAgentCaller: null as unknown as Pick, + readdir: (path: string) => readdirSync(path, { encoding: 'utf-8' }), + readFile: (path: string) => readFileSync(path, 'utf-8'), + existsSync, +}; + +export class DevEnvironmentAgentService implements IDevEnvironmentAgent { + private readonly cache = new Map(); + private readonly deps: DevEnvironmentAgentDeps; + + constructor( + deps: Partial & Pick + ) { + this.deps = { ...defaultDeps, ...deps }; + } + + async analyze( + repositoryPath: string, + options?: DevEnvironmentAnalyzeOptions + ): Promise { + log.info( + `analyze() called — repositoryPath="${repositoryPath}", skipCache=${options?.skipCache ?? false}` + ); + + if (!this.deps.existsSync(repositoryPath)) { + throw new Error(`Repository path does not exist: ${repositoryPath}`); + } + + // Check cache + if (!options?.skipCache) { + const cached = this.cache.get(repositoryPath); + if (cached) { + log.info(`cache hit for "${repositoryPath}"`); + return cached; + } + } + + // Build prompt with repo context + const prompt = this.buildPrompt(repositoryPath); + + log.info(`calling structured agent for "${repositoryPath}"`); + const result = await this.deps.structuredAgentCaller.call( + prompt, + ANALYSIS_SCHEMA, + { + silent: true, + maxTurns: 3, + cwd: repositoryPath, + } + ); + + // Cache successful result + this.cache.set(repositoryPath, result); + log.info( + `analysis complete — deployable=${result.deployable}, command=${result.command}, language=${result.language}` + ); + + return result; + } + + clearCache(repositoryPath: string): void { + log.info(`clearCache() — repositoryPath="${repositoryPath}"`); + this.cache.delete(repositoryPath); + } + + clearAllCaches(): void { + log.info(`clearAllCaches() — clearing ${this.cache.size} entries`); + this.cache.clear(); + } + + private buildPrompt(repositoryPath: string): string { + const dirListing = this.getDirListing(repositoryPath); + const configContents = this.readConfigFiles(repositoryPath, dirListing); + + return `You are a dev environment analysis agent. Analyze this repository and determine how to start a local development server. + +## Repository Directory Listing (root level) + +${dirListing.join('\n')} + +## Config File Contents + +${configContents} + +## Instructions + +Analyze the repository structure and config files above to determine: + +1. **Is this repo deployable?** Does it have a web server, API server, or UI that can be started locally? + - Libraries (npm packages, Python packages, Go modules meant only for import) are NOT deployable + - CLI tools that don't serve HTTP are NOT deployable + - Data repositories, documentation-only repos are NOT deployable + - Scripts that run once and exit are NOT deployable + +2. **What command starts the dev server?** Consider: + - Node.js: \`npm run dev\`, \`pnpm dev\`, \`yarn dev\`, \`npm start\` + - Python: \`python manage.py runserver\`, \`flask run\`, \`uvicorn main:app --reload\` + - Go: \`go run .\`, \`air\` (hot reload) + - Rust: \`cargo run\`, \`cargo watch -x run\` + - Ruby: \`rails server\`, \`bundle exec rails s\` + - Java: \`./gradlew bootRun\`, \`mvn spring-boot:run\` + - Docker: \`docker-compose up\` + - Generic: \`make dev\`, \`make run\` + +3. **What port will it listen on?** Check config files for port definitions. + +4. **What setup is needed first?** (e.g., install dependencies) + +If the repo has NO server or UI to start, set deployable=false and explain why. + +Respond with ONLY the JSON object matching the schema.`; + } + + private getDirListing(repositoryPath: string): string[] { + try { + return this.deps.readdir(repositoryPath); + } catch { + log.warn(`Failed to read directory listing for "${repositoryPath}"`); + return []; + } + } + + private readConfigFiles(repositoryPath: string, dirListing: string[]): string { + const sections: string[] = []; + + for (const configFile of CONFIG_FILES) { + if (!dirListing.includes(configFile)) continue; + + try { + const filePath = join(repositoryPath, configFile); + let content = this.deps.readFile(filePath); + + // Truncate large files + if (content.length > MAX_FILE_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_FILE_CONTENT_LENGTH)}\n... (truncated)`; + } + + sections.push(`### ${configFile}\n\`\`\`\n${content}\n\`\`\``); + } catch { + log.warn(`Failed to read config file "${configFile}" in "${repositoryPath}"`); + } + } + + if (sections.length === 0) { + return 'No recognized config files found.'; + } + + return sections.join('\n\n'); + } +} diff --git a/specs/068-agent-dev-environment-93a417/evidence/feature-unit-tests.txt b/specs/068-agent-dev-environment-93a417/evidence/feature-unit-tests.txt new file mode 100644 index 000000000..f28bd79d7 --- /dev/null +++ b/specs/068-agent-dev-environment-93a417/evidence/feature-unit-tests.txt @@ -0,0 +1,123 @@ + + RUN  v4.0.18 /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417 + + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > analyze > should call the structured agent caller with a prompt containing directory listing 1ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > analyze > should pass the correct JSON schema to the agent caller 1ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > analyze > should return the analysis from the agent 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > analyze > should return not-deployable for repos with no server to start 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > analyze > should include key config file contents in the prompt 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > analyze > should handle repos without a recognized config file gracefully 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > analyze > should throw when the repository path does not exist 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > analyze > should pass silent and maxTurns options to the agent caller 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > caching > should cache analysis results and return cached on second call 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > caching > should use separate cache entries for different repos 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > caching > should skip cache when skipCache option is true 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > caching > should update cache when skipCache forces re-analysis 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > caching > should clear cache for a specific repo 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > caching > should clear all caches 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > caching > should not cache failed analyses (agent throws) 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > prompt construction > should include Python files in the analysis when present 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > prompt construction > should include Go files in the analysis when present 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > prompt construction > should include Rust files in the analysis when present 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > prompt construction > should read relevant config files and include their contents 0ms + ✓  node  tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts > DevEnvironmentAgentService > prompt construction > should truncate large config files to prevent prompt overflow 0ms +stderr | tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - error handling > should return error when agent analysis fails +[AgentDeploymentService] agent analysis failed: Agent unavailable + +stderr | tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - error handling > should return error when deployment service start fails +[AgentDeploymentService] deployment start failed: Failed to spawn process + +stderr | tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - error handling > should return error when setup command fails +[AgentDeploymentService] setup command failed: pnpm install failed with exit code 1 + +stderr | tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - error handling > should return error when analysis returns deployable but null command +[AgentDeploymentService] analysis marked repo as deployable but provided no command + + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - deployable repo > should analyze the repo via DevEnvironmentAgent 3ms + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - deployable repo > should start the deployment service with the detected command 1ms + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - deployable repo > should return success with Booting state and analysis 0ms + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - deployable repo > should pass skipCache option to the agent 1ms + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - deployable repo > should run setup commands before starting the dev server 1ms + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - deployable repo > should run multiple setup commands in order 0ms + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - not deployable repo > should return not-deployable result without starting deployment 0ms + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - error handling > should return error when agent analysis fails 1ms + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - error handling > should return error when deployment service start fails 0ms + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - error handling > should return error when setup command fails 0ms + ✓  node  tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts > AgentDeploymentService > deploy - error handling > should return error when analysis returns deployable but null command 0ms +stderr | tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > validates repositoryPath is an absolute path +[deployRepository] rejected — not an absolute path + +stderr | tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > returns error for empty repositoryPath +[deployRepository] rejected — not an absolute path + +stderr | tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > returns error when directory does not exist +[deployRepository] directory does not exist: "/nonexistent/path" + +stderr | tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > returns error when agent reports repo is not deployable +[deployRepository] agent deployment returned not-deployable: This is a data-only repository with no server to start + +stderr | tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns error when featureId is empty +[deployFeature] rejected — featureId is empty + +stderr | tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns error when feature is not found +[deployFeature] feature not found in repository: "nonexistent-id" + +stderr | tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns error when worktree directory does not exist +[deployFeature] worktree path does not exist on disk: "/mock/.shep/repos/abc123/wt/feat-my-feature" + +stderr | tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns error when agent reports repo is not deployable +[deployFeature] agent deployment returned not-deployable: This is a CLI utility with no web server + +stderr | tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > returns error when deploy throws +[deployRepository] error: Agent unavailable Error: Agent unavailable + at /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/tests/unit/presentation/web/actions/deploy-repository.test.ts:106:34 + at file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:145:11 + at file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:915:26 + at file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1243:20 + at new Promise () + at runWithTimeout (file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1209:10) + at file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1653:37 + at Traces.$ (file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/vitest@4.0.18_@types+node@25.2.0_jiti@2.6.1_jsdom@28.0.0_lightningcss@1.30.2_terser@5.46.0_tsx@4.21.0_yaml@2.8.2/node_modules/vitest/dist/chunks/traces.CCmnQaNT.js:142:27) + at trace (file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/vitest@4.0.18_@types+node@25.2.0_jiti@2.6.1_jsdom@28.0.0_lightningcss@1.30.2_terser@5.46.0_tsx@4.21.0_yaml@2.8.2/node_modules/vitest/dist/chunks/test.B8ej_ZHS.js:239:21) + at runTest (file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1653:12) + +stderr | tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > returns generic error for non-Error throws +[deployRepository] error: Failed to deploy repository unexpected + + ✓  web  tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > validates repositoryPath is an absolute path 1ms + ✓  web  tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > returns error for empty repositoryPath 0ms + ✓  web  tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > accepts Windows-style absolute paths when path.isAbsolute recognizes them 1ms + ✓  web  tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > returns error when directory does not exist 0ms + ✓  web  tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > calls agentDeploymentService.deploy with repositoryPath as both targetId and path 0ms + ✓  web  tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > returns error when agent reports repo is not deployable 0ms + ✓  web  tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > returns error when deploy throws 1ms + ✓  web  tests/unit/presentation/web/actions/deploy-repository.test.ts > deployRepository server action > returns generic error for non-Error throws 0ms +stderr | tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns error when deploy throws +[deployFeature] error: Agent unavailable Error: Agent unavailable + at /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/tests/unit/presentation/web/actions/deploy-feature.test.ts:113:34 + at file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:145:11 + at file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:915:26 + at file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1243:20 + at new Promise () + at runWithTimeout (file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1209:10) + at file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1653:37 + at Traces.$ (file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/vitest@4.0.18_@types+node@25.2.0_jiti@2.6.1_jsdom@28.0.0_lightningcss@1.30.2_terser@5.46.0_tsx@4.21.0_yaml@2.8.2/node_modules/vitest/dist/chunks/traces.CCmnQaNT.js:142:27) + at trace (file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/vitest@4.0.18_@types+node@25.2.0_jiti@2.6.1_jsdom@28.0.0_lightningcss@1.30.2_terser@5.46.0_tsx@4.21.0_yaml@2.8.2/node_modules/vitest/dist/chunks/test.B8ej_ZHS.js:239:21) + at runTest (file:///Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-agent-dev-environment-93a417/node_modules/.pnpm/@vitest+runner@4.0.18/node_modules/@vitest/runner/dist/index.js:1653:12) + +stderr | tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns generic error for non-Error throws +[deployFeature] error: Failed to deploy feature unexpected + + ✓  web  tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > resolves feature, computes worktree path, and calls agentDeploymentService.deploy 1ms + ✓  web  tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns error when featureId is empty 1ms + ✓  web  tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns error when feature is not found 0ms + ✓  web  tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns error when worktree directory does not exist 0ms + ✓  web  tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns error when agent reports repo is not deployable 0ms + ✓  web  tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns error when deploy throws 1ms + ✓  web  tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns generic error for non-Error throws 0ms + + Test Files  4 passed (4) + Tests  46 passed (46) + Start at  13:29:08 + Duration  183ms (transform 150ms, setup 199ms, import 151ms, tests 23ms, environment 0ms) + diff --git a/specs/068-agent-dev-environment-93a417/evidence/full-test-suite.txt b/specs/068-agent-dev-environment-93a417/evidence/full-test-suite.txt new file mode 100644 index 000000000..4605a64a1 --- /dev/null +++ b/specs/068-agent-dev-environment-93a417/evidence/full-test-suite.txt @@ -0,0 +1,20 @@ +stderr | tests/unit/presentation/web/actions/deploy-feature.test.ts > deployFeature server action > returns generic error for non-Error throws +[deployFeature] error: Failed to deploy feature unexpected + + ✓  web  tests/unit/presentation/web/actions/deploy-feature.test.ts (7 tests) 14ms + ✓  web  tests/unit/presentation/web/actions/features/delete-feature.test.ts (8 tests) 4ms + ✓  web  tests/unit/presentation/web/actions/open-folder.test.ts (12 tests) 12ms + ✓  web  tests/unit/presentation/web/actions/update-settings.test.ts (6 tests) 9ms + ✓  web  tests/unit/presentation/web/actions/get-workflow-defaults.test.ts (4 tests) 4ms + ✓  web  tests/unit/presentation/web/components/common/merge-review/merge-review-config.test.ts (11 tests) 3ms + ✓  web  tests/unit/presentation/web/features/control-center/derive-state.test.ts (6 tests) 4ms + ✓  web  tests/unit/presentation/web/actions/pick-folder.test.ts (4 tests) 3ms + ✓  web  tests/unit/presentation/web/lib/compare-versions.test.ts (7 tests) 2ms + ✓  web  tests/unit/presentation/web/actions/compose-user-input.test.ts (4 tests) 1ms + ✓  web  tests/unit/presentation/web/components/common/control-center-drawer/drawer-view.test.ts (10 tests) 2ms + + Test Files  315 passed (315) + Tests  4050 passed (4050) + Start at  13:29:09 + Duration  23.19s (transform 7.65s, setup 21.43s, import 34.29s, tests 62.34s, environment 55.57s) + diff --git a/specs/068-agent-dev-environment-93a417/feature.yaml b/specs/068-agent-dev-environment-93a417/feature.yaml new file mode 100644 index 000000000..fded707c0 --- /dev/null +++ b/specs/068-agent-dev-environment-93a417/feature.yaml @@ -0,0 +1,36 @@ +feature: + id: '068-agent-dev-environment-93a417' + name: 'agent-dev-environment-93a417' + number: 68 + branch: 'feat/068-agent-dev-environment-93a417' + lifecycle: 'research' + createdAt: '2026-03-16T11:13:07Z' + +status: + phase: 'research' + progress: + completed: 0 + total: 0 + percentage: 0 + currentTask: null + lastUpdated: '2026-03-16T11:13:07Z' + lastUpdatedBy: 'feature-agent' + +validation: + lastRun: null + gatesPassed: [] + autoFixesApplied: [] + +tasks: + current: null + blocked: [] + failed: [] + +checkpoints: + - phase: 'feature-created' + completedAt: '2026-03-16T11:13:07Z' + completedBy: 'feature-agent' + +errors: + current: null + history: [] diff --git a/specs/068-agent-dev-environment-93a417/spec.yaml b/specs/068-agent-dev-environment-93a417/spec.yaml new file mode 100644 index 000000000..d89c6c095 --- /dev/null +++ b/specs/068-agent-dev-environment-93a417/spec.yaml @@ -0,0 +1,51 @@ +# Feature Specification (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: agent-dev-environment-93a417 +number: 068 +branch: feat/068-agent-dev-environment-93a417 +oneLiner: The current deployment env feature is simple stupid. lets introduce agent based deployment. so it could start dev env for any repo . it should have some level of cache per repo so it would be fast. it should support al langunges and be basiclly an agent for that. there are some repo which is basicly imposible to start local dev server as there is no server or ui maybe its just a script in such case the agent should responsed that there is nothing to start +userQuery: > + The current deployment env feature is simple stupid. lets introduce agent based deployment. so it could start dev env for any repo . it should have some level of cache per repo so it would be fast. it should support al langunges and be basiclly an agent for that. there are some repo which is basicly imposible to start local dev server as there is no server or ui maybe its just a script in such case the agent should responsed that there is nothing to start +summary: > + The current deployment env feature is simple stupid. lets introduce agent based deployment. so it could start dev env for any repo . it should have some level of cache per repo so it would be fast. it should support al langunges and be basiclly an agent for that. there are some repo which is basicly imposible to start local dev server as there is no server or ui maybe its just a script in such case the agent should responsed that there is nothing to start +phase: Analysis +sizeEstimate: M + +# Relationships +relatedFeatures: [] + +technologies: [] + +relatedLinks: [] + +# Open questions (must be resolved before implementation) +openQuestions: [] + +# Markdown content (the actual spec) +content: | + ## Problem Statement + + The current deployment env feature is simple stupid. lets introduce agent based deployment. so it could start dev env for any repo . it should have some level of cache per repo so it would be fast. it should support al langunges and be basiclly an agent for that. there are some repo which is basicly imposible to start local dev server as there is no server or ui maybe its just a script in such case the agent should responsed that there is nothing to start + + ## Success Criteria + + - [ ] TBD + + ## Affected Areas + + | Area | Impact | Reasoning | + | ---- | ------ | --------- | + | TBD | TBD | TBD | + + ## Dependencies + + None identified. + + ## Size Estimate + + **M** - To be refined during research + + --- + + _Generated by feature agent — proceed with research_ diff --git a/src/presentation/web/app/actions/deploy-feature.ts b/src/presentation/web/app/actions/deploy-feature.ts index 82e43dfd9..256b499f1 100644 --- a/src/presentation/web/app/actions/deploy-feature.ts +++ b/src/presentation/web/app/actions/deploy-feature.ts @@ -5,14 +5,14 @@ import { resolve } from '@/lib/server-container'; import { createDeploymentLogger } from '@shepai/core/infrastructure/services/deployment/deployment-logger'; import { computeWorktreePath } from '@shepai/core/infrastructure/services/ide-launchers/compute-worktree-path'; import type { IFeatureRepository } from '@shepai/core/application/ports/output/repositories/feature-repository.interface'; -import type { IDeploymentService } from '@shepai/core/application/ports/output/services/deployment-service.interface'; -import { DeploymentState } from '@shepai/core/domain/generated/output'; +import type { IAgentDeploymentService } from '@shepai/core/application/ports/output/services/agent-deployment-service.interface'; +import type { DeploymentState } from '@shepai/core/domain/generated/output'; const log = createDeploymentLogger('[deployFeature]'); export async function deployFeature( featureId: string -): Promise<{ success: boolean; error?: string; state?: DeploymentState }> { +): Promise<{ success: boolean; error?: string; state?: DeploymentState; reason?: string }> { log.info(`called — featureId="${featureId}"`); if (!featureId?.trim()) { @@ -41,12 +41,17 @@ export async function deployFeature( return { success: false, error: `Worktree path does not exist: ${worktreePath}` }; } - log.info('worktree path exists, calling deploymentService.start()'); - const deploymentService = resolve('IDeploymentService'); - deploymentService.start(featureId, worktreePath); + log.info('worktree path exists, calling agentDeploymentService.deploy()'); + const agentDeploymentService = resolve('IAgentDeploymentService'); + const result = await agentDeploymentService.deploy(featureId, worktreePath); - log.info('start() returned successfully — state=Booting'); - return { success: true, state: DeploymentState.Booting }; + if (!result.success) { + log.warn(`agent deployment returned not-deployable: ${result.error}`); + return { success: false, error: result.error, reason: result.analysis?.reason }; + } + + log.info('deploy() returned successfully — state=Booting'); + return { success: true, state: result.state }; } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Failed to deploy feature'; log.error(`error: ${message}`, error); diff --git a/src/presentation/web/app/actions/deploy-repository.ts b/src/presentation/web/app/actions/deploy-repository.ts index 69288d7f7..0383964a0 100644 --- a/src/presentation/web/app/actions/deploy-repository.ts +++ b/src/presentation/web/app/actions/deploy-repository.ts @@ -4,14 +4,14 @@ import { existsSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { resolve } from '@/lib/server-container'; import { createDeploymentLogger } from '@shepai/core/infrastructure/services/deployment/deployment-logger'; -import type { IDeploymentService } from '@shepai/core/application/ports/output/services/deployment-service.interface'; -import { DeploymentState } from '@shepai/core/domain/generated/output'; +import type { IAgentDeploymentService } from '@shepai/core/application/ports/output/services/agent-deployment-service.interface'; +import type { DeploymentState } from '@shepai/core/domain/generated/output'; const log = createDeploymentLogger('[deployRepository]'); export async function deployRepository( repositoryPath: string -): Promise<{ success: boolean; error?: string; state?: DeploymentState }> { +): Promise<{ success: boolean; error?: string; state?: DeploymentState; reason?: string }> { log.info(`called — repositoryPath="${repositoryPath}"`); if (!repositoryPath || !isAbsolute(repositoryPath)) { @@ -25,12 +25,17 @@ export async function deployRepository( return { success: false, error: `Directory does not exist: ${repositoryPath}` }; } - log.info('directory exists, calling deploymentService.start()'); - const deploymentService = resolve('IDeploymentService'); - deploymentService.start(repositoryPath, repositoryPath); + log.info('directory exists, calling agentDeploymentService.deploy()'); + const agentDeploymentService = resolve('IAgentDeploymentService'); + const result = await agentDeploymentService.deploy(repositoryPath, repositoryPath); - log.info('start() returned successfully — state=Booting'); - return { success: true, state: DeploymentState.Booting }; + if (!result.success) { + log.warn(`agent deployment returned not-deployable: ${result.error}`); + return { success: false, error: result.error, reason: result.analysis?.reason }; + } + + log.info('deploy() returned successfully — state=Booting'); + return { success: true, state: result.state }; } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Failed to deploy repository'; log.error(`error: ${message}`, error); diff --git a/src/presentation/web/next-env.d.ts b/src/presentation/web/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/src/presentation/web/next-env.d.ts +++ b/src/presentation/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts b/tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts new file mode 100644 index 000000000..a1dacbba3 --- /dev/null +++ b/tests/unit/infrastructure/services/deployment/agent-deployment.service.test.ts @@ -0,0 +1,230 @@ +// @vitest-environment node + +/** + * AgentDeploymentService Unit Tests + * + * Tests for the higher-level deployment orchestrator that uses the + * DevEnvironmentAgent for analysis before starting deployments. + * + * TDD Phase: RED → GREEN → REFACTOR + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + AgentDeploymentService, + type AgentDeploymentServiceDeps, +} from '@/infrastructure/services/deployment/agent-deployment.service.js'; +import type { DevEnvironmentAnalysis } from '@/application/ports/output/services/dev-environment-agent.interface.js'; +import { DeploymentState } from '@/domain/generated/output.js'; + +const DEPLOYABLE_ANALYSIS: DevEnvironmentAnalysis = { + deployable: true, + reason: 'Detected Next.js project with dev script', + command: 'pnpm dev', + cwd: '.', + expectedPort: 3000, + language: 'node', + framework: 'next.js', + setupCommands: [], +}; + +const NOT_DEPLOYABLE_ANALYSIS: DevEnvironmentAnalysis = { + deployable: false, + reason: 'This is a CLI utility with no web server or UI to start', + command: null, + cwd: '.', + expectedPort: null, + language: 'node', + framework: null, + setupCommands: [], +}; + +function createMockDeps( + overrides?: Partial +): AgentDeploymentServiceDeps { + return { + devEnvironmentAgent: { + analyze: vi.fn().mockResolvedValue(DEPLOYABLE_ANALYSIS), + clearCache: vi.fn(), + clearAllCaches: vi.fn(), + }, + deploymentService: { + start: vi.fn(), + stop: vi.fn().mockResolvedValue(undefined), + getStatus: vi.fn().mockReturnValue(null), + stopAll: vi.fn(), + getLogs: vi.fn().mockReturnValue(null), + on: vi.fn(), + off: vi.fn(), + }, + execCommand: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('AgentDeploymentService', () => { + let service: AgentDeploymentService; + let deps: AgentDeploymentServiceDeps; + + beforeEach(() => { + deps = createMockDeps(); + service = new AgentDeploymentService(deps); + }); + + describe('deploy - deployable repo', () => { + it('should analyze the repo via DevEnvironmentAgent', async () => { + await service.deploy('feature-1', '/path/to/repo'); + + expect(deps.devEnvironmentAgent.analyze).toHaveBeenCalledWith('/path/to/repo', { + skipCache: undefined, + }); + }); + + it('should start the deployment service with the detected command', async () => { + await service.deploy('feature-1', '/path/to/repo'); + + expect(deps.deploymentService.start).toHaveBeenCalledWith('feature-1', '/path/to/repo', { + command: 'pnpm dev', + cwd: '.', + }); + }); + + it('should return success with Booting state and analysis', async () => { + const result = await service.deploy('feature-1', '/path/to/repo'); + + expect(result).toEqual({ + success: true, + state: DeploymentState.Booting, + analysis: DEPLOYABLE_ANALYSIS, + }); + }); + + it('should pass skipCache option to the agent', async () => { + await service.deploy('feature-1', '/path/to/repo', { skipCache: true }); + + expect(deps.devEnvironmentAgent.analyze).toHaveBeenCalledWith('/path/to/repo', { + skipCache: true, + }); + }); + + it('should run setup commands before starting the dev server', async () => { + const analysisWithSetup: DevEnvironmentAnalysis = { + ...DEPLOYABLE_ANALYSIS, + setupCommands: ['pnpm install'], + }; + (deps.devEnvironmentAgent.analyze as ReturnType).mockResolvedValue( + analysisWithSetup + ); + + await service.deploy('feature-1', '/path/to/repo'); + + expect(deps.execCommand).toHaveBeenCalledWith('pnpm install', '/path/to/repo'); + expect(deps.deploymentService.start).toHaveBeenCalled(); + }); + + it('should run multiple setup commands in order', async () => { + const analysisWithSetup: DevEnvironmentAnalysis = { + ...DEPLOYABLE_ANALYSIS, + setupCommands: ['pnpm install', 'pnpm generate'], + }; + (deps.devEnvironmentAgent.analyze as ReturnType).mockResolvedValue( + analysisWithSetup + ); + + const callOrder: string[] = []; + (deps.execCommand as ReturnType).mockImplementation(async (cmd: string) => { + callOrder.push(cmd); + }); + + await service.deploy('feature-1', '/path/to/repo'); + + expect(callOrder).toEqual(['pnpm install', 'pnpm generate']); + }); + }); + + describe('deploy - not deployable repo', () => { + it('should return not-deployable result without starting deployment', async () => { + (deps.devEnvironmentAgent.analyze as ReturnType).mockResolvedValue( + NOT_DEPLOYABLE_ANALYSIS + ); + + const result = await service.deploy('feature-1', '/path/to/cli-tool'); + + expect(result).toEqual({ + success: false, + error: 'This is a CLI utility with no web server or UI to start', + analysis: NOT_DEPLOYABLE_ANALYSIS, + }); + expect(deps.deploymentService.start).not.toHaveBeenCalled(); + }); + }); + + describe('deploy - error handling', () => { + it('should return error when agent analysis fails', async () => { + (deps.devEnvironmentAgent.analyze as ReturnType).mockRejectedValue( + new Error('Agent unavailable') + ); + + const result = await service.deploy('feature-1', '/path/to/repo'); + + expect(result).toEqual({ + success: false, + error: 'Agent unavailable', + }); + expect(deps.deploymentService.start).not.toHaveBeenCalled(); + }); + + it('should return error when deployment service start fails', async () => { + (deps.deploymentService.start as ReturnType).mockImplementation(() => { + throw new Error('Failed to spawn process'); + }); + + const result = await service.deploy('feature-1', '/path/to/repo'); + + expect(result).toEqual({ + success: false, + error: 'Failed to spawn process', + analysis: DEPLOYABLE_ANALYSIS, + }); + }); + + it('should return error when setup command fails', async () => { + const analysisWithSetup: DevEnvironmentAnalysis = { + ...DEPLOYABLE_ANALYSIS, + setupCommands: ['pnpm install'], + }; + (deps.devEnvironmentAgent.analyze as ReturnType).mockResolvedValue( + analysisWithSetup + ); + (deps.execCommand as ReturnType).mockRejectedValue( + new Error('pnpm install failed with exit code 1') + ); + + const result = await service.deploy('feature-1', '/path/to/repo'); + + expect(result).toEqual({ + success: false, + error: 'Setup command failed: pnpm install failed with exit code 1', + analysis: analysisWithSetup, + }); + expect(deps.deploymentService.start).not.toHaveBeenCalled(); + }); + + it('should return error when analysis returns deployable but null command', async () => { + const badAnalysis: DevEnvironmentAnalysis = { + ...DEPLOYABLE_ANALYSIS, + command: null, + }; + (deps.devEnvironmentAgent.analyze as ReturnType).mockResolvedValue(badAnalysis); + + const result = await service.deploy('feature-1', '/path/to/repo'); + + expect(result).toEqual({ + success: false, + error: 'Analysis marked repo as deployable but provided no command', + analysis: badAnalysis, + }); + expect(deps.deploymentService.start).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts b/tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts new file mode 100644 index 000000000..55bdc1354 --- /dev/null +++ b/tests/unit/infrastructure/services/deployment/dev-environment-agent.test.ts @@ -0,0 +1,314 @@ +// @vitest-environment node + +/** + * DevEnvironmentAgentService Unit Tests + * + * Tests for the AI-driven dev environment analysis agent. + * Uses a mock structured agent caller to test analysis logic, + * caching behavior, and edge cases. + * + * TDD Phase: RED → GREEN → REFACTOR + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + DevEnvironmentAgentService, + type DevEnvironmentAgentDeps, +} from '@/infrastructure/services/deployment/dev-environment-agent.service.js'; +import type { DevEnvironmentAnalysis } from '@/application/ports/output/services/dev-environment-agent.interface.js'; + +function createMockDeps(overrides?: Partial): DevEnvironmentAgentDeps { + return { + structuredAgentCaller: { + call: vi.fn().mockResolvedValue({ + deployable: true, + reason: 'Detected Next.js project with dev script', + command: 'pnpm dev', + cwd: '.', + expectedPort: 3000, + language: 'node', + framework: 'next.js', + setupCommands: ['pnpm install'], + } satisfies DevEnvironmentAnalysis), + }, + readdir: vi.fn().mockReturnValue(['package.json', 'src', 'tsconfig.json']), + readFile: vi.fn().mockReturnValue('{}'), + existsSync: vi.fn().mockReturnValue(true), + ...overrides, + }; +} + +describe('DevEnvironmentAgentService', () => { + let service: DevEnvironmentAgentService; + let deps: DevEnvironmentAgentDeps; + + beforeEach(() => { + deps = createMockDeps(); + service = new DevEnvironmentAgentService(deps); + }); + + describe('analyze', () => { + it('should call the structured agent caller with a prompt containing directory listing', async () => { + await service.analyze('/path/to/repo'); + + expect(deps.structuredAgentCaller.call).toHaveBeenCalledTimes(1); + const [prompt] = (deps.structuredAgentCaller.call as ReturnType).mock.calls[0]; + expect(prompt).toContain('package.json'); + expect(prompt).toContain('src'); + expect(prompt).toContain('tsconfig.json'); + }); + + it('should pass the correct JSON schema to the agent caller', async () => { + await service.analyze('/path/to/repo'); + + const [, schema] = (deps.structuredAgentCaller.call as ReturnType).mock + .calls[0]; + expect(schema).toHaveProperty('type', 'object'); + expect(schema).toHaveProperty('required'); + expect((schema as { required: string[] }).required).toContain('deployable'); + expect((schema as { required: string[] }).required).toContain('reason'); + expect((schema as { required: string[] }).required).toContain('command'); + }); + + it('should return the analysis from the agent', async () => { + const result = await service.analyze('/path/to/repo'); + + expect(result).toEqual({ + deployable: true, + reason: 'Detected Next.js project with dev script', + command: 'pnpm dev', + cwd: '.', + expectedPort: 3000, + language: 'node', + framework: 'next.js', + setupCommands: ['pnpm install'], + }); + }); + + it('should return not-deployable for repos with no server to start', async () => { + (deps.structuredAgentCaller.call as ReturnType).mockResolvedValue({ + deployable: false, + reason: 'This is a CLI utility with no web server or UI to start', + command: null, + cwd: '.', + expectedPort: null, + language: 'node', + framework: null, + setupCommands: [], + } satisfies DevEnvironmentAnalysis); + + const result = await service.analyze('/path/to/cli-tool'); + + expect(result.deployable).toBe(false); + expect(result.command).toBeNull(); + expect(result.reason).toContain('CLI utility'); + }); + + it('should include key config file contents in the prompt', async () => { + (deps.readdir as ReturnType).mockReturnValue([ + 'package.json', + 'docker-compose.yml', + 'Makefile', + ]); + (deps.readFile as ReturnType).mockReturnValue( + '{"scripts": {"dev": "next dev"}}' + ); + + await service.analyze('/path/to/repo'); + + const [prompt] = (deps.structuredAgentCaller.call as ReturnType).mock.calls[0]; + expect(prompt).toContain('package.json'); + expect(prompt).toContain('"scripts"'); + }); + + it('should handle repos without a recognized config file gracefully', async () => { + (deps.readdir as ReturnType).mockReturnValue(['README.md', 'data.csv']); + (deps.readFile as ReturnType).mockReturnValue(''); + + await service.analyze('/path/to/data-repo'); + + expect(deps.structuredAgentCaller.call).toHaveBeenCalledTimes(1); + }); + + it('should throw when the repository path does not exist', async () => { + (deps.existsSync as ReturnType).mockReturnValue(false); + + await expect(service.analyze('/nonexistent/path')).rejects.toThrow( + 'Repository path does not exist' + ); + }); + + it('should pass silent and maxTurns options to the agent caller', async () => { + await service.analyze('/path/to/repo'); + + const [, , options] = (deps.structuredAgentCaller.call as ReturnType).mock + .calls[0]; + expect(options).toMatchObject({ + silent: true, + maxTurns: 3, + }); + }); + }); + + describe('caching', () => { + it('should cache analysis results and return cached on second call', async () => { + await service.analyze('/path/to/repo'); + await service.analyze('/path/to/repo'); + + expect(deps.structuredAgentCaller.call).toHaveBeenCalledTimes(1); + }); + + it('should use separate cache entries for different repos', async () => { + await service.analyze('/path/to/repo-a'); + await service.analyze('/path/to/repo-b'); + + expect(deps.structuredAgentCaller.call).toHaveBeenCalledTimes(2); + }); + + it('should skip cache when skipCache option is true', async () => { + await service.analyze('/path/to/repo'); + await service.analyze('/path/to/repo', { skipCache: true }); + + expect(deps.structuredAgentCaller.call).toHaveBeenCalledTimes(2); + }); + + it('should update cache when skipCache forces re-analysis', async () => { + (deps.structuredAgentCaller.call as ReturnType) + .mockResolvedValueOnce({ + deployable: true, + reason: 'First analysis', + command: 'npm run dev', + cwd: '.', + expectedPort: 3000, + language: 'node', + framework: null, + setupCommands: [], + }) + .mockResolvedValueOnce({ + deployable: true, + reason: 'Updated analysis', + command: 'pnpm dev', + cwd: '.', + expectedPort: 3000, + language: 'node', + framework: 'next.js', + setupCommands: ['pnpm install'], + }); + + await service.analyze('/path/to/repo'); + const result = await service.analyze('/path/to/repo', { skipCache: true }); + + expect(result.reason).toBe('Updated analysis'); + expect(result.command).toBe('pnpm dev'); + }); + + it('should clear cache for a specific repo', async () => { + await service.analyze('/path/to/repo'); + + service.clearCache('/path/to/repo'); + + await service.analyze('/path/to/repo'); + expect(deps.structuredAgentCaller.call).toHaveBeenCalledTimes(2); + }); + + it('should clear all caches', async () => { + await service.analyze('/path/to/repo-a'); + await service.analyze('/path/to/repo-b'); + + service.clearAllCaches(); + + await service.analyze('/path/to/repo-a'); + await service.analyze('/path/to/repo-b'); + expect(deps.structuredAgentCaller.call).toHaveBeenCalledTimes(4); + }); + + it('should not cache failed analyses (agent throws)', async () => { + (deps.structuredAgentCaller.call as ReturnType) + .mockRejectedValueOnce(new Error('Agent unavailable')) + .mockResolvedValueOnce({ + deployable: true, + reason: 'Success on retry', + command: 'npm run dev', + cwd: '.', + expectedPort: 3000, + language: 'node', + framework: null, + setupCommands: [], + }); + + await expect(service.analyze('/path/to/repo')).rejects.toThrow('Agent unavailable'); + + const result = await service.analyze('/path/to/repo'); + expect(result.reason).toBe('Success on retry'); + expect(deps.structuredAgentCaller.call).toHaveBeenCalledTimes(2); + }); + }); + + describe('prompt construction', () => { + it('should include Python files in the analysis when present', async () => { + (deps.readdir as ReturnType).mockReturnValue([ + 'requirements.txt', + 'manage.py', + 'setup.py', + ]); + + await service.analyze('/path/to/django-app'); + + const [prompt] = (deps.structuredAgentCaller.call as ReturnType).mock.calls[0]; + expect(prompt).toContain('requirements.txt'); + expect(prompt).toContain('manage.py'); + }); + + it('should include Go files in the analysis when present', async () => { + (deps.readdir as ReturnType).mockReturnValue(['go.mod', 'go.sum', 'main.go']); + + await service.analyze('/path/to/go-app'); + + const [prompt] = (deps.structuredAgentCaller.call as ReturnType).mock.calls[0]; + expect(prompt).toContain('go.mod'); + }); + + it('should include Rust files in the analysis when present', async () => { + (deps.readdir as ReturnType).mockReturnValue([ + 'Cargo.toml', + 'Cargo.lock', + 'src', + ]); + + await service.analyze('/path/to/rust-app'); + + const [prompt] = (deps.structuredAgentCaller.call as ReturnType).mock.calls[0]; + expect(prompt).toContain('Cargo.toml'); + }); + + it('should read relevant config files and include their contents', async () => { + (deps.readdir as ReturnType).mockReturnValue([ + 'package.json', + 'docker-compose.yml', + ]); + (deps.readFile as ReturnType).mockImplementation((path: string) => { + if (path.endsWith('package.json')) return '{"scripts":{"dev":"next dev"}}'; + if (path.endsWith('docker-compose.yml')) return 'version: "3"'; + return ''; + }); + + await service.analyze('/path/to/repo'); + + const [prompt] = (deps.structuredAgentCaller.call as ReturnType).mock.calls[0]; + expect(prompt).toContain('next dev'); + expect(prompt).toContain('version: "3"'); + }); + + it('should truncate large config files to prevent prompt overflow', async () => { + (deps.readdir as ReturnType).mockReturnValue(['package.json']); + const largeContent = 'x'.repeat(10_000); + (deps.readFile as ReturnType).mockReturnValue(largeContent); + + await service.analyze('/path/to/repo'); + + const [prompt] = (deps.structuredAgentCaller.call as ReturnType).mock.calls[0]; + // Should be truncated — exact limit is an implementation detail + expect(prompt.length).toBeLessThan(largeContent.length + 2000); + }); + }); +}); diff --git a/tests/unit/presentation/web/actions/deploy-feature.test.ts b/tests/unit/presentation/web/actions/deploy-feature.test.ts index 349060353..f4904622c 100644 --- a/tests/unit/presentation/web/actions/deploy-feature.test.ts +++ b/tests/unit/presentation/web/actions/deploy-feature.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const mockStart = vi.fn(); +const mockDeploy = vi.fn(); const mockResolve = vi.fn(); vi.mock('@/lib/server-container', () => ({ resolve: (token: string) => mockResolve(token), @@ -35,25 +35,26 @@ describe('deployFeature server action', () => { vi.clearAllMocks(); mockComputeWorktreePath.mockReturnValue(MOCK_WORKTREE_PATH); mockExistsSync.mockReturnValue(true); + mockDeploy.mockResolvedValue({ success: true, state: 'Booting' }); mockResolve.mockImplementation((token: string) => { if (token === 'IFeatureRepository') { return { findById: vi.fn().mockResolvedValue(MOCK_FEATURE) }; } - if (token === 'IDeploymentService') { - return { start: mockStart }; + if (token === 'IAgentDeploymentService') { + return { deploy: mockDeploy }; } return {}; }); }); - it('resolves feature, computes worktree path, and calls service.start', async () => { + it('resolves feature, computes worktree path, and calls agentDeploymentService.deploy', async () => { const result = await deployFeature('feat-123'); expect(mockResolve).toHaveBeenCalledWith('IFeatureRepository'); - expect(mockResolve).toHaveBeenCalledWith('IDeploymentService'); + expect(mockResolve).toHaveBeenCalledWith('IAgentDeploymentService'); expect(mockComputeWorktreePath).toHaveBeenCalledWith('/home/user/project', 'feat/my-feature'); expect(mockExistsSync).toHaveBeenCalledWith(MOCK_WORKTREE_PATH); - expect(mockStart).toHaveBeenCalledWith('feat-123', MOCK_WORKTREE_PATH); + expect(mockDeploy).toHaveBeenCalledWith('feat-123', MOCK_WORKTREE_PATH); expect(result).toEqual({ success: true, state: 'Booting' }); }); @@ -61,7 +62,7 @@ describe('deployFeature server action', () => { const result = await deployFeature(''); expect(result).toEqual({ success: false, error: 'featureId is required' }); - expect(mockStart).not.toHaveBeenCalled(); + expect(mockDeploy).not.toHaveBeenCalled(); }); it('returns error when feature is not found', async () => { @@ -69,8 +70,8 @@ describe('deployFeature server action', () => { if (token === 'IFeatureRepository') { return { findById: vi.fn().mockResolvedValue(null) }; } - if (token === 'IDeploymentService') { - return { start: mockStart }; + if (token === 'IAgentDeploymentService') { + return { deploy: mockDeploy }; } return {}; }); @@ -78,7 +79,7 @@ describe('deployFeature server action', () => { const result = await deployFeature('nonexistent-id'); expect(result).toEqual({ success: false, error: 'Feature not found: nonexistent-id' }); - expect(mockStart).not.toHaveBeenCalled(); + expect(mockDeploy).not.toHaveBeenCalled(); }); it('returns error when worktree directory does not exist', async () => { @@ -89,26 +90,38 @@ describe('deployFeature server action', () => { expect(result.success).toBe(false); expect(result.error).toContain('does not exist'); expect(result.error).toContain(MOCK_WORKTREE_PATH); - expect(mockStart).not.toHaveBeenCalled(); + expect(mockDeploy).not.toHaveBeenCalled(); }); - it('returns error when service.start throws', async () => { - mockStart.mockImplementation(() => { - throw new Error('No dev script found in package.json'); + it('returns error when agent reports repo is not deployable', async () => { + mockDeploy.mockResolvedValue({ + success: false, + error: 'This is a CLI utility with no web server', + analysis: { reason: 'CLI utility — no server to start' }, }); const result = await deployFeature('feat-123'); expect(result).toEqual({ success: false, - error: 'No dev script found in package.json', + error: 'This is a CLI utility with no web server', + reason: 'CLI utility — no server to start', }); }); - it('returns generic error for non-Error throws', async () => { - mockStart.mockImplementation(() => { - throw 'unexpected'; + it('returns error when deploy throws', async () => { + mockDeploy.mockRejectedValue(new Error('Agent unavailable')); + + const result = await deployFeature('feat-123'); + + expect(result).toEqual({ + success: false, + error: 'Agent unavailable', }); + }); + + it('returns generic error for non-Error throws', async () => { + mockDeploy.mockRejectedValue('unexpected'); const result = await deployFeature('feat-123'); diff --git a/tests/unit/presentation/web/actions/deploy-repository.test.ts b/tests/unit/presentation/web/actions/deploy-repository.test.ts index 7bf0f7a51..51dd2c05c 100644 --- a/tests/unit/presentation/web/actions/deploy-repository.test.ts +++ b/tests/unit/presentation/web/actions/deploy-repository.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const mockStart = vi.fn(); +const mockDeploy = vi.fn(); const mockResolve = vi.fn(); vi.mock('@/lib/server-container', () => ({ resolve: (token: string) => mockResolve(token), @@ -28,9 +28,10 @@ describe('deployRepository server action', () => { vi.clearAllMocks(); mockExistsSync.mockReturnValue(true); mockIsAbsolute.mockImplementation((p: string) => /^\//.test(p)); + mockDeploy.mockResolvedValue({ success: true, state: 'Booting' }); mockResolve.mockImplementation((token: string) => { - if (token === 'IDeploymentService') { - return { start: mockStart }; + if (token === 'IAgentDeploymentService') { + return { deploy: mockDeploy }; } return {}; }); @@ -43,7 +44,7 @@ describe('deployRepository server action', () => { success: false, error: 'repositoryPath must be an absolute path', }); - expect(mockStart).not.toHaveBeenCalled(); + expect(mockDeploy).not.toHaveBeenCalled(); }); it('returns error for empty repositoryPath', async () => { @@ -53,7 +54,7 @@ describe('deployRepository server action', () => { success: false, error: 'repositoryPath must be an absolute path', }); - expect(mockStart).not.toHaveBeenCalled(); + expect(mockDeploy).not.toHaveBeenCalled(); }); it('accepts Windows-style absolute paths when path.isAbsolute recognizes them', async () => { @@ -62,7 +63,7 @@ describe('deployRepository server action', () => { const result = await deployRepository('C:\\Projects\\repo'); expect(mockExistsSync).toHaveBeenCalledWith('C:\\Projects\\repo'); - expect(mockStart).toHaveBeenCalledWith('C:\\Projects\\repo', 'C:\\Projects\\repo'); + expect(mockDeploy).toHaveBeenCalledWith('C:\\Projects\\repo', 'C:\\Projects\\repo'); expect(result).toEqual({ success: true, state: 'Booting' }); }); @@ -73,35 +74,47 @@ describe('deployRepository server action', () => { expect(result.success).toBe(false); expect(result.error).toContain('does not exist'); - expect(mockStart).not.toHaveBeenCalled(); + expect(mockDeploy).not.toHaveBeenCalled(); }); - it('calls service.start with repositoryPath as both targetId and path', async () => { + it('calls agentDeploymentService.deploy with repositoryPath as both targetId and path', async () => { const result = await deployRepository('/home/user/project'); - expect(mockResolve).toHaveBeenCalledWith('IDeploymentService'); + expect(mockResolve).toHaveBeenCalledWith('IAgentDeploymentService'); expect(mockExistsSync).toHaveBeenCalledWith('/home/user/project'); - expect(mockStart).toHaveBeenCalledWith('/home/user/project', '/home/user/project'); + expect(mockDeploy).toHaveBeenCalledWith('/home/user/project', '/home/user/project'); expect(result).toEqual({ success: true, state: 'Booting' }); }); - it('returns error when service.start throws', async () => { - mockStart.mockImplementation(() => { - throw new Error('No dev script found in package.json'); + it('returns error when agent reports repo is not deployable', async () => { + mockDeploy.mockResolvedValue({ + success: false, + error: 'This is a data-only repository with no server to start', + analysis: { reason: 'Data repo — no server' }, }); const result = await deployRepository('/home/user/project'); expect(result).toEqual({ success: false, - error: 'No dev script found in package.json', + error: 'This is a data-only repository with no server to start', + reason: 'Data repo — no server', }); }); - it('returns generic error for non-Error throws', async () => { - mockStart.mockImplementation(() => { - throw 'unexpected'; + it('returns error when deploy throws', async () => { + mockDeploy.mockRejectedValue(new Error('Agent unavailable')); + + const result = await deployRepository('/home/user/project'); + + expect(result).toEqual({ + success: false, + error: 'Agent unavailable', }); + }); + + it('returns generic error for non-Error throws', async () => { + mockDeploy.mockRejectedValue('unexpected'); const result = await deployRepository('/home/user/project');