fix/PRs from old main setup to apps structure (#185)

* fix(core): add task persistence, terminal handling, and HTTP 300 fixes

Consolidated bug fixes from PRs #168, #170, #171:

- Task persistence (#168): Scan worktrees for tasks on app restart
  to prevent loss of in-progress work and wasted API credits. Tasks
  in .worktrees/*/specs are now loaded and deduplicated with main.

- Terminal buttons (#170): Fix "Open Terminal" buttons silently
  failing on macOS by properly awaiting createTerminal() Promise.
  Added useTerminalHandler hook with loading states and error display.

- HTTP 300 errors (#171): Handle branch/tag name collisions that
  cause update failures. Added validation script to prevent conflicts
  before releases and user-friendly error messages with manual
  download links.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(platform): add path resolution, spaces handling, and XDG support

This commit consolidates multiple bug fixes from community PRs:

- PR #187: Path resolution fix - Update path detection to find apps/backend
  instead of legacy auto-claude directory after v2.7.2 restructure

- PR #182/#155: Python path spaces fix - Improve parsePythonCommand() to
  handle quoted paths and paths containing spaces without splitting

- PR #161: Ollama detection fix - Add new apps structure paths for
  ollama_model_detector.py script discovery

- PR #160: AppImage support - Add XDG Base Directory compliant paths for
  Linux sandboxed environments (AppImage, Flatpak, Snap). New files:
  - config-paths.ts: XDG path utilities
  - fs-utils.ts: Filesystem utilities with fallback support

- PR #159: gh CLI PATH fix - Add getAugmentedEnv() utility to include
  common binary locations (Homebrew, snap, local) in PATH for child
  processes. Fixes gh CLI not found when app launched from Finder/Dock.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address CodeRabbit/Cursor review comments on PR #185

Fixes from code review:
- http-client.ts: Use GITHUB_CONFIG instead of hardcoded owner in HTTP 300 error message
- validate-release.js: Fix substring matching bug in branch detection that could cause false positives (e.g., v2.7 matching v2.7.2)
- bump-version.js: Remove unnecessary try-catch wrapper (exec() already exits on failure)
- execution-handlers.ts: Capture original subtask status before mutation for accurate logging
- fs-utils.ts: Add error handling to safeWriteFile with proper logging

Dismissed as trivial/not applicable:
- config-paths.ts: Exhaustive switch check (over-engineering)
- env-utils.ts: PATH priority documentation (existing comments sufficient)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address additional CodeRabbit review comments (round 2)

Fixes from second round of code review:
- fs-utils.ts: Wrap test file cleanup in try-catch for Windows file locking
- fs-utils.ts: Add error handling to safeReadFile for consistency with safeWriteFile
- http-client.ts: Use GITHUB_CONFIG in fetchJson (missed in first round)
- validate-release.js: Exclude symbolic refs (origin/HEAD -> origin/main) from branch check
- python-detector.ts: Return cleanPath instead of pythonPath for empty input edge case

Dismissed as trivial/not applicable:
- execution-handlers.ts: Redundant checkSubtasksCompletion call (micro-optimization)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2025-12-23 13:33:11 +01:00
committed by GitHub
parent 05f5d3038b
commit ced2ad479f
25 changed files with 830 additions and 74 deletions
@@ -146,6 +146,11 @@ export class ChangelogService extends EventEmitter {
}
const possiblePaths = [
// New apps structure: from out/main -> apps/backend
path.resolve(__dirname, '..', '..', '..', 'backend'),
path.resolve(app.getAppPath(), '..', 'backend'),
path.resolve(process.cwd(), 'apps', 'backend'),
// Legacy paths for backwards compatibility
path.resolve(__dirname, '..', '..', '..', 'auto-claude'),
path.resolve(app.getAppPath(), '..', 'auto-claude'),
path.resolve(process.cwd(), 'auto-claude')
+126
View File
@@ -0,0 +1,126 @@
/**
* Configuration Paths Module
*
* Provides XDG Base Directory Specification compliant paths for storing
* application configuration and data. This is essential for AppImage,
* Flatpak, and Snap installations where the application runs in a
* sandboxed or immutable filesystem environment.
*
* XDG Base Directory Specification:
* - $XDG_CONFIG_HOME: User configuration (default: ~/.config)
* - $XDG_DATA_HOME: User data (default: ~/.local/share)
* - $XDG_CACHE_HOME: User cache (default: ~/.cache)
*
* @see https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
*/
import * as path from 'path';
import * as os from 'os';
const APP_NAME = 'auto-claude';
/**
* Get the XDG config home directory
* Uses $XDG_CONFIG_HOME if set, otherwise defaults to ~/.config
*/
export function getXdgConfigHome(): string {
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
}
/**
* Get the XDG data home directory
* Uses $XDG_DATA_HOME if set, otherwise defaults to ~/.local/share
*/
export function getXdgDataHome(): string {
return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
}
/**
* Get the XDG cache home directory
* Uses $XDG_CACHE_HOME if set, otherwise defaults to ~/.cache
*/
export function getXdgCacheHome(): string {
return process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
}
/**
* Get the application config directory
* Returns the XDG-compliant path for storing configuration files
*/
export function getAppConfigDir(): string {
return path.join(getXdgConfigHome(), APP_NAME);
}
/**
* Get the application data directory
* Returns the XDG-compliant path for storing application data
*/
export function getAppDataDir(): string {
return path.join(getXdgDataHome(), APP_NAME);
}
/**
* Get the application cache directory
* Returns the XDG-compliant path for storing cache files
*/
export function getAppCacheDir(): string {
return path.join(getXdgCacheHome(), APP_NAME);
}
/**
* Get the memories storage directory
* This is where graph databases are stored (previously ~/.auto-claude/memories)
*/
export function getMemoriesDir(): string {
// For compatibility, we still support the legacy path
const legacyPath = path.join(os.homedir(), '.auto-claude', 'memories');
// On Linux with XDG variables set (AppImage, Flatpak, Snap), use XDG path
if (process.platform === 'linux' && (process.env.XDG_DATA_HOME || process.env.APPIMAGE || process.env.SNAP || process.env.FLATPAK_ID)) {
return path.join(getXdgDataHome(), APP_NAME, 'memories');
}
// Default to legacy path for backwards compatibility
return legacyPath;
}
/**
* Get the graphs storage directory (alias for memories)
*/
export function getGraphsDir(): string {
return getMemoriesDir();
}
/**
* Check if running in an immutable filesystem environment
* (AppImage, Flatpak, Snap, etc.)
*/
export function isImmutableEnvironment(): boolean {
return !!(
process.env.APPIMAGE ||
process.env.SNAP ||
process.env.FLATPAK_ID
);
}
/**
* Get environment-appropriate path for a given type
* Handles the differences between regular installs and sandboxed environments
*
* @param type - The type of path needed: 'config', 'data', 'cache', 'memories'
* @returns The appropriate path for the current environment
*/
export function getAppPath(type: 'config' | 'data' | 'cache' | 'memories'): string {
switch (type) {
case 'config':
return getAppConfigDir();
case 'data':
return getAppDataDir();
case 'cache':
return getAppCacheDir();
case 'memories':
return getMemoriesDir();
default:
return getAppDataDir();
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Environment Utilities Module
*
* Provides utilities for managing environment variables for child processes.
* Particularly important for macOS where GUI apps don't inherit the full
* shell environment, causing issues with tools installed via Homebrew.
*
* Common issue: `gh` CLI installed via Homebrew is in /opt/homebrew/bin
* which isn't in PATH when the Electron app launches from Finder/Dock.
*/
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
/**
* Common binary directories that should be in PATH
* These are locations where commonly used tools are installed
*/
const COMMON_BIN_PATHS: Record<string, string[]> = {
darwin: [
'/opt/homebrew/bin', // Apple Silicon Homebrew
'/usr/local/bin', // Intel Homebrew / system
'/opt/homebrew/sbin', // Apple Silicon Homebrew sbin
'/usr/local/sbin', // Intel Homebrew sbin
],
linux: [
'/usr/local/bin',
'/snap/bin', // Snap packages
'~/.local/bin', // User-local binaries
],
win32: [
// Windows usually handles PATH better, but we can add common locations
'C:\\Program Files\\Git\\cmd',
'C:\\Program Files\\GitHub CLI',
],
};
/**
* Get augmented environment with additional PATH entries
*
* This ensures that tools installed in common locations (like Homebrew)
* are available to child processes even when the app is launched from
* Finder/Dock which doesn't inherit the full shell environment.
*
* @param additionalPaths - Optional array of additional paths to include
* @returns Environment object with augmented PATH
*/
export function getAugmentedEnv(additionalPaths?: string[]): Record<string, string> {
const env = { ...process.env } as Record<string, string>;
const platform = process.platform as 'darwin' | 'linux' | 'win32';
const pathSeparator = platform === 'win32' ? ';' : ':';
// Get platform-specific paths
const platformPaths = COMMON_BIN_PATHS[platform] || [];
// Expand home directory in paths
const homeDir = os.homedir();
const expandedPaths = platformPaths.map(p =>
p.startsWith('~') ? p.replace('~', homeDir) : p
);
// Collect paths to add (only if they exist and aren't already in PATH)
const currentPath = env.PATH || '';
const currentPathSet = new Set(currentPath.split(pathSeparator));
const pathsToAdd: string[] = [];
// Add platform-specific paths
for (const p of expandedPaths) {
if (!currentPathSet.has(p) && fs.existsSync(p)) {
pathsToAdd.push(p);
}
}
// Add user-requested additional paths
if (additionalPaths) {
for (const p of additionalPaths) {
const expanded = p.startsWith('~') ? p.replace('~', homeDir) : p;
if (!currentPathSet.has(expanded) && fs.existsSync(expanded)) {
pathsToAdd.push(expanded);
}
}
}
// Prepend new paths to PATH (prepend so they take priority)
if (pathsToAdd.length > 0) {
env.PATH = [...pathsToAdd, currentPath].filter(Boolean).join(pathSeparator);
}
return env;
}
/**
* Find the full path to an executable
*
* Searches PATH (including augmented paths) for the given command.
* Useful for finding tools like `gh`, `git`, `node`, etc.
*
* @param command - The command name to find (e.g., 'gh', 'git')
* @returns The full path to the executable, or null if not found
*/
export function findExecutable(command: string): string | null {
const env = getAugmentedEnv();
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const pathDirs = (env.PATH || '').split(pathSeparator);
// On Windows, also check with common extensions
const extensions = process.platform === 'win32'
? ['', '.exe', '.cmd', '.bat', '.ps1']
: [''];
for (const dir of pathDirs) {
for (const ext of extensions) {
const fullPath = path.join(dir, command + ext);
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
}
return null;
}
/**
* Check if a command is available (in PATH or common locations)
*
* @param command - The command name to check
* @returns true if the command is available
*/
export function isCommandAvailable(command: string): boolean {
return findExecutable(command) !== null;
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Filesystem Utilities Module
*
* Provides utility functions for filesystem operations with
* proper support for XDG Base Directory paths and sandboxed
* environments (AppImage, Flatpak, Snap).
*/
import * as fs from 'fs';
import * as path from 'path';
import { getAppPath, isImmutableEnvironment, getMemoriesDir } from './config-paths';
/**
* Ensure a directory exists, creating it if necessary
*
* @param dirPath - The path to the directory
* @returns true if directory exists or was created, false on error
*/
export function ensureDir(dirPath: string): boolean {
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
return true;
} catch (error) {
console.error(`[fs-utils] Failed to create directory ${dirPath}:`, error);
return false;
}
}
/**
* Ensure the application data directories exist
* Creates config, data, cache, and memories directories
*/
export function ensureAppDirectories(): void {
const dirs = [
getAppPath('config'),
getAppPath('data'),
getAppPath('cache'),
getMemoriesDir(),
];
for (const dir of dirs) {
ensureDir(dir);
}
}
/**
* Get a writable path for a file
* If the original path is not writable, falls back to XDG data directory
*
* @param originalPath - The preferred path for the file
* @param filename - The filename (used for fallback path)
* @returns A writable path for the file
*/
export function getWritablePath(originalPath: string, filename: string): string {
// Check if we can write to the original path
const dir = path.dirname(originalPath);
try {
if (fs.existsSync(dir)) {
// Try to write a test file
const testFile = path.join(dir, `.write-test-${Date.now()}`);
fs.writeFileSync(testFile, '');
// Cleanup test file - ignore errors (e.g., file locked on Windows)
try { fs.unlinkSync(testFile); } catch { /* ignore cleanup failure */ }
return originalPath;
} else {
// Try to create the directory
fs.mkdirSync(dir, { recursive: true });
return originalPath;
}
} catch {
// Fall back to XDG data directory
if (isImmutableEnvironment()) {
const fallbackDir = getAppPath('data');
ensureDir(fallbackDir);
console.warn(`[fs-utils] Falling back to XDG path for ${filename}: ${fallbackDir}`);
return path.join(fallbackDir, filename);
}
// Non-immutable environment - just return original and let caller handle error
return originalPath;
}
}
/**
* Safe write file that handles immutable filesystems
* Falls back to XDG paths if the target is not writable
*
* @param filePath - The target file path
* @param content - The content to write
* @returns The actual path where the file was written
* @throws Error if write fails (with context about the attempted path)
*/
export function safeWriteFile(filePath: string, content: string): string {
const filename = path.basename(filePath);
const writablePath = getWritablePath(filePath, filename);
try {
fs.writeFileSync(writablePath, content, 'utf-8');
return writablePath;
} catch (error) {
console.error(`[fs-utils] Failed to write file ${writablePath}:`, error);
throw error;
}
}
/**
* Read a file, checking both original and XDG fallback locations
*
* @param originalPath - The expected file path
* @returns The file content or null if not found or on error
*/
export function safeReadFile(originalPath: string): string | null {
// Try original path first
try {
if (fs.existsSync(originalPath)) {
return fs.readFileSync(originalPath, 'utf-8');
}
} catch (error) {
console.error(`[fs-utils] Failed to read file ${originalPath}:`, error);
// Fall through to try XDG fallback
}
// Try XDG fallback path
if (isImmutableEnvironment()) {
const filename = path.basename(originalPath);
const fallbackPath = path.join(getAppPath('data'), filename);
try {
if (fs.existsSync(fallbackPath)) {
return fs.readFileSync(fallbackPath, 'utf-8');
}
} catch (error) {
console.error(`[fs-utils] Failed to read fallback file ${fallbackPath}:`, error);
}
}
return null;
}
@@ -41,6 +41,11 @@ export class InsightsConfig {
}
const possiblePaths = [
// New apps structure: from out/main -> apps/backend
path.resolve(__dirname, '..', '..', '..', 'backend'),
path.resolve(app.getAppPath(), '..', 'backend'),
path.resolve(process.cwd(), 'apps', 'backend'),
// Legacy paths for backwards compatibility
path.resolve(__dirname, '..', '..', '..', 'auto-claude'),
path.resolve(app.getAppPath(), '..', 'auto-claude'),
path.resolve(process.cwd(), 'auto-claude')
@@ -7,6 +7,7 @@ import { ipcMain, shell } from 'electron';
import { execSync, execFileSync, spawn } from 'child_process';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult } from '../../../shared/types';
import { getAugmentedEnv, findExecutable } from '../../env-utils';
// Debug logging helper
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
@@ -100,6 +101,7 @@ function parseDeviceFlowOutput(stdout: string, stderr: string): DeviceFlowInfo {
/**
* Check if gh CLI is installed
* Uses augmented PATH to find gh CLI in common locations (e.g., Homebrew on macOS)
*/
export function registerCheckGhCli(): void {
ipcMain.handle(
@@ -107,15 +109,24 @@ export function registerCheckGhCli(): void {
async (): Promise<IPCResult<{ installed: boolean; version?: string }>> => {
debugLog('checkGitHubCli handler called');
try {
const checkCmd = process.platform === 'win32' ? 'where gh' : 'which gh';
debugLog(`Running command: ${checkCmd}`);
// Use findExecutable to check common locations including Homebrew paths
const ghPath = findExecutable('gh');
if (!ghPath) {
debugLog('gh CLI not found in PATH or common locations');
return {
success: true,
data: { installed: false }
};
}
debugLog('gh CLI found at:', ghPath);
const whichResult = execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' });
debugLog('gh CLI found at:', whichResult.trim());
// Get version
// Get version using augmented environment
debugLog('Getting gh version...');
const versionOutput = execSync('gh --version', { encoding: 'utf-8', stdio: 'pipe' });
const versionOutput = execSync('gh --version', {
encoding: 'utf-8',
stdio: 'pipe',
env: getAugmentedEnv()
});
const version = versionOutput.trim().split('\n')[0];
debugLog('gh version:', version);
@@ -136,16 +147,18 @@ export function registerCheckGhCli(): void {
/**
* Check if user is authenticated with gh CLI
* Uses augmented PATH to find gh CLI in common locations (e.g., Homebrew on macOS)
*/
export function registerCheckGhAuth(): void {
ipcMain.handle(
IPC_CHANNELS.GITHUB_CHECK_AUTH,
async (): Promise<IPCResult<{ authenticated: boolean; username?: string }>> => {
debugLog('checkGitHubAuth handler called');
const env = getAugmentedEnv();
try {
// Check auth status
debugLog('Running: gh auth status');
const authStatus = execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe' });
const authStatus = execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe', env });
debugLog('Auth status output:', authStatus);
// Get username if authenticated
@@ -153,7 +166,8 @@ export function registerCheckGhAuth(): void {
debugLog('Getting username via: gh api user --jq .login');
const username = execSync('gh api user --jq .login', {
encoding: 'utf-8',
stdio: 'pipe'
stdio: 'pipe',
env
}).trim();
debugLog('Username:', username);
@@ -8,15 +8,18 @@ import path from 'path';
import type { Project } from '../../../shared/types';
import { parseEnvFile } from '../utils';
import type { GitHubConfig } from './types';
import { getAugmentedEnv } from '../../env-utils';
/**
* Get GitHub token from gh CLI if available
* Uses augmented PATH to find gh CLI in common locations (e.g., Homebrew on macOS)
*/
function getTokenFromGhCli(): string | null {
try {
const token = execSync('gh auth token', {
encoding: 'utf-8',
stdio: 'pipe'
stdio: 'pipe',
env: getAugmentedEnv()
}).trim();
return token || null;
} catch {
@@ -539,6 +539,10 @@ export function registerMemoryHandlers(): void {
// Find the ollama_model_detector.py script
const possiblePaths = [
// New apps structure
path.resolve(__dirname, '..', '..', '..', '..', 'backend', 'ollama_model_detector.py'),
path.resolve(process.cwd(), 'apps', 'backend', 'ollama_model_detector.py'),
// Legacy paths for backwards compatibility
path.resolve(__dirname, '..', '..', '..', 'auto-claude', 'ollama_model_detector.py'),
path.resolve(process.cwd(), 'auto-claude', 'ollama_model_detector.py'),
path.resolve(process.cwd(), '..', 'auto-claude', 'ollama_model_detector.py'),
@@ -23,13 +23,16 @@ const detectAutoBuildSourcePath = (): string | null => {
// Development mode paths
if (is.dev) {
// In dev, __dirname is typically auto-claude-ui/out/main
// We need to go up to the project root to find auto-claude/
// In dev, __dirname is typically apps/frontend/out/main
// We need to go up to find apps/backend
possiblePaths.push(
path.resolve(__dirname, '..', '..', '..', 'auto-claude'), // From out/main up 3 levels
path.resolve(__dirname, '..', '..', 'auto-claude'), // From out/main up 2 levels
path.resolve(process.cwd(), 'auto-claude'), // From cwd (project root)
path.resolve(process.cwd(), '..', 'auto-claude') // From cwd parent (if running from auto-claude-ui/)
path.resolve(__dirname, '..', '..', '..', 'backend'), // From out/main -> apps/backend
path.resolve(process.cwd(), 'apps', 'backend'), // From cwd (repo root)
// Legacy paths for backwards compatibility
path.resolve(__dirname, '..', '..', '..', 'auto-claude'), // Legacy: from out/main up 3 levels
path.resolve(__dirname, '..', '..', 'auto-claude'), // Legacy: from out/main up 2 levels
path.resolve(process.cwd(), 'auto-claude'), // Legacy: from cwd (project root)
path.resolve(process.cwd(), '..', 'auto-claude') // Legacy: from cwd parent
);
} else {
// Production mode paths (packaged app)
@@ -37,6 +40,9 @@ const detectAutoBuildSourcePath = (): string | null => {
// We check common locations relative to the app bundle
const appPath = app.getAppPath();
possiblePaths.push(
path.resolve(appPath, '..', 'backend'), // Sibling to app (new structure)
path.resolve(appPath, '..', '..', 'backend'), // Up 2 from app
// Legacy paths for backwards compatibility
path.resolve(appPath, '..', 'auto-claude'), // Sibling to app
path.resolve(appPath, '..', '..', 'auto-claude'), // Up 2 from app
path.resolve(appPath, '..', '..', '..', 'auto-claude'), // Up 3 from app
@@ -46,6 +52,7 @@ const detectAutoBuildSourcePath = (): string | null => {
}
// Add process.cwd() as last resort on all platforms
possiblePaths.push(path.resolve(process.cwd(), 'apps', 'backend'));
possiblePaths.push(path.resolve(process.cwd(), 'auto-claude'));
// Enable debug logging with DEBUG=1
@@ -10,6 +10,25 @@ import { findTaskAndProject } from './shared';
import { checkGitStatus } from '../../project-initializer';
import { getClaudeProfileManager } from '../../claude-profile-manager';
/**
* Helper function to check subtask completion status
*/
function checkSubtasksCompletion(plan: Record<string, unknown> | null): {
allSubtasks: Array<{ status: string }>;
completedCount: number;
totalCount: number;
allCompleted: boolean;
} {
const allSubtasks = (plan?.phases as Array<{ subtasks?: Array<{ status: string }> }> | undefined)?.flatMap(phase =>
phase.subtasks || []
) || [];
const completedCount = allSubtasks.filter(s => s.status === 'completed').length;
const totalCount = allSubtasks.length;
const allCompleted = totalCount > 0 && completedCount === totalCount;
return { allSubtasks, completedCount, totalCount, allCompleted };
}
/**
* Register task execution handlers (start, stop, review, status management, recovery)
*/
@@ -589,17 +608,9 @@ export function registerTaskExecutionHandlers(
if (!targetStatus && plan?.phases && Array.isArray(plan.phases)) {
// Analyze subtask statuses to determine appropriate recovery status
const allSubtasks: Array<{ status: string }> = [];
for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string }> }>) {
if (phase.subtasks && Array.isArray(phase.subtasks)) {
allSubtasks.push(...phase.subtasks);
}
}
if (allSubtasks.length > 0) {
const completedCount = allSubtasks.filter(s => s.status === 'completed').length;
const allCompleted = completedCount === allSubtasks.length;
const { completedCount, totalCount, allCompleted } = checkSubtasksCompletion(plan);
if (totalCount > 0) {
if (allCompleted) {
// All subtasks completed - should go to review (ai_review or human_review based on source)
// For recovery, human_review is safer as it requires manual verification
@@ -625,7 +636,30 @@ export function registerTaskExecutionHandlers(
// Add recovery note
plan.recoveryNote = `Task recovered from stuck state at ${new Date().toISOString()}`;
// Reset in_progress and failed subtask statuses to 'pending' so they can be retried
// Check if task is actually stuck or just completed and waiting for merge
const { allCompleted } = checkSubtasksCompletion(plan);
if (allCompleted) {
console.log('[Recovery] Task is fully complete (all subtasks done), setting to human_review without restart');
// Don't reset any subtasks - task is done!
// Just update status in plan file (project store reads from file, no separate update needed)
plan.status = 'human_review';
plan.planStatus = 'review';
writeFileSync(planPath, JSON.stringify(plan, null, 2));
return {
success: true,
data: {
taskId,
recovered: true,
newStatus: 'human_review',
message: 'Task is complete and ready for review',
autoRestarted: false
}
};
}
// Task is not complete - reset only stuck subtasks for retry
// Keep completed subtasks as-is so run.py can resume from where it left off
if (plan.phases && Array.isArray(plan.phases)) {
for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string; actual_output?: string; started_at?: string; completed_at?: string }> }>) {
@@ -634,11 +668,13 @@ export function registerTaskExecutionHandlers(
// Reset in_progress subtasks to pending (they were interrupted)
// Keep completed subtasks as-is so run.py can resume
if (subtask.status === 'in_progress') {
const originalStatus = subtask.status;
subtask.status = 'pending';
// Clear execution data to maintain consistency
delete subtask.actual_output;
delete subtask.started_at;
delete subtask.completed_at;
console.log(`[Recovery] Reset stuck subtask: ${originalStatus} -> pending`);
}
// Also reset failed subtasks so they can be retried
if (subtask.status === 'failed') {
@@ -647,6 +683,7 @@ export function registerTaskExecutionHandlers(
delete subtask.actual_output;
delete subtask.started_at;
delete subtask.completed_at;
console.log(`[Recovery] Reset failed subtask for retry`);
}
}
}
+9 -6
View File
@@ -13,6 +13,7 @@ import * as os from 'os';
import * as fs from 'fs';
import { app } from 'electron';
import { findPythonCommand, parsePythonCommand } from './python-detector';
import { getMemoriesDir } from './config-paths';
import type { MemoryEpisode } from '../shared/types';
interface MemoryServiceConfig {
@@ -82,24 +83,26 @@ interface StatusResult {
/**
* Get the default database path
* Uses XDG-compliant paths on Linux for AppImage/Flatpak/Snap support
*/
export function getDefaultDbPath(): string {
return path.join(os.homedir(), '.auto-claude', 'memories');
return getMemoriesDir();
}
/**
* Get the path to the query_memory.py script
*/
function getQueryScriptPath(): string | null {
// Look for the script in auto-claude directory (sibling to auto-claude-ui)
// Look for the script in backend directory (new apps structure)
const possiblePaths = [
// Dev mode: from dist/main -> ../../auto-claude
// New apps structure: from dist/main -> apps/backend
path.resolve(__dirname, '..', '..', '..', 'backend', 'query_memory.py'),
path.resolve(app.getAppPath(), '..', 'backend', 'query_memory.py'),
path.resolve(process.cwd(), 'apps', 'backend', 'query_memory.py'),
// Legacy paths for backwards compatibility
path.resolve(__dirname, '..', '..', '..', 'auto-claude', 'query_memory.py'),
// Packaged app: from app.getAppPath() (handles asar and resources correctly)
path.resolve(app.getAppPath(), '..', 'auto-claude', 'query_memory.py'),
// Alternative: from app root
path.resolve(process.cwd(), 'auto-claude', 'query_memory.py'),
// If running from repo root
path.resolve(process.cwd(), '..', 'auto-claude', 'query_memory.py'),
];
+62 -5
View File
@@ -245,12 +245,68 @@ export class ProjectStore {
}
console.warn('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath);
// Get specs directory path
const allTasks: Task[] = [];
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specsDir = path.join(project.path, specsBaseDir);
console.warn('[ProjectStore] specsDir:', specsDir, 'exists:', existsSync(specsDir));
if (!existsSync(specsDir)) return [];
// 1. Scan main project specs directory
const mainSpecsDir = path.join(project.path, specsBaseDir);
console.warn('[ProjectStore] Main specsDir:', mainSpecsDir, 'exists:', existsSync(mainSpecsDir));
if (existsSync(mainSpecsDir)) {
const mainTasks = this.loadTasksFromSpecsDir(mainSpecsDir, project.path, 'main', projectId, specsBaseDir);
allTasks.push(...mainTasks);
console.warn('[ProjectStore] Loaded', mainTasks.length, 'tasks from main project');
}
// 2. Scan worktree specs directories
const worktreesDir = path.join(project.path, '.worktrees');
if (existsSync(worktreesDir)) {
try {
const worktrees = readdirSync(worktreesDir, { withFileTypes: true });
for (const worktree of worktrees) {
if (!worktree.isDirectory()) continue;
const worktreeSpecsDir = path.join(worktreesDir, worktree.name, specsBaseDir);
if (existsSync(worktreeSpecsDir)) {
const worktreeTasks = this.loadTasksFromSpecsDir(
worktreeSpecsDir,
path.join(worktreesDir, worktree.name),
'worktree',
projectId,
specsBaseDir
);
allTasks.push(...worktreeTasks);
console.warn('[ProjectStore] Loaded', worktreeTasks.length, 'tasks from worktree:', worktree.name);
}
}
} catch (error) {
console.error('[ProjectStore] Error scanning worktrees:', error);
}
}
// 3. Deduplicate tasks by ID (prefer worktree version if exists in both)
const taskMap = new Map<string, Task>();
for (const task of allTasks) {
const existing = taskMap.get(task.id);
if (!existing || task.location === 'worktree') {
taskMap.set(task.id, task);
}
}
const tasks = Array.from(taskMap.values());
console.warn('[ProjectStore] Returning', tasks.length, 'unique tasks (after deduplication)');
return tasks;
}
/**
* Load tasks from a specs directory (helper method for main project and worktrees)
*/
private loadTasksFromSpecsDir(
specsDir: string,
basePath: string,
location: 'main' | 'worktree',
projectId: string,
specsBaseDir: string
): Task[] {
const tasks: Task[] = [];
let specDirs: Dirent[] = [];
@@ -401,6 +457,8 @@ export class ProjectStore {
metadata,
stagedInMainProject,
stagedAt,
location, // Add location metadata (main vs worktree)
specsPath: specPath, // Add full path to specs directory
createdAt: new Date(plan?.created_at || Date.now()),
updatedAt: new Date(plan?.updated_at || Date.now())
});
@@ -410,7 +468,6 @@ export class ProjectStore {
}
}
console.warn('[ProjectStore] Returning', tasks.length, 'tasks out of', specDirs.filter(d => d.isDirectory() && d.name !== '.gitkeep').length, 'spec directories');
return tasks;
}
+25 -3
View File
@@ -55,13 +55,35 @@ export function getDefaultPythonCommand(): string {
* @returns Tuple of [command, baseArgs] ready for use with spawn()
*/
export function parsePythonCommand(pythonPath: string): [string, string[]] {
// Remove any surrounding quotes first
let cleanPath = pythonPath.trim();
if ((cleanPath.startsWith('"') && cleanPath.endsWith('"')) ||
(cleanPath.startsWith("'") && cleanPath.endsWith("'"))) {
cleanPath = cleanPath.slice(1, -1);
}
// If the path points to an actual file, use it directly (handles paths with spaces)
if (existsSync(pythonPath)) {
return [pythonPath, []];
if (existsSync(cleanPath)) {
return [cleanPath, []];
}
// Check if it's a path (contains path separators but not just at the start)
// Paths with spaces should be treated as a single command, not split
const hasPathSeparators = cleanPath.includes('/') || cleanPath.includes('\\');
const isLikelyPath = hasPathSeparators && !cleanPath.startsWith('-');
if (isLikelyPath) {
// This looks like a file path, don't split it
// Even if the file doesn't exist (yet), treat the whole thing as the command
return [cleanPath, []];
}
// Otherwise, split on spaces for commands like "py -3"
const parts = pythonPath.split(' ');
const parts = cleanPath.split(' ').filter(p => p.length > 0);
if (parts.length === 0) {
// Return empty string for empty input, not the original uncleaned path
return [cleanPath, []];
}
const command = parts[0];
const baseArgs = parts.slice(1);
return [command, baseArgs];
@@ -47,6 +47,11 @@ export class TerminalNameGenerator extends EventEmitter {
}
const possiblePaths = [
// New apps structure: from out/main -> apps/backend
path.resolve(__dirname, '..', '..', '..', 'backend'),
path.resolve(app.getAppPath(), '..', 'backend'),
path.resolve(process.cwd(), 'apps', 'backend'),
// Legacy paths for backwards compatibility
path.resolve(__dirname, '..', '..', '..', 'auto-claude'),
path.resolve(app.getAppPath(), '..', 'auto-claude'),
path.resolve(process.cwd(), 'auto-claude')
@@ -51,6 +51,11 @@ export class TitleGenerator extends EventEmitter {
}
const possiblePaths = [
// New apps structure: from out/main -> apps/backend
path.resolve(__dirname, '..', '..', '..', 'backend'),
path.resolve(app.getAppPath(), '..', 'backend'),
path.resolve(process.cwd(), 'apps', 'backend'),
// Legacy paths for backwards compatibility
path.resolve(__dirname, '..', '..', '..', 'auto-claude'),
path.resolve(app.getAppPath(), '..', 'auto-claude'),
path.resolve(process.cwd(), 'auto-claude')
+1 -1
View File
@@ -8,7 +8,7 @@
export const GITHUB_CONFIG = {
owner: 'AndyMik90',
repo: 'Auto-Claude',
autoBuildPath: 'auto-claude' // Path within repo where auto-claude lives
autoBuildPath: 'apps/backend' // Path within repo where auto-claude backend lives
} as const;
/**
+43 -1
View File
@@ -4,7 +4,7 @@
import https from 'https';
import { createWriteStream } from 'fs';
import { TIMEOUTS } from './config';
import { TIMEOUTS, GITHUB_CONFIG } from './config';
/**
* Fetch JSON from a URL using https
@@ -26,6 +26,26 @@ export function fetchJson<T>(url: string): Promise<T> {
}
}
// Handle HTTP 300 Multiple Choices (branch/tag name collision)
if (response.statusCode === 300) {
let data = '';
response.on('data', chunk => data += chunk);
response.on('end', () => {
console.error('[HTTP] Multiple choices for resource:', {
url,
statusCode: 300,
response: data
});
reject(new Error(
`Multiple resources found for ${url}. ` +
`This usually means a branch and tag have the same name. ` +
`Please report this issue at https://github.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/issues`
));
});
response.on('error', reject);
return;
}
if (response.statusCode !== 200) {
// Collect response body for error details (limit to 10KB)
const maxErrorSize = 10 * 1024;
@@ -93,6 +113,28 @@ export function downloadFile(
}
}
// Handle HTTP 300 Multiple Choices (branch/tag name collision)
if (response.statusCode === 300) {
file.close();
let data = '';
response.on('data', chunk => data += chunk);
response.on('end', () => {
console.error('[HTTP] Multiple choices for resource:', {
url,
statusCode: 300,
response: data
});
reject(new Error(
`Multiple resources found for ${url}. ` +
`This usually means a branch and tag have the same name. ` +
`Please download the latest version manually from: ` +
`https://github.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`
));
});
response.on('error', reject);
return;
}
if (response.statusCode !== 200) {
file.close();
// Collect response body for error details (limit to 10KB)
@@ -172,14 +172,23 @@ export async function downloadAndApplyUpdate(
debugLog('[Update] Error:', errorMessage);
debugLog('[Update] ============================================');
// Provide user-friendly error message for HTTP 300 errors
let displayMessage = errorMessage;
if (errorMessage.includes('Multiple resources found')) {
displayMessage =
`Update failed due to repository configuration issue (HTTP 300). ` +
`Please download the latest version manually from: ` +
`https://github.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`;
}
onProgress?.({
stage: 'error',
message: errorMessage
message: displayMessage
});
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
error: displayMessage
};
}
}
@@ -36,6 +36,10 @@ export function getEffectiveVersion(): string {
} else {
// Development: check the actual source paths where updates are written
const possibleSourcePaths = [
// New apps structure
path.join(app.getAppPath(), '..', 'backend'),
path.join(process.cwd(), 'apps', 'backend'),
// Legacy paths for backwards compatibility
path.join(app.getAppPath(), '..', 'auto-claude'),
path.join(app.getAppPath(), '..', '..', 'auto-claude'),
path.join(process.cwd(), 'auto-claude'),
@@ -0,0 +1,32 @@
import { useState } from 'react';
/**
* Hook for handling terminal creation with proper error handling and loading states.
* Fixes silent failures when terminal buttons are clicked.
*/
export function useTerminalHandler() {
const [error, setError] = useState<string | null>(null);
const [isOpening, setIsOpening] = useState(false);
const openTerminal = async (id: string, cwd: string) => {
setIsOpening(true);
setError(null);
try {
const result = await window.electronAPI.createTerminal({ id, cwd });
if (!result.success) {
setError(result.error || 'Failed to open terminal');
console.error('[Terminal] Failed to open:', result.error);
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
setError(`Failed to open terminal: ${errorMsg}`);
console.error('[Terminal] Exception:', err);
} finally {
setIsOpening(false);
}
};
return { openTerminal, error, isOpening };
}
@@ -3,6 +3,7 @@ import { GitMerge, ExternalLink, Copy, Check, Sparkles } from 'lucide-react';
import { Button } from '../../ui/button';
import { Textarea } from '../../ui/textarea';
import type { Task } from '../../../../shared/types';
import { useTerminalHandler } from '../hooks/useTerminalHandler';
interface StagedSuccessMessageProps {
stagedSuccess: string;
@@ -22,6 +23,7 @@ export function StagedSuccessMessage({
}: StagedSuccessMessageProps) {
const [commitMessage, setCommitMessage] = useState(suggestedCommitMessage || '');
const [copied, setCopied] = useState(false);
const { openTerminal, error: terminalError, isOpening } = useTerminalHandler();
const handleCopy = async () => {
if (!commitMessage) return;
@@ -93,20 +95,23 @@ export function StagedSuccessMessage({
</ol>
</div>
{stagedProjectPath && (
<Button
variant="outline"
size="sm"
onClick={() => {
window.electronAPI.createTerminal({
id: `project-${task.id}`,
cwd: stagedProjectPath
});
}}
className="w-full"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open Project in Terminal
</Button>
<>
<Button
variant="outline"
size="sm"
onClick={() => openTerminal(`project-${task.id}`, stagedProjectPath)}
className="w-full"
disabled={isOpening}
>
<ExternalLink className="mr-2 h-4 w-4" />
{isOpening ? 'Opening Terminal...' : 'Open Project in Terminal'}
</Button>
{terminalError && (
<div className="mt-2 text-sm text-red-600">
{terminalError}
</div>
)}
</>
)}
</div>
);
@@ -18,6 +18,7 @@ import { Button } from '../../ui/button';
import { Checkbox } from '../../ui/checkbox';
import { cn } from '../../../lib/utils';
import type { Task, WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
import { useTerminalHandler } from '../hooks/useTerminalHandler';
interface WorkspaceStatusProps {
task: Task;
@@ -55,6 +56,7 @@ export function WorkspaceStatus({
onStageOnlyChange,
onMerge
}: WorkspaceStatusProps) {
const { openTerminal, error: terminalError, isOpening } = useTerminalHandler();
const hasGitConflicts = mergePreview?.gitConflicts?.hasConflicts;
const hasUncommittedChanges = mergePreview?.uncommittedChanges?.hasChanges;
const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0;
@@ -92,14 +94,10 @@ export function WorkspaceStatus({
<Button
variant="ghost"
size="sm"
onClick={() => {
window.electronAPI.createTerminal({
id: `open-${task.id}`,
cwd: worktreeStatus.worktreePath!
});
}}
onClick={() => openTerminal(`open-${task.id}`, worktreeStatus.worktreePath!)}
className="h-7 px-2"
title="Open in terminal"
disabled={isOpening}
>
<Terminal className="h-3.5 w-3.5" />
</Button>
@@ -135,6 +133,20 @@ export function WorkspaceStatus({
<code className="bg-background/80 px-1.5 py-0.5 rounded text-[11px]">{worktreeStatus.baseBranch || 'main'}</code>
</div>
)}
{/* Worktree path display */}
{worktreeStatus.worktreePath && (
<div className="mt-2 text-xs text-muted-foreground font-mono">
📁 {worktreeStatus.worktreePath}
</div>
)}
{/* Terminal error display */}
{terminalError && (
<div className="mt-2 text-sm text-red-600">
{terminalError}
</div>
)}
</div>
{/* Status/Warnings Section */}
@@ -162,15 +174,16 @@ export function WorkspaceStatus({
variant="outline"
size="sm"
onClick={() => {
window.electronAPI.createTerminal({
id: `stash-${task.id}`,
cwd: worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || undefined
});
const mainProjectPath = worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || '';
if (mainProjectPath) {
openTerminal(`stash-${task.id}`, mainProjectPath);
}
}}
className="text-xs h-6 mt-2"
disabled={isOpening}
>
<Terminal className="h-3 w-3 mr-1" />
Open Terminal
{isOpening ? 'Opening...' : 'Open Terminal'}
</Button>
</div>
</div>
+2
View File
@@ -244,6 +244,8 @@ export interface Task {
releasedInVersion?: string; // Version in which this task was released
stagedInMainProject?: boolean; // True if changes were staged to main project (worktree merged with --no-commit)
stagedAt?: string; // ISO timestamp when changes were staged
location?: 'main' | 'worktree'; // Where task was loaded from (main project or worktree)
specsPath?: string; // Full path to specs directory for this task
createdAt: Date;
updatedAt: Date;
}
+9 -4
View File
@@ -189,7 +189,12 @@ function main() {
error('New version is the same as current version');
}
// 4. Update all version files
// 4. Validate release (check for branch/tag conflicts)
info('Validating release...');
exec(`node ${path.join(__dirname, 'validate-release.js')} v${newVersion}`);
success('Release validation passed');
// 5. Update all version files
info('Updating package.json files...');
updatePackageJson(newVersion);
success('Updated package.json files');
@@ -204,18 +209,18 @@ function main() {
success('Updated README.md');
}
// 5. Create git commit
// 6. Create git commit
info('Creating git commit...');
exec('git add apps/frontend/package.json package.json apps/backend/__init__.py README.md');
exec(`git commit -m "chore: bump version to ${newVersion}"`);
success(`Created commit: "chore: bump version to ${newVersion}"`);
// 6. Create git tag
// 7. Create git tag
info('Creating git tag...');
exec(`git tag -a v${newVersion} -m "Release v${newVersion}"`);
success(`Created tag: v${newVersion}`);
// 7. Instructions
// 8. Instructions
log('\n📋 Next steps:', colors.yellow);
log(` 1. Review the changes: git log -1`, colors.yellow);
log(` 2. Push the commit: git push origin <branch-name>`, colors.yellow);
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Validate Release Script
*
* Prevents HTTP 300 errors by ensuring no branch/tag name conflicts.
* Run before creating a new release to check if the version is safe.
*
* Usage: node scripts/validate-release.js <version>
* Example: node scripts/validate-release.js v2.7.2
*/
const { execSync } = require('child_process');
function validateRelease(version) {
console.log(`Validating release: ${version}...`);
// Check if version tag already exists
try {
const tags = execSync('git tag -l').toString().split('\n').filter(Boolean);
if (tags.includes(version)) {
console.error(`\u274C Tag ${version} already exists!`);
console.error(' Cannot create duplicate tag.');
process.exit(1);
}
} catch (error) {
console.error('Failed to check git tags:', error.message);
process.exit(1);
}
// Check if branch with same name exists (locally)
try {
const branches = execSync('git branch')
.toString()
.split('\n')
.map(b => b.trim().replace(/^\*\s*/, ''))
.filter(Boolean);
if (branches.includes(version)) {
console.error(`\u274C Local branch "${version}" already exists!`);
console.error(' This will cause HTTP 300 errors during updates.');
console.error(` Please delete the branch: git branch -D ${version}`);
process.exit(1);
}
} catch (error) {
console.error('Failed to check local branches:', error.message);
process.exit(1);
}
// Check if branch with same name exists (remotely)
try {
const remoteBranches = execSync('git branch -r')
.toString()
.split('\n')
.map(b => b.trim())
.filter(b => b && !b.includes(' -> ')); // Exclude symbolic refs like origin/HEAD -> origin/main
if (remoteBranches.includes(`origin/${version}`) || remoteBranches.includes(`fork/${version}`)) {
console.error(`\u274C Remote branch "${version}" already exists!`);
console.error(' This will cause HTTP 300 errors during updates.');
console.error(` Please delete the remote branch: git push origin --delete ${version}`);
process.exit(1);
}
} catch (error) {
// Ignore errors from remote check (might not have remotes configured)
console.warn('\u26A0\uFE0F Could not check remote branches:', error.message);
}
console.log(`\u2705 Version ${version} is safe to release`);
console.log(' No conflicting branches or tags found.');
}
// Main execution
const version = process.argv[2];
if (!version) {
console.error('Usage: node validate-release.js <version>');
console.error('Example: node validate-release.js v2.7.2');
process.exit(1);
}
validateRelease(version);