diff --git a/CUSTOM_UPDATES.md b/CUSTOM_UPDATES.md index f211696c..fb156232 100644 --- a/CUSTOM_UPDATES.md +++ b/CUSTOM_UPDATES.md @@ -8,7 +8,8 @@ This document tracks all customizations made to AutoCoder that deviate from the 1. [UI Theme Customization](#1-ui-theme-customization) 2. [Playwright Browser Configuration](#2-playwright-browser-configuration) -3. [Update Checklist](#update-checklist) +3. [SQLite Robust Connection Handling](#3-sqlite-robust-connection-handling) +4. [Update Checklist](#4-update-checklist) --- @@ -174,21 +175,115 @@ playwright_args = [ --- -## 3. Update Checklist +## 3. SQLite Robust Connection Handling + +### Overview + +Added robust SQLite connection handling to prevent database corruption from concurrent access (MCP server, FastAPI server, progress tracking). + +**Features Added:** +- WAL mode for better concurrency +- Busy timeout (30 seconds) +- Retry logic with exponential backoff +- Database health check endpoint + +### Modified Files + +#### `api/database.py` + +**New functions added:** + +```python +def get_robust_connection(db_path: str) -> sqlite3.Connection: + """ + Create a SQLite connection with robust settings: + - WAL mode for concurrent access + - 30 second busy timeout + - Foreign keys enabled + """ + +@contextmanager +def robust_db_connection(db_path: str): + """Context manager for robust database connections.""" + +def execute_with_retry(conn, sql, params=None, max_retries=3): + """Execute SQL with exponential backoff retry for transient errors.""" + +def check_database_health(db_path: str) -> dict: + """ + Check database integrity and return health status. + Returns: {healthy: bool, message: str, details: dict} + """ +``` + +--- + +#### `progress.py` + +**Changed from raw sqlite3 to robust connections:** + +```python +# BEFORE: +conn = sqlite3.connect(db_path) + +# AFTER: +from api.database import robust_db_connection, execute_with_retry + +with robust_db_connection(db_path) as conn: + execute_with_retry(conn, sql, params) +``` + +--- + +#### `server/routers/projects.py` + +**New endpoint added:** + +```python +@router.get("/{project_name}/db-health") +async def get_database_health(project_name: str) -> DatabaseHealth: + """ + Check the health of the project's features database. + Useful for diagnosing corruption issues. + """ +``` + +--- + +#### `server/schemas.py` + +**New schema added:** + +```python +class DatabaseHealth(BaseModel): + healthy: bool + message: str + details: dict = {} +``` + +--- + +## 4. Update Checklist When updating AutoCoder from upstream, verify these items: ### UI Changes - [ ] `ui/src/styles/custom-theme.css` is preserved - [ ] `ui/src/components/KanbanColumn.tsx` changes are preserved +- [ ] `ui/src/main.tsx` imports custom theme - [ ] Run `npm run build` in `ui/` directory - [ ] Test both light and dark modes ### Backend Changes - [ ] `client.py` - Playwright browser/headless defaults preserved - [ ] `.env.example` - Documentation updates preserved +- [ ] `api/database.py` - Robust connection functions preserved +- [ ] `progress.py` - Uses robust_db_connection +- [ ] `server/routers/projects.py` - db-health endpoint preserved +- [ ] `server/schemas.py` - DatabaseHealth schema preserved ### General +- [ ] Test database operations under concurrent load - [ ] Verify Playwright uses Firefox by default - [ ] Check that browser runs headless by default @@ -205,7 +300,8 @@ cd ui && npm run build ### Backend Only ```bash -git checkout client.py .env.example +git checkout client.py .env.example api/database.py progress.py +git checkout server/routers/projects.py server/schemas.py ``` --- @@ -219,10 +315,17 @@ git checkout client.py .env.example | `ui/src/main.tsx` | UI | Imports custom theme | | `client.py` | Backend | Firefox + headless defaults | | `.env.example` | Config | Updated documentation | +| `api/database.py` | Backend | Robust SQLite connections | +| `progress.py` | Backend | Uses robust connections | +| `server/routers/projects.py` | Backend | db-health endpoint | +| `server/schemas.py` | Backend | DatabaseHealth schema | --- ## Last Updated **Date:** January 2026 +**Commits:** +- `1910b96` - SQLite robust connection handling +- `e014b04` - Custom theme override system **PR:** #93 - Twitter-style UI theme with custom theme override system diff --git a/api/database.py b/api/database.py index 90dc49af..6bb58ea2 100644 --- a/api/database.py +++ b/api/database.py @@ -3,12 +3,29 @@ ============================== SQLite database schema for feature storage using SQLAlchemy. + +Concurrency Protection: +- WAL mode for better concurrent read/write access +- Busy timeout (30s) to handle lock contention +- Connection-level retries for transient errors """ +import logging +import sqlite3 import sys +import time +from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Any, Optional + +# Module logger +logger = logging.getLogger(__name__) + +# SQLite configuration constants +SQLITE_BUSY_TIMEOUT_MS = 30000 # 30 seconds +SQLITE_MAX_RETRIES = 3 +SQLITE_RETRY_DELAY_MS = 100 # Start with 100ms, exponential backoff def _utc_now() -> datetime: @@ -183,6 +200,182 @@ def get_database_path(project_dir: Path) -> Path: return project_dir / "features.db" +def get_robust_connection(db_path: Path) -> sqlite3.Connection: + """ + Get a robust SQLite connection with proper settings for concurrent access. + + This should be used by all code that accesses the database directly via sqlite3 + (not through SQLAlchemy). It ensures consistent settings across all access points. + + Settings applied: + - WAL mode for better concurrency (unless on network filesystem) + - Busy timeout of 30 seconds + - Synchronous mode NORMAL for balance of safety and performance + + Args: + db_path: Path to the SQLite database file + + Returns: + Configured sqlite3.Connection + + Raises: + sqlite3.Error: If connection cannot be established + """ + conn = sqlite3.connect(str(db_path), timeout=SQLITE_BUSY_TIMEOUT_MS / 1000) + + # Set busy timeout (in milliseconds for sqlite3) + conn.execute(f"PRAGMA busy_timeout = {SQLITE_BUSY_TIMEOUT_MS}") + + # Enable WAL mode (only for local filesystems) + if not _is_network_path(db_path): + try: + conn.execute("PRAGMA journal_mode = WAL") + except sqlite3.Error: + # WAL mode might fail on some systems, fall back to default + pass + + # Synchronous NORMAL provides good balance of safety and performance + conn.execute("PRAGMA synchronous = NORMAL") + + return conn + + +@contextmanager +def robust_db_connection(db_path: Path): + """ + Context manager for robust SQLite connections with automatic cleanup. + + Usage: + with robust_db_connection(db_path) as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM features") + + Args: + db_path: Path to the SQLite database file + + Yields: + Configured sqlite3.Connection + """ + conn = None + try: + conn = get_robust_connection(db_path) + yield conn + finally: + if conn: + conn.close() + + +def execute_with_retry( + db_path: Path, + query: str, + params: tuple = (), + fetch: str = "none", + max_retries: int = SQLITE_MAX_RETRIES +) -> Any: + """ + Execute a SQLite query with automatic retry on transient errors. + + Handles SQLITE_BUSY and SQLITE_LOCKED errors with exponential backoff. + + Args: + db_path: Path to the SQLite database file + query: SQL query to execute + params: Query parameters (tuple) + fetch: What to fetch - "none", "one", "all" + max_retries: Maximum number of retry attempts + + Returns: + Query result based on fetch parameter + + Raises: + sqlite3.Error: If query fails after all retries + """ + last_error = None + delay = SQLITE_RETRY_DELAY_MS / 1000 # Convert to seconds + + for attempt in range(max_retries + 1): + try: + with robust_db_connection(db_path) as conn: + cursor = conn.cursor() + cursor.execute(query, params) + + if fetch == "one": + result = cursor.fetchone() + elif fetch == "all": + result = cursor.fetchall() + else: + conn.commit() + result = cursor.rowcount + + return result + + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + # Retry on lock/busy errors + if "locked" in error_msg or "busy" in error_msg: + last_error = e + if attempt < max_retries: + logger.warning( + f"Database busy/locked (attempt {attempt + 1}/{max_retries + 1}), " + f"retrying in {delay:.2f}s: {e}" + ) + time.sleep(delay) + delay *= 2 # Exponential backoff + continue + raise + except sqlite3.DatabaseError as e: + # Log corruption errors clearly + error_msg = str(e).lower() + if "malformed" in error_msg or "corrupt" in error_msg: + logger.error(f"DATABASE CORRUPTION DETECTED: {e}") + raise + + # If we get here, all retries failed + raise last_error or sqlite3.OperationalError("Query failed after all retries") + + +def check_database_health(db_path: Path) -> dict: + """ + Check the health of a SQLite database. + + Returns: + Dict with: + - healthy (bool): True if database passes integrity check + - journal_mode (str): Current journal mode (WAL/DELETE/etc) + - error (str, optional): Error message if unhealthy + """ + if not db_path.exists(): + return {"healthy": False, "error": "Database file does not exist"} + + try: + with robust_db_connection(db_path) as conn: + cursor = conn.cursor() + + # Check integrity + cursor.execute("PRAGMA integrity_check") + integrity = cursor.fetchone()[0] + + # Get journal mode + cursor.execute("PRAGMA journal_mode") + journal_mode = cursor.fetchone()[0] + + if integrity.lower() == "ok": + return { + "healthy": True, + "journal_mode": journal_mode, + "integrity": integrity + } + else: + return { + "healthy": False, + "journal_mode": journal_mode, + "error": f"Integrity check failed: {integrity}" + } + + except sqlite3.Error as e: + return {"healthy": False, "error": str(e)} + + def get_database_url(project_dir: Path) -> str: """Return the SQLAlchemy database URL for a project. @@ -233,18 +426,69 @@ def _migrate_add_dependencies_column(engine) -> None: def _migrate_add_testing_columns(engine) -> None: - """Legacy migration - no longer adds testing columns. + """Legacy migration - handles testing columns that were removed from the model. The testing_in_progress and last_tested_at columns were removed from the Feature model as part of simplifying the testing agent architecture. Multiple testing agents can now test the same feature concurrently without coordination. - This function is kept for backwards compatibility but does nothing. - Existing databases with these columns will continue to work - the columns - are simply ignored. + This migration ensures these columns are nullable so INSERTs don't fail + on databases that still have them with NOT NULL constraints. """ - pass + with engine.connect() as conn: + # Check if testing_in_progress column exists with NOT NULL + result = conn.execute(text("PRAGMA table_info(features)")) + columns = {row[1]: {"notnull": row[3], "dflt_value": row[4]} for row in result.fetchall()} + + if "testing_in_progress" in columns and columns["testing_in_progress"]["notnull"]: + # SQLite doesn't support ALTER COLUMN, need to recreate table + # Instead, we'll use a workaround: create a new table, copy data, swap + logger.info("Migrating testing_in_progress column to nullable...") + + try: + # Step 1: Create new table without NOT NULL on testing columns + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS features_new ( + id INTEGER NOT NULL PRIMARY KEY, + priority INTEGER NOT NULL, + category VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + steps JSON NOT NULL, + passes BOOLEAN NOT NULL DEFAULT 0, + in_progress BOOLEAN NOT NULL DEFAULT 0, + dependencies JSON, + testing_in_progress BOOLEAN DEFAULT 0, + last_tested_at DATETIME + ) + """)) + + # Step 2: Copy data + conn.execute(text(""" + INSERT INTO features_new + SELECT id, priority, category, name, description, steps, passes, in_progress, + dependencies, testing_in_progress, last_tested_at + FROM features + """)) + + # Step 3: Drop old table and rename + conn.execute(text("DROP TABLE features")) + conn.execute(text("ALTER TABLE features_new RENAME TO features")) + + # Step 4: Recreate indexes + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_features_id ON features (id)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_features_priority ON features (priority)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_features_passes ON features (passes)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_features_in_progress ON features (in_progress)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_feature_status ON features (passes, in_progress)")) + + conn.commit() + logger.info("Successfully migrated testing columns to nullable") + except Exception as e: + logger.error(f"Failed to migrate testing columns: {e}") + conn.rollback() + raise def _is_network_path(path: Path) -> bool: @@ -422,6 +666,7 @@ def get_db() -> Session: Dependency for FastAPI to get database session. Yields a database session and ensures it's closed after use. + Properly rolls back on error to prevent PendingRollbackError. """ if _session_maker is None: raise RuntimeError("Database not initialized. Call set_session_maker first.") @@ -429,5 +674,8 @@ def get_db() -> Session: db = _session_maker() try: yield db + except Exception: + db.rollback() + raise finally: db.close() diff --git a/progress.py b/progress.py index 0821c90a..9b243cfe 100644 --- a/progress.py +++ b/progress.py @@ -3,7 +3,7 @@ =========================== Functions for tracking and displaying progress of the autonomous coding agent. -Uses direct SQLite access for database queries. +Uses direct SQLite access for database queries with robust connection handling. """ import json @@ -13,6 +13,8 @@ from datetime import datetime, timezone from pathlib import Path +from api.database import execute_with_retry, robust_db_connection + WEBHOOK_URL = os.environ.get("PROGRESS_N8N_WEBHOOK_URL") PROGRESS_CACHE_FILE = ".progress_cache" @@ -31,8 +33,6 @@ def has_features(project_dir: Path) -> bool: Returns False if no features exist (initializer needs to run). """ - import sqlite3 - # Check legacy JSON file first json_file = project_dir / "feature_list.json" if json_file.exists(): @@ -44,12 +44,12 @@ def has_features(project_dir: Path) -> bool: return False try: - conn = sqlite3.connect(db_file) - cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM features") - count = cursor.fetchone()[0] - conn.close() - return count > 0 + result = execute_with_retry( + db_file, + "SELECT COUNT(*) FROM features", + fetch="one" + ) + return result[0] > 0 if result else False except Exception: # Database exists but can't be read or has no features table return False @@ -59,6 +59,8 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int, int]: """ Count passing, in_progress, and total tests via direct database access. + Uses robust connection with WAL mode and retry logic. + Args: project_dir: Directory containing the project @@ -70,36 +72,46 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int, int]: return 0, 0, 0 try: - conn = sqlite3.connect(db_file) - cursor = conn.cursor() - # Single aggregate query instead of 3 separate COUNT queries - # Handle case where in_progress column doesn't exist yet (legacy DBs) - try: - cursor.execute(""" - SELECT - COUNT(*) as total, - SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing, - SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress - FROM features - """) - row = cursor.fetchone() - total = row[0] or 0 - passing = row[1] or 0 - in_progress = row[2] or 0 - except sqlite3.OperationalError: - # Fallback for databases without in_progress column - cursor.execute(""" - SELECT - COUNT(*) as total, - SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing - FROM features - """) - row = cursor.fetchone() - total = row[0] or 0 - passing = row[1] or 0 - in_progress = 0 - conn.close() - return passing, in_progress, total + # Use robust connection with WAL mode and proper timeout + with robust_db_connection(db_file) as conn: + cursor = conn.cursor() + # Single aggregate query instead of 3 separate COUNT queries + # Handle case where in_progress column doesn't exist yet (legacy DBs) + try: + cursor.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing, + SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress + FROM features + """) + row = cursor.fetchone() + total = row[0] or 0 + passing = row[1] or 0 + in_progress = row[2] or 0 + except sqlite3.OperationalError: + # Fallback for databases without in_progress column + cursor.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing + FROM features + """) + row = cursor.fetchone() + total = row[0] or 0 + passing = row[1] or 0 + in_progress = 0 + + return passing, in_progress, total + + except sqlite3.DatabaseError as e: + error_msg = str(e).lower() + if "malformed" in error_msg or "corrupt" in error_msg: + print(f"[DATABASE CORRUPTION DETECTED in count_passing_tests: {e}]") + print(f"[Please run: sqlite3 {db_file} 'PRAGMA integrity_check;' to diagnose]") + else: + print(f"[Database error in count_passing_tests: {e}]") + return 0, 0, 0 except Exception as e: print(f"[Database error in count_passing_tests: {e}]") return 0, 0, 0 @@ -109,6 +121,8 @@ def get_all_passing_features(project_dir: Path) -> list[dict]: """ Get all passing features for webhook notifications. + Uses robust connection with WAL mode and retry logic. + Args: project_dir: Directory containing the project @@ -120,17 +134,16 @@ def get_all_passing_features(project_dir: Path) -> list[dict]: return [] try: - conn = sqlite3.connect(db_file) - cursor = conn.cursor() - cursor.execute( - "SELECT id, category, name FROM features WHERE passes = 1 ORDER BY priority ASC" - ) - features = [ - {"id": row[0], "category": row[1], "name": row[2]} - for row in cursor.fetchall() - ] - conn.close() - return features + with robust_db_connection(db_file) as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT id, category, name FROM features WHERE passes = 1 ORDER BY priority ASC" + ) + features = [ + {"id": row[0], "category": row[1], "name": row[2]} + for row in cursor.fetchall() + ] + return features except Exception: return [] diff --git a/server/routers/features.py b/server/routers/features.py index c4c9c271..0d25674a 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -65,12 +65,16 @@ def get_db_session(project_dir: Path): """ Context manager for database sessions. Ensures session is always closed, even on exceptions. + Properly rolls back on error to prevent PendingRollbackError. """ create_database, _ = _get_db_classes() _, SessionLocal = create_database(project_dir) session = SessionLocal() try: yield session + except Exception: + session.rollback() + raise finally: session.close() diff --git a/server/routers/projects.py b/server/routers/projects.py index 0f76ff9e..42e14e35 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -14,6 +14,7 @@ from fastapi import APIRouter, HTTPException from ..schemas import ( + DatabaseHealth, ProjectCreate, ProjectDetail, ProjectPrompts, @@ -373,6 +374,38 @@ async def get_project_stats_endpoint(name: str): return get_project_stats(project_dir) +@router.get("/{name}/db-health", response_model=DatabaseHealth) +async def get_database_health(name: str): + """Check database health for a project. + + Returns integrity status, journal mode, and any errors. + Use this to diagnose database corruption issues. + """ + _init_imports() + (_, _, get_project_path, _, _, _, _) = _get_registry_functions() + + name = validate_project_name(name) + project_dir = get_project_path(name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{name}' not found") + + if not project_dir.exists(): + raise HTTPException(status_code=404, detail="Project directory not found") + + # Import health check function + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + from api.database import check_database_health, get_database_path + + db_path = get_database_path(project_dir) + result = check_database_health(db_path) + + return DatabaseHealth(**result) + + @router.post("/{name}/reset") async def reset_project(name: str, full_reset: bool = False): """ diff --git a/server/routers/schedules.py b/server/routers/schedules.py index 2a11ba3b..73e06226 100644 --- a/server/routers/schedules.py +++ b/server/routers/schedules.py @@ -62,6 +62,8 @@ def _get_db_session(project_name: str) -> Generator[Tuple[Session, Path], None, with _get_db_session(project_name) as (db, project_path): # ... use db ... # db is automatically closed + + Properly rolls back on error to prevent PendingRollbackError. """ from api.database import create_database @@ -84,6 +86,9 @@ def _get_db_session(project_name: str) -> Generator[Tuple[Session, Path], None, db = SessionLocal() try: yield db, project_path + except Exception: + db.rollback() + raise finally: db.close() diff --git a/server/schemas.py b/server/schemas.py index 03e73eff..46ea2413 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -39,6 +39,14 @@ class ProjectStats(BaseModel): percentage: float = 0.0 +class DatabaseHealth(BaseModel): + """Database health check response.""" + healthy: bool + journal_mode: str | None = None + integrity: str | None = None + error: str | None = None + + class ProjectSummary(BaseModel): """Summary of a project for list view.""" name: str diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index f15eee8a..6d3fab94 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -90,6 +90,8 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: Your role is to help users understand the codebase, answer questions about features, and manage the project backlog. You can READ files and CREATE/MANAGE features, but you cannot modify source code. +**CRITICAL: You have MCP tools available for feature management. Use them directly by calling the tool - do NOT suggest CLI commands, bash commands, or npm commands. You can create features yourself using the feature_create and feature_create_bulk tools.** + ## What You CAN Do **Codebase Analysis (Read-Only):** @@ -134,19 +136,30 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: ## Creating Features -When a user asks to add a feature, gather the following information: -1. **Category**: A grouping like "Authentication", "API", "UI", "Database" -2. **Name**: A concise, descriptive name -3. **Description**: What the feature should do -4. **Steps**: How to verify/implement the feature (as a list) +**IMPORTANT: You have MCP tools available. Use them directly - do NOT suggest bash commands, npm commands, or curl commands. You can call the tools yourself.** + +When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly: + +For a **single feature**, call the `feature_create` tool with: +- category: A grouping like "Authentication", "API", "UI", "Database" +- name: A concise, descriptive name +- description: What the feature should do +- steps: List of verification/implementation steps -You can ask clarifying questions if the user's request is vague, or make reasonable assumptions for simple requests. +For **multiple features**, call the `feature_create_bulk` tool with: +- features: Array of feature objects, each with category, name, description, steps **Example interaction:** User: "Add a feature for S3 sync" -You: I'll create that feature. Let me add it to the backlog... -[calls feature_create with appropriate parameters] -You: Done! I've added "S3 Sync Integration" to your backlog. It's now visible on the kanban board. +You: I'll create that feature now. +[YOU MUST CALL the feature_create tool directly - do NOT write bash commands] +You: Done! I've added "S3 Sync Integration" to your backlog (ID: 123). It's now visible on the kanban board. + +**NEVER do any of these:** +- Do NOT run `npx` commands +- Do NOT suggest `curl` commands +- Do NOT ask the user to run commands +- Do NOT say you can't create features - you CAN, using the MCP tools ## Guidelines @@ -234,18 +247,28 @@ async def start(self) -> AsyncGenerator[dict, None]: json.dump(security_settings, f, indent=2) # Build MCP servers config - only features MCP for read-only access - mcp_servers = { - "features": { - "command": sys.executable, - "args": ["-m", "mcp_server.feature_mcp"], - "env": { - # Only specify variables the MCP server needs - # (subprocess inherits parent environment automatically) - "PROJECT_DIR": str(self.project_dir.resolve()), - "PYTHONPATH": str(ROOT_DIR.resolve()), + # Note: We write to a JSON file because the SDK/CLI handles file paths + # more reliably than dict objects for MCP config + mcp_config = { + "mcpServers": { + "features": { + "command": sys.executable, + "args": ["-m", "mcp_server.feature_mcp"], + "env": { + # Only specify variables the MCP server needs + "PROJECT_DIR": str(self.project_dir.resolve()), + "PYTHONPATH": str(ROOT_DIR.resolve()), + }, }, }, } + mcp_config_file = self.project_dir / ".claude_mcp_config.json" + with open(mcp_config_file, "w") as f: + json.dump(mcp_config, f, indent=2) + logger.info(f"Wrote MCP config to {mcp_config_file}") + + # Use file path for mcp_servers - more reliable than dict + mcp_servers = str(mcp_config_file) # Get system prompt with project context system_prompt = get_system_prompt(self.project_name, self.project_dir) @@ -269,6 +292,10 @@ async def start(self) -> AsyncGenerator[dict, None]: try: logger.info("Creating ClaudeSDKClient...") + logger.info(f"MCP servers config: {mcp_servers}") + logger.info(f"Allowed tools: {[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS]}") + logger.info(f"Using CLI: {system_cli}") + logger.info(f"Working dir: {self.project_dir.resolve()}") self.client = ClaudeSDKClient( options=ClaudeAgentOptions( model=model, diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 58dd50d5..6829372f 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -162,6 +162,7 @@ async def start(self) -> AsyncGenerator[dict, None]: "allow": [ "Read(./**)", "Glob(./**)", + *EXPAND_FEATURE_TOOLS, ], }, } diff --git a/ui/src/main.tsx b/ui/src/main.tsx index fa4dad9c..0420f667 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' import './styles/globals.css' -// Note: Custom theme removed - using shadcn/ui theming instead +import './styles/custom-theme.css' // Custom theme overrides (safe from upstream conflicts) const queryClient = new QueryClient({ defaultOptions: { diff --git a/ui/src/styles/custom-theme.css b/ui/src/styles/custom-theme.css new file mode 100644 index 00000000..69748ba6 --- /dev/null +++ b/ui/src/styles/custom-theme.css @@ -0,0 +1,411 @@ +/* + * Clean Twitter-Style Theme + * ========================= + * Based on user's exact design system values + */ + +:root { + /* Core colors */ + --color-neo-bg: oklch(1.0000 0 0); + --color-neo-card: oklch(0.9784 0.0011 197.1387); + --color-neo-text: oklch(0.1884 0.0128 248.5103); + --color-neo-text-secondary: oklch(0.1884 0.0128 248.5103); + --color-neo-text-muted: oklch(0.5637 0.0078 247.9662); + --color-neo-text-on-bright: oklch(1.0000 0 0); + + /* Primary accent - Twitter blue */ + --color-neo-accent: oklch(0.6723 0.1606 244.9955); + + /* Status colors - all use accent blue except danger */ + --color-neo-pending: oklch(0.6723 0.1606 244.9955); + --color-neo-progress: oklch(0.6723 0.1606 244.9955); + --color-neo-done: oklch(0.6723 0.1606 244.9955); + --color-neo-danger: oklch(0.6188 0.2376 25.7658); + + /* Borders and neutrals */ + --color-neo-border: oklch(0.9317 0.0118 231.6594); + --color-neo-neutral-50: oklch(0.9809 0.0025 228.7836); + --color-neo-neutral-100: oklch(0.9392 0.0166 250.8453); + --color-neo-neutral-200: oklch(0.9222 0.0013 286.3737); + --color-neo-neutral-300: oklch(0.9317 0.0118 231.6594); + + /* No shadows */ + --shadow-neo-sm: none; + --shadow-neo-md: none; + --shadow-neo-lg: none; + --shadow-neo-xl: none; + --shadow-neo-left: none; + --shadow-neo-inset: none; + + /* Typography */ + --font-neo-sans: Open Sans, sans-serif; + --font-neo-mono: Menlo, monospace; + + /* Radius - 1.3rem base */ + --radius-neo-sm: calc(1.3rem - 4px); + --radius-neo-md: calc(1.3rem - 2px); + --radius-neo-lg: 1.3rem; + --radius-neo-xl: calc(1.3rem + 4px); +} + +.dark { + /* Core colors - dark mode (Twitter dark style) */ + --color-neo-bg: oklch(0.08 0 0); + --color-neo-card: oklch(0.16 0.005 250); + --color-neo-text: oklch(0.95 0 0); + --color-neo-text-secondary: oklch(0.75 0 0); + --color-neo-text-muted: oklch(0.55 0 0); + --color-neo-text-on-bright: oklch(1.0 0 0); + + /* Primary accent */ + --color-neo-accent: oklch(0.6692 0.1607 245.0110); + + /* Status colors - all use accent blue except danger */ + --color-neo-pending: oklch(0.6692 0.1607 245.0110); + --color-neo-progress: oklch(0.6692 0.1607 245.0110); + --color-neo-done: oklch(0.6692 0.1607 245.0110); + --color-neo-danger: oklch(0.6188 0.2376 25.7658); + + /* Borders and neutrals - better contrast */ + --color-neo-border: oklch(0.30 0 0); + --color-neo-neutral-50: oklch(0.20 0 0); + --color-neo-neutral-100: oklch(0.25 0.01 250); + --color-neo-neutral-200: oklch(0.22 0 0); + --color-neo-neutral-300: oklch(0.30 0 0); + + /* No shadows */ + --shadow-neo-sm: none; + --shadow-neo-md: none; + --shadow-neo-lg: none; + --shadow-neo-xl: none; + --shadow-neo-left: none; + --shadow-neo-inset: none; +} + +/* ===== GLOBAL OVERRIDES ===== */ + +* { + box-shadow: none !important; +} + +/* ===== CARDS ===== */ +.neo-card, +[class*="neo-card"] { + border: 1px solid var(--color-neo-border) !important; + box-shadow: none !important; + transform: none !important; + border-radius: var(--radius-neo-lg) !important; + background-color: var(--color-neo-card) !important; +} + +.neo-card:hover, +[class*="neo-card"]:hover { + transform: none !important; + box-shadow: none !important; +} + +/* ===== BUTTONS ===== */ +.neo-btn, +[class*="neo-btn"], +button { + border-width: 1px !important; + box-shadow: none !important; + text-transform: none !important; + font-weight: 500 !important; + transform: none !important; + border-radius: var(--radius-neo-lg) !important; + font-family: var(--font-neo-sans) !important; +} + +.neo-btn:hover, +[class*="neo-btn"]:hover, +button:hover { + transform: none !important; + box-shadow: none !important; +} + +.neo-btn:active, +[class*="neo-btn"]:active { + transform: none !important; +} + +/* Primary button */ +.neo-btn-primary { + background-color: var(--color-neo-accent) !important; + border-color: var(--color-neo-accent) !important; + color: white !important; +} + +/* Success button - use accent blue instead of green */ +.neo-btn-success { + background-color: var(--color-neo-accent) !important; + border-color: var(--color-neo-accent) !important; + color: white !important; +} + +/* Danger button - subtle red */ +.neo-btn-danger { + background-color: var(--color-neo-danger) !important; + border-color: var(--color-neo-danger) !important; + color: white !important; +} + +/* ===== INPUTS ===== */ +.neo-input, +.neo-textarea, +input, +textarea, +select { + border: 1px solid var(--color-neo-border) !important; + box-shadow: none !important; + border-radius: var(--radius-neo-md) !important; + background-color: var(--color-neo-neutral-50) !important; +} + +.neo-input:focus, +.neo-textarea:focus, +input:focus, +textarea:focus, +select:focus { + box-shadow: none !important; + border-color: var(--color-neo-accent) !important; + outline: none !important; +} + +/* ===== BADGES ===== */ +.neo-badge, +[class*="neo-badge"] { + border: 1px solid var(--color-neo-border) !important; + box-shadow: none !important; + border-radius: var(--radius-neo-lg) !important; + font-weight: 500 !important; + text-transform: none !important; +} + +/* ===== PROGRESS BAR ===== */ +.neo-progress { + border: none !important; + box-shadow: none !important; + border-radius: var(--radius-neo-lg) !important; + background-color: var(--color-neo-neutral-100) !important; + overflow: hidden !important; + height: 0.75rem !important; +} + +.neo-progress-fill { + background-color: var(--color-neo-accent) !important; + border-radius: var(--radius-neo-lg) !important; +} + +.neo-progress-fill::after { + display: none !important; +} + +/* ===== KANBAN COLUMNS ===== */ +.kanban-column { + border: 1px solid var(--color-neo-border) !important; + border-radius: var(--radius-neo-lg) !important; + overflow: hidden; + background-color: var(--color-neo-bg) !important; + border-left: none !important; +} + +/* Left accent border on the whole column */ +.kanban-column.kanban-header-pending { + border-left: 3px solid var(--color-neo-accent) !important; +} + +.kanban-column.kanban-header-progress { + border-left: 3px solid var(--color-neo-accent) !important; +} + +.kanban-column.kanban-header-done { + border-left: 3px solid var(--color-neo-accent) !important; +} + +.kanban-header { + background-color: var(--color-neo-card) !important; + border-bottom: 1px solid var(--color-neo-border) !important; + border-left: none !important; +} + +/* ===== MODALS & DROPDOWNS ===== */ +.neo-modal, +[class*="neo-modal"], +[role="dialog"] { + border: 1px solid var(--color-neo-border) !important; + border-radius: var(--radius-neo-xl) !important; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.1) !important; +} + +.neo-dropdown, +[class*="dropdown"], +[role="menu"], +[data-radix-popper-content-wrapper] { + border: 1px solid var(--color-neo-border) !important; + border-radius: var(--radius-neo-lg) !important; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.08) !important; +} + +/* ===== STATUS BADGES ===== */ +[class*="bg-neo-pending"], +.bg-\[var\(--color-neo-pending\)\] { + background-color: var(--color-neo-neutral-100) !important; + color: var(--color-neo-text-secondary) !important; +} + +[class*="bg-neo-progress"], +.bg-\[var\(--color-neo-progress\)\] { + background-color: oklch(0.9392 0.0166 250.8453) !important; + color: var(--color-neo-accent) !important; +} + +[class*="bg-neo-done"], +.bg-\[var\(--color-neo-done\)\] { + background-color: oklch(0.9392 0.0166 250.8453) !important; + color: var(--color-neo-accent) !important; +} + +/* ===== REMOVE NEO EFFECTS ===== */ +[class*="shadow-neo"], +[class*="shadow-"] { + box-shadow: none !important; +} + +[class*="hover:translate"], +[class*="hover:-translate"], +[class*="translate-x"], +[class*="translate-y"] { + transform: none !important; +} + +/* ===== TEXT STYLING ===== */ +h1, h2, h3, h4, h5, h6, +[class*="heading"], +[class*="title"], +[class*="font-display"] { + text-transform: none !important; + font-family: var(--font-neo-sans) !important; +} + +.uppercase { + text-transform: none !important; +} + +strong, b, +[class*="font-bold"], +[class*="font-black"] { + font-weight: 600 !important; +} + +/* ===== SPECIFIC ELEMENT FIXES ===== */ + +/* Green badges should use accent color */ +[class*="bg-green"], +[class*="bg-emerald"], +[class*="bg-lime"] { + background-color: oklch(0.9392 0.0166 250.8453) !important; + color: var(--color-neo-accent) !important; +} + +/* Category badges */ +[class*="FUNCTIONAL"], +[class*="functional"] { + background-color: oklch(0.9392 0.0166 250.8453) !important; + color: var(--color-neo-accent) !important; +} + +/* Live/Status indicators - use accent instead of green */ +.text-\[var\(--color-neo-done\)\] { + color: var(--color-neo-accent) !important; +} + +/* Override any remaining borders to be thin */ +[class*="border-3"], +[class*="border-b-3"] { + border-width: 1px !important; +} + +/* ===== DARK MODE SPECIFIC FIXES ===== */ + +.dark .neo-card, +.dark [class*="neo-card"] { + background-color: var(--color-neo-card) !important; + border-color: var(--color-neo-border) !important; +} + +.dark .kanban-column { + background-color: var(--color-neo-card) !important; +} + +.dark .kanban-header { + background-color: var(--color-neo-neutral-50) !important; +} + +/* Feature cards in dark mode */ +.dark .neo-card .neo-card { + background-color: var(--color-neo-neutral-50) !important; +} + +/* Badges in dark mode - lighter background for visibility */ +.dark .neo-badge, +.dark [class*="neo-badge"] { + background-color: var(--color-neo-neutral-100) !important; + color: var(--color-neo-text) !important; + border-color: var(--color-neo-border) !important; +} + +/* Status badges in dark mode */ +.dark [class*="bg-neo-done"], +.dark .bg-\[var\(--color-neo-done\)\] { + background-color: oklch(0.25 0.05 245) !important; + color: var(--color-neo-accent) !important; +} + +.dark [class*="bg-neo-progress"], +.dark .bg-\[var\(--color-neo-progress\)\] { + background-color: oklch(0.25 0.05 245) !important; + color: var(--color-neo-accent) !important; +} + +/* Green badges in dark mode */ +.dark [class*="bg-green"], +.dark [class*="bg-emerald"], +.dark [class*="bg-lime"] { + background-color: oklch(0.25 0.05 245) !important; + color: var(--color-neo-accent) !important; +} + +/* Category badges in dark mode */ +.dark [class*="FUNCTIONAL"], +.dark [class*="functional"] { + background-color: oklch(0.25 0.05 245) !important; + color: var(--color-neo-accent) !important; +} + +/* Buttons in dark mode - better visibility */ +.dark .neo-btn, +.dark button { + border-color: var(--color-neo-border) !important; +} + +.dark .neo-btn-primary, +.dark .neo-btn-success { + background-color: var(--color-neo-accent) !important; + border-color: var(--color-neo-accent) !important; + color: white !important; +} + +/* Toggle buttons - fix "Graph" visibility */ +.dark [class*="text-neo-text"] { + color: var(--color-neo-text) !important; +} + +/* Inputs in dark mode */ +.dark input, +.dark textarea, +.dark select { + background-color: var(--color-neo-neutral-50) !important; + border-color: var(--color-neo-border) !important; + color: var(--color-neo-text) !important; +}