mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Compare commits
8 Commits
refactor/a
...
weird-side
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eb92a0402 | ||
|
|
524a9736b4 | ||
|
|
036a7d9d26 | ||
|
|
c4df2c141a | ||
|
|
7c75c24b5c | ||
|
|
2588ecaafa | ||
|
|
a071097c0d | ||
|
|
b930091c42 |
99
CLAUDE.md
99
CLAUDE.md
@@ -1,99 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Automaker is an autonomous AI development studio - a Kanban-based application where users describe features and AI agents (powered by Claude Agent SDK) automatically implement them. It runs as an Electron desktop app or in web browser mode.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Build shared packages (REQUIRED before running)
|
|
||||||
npm run build:packages
|
|
||||||
|
|
||||||
# Development
|
|
||||||
npm run dev # Interactive mode selector
|
|
||||||
npm run dev:electron # Electron desktop app
|
|
||||||
npm run dev:web # Web browser mode (localhost:3007)
|
|
||||||
npm run dev:server # Backend server only
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
npm run test # UI E2E tests (Playwright)
|
|
||||||
npm run test:headed # UI tests with visible browser
|
|
||||||
npm run test:server # Server unit tests (Vitest)
|
|
||||||
npm run test:packages # Shared package tests
|
|
||||||
|
|
||||||
# Linting & Formatting
|
|
||||||
npm run lint # ESLint
|
|
||||||
npm run format # Prettier
|
|
||||||
npm run format:check # Check formatting
|
|
||||||
|
|
||||||
# Building
|
|
||||||
npm run build # Build Next.js app
|
|
||||||
npm run build:electron # Build Electron distribution
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Monorepo Structure (npm workspaces)
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/
|
|
||||||
├── ui/ # Electron + Vite + React frontend (@automaker/ui)
|
|
||||||
└── server/ # Express + WebSocket backend (@automaker/server)
|
|
||||||
|
|
||||||
libs/ # Shared packages (@automaker/*)
|
|
||||||
├── types/ # Shared TypeScript interfaces
|
|
||||||
├── utils/ # Common utilities
|
|
||||||
├── prompts/ # AI prompt templates
|
|
||||||
├── platform/ # Platform-specific code (paths, security)
|
|
||||||
├── git-utils/ # Git operations
|
|
||||||
├── model-resolver/ # AI model configuration
|
|
||||||
└── dependency-resolver/ # Dependency management
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Patterns
|
|
||||||
|
|
||||||
**State Management (UI)**: Zustand stores in `apps/ui/src/store/`
|
|
||||||
|
|
||||||
- `app-store.ts` - Main application state (features, settings, themes)
|
|
||||||
- `setup-store.ts` - Project setup wizard state
|
|
||||||
|
|
||||||
**Routing (UI)**: TanStack Router with file-based routes in `apps/ui/src/routes/`
|
|
||||||
|
|
||||||
**Backend Services**: Express + WebSocket in `apps/server/src/`
|
|
||||||
|
|
||||||
- Services in `/services/` handle business logic
|
|
||||||
- Routes in `/routes/` define API endpoints
|
|
||||||
- Providers in `/providers/` abstract AI model integrations
|
|
||||||
|
|
||||||
**Provider Architecture**: Model-based routing via `ProviderFactory`
|
|
||||||
|
|
||||||
- `ClaudeProvider` wraps @anthropic-ai/claude-agent-sdk
|
|
||||||
- Designed for easy addition of other providers
|
|
||||||
|
|
||||||
**Feature Storage**: Features stored in `.automaker/features/{id}/feature.json`
|
|
||||||
|
|
||||||
**Communication**:
|
|
||||||
|
|
||||||
- Electron: IPC via preload script (`apps/ui/src/preload.ts`)
|
|
||||||
- Web: HTTP API client (`apps/ui/src/lib/http-api-client.ts`)
|
|
||||||
|
|
||||||
### Important Files
|
|
||||||
|
|
||||||
- `apps/ui/src/main.ts` - Electron main process
|
|
||||||
- `apps/server/src/index.ts` - Server entry point
|
|
||||||
- `apps/ui/src/lib/electron.ts` - IPC type definitions
|
|
||||||
- `apps/server/src/services/agent-service.ts` - AI agent session management
|
|
||||||
- `apps/server/src/providers/provider-factory.ts` - Model routing
|
|
||||||
|
|
||||||
## Development Notes
|
|
||||||
|
|
||||||
- Always run `npm run build:packages` after modifying any `libs/*` package
|
|
||||||
- Server runs on port 3008 by default
|
|
||||||
- UI runs on port 3007 in web mode
|
|
||||||
- Authentication: Set `ANTHROPIC_API_KEY` env var or configure via Settings
|
|
||||||
25
apps/server/src/lib/enhancement-prompts.ts
Normal file
25
apps/server/src/lib/enhancement-prompts.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Enhancement Prompts - Re-exported from @automaker/prompts
|
||||||
|
*
|
||||||
|
* This file now re-exports enhancement prompts from the shared @automaker/prompts package
|
||||||
|
* to maintain backward compatibility with existing imports in the server codebase.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
IMPROVE_SYSTEM_PROMPT,
|
||||||
|
TECHNICAL_SYSTEM_PROMPT,
|
||||||
|
SIMPLIFY_SYSTEM_PROMPT,
|
||||||
|
ACCEPTANCE_SYSTEM_PROMPT,
|
||||||
|
IMPROVE_EXAMPLES,
|
||||||
|
TECHNICAL_EXAMPLES,
|
||||||
|
SIMPLIFY_EXAMPLES,
|
||||||
|
ACCEPTANCE_EXAMPLES,
|
||||||
|
getEnhancementPrompt,
|
||||||
|
getSystemPrompt,
|
||||||
|
getExamples,
|
||||||
|
buildUserPrompt,
|
||||||
|
isValidEnhancementMode,
|
||||||
|
getAvailableEnhancementModes,
|
||||||
|
} from '@automaker/prompts';
|
||||||
|
|
||||||
|
export type { EnhancementMode, EnhancementExample } from '@automaker/prompts';
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shell execution utilities
|
|
||||||
*
|
|
||||||
* Provides cross-platform shell execution with extended PATH
|
|
||||||
* to find tools like git and gh in Electron environments.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promisified exec for async/await usage
|
|
||||||
*/
|
|
||||||
export const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path separator for the current platform
|
|
||||||
*/
|
|
||||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional paths to search for executables.
|
|
||||||
* Electron apps don't inherit the user's shell PATH, so we need to add
|
|
||||||
* common tool installation locations.
|
|
||||||
*/
|
|
||||||
const additionalPaths: string[] = [];
|
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
// Windows paths for Git and other tools
|
|
||||||
if (process.env.LOCALAPPDATA) {
|
|
||||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
|
||||||
}
|
|
||||||
if (process.env.PROGRAMFILES) {
|
|
||||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
|
||||||
}
|
|
||||||
if (process.env['ProgramFiles(x86)']) {
|
|
||||||
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Unix/Mac paths
|
|
||||||
additionalPaths.push(
|
|
||||||
'/opt/homebrew/bin', // Homebrew on Apple Silicon
|
|
||||||
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
|
|
||||||
'/home/linuxbrew/.linuxbrew/bin', // Linuxbrew
|
|
||||||
`${process.env.HOME}/.local/bin` // pipx, other user installs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extended PATH that includes common tool installation locations.
|
|
||||||
*/
|
|
||||||
export const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(pathSeparator);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Environment variables with extended PATH for executing shell commands.
|
|
||||||
*/
|
|
||||||
export const execEnv = {
|
|
||||||
...process.env,
|
|
||||||
PATH: extendedPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error is ENOENT (file/path not found or spawn failed)
|
|
||||||
*/
|
|
||||||
export function isENOENT(error: unknown): boolean {
|
|
||||||
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
|
||||||
}
|
|
||||||
23
apps/server/src/lib/secure-fs.ts
Normal file
23
apps/server/src/lib/secure-fs.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Re-export secure file system utilities from @automaker/platform
|
||||||
|
* This file exists for backward compatibility with existing imports
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { secureFs } from '@automaker/platform';
|
||||||
|
|
||||||
|
export const {
|
||||||
|
access,
|
||||||
|
readFile,
|
||||||
|
writeFile,
|
||||||
|
mkdir,
|
||||||
|
readdir,
|
||||||
|
stat,
|
||||||
|
rm,
|
||||||
|
unlink,
|
||||||
|
copyFile,
|
||||||
|
appendFile,
|
||||||
|
rename,
|
||||||
|
lstat,
|
||||||
|
joinPath,
|
||||||
|
resolvePath,
|
||||||
|
} = secureFs;
|
||||||
@@ -3,16 +3,26 @@
|
|||||||
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
|
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from './secure-fs.js';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { WorktreePRInfo, WorktreeMetadata } from '@automaker/types';
|
|
||||||
|
|
||||||
// Re-export types for convenience
|
|
||||||
export type { WorktreePRInfo, WorktreeMetadata } from '@automaker/types';
|
|
||||||
|
|
||||||
/** Maximum length for sanitized branch names in filesystem paths */
|
/** Maximum length for sanitized branch names in filesystem paths */
|
||||||
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
||||||
|
|
||||||
|
export interface WorktreePRInfo {
|
||||||
|
number: number;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeMetadata {
|
||||||
|
branch: string;
|
||||||
|
createdAt: string;
|
||||||
|
pr?: WorktreePRInfo;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize branch name for cross-platform filesystem safety
|
* Sanitize branch name for cross-platform filesystem safety
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import type {
|
|||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
SimpleQueryOptions,
|
|
||||||
SimpleQueryResult,
|
|
||||||
StreamingQueryOptions,
|
|
||||||
StreamingQueryResult,
|
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,22 +35,6 @@ export abstract class BaseProvider {
|
|||||||
*/
|
*/
|
||||||
abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
|
abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a simple one-shot query and return text directly
|
|
||||||
* Use for quick completions without tools (title gen, descriptions, etc.)
|
|
||||||
* @param options Simple query options
|
|
||||||
* @returns Query result with text
|
|
||||||
*/
|
|
||||||
abstract executeSimpleQuery(options: SimpleQueryOptions): Promise<SimpleQueryResult>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a streaming query with tools and/or structured output
|
|
||||||
* Use for queries that need tools, progress callbacks, or structured JSON output
|
|
||||||
* @param options Streaming query options
|
|
||||||
* @returns Query result with text and optional structured output
|
|
||||||
*/
|
|
||||||
abstract executeStreamingQuery(options: StreamingQueryOptions): Promise<StreamingQueryResult>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if the provider is installed and configured
|
* Detect if the provider is installed and configured
|
||||||
* @returns Installation status
|
* @returns Installation status
|
||||||
|
|||||||
@@ -3,26 +3,15 @@
|
|||||||
*
|
*
|
||||||
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
|
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
|
||||||
* with the provider architecture.
|
* with the provider architecture.
|
||||||
*
|
|
||||||
* Provides two query methods:
|
|
||||||
* - executeQuery(): Streaming async generator for complex multi-turn sessions
|
|
||||||
* - executeSimpleQuery(): One-shot queries that return text directly (title gen, descriptions, etc.)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
|
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { BaseProvider } from './base-provider.js';
|
import { BaseProvider } from './base-provider.js';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
|
||||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
|
||||||
import type {
|
import type {
|
||||||
ExecuteOptions,
|
ExecuteOptions,
|
||||||
ProviderMessage,
|
ProviderMessage,
|
||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
SimpleQueryOptions,
|
|
||||||
SimpleQueryResult,
|
|
||||||
StreamingQueryOptions,
|
|
||||||
StreamingQueryResult,
|
|
||||||
PromptContentBlock,
|
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
export class ClaudeProvider extends BaseProvider {
|
export class ClaudeProvider extends BaseProvider {
|
||||||
@@ -186,225 +175,4 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
const supportedFeatures = ['tools', 'text', 'vision', 'thinking'];
|
const supportedFeatures = ['tools', 'text', 'vision', 'thinking'];
|
||||||
return supportedFeatures.includes(feature);
|
return supportedFeatures.includes(feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a simple one-shot query and return text directly
|
|
||||||
*
|
|
||||||
* Use this for:
|
|
||||||
* - Title generation from description
|
|
||||||
* - Text enhancement
|
|
||||||
* - File/image description
|
|
||||||
* - Any quick, single-turn completion without tools
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const provider = ProviderFactory.getProviderForModel('haiku');
|
|
||||||
* const result = await provider.executeSimpleQuery({
|
|
||||||
* prompt: 'Generate a title for: User authentication feature',
|
|
||||||
* systemPrompt: 'You are a title generator...',
|
|
||||||
* });
|
|
||||||
* if (result.success) console.log(result.text);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async executeSimpleQuery(options: SimpleQueryOptions): Promise<SimpleQueryResult> {
|
|
||||||
const { prompt, model, systemPrompt, abortController } = options;
|
|
||||||
|
|
||||||
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.haiku);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sdkOptions: Options = {
|
|
||||||
model: resolvedModel,
|
|
||||||
systemPrompt,
|
|
||||||
maxTurns: 1,
|
|
||||||
allowedTools: [],
|
|
||||||
permissionMode: 'acceptEdits',
|
|
||||||
abortController,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle both string prompts and multi-part content blocks
|
|
||||||
const stream = Array.isArray(prompt)
|
|
||||||
? query({ prompt: this.createPromptGenerator(prompt), options: sdkOptions })
|
|
||||||
: query({ prompt, options: sdkOptions });
|
|
||||||
const { text } = await this.extractTextFromStream(stream);
|
|
||||||
|
|
||||||
if (!text || text.trim().length === 0) {
|
|
||||||
return {
|
|
||||||
text: '',
|
|
||||||
success: false,
|
|
||||||
error: 'Empty response from Claude',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: text.trim(),
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error('[ClaudeProvider] executeSimpleQuery() error:', errorMessage);
|
|
||||||
return {
|
|
||||||
text: '',
|
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a streaming query with tools and/or structured output
|
|
||||||
*
|
|
||||||
* Use this for:
|
|
||||||
* - Spec generation (with JSON schema output)
|
|
||||||
* - Feature generation from specs
|
|
||||||
* - Suggestions generation
|
|
||||||
* - Any query that needs tools or progress callbacks
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const provider = ProviderFactory.getProviderForModel('opus');
|
|
||||||
* const result = await provider.executeStreamingQuery({
|
|
||||||
* prompt: 'Analyze this project...',
|
|
||||||
* cwd: '/path/to/project',
|
|
||||||
* allowedTools: ['Read', 'Glob', 'Grep'],
|
|
||||||
* outputFormat: { type: 'json_schema', schema: mySchema },
|
|
||||||
* onText: (chunk) => console.log('Progress:', chunk),
|
|
||||||
* });
|
|
||||||
* console.log(result.structuredOutput);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async executeStreamingQuery(options: StreamingQueryOptions): Promise<StreamingQueryResult> {
|
|
||||||
const {
|
|
||||||
prompt,
|
|
||||||
model,
|
|
||||||
systemPrompt,
|
|
||||||
cwd,
|
|
||||||
maxTurns = 100,
|
|
||||||
allowedTools = ['Read', 'Glob', 'Grep'],
|
|
||||||
abortController,
|
|
||||||
outputFormat,
|
|
||||||
onText,
|
|
||||||
onToolUse,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.haiku);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sdkOptions: Options = {
|
|
||||||
model: resolvedModel,
|
|
||||||
systemPrompt,
|
|
||||||
maxTurns,
|
|
||||||
cwd,
|
|
||||||
allowedTools: [...allowedTools],
|
|
||||||
permissionMode: 'acceptEdits',
|
|
||||||
abortController,
|
|
||||||
...(outputFormat && { outputFormat }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle both string prompts and multi-part content blocks
|
|
||||||
const stream = Array.isArray(prompt)
|
|
||||||
? query({ prompt: this.createPromptGenerator(prompt), options: sdkOptions })
|
|
||||||
: query({ prompt, options: sdkOptions });
|
|
||||||
const { text, structuredOutput } = await this.extractTextFromStream(stream, {
|
|
||||||
onText,
|
|
||||||
onToolUse,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!text && !structuredOutput) {
|
|
||||||
return {
|
|
||||||
text: '',
|
|
||||||
success: false,
|
|
||||||
error: 'Empty response from Claude',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: text.trim(),
|
|
||||||
success: true,
|
|
||||||
structuredOutput,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error('[ClaudeProvider] executeStreamingQuery() error:', errorMessage);
|
|
||||||
return {
|
|
||||||
text: '',
|
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a multi-part prompt generator for content blocks
|
|
||||||
*/
|
|
||||||
private createPromptGenerator(content: PromptContentBlock[]) {
|
|
||||||
// Return an async generator that yields SDK user messages
|
|
||||||
// The SDK expects this format for multi-part prompts
|
|
||||||
return (async function* () {
|
|
||||||
yield {
|
|
||||||
type: 'user' as const,
|
|
||||||
session_id: '',
|
|
||||||
message: { role: 'user' as const, content },
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract text and structured output from SDK stream
|
|
||||||
*
|
|
||||||
* This consolidates the duplicated extractTextFromStream() function
|
|
||||||
* that was copied across 5+ route files.
|
|
||||||
*/
|
|
||||||
private async extractTextFromStream(
|
|
||||||
stream: AsyncIterable<unknown>,
|
|
||||||
handlers?: {
|
|
||||||
onText?: (text: string) => void;
|
|
||||||
onToolUse?: (name: string, input: unknown) => void;
|
|
||||||
}
|
|
||||||
): Promise<{ text: string; structuredOutput?: unknown }> {
|
|
||||||
let responseText = '';
|
|
||||||
let structuredOutput: unknown = undefined;
|
|
||||||
|
|
||||||
for await (const msg of stream) {
|
|
||||||
const message = msg as {
|
|
||||||
type: string;
|
|
||||||
subtype?: string;
|
|
||||||
result?: string;
|
|
||||||
structured_output?: unknown;
|
|
||||||
message?: {
|
|
||||||
content?: Array<{ type: string; text?: string; name?: string; input?: unknown }>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
if (message.type === 'assistant' && message.message?.content) {
|
|
||||||
for (const block of message.message.content) {
|
|
||||||
if (block.type === 'text' && block.text) {
|
|
||||||
responseText += block.text;
|
|
||||||
handlers?.onText?.(block.text);
|
|
||||||
} else if (block.type === 'tool_use' && block.name) {
|
|
||||||
handlers?.onToolUse?.(block.name, block.input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (message.type === 'result' && message.subtype === 'success') {
|
|
||||||
if (message.result) {
|
|
||||||
responseText = message.result;
|
|
||||||
}
|
|
||||||
if (message.structured_output) {
|
|
||||||
structuredOutput = message.structured_output;
|
|
||||||
}
|
|
||||||
} else if (message.type === 'result' && message.subtype === 'error_max_turns') {
|
|
||||||
console.warn('[ClaudeProvider] Hit max turns limit');
|
|
||||||
} else if (
|
|
||||||
message.type === 'result' &&
|
|
||||||
message.subtype === 'error_max_structured_output_retries'
|
|
||||||
) {
|
|
||||||
throw new Error('Failed to produce valid structured output after retries');
|
|
||||||
} else if (message.type === 'error') {
|
|
||||||
const errorMsg = (message as { error?: string }).error || 'Unknown error';
|
|
||||||
throw new Error(errorMsg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { text: responseText, structuredOutput };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,92 +102,3 @@ export interface ModelDefinition {
|
|||||||
tier?: 'basic' | 'standard' | 'premium';
|
tier?: 'basic' | 'standard' | 'premium';
|
||||||
default?: boolean;
|
default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Content block for multi-part prompts (images, structured text)
|
|
||||||
*/
|
|
||||||
export interface TextContentBlock {
|
|
||||||
type: 'text';
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageContentBlock {
|
|
||||||
type: 'image';
|
|
||||||
source: {
|
|
||||||
type: 'base64';
|
|
||||||
media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
|
||||||
data: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PromptContentBlock = TextContentBlock | ImageContentBlock;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for simple one-shot queries (title generation, descriptions, text enhancement)
|
|
||||||
*
|
|
||||||
* These queries:
|
|
||||||
* - Don't need tools
|
|
||||||
* - Return text directly (no streaming)
|
|
||||||
* - Are single-turn (maxTurns=1)
|
|
||||||
*/
|
|
||||||
export interface SimpleQueryOptions {
|
|
||||||
/** The prompt - either a string or array of content blocks */
|
|
||||||
prompt: string | PromptContentBlock[];
|
|
||||||
|
|
||||||
/** Model to use (defaults to haiku) */
|
|
||||||
model?: string;
|
|
||||||
|
|
||||||
/** Optional system prompt */
|
|
||||||
systemPrompt?: string;
|
|
||||||
|
|
||||||
/** Abort controller for cancellation */
|
|
||||||
abortController?: AbortController;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result from a simple query
|
|
||||||
*/
|
|
||||||
export interface SimpleQueryResult {
|
|
||||||
/** Extracted text from the response */
|
|
||||||
text: string;
|
|
||||||
|
|
||||||
/** Whether the query completed successfully */
|
|
||||||
success: boolean;
|
|
||||||
|
|
||||||
/** Error message if failed */
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for streaming queries with tools and/or structured output
|
|
||||||
*/
|
|
||||||
export interface StreamingQueryOptions extends SimpleQueryOptions {
|
|
||||||
/** Working directory for tool execution */
|
|
||||||
cwd: string;
|
|
||||||
|
|
||||||
/** Max turns (defaults to sdk-options presets) */
|
|
||||||
maxTurns?: number;
|
|
||||||
|
|
||||||
/** Tools to allow */
|
|
||||||
allowedTools?: readonly string[];
|
|
||||||
|
|
||||||
/** JSON schema for structured output */
|
|
||||||
outputFormat?: {
|
|
||||||
type: 'json_schema';
|
|
||||||
schema: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Callback for text chunks */
|
|
||||||
onText?: (text: string) => void;
|
|
||||||
|
|
||||||
/** Callback for tool usage */
|
|
||||||
onToolUse?: (name: string, input: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result from a streaming query with structured output
|
|
||||||
*/
|
|
||||||
export interface StreamingQueryResult extends SimpleQueryResult {
|
|
||||||
/** Parsed structured output if outputFormat was specified */
|
|
||||||
structuredOutput?: unknown;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Generate features from existing app_spec.txt
|
* Generate features from existing app_spec.txt
|
||||||
*
|
|
||||||
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
||||||
|
import { logAuthStatus } from './common.js';
|
||||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||||
import { getAppSpecPath, secureFs } from '@automaker/platform';
|
import { getAppSpecPath } from '@automaker/platform';
|
||||||
|
|
||||||
const logger = createLogger('SpecRegeneration');
|
const logger = createLogger('SpecRegeneration');
|
||||||
|
|
||||||
@@ -90,37 +91,72 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
|||||||
projectPath: projectPath,
|
projectPath: projectPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Calling provider.executeStreamingQuery() for features...');
|
const options = createFeatureGenerationOptions({
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
|
||||||
const result = await provider.executeStreamingQuery({
|
|
||||||
prompt,
|
|
||||||
model: 'haiku',
|
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 50,
|
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
|
||||||
abortController,
|
abortController,
|
||||||
onText: (text) => {
|
|
||||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
|
||||||
events.emit('spec-regeneration:event', {
|
|
||||||
type: 'spec_regeneration_progress',
|
|
||||||
content: text,
|
|
||||||
projectPath: projectPath,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
||||||
logger.error('❌ Feature generation failed:', result.error);
|
logger.info('Calling Claude Agent SDK query() for features...');
|
||||||
throw new Error(result.error || 'Feature generation failed');
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Feature response length: ${result.text.length} chars`);
|
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`);
|
||||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||||
logger.info(result.text);
|
logger.info(responseText);
|
||||||
logger.info('========== END RESPONSE TEXT ==========');
|
logger.info('========== END RESPONSE TEXT ==========');
|
||||||
|
|
||||||
await parseAndCreateFeatures(projectPath, result.text, events);
|
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||||
|
|
||||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Generate app_spec.txt from project overview
|
* Generate app_spec.txt from project overview
|
||||||
*
|
|
||||||
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
|
import path from 'path';
|
||||||
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import {
|
import {
|
||||||
specOutputSchema,
|
specOutputSchema,
|
||||||
@@ -12,9 +13,10 @@ import {
|
|||||||
type SpecOutput,
|
type SpecOutput,
|
||||||
} from '../../lib/app-spec-format.js';
|
} from '../../lib/app-spec-format.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
||||||
|
import { logAuthStatus } from './common.js';
|
||||||
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
||||||
import { ensureAutomakerDir, getAppSpecPath, secureFs } from '@automaker/platform';
|
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
||||||
|
|
||||||
const logger = createLogger('SpecRegeneration');
|
const logger = createLogger('SpecRegeneration');
|
||||||
|
|
||||||
@@ -81,53 +83,105 @@ ${getStructuredSpecPromptInstruction()}`;
|
|||||||
content: 'Starting spec generation...\n',
|
content: 'Starting spec generation...\n',
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Calling provider.executeStreamingQuery()...');
|
const options = createSpecGenerationOptions({
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
|
||||||
const result = await provider.executeStreamingQuery({
|
|
||||||
prompt,
|
|
||||||
model: 'haiku',
|
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 1000,
|
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
|
||||||
abortController,
|
abortController,
|
||||||
outputFormat: {
|
outputFormat: {
|
||||||
type: 'json_schema',
|
type: 'json_schema',
|
||||||
schema: specOutputSchema,
|
schema: specOutputSchema,
|
||||||
},
|
},
|
||||||
onText: (text) => {
|
|
||||||
logger.info(`Text block received (${text.length} chars)`);
|
|
||||||
events.emit('spec-regeneration:event', {
|
|
||||||
type: 'spec_regeneration_progress',
|
|
||||||
content: text,
|
|
||||||
projectPath: projectPath,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onToolUse: (name, input) => {
|
|
||||||
logger.info('Tool use:', name);
|
|
||||||
events.emit('spec-regeneration:event', {
|
|
||||||
type: 'spec_tool',
|
|
||||||
tool: name,
|
|
||||||
input,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
||||||
logger.error('❌ Spec generation failed:', result.error);
|
logger.info('Calling Claude Agent SDK query()...');
|
||||||
throw new Error(result.error || 'Spec generation failed');
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseText = result.text;
|
let responseText = '';
|
||||||
const structuredOutput = result.structuredOutput as SpecOutput | undefined;
|
let messageCount = 0;
|
||||||
|
let structuredOutput: SpecOutput | null = null;
|
||||||
|
|
||||||
|
logger.info('Starting to iterate over stream...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const msg of stream) {
|
||||||
|
messageCount++;
|
||||||
|
logger.info(
|
||||||
|
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (msg.type === 'assistant') {
|
||||||
|
const msgAny = msg as any;
|
||||||
|
if (msgAny.message?.content) {
|
||||||
|
for (const block of msgAny.message.content) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
responseText += block.text;
|
||||||
|
logger.info(
|
||||||
|
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
|
||||||
|
);
|
||||||
|
events.emit('spec-regeneration:event', {
|
||||||
|
type: 'spec_regeneration_progress',
|
||||||
|
content: block.text,
|
||||||
|
projectPath: projectPath,
|
||||||
|
});
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
logger.info('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.info('Received success result');
|
||||||
|
// Check for structured output - this is the reliable way to get spec data
|
||||||
|
const resultMsg = msg as any;
|
||||||
|
if (resultMsg.structured_output) {
|
||||||
|
structuredOutput = resultMsg.structured_output as SpecOutput;
|
||||||
|
logger.info('✅ Received structured output');
|
||||||
|
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
|
||||||
|
} else {
|
||||||
|
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'result') {
|
||||||
|
// Handle error result types
|
||||||
|
const subtype = (msg as any).subtype;
|
||||||
|
logger.info(`Result message: subtype=${subtype}`);
|
||||||
|
if (subtype === 'error_max_turns') {
|
||||||
|
logger.error('❌ Hit max turns limit!');
|
||||||
|
} else if (subtype === 'error_max_structured_output_retries') {
|
||||||
|
logger.error('❌ Failed to produce valid structured output after retries');
|
||||||
|
throw new Error('Could not produce valid spec output');
|
||||||
|
}
|
||||||
|
} else if ((msg as { type: string }).type === 'error') {
|
||||||
|
logger.error('❌ Received error message from stream:');
|
||||||
|
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
||||||
|
} else if (msg.type === 'user') {
|
||||||
|
// Log user messages (tool results)
|
||||||
|
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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`);
|
logger.info(`Response text length: ${responseText.length} chars`);
|
||||||
if (structuredOutput) {
|
|
||||||
logger.info('✅ Received structured output');
|
|
||||||
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
|
|
||||||
} else {
|
|
||||||
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine XML content to save
|
// Determine XML content to save
|
||||||
let xmlContent: string;
|
let xmlContent: string;
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { getFeaturesDir, secureFs } from '@automaker/platform';
|
import { getFeaturesDir } from '@automaker/platform';
|
||||||
|
|
||||||
const logger = createLogger('SpecRegeneration');
|
const logger = createLogger('SpecRegeneration');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* Claude Usage types for CLI-based usage tracking
|
* Claude Usage types for CLI-based usage tracking
|
||||||
* Re-exported from @automaker/types for convenience
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type { ClaudeUsage, ClaudeStatus } from '@automaker/types';
|
export type ClaudeUsage = {
|
||||||
|
sessionTokensUsed: number;
|
||||||
|
sessionLimit: number;
|
||||||
|
sessionPercentage: number;
|
||||||
|
sessionResetTime: string; // ISO date string
|
||||||
|
sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)"
|
||||||
|
|
||||||
|
weeklyTokensUsed: number;
|
||||||
|
weeklyLimit: number;
|
||||||
|
weeklyPercentage: number;
|
||||||
|
weeklyResetTime: string; // ISO date string
|
||||||
|
weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)"
|
||||||
|
|
||||||
|
sonnetWeeklyTokensUsed: number;
|
||||||
|
sonnetWeeklyPercentage: number;
|
||||||
|
sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)"
|
||||||
|
|
||||||
|
costUsed: number | null;
|
||||||
|
costLimit: number | null;
|
||||||
|
costCurrency: string | null;
|
||||||
|
|
||||||
|
lastUpdated: string; // ISO date string
|
||||||
|
userTimezone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClaudeStatus = {
|
||||||
|
indicator: {
|
||||||
|
color: 'green' | 'yellow' | 'orange' | 'red' | 'gray';
|
||||||
|
};
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -18,14 +18,15 @@ export {
|
|||||||
getGitRepositoryDiffs,
|
getGitRepositoryDiffs,
|
||||||
} from '@automaker/git-utils';
|
} from '@automaker/git-utils';
|
||||||
|
|
||||||
// Re-export error utilities from shared package
|
|
||||||
export { getErrorMessage } from '@automaker/utils';
|
|
||||||
|
|
||||||
// Re-export exec utilities
|
|
||||||
export { execAsync, execEnv, isENOENT } from '../lib/exec-utils.js';
|
|
||||||
|
|
||||||
type Logger = ReturnType<typeof createLogger>;
|
type Logger = ReturnType<typeof createLogger>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get error message from error object
|
||||||
|
*/
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a logError function for a specific logger
|
* Create a logError function for a specific logger
|
||||||
* This ensures consistent error logging format across all routes
|
* This ensures consistent error logging format across all routes
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /context/describe-file endpoint - Generate description for a text file
|
* POST /context/describe-file endpoint - Generate description for a text file
|
||||||
*
|
*
|
||||||
* Uses Claude Haiku via ClaudeProvider to analyze a text file and generate
|
* Uses Claude Haiku to analyze a text file and generate a concise description
|
||||||
* a concise description suitable for context file metadata.
|
* suitable for context file metadata.
|
||||||
*
|
*
|
||||||
* SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY
|
* SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY
|
||||||
* and reads file content directly (not via Claude's Read tool) to prevent
|
* and reads file content directly (not via Claude's Read tool) to prevent
|
||||||
@@ -10,9 +10,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { PathNotAllowedError, secureFs } from '@automaker/platform';
|
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
|
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const logger = createLogger('DescribeFile');
|
const logger = createLogger('DescribeFile');
|
||||||
@@ -41,6 +44,31 @@ interface DescribeFileErrorResponse {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text content from Claude SDK response messages
|
||||||
|
*/
|
||||||
|
async function extractTextFromStream(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
stream: AsyncIterable<any>
|
||||||
|
): Promise<string> {
|
||||||
|
let responseText = '';
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
responseText += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||||
|
responseText = msg.result || responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseText;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the describe-file request handler
|
* Create the describe-file request handler
|
||||||
*
|
*
|
||||||
@@ -122,39 +150,60 @@ export function createDescribeFileHandler(): (req: Request, res: Response) => Pr
|
|||||||
const fileName = path.basename(resolvedPath);
|
const fileName = path.basename(resolvedPath);
|
||||||
|
|
||||||
// Build prompt with file content passed as structured data
|
// Build prompt with file content passed as structured data
|
||||||
const promptContent = [
|
// The file content is included directly, not via tool invocation
|
||||||
{
|
const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
|
||||||
type: 'text' as const,
|
|
||||||
text: `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
|
|
||||||
|
|
||||||
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
|
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
|
||||||
|
|
||||||
File: ${fileName}${truncated ? ' (truncated)' : ''}`,
|
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
||||||
},
|
|
||||||
|
const promptContent = [
|
||||||
|
{ type: 'text' as const, text: instructionText },
|
||||||
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
|
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
|
||||||
];
|
];
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
// Use the file's directory as the working directory
|
||||||
const result = await provider.executeSimpleQuery({
|
const cwd = path.dirname(resolvedPath);
|
||||||
prompt: promptContent,
|
|
||||||
model: 'haiku',
|
// Use centralized SDK options with proper cwd validation
|
||||||
|
// No tools needed since we're passing file content directly
|
||||||
|
const sdkOptions = createCustomOptions({
|
||||||
|
cwd,
|
||||||
|
model: CLAUDE_MODEL_MAP.haiku,
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: [],
|
||||||
|
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
const promptGenerator = (async function* () {
|
||||||
logger.warn('Failed to generate description:', result.error);
|
yield {
|
||||||
|
type: 'user' as const,
|
||||||
|
session_id: '',
|
||||||
|
message: { role: 'user' as const, content: promptContent },
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
||||||
|
|
||||||
|
// Extract the description from the response
|
||||||
|
const description = await extractTextFromStream(stream);
|
||||||
|
|
||||||
|
if (!description || description.trim().length === 0) {
|
||||||
|
logger.warn('Received empty response from Claude');
|
||||||
const response: DescribeFileErrorResponse = {
|
const response: DescribeFileErrorResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error || 'Failed to generate description',
|
error: 'Failed to generate description - empty response',
|
||||||
};
|
};
|
||||||
res.status(500).json(response);
|
res.status(500).json(response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Description generated, length: ${result.text.length} chars`);
|
logger.info(`Description generated, length: ${description.length} chars`);
|
||||||
|
|
||||||
const response: DescribeFileSuccessResponse = {
|
const response: DescribeFileSuccessResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
description: result.text,
|
description: description.trim(),
|
||||||
};
|
};
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /context/describe-image endpoint - Generate description for an image
|
* POST /context/describe-image endpoint - Generate description for an image
|
||||||
*
|
*
|
||||||
* Uses Claude Haiku via ClaudeProvider to analyze an image and generate
|
* Uses Claude Haiku to analyze an image and generate a concise description
|
||||||
* a concise description suitable for context file metadata.
|
* suitable for context file metadata.
|
||||||
*
|
*
|
||||||
* IMPORTANT:
|
* IMPORTANT:
|
||||||
* The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks),
|
* The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks),
|
||||||
@@ -11,9 +11,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||||
import type { PromptContentBlock } from '../../../providers/types.js';
|
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
@@ -172,6 +173,53 @@ function mapDescribeImageError(rawMessage: string | undefined): {
|
|||||||
return baseResponse;
|
return baseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text content from Claude SDK response messages and log high-signal stream events.
|
||||||
|
*/
|
||||||
|
async function extractTextFromStream(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
stream: AsyncIterable<any>,
|
||||||
|
requestId: string
|
||||||
|
): Promise<string> {
|
||||||
|
let responseText = '';
|
||||||
|
let messageCount = 0;
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`);
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
messageCount++;
|
||||||
|
const msgType = msg?.type;
|
||||||
|
const msgSubtype = msg?.subtype;
|
||||||
|
|
||||||
|
// Keep this concise but informative. Full error object is logged in catch blocks.
|
||||||
|
logger.info(
|
||||||
|
`[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (msgType === 'assistant' && msg.message?.content) {
|
||||||
|
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
|
||||||
|
logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`);
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
responseText += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgType === 'result' && msgSubtype === 'success') {
|
||||||
|
if (typeof msg.result === 'string' && msg.result.length > 0) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return responseText;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the describe-image request handler
|
* Create the describe-image request handler
|
||||||
*
|
*
|
||||||
@@ -260,17 +308,13 @@ export function createDescribeImageHandler(): (req: Request, res: Response) => P
|
|||||||
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
|
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
|
||||||
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
|
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
|
||||||
|
|
||||||
const promptContent: PromptContentBlock[] = [
|
const promptContent = [
|
||||||
{ type: 'text', text: instructionText },
|
{ type: 'text' as const, text: instructionText },
|
||||||
{
|
{
|
||||||
type: 'image',
|
type: 'image' as const,
|
||||||
source: {
|
source: {
|
||||||
type: 'base64',
|
type: 'base64' as const,
|
||||||
media_type: imageData.mimeType as
|
media_type: imageData.mimeType,
|
||||||
| 'image/jpeg'
|
|
||||||
| 'image/png'
|
|
||||||
| 'image/gif'
|
|
||||||
| 'image/webp',
|
|
||||||
data: imageData.base64,
|
data: imageData.base64,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -278,26 +322,48 @@ export function createDescribeImageHandler(): (req: Request, res: Response) => P
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`);
|
logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`);
|
||||||
|
|
||||||
logger.info(`[${requestId}] Calling provider.executeSimpleQuery()...`);
|
const cwd = path.dirname(actualPath);
|
||||||
const queryStart = Date.now();
|
logger.info(`[${requestId}] Using cwd=${cwd}`);
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
// Use the same centralized option builder used across the server (validates cwd)
|
||||||
const result = await provider.executeSimpleQuery({
|
const sdkOptions = createCustomOptions({
|
||||||
prompt: promptContent,
|
cwd,
|
||||||
model: 'haiku',
|
model: CLAUDE_MODEL_MAP.haiku,
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: [],
|
||||||
|
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`[${requestId}] Query completed in ${Date.now() - queryStart}ms`);
|
logger.info(
|
||||||
|
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
||||||
|
sdkOptions.allowedTools
|
||||||
|
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
|
||||||
|
);
|
||||||
|
|
||||||
const description = result.success ? result.text : '';
|
const promptGenerator = (async function* () {
|
||||||
|
yield {
|
||||||
|
type: 'user' as const,
|
||||||
|
session_id: '',
|
||||||
|
message: { role: 'user' as const, content: promptContent },
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
if (!result.success || !description || description.trim().length === 0) {
|
logger.info(`[${requestId}] Calling query()...`);
|
||||||
logger.warn(
|
const queryStart = Date.now();
|
||||||
`[${requestId}] Failed to generate description: ${result.error || 'empty response'}`
|
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
||||||
);
|
logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`);
|
||||||
|
|
||||||
|
// Extract the description from the response
|
||||||
|
const extractStart = Date.now();
|
||||||
|
const description = await extractTextFromStream(stream, requestId);
|
||||||
|
logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`);
|
||||||
|
|
||||||
|
if (!description || description.trim().length === 0) {
|
||||||
|
logger.warn(`[${requestId}] Received empty response from Claude`);
|
||||||
const response: DescribeImageErrorResponse = {
|
const response: DescribeImageErrorResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error || 'Failed to generate description - empty response',
|
error: 'Failed to generate description - empty response',
|
||||||
requestId,
|
requestId,
|
||||||
};
|
};
|
||||||
res.status(500).json(response);
|
res.status(500).json(response);
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* POST /enhance-prompt endpoint - Enhance user input text
|
* POST /enhance-prompt endpoint - Enhance user input text
|
||||||
*
|
*
|
||||||
* Uses Claude AI via ClaudeProvider to enhance text based on the specified
|
* Uses Claude AI to enhance text based on the specified enhancement mode.
|
||||||
* enhancement mode. Supports modes: improve, technical, simplify, acceptance
|
* Supports modes: improve, technical, simplify, acceptance
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
|
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
getSystemPrompt,
|
getSystemPrompt,
|
||||||
buildUserPrompt,
|
buildUserPrompt,
|
||||||
isValidEnhancementMode,
|
isValidEnhancementMode,
|
||||||
type EnhancementMode,
|
type EnhancementMode,
|
||||||
} from '@automaker/prompts';
|
} from '../../../lib/enhancement-prompts.js';
|
||||||
|
|
||||||
const logger = createLogger('EnhancePrompt');
|
const logger = createLogger('EnhancePrompt');
|
||||||
|
|
||||||
@@ -45,6 +47,39 @@ interface EnhanceErrorResponse {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text content from Claude SDK response messages
|
||||||
|
*
|
||||||
|
* @param stream - The async iterable from the query function
|
||||||
|
* @returns The extracted text content
|
||||||
|
*/
|
||||||
|
async function extractTextFromStream(
|
||||||
|
stream: AsyncIterable<{
|
||||||
|
type: string;
|
||||||
|
subtype?: string;
|
||||||
|
result?: string;
|
||||||
|
message?: {
|
||||||
|
content?: Array<{ type: string; text?: string }>;
|
||||||
|
};
|
||||||
|
}>
|
||||||
|
): Promise<string> {
|
||||||
|
let responseText = '';
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
responseText += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||||
|
responseText = msg.result || responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseText;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the enhance request handler
|
* Create the enhance request handler
|
||||||
*
|
*
|
||||||
@@ -97,30 +132,45 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
|
|||||||
const systemPrompt = getSystemPrompt(validMode);
|
const systemPrompt = getSystemPrompt(validMode);
|
||||||
|
|
||||||
// Build the user prompt with few-shot examples
|
// Build the user prompt with few-shot examples
|
||||||
|
// This helps the model understand this is text transformation, not a coding task
|
||||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model || 'sonnet');
|
// Resolve the model - use the passed model, default to sonnet for quality
|
||||||
const result = await provider.executeSimpleQuery({
|
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||||
|
|
||||||
|
logger.debug(`Using model: ${resolvedModel}`);
|
||||||
|
|
||||||
|
// Call Claude SDK with minimal configuration for text transformation
|
||||||
|
// Key: no tools, just text completion
|
||||||
|
const stream = query({
|
||||||
prompt: userPrompt,
|
prompt: userPrompt,
|
||||||
model: model || 'sonnet',
|
options: {
|
||||||
systemPrompt,
|
model: resolvedModel,
|
||||||
|
systemPrompt,
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: [],
|
||||||
|
permissionMode: 'acceptEdits',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
// Extract the enhanced text from the response
|
||||||
logger.warn('Failed to enhance text:', result.error);
|
const enhancedText = await extractTextFromStream(stream);
|
||||||
|
|
||||||
|
if (!enhancedText || enhancedText.trim().length === 0) {
|
||||||
|
logger.warn('Received empty response from Claude');
|
||||||
const response: EnhanceErrorResponse = {
|
const response: EnhanceErrorResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error || 'Failed to generate enhanced text',
|
error: 'Failed to generate enhanced text - empty response',
|
||||||
};
|
};
|
||||||
res.status(500).json(response);
|
res.status(500).json(response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Enhancement complete, output length: ${result.text.length} chars`);
|
logger.info(`Enhancement complete, output length: ${enhancedText.length} chars`);
|
||||||
|
|
||||||
const response: EnhanceSuccessResponse = {
|
const response: EnhanceSuccessResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
enhancedText: result.text,
|
enhancedText: enhancedText.trim(),
|
||||||
};
|
};
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* POST /features/generate-title endpoint - Generate a concise title from description
|
* POST /features/generate-title endpoint - Generate a concise title from description
|
||||||
*
|
*
|
||||||
* Uses Claude Haiku via ClaudeProvider to generate a short, descriptive title.
|
* Uses Claude Haiku to generate a short, descriptive title from feature description.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
||||||
|
|
||||||
const logger = createLogger('GenerateTitle');
|
const logger = createLogger('GenerateTitle');
|
||||||
|
|
||||||
@@ -33,6 +34,33 @@ Rules:
|
|||||||
- No quotes, periods, or extra formatting
|
- No quotes, periods, or extra formatting
|
||||||
- Capture the essence of the feature in a scannable way`;
|
- Capture the essence of the feature in a scannable way`;
|
||||||
|
|
||||||
|
async function extractTextFromStream(
|
||||||
|
stream: AsyncIterable<{
|
||||||
|
type: string;
|
||||||
|
subtype?: string;
|
||||||
|
result?: string;
|
||||||
|
message?: {
|
||||||
|
content?: Array<{ type: string; text?: string }>;
|
||||||
|
};
|
||||||
|
}>
|
||||||
|
): Promise<string> {
|
||||||
|
let responseText = '';
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
responseText += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||||
|
responseText = msg.result || responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseText;
|
||||||
|
}
|
||||||
|
|
||||||
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
|
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -61,28 +89,34 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
|
|||||||
|
|
||||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
const stream = query({
|
||||||
const result = await provider.executeSimpleQuery({
|
|
||||||
prompt: userPrompt,
|
prompt: userPrompt,
|
||||||
model: 'haiku',
|
options: {
|
||||||
systemPrompt: SYSTEM_PROMPT,
|
model: CLAUDE_MODEL_MAP.haiku,
|
||||||
|
systemPrompt: SYSTEM_PROMPT,
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: [],
|
||||||
|
permissionMode: 'acceptEdits',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
const title = await extractTextFromStream(stream);
|
||||||
logger.warn('Failed to generate title:', result.error);
|
|
||||||
|
if (!title || title.trim().length === 0) {
|
||||||
|
logger.warn('Received empty response from Claude');
|
||||||
const response: GenerateTitleErrorResponse = {
|
const response: GenerateTitleErrorResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error || 'Failed to generate title',
|
error: 'Failed to generate title - empty response',
|
||||||
};
|
};
|
||||||
res.status(500).json(response);
|
res.status(500).json(response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Generated title: ${result.text}`);
|
logger.info(`Generated title: ${title.trim()}`);
|
||||||
|
|
||||||
const response: GenerateTitleSuccessResponse = {
|
const response: GenerateTitleSuccessResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
title: result.text,
|
title: title.trim(),
|
||||||
};
|
};
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { getErrorMessage as getErrorMessageShared, createLogError, isENOENT } from '../common.js';
|
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||||
|
|
||||||
const logger = createLogger('FS');
|
const logger = createLogger('FS');
|
||||||
|
|
||||||
// Re-export shared utilities
|
// Re-export shared utilities
|
||||||
export { getErrorMessageShared as getErrorMessage };
|
export { getErrorMessageShared as getErrorMessage };
|
||||||
export { isENOENT };
|
|
||||||
export const logError = createLogError(logger);
|
export const logError = createLogError(logger);
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createBrowseHandler() {
|
export function createBrowseHandler() {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, getBoardDir } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { getBoardDir } from '@automaker/platform';
|
||||||
|
|
||||||
export function createDeleteBoardBackgroundHandler() {
|
export function createDeleteBoardBackgroundHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createDeleteHandler() {
|
export function createDeleteHandler() {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createExistsHandler() {
|
export function createExistsHandler() {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createImageHandler() {
|
export function createImageHandler() {
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createMkdirHandler() {
|
export function createMkdirHandler() {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { getErrorMessage, logError, isENOENT } from '../common.js';
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
// Optional files that are expected to not exist in new projects
|
// Optional files that are expected to not exist in new projects
|
||||||
// Don't log ENOENT errors for these to reduce noise
|
// Don't log ENOENT errors for these to reduce noise
|
||||||
@@ -14,6 +15,10 @@ function isOptionalFile(filePath: string): boolean {
|
|||||||
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
|
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isENOENT(error: unknown): boolean {
|
||||||
|
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
||||||
|
}
|
||||||
|
|
||||||
export function createReadHandler() {
|
export function createReadHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createReaddirHandler() {
|
export function createReaddirHandler() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, getBoardDir } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { getBoardDir } from '@automaker/platform';
|
||||||
|
|
||||||
export function createSaveBoardBackgroundHandler() {
|
export function createSaveBoardBackgroundHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, getImagesDir } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { getImagesDir } from '@automaker/platform';
|
||||||
|
|
||||||
export function createSaveImageHandler() {
|
export function createSaveImageHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createStatHandler() {
|
export function createStatHandler() {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, isPathAllowed } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { isPathAllowed } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createValidatePathHandler() {
|
export function createValidatePathHandler() {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { mkdirSafe } from '@automaker/utils';
|
import { mkdirSafe } from '@automaker/utils';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { GitHubRemoteStatus } from '@automaker/types';
|
|
||||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||||
|
|
||||||
// Re-export type for convenience
|
export interface GitHubRemoteStatus {
|
||||||
export type { GitHubRemoteStatus } from '@automaker/types';
|
hasGitHubRemote: boolean;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
owner: string | null;
|
||||||
|
repo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemoteStatus> {
|
export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemoteStatus> {
|
||||||
const status: GitHubRemoteStatus = {
|
const status: GitHubRemoteStatus = {
|
||||||
|
|||||||
@@ -2,16 +2,34 @@
|
|||||||
* Common utilities for GitHub routes
|
* Common utilities for GitHub routes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { exec } from 'child_process';
|
||||||
import { createLogError, getErrorMessage } from '../../common.js';
|
import { promisify } from 'util';
|
||||||
import { execAsync, execEnv } from '../../../lib/exec-utils.js';
|
|
||||||
|
|
||||||
const logger = createLogger('GitHub');
|
export const execAsync = promisify(exec);
|
||||||
|
|
||||||
// Re-export exec utilities for convenience
|
// Extended PATH to include common tool installation locations
|
||||||
export { execAsync, execEnv } from '../../../lib/exec-utils.js';
|
export const extendedPath = [
|
||||||
|
process.env.PATH,
|
||||||
|
'/opt/homebrew/bin',
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/home/linuxbrew/.linuxbrew/bin',
|
||||||
|
`${process.env.HOME}/.local/bin`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(':');
|
||||||
|
|
||||||
// Re-export error utilities
|
export const execEnv = {
|
||||||
export { getErrorMessage } from '../../common.js';
|
...process.env,
|
||||||
|
PATH: extendedPath,
|
||||||
|
};
|
||||||
|
|
||||||
export const logError = createLogError(logger);
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logError(error: unknown, context: string): void {
|
||||||
|
console.error(`[GitHub] ${context}:`, error);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,35 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { GitHubIssue, ListIssuesResult } from '@automaker/types';
|
|
||||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||||
import { checkGitHubRemote } from './check-github-remote.js';
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
|
||||||
// Re-export types for convenience
|
export interface GitHubLabel {
|
||||||
export type { GitHubLabel, GitHubAuthor, GitHubIssue, ListIssuesResult } from '@automaker/types';
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubAuthor {
|
||||||
|
login: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubIssue {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
author: GitHubAuthor;
|
||||||
|
createdAt: string;
|
||||||
|
labels: GitHubLabel[];
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListIssuesResult {
|
||||||
|
success: boolean;
|
||||||
|
openIssues?: GitHubIssue[];
|
||||||
|
closedIssues?: GitHubIssue[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function createListIssuesHandler() {
|
export function createListIssuesHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -3,12 +3,39 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { GitHubPR, ListPRsResult } from '@automaker/types';
|
|
||||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||||
import { checkGitHubRemote } from './check-github-remote.js';
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
|
||||||
// Re-export types for convenience
|
export interface GitHubLabel {
|
||||||
export type { GitHubLabel, GitHubAuthor, GitHubPR, ListPRsResult } from '@automaker/types';
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubAuthor {
|
||||||
|
login: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubPR {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
author: GitHubAuthor;
|
||||||
|
createdAt: string;
|
||||||
|
labels: GitHubLabel[];
|
||||||
|
url: string;
|
||||||
|
isDraft: boolean;
|
||||||
|
headRefName: string;
|
||||||
|
reviewDecision: string | null;
|
||||||
|
mergeable: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListPRsResult {
|
||||||
|
success: boolean;
|
||||||
|
openPRs?: GitHubPR[];
|
||||||
|
mergedPRs?: GitHubPR[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function createListPRsHandler() {
|
export function createListPRsHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Business logic for generating suggestions
|
* Business logic for generating suggestions
|
||||||
*
|
|
||||||
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
|
||||||
|
|
||||||
const logger = createLogger('Suggestions');
|
const logger = createLogger('Suggestions');
|
||||||
|
|
||||||
@@ -69,44 +68,62 @@ The response will be automatically formatted as structured JSON.`;
|
|||||||
content: `Starting ${suggestionType} analysis...\n`,
|
content: `Starting ${suggestionType} analysis...\n`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
const options = createSuggestionsOptions({
|
||||||
const result = await provider.executeStreamingQuery({
|
|
||||||
prompt,
|
|
||||||
model: 'haiku',
|
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxTurns: 250,
|
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
|
||||||
abortController,
|
abortController,
|
||||||
outputFormat: {
|
outputFormat: {
|
||||||
type: 'json_schema',
|
type: 'json_schema',
|
||||||
schema: suggestionsSchema,
|
schema: suggestionsSchema,
|
||||||
},
|
},
|
||||||
onText: (text) => {
|
|
||||||
events.emit('suggestions:event', {
|
|
||||||
type: 'suggestions_progress',
|
|
||||||
content: text,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onToolUse: (name, input) => {
|
|
||||||
events.emit('suggestions:event', {
|
|
||||||
type: 'suggestions_tool',
|
|
||||||
tool: name,
|
|
||||||
input,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stream = query({ prompt, options });
|
||||||
|
let responseText = '';
|
||||||
|
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
if (msg.type === 'assistant' && msg.message.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
responseText += block.text;
|
||||||
|
events.emit('suggestions:event', {
|
||||||
|
type: 'suggestions_progress',
|
||||||
|
content: block.text,
|
||||||
|
});
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
events.emit('suggestions:event', {
|
||||||
|
type: 'suggestions_tool',
|
||||||
|
tool: block.name,
|
||||||
|
input: block.input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||||
|
// Check for structured output
|
||||||
|
const resultMsg = msg as any;
|
||||||
|
if (resultMsg.structured_output) {
|
||||||
|
structuredOutput = resultMsg.structured_output as {
|
||||||
|
suggestions: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
logger.debug('Received structured output:', structuredOutput);
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'result') {
|
||||||
|
const resultMsg = msg as any;
|
||||||
|
if (resultMsg.subtype === 'error_max_structured_output_retries') {
|
||||||
|
logger.error('Failed to produce valid structured output after retries');
|
||||||
|
throw new Error('Could not produce valid suggestions output');
|
||||||
|
} else if (resultMsg.subtype === 'error_max_turns') {
|
||||||
|
logger.error('Hit max turns limit before completing suggestions generation');
|
||||||
|
logger.warn(`Response text length: ${responseText.length} chars`);
|
||||||
|
// Still try to parse what we have
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use structured output if available, otherwise fall back to parsing text
|
// Use structured output if available, otherwise fall back to parsing text
|
||||||
try {
|
try {
|
||||||
const structuredOutput = result.structuredOutput as
|
|
||||||
| {
|
|
||||||
suggestions: Array<Record<string, unknown>>;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (structuredOutput && structuredOutput.suggestions) {
|
if (structuredOutput && structuredOutput.suggestions) {
|
||||||
// Use structured output directly
|
// Use structured output directly
|
||||||
logger.debug('Received structured output:', structuredOutput);
|
|
||||||
events.emit('suggestions:event', {
|
events.emit('suggestions:event', {
|
||||||
type: 'suggestions_complete',
|
type: 'suggestions_complete',
|
||||||
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
|
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
|
||||||
@@ -117,7 +134,7 @@ The response will be automatically formatted as structured JSON.`;
|
|||||||
} else {
|
} else {
|
||||||
// Fallback: try to parse from text (for backwards compatibility)
|
// Fallback: try to parse from text (for backwards compatibility)
|
||||||
logger.warn('No structured output received, attempting to parse from text');
|
logger.warn('No structured output received, attempting to parse from text');
|
||||||
const jsonMatch = result.text.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
|
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
|
||||||
if (jsonMatch) {
|
if (jsonMatch) {
|
||||||
const parsed = JSON.parse(jsonMatch[0]);
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
events.emit('suggestions:event', {
|
events.emit('suggestions:event', {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
import { logger, getErrorMessage, logError } from '../common.js';
|
import { logger, getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createCloneHandler() {
|
export function createCloneHandler() {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, getAllowedRootDirectory, getDataDirectory } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { getAllowedRootDirectory, getDataDirectory } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createConfigHandler() {
|
export function createConfigHandler() {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { secureFs, getAllowedRootDirectory } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { getAllowedRootDirectory } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createDirectoriesHandler() {
|
export function createDirectoriesHandler() {
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import path from 'path';
|
||||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||||
import { execAsync, execEnv, isENOENT } from '../../lib/exec-utils.js';
|
|
||||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
|
export const execAsync = promisify(exec);
|
||||||
const featureLoader = new FeatureLoader();
|
const featureLoader = new FeatureLoader();
|
||||||
|
|
||||||
// Re-export exec utilities for convenience
|
|
||||||
export { execAsync, execEnv, isENOENT } from '../../lib/exec-utils.js';
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants
|
// Constants
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -20,6 +20,48 @@ export { execAsync, execEnv, isENOENT } from '../../lib/exec-utils.js';
|
|||||||
/** Maximum allowed length for git branch names */
|
/** Maximum allowed length for git branch names */
|
||||||
export const MAX_BRANCH_NAME_LENGTH = 250;
|
export const MAX_BRANCH_NAME_LENGTH = 250;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Extended PATH configuration for Electron apps
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||||
|
const additionalPaths: string[] = [];
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// Windows paths
|
||||||
|
if (process.env.LOCALAPPDATA) {
|
||||||
|
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||||
|
}
|
||||||
|
if (process.env.PROGRAMFILES) {
|
||||||
|
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||||
|
}
|
||||||
|
if (process.env['ProgramFiles(x86)']) {
|
||||||
|
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unix/Mac paths
|
||||||
|
additionalPaths.push(
|
||||||
|
'/opt/homebrew/bin', // Homebrew on Apple Silicon
|
||||||
|
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
|
||||||
|
'/home/linuxbrew/.linuxbrew/bin', // Linuxbrew
|
||||||
|
`${process.env.HOME}/.local/bin` // pipx, other user installs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(pathSeparator);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variables with extended PATH for executing shell commands.
|
||||||
|
* Electron apps don't inherit the user's shell PATH, so we need to add
|
||||||
|
* common tool installation locations.
|
||||||
|
*/
|
||||||
|
export const execEnv = {
|
||||||
|
...process.env,
|
||||||
|
PATH: extendedPath,
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Validation utilities
|
// Validation utilities
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -69,6 +111,14 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is ENOENT (file/path not found or spawn failed)
|
||||||
|
* These are expected in test environments with mock paths
|
||||||
|
*/
|
||||||
|
export function isENOENT(error: unknown): boolean {
|
||||||
|
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a path is a mock/test path that doesn't exist
|
* Check if a path is a mock/test path that doesn't exist
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,12 +5,15 @@
|
|||||||
* can switch between branches even after worktrees are removed.
|
* can switch between branches even after worktrees are removed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { secureFs, getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import type { TrackedBranch } from '@automaker/types';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
|
||||||
|
|
||||||
// Re-export type for convenience
|
export interface TrackedBranch {
|
||||||
export type { TrackedBranch } from '@automaker/types';
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastActivatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface BranchTrackingData {
|
interface BranchTrackingData {
|
||||||
branches: TrackedBranch[];
|
branches: TrackedBranch[];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { Request, Response } from 'express';
|
|||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import {
|
import {
|
||||||
isGitRepo,
|
isGitRepo,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { getGitRepositoryDiffs } from '../../common.js';
|
import { getGitRepositoryDiffs } from '../../common.js';
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
|||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { generateSyntheticDiffForNewFile } from '../../common.js';
|
import { generateSyntheticDiffForNewFile } from '../../common.js';
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
|||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { PRComment, PRInfo } from '@automaker/types';
|
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
logError,
|
logError,
|
||||||
@@ -13,8 +12,26 @@ import {
|
|||||||
isGhCliAvailable,
|
isGhCliAvailable,
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
|
|
||||||
// Re-export types for convenience
|
export interface PRComment {
|
||||||
export type { PRComment, PRInfo } from '@automaker/types';
|
id: number;
|
||||||
|
author: string;
|
||||||
|
body: string;
|
||||||
|
path?: string;
|
||||||
|
line?: number;
|
||||||
|
createdAt: string;
|
||||||
|
isReviewComment: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PRInfo {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
state: string;
|
||||||
|
author: string;
|
||||||
|
body: string;
|
||||||
|
comments: PRComment[];
|
||||||
|
reviewComments: PRComment[];
|
||||||
|
}
|
||||||
|
|
||||||
export function createPRInfoHandler() {
|
export function createPRInfoHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
|||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../lib/events.js';
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
import type { ExecuteOptions } from '@automaker/types';
|
import type { ExecuteOptions } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
@@ -11,13 +12,10 @@ import {
|
|||||||
buildPromptWithImages,
|
buildPromptWithImages,
|
||||||
isAbortError,
|
isAbortError,
|
||||||
loadContextFiles,
|
loadContextFiles,
|
||||||
createLogger,
|
|
||||||
} from '@automaker/utils';
|
} from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||||
import { PathNotAllowedError, secureFs } from '@automaker/platform';
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
|
|
||||||
const logger = createLogger('AgentService');
|
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -152,7 +150,7 @@ export class AgentService {
|
|||||||
filename: imageData.filename,
|
filename: imageData.filename,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to load image ${imagePath}:`, error);
|
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +215,9 @@ export class AgentService {
|
|||||||
// Get provider for this model
|
// Get provider for this model
|
||||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||||
|
|
||||||
logger.info(`Using provider "${provider.getName()}" for model "${effectiveModel}"`);
|
console.log(
|
||||||
|
`[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"`
|
||||||
|
);
|
||||||
|
|
||||||
// Build options for provider
|
// Build options for provider
|
||||||
const options: ExecuteOptions = {
|
const options: ExecuteOptions = {
|
||||||
@@ -254,7 +254,7 @@ export class AgentService {
|
|||||||
// Capture SDK session ID from any message and persist it
|
// Capture SDK session ID from any message and persist it
|
||||||
if (msg.session_id && !session.sdkSessionId) {
|
if (msg.session_id && !session.sdkSessionId) {
|
||||||
session.sdkSessionId = msg.session_id;
|
session.sdkSessionId = msg.session_id;
|
||||||
logger.info(`Captured SDK session ID: ${msg.session_id}`);
|
console.log(`[AgentService] Captured SDK session ID: ${msg.session_id}`);
|
||||||
// Persist the SDK session ID to ensure conversation continuity across server restarts
|
// Persist the SDK session ID to ensure conversation continuity across server restarts
|
||||||
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
|
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
|
||||||
}
|
}
|
||||||
@@ -330,7 +330,7 @@ export class AgentService {
|
|||||||
return { success: false, aborted: true };
|
return { success: false, aborted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Error:', error);
|
console.error('[AgentService] Error:', error);
|
||||||
|
|
||||||
session.isRunning = false;
|
session.isRunning = false;
|
||||||
session.abortController = null;
|
session.abortController = null;
|
||||||
@@ -424,7 +424,7 @@ export class AgentService {
|
|||||||
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
|
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
|
||||||
await this.updateSessionTimestamp(sessionId);
|
await this.updateSessionTimestamp(sessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to save session:', error);
|
console.error('[AgentService] Failed to save session:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* Feature Verification Service - Handles verification and commit operations
|
|
||||||
*
|
|
||||||
* Provides functionality to verify feature implementations (lint, typecheck, test, build)
|
|
||||||
* and commit changes to git.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import {
|
|
||||||
runVerificationChecks,
|
|
||||||
hasUncommittedChanges,
|
|
||||||
commitAll,
|
|
||||||
shortHash,
|
|
||||||
} from '@automaker/git-utils';
|
|
||||||
import { extractTitleFromDescription } from '@automaker/prompts';
|
|
||||||
import { getFeatureDir, secureFs } from '@automaker/platform';
|
|
||||||
import path from 'path';
|
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
|
||||||
import type { Feature } from '@automaker/types';
|
|
||||||
|
|
||||||
const logger = createLogger('FeatureVerification');
|
|
||||||
|
|
||||||
export interface VerificationResult {
|
|
||||||
success: boolean;
|
|
||||||
failedCheck?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommitResult {
|
|
||||||
hash: string | null;
|
|
||||||
shortHash?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FeatureVerificationService {
|
|
||||||
private events: EventEmitter;
|
|
||||||
|
|
||||||
constructor(events: EventEmitter) {
|
|
||||||
this.events = events;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the working directory for a feature (checks for worktree)
|
|
||||||
*/
|
|
||||||
async resolveWorkDir(projectPath: string, featureId: string): Promise<string> {
|
|
||||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await secureFs.access(worktreePath);
|
|
||||||
return worktreePath;
|
|
||||||
} catch {
|
|
||||||
return projectPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a feature's implementation by running checks
|
|
||||||
*/
|
|
||||||
async verify(projectPath: string, featureId: string): Promise<VerificationResult> {
|
|
||||||
const workDir = await this.resolveWorkDir(projectPath, featureId);
|
|
||||||
|
|
||||||
const result = await runVerificationChecks(workDir);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.emitEvent('auto_mode_feature_complete', {
|
|
||||||
featureId,
|
|
||||||
passes: true,
|
|
||||||
message: 'All verification checks passed',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.emitEvent('auto_mode_feature_complete', {
|
|
||||||
featureId,
|
|
||||||
passes: false,
|
|
||||||
message: `Verification failed: ${result.failedCheck}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Commit feature changes
|
|
||||||
*/
|
|
||||||
async commit(
|
|
||||||
projectPath: string,
|
|
||||||
featureId: string,
|
|
||||||
feature: Feature | null,
|
|
||||||
providedWorktreePath?: string
|
|
||||||
): Promise<CommitResult> {
|
|
||||||
let workDir = projectPath;
|
|
||||||
|
|
||||||
if (providedWorktreePath) {
|
|
||||||
try {
|
|
||||||
await secureFs.access(providedWorktreePath);
|
|
||||||
workDir = providedWorktreePath;
|
|
||||||
} catch {
|
|
||||||
// Use project path
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
workDir = await this.resolveWorkDir(projectPath, featureId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for changes
|
|
||||||
const hasChanges = await hasUncommittedChanges(workDir);
|
|
||||||
if (!hasChanges) {
|
|
||||||
return { hash: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build commit message
|
|
||||||
const title = feature
|
|
||||||
? extractTitleFromDescription(feature.description)
|
|
||||||
: `Feature ${featureId}`;
|
|
||||||
const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`;
|
|
||||||
|
|
||||||
// Commit changes
|
|
||||||
const hash = await commitAll(workDir, commitMessage);
|
|
||||||
|
|
||||||
if (hash) {
|
|
||||||
const short = shortHash(hash);
|
|
||||||
this.emitEvent('auto_mode_feature_complete', {
|
|
||||||
featureId,
|
|
||||||
passes: true,
|
|
||||||
message: `Changes committed: ${short}`,
|
|
||||||
});
|
|
||||||
return { hash, shortHash: short };
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error(`Commit failed for ${featureId}`);
|
|
||||||
return { hash: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if context (agent-output.md) exists for a feature
|
|
||||||
*/
|
|
||||||
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
|
||||||
const featureDir = getFeatureDir(projectPath, featureId);
|
|
||||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await secureFs.access(contextPath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load existing context for a feature
|
|
||||||
*/
|
|
||||||
async loadContext(projectPath: string, featureId: string): Promise<string | null> {
|
|
||||||
const featureDir = getFeatureDir(projectPath, featureId);
|
|
||||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
|
||||||
|
|
||||||
try {
|
|
||||||
return (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitEvent(eventType: string, data: Record<string, unknown>): void {
|
|
||||||
this.events.emit('auto-mode:event', { type: eventType, ...data });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto Mode Services
|
|
||||||
*
|
|
||||||
* Re-exports all auto-mode related services and types.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Services
|
|
||||||
export { PlanApprovalService } from './plan-approval-service.js';
|
|
||||||
export { TaskExecutor } from './task-executor.js';
|
|
||||||
export { WorktreeManager, worktreeManager } from './worktree-manager.js';
|
|
||||||
export { OutputWriter, createFeatureOutputWriter } from './output-writer.js';
|
|
||||||
export { ProjectAnalyzer } from './project-analyzer.js';
|
|
||||||
export { FeatureVerificationService } from './feature-verification.js';
|
|
||||||
export type { VerificationResult, CommitResult } from './feature-verification.js';
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export type {
|
|
||||||
RunningFeature,
|
|
||||||
AutoLoopState,
|
|
||||||
AutoModeConfig,
|
|
||||||
PendingApproval,
|
|
||||||
ApprovalResult,
|
|
||||||
FeatureExecutionOptions,
|
|
||||||
RunAgentOptions,
|
|
||||||
FeatureWithPlanning,
|
|
||||||
TaskExecutionContext,
|
|
||||||
TaskProgress,
|
|
||||||
} from './types.js';
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
/**
|
|
||||||
* Output Writer - Incremental file writing for agent output
|
|
||||||
*
|
|
||||||
* Handles debounced file writes to avoid excessive I/O during streaming.
|
|
||||||
* Used to persist agent output to agent-output.md in the feature directory.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { secureFs } from '@automaker/platform';
|
|
||||||
import path from 'path';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('OutputWriter');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles incremental, debounced file writing for agent output
|
|
||||||
*/
|
|
||||||
export class OutputWriter {
|
|
||||||
private content = '';
|
|
||||||
private writeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private readonly debounceMs: number;
|
|
||||||
private readonly outputPath: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new output writer
|
|
||||||
*
|
|
||||||
* @param outputPath - Full path to the output file
|
|
||||||
* @param debounceMs - Debounce interval for writes (default: 500ms)
|
|
||||||
* @param initialContent - Optional initial content to start with
|
|
||||||
*/
|
|
||||||
constructor(outputPath: string, debounceMs = 500, initialContent = '') {
|
|
||||||
this.outputPath = outputPath;
|
|
||||||
this.debounceMs = debounceMs;
|
|
||||||
this.content = initialContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append text to the output
|
|
||||||
*
|
|
||||||
* Schedules a debounced write to the file.
|
|
||||||
*/
|
|
||||||
append(text: string): void {
|
|
||||||
this.content += text;
|
|
||||||
this.scheduleWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append text with automatic separator handling
|
|
||||||
*
|
|
||||||
* Ensures proper spacing between sections.
|
|
||||||
*/
|
|
||||||
appendWithSeparator(text: string): void {
|
|
||||||
if (this.content.length > 0 && !this.content.endsWith('\n\n')) {
|
|
||||||
if (this.content.endsWith('\n')) {
|
|
||||||
this.content += '\n';
|
|
||||||
} else {
|
|
||||||
this.content += '\n\n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.append(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a tool use entry
|
|
||||||
*/
|
|
||||||
appendToolUse(toolName: string, input?: unknown): void {
|
|
||||||
if (this.content.length > 0 && !this.content.endsWith('\n')) {
|
|
||||||
this.content += '\n';
|
|
||||||
}
|
|
||||||
this.content += `\n🔧 Tool: ${toolName}\n`;
|
|
||||||
if (input) {
|
|
||||||
this.content += `Input: ${JSON.stringify(input, null, 2)}\n`;
|
|
||||||
}
|
|
||||||
this.scheduleWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current accumulated content
|
|
||||||
*/
|
|
||||||
getContent(): string {
|
|
||||||
return this.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set content directly (for follow-up sessions with previous content)
|
|
||||||
*/
|
|
||||||
setContent(content: string): void {
|
|
||||||
this.content = content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule a debounced write
|
|
||||||
*/
|
|
||||||
private scheduleWrite(): void {
|
|
||||||
if (this.writeTimeout) {
|
|
||||||
clearTimeout(this.writeTimeout);
|
|
||||||
}
|
|
||||||
this.writeTimeout = setTimeout(() => {
|
|
||||||
this.flush().catch((error) => {
|
|
||||||
logger.error('Failed to flush output', error);
|
|
||||||
});
|
|
||||||
}, this.debounceMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flush content to disk immediately
|
|
||||||
*
|
|
||||||
* Call this to ensure all content is written, e.g., at the end of execution.
|
|
||||||
*/
|
|
||||||
async flush(): Promise<void> {
|
|
||||||
if (this.writeTimeout) {
|
|
||||||
clearTimeout(this.writeTimeout);
|
|
||||||
this.writeTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await secureFs.mkdir(path.dirname(this.outputPath), { recursive: true });
|
|
||||||
await secureFs.writeFile(this.outputPath, this.content);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to write to ${this.outputPath}`, error);
|
|
||||||
// Don't throw - file write errors shouldn't crash execution
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel any pending writes
|
|
||||||
*/
|
|
||||||
cancel(): void {
|
|
||||||
if (this.writeTimeout) {
|
|
||||||
clearTimeout(this.writeTimeout);
|
|
||||||
this.writeTimeout = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an output writer for a feature
|
|
||||||
*
|
|
||||||
* @param featureDir - The feature directory path
|
|
||||||
* @param previousContent - Optional content from previous session
|
|
||||||
* @returns Configured output writer
|
|
||||||
*/
|
|
||||||
export function createFeatureOutputWriter(
|
|
||||||
featureDir: string,
|
|
||||||
previousContent?: string
|
|
||||||
): OutputWriter {
|
|
||||||
const outputPath = path.join(featureDir, 'agent-output.md');
|
|
||||||
|
|
||||||
// If there's previous content, add a follow-up separator
|
|
||||||
const initialContent = previousContent
|
|
||||||
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return new OutputWriter(outputPath, 500, initialContent);
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
/**
|
|
||||||
* Plan Approval Service - Handles plan/spec approval workflow
|
|
||||||
*
|
|
||||||
* Manages the async approval flow where:
|
|
||||||
* 1. Agent generates a spec with [SPEC_GENERATED] marker
|
|
||||||
* 2. Service emits plan_approval_required event
|
|
||||||
* 3. User reviews and approves/rejects via API
|
|
||||||
* 4. Service resolves the waiting promise to continue execution
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
|
||||||
import type { PlanSpec, PlanningMode } from '@automaker/types';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import type { PendingApproval, ApprovalResult } from './types.js';
|
|
||||||
|
|
||||||
const logger = createLogger('PlanApprovalService');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages plan approval workflow for spec-driven development
|
|
||||||
*/
|
|
||||||
export class PlanApprovalService {
|
|
||||||
private pendingApprovals = new Map<string, PendingApproval>();
|
|
||||||
private events: EventEmitter;
|
|
||||||
|
|
||||||
constructor(events: EventEmitter) {
|
|
||||||
this.events = events;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for plan approval from the user
|
|
||||||
*
|
|
||||||
* Returns a promise that resolves when the user approves or rejects
|
|
||||||
* the plan via the API.
|
|
||||||
*
|
|
||||||
* @param featureId - The feature awaiting approval
|
|
||||||
* @param projectPath - The project path
|
|
||||||
* @returns Promise resolving to approval result
|
|
||||||
*/
|
|
||||||
waitForApproval(featureId: string, projectPath: string): Promise<ApprovalResult> {
|
|
||||||
logger.debug(`Registering pending approval for feature ${featureId}`);
|
|
||||||
logger.debug(
|
|
||||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.pendingApprovals.set(featureId, {
|
|
||||||
resolve,
|
|
||||||
reject,
|
|
||||||
featureId,
|
|
||||||
projectPath,
|
|
||||||
});
|
|
||||||
logger.debug(`Pending approval registered for feature ${featureId}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a pending plan approval
|
|
||||||
*
|
|
||||||
* Called when the user approves or rejects the plan via API.
|
|
||||||
*
|
|
||||||
* @param featureId - The feature ID
|
|
||||||
* @param approved - Whether the plan was approved
|
|
||||||
* @param editedPlan - Optional edited plan content
|
|
||||||
* @param feedback - Optional user feedback
|
|
||||||
* @returns Result indicating success or error
|
|
||||||
*/
|
|
||||||
resolve(
|
|
||||||
featureId: string,
|
|
||||||
approved: boolean,
|
|
||||||
editedPlan?: string,
|
|
||||||
feedback?: string
|
|
||||||
): { success: boolean; error?: string; projectPath?: string } {
|
|
||||||
logger.debug(`resolvePlanApproval called for feature ${featureId}, approved=${approved}`);
|
|
||||||
logger.debug(
|
|
||||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const pending = this.pendingApprovals.get(featureId);
|
|
||||||
|
|
||||||
if (!pending) {
|
|
||||||
logger.warn(`No pending approval found for feature ${featureId}`);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `No pending approval for feature ${featureId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`Found pending approval for feature ${featureId}, resolving...`);
|
|
||||||
|
|
||||||
// Resolve the promise with all data including feedback
|
|
||||||
pending.resolve({ approved, editedPlan, feedback });
|
|
||||||
this.pendingApprovals.delete(featureId);
|
|
||||||
|
|
||||||
return { success: true, projectPath: pending.projectPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel a pending plan approval
|
|
||||||
*
|
|
||||||
* Called when a feature is stopped while waiting for approval.
|
|
||||||
*
|
|
||||||
* @param featureId - The feature ID to cancel
|
|
||||||
*/
|
|
||||||
cancel(featureId: string): void {
|
|
||||||
logger.debug(`cancelPlanApproval called for feature ${featureId}`);
|
|
||||||
const pending = this.pendingApprovals.get(featureId);
|
|
||||||
|
|
||||||
if (pending) {
|
|
||||||
logger.debug(`Found and cancelling pending approval for feature ${featureId}`);
|
|
||||||
pending.reject(new Error('Plan approval cancelled - feature was stopped'));
|
|
||||||
this.pendingApprovals.delete(featureId);
|
|
||||||
} else {
|
|
||||||
logger.debug(`No pending approval to cancel for feature ${featureId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a feature has a pending plan approval
|
|
||||||
*
|
|
||||||
* @param featureId - The feature ID to check
|
|
||||||
* @returns True if there's a pending approval
|
|
||||||
*/
|
|
||||||
hasPending(featureId: string): boolean {
|
|
||||||
return this.pendingApprovals.has(featureId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the project path for a pending approval
|
|
||||||
*
|
|
||||||
* Useful for recovery scenarios where we need to know which
|
|
||||||
* project a pending approval belongs to.
|
|
||||||
*
|
|
||||||
* @param featureId - The feature ID
|
|
||||||
* @returns The project path or undefined
|
|
||||||
*/
|
|
||||||
getProjectPath(featureId: string): string | undefined {
|
|
||||||
return this.pendingApprovals.get(featureId)?.projectPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all pending approval feature IDs
|
|
||||||
*
|
|
||||||
* @returns Array of feature IDs with pending approvals
|
|
||||||
*/
|
|
||||||
getAllPending(): string[] {
|
|
||||||
return Array.from(this.pendingApprovals.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a plan-related event
|
|
||||||
*/
|
|
||||||
emitPlanEvent(
|
|
||||||
eventType: string,
|
|
||||||
featureId: string,
|
|
||||||
projectPath: string,
|
|
||||||
data: Record<string, unknown> = {}
|
|
||||||
): void {
|
|
||||||
this.events.emit('auto-mode:event', {
|
|
||||||
type: eventType,
|
|
||||||
featureId,
|
|
||||||
projectPath,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit plan approval required event
|
|
||||||
*/
|
|
||||||
emitApprovalRequired(
|
|
||||||
featureId: string,
|
|
||||||
projectPath: string,
|
|
||||||
planContent: string,
|
|
||||||
planningMode: PlanningMode,
|
|
||||||
planVersion: number
|
|
||||||
): void {
|
|
||||||
this.emitPlanEvent('plan_approval_required', featureId, projectPath, {
|
|
||||||
planContent,
|
|
||||||
planningMode,
|
|
||||||
planVersion,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit plan approved event
|
|
||||||
*/
|
|
||||||
emitApproved(
|
|
||||||
featureId: string,
|
|
||||||
projectPath: string,
|
|
||||||
hasEdits: boolean,
|
|
||||||
planVersion: number
|
|
||||||
): void {
|
|
||||||
this.emitPlanEvent('plan_approved', featureId, projectPath, {
|
|
||||||
hasEdits,
|
|
||||||
planVersion,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit plan rejected event
|
|
||||||
*/
|
|
||||||
emitRejected(featureId: string, projectPath: string, feedback?: string): void {
|
|
||||||
this.emitPlanEvent('plan_rejected', featureId, projectPath, { feedback });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit plan auto-approved event
|
|
||||||
*/
|
|
||||||
emitAutoApproved(
|
|
||||||
featureId: string,
|
|
||||||
projectPath: string,
|
|
||||||
planContent: string,
|
|
||||||
planningMode: PlanningMode
|
|
||||||
): void {
|
|
||||||
this.emitPlanEvent('plan_auto_approved', featureId, projectPath, {
|
|
||||||
planContent,
|
|
||||||
planningMode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit plan revision requested event
|
|
||||||
*/
|
|
||||||
emitRevisionRequested(
|
|
||||||
featureId: string,
|
|
||||||
projectPath: string,
|
|
||||||
feedback: string | undefined,
|
|
||||||
hasEdits: boolean,
|
|
||||||
planVersion: number
|
|
||||||
): void {
|
|
||||||
this.emitPlanEvent('plan_revision_requested', featureId, projectPath, {
|
|
||||||
feedback,
|
|
||||||
hasEdits,
|
|
||||||
planVersion,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/**
|
|
||||||
* Project Analyzer - Analyzes project structure and context
|
|
||||||
*
|
|
||||||
* Provides project analysis functionality using Claude to understand
|
|
||||||
* codebase architecture, patterns, and conventions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExecuteOptions } from '@automaker/types';
|
|
||||||
import { createLogger, classifyError, processStream } from '@automaker/utils';
|
|
||||||
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
|
|
||||||
import { getAutomakerDir, secureFs } from '@automaker/platform';
|
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
|
||||||
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
|
|
||||||
import path from 'path';
|
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
|
||||||
|
|
||||||
const logger = createLogger('ProjectAnalyzer');
|
|
||||||
|
|
||||||
const ANALYSIS_PROMPT = `Analyze this project and provide a summary of:
|
|
||||||
1. Project structure and architecture
|
|
||||||
2. Main technologies and frameworks used
|
|
||||||
3. Key components and their responsibilities
|
|
||||||
4. Build and test commands
|
|
||||||
5. Any existing conventions or patterns
|
|
||||||
|
|
||||||
Format your response as a structured markdown document.`;
|
|
||||||
|
|
||||||
export class ProjectAnalyzer {
|
|
||||||
private events: EventEmitter;
|
|
||||||
|
|
||||||
constructor(events: EventEmitter) {
|
|
||||||
this.events = events;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze project to gather context
|
|
||||||
*/
|
|
||||||
async analyze(projectPath: string): Promise<void> {
|
|
||||||
validateWorkingDirectory(projectPath);
|
|
||||||
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const analysisFeatureId = `analysis-${Date.now()}`;
|
|
||||||
|
|
||||||
this.emitEvent('auto_mode_feature_start', {
|
|
||||||
featureId: analysisFeatureId,
|
|
||||||
projectPath,
|
|
||||||
feature: {
|
|
||||||
id: analysisFeatureId,
|
|
||||||
title: 'Project Analysis',
|
|
||||||
description: 'Analyzing project structure',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude);
|
|
||||||
const provider = ProviderFactory.getProviderForModel(analysisModel);
|
|
||||||
|
|
||||||
const options: ExecuteOptions = {
|
|
||||||
prompt: ANALYSIS_PROMPT,
|
|
||||||
model: analysisModel,
|
|
||||||
maxTurns: 5,
|
|
||||||
cwd: projectPath,
|
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
|
||||||
abortController,
|
|
||||||
};
|
|
||||||
|
|
||||||
const stream = provider.executeQuery(options);
|
|
||||||
let analysisResult = '';
|
|
||||||
|
|
||||||
const result = await processStream(stream, {
|
|
||||||
onText: (text) => {
|
|
||||||
analysisResult += text;
|
|
||||||
this.emitEvent('auto_mode_progress', {
|
|
||||||
featureId: analysisFeatureId,
|
|
||||||
content: text,
|
|
||||||
projectPath,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
analysisResult = result.text || analysisResult;
|
|
||||||
|
|
||||||
// Save analysis
|
|
||||||
const automakerDir = getAutomakerDir(projectPath);
|
|
||||||
const analysisPath = path.join(automakerDir, 'project-analysis.md');
|
|
||||||
await secureFs.mkdir(automakerDir, { recursive: true });
|
|
||||||
await secureFs.writeFile(analysisPath, analysisResult);
|
|
||||||
|
|
||||||
this.emitEvent('auto_mode_feature_complete', {
|
|
||||||
featureId: analysisFeatureId,
|
|
||||||
passes: true,
|
|
||||||
message: 'Project analysis completed',
|
|
||||||
projectPath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const errorInfo = classifyError(error);
|
|
||||||
this.emitEvent('auto_mode_error', {
|
|
||||||
featureId: analysisFeatureId,
|
|
||||||
error: errorInfo.message,
|
|
||||||
errorType: errorInfo.type,
|
|
||||||
projectPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitEvent(eventType: string, data: Record<string, unknown>): void {
|
|
||||||
this.events.emit('auto-mode:event', { type: eventType, ...data });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
/**
|
|
||||||
* Task Executor - Multi-agent task execution for spec-driven development
|
|
||||||
*
|
|
||||||
* Handles the sequential execution of parsed tasks from a spec,
|
|
||||||
* where each task gets its own focused agent call.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExecuteOptions, ParsedTask } from '@automaker/types';
|
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
|
||||||
import type { BaseProvider } from '../../providers/base-provider.js';
|
|
||||||
import { buildTaskPrompt } from '@automaker/prompts';
|
|
||||||
import { createLogger, processStream } from '@automaker/utils';
|
|
||||||
import type { TaskExecutionContext, TaskProgress } from './types.js';
|
|
||||||
|
|
||||||
const logger = createLogger('TaskExecutor');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles multi-agent task execution for spec-driven development
|
|
||||||
*/
|
|
||||||
export class TaskExecutor {
|
|
||||||
private events: EventEmitter;
|
|
||||||
|
|
||||||
constructor(events: EventEmitter) {
|
|
||||||
this.events = events;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute all tasks sequentially
|
|
||||||
*
|
|
||||||
* Each task gets its own focused agent call with context about
|
|
||||||
* completed and remaining tasks.
|
|
||||||
*
|
|
||||||
* @param tasks - Parsed tasks from the spec
|
|
||||||
* @param context - Execution context including provider, model, etc.
|
|
||||||
* @param provider - The provider to use for execution
|
|
||||||
* @yields TaskProgress events for each task
|
|
||||||
*/
|
|
||||||
async *executeAll(
|
|
||||||
tasks: ParsedTask[],
|
|
||||||
context: TaskExecutionContext,
|
|
||||||
provider: BaseProvider
|
|
||||||
): AsyncGenerator<TaskProgress> {
|
|
||||||
logger.info(
|
|
||||||
`Starting multi-agent execution: ${tasks.length} tasks for feature ${context.featureId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) {
|
|
||||||
const task = tasks[taskIndex];
|
|
||||||
|
|
||||||
// Check for abort
|
|
||||||
if (context.abortController.signal.aborted) {
|
|
||||||
throw new Error('Feature execution aborted');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit task started
|
|
||||||
logger.info(`Starting task ${task.id}: ${task.description}`);
|
|
||||||
this.emitTaskEvent('auto_mode_task_started', context, {
|
|
||||||
taskId: task.id,
|
|
||||||
taskDescription: task.description,
|
|
||||||
taskIndex,
|
|
||||||
tasksTotal: tasks.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
yield {
|
|
||||||
taskId: task.id,
|
|
||||||
taskIndex,
|
|
||||||
tasksTotal: tasks.length,
|
|
||||||
status: 'started',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build focused prompt for this task
|
|
||||||
const taskPrompt = buildTaskPrompt(
|
|
||||||
task,
|
|
||||||
tasks,
|
|
||||||
taskIndex,
|
|
||||||
context.planContent,
|
|
||||||
context.userFeedback
|
|
||||||
);
|
|
||||||
|
|
||||||
// Execute task with dedicated agent call
|
|
||||||
const taskOptions: ExecuteOptions = {
|
|
||||||
prompt: taskPrompt,
|
|
||||||
model: context.model,
|
|
||||||
maxTurns: Math.min(context.maxTurns, 50), // Limit turns per task
|
|
||||||
cwd: context.workDir,
|
|
||||||
allowedTools: context.allowedTools,
|
|
||||||
abortController: context.abortController,
|
|
||||||
};
|
|
||||||
|
|
||||||
const taskStream = provider.executeQuery(taskOptions);
|
|
||||||
|
|
||||||
// Process task stream
|
|
||||||
let taskOutput = '';
|
|
||||||
try {
|
|
||||||
const result = await processStream(taskStream, {
|
|
||||||
onText: (text) => {
|
|
||||||
taskOutput += text;
|
|
||||||
this.emitProgressEvent(context.featureId, text);
|
|
||||||
},
|
|
||||||
onToolUse: (name, input) => {
|
|
||||||
this.emitToolEvent(context.featureId, name, input);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
taskOutput = result.text;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(`Task ${task.id} failed: ${errorMessage}`);
|
|
||||||
yield {
|
|
||||||
taskId: task.id,
|
|
||||||
taskIndex,
|
|
||||||
tasksTotal: tasks.length,
|
|
||||||
status: 'failed',
|
|
||||||
output: errorMessage,
|
|
||||||
};
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit task completed
|
|
||||||
logger.info(`Task ${task.id} completed for feature ${context.featureId}`);
|
|
||||||
this.emitTaskEvent('auto_mode_task_complete', context, {
|
|
||||||
taskId: task.id,
|
|
||||||
tasksCompleted: taskIndex + 1,
|
|
||||||
tasksTotal: tasks.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for phase completion
|
|
||||||
const phaseComplete = this.checkPhaseComplete(task, tasks, taskIndex);
|
|
||||||
|
|
||||||
yield {
|
|
||||||
taskId: task.id,
|
|
||||||
taskIndex,
|
|
||||||
tasksTotal: tasks.length,
|
|
||||||
status: 'completed',
|
|
||||||
output: taskOutput,
|
|
||||||
phaseComplete,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emit phase complete if needed
|
|
||||||
if (phaseComplete !== undefined) {
|
|
||||||
this.emitPhaseComplete(context, phaseComplete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`All ${tasks.length} tasks completed for feature ${context.featureId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a single task (for cases where you don't need the full loop)
|
|
||||||
*
|
|
||||||
* @param task - The task to execute
|
|
||||||
* @param allTasks - All tasks for context
|
|
||||||
* @param taskIndex - Index of this task
|
|
||||||
* @param context - Execution context
|
|
||||||
* @param provider - The provider to use
|
|
||||||
* @returns Task output text
|
|
||||||
*/
|
|
||||||
async executeOne(
|
|
||||||
task: ParsedTask,
|
|
||||||
allTasks: ParsedTask[],
|
|
||||||
taskIndex: number,
|
|
||||||
context: TaskExecutionContext,
|
|
||||||
provider: BaseProvider
|
|
||||||
): Promise<string> {
|
|
||||||
const taskPrompt = buildTaskPrompt(
|
|
||||||
task,
|
|
||||||
allTasks,
|
|
||||||
taskIndex,
|
|
||||||
context.planContent,
|
|
||||||
context.userFeedback
|
|
||||||
);
|
|
||||||
|
|
||||||
const taskOptions: ExecuteOptions = {
|
|
||||||
prompt: taskPrompt,
|
|
||||||
model: context.model,
|
|
||||||
maxTurns: Math.min(context.maxTurns, 50),
|
|
||||||
cwd: context.workDir,
|
|
||||||
allowedTools: context.allowedTools,
|
|
||||||
abortController: context.abortController,
|
|
||||||
};
|
|
||||||
|
|
||||||
const taskStream = provider.executeQuery(taskOptions);
|
|
||||||
|
|
||||||
const result = await processStream(taskStream, {
|
|
||||||
onText: (text) => {
|
|
||||||
this.emitProgressEvent(context.featureId, text);
|
|
||||||
},
|
|
||||||
onToolUse: (name, input) => {
|
|
||||||
this.emitToolEvent(context.featureId, name, input);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if completing this task completes a phase
|
|
||||||
*/
|
|
||||||
private checkPhaseComplete(
|
|
||||||
task: ParsedTask,
|
|
||||||
allTasks: ParsedTask[],
|
|
||||||
taskIndex: number
|
|
||||||
): number | undefined {
|
|
||||||
if (!task.phase) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextTask = allTasks[taskIndex + 1];
|
|
||||||
if (!nextTask || nextTask.phase !== task.phase) {
|
|
||||||
// Phase changed or no more tasks
|
|
||||||
const phaseMatch = task.phase.match(/Phase\s*(\d+)/i);
|
|
||||||
return phaseMatch ? parseInt(phaseMatch[1], 10) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a task-related event
|
|
||||||
*/
|
|
||||||
private emitTaskEvent(
|
|
||||||
eventType: string,
|
|
||||||
context: TaskExecutionContext,
|
|
||||||
data: Record<string, unknown>
|
|
||||||
): void {
|
|
||||||
this.events.emit('auto-mode:event', {
|
|
||||||
type: eventType,
|
|
||||||
featureId: context.featureId,
|
|
||||||
projectPath: context.projectPath,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit progress event for text output
|
|
||||||
*/
|
|
||||||
private emitProgressEvent(featureId: string, content: string): void {
|
|
||||||
this.events.emit('auto-mode:event', {
|
|
||||||
type: 'auto_mode_progress',
|
|
||||||
featureId,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit tool use event
|
|
||||||
*/
|
|
||||||
private emitToolEvent(featureId: string, tool: string, input: unknown): void {
|
|
||||||
this.events.emit('auto-mode:event', {
|
|
||||||
type: 'auto_mode_tool',
|
|
||||||
featureId,
|
|
||||||
tool,
|
|
||||||
input,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit phase complete event
|
|
||||||
*/
|
|
||||||
private emitPhaseComplete(context: TaskExecutionContext, phaseNumber: number): void {
|
|
||||||
this.events.emit('auto-mode:event', {
|
|
||||||
type: 'auto_mode_phase_complete',
|
|
||||||
featureId: context.featureId,
|
|
||||||
projectPath: context.projectPath,
|
|
||||||
phaseNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* Internal types for AutoModeService
|
|
||||||
*
|
|
||||||
* These types are used internally by the auto-mode services
|
|
||||||
* and are not exported to the public API.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { PlanningMode, PlanSpec } from '@automaker/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Running feature state
|
|
||||||
*/
|
|
||||||
export interface RunningFeature {
|
|
||||||
featureId: string;
|
|
||||||
projectPath: string;
|
|
||||||
worktreePath: string | null;
|
|
||||||
branchName: string | null;
|
|
||||||
abortController: AbortController;
|
|
||||||
isAutoMode: boolean;
|
|
||||||
startTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-loop configuration
|
|
||||||
*/
|
|
||||||
export interface AutoLoopState {
|
|
||||||
projectPath: string;
|
|
||||||
maxConcurrency: number;
|
|
||||||
abortController: AbortController;
|
|
||||||
isRunning: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-mode configuration
|
|
||||||
*/
|
|
||||||
export interface AutoModeConfig {
|
|
||||||
maxConcurrency: number;
|
|
||||||
useWorktrees: boolean;
|
|
||||||
projectPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pending plan approval state
|
|
||||||
*/
|
|
||||||
export interface PendingApproval {
|
|
||||||
resolve: (result: ApprovalResult) => void;
|
|
||||||
reject: (error: Error) => void;
|
|
||||||
featureId: string;
|
|
||||||
projectPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of plan approval
|
|
||||||
*/
|
|
||||||
export interface ApprovalResult {
|
|
||||||
approved: boolean;
|
|
||||||
editedPlan?: string;
|
|
||||||
feedback?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for executing a feature
|
|
||||||
*/
|
|
||||||
export interface FeatureExecutionOptions {
|
|
||||||
continuationPrompt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for running the agent
|
|
||||||
*/
|
|
||||||
export interface RunAgentOptions {
|
|
||||||
projectPath: string;
|
|
||||||
planningMode?: PlanningMode;
|
|
||||||
requirePlanApproval?: boolean;
|
|
||||||
previousContent?: string;
|
|
||||||
systemPrompt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feature with planning fields for internal use
|
|
||||||
*/
|
|
||||||
export interface FeatureWithPlanning {
|
|
||||||
id: string;
|
|
||||||
description: string;
|
|
||||||
spec?: string;
|
|
||||||
model?: string;
|
|
||||||
imagePaths?: Array<string | { path: string; filename?: string; mimeType?: string }>;
|
|
||||||
branchName?: string;
|
|
||||||
skipTests?: boolean;
|
|
||||||
planningMode?: PlanningMode;
|
|
||||||
requirePlanApproval?: boolean;
|
|
||||||
planSpec?: PlanSpec;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Task execution context
|
|
||||||
*/
|
|
||||||
export interface TaskExecutionContext {
|
|
||||||
workDir: string;
|
|
||||||
featureId: string;
|
|
||||||
projectPath: string;
|
|
||||||
model: string;
|
|
||||||
maxTurns: number;
|
|
||||||
allowedTools?: string[];
|
|
||||||
abortController: AbortController;
|
|
||||||
planContent: string;
|
|
||||||
userFeedback?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Task progress event
|
|
||||||
*/
|
|
||||||
export interface TaskProgress {
|
|
||||||
taskId: string;
|
|
||||||
taskIndex: number;
|
|
||||||
tasksTotal: number;
|
|
||||||
status: 'started' | 'completed' | 'failed';
|
|
||||||
output?: string;
|
|
||||||
phaseComplete?: number;
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
/**
|
|
||||||
* Worktree Manager - Git worktree operations for feature isolation
|
|
||||||
*
|
|
||||||
* Handles finding and resolving git worktrees for feature branches.
|
|
||||||
* Worktrees are created when features are added/edited, this service
|
|
||||||
* finds existing worktrees for execution.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import path from 'path';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const logger = createLogger('WorktreeManager');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of resolving a working directory
|
|
||||||
*/
|
|
||||||
export interface WorkDirResult {
|
|
||||||
/** The resolved working directory path */
|
|
||||||
workDir: string;
|
|
||||||
/** The worktree path if using a worktree, null otherwise */
|
|
||||||
worktreePath: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages git worktree operations for feature isolation
|
|
||||||
*/
|
|
||||||
export class WorktreeManager {
|
|
||||||
/**
|
|
||||||
* Find existing worktree path for a branch
|
|
||||||
*
|
|
||||||
* Parses `git worktree list --porcelain` output to find the worktree
|
|
||||||
* associated with a specific branch.
|
|
||||||
*
|
|
||||||
* @param projectPath - The main project path
|
|
||||||
* @param branchName - The branch to find a worktree for
|
|
||||||
* @returns The absolute path to the worktree, or null if not found
|
|
||||||
*/
|
|
||||||
async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
|
||||||
cwd: projectPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
const lines = stdout.split('\n');
|
|
||||||
let currentPath: string | null = null;
|
|
||||||
let currentBranch: string | null = null;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('worktree ')) {
|
|
||||||
currentPath = line.slice(9);
|
|
||||||
} else if (line.startsWith('branch ')) {
|
|
||||||
currentBranch = line.slice(7).replace('refs/heads/', '');
|
|
||||||
} else if (line === '' && currentPath && currentBranch) {
|
|
||||||
// End of a worktree entry
|
|
||||||
if (currentBranch === branchName) {
|
|
||||||
// Resolve to absolute path - git may return relative paths
|
|
||||||
// On Windows, this is critical for cwd to work correctly
|
|
||||||
const resolvedPath = path.isAbsolute(currentPath)
|
|
||||||
? path.resolve(currentPath)
|
|
||||||
: path.resolve(projectPath, currentPath);
|
|
||||||
return resolvedPath;
|
|
||||||
}
|
|
||||||
currentPath = null;
|
|
||||||
currentBranch = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the last entry (if file doesn't end with newline)
|
|
||||||
if (currentPath && currentBranch && currentBranch === branchName) {
|
|
||||||
const resolvedPath = path.isAbsolute(currentPath)
|
|
||||||
? path.resolve(currentPath)
|
|
||||||
: path.resolve(projectPath, currentPath);
|
|
||||||
return resolvedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Failed to find worktree for branch ${branchName}`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the working directory for feature execution
|
|
||||||
*
|
|
||||||
* If worktrees are enabled and a branch name is provided, attempts to
|
|
||||||
* find an existing worktree. Falls back to the project path if no
|
|
||||||
* worktree is found.
|
|
||||||
*
|
|
||||||
* @param projectPath - The main project path
|
|
||||||
* @param branchName - Optional branch name to look for
|
|
||||||
* @param useWorktrees - Whether to use worktrees
|
|
||||||
* @returns The resolved work directory and worktree path
|
|
||||||
*/
|
|
||||||
async resolveWorkDir(
|
|
||||||
projectPath: string,
|
|
||||||
branchName: string | undefined,
|
|
||||||
useWorktrees: boolean
|
|
||||||
): Promise<WorkDirResult> {
|
|
||||||
let worktreePath: string | null = null;
|
|
||||||
|
|
||||||
if (useWorktrees && branchName) {
|
|
||||||
worktreePath = await this.findWorktreeForBranch(projectPath, branchName);
|
|
||||||
|
|
||||||
if (worktreePath) {
|
|
||||||
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
|
||||||
} else {
|
|
||||||
logger.warn(`Worktree for branch "${branchName}" not found, using project path`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
|
||||||
|
|
||||||
return { workDir, worktreePath };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a path is a valid worktree
|
|
||||||
*
|
|
||||||
* @param worktreePath - Path to check
|
|
||||||
* @returns True if the path is a valid git worktree
|
|
||||||
*/
|
|
||||||
async isValidWorktree(worktreePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// Check if .git file exists (worktrees have a .git file, not directory)
|
|
||||||
const { stdout } = await execAsync('git rev-parse --is-inside-work-tree', {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
return stdout.trim() === 'true';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the branch name for a worktree
|
|
||||||
*
|
|
||||||
* @param worktreePath - Path to the worktree
|
|
||||||
* @returns The branch name or null if not a valid worktree
|
|
||||||
*/
|
|
||||||
async getWorktreeBranch(worktreePath: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
return stdout.trim();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export a singleton instance for convenience
|
|
||||||
export const worktreeManager = new WorktreeManager();
|
|
||||||
@@ -8,13 +8,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn, execSync, type ChildProcess } from 'child_process';
|
import { spawn, execSync, type ChildProcess } from 'child_process';
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
|
|
||||||
const logger = createLogger('DevServerService');
|
|
||||||
|
|
||||||
export interface DevServerInfo {
|
export interface DevServerInfo {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -72,7 +69,7 @@ class DevServerService {
|
|||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
try {
|
try {
|
||||||
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
|
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
|
||||||
logger.info(`Killed process ${pid} on port ${port}`);
|
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
|
||||||
} catch {
|
} catch {
|
||||||
// Process may have already exited
|
// Process may have already exited
|
||||||
}
|
}
|
||||||
@@ -85,7 +82,7 @@ class DevServerService {
|
|||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
try {
|
try {
|
||||||
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
|
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
|
||||||
logger.info(`Killed process ${pid} on port ${port}`);
|
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
|
||||||
} catch {
|
} catch {
|
||||||
// Process may have already exited
|
// Process may have already exited
|
||||||
}
|
}
|
||||||
@@ -96,7 +93,7 @@ class DevServerService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore errors - port might not have any process
|
// Ignore errors - port might not have any process
|
||||||
logger.info(`No process to kill on port ${port}`);
|
console.log(`[DevServerService] No process to kill on port ${port}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,9 +251,11 @@ class DevServerService {
|
|||||||
// Small delay to ensure related ports are freed
|
// Small delay to ensure related ports are freed
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
logger.info(`Starting dev server on port ${port}`);
|
console.log(`[DevServerService] Starting dev server on port ${port}`);
|
||||||
logger.info(`Working directory (cwd): ${worktreePath}`);
|
console.log(`[DevServerService] Working directory (cwd): ${worktreePath}`);
|
||||||
logger.info(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
console.log(
|
||||||
|
`[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`
|
||||||
|
);
|
||||||
|
|
||||||
// Spawn the dev process with PORT environment variable
|
// Spawn the dev process with PORT environment variable
|
||||||
const env = {
|
const env = {
|
||||||
@@ -277,26 +276,26 @@ class DevServerService {
|
|||||||
// Log output for debugging
|
// Log output for debugging
|
||||||
if (devProcess.stdout) {
|
if (devProcess.stdout) {
|
||||||
devProcess.stdout.on('data', (data: Buffer) => {
|
devProcess.stdout.on('data', (data: Buffer) => {
|
||||||
logger.info(`[DevServer:${port}] ${data.toString().trim()}`);
|
console.log(`[DevServer:${port}] ${data.toString().trim()}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (devProcess.stderr) {
|
if (devProcess.stderr) {
|
||||||
devProcess.stderr.on('data', (data: Buffer) => {
|
devProcess.stderr.on('data', (data: Buffer) => {
|
||||||
const msg = data.toString().trim();
|
const msg = data.toString().trim();
|
||||||
logger.error(`[DevServer:${port}] ${msg}`);
|
console.error(`[DevServer:${port}] ${msg}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
devProcess.on('error', (error) => {
|
devProcess.on('error', (error) => {
|
||||||
logger.error(`Process error:`, error);
|
console.error(`[DevServerService] Process error:`, error);
|
||||||
status.error = error.message;
|
status.error = error.message;
|
||||||
this.allocatedPorts.delete(port);
|
this.allocatedPorts.delete(port);
|
||||||
this.runningServers.delete(worktreePath);
|
this.runningServers.delete(worktreePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
devProcess.on('exit', (code) => {
|
devProcess.on('exit', (code) => {
|
||||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
console.log(`[DevServerService] Process for ${worktreePath} exited with code ${code}`);
|
||||||
status.exited = true;
|
status.exited = true;
|
||||||
this.allocatedPorts.delete(port);
|
this.allocatedPorts.delete(port);
|
||||||
this.runningServers.delete(worktreePath);
|
this.runningServers.delete(worktreePath);
|
||||||
@@ -353,7 +352,9 @@ class DevServerService {
|
|||||||
// If we don't have a record of this server, it may have crashed/exited on its own
|
// If we don't have a record of this server, it may have crashed/exited on its own
|
||||||
// Return success so the frontend can clear its state
|
// Return success so the frontend can clear its state
|
||||||
if (!server) {
|
if (!server) {
|
||||||
logger.info(`No server record for ${worktreePath}, may have already stopped`);
|
console.log(
|
||||||
|
`[DevServerService] No server record for ${worktreePath}, may have already stopped`
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
@@ -363,7 +364,7 @@ class DevServerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Stopping dev server for ${worktreePath}`);
|
console.log(`[DevServerService] Stopping dev server for ${worktreePath}`);
|
||||||
|
|
||||||
// Kill the process
|
// Kill the process
|
||||||
if (server.process && !server.process.killed) {
|
if (server.process && !server.process.killed) {
|
||||||
@@ -433,7 +434,7 @@ class DevServerService {
|
|||||||
* Stop all running dev servers (for cleanup)
|
* Stop all running dev servers (for cleanup)
|
||||||
*/
|
*/
|
||||||
async stopAll(): Promise<void> {
|
async stopAll(): Promise<void> {
|
||||||
logger.info(`Stopping all ${this.runningServers.size} dev servers`);
|
console.log(`[DevServerService] Stopping all ${this.runningServers.size} dev servers`);
|
||||||
|
|
||||||
for (const [worktreePath] of this.runningServers) {
|
for (const [worktreePath] of this.runningServers) {
|
||||||
await this.stopDevServer(worktreePath);
|
await this.stopDevServer(worktreePath);
|
||||||
|
|||||||
@@ -4,15 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { Feature, PlanSpec, FeatureStatus } from '@automaker/types';
|
import type { Feature } from '@automaker/types';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver';
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
import {
|
import {
|
||||||
getFeaturesDir,
|
getFeaturesDir,
|
||||||
getFeatureDir,
|
getFeatureDir,
|
||||||
getFeatureImagesDir,
|
getFeatureImagesDir,
|
||||||
ensureAutomakerDir,
|
ensureAutomakerDir,
|
||||||
secureFs,
|
|
||||||
} from '@automaker/platform';
|
} from '@automaker/platform';
|
||||||
|
|
||||||
const logger = createLogger('FeatureLoader');
|
const logger = createLogger('FeatureLoader');
|
||||||
@@ -57,7 +56,7 @@ export class FeatureLoader {
|
|||||||
try {
|
try {
|
||||||
// Paths are now absolute
|
// Paths are now absolute
|
||||||
await secureFs.unlink(oldPath);
|
await secureFs.unlink(oldPath);
|
||||||
logger.info(`Deleted orphaned image: ${oldPath}`);
|
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore errors when deleting (file may already be gone)
|
// Ignore errors when deleting (file may already be gone)
|
||||||
logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
|
logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
|
||||||
@@ -112,7 +111,7 @@ export class FeatureLoader {
|
|||||||
|
|
||||||
// Copy the file
|
// Copy the file
|
||||||
await secureFs.copyFile(fullOriginalPath, newPath);
|
await secureFs.copyFile(fullOriginalPath, newPath);
|
||||||
logger.info(`Copied image: ${originalPath} -> ${newPath}`);
|
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`);
|
||||||
|
|
||||||
// Try to delete the original temp file
|
// Try to delete the original temp file
|
||||||
try {
|
try {
|
||||||
@@ -333,7 +332,7 @@ export class FeatureLoader {
|
|||||||
try {
|
try {
|
||||||
const featureDir = this.getFeatureDir(projectPath, featureId);
|
const featureDir = this.getFeatureDir(projectPath, featureId);
|
||||||
await secureFs.rm(featureDir, { recursive: true, force: true });
|
await secureFs.rm(featureDir, { recursive: true, force: true });
|
||||||
logger.info(`Deleted feature ${featureId}`);
|
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
|
logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
|
||||||
@@ -382,115 +381,4 @@ export class FeatureLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if agent output exists for a feature
|
|
||||||
*/
|
|
||||||
async hasAgentOutput(projectPath: string, featureId: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
|
|
||||||
await secureFs.access(agentOutputPath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update feature status with proper timestamp handling
|
|
||||||
* Used by auto-mode to update feature status during execution
|
|
||||||
*/
|
|
||||||
async updateStatus(
|
|
||||||
projectPath: string,
|
|
||||||
featureId: string,
|
|
||||||
status: FeatureStatus
|
|
||||||
): Promise<Feature | null> {
|
|
||||||
try {
|
|
||||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
|
||||||
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
|
|
||||||
const feature = JSON.parse(content) as Feature;
|
|
||||||
|
|
||||||
feature.status = status;
|
|
||||||
feature.updatedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
// Handle justFinishedAt for waiting_approval status
|
|
||||||
if (status === 'waiting_approval') {
|
|
||||||
feature.justFinishedAt = new Date().toISOString();
|
|
||||||
} else {
|
|
||||||
feature.justFinishedAt = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2));
|
|
||||||
return feature;
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
logger.error(`[FeatureLoader] Failed to update status for ${featureId}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update feature plan specification
|
|
||||||
* Handles version incrementing and timestamp management
|
|
||||||
*/
|
|
||||||
async updatePlanSpec(
|
|
||||||
projectPath: string,
|
|
||||||
featureId: string,
|
|
||||||
updates: Partial<PlanSpec>
|
|
||||||
): Promise<Feature | null> {
|
|
||||||
try {
|
|
||||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
|
||||||
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
|
|
||||||
const feature = JSON.parse(content) as Feature;
|
|
||||||
|
|
||||||
// Initialize planSpec if not present
|
|
||||||
if (!feature.planSpec) {
|
|
||||||
feature.planSpec = { status: 'pending', version: 1, reviewedByUser: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment version if content changed
|
|
||||||
if (updates.content && updates.content !== feature.planSpec.content) {
|
|
||||||
feature.planSpec.version = (feature.planSpec.version || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge updates
|
|
||||||
Object.assign(feature.planSpec, updates);
|
|
||||||
feature.updatedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2));
|
|
||||||
return feature;
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
logger.error(`[FeatureLoader] Failed to update planSpec for ${featureId}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get features that are pending and ready to execute
|
|
||||||
* Filters by status and resolves dependencies
|
|
||||||
*/
|
|
||||||
async getPending(projectPath: string): Promise<Feature[]> {
|
|
||||||
try {
|
|
||||||
const allFeatures = await this.getAll(projectPath);
|
|
||||||
const pendingFeatures = allFeatures.filter(
|
|
||||||
(f) => f.status && ['pending', 'ready', 'backlog'].includes(f.status)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resolve dependencies and order features
|
|
||||||
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
|
||||||
|
|
||||||
// Filter to features whose dependencies are satisfied
|
|
||||||
return orderedFeatures.filter((feature: Feature) =>
|
|
||||||
areDependenciesSatisfied(feature, allFeatures)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[FeatureLoader] Failed to get pending features:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getGlobalSettingsPath,
|
getGlobalSettingsPath,
|
||||||
getCredentialsPath,
|
getCredentialsPath,
|
||||||
getProjectSettingsPath,
|
getProjectSettingsPath,
|
||||||
ensureDataDir,
|
ensureDataDir,
|
||||||
ensureAutomakerDir,
|
ensureAutomakerDir,
|
||||||
secureFs,
|
|
||||||
} from '@automaker/platform';
|
} from '@automaker/platform';
|
||||||
import type {
|
import type {
|
||||||
GlobalSettings,
|
GlobalSettings,
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import { EventEmitter } from 'events';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('Terminal');
|
|
||||||
|
|
||||||
// Maximum scrollback buffer size (characters)
|
// Maximum scrollback buffer size (characters)
|
||||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||||
@@ -174,7 +171,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
|
|
||||||
// Reject paths with null bytes (could bypass path checks)
|
// Reject paths with null bytes (could bypass path checks)
|
||||||
if (cwd.includes('\0')) {
|
if (cwd.includes('\0')) {
|
||||||
logger.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
|
console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
|
||||||
return homeDir;
|
return homeDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,10 +192,10 @@ export class TerminalService extends EventEmitter {
|
|||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
return cwd;
|
return cwd;
|
||||||
}
|
}
|
||||||
logger.warn(`Path exists but is not a directory: ${cwd}, falling back to home`);
|
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||||
return homeDir;
|
return homeDir;
|
||||||
} catch {
|
} catch {
|
||||||
logger.warn(`Working directory does not exist: ${cwd}, falling back to home`);
|
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
|
||||||
return homeDir;
|
return homeDir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,7 +220,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
setMaxSessions(limit: number): void {
|
setMaxSessions(limit: number): void {
|
||||||
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
|
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
|
||||||
maxSessions = limit;
|
maxSessions = limit;
|
||||||
logger.info(`Max sessions limit updated to ${limit}`);
|
console.log(`[Terminal] Max sessions limit updated to ${limit}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +231,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
createSession(options: TerminalOptions = {}): TerminalSession | null {
|
createSession(options: TerminalOptions = {}): TerminalSession | null {
|
||||||
// Check session limit
|
// Check session limit
|
||||||
if (this.sessions.size >= maxSessions) {
|
if (this.sessions.size >= maxSessions) {
|
||||||
logger.error(`Max sessions (${maxSessions}) reached, refusing new session`);
|
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +256,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
...options.env,
|
...options.env,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||||
|
|
||||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
@@ -331,13 +328,13 @@ export class TerminalService extends EventEmitter {
|
|||||||
|
|
||||||
// Handle exit
|
// Handle exit
|
||||||
ptyProcess.onExit(({ exitCode }) => {
|
ptyProcess.onExit(({ exitCode }) => {
|
||||||
logger.info(`Session ${id} exited with code ${exitCode}`);
|
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
|
||||||
this.sessions.delete(id);
|
this.sessions.delete(id);
|
||||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
||||||
this.emit('exit', id, exitCode);
|
this.emit('exit', id, exitCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Session ${id} created successfully`);
|
console.log(`[Terminal] Session ${id} created successfully`);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +344,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
write(sessionId: string, data: string): boolean {
|
write(sessionId: string, data: string): boolean {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
logger.warn(`Session ${sessionId} not found`);
|
console.warn(`[Terminal] Session ${sessionId} not found`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
session.pty.write(data);
|
session.pty.write(data);
|
||||||
@@ -362,7 +359,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean {
|
resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
logger.warn(`Session ${sessionId} not found for resize`);
|
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -388,7 +385,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error resizing session ${sessionId}:`, error);
|
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
|
||||||
session.resizeInProgress = false; // Clear flag on error
|
session.resizeInProgress = false; // Clear flag on error
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -416,14 +413,14 @@ export class TerminalService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First try graceful SIGTERM to allow process cleanup
|
// First try graceful SIGTERM to allow process cleanup
|
||||||
logger.info(`Session ${sessionId} sending SIGTERM`);
|
console.log(`[Terminal] Session ${sessionId} sending SIGTERM`);
|
||||||
session.pty.kill('SIGTERM');
|
session.pty.kill('SIGTERM');
|
||||||
|
|
||||||
// Schedule SIGKILL fallback if process doesn't exit gracefully
|
// Schedule SIGKILL fallback if process doesn't exit gracefully
|
||||||
// The onExit handler will remove session from map when it actually exits
|
// The onExit handler will remove session from map when it actually exits
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.sessions.has(sessionId)) {
|
if (this.sessions.has(sessionId)) {
|
||||||
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||||
try {
|
try {
|
||||||
session.pty.kill('SIGKILL');
|
session.pty.kill('SIGKILL');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -434,10 +431,10 @@ export class TerminalService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
logger.info(`Session ${sessionId} kill initiated`);
|
console.log(`[Terminal] Session ${sessionId} kill initiated`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error killing session ${sessionId}:`, error);
|
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
|
||||||
// Still try to remove from map even if kill fails
|
// Still try to remove from map even if kill fails
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
return false;
|
return false;
|
||||||
@@ -520,7 +517,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
* Clean up all sessions
|
* Clean up all sessions
|
||||||
*/
|
*/
|
||||||
cleanup(): void {
|
cleanup(): void {
|
||||||
logger.info(`Cleaning up ${this.sessions.size} sessions`);
|
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
|
||||||
this.sessions.forEach((session, id) => {
|
this.sessions.forEach((session, id) => {
|
||||||
try {
|
try {
|
||||||
// Clean up flush timeout
|
// Clean up flush timeout
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
SIMPLIFY_EXAMPLES,
|
SIMPLIFY_EXAMPLES,
|
||||||
ACCEPTANCE_EXAMPLES,
|
ACCEPTANCE_EXAMPLES,
|
||||||
type EnhancementMode,
|
type EnhancementMode,
|
||||||
} from '@automaker/prompts';
|
} from '@/lib/enhancement-prompts.js';
|
||||||
|
|
||||||
describe('enhancement-prompts.ts', () => {
|
describe('enhancement-prompts.ts', () => {
|
||||||
describe('System Prompt Constants', () => {
|
describe('System Prompt Constants', () => {
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
|
|
||||||
// Store original platform and env
|
|
||||||
const originalPlatform = process.platform;
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
|
|
||||||
describe('exec-utils.ts', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// Restore original values
|
|
||||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('execAsync', () => {
|
|
||||||
it('should be a promisified exec function', async () => {
|
|
||||||
const { execAsync } = await import('@/lib/exec-utils.js');
|
|
||||||
expect(typeof execAsync).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should execute shell commands successfully', async () => {
|
|
||||||
const { execAsync } = await import('@/lib/exec-utils.js');
|
|
||||||
const result = await execAsync('echo "hello"');
|
|
||||||
expect(result.stdout.trim()).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject on invalid commands', async () => {
|
|
||||||
const { execAsync } = await import('@/lib/exec-utils.js');
|
|
||||||
await expect(execAsync('nonexistent-command-12345')).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('extendedPath', () => {
|
|
||||||
it('should include the original PATH', async () => {
|
|
||||||
const { extendedPath } = await import('@/lib/exec-utils.js');
|
|
||||||
expect(extendedPath).toContain(process.env.PATH);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include additional Unix paths on non-Windows', async () => {
|
|
||||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
const { extendedPath } = await import('@/lib/exec-utils.js');
|
|
||||||
expect(extendedPath).toContain('/opt/homebrew/bin');
|
|
||||||
expect(extendedPath).toContain('/usr/local/bin');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('execEnv', () => {
|
|
||||||
it('should have PATH set to extendedPath', async () => {
|
|
||||||
const { execEnv, extendedPath } = await import('@/lib/exec-utils.js');
|
|
||||||
expect(execEnv.PATH).toBe(extendedPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include all original environment variables', async () => {
|
|
||||||
const { execEnv } = await import('@/lib/exec-utils.js');
|
|
||||||
// Should have common env vars
|
|
||||||
expect(execEnv.HOME || execEnv.USERPROFILE).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isENOENT', () => {
|
|
||||||
it('should return true for ENOENT errors', async () => {
|
|
||||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
|
||||||
const error = { code: 'ENOENT' };
|
|
||||||
expect(isENOENT(error)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for other error codes', async () => {
|
|
||||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
|
||||||
const error = { code: 'EACCES' };
|
|
||||||
expect(isENOENT(error)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for null', async () => {
|
|
||||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
|
||||||
expect(isENOENT(null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for undefined', async () => {
|
|
||||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
|
||||||
expect(isENOENT(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for non-objects', async () => {
|
|
||||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
|
||||||
expect(isENOENT('ENOENT')).toBe(false);
|
|
||||||
expect(isENOENT(123)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for objects without code property', async () => {
|
|
||||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
|
||||||
expect(isENOENT({})).toBe(false);
|
|
||||||
expect(isENOENT({ message: 'error' })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle Error objects with code', async () => {
|
|
||||||
const { isENOENT } = await import('@/lib/exec-utils.js');
|
|
||||||
const error = new Error('File not found') as Error & { code: string };
|
|
||||||
error.code = 'ENOENT';
|
|
||||||
expect(isENOENT(error)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Windows platform handling', () => {
|
|
||||||
it('should use semicolon as path separator on Windows', async () => {
|
|
||||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
||||||
process.env.LOCALAPPDATA = 'C:\\Users\\Test\\AppData\\Local';
|
|
||||||
process.env.PROGRAMFILES = 'C:\\Program Files';
|
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
const { extendedPath } = await import('@/lib/exec-utils.js');
|
|
||||||
// Windows uses semicolon separator
|
|
||||||
expect(extendedPath).toContain(';');
|
|
||||||
expect(extendedPath).toContain('\\Git\\cmd');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Unix platform handling', () => {
|
|
||||||
it('should use colon as path separator on Unix', async () => {
|
|
||||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
||||||
process.env.HOME = '/home/testuser';
|
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
const { extendedPath } = await import('@/lib/exec-utils.js');
|
|
||||||
// Unix uses colon separator
|
|
||||||
expect(extendedPath).toContain(':');
|
|
||||||
expect(extendedPath).toContain('/home/linuxbrew/.linuxbrew/bin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include HOME/.local/bin path', async () => {
|
|
||||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
||||||
process.env.HOME = '/Users/testuser';
|
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
const { extendedPath } = await import('@/lib/exec-utils.js');
|
|
||||||
expect(extendedPath).toContain('/Users/testuser/.local/bin');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,15 +9,7 @@ import { collectAsyncGenerator } from '../../utils/helpers.js';
|
|||||||
|
|
||||||
vi.mock('fs/promises');
|
vi.mock('fs/promises');
|
||||||
vi.mock('@/providers/provider-factory.js');
|
vi.mock('@/providers/provider-factory.js');
|
||||||
vi.mock('@automaker/utils', async () => {
|
vi.mock('@automaker/utils');
|
||||||
const actual = await vi.importActual('@automaker/utils');
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
readImageAsBase64: vi.fn(),
|
|
||||||
buildPromptWithImages: vi.fn(),
|
|
||||||
loadContextFiles: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('agent-service.ts', () => {
|
describe('agent-service.ts', () => {
|
||||||
let service: AgentService;
|
let service: AgentService;
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { AutoModeService } from '@/services/auto-mode-service.js';
|
import { AutoModeService } from '@/services/auto-mode-service.js';
|
||||||
import {
|
|
||||||
getPlanningPromptPrefix,
|
|
||||||
parseTasksFromSpec,
|
|
||||||
parseTaskLine,
|
|
||||||
buildFeaturePrompt,
|
|
||||||
extractTitleFromDescription,
|
|
||||||
} from '@automaker/prompts';
|
|
||||||
|
|
||||||
describe('auto-mode-service.ts - Planning Mode', () => {
|
describe('auto-mode-service.ts - Planning Mode', () => {
|
||||||
let service: AutoModeService;
|
let service: AutoModeService;
|
||||||
@@ -25,28 +18,54 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
|||||||
await service.stopAutoLoop().catch(() => {});
|
await service.stopAutoLoop().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getPlanningPromptPrefix (from @automaker/prompts)', () => {
|
describe('getPlanningPromptPrefix', () => {
|
||||||
|
// Access private method through any cast for testing
|
||||||
|
const getPlanningPromptPrefix = (svc: any, feature: any) => {
|
||||||
|
return svc.getPlanningPromptPrefix(feature);
|
||||||
|
};
|
||||||
|
|
||||||
it('should return empty string for skip mode', () => {
|
it('should return empty string for skip mode', () => {
|
||||||
const result = getPlanningPromptPrefix('skip');
|
const feature = { id: 'test', planningMode: 'skip' as const };
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string when planningMode is undefined', () => {
|
||||||
|
const feature = { id: 'test' };
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result).toBe('');
|
expect(result).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return lite prompt for lite mode without approval', () => {
|
it('should return lite prompt for lite mode without approval', () => {
|
||||||
const result = getPlanningPromptPrefix('lite', false);
|
const feature = {
|
||||||
|
id: 'test',
|
||||||
|
planningMode: 'lite' as const,
|
||||||
|
requirePlanApproval: false,
|
||||||
|
};
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result).toContain('Planning Phase (Lite Mode)');
|
expect(result).toContain('Planning Phase (Lite Mode)');
|
||||||
expect(result).toContain('[PLAN_GENERATED]');
|
expect(result).toContain('[PLAN_GENERATED]');
|
||||||
expect(result).toContain('Feature Request');
|
expect(result).toContain('Feature Request');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return lite_with_approval prompt for lite mode with approval', () => {
|
it('should return lite_with_approval prompt for lite mode with approval', () => {
|
||||||
const result = getPlanningPromptPrefix('lite', true);
|
const feature = {
|
||||||
|
id: 'test',
|
||||||
|
planningMode: 'lite' as const,
|
||||||
|
requirePlanApproval: true,
|
||||||
|
};
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result).toContain('Planning Phase (Lite Mode)');
|
expect(result).toContain('Planning Phase (Lite Mode)');
|
||||||
expect(result).toContain('[SPEC_GENERATED]');
|
expect(result).toContain('[SPEC_GENERATED]');
|
||||||
expect(result).toContain('DO NOT proceed with implementation');
|
expect(result).toContain('DO NOT proceed with implementation');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return spec prompt for spec mode', () => {
|
it('should return spec prompt for spec mode', () => {
|
||||||
const result = getPlanningPromptPrefix('spec');
|
const feature = {
|
||||||
|
id: 'test',
|
||||||
|
planningMode: 'spec' as const,
|
||||||
|
};
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result).toContain('Specification Phase (Spec Mode)');
|
expect(result).toContain('Specification Phase (Spec Mode)');
|
||||||
expect(result).toContain('```tasks');
|
expect(result).toContain('```tasks');
|
||||||
expect(result).toContain('T001');
|
expect(result).toContain('T001');
|
||||||
@@ -55,7 +74,11 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return full prompt for full mode', () => {
|
it('should return full prompt for full mode', () => {
|
||||||
const result = getPlanningPromptPrefix('full');
|
const feature = {
|
||||||
|
id: 'test',
|
||||||
|
planningMode: 'full' as const,
|
||||||
|
};
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result).toContain('Full Specification Phase (Full SDD Mode)');
|
expect(result).toContain('Full Specification Phase (Full SDD Mode)');
|
||||||
expect(result).toContain('Phase 1: Foundation');
|
expect(result).toContain('Phase 1: Foundation');
|
||||||
expect(result).toContain('Phase 2: Core Implementation');
|
expect(result).toContain('Phase 2: Core Implementation');
|
||||||
@@ -63,7 +86,11 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should include the separator and Feature Request header', () => {
|
it('should include the separator and Feature Request header', () => {
|
||||||
const result = getPlanningPromptPrefix('spec');
|
const feature = {
|
||||||
|
id: 'test',
|
||||||
|
planningMode: 'spec' as const,
|
||||||
|
};
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result).toContain('---');
|
expect(result).toContain('---');
|
||||||
expect(result).toContain('## Feature Request');
|
expect(result).toContain('## Feature Request');
|
||||||
});
|
});
|
||||||
@@ -71,7 +98,8 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
|||||||
it('should instruct agent to NOT output exploration text', () => {
|
it('should instruct agent to NOT output exploration text', () => {
|
||||||
const modes = ['lite', 'spec', 'full'] as const;
|
const modes = ['lite', 'spec', 'full'] as const;
|
||||||
for (const mode of modes) {
|
for (const mode of modes) {
|
||||||
const result = getPlanningPromptPrefix(mode);
|
const feature = { id: 'test', planningMode: mode };
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result).toContain('Do NOT output exploration text');
|
expect(result).toContain('Do NOT output exploration text');
|
||||||
expect(result).toContain('Start DIRECTLY');
|
expect(result).toContain('Start DIRECTLY');
|
||||||
}
|
}
|
||||||
@@ -170,14 +198,17 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildFeaturePrompt (from @automaker/prompts)', () => {
|
describe('buildFeaturePrompt', () => {
|
||||||
|
const buildFeaturePrompt = (svc: any, feature: any) => {
|
||||||
|
return svc.buildFeaturePrompt(feature);
|
||||||
|
};
|
||||||
|
|
||||||
it('should include feature ID and description', () => {
|
it('should include feature ID and description', () => {
|
||||||
const feature = {
|
const feature = {
|
||||||
id: 'feat-123',
|
id: 'feat-123',
|
||||||
category: 'Test',
|
|
||||||
description: 'Add user authentication',
|
description: 'Add user authentication',
|
||||||
};
|
};
|
||||||
const result = buildFeaturePrompt(feature);
|
const result = buildFeaturePrompt(service, feature);
|
||||||
expect(result).toContain('feat-123');
|
expect(result).toContain('feat-123');
|
||||||
expect(result).toContain('Add user authentication');
|
expect(result).toContain('Add user authentication');
|
||||||
});
|
});
|
||||||
@@ -185,11 +216,10 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
|||||||
it('should include specification when present', () => {
|
it('should include specification when present', () => {
|
||||||
const feature = {
|
const feature = {
|
||||||
id: 'feat-123',
|
id: 'feat-123',
|
||||||
category: 'Test',
|
|
||||||
description: 'Test feature',
|
description: 'Test feature',
|
||||||
spec: 'Detailed specification here',
|
spec: 'Detailed specification here',
|
||||||
};
|
};
|
||||||
const result = buildFeaturePrompt(feature);
|
const result = buildFeaturePrompt(service, feature);
|
||||||
expect(result).toContain('Specification:');
|
expect(result).toContain('Specification:');
|
||||||
expect(result).toContain('Detailed specification here');
|
expect(result).toContain('Detailed specification here');
|
||||||
});
|
});
|
||||||
@@ -197,14 +227,13 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
|||||||
it('should include image paths when present', () => {
|
it('should include image paths when present', () => {
|
||||||
const feature = {
|
const feature = {
|
||||||
id: 'feat-123',
|
id: 'feat-123',
|
||||||
category: 'Test',
|
|
||||||
description: 'Test feature',
|
description: 'Test feature',
|
||||||
imagePaths: [
|
imagePaths: [
|
||||||
{ path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' },
|
{ path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' },
|
||||||
'/tmp/image2.jpg',
|
'/tmp/image2.jpg',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const result = buildFeaturePrompt(feature);
|
const result = buildFeaturePrompt(service, feature);
|
||||||
expect(result).toContain('Context Images Attached');
|
expect(result).toContain('Context Images Attached');
|
||||||
expect(result).toContain('image1.png');
|
expect(result).toContain('image1.png');
|
||||||
expect(result).toContain('/tmp/image2.jpg');
|
expect(result).toContain('/tmp/image2.jpg');
|
||||||
@@ -213,46 +242,55 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
|||||||
it('should include summary tags instruction', () => {
|
it('should include summary tags instruction', () => {
|
||||||
const feature = {
|
const feature = {
|
||||||
id: 'feat-123',
|
id: 'feat-123',
|
||||||
category: 'Test',
|
|
||||||
description: 'Test feature',
|
description: 'Test feature',
|
||||||
};
|
};
|
||||||
const result = buildFeaturePrompt(feature);
|
const result = buildFeaturePrompt(service, feature);
|
||||||
expect(result).toContain('<summary>');
|
expect(result).toContain('<summary>');
|
||||||
expect(result).toContain('summary');
|
expect(result).toContain('</summary>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractTitleFromDescription (from @automaker/prompts)', () => {
|
describe('extractTitleFromDescription', () => {
|
||||||
|
const extractTitle = (svc: any, description: string) => {
|
||||||
|
return svc.extractTitleFromDescription(description);
|
||||||
|
};
|
||||||
|
|
||||||
it("should return 'Untitled Feature' for empty description", () => {
|
it("should return 'Untitled Feature' for empty description", () => {
|
||||||
expect(extractTitleFromDescription('')).toBe('Untitled Feature');
|
expect(extractTitle(service, '')).toBe('Untitled Feature');
|
||||||
expect(extractTitleFromDescription(' ')).toBe('Untitled Feature');
|
expect(extractTitle(service, ' ')).toBe('Untitled Feature');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return first line if under 60 characters', () => {
|
it('should return first line if under 60 characters', () => {
|
||||||
const description = 'Add user login\nWith email validation';
|
const description = 'Add user login\nWith email validation';
|
||||||
expect(extractTitleFromDescription(description)).toBe('Add user login');
|
expect(extractTitle(service, description)).toBe('Add user login');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should truncate long first lines to 60 characters', () => {
|
it('should truncate long first lines to 60 characters', () => {
|
||||||
const description =
|
const description =
|
||||||
'This is a very long feature description that exceeds the sixty character limit significantly';
|
'This is a very long feature description that exceeds the sixty character limit significantly';
|
||||||
const result = extractTitleFromDescription(description);
|
const result = extractTitle(service, description);
|
||||||
expect(result.length).toBe(60);
|
expect(result.length).toBe(60);
|
||||||
expect(result).toContain('...');
|
expect(result).toContain('...');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PLANNING_PROMPTS structure (from @automaker/prompts)', () => {
|
describe('PLANNING_PROMPTS structure', () => {
|
||||||
|
const getPlanningPromptPrefix = (svc: any, feature: any) => {
|
||||||
|
return svc.getPlanningPromptPrefix(feature);
|
||||||
|
};
|
||||||
|
|
||||||
it('should have all required planning modes', () => {
|
it('should have all required planning modes', () => {
|
||||||
const modes = ['lite', 'spec', 'full'] as const;
|
const modes = ['lite', 'spec', 'full'] as const;
|
||||||
for (const mode of modes) {
|
for (const mode of modes) {
|
||||||
const result = getPlanningPromptPrefix(mode);
|
const feature = { id: 'test', planningMode: mode };
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result.length).toBeGreaterThan(100);
|
expect(result.length).toBeGreaterThan(100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lite prompt should include correct structure', () => {
|
it('lite prompt should include correct structure', () => {
|
||||||
const result = getPlanningPromptPrefix('lite');
|
const feature = { id: 'test', planningMode: 'lite' as const };
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result).toContain('Goal');
|
expect(result).toContain('Goal');
|
||||||
expect(result).toContain('Approach');
|
expect(result).toContain('Approach');
|
||||||
expect(result).toContain('Files to Touch');
|
expect(result).toContain('Files to Touch');
|
||||||
@@ -261,7 +299,8 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('spec prompt should include task format instructions', () => {
|
it('spec prompt should include task format instructions', () => {
|
||||||
const result = getPlanningPromptPrefix('spec');
|
const feature = { id: 'test', planningMode: 'spec' as const };
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result).toContain('Problem');
|
expect(result).toContain('Problem');
|
||||||
expect(result).toContain('Solution');
|
expect(result).toContain('Solution');
|
||||||
expect(result).toContain('Acceptance Criteria');
|
expect(result).toContain('Acceptance Criteria');
|
||||||
@@ -271,7 +310,8 @@ describe('auto-mode-service.ts - Planning Mode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('full prompt should include phases', () => {
|
it('full prompt should include phases', () => {
|
||||||
const result = getPlanningPromptPrefix('full');
|
const feature = { id: 'test', planningMode: 'full' as const };
|
||||||
|
const result = getPlanningPromptPrefix(service, feature);
|
||||||
expect(result).toContain('Problem Statement');
|
expect(result).toContain('Problem Statement');
|
||||||
expect(result).toContain('User Story');
|
expect(result).toContain('User Story');
|
||||||
expect(result).toContain('Technical Context');
|
expect(result).toContain('Technical Context');
|
||||||
|
|||||||
@@ -1,5 +1,92 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseTaskLine, parseTasksFromSpec } from '@automaker/prompts';
|
|
||||||
|
/**
|
||||||
|
* Test the task parsing logic by reimplementing the parsing functions
|
||||||
|
* These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ParsedTask {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
filePath?: string;
|
||||||
|
phase?: string;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
|
||||||
|
// Match pattern: - [ ] T###: Description | File: path
|
||||||
|
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
|
||||||
|
if (!taskMatch) {
|
||||||
|
// Try simpler pattern without file
|
||||||
|
const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/);
|
||||||
|
if (simpleMatch) {
|
||||||
|
return {
|
||||||
|
id: simpleMatch[1],
|
||||||
|
description: simpleMatch[2].trim(),
|
||||||
|
phase: currentPhase,
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: taskMatch[1],
|
||||||
|
description: taskMatch[2].trim(),
|
||||||
|
filePath: taskMatch[3]?.trim(),
|
||||||
|
phase: currentPhase,
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTasksFromSpec(specContent: string): ParsedTask[] {
|
||||||
|
const tasks: ParsedTask[] = [];
|
||||||
|
|
||||||
|
// Extract content within ```tasks ... ``` block
|
||||||
|
const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/);
|
||||||
|
if (!tasksBlockMatch) {
|
||||||
|
// Try fallback: look for task lines anywhere in content
|
||||||
|
const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm);
|
||||||
|
if (!taskLines) {
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
// Parse fallback task lines
|
||||||
|
let currentPhase: string | undefined;
|
||||||
|
for (const line of taskLines) {
|
||||||
|
const parsed = parseTaskLine(line, currentPhase);
|
||||||
|
if (parsed) {
|
||||||
|
tasks.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksContent = tasksBlockMatch[1];
|
||||||
|
const lines = tasksContent.split('\n');
|
||||||
|
|
||||||
|
let currentPhase: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
|
// Check for phase header (e.g., "## Phase 1: Foundation")
|
||||||
|
const phaseMatch = trimmedLine.match(/^##\s*(.+)$/);
|
||||||
|
if (phaseMatch) {
|
||||||
|
currentPhase = phaseMatch[1].trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for task line
|
||||||
|
if (trimmedLine.startsWith('- [ ]')) {
|
||||||
|
const parsed = parseTaskLine(trimmedLine, currentPhase);
|
||||||
|
if (parsed) {
|
||||||
|
tasks.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Task Parsing', () => {
|
describe('Task Parsing', () => {
|
||||||
describe('parseTaskLine', () => {
|
describe('parseTaskLine', () => {
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { FeatureVerificationService } from '@/services/auto-mode/feature-verification.js';
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock('@automaker/platform', () => ({
|
|
||||||
secureFs: {
|
|
||||||
access: vi.fn(),
|
|
||||||
readFile: vi.fn(),
|
|
||||||
},
|
|
||||||
getFeatureDir: vi.fn(
|
|
||||||
(projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}`
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@automaker/git-utils', () => ({
|
|
||||||
runVerificationChecks: vi.fn(),
|
|
||||||
hasUncommittedChanges: vi.fn(),
|
|
||||||
commitAll: vi.fn(),
|
|
||||||
shortHash: vi.fn((hash: string) => hash.substring(0, 7)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@automaker/prompts', () => ({
|
|
||||||
extractTitleFromDescription: vi.fn((desc: string) => desc.split('\n')[0]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { secureFs, getFeatureDir } from '@automaker/platform';
|
|
||||||
import { runVerificationChecks, hasUncommittedChanges, commitAll } from '@automaker/git-utils';
|
|
||||||
|
|
||||||
describe('FeatureVerificationService', () => {
|
|
||||||
let service: FeatureVerificationService;
|
|
||||||
let mockEvents: { emit: ReturnType<typeof vi.fn> };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockEvents = { emit: vi.fn() };
|
|
||||||
service = new FeatureVerificationService(mockEvents as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('constructor', () => {
|
|
||||||
it('should create service instance', () => {
|
|
||||||
expect(service).toBeInstanceOf(FeatureVerificationService);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveWorkDir', () => {
|
|
||||||
it('should return worktree path if it exists', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
const result = await service.resolveWorkDir('/project', 'feature-1');
|
|
||||||
|
|
||||||
expect(result).toBe('/project/.worktrees/feature-1');
|
|
||||||
expect(secureFs.access).toHaveBeenCalledWith('/project/.worktrees/feature-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return project path if worktree does not exist', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
|
|
||||||
const result = await service.resolveWorkDir('/project', 'feature-1');
|
|
||||||
|
|
||||||
expect(result).toBe('/project');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verify', () => {
|
|
||||||
it('should emit success event when verification passes', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
vi.mocked(runVerificationChecks).mockResolvedValue({ success: true });
|
|
||||||
|
|
||||||
const result = await service.verify('/project', 'feature-1');
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
|
||||||
type: 'auto_mode_feature_complete',
|
|
||||||
featureId: 'feature-1',
|
|
||||||
passes: true,
|
|
||||||
message: 'All verification checks passed',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit failure event when verification fails', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
vi.mocked(runVerificationChecks).mockResolvedValue({
|
|
||||||
success: false,
|
|
||||||
failedCheck: 'lint',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.verify('/project', 'feature-1');
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.failedCheck).toBe('lint');
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
|
||||||
type: 'auto_mode_feature_complete',
|
|
||||||
featureId: 'feature-1',
|
|
||||||
passes: false,
|
|
||||||
message: 'Verification failed: lint',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use worktree path if available', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
||||||
vi.mocked(runVerificationChecks).mockResolvedValue({ success: true });
|
|
||||||
|
|
||||||
await service.verify('/project', 'feature-1');
|
|
||||||
|
|
||||||
expect(runVerificationChecks).toHaveBeenCalledWith('/project/.worktrees/feature-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('commit', () => {
|
|
||||||
it('should return null hash when no changes', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(false);
|
|
||||||
|
|
||||||
const result = await service.commit('/project', 'feature-1', null);
|
|
||||||
|
|
||||||
expect(result.hash).toBeNull();
|
|
||||||
expect(commitAll).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should commit changes and return hash', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
|
|
||||||
vi.mocked(commitAll).mockResolvedValue('abc123def456');
|
|
||||||
|
|
||||||
const result = await service.commit('/project', 'feature-1', {
|
|
||||||
id: 'feature-1',
|
|
||||||
description: 'Add login button\nWith authentication',
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(result.hash).toBe('abc123def456');
|
|
||||||
expect(result.shortHash).toBe('abc123d');
|
|
||||||
expect(commitAll).toHaveBeenCalledWith(
|
|
||||||
'/project',
|
|
||||||
expect.stringContaining('feat: Add login button')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use provided worktree path', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
||||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
|
|
||||||
vi.mocked(commitAll).mockResolvedValue('abc123');
|
|
||||||
|
|
||||||
await service.commit('/project', 'feature-1', null, '/custom/worktree');
|
|
||||||
|
|
||||||
expect(hasUncommittedChanges).toHaveBeenCalledWith('/custom/worktree');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fall back to project path if provided worktree does not exist', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(false);
|
|
||||||
|
|
||||||
await service.commit('/project', 'feature-1', null, '/nonexistent/worktree');
|
|
||||||
|
|
||||||
expect(hasUncommittedChanges).toHaveBeenCalledWith('/project');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use feature ID in commit message when no feature provided', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
|
|
||||||
vi.mocked(commitAll).mockResolvedValue('abc123');
|
|
||||||
|
|
||||||
await service.commit('/project', 'feature-123', null);
|
|
||||||
|
|
||||||
expect(commitAll).toHaveBeenCalledWith(
|
|
||||||
'/project',
|
|
||||||
expect.stringContaining('feat: Feature feature-123')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit event on successful commit', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
|
|
||||||
vi.mocked(commitAll).mockResolvedValue('abc123def');
|
|
||||||
|
|
||||||
await service.commit('/project', 'feature-1', null);
|
|
||||||
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
|
||||||
type: 'auto_mode_feature_complete',
|
|
||||||
featureId: 'feature-1',
|
|
||||||
passes: true,
|
|
||||||
message: expect.stringContaining('Changes committed:'),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null hash when commit fails', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
vi.mocked(hasUncommittedChanges).mockResolvedValue(true);
|
|
||||||
vi.mocked(commitAll).mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await service.commit('/project', 'feature-1', null);
|
|
||||||
|
|
||||||
expect(result.hash).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('contextExists', () => {
|
|
||||||
it('should return true if context file exists', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
const result = await service.contextExists('/project', 'feature-1');
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(secureFs.access).toHaveBeenCalledWith(
|
|
||||||
'/project/.automaker/features/feature-1/agent-output.md'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if context file does not exist', async () => {
|
|
||||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
|
|
||||||
const result = await service.contextExists('/project', 'feature-1');
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadContext', () => {
|
|
||||||
it('should return context content if file exists', async () => {
|
|
||||||
vi.mocked(secureFs.readFile).mockResolvedValue('# Agent Output\nSome content');
|
|
||||||
|
|
||||||
const result = await service.loadContext('/project', 'feature-1');
|
|
||||||
|
|
||||||
expect(result).toBe('# Agent Output\nSome content');
|
|
||||||
expect(secureFs.readFile).toHaveBeenCalledWith(
|
|
||||||
'/project/.automaker/features/feature-1/agent-output.md',
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if file does not exist', async () => {
|
|
||||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
|
|
||||||
const result = await service.loadContext('/project', 'feature-1');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
// Use vi.hoisted for mock functions that need to be used in vi.mock factories
|
|
||||||
const {
|
|
||||||
mockExecuteQuery,
|
|
||||||
mockProcessStream,
|
|
||||||
mockMkdir,
|
|
||||||
mockWriteFile,
|
|
||||||
mockValidateWorkingDirectory,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
mockExecuteQuery: vi.fn(),
|
|
||||||
mockProcessStream: vi.fn(),
|
|
||||||
mockMkdir: vi.fn(),
|
|
||||||
mockWriteFile: vi.fn(),
|
|
||||||
mockValidateWorkingDirectory: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock('@automaker/platform', () => ({
|
|
||||||
secureFs: {
|
|
||||||
mkdir: mockMkdir,
|
|
||||||
writeFile: mockWriteFile,
|
|
||||||
},
|
|
||||||
getAutomakerDir: (projectPath: string) => `${projectPath}/.automaker`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@automaker/utils', async () => {
|
|
||||||
const actual = await vi.importActual('@automaker/utils');
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
processStream: mockProcessStream,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('@automaker/model-resolver', () => ({
|
|
||||||
resolveModelString: () => 'claude-sonnet-4-20250514',
|
|
||||||
DEFAULT_MODELS: { claude: 'claude-sonnet-4-20250514' },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/providers/provider-factory.js', () => ({
|
|
||||||
ProviderFactory: {
|
|
||||||
getProviderForModel: () => ({
|
|
||||||
executeQuery: mockExecuteQuery,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/sdk-options.js', () => ({
|
|
||||||
validateWorkingDirectory: mockValidateWorkingDirectory,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { ProjectAnalyzer } from '@/services/auto-mode/project-analyzer.js';
|
|
||||||
|
|
||||||
describe('ProjectAnalyzer', () => {
|
|
||||||
let analyzer: ProjectAnalyzer;
|
|
||||||
let mockEvents: { emit: ReturnType<typeof vi.fn> };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockEvents = { emit: vi.fn() };
|
|
||||||
|
|
||||||
mockExecuteQuery.mockReturnValue(
|
|
||||||
(async function* () {
|
|
||||||
yield { type: 'text', text: 'Analysis result' };
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
|
|
||||||
mockProcessStream.mockResolvedValue({
|
|
||||||
text: '# Project Analysis\nThis is a test project.',
|
|
||||||
toolUses: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
analyzer = new ProjectAnalyzer(mockEvents as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('constructor', () => {
|
|
||||||
it('should create analyzer instance', () => {
|
|
||||||
expect(analyzer).toBeInstanceOf(ProjectAnalyzer);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('analyze', () => {
|
|
||||||
it('should validate working directory', async () => {
|
|
||||||
await analyzer.analyze('/project');
|
|
||||||
|
|
||||||
expect(mockValidateWorkingDirectory).toHaveBeenCalledWith('/project');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit start event', async () => {
|
|
||||||
await analyzer.analyze('/project');
|
|
||||||
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
|
||||||
'auto-mode:event',
|
|
||||||
expect.objectContaining({
|
|
||||||
type: 'auto_mode_feature_start',
|
|
||||||
projectPath: '/project',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call provider executeQuery with correct options', async () => {
|
|
||||||
await analyzer.analyze('/project');
|
|
||||||
|
|
||||||
expect(mockExecuteQuery).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
cwd: '/project',
|
|
||||||
maxTurns: 5,
|
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should save analysis to file', async () => {
|
|
||||||
await analyzer.analyze('/project');
|
|
||||||
|
|
||||||
expect(mockMkdir).toHaveBeenCalledWith('/project/.automaker', { recursive: true });
|
|
||||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
||||||
'/project/.automaker/project-analysis.md',
|
|
||||||
'# Project Analysis\nThis is a test project.'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit complete event on success', async () => {
|
|
||||||
await analyzer.analyze('/project');
|
|
||||||
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
|
||||||
'auto-mode:event',
|
|
||||||
expect.objectContaining({
|
|
||||||
type: 'auto_mode_feature_complete',
|
|
||||||
passes: true,
|
|
||||||
message: 'Project analysis completed',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit error event on failure', async () => {
|
|
||||||
mockProcessStream.mockRejectedValue(new Error('Analysis failed'));
|
|
||||||
|
|
||||||
await analyzer.analyze('/project');
|
|
||||||
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
|
||||||
'auto-mode:event',
|
|
||||||
expect.objectContaining({
|
|
||||||
type: 'auto_mode_error',
|
|
||||||
error: expect.stringContaining('Analysis failed'),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle stream with onText callback', async () => {
|
|
||||||
await analyzer.analyze('/project');
|
|
||||||
|
|
||||||
expect(mockProcessStream).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
onText: expect.any(Function),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { TaskExecutor } from '@/services/auto-mode/task-executor.js';
|
|
||||||
import type { ParsedTask } from '@automaker/types';
|
|
||||||
import type { TaskExecutionContext } from '@/services/auto-mode/types.js';
|
|
||||||
|
|
||||||
// Use vi.hoisted for mock functions
|
|
||||||
const { mockBuildTaskPrompt, mockProcessStream } = vi.hoisted(() => ({
|
|
||||||
mockBuildTaskPrompt: vi.fn(),
|
|
||||||
mockProcessStream: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock('@automaker/prompts', () => ({
|
|
||||||
buildTaskPrompt: mockBuildTaskPrompt,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@automaker/utils', async () => {
|
|
||||||
const actual = await vi.importActual('@automaker/utils');
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
processStream: mockProcessStream,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TaskExecutor', () => {
|
|
||||||
let executor: TaskExecutor;
|
|
||||||
let mockEvents: { emit: ReturnType<typeof vi.fn> };
|
|
||||||
let mockProvider: { executeQuery: ReturnType<typeof vi.fn> };
|
|
||||||
let mockContext: TaskExecutionContext;
|
|
||||||
let mockTasks: ParsedTask[];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
mockEvents = { emit: vi.fn() };
|
|
||||||
mockProvider = {
|
|
||||||
executeQuery: vi.fn().mockReturnValue(
|
|
||||||
(async function* () {
|
|
||||||
yield { type: 'text', text: 'Task output' };
|
|
||||||
})()
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockContext = {
|
|
||||||
featureId: 'feature-1',
|
|
||||||
projectPath: '/project',
|
|
||||||
workDir: '/project/worktree',
|
|
||||||
model: 'claude-sonnet-4-20250514',
|
|
||||||
maxTurns: 100,
|
|
||||||
allowedTools: ['Read', 'Write'],
|
|
||||||
abortController: new AbortController(),
|
|
||||||
planContent: '# Plan\nTask list',
|
|
||||||
userFeedback: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
mockTasks = [
|
|
||||||
{ id: '1', description: 'Task 1', phase: 'Phase 1' },
|
|
||||||
{ id: '2', description: 'Task 2', phase: 'Phase 1' },
|
|
||||||
{ id: '3', description: 'Task 3', phase: 'Phase 2' },
|
|
||||||
];
|
|
||||||
|
|
||||||
mockBuildTaskPrompt.mockReturnValue('Generated task prompt');
|
|
||||||
mockProcessStream.mockResolvedValue({ text: 'Processed output', toolUses: [] });
|
|
||||||
|
|
||||||
executor = new TaskExecutor(mockEvents as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('constructor', () => {
|
|
||||||
it('should create executor instance', () => {
|
|
||||||
expect(executor).toBeInstanceOf(TaskExecutor);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('executeAll', () => {
|
|
||||||
it('should yield started and completed events for each task', async () => {
|
|
||||||
const results: any[] = [];
|
|
||||||
for await (const progress of executor.executeAll(
|
|
||||||
mockTasks,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
)) {
|
|
||||||
results.push(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have 2 events per task (started + completed)
|
|
||||||
expect(results).toHaveLength(6);
|
|
||||||
expect(results[0]).toEqual({
|
|
||||||
taskId: '1',
|
|
||||||
taskIndex: 0,
|
|
||||||
tasksTotal: 3,
|
|
||||||
status: 'started',
|
|
||||||
});
|
|
||||||
expect(results[1]).toEqual({
|
|
||||||
taskId: '1',
|
|
||||||
taskIndex: 0,
|
|
||||||
tasksTotal: 3,
|
|
||||||
status: 'completed',
|
|
||||||
output: 'Processed output',
|
|
||||||
phaseComplete: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit task started events', async () => {
|
|
||||||
const results: any[] = [];
|
|
||||||
for await (const progress of executor.executeAll(
|
|
||||||
mockTasks,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
)) {
|
|
||||||
results.push(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
|
||||||
type: 'auto_mode_task_started',
|
|
||||||
featureId: 'feature-1',
|
|
||||||
projectPath: '/project',
|
|
||||||
taskId: '1',
|
|
||||||
taskDescription: 'Task 1',
|
|
||||||
taskIndex: 0,
|
|
||||||
tasksTotal: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit task complete events', async () => {
|
|
||||||
const results: any[] = [];
|
|
||||||
for await (const progress of executor.executeAll(
|
|
||||||
mockTasks,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
)) {
|
|
||||||
results.push(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
|
||||||
type: 'auto_mode_task_complete',
|
|
||||||
featureId: 'feature-1',
|
|
||||||
projectPath: '/project',
|
|
||||||
taskId: '1',
|
|
||||||
tasksCompleted: 1,
|
|
||||||
tasksTotal: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw on abort', async () => {
|
|
||||||
mockContext.abortController.abort();
|
|
||||||
|
|
||||||
const results: any[] = [];
|
|
||||||
await expect(async () => {
|
|
||||||
for await (const progress of executor.executeAll(
|
|
||||||
mockTasks,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
)) {
|
|
||||||
results.push(progress);
|
|
||||||
}
|
|
||||||
}).rejects.toThrow('Feature execution aborted');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call provider executeQuery with correct options', async () => {
|
|
||||||
const results: any[] = [];
|
|
||||||
for await (const progress of executor.executeAll(
|
|
||||||
mockTasks,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
)) {
|
|
||||||
results.push(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mockProvider.executeQuery).toHaveBeenCalledWith({
|
|
||||||
prompt: 'Generated task prompt',
|
|
||||||
model: 'claude-sonnet-4-20250514',
|
|
||||||
maxTurns: 50, // Limited to 50 per task
|
|
||||||
cwd: '/project/worktree',
|
|
||||||
allowedTools: ['Read', 'Write'],
|
|
||||||
abortController: mockContext.abortController,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect phase completion', async () => {
|
|
||||||
const results: any[] = [];
|
|
||||||
for await (const progress of executor.executeAll(
|
|
||||||
mockTasks,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
)) {
|
|
||||||
results.push(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task 2 completes Phase 1 (next task is Phase 2)
|
|
||||||
const task2Completed = results.find((r) => r.taskId === '2' && r.status === 'completed');
|
|
||||||
expect(task2Completed?.phaseComplete).toBe(1);
|
|
||||||
|
|
||||||
// Task 3 completes Phase 2 (no more tasks)
|
|
||||||
const task3Completed = results.find((r) => r.taskId === '3' && r.status === 'completed');
|
|
||||||
expect(task3Completed?.phaseComplete).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit phase complete events', async () => {
|
|
||||||
const results: any[] = [];
|
|
||||||
for await (const progress of executor.executeAll(
|
|
||||||
mockTasks,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
)) {
|
|
||||||
results.push(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
|
||||||
type: 'auto_mode_phase_complete',
|
|
||||||
featureId: 'feature-1',
|
|
||||||
projectPath: '/project',
|
|
||||||
phaseNumber: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should yield failed status on error', async () => {
|
|
||||||
mockProcessStream.mockRejectedValueOnce(new Error('Task failed'));
|
|
||||||
|
|
||||||
const results: any[] = [];
|
|
||||||
await expect(async () => {
|
|
||||||
for await (const progress of executor.executeAll(
|
|
||||||
mockTasks,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
)) {
|
|
||||||
results.push(progress);
|
|
||||||
}
|
|
||||||
}).rejects.toThrow('Task failed');
|
|
||||||
|
|
||||||
expect(results).toContainEqual({
|
|
||||||
taskId: '1',
|
|
||||||
taskIndex: 0,
|
|
||||||
tasksTotal: 3,
|
|
||||||
status: 'failed',
|
|
||||||
output: 'Task failed',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('executeOne', () => {
|
|
||||||
it('should execute a single task and return output', async () => {
|
|
||||||
const result = await executor.executeOne(
|
|
||||||
mockTasks[0],
|
|
||||||
mockTasks,
|
|
||||||
0,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe('Processed output');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should build prompt with correct parameters', async () => {
|
|
||||||
await executor.executeOne(mockTasks[0], mockTasks, 0, mockContext, mockProvider as any);
|
|
||||||
|
|
||||||
expect(mockBuildTaskPrompt).toHaveBeenCalledWith(
|
|
||||||
mockTasks[0],
|
|
||||||
mockTasks,
|
|
||||||
0,
|
|
||||||
mockContext.planContent,
|
|
||||||
mockContext.userFeedback
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit progress events for text output', async () => {
|
|
||||||
mockProcessStream.mockImplementation(async (_stream, options) => {
|
|
||||||
options.onText?.('Some output');
|
|
||||||
return { text: 'Some output', toolUses: [] };
|
|
||||||
});
|
|
||||||
|
|
||||||
await executor.executeOne(mockTasks[0], mockTasks, 0, mockContext, mockProvider as any);
|
|
||||||
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
|
||||||
type: 'auto_mode_progress',
|
|
||||||
featureId: 'feature-1',
|
|
||||||
content: 'Some output',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit tool events for tool use', async () => {
|
|
||||||
mockProcessStream.mockImplementation(async (_stream, options) => {
|
|
||||||
options.onToolUse?.('Read', { path: '/file.txt' });
|
|
||||||
return { text: 'Output', toolUses: [] };
|
|
||||||
});
|
|
||||||
|
|
||||||
await executor.executeOne(mockTasks[0], mockTasks, 0, mockContext, mockProvider as any);
|
|
||||||
|
|
||||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
|
||||||
type: 'auto_mode_tool',
|
|
||||||
featureId: 'feature-1',
|
|
||||||
tool: 'Read',
|
|
||||||
input: { path: '/file.txt' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('phase detection', () => {
|
|
||||||
it('should not detect phase completion for tasks without phase', async () => {
|
|
||||||
const tasksNoPhase = [
|
|
||||||
{ id: '1', description: 'Task 1' },
|
|
||||||
{ id: '2', description: 'Task 2' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const results: any[] = [];
|
|
||||||
for await (const progress of executor.executeAll(
|
|
||||||
tasksNoPhase,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
)) {
|
|
||||||
results.push(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
const completedResults = results.filter((r) => r.status === 'completed');
|
|
||||||
expect(completedResults.every((r) => r.phaseComplete === undefined)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect phase change when next task has different phase', async () => {
|
|
||||||
const results: any[] = [];
|
|
||||||
for await (const progress of executor.executeAll(
|
|
||||||
mockTasks,
|
|
||||||
mockContext,
|
|
||||||
mockProvider as any
|
|
||||||
)) {
|
|
||||||
results.push(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task 2 (Phase 1) -> Task 3 (Phase 2) = phase complete
|
|
||||||
const task2Completed = results.find((r) => r.taskId === '2' && r.status === 'completed');
|
|
||||||
expect(task2Completed?.phaseComplete).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,16 +10,10 @@ vi.mock('child_process', () => ({
|
|||||||
execSync: vi.fn(),
|
execSync: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock secure-fs from @automaker/platform
|
// Mock secure-fs
|
||||||
vi.mock('@automaker/platform', async () => {
|
vi.mock('@/lib/secure-fs.js', () => ({
|
||||||
const actual = await vi.importActual('@automaker/platform');
|
access: vi.fn(),
|
||||||
return {
|
}));
|
||||||
...actual,
|
|
||||||
secureFs: {
|
|
||||||
access: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock net
|
// Mock net
|
||||||
vi.mock('net', () => ({
|
vi.mock('net', () => ({
|
||||||
@@ -30,7 +24,7 @@ vi.mock('net', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { spawn, execSync } from 'child_process';
|
import { spawn, execSync } from 'child_process';
|
||||||
import { secureFs } from '@automaker/platform';
|
import * as secureFs from '@/lib/secure-fs.js';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
|
|
||||||
describe('dev-server-service.ts', () => {
|
describe('dev-server-service.ts', () => {
|
||||||
|
|||||||
@@ -63,17 +63,21 @@
|
|||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"framer-motion": "^12.23.26",
|
||||||
"geist": "^1.5.1",
|
"geist": "^1.5.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"rehype-raw": "^7.0.0",
|
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
@@ -95,6 +99,7 @@
|
|||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/router-plugin": "^1.141.7",
|
"@tanstack/router-plugin": "^1.141.7",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useSettingsMigration } from './hooks/use-settings-migration';
|
|||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
import './styles/theme-imports';
|
import './styles/theme-imports';
|
||||||
|
|
||||||
|
import { Shell } from './components/layout/shell';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [showSplash, setShowSplash] = useState(() => {
|
const [showSplash, setShowSplash] = useState(() => {
|
||||||
// Only show splash once per session
|
// Only show splash once per session
|
||||||
@@ -27,9 +29,9 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Shell>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
|
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
|
||||||
</>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
118
apps/ui/src/components/layout/floating-dock.tsx
Normal file
118
apps/ui/src/components/layout/floating-dock.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
|
||||||
|
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Bot,
|
||||||
|
FileText,
|
||||||
|
Database,
|
||||||
|
Terminal,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
|
export function FloatingDock() {
|
||||||
|
const mouseX = useMotionValue(Infinity);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ id: 'board', icon: LayoutDashboard, label: 'Board', path: '/board' },
|
||||||
|
{ id: 'agent', icon: Bot, label: 'Agent', path: '/agent' },
|
||||||
|
{ id: 'spec', icon: FileText, label: 'Spec', path: '/spec' },
|
||||||
|
{ id: 'context', icon: Database, label: 'Context', path: '/context' },
|
||||||
|
{ id: 'profiles', icon: Users, label: 'Profiles', path: '/profiles' },
|
||||||
|
{ id: 'terminal', icon: Terminal, label: 'Terminal', path: '/terminal' },
|
||||||
|
{ id: 'settings', icon: Settings, label: 'Settings', path: '/settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!currentProject) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
|
||||||
|
<motion.div
|
||||||
|
onMouseMove={(e) => mouseX.set(e.pageX)}
|
||||||
|
onMouseLeave={() => mouseX.set(Infinity)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-16 items-end gap-4 rounded-2xl px-4 pb-3',
|
||||||
|
'bg-white/5 backdrop-blur-2xl border border-white/10 shadow-2xl'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<DockIcon
|
||||||
|
key={item.id}
|
||||||
|
mouseX={mouseX}
|
||||||
|
icon={item.icon}
|
||||||
|
path={item.path}
|
||||||
|
label={item.label}
|
||||||
|
isActive={location.pathname.startsWith(item.path)}
|
||||||
|
onClick={() => navigate({ to: item.path })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DockIcon({
|
||||||
|
mouseX,
|
||||||
|
icon: Icon,
|
||||||
|
path,
|
||||||
|
label,
|
||||||
|
isActive,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
mouseX: any;
|
||||||
|
icon: LucideIcon;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const distance = useTransform(mouseX, (val: number) => {
|
||||||
|
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
|
||||||
|
return val - bounds.x - bounds.width / 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const widthSync = useTransform(distance, [-150, 0, 150], [40, 80, 40]);
|
||||||
|
const width = useSpring(widthSync, { mass: 0.1, stiffness: 150, damping: 12 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
style={{ width }}
|
||||||
|
className="aspect-square cursor-pointer group relative"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute -top-10 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity text-xs font-mono bg-black/80 text-white px-2 py-1 rounded backdrop-blur-md border border-white/10 pointer-events-none whitespace-nowrap">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full items-center justify-center rounded-full transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-foreground shadow-[0_0_20px_rgba(34,211,238,0.3)]'
|
||||||
|
: 'bg-white/5 text-muted-foreground hover:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-[40%] w-[40%]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Dot */}
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeDockDot"
|
||||||
|
className="absolute -bottom-2 left-1/2 w-1 h-1 bg-primary rounded-full -translate-x-1/2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/ui/src/components/layout/hud.tsx
Normal file
70
apps/ui/src/components/layout/hud.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { ChevronDown, Command, Folder } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
interface HudProps {
|
||||||
|
onOpenProjectPicker: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hud({ onOpenProjectPicker, onOpenFolder }: HudProps) {
|
||||||
|
const { currentProject, projects, setCurrentProject } = useAppStore();
|
||||||
|
|
||||||
|
if (!currentProject) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 left-4 z-50 flex items-center gap-3">
|
||||||
|
{/* Project Pill */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-3 px-4 py-2 rounded-full cursor-pointer',
|
||||||
|
'bg-white/5 backdrop-blur-md border border-white/10',
|
||||||
|
'hover:bg-white/10 transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.4)] animate-pulse" />
|
||||||
|
<span className="font-mono text-sm font-medium tracking-tight">
|
||||||
|
{currentProject.name}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56 glass border-white/10" align="start">
|
||||||
|
<DropdownMenuLabel>Switch Project</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{projects.slice(0, 5).map((p) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setCurrentProject(p)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={onOpenProjectPicker}>
|
||||||
|
<Command className="mr-2 w-3 h-3" />
|
||||||
|
All Projects...
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onOpenFolder}>
|
||||||
|
<Folder className="mr-2 w-3 h-3" />
|
||||||
|
Open Local Folder...
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Dynamic Status / Breadcrumbs could go here */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/ui/src/components/layout/noise-overlay.tsx
Normal file
17
apps/ui/src/components/layout/noise-overlay.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function NoiseOverlay() {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 pointer-events-none opacity-[0.015] mix-blend-overlay">
|
||||||
|
<svg className="w-full h-full">
|
||||||
|
<filter id="noiseFilter">
|
||||||
|
<feTurbulence
|
||||||
|
type="fractalNoise"
|
||||||
|
baseFrequency="0.80"
|
||||||
|
numOctaves="3"
|
||||||
|
stitchTiles="stitch"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/ui/src/components/layout/page-shell.tsx
Normal file
30
apps/ui/src/components/layout/page-shell.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface PageShellProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageShell({ children, className, fullWidth = false }: PageShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full pt-16 pb-24 px-6 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.2, 0, 0, 1] }}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full rounded-3xl overflow-hidden',
|
||||||
|
'bg-black/20 backdrop-blur-2xl border border-white/5 shadow-2xl',
|
||||||
|
'flex flex-col',
|
||||||
|
!fullWidth && 'max-w-7xl mx-auto',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/ui/src/components/layout/prism-field.tsx
Normal file
69
apps/ui/src/components/layout/prism-field.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function PrismField() {
|
||||||
|
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
setMousePosition({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[#0b101a]">
|
||||||
|
{/* Deep Space Base */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(17,24,39,1)_0%,rgba(11,16,26,1)_100%)]" />
|
||||||
|
|
||||||
|
{/* Animated Orbs */}
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
x: mousePosition.x * 0.02,
|
||||||
|
y: mousePosition.y * 0.02,
|
||||||
|
}}
|
||||||
|
transition={{ type: 'spring', damping: 50, stiffness: 400 }}
|
||||||
|
className="absolute top-[-20%] left-[-10%] w-[70vw] h-[70vw] rounded-full bg-cyan-500/5 blur-[120px] mix-blend-screen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
x: mousePosition.x * -0.03,
|
||||||
|
y: mousePosition.y * -0.03,
|
||||||
|
}}
|
||||||
|
transition={{ type: 'spring', damping: 50, stiffness: 400 }}
|
||||||
|
className="absolute bottom-[-20%] right-[-10%] w-[60vw] h-[60vw] rounded-full bg-violet-600/5 blur-[120px] mix-blend-screen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.1, 1],
|
||||||
|
opacity: [0.3, 0.5, 0.3],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 8,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
className="absolute top-[30%] left-[50%] transform -translate-x-1/2 -translate-y-1/2 w-[40vw] h-[40vw] rounded-full bg-blue-500/5 blur-[100px] mix-blend-screen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Grid Overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10 opacity-[0.03]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '50px 50px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vignette */}
|
||||||
|
<div className="absolute inset-0 z-20 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(11,16,26,0.8)_100%)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/ui/src/components/layout/shell.tsx
Normal file
32
apps/ui/src/components/layout/shell.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { PrismField } from './prism-field';
|
||||||
|
import { NoiseOverlay } from './noise-overlay';
|
||||||
|
|
||||||
|
interface ShellProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
showBackgroundElements?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Shell({ children, className, showBackgroundElements = true }: ShellProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative min-h-screen w-full overflow-hidden bg-background text-foreground transition-colors duration-500',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Animated Background Layers */}
|
||||||
|
{showBackgroundElements && (
|
||||||
|
<>
|
||||||
|
<PrismField />
|
||||||
|
<NoiseOverlay />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content wrapper */}
|
||||||
|
<div className="relative z-10 flex h-screen flex-col">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
ProjectActions,
|
ProjectActions,
|
||||||
SidebarNavigation,
|
SidebarNavigation,
|
||||||
ProjectSelectorWithOptions,
|
ProjectSelectorWithOptions,
|
||||||
SidebarFooter,
|
|
||||||
} from './sidebar/components';
|
} from './sidebar/components';
|
||||||
|
import { Hud } from './hud';
|
||||||
|
import { FloatingDock } from './floating-dock';
|
||||||
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
||||||
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
||||||
import {
|
import {
|
||||||
@@ -247,64 +248,27 @@ export function Sidebar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
className={cn(
|
{/* Heads-Up Display (Top Bar) */}
|
||||||
'flex-shrink-0 flex flex-col z-30 relative',
|
<Hud
|
||||||
// Glass morphism background with gradient
|
onOpenProjectPicker={() => setIsProjectPickerOpen(true)}
|
||||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
onOpenFolder={handleOpenFolder}
|
||||||
// Premium border with subtle glow
|
|
||||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
|
||||||
// Smooth width transition
|
|
||||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
|
||||||
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
|
|
||||||
)}
|
|
||||||
data-testid="sidebar"
|
|
||||||
>
|
|
||||||
<CollapseToggleButton
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
toggleSidebar={toggleSidebar}
|
|
||||||
shortcut={shortcuts.toggleSidebar}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
{/* Floating Navigation Dock */}
|
||||||
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
|
<FloatingDock />
|
||||||
|
|
||||||
{/* Project Actions - Moved above project selector */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<ProjectActions
|
|
||||||
setShowNewProjectModal={setShowNewProjectModal}
|
|
||||||
handleOpenFolder={handleOpenFolder}
|
|
||||||
setShowTrashDialog={setShowTrashDialog}
|
|
||||||
trashedProjects={trashedProjects}
|
|
||||||
shortcuts={{ openProject: shortcuts.openProject }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* Project Selector Dialog (Hidden logic, controlled by state) */}
|
||||||
|
<div className="hidden">
|
||||||
<ProjectSelectorWithOptions
|
<ProjectSelectorWithOptions
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={true}
|
||||||
isProjectPickerOpen={isProjectPickerOpen}
|
isProjectPickerOpen={isProjectPickerOpen}
|
||||||
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
||||||
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SidebarNavigation
|
|
||||||
currentProject={currentProject}
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
navSections={navSections}
|
|
||||||
isActiveRoute={isActiveRoute}
|
|
||||||
navigate={navigate}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarFooter
|
{/* Dialogs & Modals - Preservation of Logic */}
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
isActiveRoute={isActiveRoute}
|
|
||||||
navigate={navigate}
|
|
||||||
hideWiki={hideWiki}
|
|
||||||
hideRunningAgents={hideRunningAgents}
|
|
||||||
runningAgentsCount={runningAgentsCount}
|
|
||||||
shortcuts={{ settings: shortcuts.settings }}
|
|
||||||
/>
|
|
||||||
<TrashDialog
|
<TrashDialog
|
||||||
open={showTrashDialog}
|
open={showTrashDialog}
|
||||||
onOpenChange={setShowTrashDialog}
|
onOpenChange={setShowTrashDialog}
|
||||||
@@ -317,7 +281,6 @@ export function Sidebar() {
|
|||||||
isEmptyingTrash={isEmptyingTrash}
|
isEmptyingTrash={isEmptyingTrash}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* New Project Setup Dialog */}
|
|
||||||
<CreateSpecDialog
|
<CreateSpecDialog
|
||||||
open={showSetupDialog}
|
open={showSetupDialog}
|
||||||
onOpenChange={setShowSetupDialog}
|
onOpenChange={setShowSetupDialog}
|
||||||
@@ -345,7 +308,6 @@ export function Sidebar() {
|
|||||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
onGenerateSpec={handleOnboardingGenerateSpec}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Project Confirmation Dialog */}
|
|
||||||
<DeleteProjectDialog
|
<DeleteProjectDialog
|
||||||
open={showDeleteProjectDialog}
|
open={showDeleteProjectDialog}
|
||||||
onOpenChange={setShowDeleteProjectDialog}
|
onOpenChange={setShowDeleteProjectDialog}
|
||||||
@@ -353,7 +315,6 @@ export function Sidebar() {
|
|||||||
onConfirm={moveProjectToTrash}
|
onConfirm={moveProjectToTrash}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* New Project Modal */}
|
|
||||||
<NewProjectModal
|
<NewProjectModal
|
||||||
open={showNewProjectModal}
|
open={showNewProjectModal}
|
||||||
onOpenChange={setShowNewProjectModal}
|
onOpenChange={setShowNewProjectModal}
|
||||||
@@ -362,6 +323,6 @@ export function Sidebar() {
|
|||||||
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
||||||
isCreating={isCreatingProject}
|
isCreating={isCreatingProject}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ const badgeVariants = cva(
|
|||||||
// Muted variants for subtle indication
|
// Muted variants for subtle indication
|
||||||
muted: 'border-border/50 bg-muted/50 text-muted-foreground',
|
muted: 'border-border/50 bg-muted/50 text-muted-foreground',
|
||||||
brand: 'border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30',
|
brand: 'border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30',
|
||||||
|
// Prism variants
|
||||||
|
prism:
|
||||||
|
'border-cyan-500/30 bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 font-mono tracking-wide rounded-md',
|
||||||
|
'prism-orange':
|
||||||
|
'border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 font-mono tracking-wide rounded-md',
|
||||||
|
'prism-green':
|
||||||
|
'border-emerald-500/30 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 font-mono tracking-wide rounded-md',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'px-2.5 py-0.5 text-xs',
|
default: 'px-2.5 py-0.5 text-xs',
|
||||||
|
|||||||
@@ -6,25 +6,32 @@ import { Loader2 } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-300 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25',
|
'bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90 hover:shadow-primary/40 hover:-translate-y-0.5',
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
'bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
outline:
|
outline:
|
||||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
'border border-border/50 bg-background/50 backdrop-blur-sm shadow-sm hover:bg-accent hover:text-accent-foreground dark:bg-white/5 dark:hover:bg-white/10 hover:border-accent',
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
secondary:
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 hover:shadow-md',
|
||||||
|
ghost: 'hover:bg-accent/50 hover:text-accent-foreground hover:backdrop-blur-sm',
|
||||||
link: 'text-primary underline-offset-4 hover:underline active:scale-100',
|
link: 'text-primary underline-offset-4 hover:underline active:scale-100',
|
||||||
|
glass:
|
||||||
|
'border border-white/10 bg-white/5 text-foreground shadow-sm drop-shadow-sm backdrop-blur-md hover:bg-white/10 hover:border-white/20 hover:shadow-md transition-all duration-300',
|
||||||
'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none',
|
'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none',
|
||||||
|
'prism-primary':
|
||||||
|
'bg-cyan-400 text-slate-950 font-extrabold shadow-lg shadow-cyan-400/20 hover:brightness-110 hover:shadow-cyan-400/40 transition-all duration-200 tracking-wide',
|
||||||
|
'prism-glass':
|
||||||
|
'glass hover:bg-white/10 text-xs font-bold rounded-xl transition-all duration-200',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-xs',
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
lg: 'h-11 rounded-md px-8 has-[>svg]:px-5 text-base',
|
||||||
icon: 'size-9',
|
icon: 'size-9',
|
||||||
'icon-sm': 'size-8',
|
'icon-sm': 'size-8',
|
||||||
'icon-lg': 'size-10',
|
'icon-lg': 'size-10',
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ function Card({ className, gradient = false, ...props }: CardProps) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6',
|
'bg-white/5 text-card-foreground flex flex-col gap-1 rounded-[1.5rem] border border-white/10 backdrop-blur-xl py-6 transition-all duration-300',
|
||||||
// Premium layered shadow
|
// Prism hover effect
|
||||||
'shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]',
|
'hover:-translate-y-1 hover:bg-white/[0.06] hover:border-white/15',
|
||||||
// Gradient border option
|
// Gradient border option
|
||||||
gradient &&
|
gradient &&
|
||||||
'relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10',
|
'relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10',
|
||||||
|
|||||||
@@ -66,10 +66,10 @@ function DialogOverlay({
|
|||||||
<DialogOverlayPrimitive
|
<DialogOverlayPrimitive
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
'fixed inset-0 z-50 bg-black/40 backdrop-blur-md',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
'duration-200',
|
'duration-300',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -99,15 +99,15 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
|||||||
className={cn(
|
className={cn(
|
||||||
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
||||||
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
|
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
|
||||||
'bg-card border border-border rounded-xl shadow-2xl',
|
'bg-card/90 border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl',
|
||||||
// Premium shadow
|
// Premium shadow
|
||||||
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',
|
'shadow-[0_40px_80px_-12px_rgba(0,0,0,0.5)]',
|
||||||
// Animations - smoother with scale
|
// Animations - smoother with scale
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
|
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
|
||||||
'duration-200',
|
'duration-300 ease-out',
|
||||||
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6',
|
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-white/10 bg-popover/80 p-1 text-popover-foreground shadow-xl backdrop-blur-xl',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -15,17 +15,21 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
'file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
'file:text-foreground placeholder:text-muted-foreground/50 selection:bg-cyan-500/30 selection:text-cyan-100',
|
||||||
// Inner shadow for depth
|
'bg-white/5 border-white/10 h-9 w-full min-w-0 rounded-xl border px-3 py-1 text-sm shadow-sm outline-none transition-all duration-200',
|
||||||
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
|
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||||
// Animated focus ring
|
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
'transition-[color,box-shadow,border-color] duration-200 ease-out',
|
'backdrop-blur-sm',
|
||||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
// Hover state
|
||||||
|
'hover:bg-white/10 hover:border-white/20',
|
||||||
|
// Focus state with ring
|
||||||
|
'focus:bg-white/10 focus:border-cyan-500/50',
|
||||||
|
'focus-visible:border-cyan-500/50 focus-visible:ring-cyan-500/20 focus-visible:ring-[4px]',
|
||||||
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
||||||
// Adjust padding for addons
|
// Adjust padding for addons
|
||||||
startAddon && 'pl-0',
|
startAddon && 'pl-0',
|
||||||
endAddon && 'pr-0',
|
endAddon && 'pr-0',
|
||||||
hasAddons && 'border-0 shadow-none focus-visible:ring-0',
|
hasAddons && 'border-0 shadow-none focus-visible:ring-0 bg-transparent',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,10 +43,10 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs',
|
'flex items-center h-9 w-full rounded-lg border border-input/50 bg-input/50 shadow-xs backdrop-blur-sm transition-all duration-300',
|
||||||
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
|
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
|
||||||
'transition-[box-shadow,border-color] duration-200 ease-out',
|
'focus-within:bg-input/80 focus-within:border-ring/50',
|
||||||
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',
|
'focus-within:border-ring focus-within:ring-ring/20 focus-within:ring-[4px]',
|
||||||
'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed',
|
'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed',
|
||||||
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive'
|
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(({ className, ...p
|
|||||||
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10 cursor-pointer">
|
||||||
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
|
<SliderRangePrimitive className="slider-range absolute h-full bg-cyan-400" />
|
||||||
</SliderTrackPrimitive>
|
</SliderTrackPrimitive>
|
||||||
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-cyan-400/50 bg-background shadow-none transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400 disabled:pointer-events-none disabled:opacity-50 hover:bg-cyan-950/30 hover:border-cyan-400" />
|
||||||
</SliderRootPrimitive>
|
</SliderRootPrimitive>
|
||||||
));
|
));
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-cyan-500 data-[state=unchecked]:bg-white/10',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ import { RefreshCw } from 'lucide-react';
|
|||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { useWindowState } from '@/hooks/use-window-state';
|
import { useWindowState } from '@/hooks/use-window-state';
|
||||||
|
import { PageShell } from '@/components/layout/page-shell';
|
||||||
// Board-view specific imports
|
// Board-view specific imports
|
||||||
import { BoardHeader } from './board-view/board-header';
|
import { BoardHeader } from './board-view/board-header';
|
||||||
import { BoardSearchBar } from './board-view/board-search-bar';
|
import { BoardSearchBar } from './board-view/board-search-bar';
|
||||||
import { BoardControls } from './board-view/board-controls';
|
import { BoardControls } from './board-view/board-controls';
|
||||||
import { KanbanBoard } from './board-view/kanban-board';
|
import { KanbanBoard } from './board-view/kanban-board';
|
||||||
|
import { GraphView } from './graph-view';
|
||||||
import {
|
import {
|
||||||
AddFeatureDialog,
|
AddFeatureDialog,
|
||||||
AgentOutputModal,
|
AgentOutputModal,
|
||||||
@@ -69,6 +71,8 @@ export function BoardView() {
|
|||||||
aiProfiles,
|
aiProfiles,
|
||||||
kanbanCardDetailLevel,
|
kanbanCardDetailLevel,
|
||||||
setKanbanCardDetailLevel,
|
setKanbanCardDetailLevel,
|
||||||
|
boardViewMode,
|
||||||
|
setBoardViewMode,
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
pendingPlanApproval,
|
pendingPlanApproval,
|
||||||
@@ -989,40 +993,54 @@ export function BoardView() {
|
|||||||
completedCount={completedFeatures.length}
|
completedCount={completedFeatures.length}
|
||||||
kanbanCardDetailLevel={kanbanCardDetailLevel}
|
kanbanCardDetailLevel={kanbanCardDetailLevel}
|
||||||
onDetailLevelChange={setKanbanCardDetailLevel}
|
onDetailLevelChange={setKanbanCardDetailLevel}
|
||||||
|
boardViewMode={boardViewMode}
|
||||||
|
onBoardViewModeChange={setBoardViewMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Kanban Columns */}
|
{/* View Content - Kanban or Graph */}
|
||||||
<KanbanBoard
|
{boardViewMode === 'kanban' ? (
|
||||||
sensors={sensors}
|
<KanbanBoard
|
||||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
sensors={sensors}
|
||||||
onDragStart={handleDragStart}
|
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||||
onDragEnd={handleDragEnd}
|
onDragStart={handleDragStart}
|
||||||
activeFeature={activeFeature}
|
onDragEnd={handleDragEnd}
|
||||||
getColumnFeatures={getColumnFeatures}
|
activeFeature={activeFeature}
|
||||||
backgroundImageStyle={backgroundImageStyle}
|
getColumnFeatures={getColumnFeatures}
|
||||||
backgroundSettings={backgroundSettings}
|
backgroundImageStyle={backgroundImageStyle}
|
||||||
onEdit={(feature) => setEditingFeature(feature)}
|
backgroundSettings={backgroundSettings}
|
||||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
onEdit={(feature) => setEditingFeature(feature)}
|
||||||
onViewOutput={handleViewOutput}
|
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||||
onVerify={handleVerifyFeature}
|
onViewOutput={handleViewOutput}
|
||||||
onResume={handleResumeFeature}
|
onVerify={handleVerifyFeature}
|
||||||
onForceStop={handleForceStopFeature}
|
onResume={handleResumeFeature}
|
||||||
onManualVerify={handleManualVerify}
|
onForceStop={handleForceStopFeature}
|
||||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
onManualVerify={handleManualVerify}
|
||||||
onFollowUp={handleOpenFollowUp}
|
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||||
onCommit={handleCommitFeature}
|
onFollowUp={handleOpenFollowUp}
|
||||||
onComplete={handleCompleteFeature}
|
onCommit={handleCommitFeature}
|
||||||
onImplement={handleStartImplementation}
|
onComplete={handleCompleteFeature}
|
||||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
onImplement={handleStartImplementation}
|
||||||
onApprovePlan={handleOpenApprovalDialog}
|
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||||
featuresWithContext={featuresWithContext}
|
onApprovePlan={handleOpenApprovalDialog}
|
||||||
runningAutoTasks={runningAutoTasks}
|
featuresWithContext={featuresWithContext}
|
||||||
shortcuts={shortcuts}
|
runningAutoTasks={runningAutoTasks}
|
||||||
onStartNextFeatures={handleStartNextFeatures}
|
shortcuts={shortcuts}
|
||||||
onShowSuggestions={() => setShowSuggestionsDialog(true)}
|
onStartNextFeatures={handleStartNextFeatures}
|
||||||
suggestionsCount={suggestionsCount}
|
onShowSuggestions={() => setShowSuggestionsDialog(true)}
|
||||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
suggestionsCount={suggestionsCount}
|
||||||
/>
|
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GraphView
|
||||||
|
features={hookFeatures}
|
||||||
|
runningAutoTasks={runningAutoTasks}
|
||||||
|
currentWorktreePath={currentWorktreePath}
|
||||||
|
currentWorktreeBranch={currentWorktreeBranch}
|
||||||
|
projectPath={currentProject?.path || null}
|
||||||
|
onEditFeature={(feature) => setEditingFeature(feature)}
|
||||||
|
onViewOutput={handleViewOutput}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Board Background Modal */}
|
{/* Board Background Modal */}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from 'lucide-react';
|
import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { BoardViewMode } from '@/store/app-store';
|
||||||
|
|
||||||
interface BoardControlsProps {
|
interface BoardControlsProps {
|
||||||
isMounted: boolean;
|
isMounted: boolean;
|
||||||
@@ -10,6 +11,8 @@ interface BoardControlsProps {
|
|||||||
completedCount: number;
|
completedCount: number;
|
||||||
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
|
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
|
||||||
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
|
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
|
||||||
|
boardViewMode: BoardViewMode;
|
||||||
|
onBoardViewModeChange: (mode: BoardViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardControls({
|
export function BoardControls({
|
||||||
@@ -19,12 +22,59 @@ export function BoardControls({
|
|||||||
completedCount,
|
completedCount,
|
||||||
kanbanCardDetailLevel,
|
kanbanCardDetailLevel,
|
||||||
onDetailLevelChange,
|
onDetailLevelChange,
|
||||||
|
boardViewMode,
|
||||||
|
onBoardViewModeChange,
|
||||||
}: BoardControlsProps) {
|
}: BoardControlsProps) {
|
||||||
if (!isMounted) return null;
|
if (!isMounted) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{/* View Mode Toggle - Kanban / Graph */}
|
||||||
|
<div
|
||||||
|
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||||
|
data-testid="view-mode-toggle"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onBoardViewModeChange('kanban')}
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-l-lg transition-colors',
|
||||||
|
boardViewMode === 'kanban'
|
||||||
|
? 'bg-brand-500/20 text-brand-500'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
data-testid="view-mode-kanban"
|
||||||
|
>
|
||||||
|
<Columns3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Kanban Board View</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onBoardViewModeChange('graph')}
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-r-lg transition-colors',
|
||||||
|
boardViewMode === 'graph'
|
||||||
|
? 'bg-brand-500/20 text-brand-500'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
data-testid="view-mode-graph"
|
||||||
|
>
|
||||||
|
<Network className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Dependency Graph View</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Board Background Button */}
|
{/* Board Background Button */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -39,23 +40,20 @@ export function BoardHeader({
|
|||||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
<header className="h-16 flex items-center justify-between px-8 border-b border-white/5 bg-[#0b101a]/40 backdrop-blur-md z-20 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
<h2 className="text-lg font-bold text-white tracking-tight">Kanban Board</h2>
|
||||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
<p className="text-[10px] text-slate-500 uppercase tracking-[0.2em] font-bold mono">
|
||||||
|
{projectName}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
{/* Usage Popover - only show for CLI users (not API key users) */}
|
|
||||||
{isMounted && showUsageTracking && <ClaudeUsagePopover />}
|
|
||||||
|
|
||||||
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
<div className="flex items-center gap-5">
|
||||||
|
{/* Concurrency/Agent Control - Styled as Toggle for visual matching, but keeps slider logic if needed or simplified */}
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<div
|
<div className="flex items-center bg-white/5 border border-white/10 rounded-full px-4 py-1.5 gap-3">
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
|
<Bot className="w-4 h-4 text-slate-500" />
|
||||||
data-testid="concurrency-slider-container"
|
{/* We keep the slider for functionality, but could style it to look like the toggle or just use the slider cleanly */}
|
||||||
>
|
|
||||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium">Agents</span>
|
|
||||||
<Slider
|
<Slider
|
||||||
value={[maxConcurrency]}
|
value={[maxConcurrency]}
|
||||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||||
@@ -63,43 +61,43 @@ export function BoardHeader({
|
|||||||
max={10}
|
max={10}
|
||||||
step={1}
|
step={1}
|
||||||
className="w-20"
|
className="w-20"
|
||||||
data-testid="concurrency-slider"
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span className="mono text-xs font-bold text-slate-400">
|
||||||
className="text-sm text-muted-foreground min-w-[5ch] text-center"
|
|
||||||
data-testid="concurrency-value"
|
|
||||||
>
|
|
||||||
{runningAgentsCount} / {maxConcurrency}
|
{runningAgentsCount} / {maxConcurrency}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
{/* Auto Mode Button */}
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
|
<button
|
||||||
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||||
Auto Mode
|
className={cn(
|
||||||
</Label>
|
'flex items-center gap-2 px-5 py-2 rounded-xl text-xs font-bold transition',
|
||||||
<Switch
|
isAutoModeRunning
|
||||||
id="auto-mode-toggle"
|
? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20'
|
||||||
checked={isAutoModeRunning}
|
: 'glass hover:bg-white/10'
|
||||||
onCheckedChange={onAutoModeToggle}
|
)}
|
||||||
data-testid="auto-mode-toggle"
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
isAutoModeRunning ? 'bg-cyan-400 animate-pulse' : 'bg-slate-500'
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
Auto Mode
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HotkeyButton
|
{/* Add Feature Button */}
|
||||||
size="sm"
|
<button
|
||||||
onClick={onAddFeature}
|
onClick={onAddFeature}
|
||||||
hotkey={addFeatureShortcut}
|
className="btn-cyan px-6 py-2 rounded-xl text-xs font-black flex items-center gap-2 shadow-lg shadow-cyan-500/20"
|
||||||
hotkeyActive={false}
|
|
||||||
data-testid="add-feature-button"
|
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 stroke-[3.5px]" />
|
||||||
Add Feature
|
ADD FEATURE
|
||||||
</HotkeyButton>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface KanbanColumnProps {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
colorClass: string;
|
colorClass: string;
|
||||||
|
columnClass?: string;
|
||||||
count: number;
|
count: number;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
headerAction?: ReactNode;
|
headerAction?: ReactNode;
|
||||||
@@ -21,6 +22,7 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
colorClass,
|
colorClass,
|
||||||
|
columnClass,
|
||||||
count,
|
count,
|
||||||
children,
|
children,
|
||||||
headerAction,
|
headerAction,
|
||||||
@@ -43,7 +45,8 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
'transition-[box-shadow,ring] duration-200',
|
'transition-[box-shadow,ring] duration-200',
|
||||||
!width && 'w-72', // Only apply w-72 if no custom width
|
!width && 'w-72', // Only apply w-72 if no custom width
|
||||||
showBorder && 'border border-border/60',
|
showBorder && 'border border-border/60',
|
||||||
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background',
|
||||||
|
columnClass
|
||||||
)}
|
)}
|
||||||
style={widthStyle}
|
style={widthStyle}
|
||||||
data-testid={`kanban-column-${id}`}
|
data-testid={`kanban-column-${id}`}
|
||||||
|
|||||||
@@ -2,21 +2,25 @@ import { Feature } from '@/store/app-store';
|
|||||||
|
|
||||||
export type ColumnId = Feature['status'];
|
export type ColumnId = Feature['status'];
|
||||||
|
|
||||||
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
export const COLUMNS: { id: ColumnId; title: string; colorClass: string; columnClass?: string }[] =
|
||||||
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
|
[
|
||||||
{
|
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-white/20', columnClass: '' },
|
||||||
id: 'in_progress',
|
{
|
||||||
title: 'In Progress',
|
id: 'in_progress',
|
||||||
colorClass: 'bg-[var(--status-in-progress)]',
|
title: 'In Progress',
|
||||||
},
|
colorClass: 'bg-cyan-400',
|
||||||
{
|
columnClass: 'col-in-progress',
|
||||||
id: 'waiting_approval',
|
},
|
||||||
title: 'Waiting Approval',
|
{
|
||||||
colorClass: 'bg-[var(--status-waiting)]',
|
id: 'waiting_approval',
|
||||||
},
|
title: 'Waiting Approval',
|
||||||
{
|
colorClass: 'bg-amber-500',
|
||||||
id: 'verified',
|
columnClass: 'col-waiting',
|
||||||
title: 'Verified',
|
},
|
||||||
colorClass: 'bg-[var(--status-success)]',
|
{
|
||||||
},
|
id: 'verified',
|
||||||
];
|
title: 'Verified',
|
||||||
|
colorClass: 'bg-emerald-500',
|
||||||
|
columnClass: 'col-verified',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export function KanbanBoard({
|
|||||||
id={column.id}
|
id={column.id}
|
||||||
title={column.title}
|
title={column.title}
|
||||||
colorClass={column.colorClass}
|
colorClass={column.colorClass}
|
||||||
|
columnClass={column.columnClass}
|
||||||
count={columnFeatures.length}
|
count={columnFeatures.length}
|
||||||
width={columnWidth}
|
width={columnWidth}
|
||||||
opacity={backgroundSettings.columnOpacity}
|
opacity={backgroundSettings.columnOpacity}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { PlanningMode, PlanSpec, ParsedTask } from '@automaker/types';
|
import type { PlanSpec } from '@/store/app-store';
|
||||||
|
|
||||||
|
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export type { PlanningMode, ParsedTask, PlanSpec };
|
export type { ParsedTask, PlanSpec } from '@/store/app-store';
|
||||||
|
|
||||||
interface PlanningModeSelectorProps {
|
interface PlanningModeSelectorProps {
|
||||||
mode: PlanningMode;
|
mode: PlanningMode;
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { BaseEdge, getBezierPath, EdgeLabelRenderer } from '@xyflow/react';
|
||||||
|
import type { EdgeProps } from '@xyflow/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Feature } from '@/store/app-store';
|
||||||
|
|
||||||
|
export interface DependencyEdgeData {
|
||||||
|
sourceStatus: Feature['status'];
|
||||||
|
targetStatus: Feature['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
||||||
|
// If source is completed/verified, the dependency is satisfied
|
||||||
|
if (sourceStatus === 'completed' || sourceStatus === 'verified') {
|
||||||
|
return 'var(--status-success)';
|
||||||
|
}
|
||||||
|
// If target is in progress, show active color
|
||||||
|
if (targetStatus === 'in_progress') {
|
||||||
|
return 'var(--status-in-progress)';
|
||||||
|
}
|
||||||
|
// If target is blocked (in backlog with incomplete deps)
|
||||||
|
if (targetStatus === 'backlog') {
|
||||||
|
return 'var(--border)';
|
||||||
|
}
|
||||||
|
// Default
|
||||||
|
return 'var(--border)';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
data,
|
||||||
|
selected,
|
||||||
|
animated,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const edgeData = data as DependencyEdgeData | undefined;
|
||||||
|
|
||||||
|
const [edgePath, labelX, labelY] = getBezierPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourcePosition,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
targetPosition,
|
||||||
|
curvature: 0.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
const edgeColor = edgeData
|
||||||
|
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
|
||||||
|
: 'var(--border)';
|
||||||
|
|
||||||
|
const isCompleted = edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
||||||
|
const isInProgress = edgeData?.targetStatus === 'in_progress';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Background edge for better visibility */}
|
||||||
|
<BaseEdge
|
||||||
|
id={`${id}-bg`}
|
||||||
|
path={edgePath}
|
||||||
|
style={{
|
||||||
|
strokeWidth: 4,
|
||||||
|
stroke: 'var(--background)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main edge */}
|
||||||
|
<BaseEdge
|
||||||
|
id={id}
|
||||||
|
path={edgePath}
|
||||||
|
className={cn(
|
||||||
|
'transition-all duration-300',
|
||||||
|
animated && 'animated-edge',
|
||||||
|
isInProgress && 'edge-flowing'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
strokeWidth: selected ? 3 : 2,
|
||||||
|
stroke: edgeColor,
|
||||||
|
strokeDasharray: isCompleted ? 'none' : '5 5',
|
||||||
|
filter: selected ? 'drop-shadow(0 0 3px var(--brand-500))' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Animated particles for in-progress edges */}
|
||||||
|
{animated && (
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
className="edge-particle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
isInProgress
|
||||||
|
? 'bg-[var(--status-in-progress)] animate-ping'
|
||||||
|
: 'bg-brand-500 animate-pulse'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useReactFlow, Panel } from '@xyflow/react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
Maximize2,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
|
GitBranch,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowDown,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface GraphControlsProps {
|
||||||
|
isLocked: boolean;
|
||||||
|
onToggleLock: () => void;
|
||||||
|
onRunLayout: (direction: 'LR' | 'TB') => void;
|
||||||
|
layoutDirection: 'LR' | 'TB';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphControls({
|
||||||
|
isLocked,
|
||||||
|
onToggleLock,
|
||||||
|
onRunLayout,
|
||||||
|
layoutDirection,
|
||||||
|
}: GraphControlsProps) {
|
||||||
|
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel position="bottom-left" className="flex flex-col gap-2">
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg">
|
||||||
|
{/* Zoom controls */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => zoomIn({ duration: 200 })}
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Zoom In</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => zoomOut({ duration: 200 })}
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Zoom Out</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => fitView({ padding: 0.2, duration: 300 })}
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Fit View</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
|
||||||
|
{/* Layout controls */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 p-0',
|
||||||
|
layoutDirection === 'LR' && 'bg-brand-500/20 text-brand-500'
|
||||||
|
)}
|
||||||
|
onClick={() => onRunLayout('LR')}
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Horizontal Layout</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 p-0',
|
||||||
|
layoutDirection === 'TB' && 'bg-brand-500/20 text-brand-500'
|
||||||
|
)}
|
||||||
|
onClick={() => onRunLayout('TB')}
|
||||||
|
>
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Vertical Layout</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
|
||||||
|
{/* Lock toggle */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 p-0',
|
||||||
|
isLocked && 'bg-brand-500/20 text-brand-500'
|
||||||
|
)}
|
||||||
|
onClick={onToggleLock}
|
||||||
|
>
|
||||||
|
{isLocked ? (
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Unlock className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Panel } from '@xyflow/react';
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
CheckCircle2,
|
||||||
|
Lock,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const legendItems = [
|
||||||
|
{
|
||||||
|
icon: Clock,
|
||||||
|
label: 'Backlog',
|
||||||
|
colorClass: 'text-muted-foreground',
|
||||||
|
bgClass: 'bg-muted/50',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Play,
|
||||||
|
label: 'In Progress',
|
||||||
|
colorClass: 'text-[var(--status-in-progress)]',
|
||||||
|
bgClass: 'bg-[var(--status-in-progress)]/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Pause,
|
||||||
|
label: 'Waiting',
|
||||||
|
colorClass: 'text-[var(--status-waiting)]',
|
||||||
|
bgClass: 'bg-[var(--status-warning)]/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CheckCircle2,
|
||||||
|
label: 'Verified',
|
||||||
|
colorClass: 'text-[var(--status-success)]',
|
||||||
|
bgClass: 'bg-[var(--status-success)]/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Lock,
|
||||||
|
label: 'Blocked',
|
||||||
|
colorClass: 'text-orange-500',
|
||||||
|
bgClass: 'bg-orange-500/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: AlertCircle,
|
||||||
|
label: 'Error',
|
||||||
|
colorClass: 'text-[var(--status-error)]',
|
||||||
|
bgClass: 'bg-[var(--status-error)]/20',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GraphLegend() {
|
||||||
|
return (
|
||||||
|
<Panel position="bottom-right" className="pointer-events-none">
|
||||||
|
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto">
|
||||||
|
{legendItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<div key={item.label} className="flex items-center gap-1.5">
|
||||||
|
<div className={cn('p-1 rounded', item.bgClass)}>
|
||||||
|
<Icon className={cn('w-3 h-3', item.colorClass)} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user