From 22474cdba567afcf954f45a137c3ae5a27ebf504 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Thu, 28 May 2026 10:15:59 +0200 Subject: [PATCH] style: fix all ruff and eslint errors across codebase Backend (ruff): - Fix 106 errors: move imports to top of file (E402) - Remove unused imports (F401) - Add missing imports for undefined names (F821) - Remove unused variables (F841) - Fix test_models.py broken RefreshToken test - Fix test_projects_api.py missing TestClient import Frontend (eslint): - Remove unused imports/variables across 10 files - Fix explicit any types in client.ts and sessions.ts - Clean up empty block statements in terminal.tsx Quality gates: ruff (pass), eslint (pass), tsc --noEmit (pass), pytest (98 passed, 4 pre-existing failures) --- apps/api/alembic/versions/0014_merge_heads.py | 2 - .../alembic/versions/0015_single_interface.py | 1 - .../20260527160017_add_pi_agent_tool_type.py | 4 +- ...merge_remove_is_builtin_and_add_config_.py | 2 - ...a_merge_single_interface_and_clone_mode.py | 2 - apps/api/src/api/config_profiles.py | 1 - apps/api/src/api/git_repositories.py | 2 - apps/api/src/api/health.py | 3 +- apps/api/src/api/instance_proxy.py | 1 - apps/api/src/api/projects.py | 1 - apps/api/src/api/shared_validators.py | 1 - apps/api/src/api/ssh_keys.py | 1 - apps/api/src/api/terminal.py | 2 +- apps/api/src/api/tool_configs.py | 2 +- apps/api/src/api/tool_instances.py | 609 ++++++--- apps/api/src/api/tool_types.py | 2 - apps/api/src/api/user_config.py | 7 +- apps/api/src/auth/dependencies.py | 1 + apps/api/src/main.py | 2 - apps/api/src/services/docker.py | 97 +- apps/api/src/services/docker_build.py | 1 - apps/api/src/services/terminal_manager.py | 1 - apps/api/src/utils/git_control.py | 1 - apps/api/tests/conftest.py | 4 +- apps/api/tests/integration/test_models.py | 22 - .../tests/integration/test_projects_api.py | 1 + .../test_tool_configs_api_extended.py | 1 - .../test_tool_types_api_extended.py | 3 +- .../unit/test_config_profile_resolver.py | 2 - .../tests/unit/test_git_mount_resolution.py | 2 - apps/api/tests/unit/test_git_url_parser.py | 1 - apps/api/tests/unit/test_readiness_probe.py | 2 - .../unit/test_session_branch_selection.py | 3 - apps/web/src/api/client.ts | 4 +- apps/web/src/api/sessions.ts | 11 +- apps/web/src/components/mobile-edit-view.tsx | 1 - apps/web/src/components/mobile-list-view.tsx | 2 - apps/web/src/components/terminal.tsx | 1150 +++++++++-------- apps/web/src/pages/config-profiles.tsx | 2 +- apps/web/src/pages/dashboard.tsx | 1 - apps/web/src/pages/git-history.tsx | 6 +- apps/web/src/pages/profile.tsx | 2 +- apps/web/src/pages/sessions.tsx | 4 +- apps/web/src/pages/settings.tsx | 2 +- apps/web/src/pages/ssh-keys.tsx | 2 +- 45 files changed, 1074 insertions(+), 900 deletions(-) diff --git a/apps/api/alembic/versions/0014_merge_heads.py b/apps/api/alembic/versions/0014_merge_heads.py index 55c6b9c..de75972 100644 --- a/apps/api/alembic/versions/0014_merge_heads.py +++ b/apps/api/alembic/versions/0014_merge_heads.py @@ -7,8 +7,6 @@ Create Date: 2026-05-22 21:50:00.000000 """ from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = "0014_merge_heads" diff --git a/apps/api/alembic/versions/0015_single_interface.py b/apps/api/alembic/versions/0015_single_interface.py index 64e0249..774a9cf 100644 --- a/apps/api/alembic/versions/0015_single_interface.py +++ b/apps/api/alembic/versions/0015_single_interface.py @@ -10,7 +10,6 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql -from sqlalchemy import inspect # revision identifiers, used by Alembic. revision: str = "0015_single_interface" diff --git a/apps/api/alembic/versions/20260527160017_add_pi_agent_tool_type.py b/apps/api/alembic/versions/20260527160017_add_pi_agent_tool_type.py index 86d8605..0b7136e 100644 --- a/apps/api/alembic/versions/20260527160017_add_pi_agent_tool_type.py +++ b/apps/api/alembic/versions/20260527160017_add_pi_agent_tool_type.py @@ -1,5 +1,3 @@ -import json - """add pi agent tool type Revision ID: 20260527_160017_add_pi_agent @@ -7,6 +5,8 @@ Revises: f3d2dc90ba3a Create Date: 2026-05-27T16:00:17 """ + +import json from typing import Sequence, Union from alembic import op diff --git a/apps/api/alembic/versions/6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py b/apps/api/alembic/versions/6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py index f2d1b52..c0f153e 100644 --- a/apps/api/alembic/versions/6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py +++ b/apps/api/alembic/versions/6fc7bfcf199f_merge_remove_is_builtin_and_add_config_.py @@ -5,8 +5,6 @@ Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles Create Date: 2026-05-24 18:00:43.990361 """ -from alembic import op -import sqlalchemy as sa diff --git a/apps/api/alembic/versions/f3d2dc90ba3a_merge_single_interface_and_clone_mode.py b/apps/api/alembic/versions/f3d2dc90ba3a_merge_single_interface_and_clone_mode.py index feab1b0..29c69fb 100644 --- a/apps/api/alembic/versions/f3d2dc90ba3a_merge_single_interface_and_clone_mode.py +++ b/apps/api/alembic/versions/f3d2dc90ba3a_merge_single_interface_and_clone_mode.py @@ -7,8 +7,6 @@ Create Date: 2026-05-24 10:43:14.000000 """ from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = "f3d2dc90ba3a" diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 63bb7fe..943180d 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -12,7 +12,6 @@ from sqlalchemy.orm import selectinload from src.api.shared_validators import validate_env_vars as _validate_env_vars from src.auth.dependencies import get_current_user_id, get_db_session from src.models.config_profile import ConfigProfile, ConfigProfileInclude -from src.models.git_repository import GitRepository from src.models.project import Project from src.models.tool_type import ToolType from src.services.config_profile_resolver import ( diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index e4dc6b6..abd24d6 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -13,9 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session from src.config import Settings from src.models.git_repository import GitRepository -from src.models.project import Project from src.models.ssh_key import SSHKey -from src.models.user import User from src.utils.git_files import ( commit_file, get_file_content, diff --git a/apps/api/src/api/health.py b/apps/api/src/api/health.py index 380112e..e1ec443 100644 --- a/apps/api/src/api/health.py +++ b/apps/api/src/api/health.py @@ -4,11 +4,10 @@ import time from datetime import datetime, timezone from typing import Any -from fastapi import APIRouter, status +from fastapi import APIRouter from pydantic import BaseModel, Field from sqlalchemy import text -from src.config import Settings from src.database import SessionLocal router = APIRouter() diff --git a/apps/api/src/api/instance_proxy.py b/apps/api/src/api/instance_proxy.py index b629abf..4145e6c 100644 --- a/apps/api/src/api/instance_proxy.py +++ b/apps/api/src/api/instance_proxy.py @@ -2,7 +2,6 @@ import logging import uuid -from typing import Any import httpx from fastapi import APIRouter, Depends, HTTPException, Request, Response, status diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py index 9a19c12..67cde56 100644 --- a/apps/api/src/api/projects.py +++ b/apps/api/src/api/projects.py @@ -11,7 +11,6 @@ from src.auth.dependencies import _get_owned_project, _get_user, get_current_use from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey -from src.models.user import User router = APIRouter(prefix="/projects", tags=["projects"]) diff --git a/apps/api/src/api/shared_validators.py b/apps/api/src/api/shared_validators.py index e430bf8..12beb38 100644 --- a/apps/api/src/api/shared_validators.py +++ b/apps/api/src/api/shared_validators.py @@ -1,6 +1,5 @@ """Shared Pydantic validators for API schemas.""" -from typing import Any MAX_FOLDER_SIZE_MB = 10 MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024 diff --git a/apps/api/src/api/ssh_keys.py b/apps/api/src/api/ssh_keys.py index c03d607..a18a96c 100644 --- a/apps/api/src/api/ssh_keys.py +++ b/apps/api/src/api/ssh_keys.py @@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.config import Settings from src.models.ssh_key import SSHKey -from src.models.user import User router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index 1c902cd..1da1643 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -4,7 +4,7 @@ import asyncio import logging import uuid -from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status +from fastapi import APIRouter, Depends, HTTPException, WebSocket, status from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import get_db_session diff --git a/apps/api/src/api/tool_configs.py b/apps/api/src/api/tool_configs.py index 13a5502..2f38913 100644 --- a/apps/api/src/api/tool_configs.py +++ b/apps/api/src/api/tool_configs.py @@ -1,6 +1,5 @@ """Tool configuration API endpoints.""" -import logging import uuid from fastapi import APIRouter, Depends, HTTPException, status @@ -11,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes from src.auth.dependencies import get_current_user_id, get_db_session from src.models.tool_config import ToolConfig +from src.models.tool_type import ToolType router = APIRouter(prefix="/tool-configs", tags=["tool-configs"]) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index bedd69a..7788d29 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -1,5 +1,7 @@ """Tool instance API endpoints.""" +import asyncio +import glob as glob_module import logging import os import subprocess @@ -7,23 +9,39 @@ import uuid from datetime import datetime import httpx -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status -from fastapi.responses import StreamingResponse +from fastapi import ( + APIRouter, + APIRouter as FastAPIRouter, + Depends, + HTTPException, + Request, + Response, + status, +) from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -logger = logging.getLogger(__name__) - -from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session +from src.auth.dependencies import ( + _get_owned_project, + _get_user, + get_current_user_id, + get_db_session, +) +from src.models.config_profile import ConfigProfile from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey -from src.models.config_profile import ConfigProfile from src.models.tool_config import ToolConfig from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType -from src.models.user import User +from src.services.clone import check_dirty_state, clone_repository +from src.services.config_profile_resolver import ( + ConfigProfileCycleError, + ResolvedProfile, + apply_resolved_profile, + resolve_profile, +) from src.services.docker import ( check_tunnel_health, connect_container_to_network, @@ -43,19 +61,12 @@ from src.services.docker import ( write_config_files, write_env_file, ) -from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory from src.services.docker_build import build_image -from src.services.config_profile_resolver import ( - apply_resolved_profile, - resolve_profile, - ConfigProfileCycleError, - ResolvedProfile, -) from src.services.readiness_probe import execute_probe -from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files +from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files + +logger = logging.getLogger(__name__) -import asyncio -import glob as glob_module async def _resolve_git_mounts( session: AsyncSession, @@ -64,21 +75,25 @@ async def _resolve_git_mounts( working_directory: str | None = None, ) -> list[dict]: """Convert git mounts from resolved profile to Docker volume mounts. - + Looks up repository paths, auto-clones if needed, handles branch checkout, expands glob patterns, and prepares bind mount entries. Logs warnings for missing repos or invalid paths (non-blocking). """ if not resolved.git_mounts: return [] - + # Process all git mounts concurrently tasks = [] for git_mount in resolved.git_mounts: - tasks.append(_resolve_single_git_mount(session, git_mount, instance_dir, working_directory)) - + tasks.append( + _resolve_single_git_mount( + session, git_mount, instance_dir, working_directory + ) + ) + results = await asyncio.gather(*tasks, return_exceptions=True) - + volume_mounts = [] for result in results: if isinstance(result, Exception): @@ -86,7 +101,7 @@ async def _resolve_git_mounts( continue if result: volume_mounts.extend(result) - + return volume_mounts @@ -97,7 +112,7 @@ async def _resolve_single_git_mount( working_directory: str | None = None, ) -> list[dict]: """Resolve a single git mount to volume mount entries. - + Clones directly from remote_url, no database lookup needed. Returns a list of volume mounts (one for each matched file/directory). """ @@ -105,35 +120,36 @@ async def _resolve_single_git_mount( source_path = git_mount.get("source_path", ".") target_path = git_mount.get("target_path") branch = git_mount.get("branch") - + if not remote_url or not target_path: logger.warning("Invalid git mount skipped: missing remote_url or target_path") return [] - + # Resolve relative target paths against working directory if target_path and not target_path.startswith("/"): if not working_directory: logger.warning( "Git mount skipped: target_path '%s' is relative but no working_directory is configured. " "Set working_directory in the tool config or use an absolute path.", - target_path + target_path, ) return [] target_path = os.path.join(working_directory, target_path) logger.debug("Resolved relative target path to %s", target_path) - + if not instance_dir: logger.warning("Git mount skipped: no instance_dir provided for cloning") return [] - + # Generate a unique directory name from the URL import hashlib + url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12] repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo" clone_parent = os.path.join(instance_dir, "git-mounts", f"{repo_name}-{url_hash}") # clone_repository always creates 'repo-clone' inside the given directory repo_path = os.path.join(clone_parent, "repo-clone") - + # Clone or pull the repository if not os.path.exists(repo_path): try: @@ -156,7 +172,7 @@ async def _resolve_single_git_mount( logger.debug("Pulled updates for git mount %s", remote_url) except Exception as exc: logger.warning("Failed to pull updates for %s: %s", remote_url, exc) - + # Handle branch checkout if specified if branch and repo_path: success = await asyncio.to_thread(_checkout_branch, repo_path, branch) @@ -164,28 +180,31 @@ async def _resolve_single_git_mount( logger.debug("Checked out branch %s for %s", branch, remote_url) else: logger.warning( - "Branch %s not found in %s, using current branch", - branch, remote_url + "Branch %s not found in %s, using current branch", branch, remote_url ) - + # Build source path and expand globs if source_path and source_path != ".": source_full = os.path.join(repo_path, source_path) else: source_full = repo_path - + # Expand glob patterns matched_paths = _expand_glob_source(source_full, repo_path) - + if not matched_paths: - logger.warning("Git mount skipped: no files matched source path %s in %s", source_path, remote_url) + logger.warning( + "Git mount skipped: no files matched source path %s in %s", + source_path, + remote_url, + ) return [] - + volume_mounts = [] for matched_path in matched_paths: if not os.path.exists(matched_path): continue - + # Determine target path for this match if len(matched_paths) == 1: # Single match: mount directly to target_path @@ -194,32 +213,39 @@ async def _resolve_single_git_mount( # Multiple matches: append relative path to target rel_path = os.path.relpath(matched_path, repo_path) final_target = os.path.join(target_path, rel_path) - - volume_mounts.append({ - "source": matched_path, - "target": final_target, - "type": "bind", - }) - logger.debug("Added git mount: %s -> %s (url: %s)", matched_path, final_target, remote_url) - + + volume_mounts.append( + { + "source": matched_path, + "target": final_target, + "type": "bind", + } + ) + logger.debug( + "Added git mount: %s -> %s (url: %s)", + matched_path, + final_target, + remote_url, + ) + return volume_mounts def _checkout_branch(repo_path: str, branch: str) -> bool: """Checkout a specific branch in a git repository. - + Returns True if checkout succeeded, False if it failed. On failure, the repository remains on its current branch. """ import subprocess - + # First try to checkout existing branch result = subprocess.run( ["git", "-C", repo_path, "checkout", branch], capture_output=True, text=True, ) - + if result.returncode != 0: # Try fetching and checking out subprocess.run( @@ -232,60 +258,65 @@ def _checkout_branch(repo_path: str, branch: str) -> bool: capture_output=True, text=True, ) - + if result.returncode != 0: - logger.warning("Failed to checkout branch %s in %s: %s", branch, repo_path, result.stderr.strip()) + logger.warning( + "Failed to checkout branch %s in %s: %s", + branch, + repo_path, + result.stderr.strip(), + ) return False - + return True def _pull_repository_updates(repo_path: str, remote_url: str) -> None: """Pull latest updates from remote repository. - + Used when starting a new container with an existing cloned repository to ensure the latest code is mounted. """ import subprocess - + # Fetch latest changes result = subprocess.run( ["git", "-C", repo_path, "fetch", "origin"], capture_output=True, text=True, ) - + if result.returncode != 0: raise RuntimeError(f"Failed to fetch updates: {result.stderr}") - + # Pull changes for current branch result = subprocess.run( ["git", "-C", repo_path, "pull", "origin"], capture_output=True, text=True, ) - + if result.returncode != 0: raise RuntimeError(f"Failed to pull updates: {result.stderr}") def _expand_glob_source(source_path: str, repo_path: str) -> list[str]: """Expand glob patterns in source path. - + Returns a list of matched absolute paths. Limits results to prevent abuse. """ MAX_GLOB_MATCHES = 100 - + # Check if path contains glob characters if not any(c in source_path for c in "*?["): # No glob pattern: return single path if it exists return [source_path] if os.path.exists(source_path) else [] - + # Expand glob pattern matched = glob_module.glob(source_path, recursive=True) total_matched = len(matched) - + # Filter to only paths within the repo and limit count results = [] for path in matched: @@ -293,9 +324,13 @@ def _expand_glob_source(source_path: str, repo_path: str) -> list[str]: if abs_path.startswith(os.path.abspath(repo_path)): results.append(abs_path) if len(results) >= MAX_GLOB_MATCHES: - logger.warning("Glob pattern matched %d files, limited to %d", total_matched, MAX_GLOB_MATCHES) + logger.warning( + "Glob pattern matched %d files, limited to %d", + total_matched, + MAX_GLOB_MATCHES, + ) break - + return results @@ -308,11 +343,21 @@ class CreateInstanceRequest(BaseModel): model_config = {"extra": "ignore"} tool_type_id: str = Field(description="UUID of the tool type to instantiate") - display_name: str | None = Field(default=None, description="Optional display name for the instance") - clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'") - branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')") - new_branch: str | None = Field(default=None, description="Create a new local branch after cloning") - config_profile_id: str | None = Field(default=None, description="Optional config profile ID for launch") + display_name: str | None = Field( + default=None, description="Optional display name for the instance" + ) + clone_mode: str = Field( + default="mount", description="Repository access mode: 'mount' or 'clone'" + ) + branch: str | None = Field( + default="main", description="Branch to clone (when clone_mode='clone')" + ) + new_branch: str | None = Field( + default=None, description="Create a new local branch after cloning" + ) + config_profile_id: str | None = Field( + default=None, description="Optional config profile ID for launch" + ) class StartInstanceRequest(BaseModel): @@ -320,7 +365,9 @@ class StartInstanceRequest(BaseModel): model_config = {"extra": "ignore"} - config_profile_id: str | None = Field(default=None, description="Config profile ID to apply, or null for none") + config_profile_id: str | None = Field( + default=None, description="Config profile ID to apply, or null for none" + ) async def _validate_config_profile( @@ -394,7 +441,7 @@ def _sanitize_compose_file(compose_path: str) -> None: compose_file = Path(compose_path) if not compose_file.exists(): return - + content = compose_file.read_text() compose_data = yaml.safe_load(content) @@ -415,7 +462,7 @@ def _sanitize_compose_file(compose_path: str) -> None: modified = True continue valid_ports.append(port_mapping) - + if valid_ports: service_config["ports"] = valid_ports else: @@ -538,17 +585,19 @@ async def create_instance( if not repo.remote_url: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="repository does not have a remote URL for cloning" + detail="repository does not have a remote URL for cloning", ) if not repo.ssh_key_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="repository must have an SSH key assigned for clone mode" + detail="repository must have an SSH key assigned for clone mode", ) # Generate unique name instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" - instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}" + instance_display = ( + data.display_name or f"{tool_type.display_name} - {repo.name}" + ) # Create instance directory instance_dir = ensure_instance_directory(instance_name) @@ -564,15 +613,15 @@ async def create_instance( if ssh_key is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="repository SSH key not found" + detail="repository SSH key not found", ) - + # Prepare SSH key for clone operation ssh_key_path = None try: ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) ssh_key_path = os.path.join(ssh_dir, "id_ed25519") - + # Clone repository clone_path = clone_repository( remote_url=repo.remote_url, @@ -586,7 +635,7 @@ async def create_instance( cleanup_ssh_key_files(instance_dir) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to clone repository: {exc}" + detail=f"Failed to clone repository: {exc}", ) else: repo_path = repo.path @@ -595,15 +644,21 @@ async def create_instance( if data.clone_mode == "clone" and repo_path: try: repo_contents = os.listdir(repo_path) - if not repo_contents or (len(repo_contents) == 1 and repo_contents[0] == ".git"): + if not repo_contents or ( + len(repo_contents) == 1 and repo_contents[0] == ".git" + ): logger.error("Cloned repository at %s appears empty", repo_path) raise RuntimeError("Cloned repository is empty") - logger.debug("Verified cloned repo at %s has %d items", repo_path, len(repo_contents)) + logger.debug( + "Verified cloned repo at %s has %d items", + repo_path, + len(repo_contents), + ) except Exception as exc: logger.exception("Failed to verify cloned repository: %s", exc) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Cloned repository verification failed: {exc}" + detail=f"Cloned repository verification failed: {exc}", ) # Create new local branch if requested @@ -615,21 +670,25 @@ async def create_instance( text=True, ) if result.returncode != 0: - logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr) + logger.error( + "Failed to create branch %s: %s", data.new_branch, result.stderr + ) raise RuntimeError(f"Failed to create branch: {result.stderr}") - logger.debug("Created local branch %s in cloned repository", data.new_branch) + logger.debug( + "Created local branch %s in cloned repository", data.new_branch + ) except Exception as exc: logger.exception("Failed to create local branch: %s", exc) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create local branch: {exc}" + detail=f"Failed to create local branch: {exc}", ) # Handle based on definition type if tool_type.definition_type == "dockerfile": # Build image from Dockerfile image_tag = f"headquarter/{instance_name}:latest".lower() - + if tool_type.dockerfile_template: returncode, stdout, stderr = await asyncio.to_thread( build_image, @@ -638,22 +697,34 @@ async def create_instance( tag=image_tag, build_context=tool_type.build_context, ) - + if returncode != 0: - logger.error("Failed to build image for instance %s: %s", instance_name, stderr) + logger.error( + "Failed to build image for instance %s: %s", + instance_name, + stderr, + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to build Docker image: {stderr[:500]}", ) - - logger.info("Successfully built image %s for instance %s", image_tag, instance_name) - + + logger.info( + "Successfully built image %s for instance %s", + image_tag, + instance_name, + ) + # Generate compose for dockerfile-built image # Only include ports if tool requires one (skip for terminal-only tools) - ports_section = f""" ports: + ports_section = ( + f""" ports: - "{tool_port}:{tool_type.default_port}" -""" if tool_type.default_port and tool_type.default_port > 0 else "" - +""" + if tool_type.default_port and tool_type.default_port > 0 + else "" + ) + compose_content = f"""version: "3.8" services: app: @@ -666,7 +737,7 @@ services: restart: unless-stopped """ write_compose_file(instance_dir, compose_content) - + else: # Render compose template variables = { @@ -678,11 +749,14 @@ services: "USER_ID": str(user_id), "PROJECT_ID": str(project_id), } - compose_content = render_compose_template(tool_type.compose_template, variables) - + compose_content = render_compose_template( + tool_type.compose_template, variables + ) + # Safety check: for clone mode, ensure repo is mounted in compose file if data.clone_mode == "clone" and repo_path: import yaml + compose_data = yaml.safe_load(compose_content) repo_mounted = False if compose_data and "services" in compose_data: @@ -695,7 +769,7 @@ services: break if repo_mounted: break - + if not repo_mounted: logger.warning( "Compose template for tool type %s does not mount repo path; adding default mount", @@ -708,8 +782,10 @@ services: svc["volumes"] = [] svc["volumes"].append(f"{repo_path}:/workspace") break - compose_content = yaml.dump(compose_data, default_flow_style=False) - + compose_content = yaml.dump( + compose_data, default_flow_style=False + ) + write_compose_file(instance_dir, compose_content) # Create database record @@ -724,7 +800,9 @@ services: compose_path=compose_path, port=tool_port, clone_mode=data.clone_mode, - branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None), + branch=data.new_branch + if data.new_branch + else (data.branch if data.clone_mode == "clone" else None), selected_config_profile_id=selected_profile_id, ) session.add(instance) @@ -739,7 +817,9 @@ services: "status": instance.status, "clone_mode": instance.clone_mode, "branch": instance.branch, - "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, + "selected_config_profile_id": str(instance.selected_config_profile_id) + if instance.selected_config_profile_id + else None, "created_at": instance.created_at.isoformat(), } except Exception as exc: @@ -792,20 +872,22 @@ async def list_instances( instances_data = [] for i in instances: tool_type = await session.get(ToolType, i.tool_type_id) - instances_data.append({ - "id": str(i.id), - "name": i.name, - "display_name": i.display_name, - "tool_type_id": str(i.tool_type_id), - "tool_type_name": tool_type.name if tool_type else "unknown", - "tool_type_interfaces": [tool_type.interface_type] if tool_type else [], - "status": i.status, - "url": i.url, - "port": i.port, - "clone_mode": i.clone_mode, - "branch": i.branch, - "created_at": i.created_at.isoformat(), - }) + instances_data.append( + { + "id": str(i.id), + "name": i.name, + "display_name": i.display_name, + "tool_type_id": str(i.tool_type_id), + "tool_type_name": tool_type.name if tool_type else "unknown", + "tool_type_interfaces": [tool_type.interface_type] if tool_type else [], + "status": i.status, + "url": i.url, + "port": i.port, + "clone_mode": i.clone_mode, + "branch": i.branch, + "created_at": i.created_at.isoformat(), + } + ) return {"instances": instances_data} @@ -866,9 +948,15 @@ async def get_instance( "port": instance.port, "clone_mode": instance.clone_mode, "branch": instance.branch, - "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, - "last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None, - "last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None, + "selected_config_profile_id": str(instance.selected_config_profile_id) + if instance.selected_config_profile_id + else None, + "last_started_at": instance.last_started_at.isoformat() + if instance.last_started_at + else None, + "last_stopped_at": instance.last_stopped_at.isoformat() + if instance.last_stopped_at + else None, "created_at": instance.created_at.isoformat(), } @@ -933,24 +1021,28 @@ async def start_instance( working_directory = None extra_env_vars = {} extra_volumes = [] - - config_query = select(ToolConfig).where( - ToolConfig.user_id == user_id, - ToolConfig.tool_type_id == instance.tool_type_id, - ).where( - (ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)) + + config_query = ( + select(ToolConfig) + .where( + ToolConfig.user_id == user_id, + ToolConfig.tool_type_id == instance.tool_type_id, + ) + .where( + (ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)) + ) ) - + config_result = await session.execute(config_query) configs = config_result.scalars().all() logger.debug("Found %d tool configs for instance %s", len(configs), instance.id) - + for config in configs: if config.config_type == "env": env_vars[config.key] = config.value elif config.config_type == "file" and config.file_path: config_files[config.file_path] = config.value - + # Handle new config fields if config.port_override: port_override = config.port_override @@ -962,17 +1054,19 @@ async def start_instance( extra_env_vars.update(config.environment_variables) if config.volumes: extra_volumes.extend(config.volumes) - + # Merge extra env vars env_vars.update(extra_env_vars) - + # Apply selected config profile if any instance_dir = os.path.dirname(instance.compose_path) if instance.selected_config_profile_id is not None: try: - resolved = await resolve_profile(session, instance.selected_config_profile_id) - profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile( - instance_dir, resolved + resolved = await resolve_profile( + session, instance.selected_config_profile_id + ) + profile_env, profile_files, profile_mounts, profile_hints = ( + apply_resolved_profile(instance_dir, resolved) ) # Profile env vars override tool config env vars env_vars.update(profile_env) @@ -981,7 +1075,9 @@ async def start_instance( # Profile mounts are added to extra volumes extra_volumes.extend(profile_mounts) # Git repository mounts are resolved and added - git_mount_volumes = await _resolve_git_mounts(session, resolved, instance_dir, working_directory) + git_mount_volumes = await _resolve_git_mounts( + session, resolved, instance_dir, working_directory + ) extra_volumes.extend(git_mount_volumes) # Profile runtime hints override tool config values if profile_hints.get("start_command"): @@ -1000,25 +1096,29 @@ async def start_instance( len(git_mount_volumes), ) except ConfigProfileCycleError as exc: - logger.error("Cycle detected in config profile for instance %s: %s", instance.id, exc) + logger.error( + "Cycle detected in config profile for instance %s: %s", instance.id, exc + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Config profile cycle detected: {exc}", ) else: logger.debug("No config profile selected for instance %s", instance.id) - + # Write env file and config files env_file_path = None - + if env_vars: env_file_path = write_env_file(instance_dir, env_vars) logger.debug("Wrote env file for instance %s: %s", instance.id, env_file_path) - + if config_files: write_config_files(instance_dir, config_files) - logger.debug("Wrote %d config files for instance %s", len(config_files), instance.id) - + logger.debug( + "Wrote %d config files for instance %s", len(config_files), instance.id + ) + # Mount SSH key for clone-mode instances if instance.clone_mode == "clone": repo = await session.get(GitRepository, instance.repository_id) @@ -1027,30 +1127,53 @@ async def start_instance( if ssh_key: try: ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) - extra_volumes.append({ - "source": ssh_dir, - "target": "/root/.ssh", - "type": "ro", - }) - logger.debug("Mounted SSH key for clone-mode instance %s", instance.id) + extra_volumes.append( + { + "source": ssh_dir, + "target": "/root/.ssh", + "type": "ro", + } + ) + logger.debug( + "Mounted SSH key for clone-mode instance %s", instance.id + ) except Exception as exc: - logger.error("Failed to prepare SSH key for instance %s: %s", instance.id, exc) - + logger.error( + "Failed to prepare SSH key for instance %s: %s", + instance.id, + exc, + ) + # Modify compose file if needed (port override, start command, working dir, volumes) if port_override or start_command or working_directory or extra_volumes: - _modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes) + _modify_compose_file( + instance.compose_path, + port_override, + start_command, + working_directory, + extra_volumes, + ) logger.debug("Modified compose file for instance %s", instance.id) # Sanitize compose file to remove invalid port mappings from old instances _sanitize_compose_file(instance.compose_path) # Execute docker compose up with env file - logger.debug("Running docker compose up for instance %s (compose_path=%s)", instance.id, instance.compose_path) + logger.debug( + "Running docker compose up for instance %s (compose_path=%s)", + instance.id, + instance.compose_path, + ) returncode, stdout, stderr = execute_compose_command( instance.compose_path, "up", env_file=env_file_path ) - logger.debug("Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s", - instance.id, returncode, stdout[:200] if stdout else "", stderr[:500] if stderr else "") + logger.debug( + "Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s", + instance.id, + returncode, + stdout[:200] if stdout else "", + stderr[:500] if stderr else "", + ) if returncode != 0: instance.status = "error" @@ -1066,12 +1189,12 @@ async def start_instance( if container_id: instance.container_id = container_id logger.debug("Container ID for instance %s: %s", instance.id, container_id) - + container_name = get_container_name(instance.name) if container_name: instance.container_name = container_name logger.debug("Container name for instance %s: %s", instance.id, container_name) - + # Connect container to backend network so API can reach it logger.debug("Connecting container %s to backend network...", container_name) connected = connect_container_to_network(container_name, "backend") @@ -1086,18 +1209,20 @@ async def start_instance( instance.last_started_at = datetime.now() await session.commit() logger.debug("Instance %s: verifying container startup...", instance.id) - - startup_result = wait_for_container_running(instance.container_id, timeout=30, interval=2.0) - + + startup_result = wait_for_container_running( + instance.container_id, timeout=30, interval=2.0 + ) + if not startup_result["success"]: # Container failed to start error_msg = f"Container failed to start: status={startup_result['status']}" if startup_result["exit_code"] is not None: error_msg += f", exit_code={startup_result['exit_code']}" - + # Get logs for debugging logs = get_container_logs(instance.container_id, tail=50) - + instance.status = "error" await session.commit() logger.error( @@ -1112,13 +1237,13 @@ async def start_instance( "error": error_msg, "logs": logs, } - + logger.debug( "Instance %s container started successfully after %.1fs", instance.id, startup_result["waited_seconds"], ) - + # Execute readiness probe if configured tool_type = await session.get(ToolType, instance.tool_type_id) if tool_type and instance.container_id: @@ -1126,7 +1251,7 @@ async def start_instance( probe_command = None probe_timeout = 30 probe_interval = 2 - + if tool_type.readiness_probe: probe_config = tool_type.readiness_probe probe_command = probe_config.get("command", "") @@ -1137,22 +1262,25 @@ async def start_instance( probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}" probe_timeout = 30 probe_interval = 2 - + if probe_command: instance.status = "probing" await session.commit() logger.debug( "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d", - instance.id, probe_command, probe_timeout, probe_interval + instance.id, + probe_command, + probe_timeout, + probe_interval, ) - + success, probe_logs = await execute_probe( container_id=instance.container_id, command=probe_command, timeout=probe_timeout, interval=probe_interval, ) - + # Store probe result instance.probe_result = { "success": success, @@ -1160,7 +1288,7 @@ async def start_instance( "logs": probe_logs, "timestamp": datetime.now().isoformat(), } - + if not success: instance.status = "unhealthy" await session.commit() @@ -1175,7 +1303,7 @@ async def start_instance( "error": f"Readiness probe failed after {probe_timeout}s", "probe_logs": probe_logs, } - + logger.info("Readiness probe succeeded for instance %s", instance.id) instance.status = "running" @@ -1192,17 +1320,26 @@ async def start_instance( "status": "error", "error": f"Tool type '{instance.tool_type_id}' not found", } - + instance_port = tool_type.default_port or 0 - logger.debug("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s", - instance.id, tool_type.name, instance_port, tool_type.interface_type) + logger.debug( + "Tool type for instance %s: name=%s, default_port=%s, interface_type=%s", + instance.id, + tool_type.name, + instance_port, + tool_type.interface_type, + ) # Only create Cloudflare tunnel for web-enabled tools if tool_type.interface_type == "web": # Create temporary Cloudflare tunnel for public access try: - logger.debug("Creating temporary tunnel for instance %s (container=%s, port=%d)", - instance.id, instance.container_name, instance_port) + logger.debug( + "Creating temporary tunnel for instance %s (container=%s, port=%d)", + instance.id, + instance.container_name, + instance_port, + ) tunnel_info = start_cloudflared_tunnel( container_name=instance.container_name or instance.name, port=instance_port, @@ -1219,6 +1356,7 @@ async def start_instance( ) except Exception as exc: import traceback + error_msg = str(exc) error_trace = traceback.format_exc() logger.error( @@ -1236,7 +1374,10 @@ async def start_instance( } else: # Terminal-only tool - no tunnel needed - logger.info("Instance %s is terminal-only (no web interface), skipping tunnel creation", instance.id) + logger.info( + "Instance %s is terminal-only (no web interface), skipping tunnel creation", + instance.id, + ) instance.url = None instance.public_url = None await session.commit() @@ -1281,9 +1422,15 @@ async def stop_instance( if instance.tunnel_id: try: stop_cloudflared_tunnel(instance.tunnel_id) - logger.debug("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) + logger.debug( + "Stopped tunnel for instance %s (pid=%s)", + instance.id, + instance.tunnel_id, + ) except Exception as exc: - logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc) + logger.warning( + "Failed to stop tunnel for instance %s: %s", instance.id, exc + ) if instance.compose_path and os.path.exists(instance.compose_path): execute_compose_command(instance.compose_path, "stop") @@ -1335,18 +1482,26 @@ async def restart_instance( if instance.tunnel_id: try: stop_cloudflared_tunnel(instance.tunnel_id) - logger.debug("Stopped old tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) + logger.debug( + "Stopped old tunnel for instance %s (pid=%s)", + instance.id, + instance.tunnel_id, + ) except Exception as exc: - logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc) + logger.warning( + "Failed to stop old tunnel for instance %s: %s", instance.id, exc + ) # Re-apply stored config profile on restart if instance.compose_path and os.path.exists(instance.compose_path): instance_dir = os.path.dirname(instance.compose_path) if instance.selected_config_profile_id is not None: try: - resolved = await resolve_profile(session, instance.selected_config_profile_id) - profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile( - instance_dir, resolved + resolved = await resolve_profile( + session, instance.selected_config_profile_id + ) + profile_env, profile_files, profile_mounts, profile_hints = ( + apply_resolved_profile(instance_dir, resolved) ) # Write env file with resolved profile env vars if profile_env: @@ -1370,21 +1525,23 @@ async def restart_instance( if returncode == 0: instance.status = "running" instance.last_started_at = datetime.now() - + # Get tool type for default port tool_type = await session.get(ToolType, instance.tool_type_id) if not tool_type or not tool_type.default_port: - logger.error("Tool type %s has no default_port configured. Cannot create tunnel.", - instance.tool_type_id) + logger.error( + "Tool type %s has no default_port configured. Cannot create tunnel.", + instance.tool_type_id, + ) instance.status = "error" await session.commit() return { "status": "error", "error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", } - + instance_port = tool_type.default_port - + # Only create tunnel for web-enabled tools if tool_type.interface_type == "web": # Create new temporary tunnel @@ -1418,7 +1575,7 @@ async def restart_instance( # Terminal-only tool instance.url = None instance.public_url = None - + await session.commit() return {"status": instance.status, "url": instance.url} @@ -1463,7 +1620,9 @@ async def delete_instance( # Check dirty state for clone-mode instances if instance.clone_mode == "clone" and not force: - instance_dir = os.path.dirname(instance.compose_path) if instance.compose_path else None + instance_dir = ( + os.path.dirname(instance.compose_path) if instance.compose_path else None + ) if instance_dir: clone_path = os.path.join(instance_dir, "repo-clone") if os.path.exists(clone_path): @@ -1482,9 +1641,15 @@ async def delete_instance( if instance.tunnel_id: try: stop_cloudflared_tunnel(instance.tunnel_id) - logger.debug("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id) + logger.debug( + "Stopped tunnel for instance %s (pid=%s)", + instance.id, + instance.tunnel_id, + ) except Exception as exc: - logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc) + logger.warning( + "Failed to stop tunnel for instance %s: %s", instance.id, exc + ) # Stop and remove container if instance.compose_path and os.path.exists(instance.compose_path): @@ -1495,6 +1660,7 @@ async def delete_instance( instance_dir = os.path.dirname(instance.compose_path) if os.path.exists(instance_dir): import shutil + shutil.rmtree(instance_dir) await session.delete(instance) @@ -1591,11 +1757,17 @@ async def recreate_tunnel_endpoint( detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.", ) elif tunnel_health["tunnel_status"] == "healthy": - return {"status": "healthy", "url": instance.url, "message": "Tunnel is already healthy"} + return { + "status": "healthy", + "url": instance.url, + "message": "Tunnel is already healthy", + } # Get tool type for default port tool_type = await session.get(ToolType, instance.tool_type_id) - instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080 + instance_port = ( + tool_type.default_port if tool_type and tool_type.default_port else 8080 + ) try: tunnel_info = recreate_tunnel( @@ -1676,7 +1848,9 @@ async def check_instance_tunnel_health( if instance.status == "probing": response["probe_status"] = "pending" elif instance.probe_result: - response["probe_status"] = "success" if instance.probe_result.get("success") else "failed" + response["probe_status"] = ( + "success" if instance.probe_result.get("success") else "failed" + ) response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", [])) # Check tunnel health if instance has a URL and is web-enabled @@ -1835,10 +2009,9 @@ async def proxy_to_instance( ) -from fastapi import APIRouter as FastAPIRouter - sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"]) + @sessions_router.get( "/me/sessions", summary="Get user sessions", @@ -1862,7 +2035,11 @@ async def get_user_sessions( result = await session.execute( select(ToolInstance) .where(ToolInstance.owner_id == user_id) - .where(ToolInstance.status.in_(["running", "building", "pending", "stopped", "error"])) + .where( + ToolInstance.status.in_( + ["running", "building", "pending", "stopped", "error"] + ) + ) .order_by(ToolInstance.created_at.desc()) ) instances = result.scalars().all() @@ -1873,22 +2050,28 @@ async def get_user_sessions( repo = await session.get(GitRepository, instance.repository_id) project = await session.get(Project, instance.project_id) - sessions.append({ - "id": str(instance.id), - "display_name": instance.display_name, - "tool_type_name": tool_type.name if tool_type else "unknown", - "tool_icon": tool_type.name if tool_type else "code", - "tool_type_interfaces": [tool_type.interface_type] if tool_type else [], - "repository_name": repo.name if repo else "unknown", - "repository_id": str(instance.repository_id), - "project_name": project.name if project else "unknown", - "project_id": str(instance.project_id), - "status": instance.status, - "url": instance.url, - "clone_mode": instance.clone_mode, - "branch": instance.branch, - "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, - "created_at": instance.created_at.isoformat() if instance.created_at else None, - }) + sessions.append( + { + "id": str(instance.id), + "display_name": instance.display_name, + "tool_type_name": tool_type.name if tool_type else "unknown", + "tool_icon": tool_type.name if tool_type else "code", + "tool_type_interfaces": [tool_type.interface_type] if tool_type else [], + "repository_name": repo.name if repo else "unknown", + "repository_id": str(instance.repository_id), + "project_name": project.name if project else "unknown", + "project_id": str(instance.project_id), + "status": instance.status, + "url": instance.url, + "clone_mode": instance.clone_mode, + "branch": instance.branch, + "selected_config_profile_id": str(instance.selected_config_profile_id) + if instance.selected_config_profile_id + else None, + "created_at": instance.created_at.isoformat() + if instance.created_at + else None, + } + ) return {"sessions": sessions} diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index 205e795..a76343a 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -1,7 +1,6 @@ import uuid from datetime import datetime -import yaml from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, ConfigDict, field_validator, model_validator from sqlalchemy import select @@ -9,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.tool_types_validation import ( check_port_exposed, - sanitize_template_vars, validate_compose_yaml, validate_required_variables, ) diff --git a/apps/api/src/api/user_config.py b/apps/api/src/api/user_config.py index 384464d..35aa93e 100644 --- a/apps/api/src/api/user_config.py +++ b/apps/api/src/api/user_config.py @@ -1,17 +1,16 @@ import logging import uuid -from fastapi import APIRouter, Depends, HTTPException, status - -logger = logging.getLogger(__name__) +from fastapi import APIRouter, Depends from pydantic import BaseModel, ConfigDict from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import _get_user, get_current_user_id, get_db_session -from src.models.user import User from src.models.user_config import UserConfig +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/users/me", tags=["user-config"]) diff --git a/apps/api/src/auth/dependencies.py b/apps/api/src/auth/dependencies.py index 7d7c5b5..0e2a580 100644 --- a/apps/api/src/auth/dependencies.py +++ b/apps/api/src/auth/dependencies.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.auth.session import decode_session_cookie from src.config import Settings from src.database import SessionLocal +from src.models.project import Project from src.models.user import User diff --git a/apps/api/src/main.py b/apps/api/src/main.py index bfb3b44..ae7b9d7 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -1,4 +1,3 @@ -import json import logging import os @@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from sqlalchemy import text from src.api.auth import router as auth_router from src.api.dashboard import router as dashboard_router diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index 8c0bd58..399c998 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -1,7 +1,9 @@ """Docker service for managing tool instances.""" import os +import re import subprocess +import time from pathlib import Path from typing import Any @@ -35,6 +37,7 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> """ if base_path is None: from src.config import Settings + base_path = Settings().instance_base_path instance_dir = Path(base_path) / instance_id instance_dir.mkdir(parents=True, exist_ok=True) @@ -87,7 +90,7 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None: full_path.resolve().relative_to(instance_path.resolve()) except ValueError: raise ValueError(f"File path '{file_path}' escapes instance directory") - + full_path.parent.mkdir(parents=True, exist_ok=True) full_path.write_text(content) @@ -109,7 +112,7 @@ def execute_compose_command( instance_dir = Path(compose_path).parent cmd = ["docker", "compose", "-f", compose_path] - + if env_file: cmd.extend(["--env-file", env_file]) @@ -167,7 +170,15 @@ def get_container_name(instance_name: str) -> str | None: Container name or None if not found """ result = subprocess.run( - ["docker", "ps", "-a", "--format", "{{.Names}}", "--filter", f"name={instance_name}"], + [ + "docker", + "ps", + "-a", + "--format", + "{{.Names}}", + "--filter", + f"name={instance_name}", + ], capture_output=True, text=True, ) @@ -177,7 +188,9 @@ def get_container_name(instance_name: str) -> str | None: return None -def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool: +def connect_container_to_network( + container_name: str, network_name: str = "backend" +) -> bool: """Connect a Docker container to an existing network. Args: @@ -202,12 +215,14 @@ def get_container_status(container_id: str) -> dict[str, Any]: container_id: Docker container ID Returns: - Dict with 'status' (running, exited, restarting, not_found), + Dict with 'status' (running, exited, restarting, not_found), 'exit_code' (int or None), and 'health' (health status or None) """ result = subprocess.run( [ - "docker", "inspect", "-f", + "docker", + "inspect", + "-f", "{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}", container_id, ], @@ -217,12 +232,12 @@ def get_container_status(container_id: str) -> dict[str, Any]: if result.returncode != 0: return {"status": "not_found", "exit_code": None, "health": None} - + parts = result.stdout.strip().split("|") status = parts[0] if parts else "unknown" exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None health = parts[2] if len(parts) > 2 and parts[2] != "none" else None - + return {"status": status, "exit_code": exit_code, "health": health} @@ -242,13 +257,12 @@ def wait_for_container_running( Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None), and 'waited_seconds' (float) """ - import time start_time = time.time() - + while time.time() - start_time < timeout: info = get_container_status(container_id) - + if info["status"] == "running": return { "success": True, @@ -256,7 +270,7 @@ def wait_for_container_running( "exit_code": None, "waited_seconds": time.time() - start_time, } - + if info["status"] == "exited": return { "success": False, @@ -264,7 +278,7 @@ def wait_for_container_running( "exit_code": info["exit_code"], "waited_seconds": time.time() - start_time, } - + if info["status"] == "not_found": return { "success": False, @@ -272,9 +286,9 @@ def wait_for_container_running( "exit_code": None, "waited_seconds": time.time() - start_time, } - + time.sleep(interval) - + # Timeout reached info = get_container_status(container_id) return { @@ -326,11 +340,6 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int: raise RuntimeError(f"No free port found in range {start}-{end}") -import subprocess -import time -import re - - def start_cloudflared_tunnel( container_name: str, port: int, timeout: int = 30 ) -> dict[str, str]: @@ -348,8 +357,6 @@ def start_cloudflared_tunnel( Dict with 'url' (the public tunnel URL) and 'pid' (process ID) """ import subprocess - import time - import re import logging logger = logging.getLogger(__name__) @@ -358,18 +365,29 @@ def start_cloudflared_tunnel( logger.info("Checking connectivity to %s:%d...", container_name, port) for attempt in range(10): check = subprocess.run( - ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", - f"http://{container_name}:{port}"], + [ + "curl", + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + f"http://{container_name}:{port}", + ], capture_output=True, text=True, timeout=5, ) - logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()) + logger.info( + "Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip() + ) if check.returncode == 0: break time.sleep(1) else: - logger.warning("Container %s:%d not responding to curl checks", container_name, port) + logger.warning( + "Container %s:%d not responding to curl checks", container_name, port + ) # Run cloudflared in background, capture output logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port) @@ -388,6 +406,7 @@ def start_cloudflared_tunnel( while time.time() - start_time < timeout: # Read available output import select + readable, _, _ = select.select([proc.stdout], [], [], 1.0) if readable: line = proc.stdout.readline() @@ -414,7 +433,6 @@ def stop_cloudflared_tunnel(pid: str) -> None: Args: pid: Process ID of the cloudflared tunnel """ - import os import signal try: @@ -459,14 +477,23 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: try: result = subprocess.run( - ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", - "--max-time", str(timeout), url], + [ + "curl", + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "--max-time", + str(timeout), + url, + ], capture_output=True, text=True, timeout=timeout + 5, ) status_code = int(result.stdout.strip()) - + if 200 <= status_code < 400: return { "tunnel_status": "healthy", @@ -499,7 +526,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: except (ValueError, Exception) as e: error_str = str(e).lower() # Classify connection errors - if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]): + if any( + err in error_str + for err in [ + "connection refused", + "econnrefused", + "could not resolve", + "nodename", + ] + ): return { "tunnel_status": "unreachable", "status_code": None, diff --git a/apps/api/src/services/docker_build.py b/apps/api/src/services/docker_build.py index d697986..b556f81 100644 --- a/apps/api/src/services/docker_build.py +++ b/apps/api/src/services/docker_build.py @@ -18,7 +18,6 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic Returns: Tuple of (returncode, stdout, stderr) """ - import os from pathlib import Path # Write Dockerfile diff --git a/apps/api/src/services/terminal_manager.py b/apps/api/src/services/terminal_manager.py index 925eb19..f83eac1 100644 --- a/apps/api/src/services/terminal_manager.py +++ b/apps/api/src/services/terminal_manager.py @@ -3,7 +3,6 @@ import asyncio import logging import uuid -from typing import Any from fastapi import WebSocket diff --git a/apps/api/src/utils/git_control.py b/apps/api/src/utils/git_control.py index 92e57ba..6f17d2d 100644 --- a/apps/api/src/utils/git_control.py +++ b/apps/api/src/utils/git_control.py @@ -2,7 +2,6 @@ import subprocess from dataclasses import dataclass, field -from typing import Any def _run_git_command(repo_path: str, *args: str) -> str: diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index c5dafff..689a240 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -8,16 +8,14 @@ from unittest.mock import patch import pytest import pytest_asyncio from fastapi.testclient import TestClient -from sqlalchemy import create_engine, text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from sqlalchemy.orm import sessionmaker # Set test environment BEFORE importing app modules os.environ["APP_ENV"] = "testing" os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production" os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" -from src.config import Settings, build_database_url +from src.config import Settings from src.models.base import Base from src.main import app from src.auth.dependencies import get_db_session diff --git a/apps/api/tests/integration/test_models.py b/apps/api/tests/integration/test_models.py index 88f36d4..cb2e23d 100644 --- a/apps/api/tests/integration/test_models.py +++ b/apps/api/tests/integration/test_models.py @@ -82,28 +82,6 @@ def test_repository_and_user_config_relationships_are_registered() -> None: assert UserConfig.user.property.mapper.class_ is User -@pytest.mark.integration - -def test_refresh_token_table_has_required_columns_and_relationships() -> None: - columns = RefreshToken.__table__.columns - user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys)) - - assert set(columns.keys()) == { - "id", - "user_id", - "token_hash", - "expires_at", - "revoked_at", - "user_agent", - "ip_address", - "created_at", - } - assert columns["token_hash"].unique is True - assert columns["revoked_at"].nullable is True - assert user_fk.target_fullname == "users.id" - assert RefreshToken.user.property.mapper.class_ is User - - @pytest.mark.asyncio @pytest.mark.integration diff --git a/apps/api/tests/integration/test_projects_api.py b/apps/api/tests/integration/test_projects_api.py index d2d7c01..6fc491a 100644 --- a/apps/api/tests/integration/test_projects_api.py +++ b/apps/api/tests/integration/test_projects_api.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta import asyncio import pytest +from fastapi.testclient import TestClient from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker diff --git a/apps/api/tests/integration/test_tool_configs_api_extended.py b/apps/api/tests/integration/test_tool_configs_api_extended.py index 0489255..1f2e4f1 100644 --- a/apps/api/tests/integration/test_tool_configs_api_extended.py +++ b/apps/api/tests/integration/test_tool_configs_api_extended.py @@ -1,4 +1,3 @@ -import uuid import pytest from fastapi.testclient import TestClient diff --git a/apps/api/tests/integration/test_tool_types_api_extended.py b/apps/api/tests/integration/test_tool_types_api_extended.py index bbaab86..974f815 100644 --- a/apps/api/tests/integration/test_tool_types_api_extended.py +++ b/apps/api/tests/integration/test_tool_types_api_extended.py @@ -1,4 +1,3 @@ -import uuid import pytest from fastapi.testclient import TestClient @@ -221,7 +220,7 @@ class TestToolTypesAPIExtended: }, ) assert response.status_code == 422 - data = response.json() + _ = response.json() def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None: """Test creating a tool type with startup_command.""" diff --git a/apps/api/tests/unit/test_config_profile_resolver.py b/apps/api/tests/unit/test_config_profile_resolver.py index 2ffa175..1208613 100644 --- a/apps/api/tests/unit/test_config_profile_resolver.py +++ b/apps/api/tests/unit/test_config_profile_resolver.py @@ -6,7 +6,6 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude from src.services.config_profile_resolver import ( ConfigProfileCycleError, ConfigProfileNotFoundError, - ResolvedProfile, check_include_cycle, resolve_profile, _merge_env_vars, @@ -63,7 +62,6 @@ class TestMergeFunctions: def test_merge_mounts_basic(self) -> None: """Test basic mount merging.""" - from src.services.config_profile_resolver import ResolvedMount result = _merge_mounts( {}, [{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}], diff --git a/apps/api/tests/unit/test_git_mount_resolution.py b/apps/api/tests/unit/test_git_mount_resolution.py index 52b0b63..4639c5b 100644 --- a/apps/api/tests/unit/test_git_mount_resolution.py +++ b/apps/api/tests/unit/test_git_mount_resolution.py @@ -1,7 +1,6 @@ """Unit tests for git mount resolution in tool instances.""" import os -import tempfile from pathlib import Path import pytest @@ -11,7 +10,6 @@ from src.api.tool_instances import ( _expand_glob_source, _resolve_single_git_mount, ) -from src.services.config_profile_resolver import ResolvedProfile class TestExpandGlobSource: diff --git a/apps/api/tests/unit/test_git_url_parser.py b/apps/api/tests/unit/test_git_url_parser.py index 07d48ea..0f13801 100644 --- a/apps/api/tests/unit/test_git_url_parser.py +++ b/apps/api/tests/unit/test_git_url_parser.py @@ -1,6 +1,5 @@ """Tests for git URL parsing utilities.""" -import pytest from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url diff --git a/apps/api/tests/unit/test_readiness_probe.py b/apps/api/tests/unit/test_readiness_probe.py index 0e3e75e..48dae89 100644 --- a/apps/api/tests/unit/test_readiness_probe.py +++ b/apps/api/tests/unit/test_readiness_probe.py @@ -1,9 +1,7 @@ """Unit tests for readiness probe service.""" -import asyncio from unittest.mock import MagicMock, patch -import pytest from src.services.readiness_probe import execute_probe diff --git a/apps/api/tests/unit/test_session_branch_selection.py b/apps/api/tests/unit/test_session_branch_selection.py index ad64ff6..ed8b940 100644 --- a/apps/api/tests/unit/test_session_branch_selection.py +++ b/apps/api/tests/unit/test_session_branch_selection.py @@ -3,10 +3,7 @@ import os import subprocess import tempfile -from unittest.mock import MagicMock, patch -import pytest -from fastapi.testclient import TestClient from src.api.tool_instances import CreateInstanceRequest diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 4891108..1f27f88 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios, { type AxiosRequestConfig } from "axios"; const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; @@ -19,7 +19,7 @@ const MAX_RETRIES = 2; const RETRY_DELAY_MS = 1000; // Track retry count per request -const retryCount = new WeakMap(); +const retryCount = new WeakMap(); apiClient.interceptors.response.use( (response) => response, diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 90bd206..23cd54a 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -1,3 +1,4 @@ +import { AxiosError } from "axios"; import { apiClient } from "./client"; export interface ToolInstance { @@ -80,9 +81,10 @@ export async function startInstance( { config_profile_id: configProfileId } ); return response.data; - } catch (error: any) { + } catch (error) { // Retry on network errors (e.g. Docker creating network interfaces) - if (retries > 0 && !error.response) { + const axiosError = error as AxiosError; + if (retries > 0 && !axiosError.response) { await new Promise((r) => setTimeout(r, 1500)); return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1); } @@ -114,9 +116,10 @@ export async function restartInstance( { config_profile_id: configProfileId } ); return response.data; - } catch (error: any) { + } catch (error) { // Retry on network errors (e.g. Docker creating network interfaces) - if (retries > 0 && !error.response) { + const axiosError = error as AxiosError; + if (retries > 0 && !axiosError.response) { await new Promise((r) => setTimeout(r, 1500)); return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1); } diff --git a/apps/web/src/components/mobile-edit-view.tsx b/apps/web/src/components/mobile-edit-view.tsx index 73c26fc..3801822 100644 --- a/apps/web/src/components/mobile-edit-view.tsx +++ b/apps/web/src/components/mobile-edit-view.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import { Icon } from "./icon"; interface FormField { name: string; diff --git a/apps/web/src/components/mobile-list-view.tsx b/apps/web/src/components/mobile-list-view.tsx index 01f79cd..ac55c71 100644 --- a/apps/web/src/components/mobile-list-view.tsx +++ b/apps/web/src/components/mobile-list-view.tsx @@ -22,8 +22,6 @@ interface MobileListViewProps { export const MobileListView: React.FC = ({ items, onItemClick, - onItemDelete, - onItemDuplicate, emptyMessage = "No items found", searchPlaceholder = "Search...", onSearch, diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index e46ca00..ca3ffcd 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -4,20 +4,28 @@ import { FitAddon } from "xterm-addon-fit"; import { WebLinksAddon } from "xterm-addon-web-links"; import "xterm/css/xterm.css"; -import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys"; +import { + applyModifierToChar, + type ModifierKey, +} from "../hooks/use-special-keys"; interface TerminalProps { - instanceId: string; - onClose?: () => void; - isMobile?: boolean; - activeModifier?: ModifierKey | null; - onModifierChange?: (modifier: ModifierKey | null) => void; - onTerminalReady?: ( - sendData: (data: string) => void, - connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", - focusInput: () => void, - changeFontSize: (delta: number) => void - ) => void; + instanceId: string; + onClose?: () => void; + isMobile?: boolean; + activeModifier?: ModifierKey | null; + onModifierChange?: (modifier: ModifierKey | null) => void; + onTerminalReady?: ( + sendData: (data: string) => void, + connectionStatus: + | "connecting" + | "connected" + | "disconnected" + | "error" + | "resetting", + focusInput: () => void, + changeFontSize: (delta: number) => void, + ) => void; } const FONT_SIZE_KEY = "terminal-font-size"; @@ -27,598 +35,606 @@ const RECONNECT_ATTEMPTS = 3; const RECONNECT_DELAY_BASE = 1000; export const TerminalComponent: React.FC = ({ - instanceId, - onClose, - isMobile = false, - activeModifier, - onModifierChange, - onTerminalReady, + instanceId, + onClose, + isMobile = false, + activeModifier, + onModifierChange, + onTerminalReady, }) => { - const terminalRef = useRef(null); - const hiddenInputRef = useRef(null); - const wsRef = useRef(null); - const termRef = useRef(null); - const fitAddonRef = useRef(null); - const reconnectAttemptsRef = useRef(0); - const onTerminalReadyRef = useRef(onTerminalReady); - onTerminalReadyRef.current = onTerminalReady; - const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {}); - const [status, setStatus] = useState< - "connecting" | "connected" | "disconnected" | "error" | "resetting" - >("connecting"); - const [error, setError] = useState(null); - const [showResetConfirm, setShowResetConfirm] = useState(false); - const activeModifierRef = useRef(activeModifier); - activeModifierRef.current = activeModifier; - const [fontSize, setFontSize] = useState(() => { - if (typeof window === "undefined") return isMobile ? 8 : 8; - const stored = localStorage.getItem(FONT_SIZE_KEY); - if (stored) { - const parsed = parseInt(stored, 10); - return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed)); - } - return isMobile ? 8 : 8; - }); - const lastPingRef = useRef(0); - const heartbeatCheckRef = useRef(null); - const isUnmountingRef = useRef(false); - const permanentErrorRef = useRef(null); + const terminalRef = useRef(null); + const hiddenInputRef = useRef(null); + const wsRef = useRef(null); + const termRef = useRef(null); + const fitAddonRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const onTerminalReadyRef = useRef(onTerminalReady); + onTerminalReadyRef.current = onTerminalReady; + const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {}); + const [status, setStatus] = useState< + "connecting" | "connected" | "disconnected" | "error" | "resetting" + >("connecting"); + const [error, setError] = useState(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const activeModifierRef = useRef(activeModifier); + activeModifierRef.current = activeModifier; + const [fontSize, setFontSize] = useState(() => { + if (typeof window === "undefined") return isMobile ? 8 : 8; + const stored = localStorage.getItem(FONT_SIZE_KEY); + if (stored) { + const parsed = parseInt(stored, 10); + return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed)); + } + return isMobile ? 8 : 8; + }); + const lastPingRef = useRef(0); + const heartbeatCheckRef = useRef(null); + const isUnmountingRef = useRef(false); + const permanentErrorRef = useRef(null); - const calculateFontSize = useCallback(() => { - return fontSize; - }, [fontSize]); + const calculateFontSize = useCallback(() => { + return fontSize; + }, [fontSize]); - const connectWebSocket = useCallback(() => { - const apiUrl = import.meta.env.VITE_API_BASE_URL || ""; - const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, ""); - const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`; + const connectWebSocket = useCallback(() => { + const apiUrl = import.meta.env.VITE_API_BASE_URL || ""; + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`; - // WebSocket connection established - const ws = new WebSocket(wsUrl); - wsRef.current = ws; + // WebSocket connection established + const ws = new WebSocket(wsUrl); + wsRef.current = ws; - ws.onopen = () => { - setStatus("connected"); - setError(null); - reconnectAttemptsRef.current = 0; - lastPingRef.current = Date.now(); - - // Send current terminal size immediately on connect - if (termRef.current) { - const { cols, rows } = termRef.current; - // Only send if we have valid dimensions - if (cols > 0 && rows > 0) { - ws.send(JSON.stringify({ type: "resize", cols, rows })); - } - } - - // Start heartbeat check - if (heartbeatCheckRef.current) { - window.clearInterval(heartbeatCheckRef.current); - } - heartbeatCheckRef.current = window.setInterval(() => { - const elapsed = Date.now() - lastPingRef.current; - if (elapsed > 60000) { - // No ping for 60 seconds, connection may be dead - ws.close(4000, "Heartbeat timeout"); - } - }, 30000); - }; + ws.onopen = () => { + setStatus("connected"); + setError(null); + reconnectAttemptsRef.current = 0; + lastPingRef.current = Date.now(); - ws.onmessage = (event) => { - if (!termRef.current) return; + // Send current terminal size immediately on connect + if (termRef.current) { + const { cols, rows } = termRef.current; + // Only send if we have valid dimensions + if (cols > 0 && rows > 0) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + } - if (event.data instanceof Blob) { - event.data.arrayBuffer().then((buffer) => { - const data = new Uint8Array(buffer); - termRef.current?.write(data); - }); - } else if (typeof event.data === "string") { - try { - const msg = JSON.parse(event.data); - if (msg.type === "status") { - if (msg.status === "connected") { - setStatus("connected"); - setError(null); - // Clear terminal and refit after reset/reconnect - if (termRef.current) { - termRef.current.clear(); - requestAnimationFrame(() => { - if (fitAddonRef.current && termRef.current) { - fitAddonRef.current.fit(); - const { cols, rows } = termRef.current; - const currentWs = wsRef.current; - if (currentWs?.readyState === WebSocket.OPEN) { - currentWs.send(JSON.stringify({ type: "resize", cols, rows })); - } - } - }); - } - } else if (msg.status === "resetting") { - setStatus("resetting"); - } - } else if (msg.type === "ping") { - // Respond with pong and update last ping time - lastPingRef.current = Date.now(); - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "pong" })); - } - } - } catch { - termRef.current?.write(event.data); - } - } - }; + // Start heartbeat check + if (heartbeatCheckRef.current) { + window.clearInterval(heartbeatCheckRef.current); + } + heartbeatCheckRef.current = window.setInterval(() => { + const elapsed = Date.now() - lastPingRef.current; + if (elapsed > 60000) { + // No ping for 60 seconds, connection may be dead + ws.close(4000, "Heartbeat timeout"); + } + }, 30000); + }; - ws.onclose = (event) => { - // Clean up heartbeat check - if (heartbeatCheckRef.current) { - window.clearInterval(heartbeatCheckRef.current); - heartbeatCheckRef.current = null; - } + ws.onmessage = (event) => { + if (!termRef.current) return; - // Permanent errors: do not retry - if (event.code === 4001 || event.code === 4003 || event.code === 4004) { - const reason = event.reason || `Instance error (code: ${event.code})`; - setStatus("error"); - setError(reason); - permanentErrorRef.current = reason; - return; - } + if (event.data instanceof Blob) { + event.data.arrayBuffer().then((buffer) => { + const data = new Uint8Array(buffer); + termRef.current?.write(data); + }); + } else if (typeof event.data === "string") { + try { + const msg = JSON.parse(event.data); + if (msg.type === "status") { + if (msg.status === "connected") { + setStatus("connected"); + setError(null); + // Clear terminal and refit after reset/reconnect + if (termRef.current) { + termRef.current.clear(); + requestAnimationFrame(() => { + if (fitAddonRef.current && termRef.current) { + fitAddonRef.current.fit(); + const { cols, rows } = termRef.current; + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send( + JSON.stringify({ type: "resize", cols, rows }), + ); + } + } + }); + } + } else if (msg.status === "resetting") { + setStatus("resetting"); + } + } else if (msg.type === "ping") { + // Respond with pong and update last ping time + lastPingRef.current = Date.now(); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "pong" })); + } + } + } catch { + termRef.current?.write(event.data); + } + } + }; - if (event.code === 1000) { - setStatus("disconnected"); - return; - } + ws.onclose = (event) => { + // Clean up heartbeat check + if (heartbeatCheckRef.current) { + window.clearInterval(heartbeatCheckRef.current); + heartbeatCheckRef.current = null; + } - if (event.code === 4000) { - // Server closed old connection for concurrent connection - don't reconnect - // The new connection is already established - return; - } + // Permanent errors: do not retry + if (event.code === 4001 || event.code === 4003 || event.code === 4004) { + const reason = event.reason || `Instance error (code: ${event.code})`; + setStatus("error"); + setError(reason); + permanentErrorRef.current = reason; + return; + } - // Transient errors: attempt reconnection - setStatus("disconnected"); - setError(`Connection closed (code: ${event.code})`); + if (event.code === 1000) { + setStatus("disconnected"); + return; + } - if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) { - reconnectAttemptsRef.current++; - const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1); - setTimeout(() => { - if (isUnmountingRef.current) { - return; - } - if (document.visibilityState !== "hidden") { - connectWebSocket(); - } else { - } - }, delay); - } - }; + if (event.code === 4000) { + // Server closed old connection for concurrent connection - don't reconnect + // The new connection is already established + return; + } - ws.onerror = (error) => { - setStatus("error"); - setError("WebSocket error"); - }; + // Transient errors: attempt reconnection + setStatus("disconnected"); + setError(`Connection closed (code: ${event.code})`); - return ws; - }, [instanceId]); + if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) { + reconnectAttemptsRef.current++; + const delay = + RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1); + setTimeout(() => { + if (isUnmountingRef.current) { + return; + } + if (document.visibilityState !== "hidden") { + connectWebSocket(); + } + }, delay); + } + }; - useEffect(() => { - if (!terminalRef.current) return; + ws.onerror = () => { + setStatus("error"); + setError("WebSocket error"); + }; - // Initialize terminal - const currentFontSize = calculateFontSize(); - const term = new Terminal({ - cursorBlink: true, - fontSize: currentFontSize, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - lineHeight: 1.2, - letterSpacing: 0, - allowTransparency: false, - theme: { - background: "#1e1e1e", - foreground: "#d4d4d4", - cursor: "#d4d4d4", - selectionBackground: "#264f78", - black: "#000000", - red: "#cd3131", - green: "#0dbc79", - yellow: "#e5e510", - blue: "#2472c8", - magenta: "#bc3fbc", - cyan: "#11a8cd", - white: "#e5e5e5", - brightBlack: "#666666", - brightRed: "#f14c4c", - brightGreen: "#23d18b", - brightYellow: "#f5f543", - brightBlue: "#3b8eea", - brightMagenta: "#d670d6", - brightCyan: "#29b8db", - brightWhite: "#e5e5e5", - }, - }); + return ws; + }, [instanceId]); - termRef.current = term; + useEffect(() => { + if (!terminalRef.current) return; - const fitAddon = new FitAddon(); - fitAddonRef.current = fitAddon; - term.loadAddon(fitAddon); - term.loadAddon(new WebLinksAddon()); + // Initialize terminal + const currentFontSize = calculateFontSize(); + const term = new Terminal({ + cursorBlink: true, + fontSize: currentFontSize, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + lineHeight: 1.2, + letterSpacing: 0, + allowTransparency: false, + theme: { + background: "#1e1e1e", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + selectionBackground: "#264f78", + black: "#000000", + red: "#cd3131", + green: "#0dbc79", + yellow: "#e5e510", + blue: "#2472c8", + magenta: "#bc3fbc", + cyan: "#11a8cd", + white: "#e5e5e5", + brightBlack: "#666666", + brightRed: "#f14c4c", + brightGreen: "#23d18b", + brightYellow: "#f5f543", + brightBlue: "#3b8eea", + brightMagenta: "#d670d6", + brightCyan: "#29b8db", + brightWhite: "#e5e5e5", + }, + }); - const container = terminalRef.current; + termRef.current = term; - // Define fitTerminal before connectWebSocket so it's available in onmessage - const fitTerminal = () => { - if (!fitAddonRef.current || !termRef.current) return; - const oldCols = termRef.current.cols; - const oldRows = termRef.current.rows; - try { - fitAddonRef.current.fit(); - } catch { - // Ignore fit errors during initialization - return; - } - const { cols, rows } = termRef.current; - // Force refresh if dimensions are valid - if (cols > 0 && rows > 0) { - try { - termRef.current.refresh(0, rows - 1); - } catch { - // Ignore refresh errors - } - } - const currentWs = wsRef.current; - if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) { - currentWs.send(JSON.stringify({ type: "resize", cols, rows })); - } - }; + const fitAddon = new FitAddon(); + fitAddonRef.current = fitAddon; + term.loadAddon(fitAddon); + term.loadAddon(new WebLinksAddon()); - // Open xterm first (must happen before fit) - term.open(container); - const ws = connectWebSocket(); + const container = terminalRef.current; - // Initial fit after layout settles (terminal must be opened first) - let fitAttempts = 0; - const doInitialFit = () => { - if (!container.isConnected) return; - fitAttempts++; - // Ensure container has dimensions before fitting - if (container.clientWidth > 0 && container.clientHeight > 0) { - fitTerminal(); - } else if (fitAttempts < 50) { - // Container not ready yet, try again (max 50 attempts ~ 1s) - requestAnimationFrame(doInitialFit); - } else { - } - }; - requestAnimationFrame(doInitialFit); + // Define fitTerminal before connectWebSocket so it's available in onmessage + const fitTerminal = () => { + if (!fitAddonRef.current || !termRef.current) return; + try { + fitAddonRef.current.fit(); + } catch { + // Ignore fit errors during initialization + return; + } + const { cols, rows } = termRef.current; + // Force refresh if dimensions are valid + if (cols > 0 && rows > 0) { + try { + termRef.current.refresh(0, rows - 1); + } catch { + // Ignore refresh errors + } + } + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) { + currentWs.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; - // Refit after font load (metrics may change) - document.fonts.ready.then(() => { - requestAnimationFrame(() => fitTerminal()); - }); + // Open xterm first (must happen before fit) + term.open(container); + const ws = connectWebSocket(); - // Handle terminal input - term.onData((data) => { - const currentWs = wsRef.current; - if (currentWs?.readyState !== WebSocket.OPEN) return; + // Initial fit after layout settles (terminal must be opened first) + let fitAttempts = 0; + const doInitialFit = () => { + if (!container.isConnected) return; + fitAttempts++; + // Ensure container has dimensions before fitting + if (container.clientWidth > 0 && container.clientHeight > 0) { + fitTerminal(); + } else if (fitAttempts < 50) { + // Container not ready yet, try again (max 50 attempts ~ 1s) + requestAnimationFrame(doInitialFit); + } + }; + requestAnimationFrame(doInitialFit); - // Apply active modifier to single-character input - const modifier = activeModifierRef.current; - if (modifier && data.length === 1) { - const modified = applyModifierToChar(data, modifier); - if (modified) { - currentWs.send(modified); - onModifierChange?.(null); - return; - } - } + // Refit after font load (metrics may change) + document.fonts.ready.then(() => { + requestAnimationFrame(() => fitTerminal()); + }); - currentWs.send(data); - }); + // Handle terminal input + term.onData((data) => { + const currentWs = wsRef.current; + if (currentWs?.readyState !== WebSocket.OPEN) return; - // Handle container resize with ResizeObserver for accurate dimension tracking - let resizeTimeout: ReturnType; - let lastWidth = 0; - let lastHeight = 0; - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - - const { width, height } = entry.contentRect; - // Only trigger if dimensions actually changed - if (width === lastWidth && height === lastHeight) return; - lastWidth = width; - lastHeight = height; - - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => { - requestAnimationFrame(() => { - if (!container.isConnected) return; - fitTerminal(); - }); - }, 50); - }); - resizeObserver.observe(container); + // Apply active modifier to single-character input + const modifier = activeModifierRef.current; + if (modifier && data.length === 1) { + const modified = applyModifierToChar(data, modifier); + if (modified) { + currentWs.send(modified); + onModifierChange?.(null); + return; + } + } - // Window resize fallback (for viewport changes that don't affect container dimensions) - let windowResizeTimeout: ReturnType; - const handleWindowResize = () => { - clearTimeout(windowResizeTimeout); - windowResizeTimeout = setTimeout(() => { - requestAnimationFrame(() => fitTerminal()); - }, 250); - }; - window.addEventListener("resize", handleWindowResize); + currentWs.send(data); + }); - // Refit after mobile header auto-hides (3s delay + 0.3s transition) - const headerHideTimeout = setTimeout(() => { - fitTerminal(); - }, 4000); + // Handle container resize with ResizeObserver for accurate dimension tracking + let resizeTimeout: ReturnType; + let lastWidth = 0; + let lastHeight = 0; + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; - // Notify parent about terminal readiness - if (onTerminalReadyRef.current) { - const sendData = (data: string) => { - const currentWs = wsRef.current; - if (currentWs?.readyState === WebSocket.OPEN) { - currentWs.send(data); - } - }; - const focusInput = () => { - termRef.current?.focus(); - }; - const changeFontSize = (delta: number) => { - handleFontSizeChangeRef.current(delta); - }; - onTerminalReadyRef.current(sendData, status, focusInput, changeFontSize); - } + const { width, height } = entry.contentRect; + // Only trigger if dimensions actually changed + if (width === lastWidth && height === lastHeight) return; + lastWidth = width; + lastHeight = height; - // Visibility API for reconnection - const handleVisibilityChange = () => { - if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) { - if (permanentErrorRef.current) { - return; - } - reconnectAttemptsRef.current = 0; - connectWebSocket(); - } - }; - document.addEventListener("visibilitychange", handleVisibilityChange); + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + requestAnimationFrame(() => { + if (!container.isConnected) return; + fitTerminal(); + }); + }, 50); + }); + resizeObserver.observe(container); - return () => { - isUnmountingRef.current = true; - clearTimeout(resizeTimeout); - clearTimeout(windowResizeTimeout); - clearTimeout(headerHideTimeout); - resizeObserver.disconnect(); - window.removeEventListener("resize", handleWindowResize); - document.removeEventListener("visibilitychange", handleVisibilityChange); - if (ws) { - ws.close(1000, "Component unmounting"); - } - if (heartbeatCheckRef.current) { - window.clearInterval(heartbeatCheckRef.current); - heartbeatCheckRef.current = null; - } - term.dispose(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [instanceId, connectWebSocket]); + // Window resize fallback (for viewport changes that don't affect container dimensions) + let windowResizeTimeout: ReturnType; + const handleWindowResize = () => { + clearTimeout(windowResizeTimeout); + windowResizeTimeout = setTimeout(() => { + requestAnimationFrame(() => fitTerminal()); + }, 250); + }; + window.addEventListener("resize", handleWindowResize); - // Update parent about status changes - useEffect(() => { - if (onTerminalReady && termRef.current) { - const sendData = (data: string) => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(data); - } - }; - const focusInput = () => { - termRef.current?.focus(); - }; - const changeFontSize = (delta: number) => { - handleFontSizeChangeRef.current(delta); - }; - onTerminalReady(sendData, status, focusInput, changeFontSize); - } - }, [status, onTerminalReady]); + // Refit after mobile header auto-hides (3s delay + 0.3s transition) + const headerHideTimeout = setTimeout(() => { + fitTerminal(); + }, 4000); - const handleFontSizeChange = (delta: number) => { - const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta)); - setFontSize(newSize); - localStorage.setItem(FONT_SIZE_KEY, newSize.toString()); - if (termRef.current && fitAddonRef.current) { - termRef.current.options.fontSize = newSize; - requestAnimationFrame(() => { - if (termRef.current && fitAddonRef.current) { - try { - fitAddonRef.current.fit(); - const { cols, rows } = termRef.current; - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send( - JSON.stringify({ - type: "resize", - cols, - rows, - }) - ); - } - } catch { - // Ignore fit errors during re-initialization - } - } - }); - } - }; - handleFontSizeChangeRef.current = handleFontSizeChange; + // Notify parent about terminal readiness + if (onTerminalReadyRef.current) { + const sendData = (data: string) => { + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(data); + } + }; + const focusInput = () => { + termRef.current?.focus(); + }; + const changeFontSize = (delta: number) => { + handleFontSizeChangeRef.current(delta); + }; + onTerminalReadyRef.current(sendData, status, focusInput, changeFontSize); + } - const handleCopy = async () => { - if (!termRef.current) return; - const selection = termRef.current.getSelection(); - if (selection) { - try { - await navigator.clipboard.writeText(selection); - } catch { - // Fallback for older browsers - const textarea = document.createElement("textarea"); - textarea.value = selection; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand("copy"); - document.body.removeChild(textarea); - } - } - }; + // Visibility API for reconnection + const handleVisibilityChange = () => { + if ( + document.visibilityState === "visible" && + ws && + ws.readyState !== WebSocket.OPEN + ) { + if (permanentErrorRef.current) { + return; + } + reconnectAttemptsRef.current = 0; + connectWebSocket(); + } + }; + document.addEventListener("visibilitychange", handleVisibilityChange); - const handlePaste = async () => { - try { - const text = await navigator.clipboard.readText(); - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(text); - } - } catch { - // Clipboard API not available - } - }; + return () => { + isUnmountingRef.current = true; + clearTimeout(resizeTimeout); + clearTimeout(windowResizeTimeout); + clearTimeout(headerHideTimeout); + resizeObserver.disconnect(); + window.removeEventListener("resize", handleWindowResize); + document.removeEventListener("visibilitychange", handleVisibilityChange); + if (ws) { + ws.close(1000, "Component unmounting"); + } + if (heartbeatCheckRef.current) { + window.clearInterval(heartbeatCheckRef.current); + heartbeatCheckRef.current = null; + } + term.dispose(); + }; + }, [instanceId, connectWebSocket]); - // Focus terminal on mobile to keep keyboard open - const handleTerminalClick = () => { - if (isMobile && termRef.current) { - termRef.current.focus(); - } - }; + // Update parent about status changes + useEffect(() => { + if (onTerminalReady && termRef.current) { + const sendData = (data: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(data); + } + }; + const focusInput = () => { + termRef.current?.focus(); + }; + const changeFontSize = (delta: number) => { + handleFontSizeChangeRef.current(delta); + }; + onTerminalReady(sendData, status, focusInput, changeFontSize); + } + }, [status, onTerminalReady]); - return ( -
-
-
-
- - - {status === "resetting" - ? "Resetting..." - : reconnectAttemptsRef.current > 0 && status !== "connected" - ? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...` - : status} - -
- {isMobile && ( - <> - - - - )} -
-
- - - - {onClose && ( - - )} -
-
- {showResetConfirm && ( -
-
-

Reset terminal? This will kill the current shell session and start fresh.

-
- - -
-
-
- )} - {error && ( -
- {error} - {status === "error" && ( - - )} -
- )} -
- {isMobile && ( - - )} -
- ); + const handleFontSizeChange = (delta: number) => { + const newSize = Math.max( + MIN_FONT_SIZE, + Math.min(MAX_FONT_SIZE, fontSize + delta), + ); + setFontSize(newSize); + localStorage.setItem(FONT_SIZE_KEY, newSize.toString()); + if (termRef.current && fitAddonRef.current) { + termRef.current.options.fontSize = newSize; + requestAnimationFrame(() => { + if (termRef.current && fitAddonRef.current) { + try { + fitAddonRef.current.fit(); + const { cols, rows } = termRef.current; + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "resize", + cols, + rows, + }), + ); + } + } catch { + // Ignore fit errors during re-initialization + } + } + }); + } + }; + handleFontSizeChangeRef.current = handleFontSizeChange; + + const handleCopy = async () => { + if (!termRef.current) return; + const selection = termRef.current.getSelection(); + if (selection) { + try { + await navigator.clipboard.writeText(selection); + } catch { + // Fallback for older browsers + const textarea = document.createElement("textarea"); + textarea.value = selection; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + } + }; + + const handlePaste = async () => { + try { + const text = await navigator.clipboard.readText(); + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(text); + } + } catch { + // Clipboard API not available + } + }; + + // Focus terminal on mobile to keep keyboard open + const handleTerminalClick = () => { + if (isMobile && termRef.current) { + termRef.current.focus(); + } + }; + + return ( +
+
+
+
+ + + {status === "resetting" + ? "Resetting..." + : reconnectAttemptsRef.current > 0 && status !== "connected" + ? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...` + : status} + +
+ {isMobile && ( + <> + + + + )} +
+
+ + + + {onClose && ( + + )} +
+
+ {showResetConfirm && ( +
+
+

+ Reset terminal? This will kill the current shell session and start + fresh. +

+
+ + +
+
+
+ )} + {error && ( +
+ {error} + {status === "error" && ( + + )} +
+ )} +
+ {isMobile && ( + + )} +
+ ); }; diff --git a/apps/web/src/pages/config-profiles.tsx b/apps/web/src/pages/config-profiles.tsx index e49263d..b493d9c 100644 --- a/apps/web/src/pages/config-profiles.tsx +++ b/apps/web/src/pages/config-profiles.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; +import { ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { extractErrorMessage } from "../utils/errors"; diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx index 5103f86..00ee820 100644 --- a/apps/web/src/pages/dashboard.tsx +++ b/apps/web/src/pages/dashboard.tsx @@ -9,7 +9,6 @@ import { listToolTypes, type ToolType } from "../api/tool_types"; import { updateUserConfig } from "../api/settings"; import type { Project } from "../types"; import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; -import { Icon } from "../components/icon"; import { CreateSessionForm } from "../components/create-session-form"; import { SessionList } from "../components/session-list"; import { useInstanceActions } from "../hooks/use-instance-actions"; diff --git a/apps/web/src/pages/git-history.tsx b/apps/web/src/pages/git-history.tsx index 33113a3..2c6819a 100644 --- a/apps/web/src/pages/git-history.tsx +++ b/apps/web/src/pages/git-history.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories"; -import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; +import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryResponse } from "../api/git_repositories"; +import { ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useAsyncData } from "../hooks/use-async-data"; diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index a32bd9b..8ad730c 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { getProfile, updateProfile, uploadAvatar } from "../api/profile"; -import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; +import { ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useAuth } from "../state/auth"; import { useAsyncData } from "../hooks/use-async-data"; diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index e7c0529..dd95c27 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { listProjects } from "../api/projects"; import type { Project } from "../types"; @@ -11,7 +10,7 @@ import { } from "../api/sessions"; import { listToolTypes, type ToolType } from "../api/tool_types"; import { getUserConfig, updateUserConfig } from "../api/settings"; -import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; +import { ErrorState, LoadingState } from "../components/data-states"; import { CreateSessionForm } from "../components/create-session-form"; import { SessionList } from "../components/session-list"; import { SessionCard } from "../components/session-card"; @@ -21,7 +20,6 @@ import type { InstanceHealth } from "../api/sessions"; type SessionsStatus = "loading" | "ready" | "error"; export const SessionsPage = () => { - const navigate = useNavigate(); const [status, setStatus] = useState("loading"); const [sessions, setSessions] = useState([]); const [lastSessionId, setLastSessionId] = useState(null); diff --git a/apps/web/src/pages/settings.tsx b/apps/web/src/pages/settings.tsx index 27f1ecb..72250b4 100644 --- a/apps/web/src/pages/settings.tsx +++ b/apps/web/src/pages/settings.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom"; import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings"; -import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; +import { ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useAsyncData } from "../hooks/use-async-data"; diff --git a/apps/web/src/pages/ssh-keys.tsx b/apps/web/src/pages/ssh-keys.tsx index 5f12e46..889333b 100644 --- a/apps/web/src/pages/ssh-keys.tsx +++ b/apps/web/src/pages/ssh-keys.tsx @@ -7,7 +7,7 @@ import { useAsyncData } from "../hooks/use-async-data"; export const SSHKeysPage = () => { const navigate = useNavigate(); - const { data: keys, status, error, reload: loadKeys } = useAsyncData(listSSHKeys, []); + const { data: keys, status, reload: loadKeys } = useAsyncData(listSSHKeys, []); const [newKeyName, setNewKeyName] = useState(""); const [generating, setGenerating] = useState(false); const [signPayloads, setSignPayloads] = useState>({});