diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 36dbbda..d1dd001 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -17,6 +17,7 @@ from src.auth.dependencies import get_current_user_id, get_db_session from src.models.config_profile import ConfigProfile, ConfigProfileInclude from src.models.project import Project from src.models.tool_type import ToolType +from src.models.user_config import UserConfig from src.services.config_profile_resolver import ( ConfigProfileCycleError, check_include_cycle, @@ -840,6 +841,106 @@ async def resolve_default_profile( return {"profile_id": str(first.id), "profile_name": first.name} +# --------------------------------------------------------------------------- +# Default profile management +# --------------------------------------------------------------------------- + +class DefaultProfilesUpdate(BaseModel): + default_profiles: dict[str, str] = Field( + description="Mapping of tool_type_id -> profile_id for default profiles" + ) + + +async def _get_or_create_user_config( + session: AsyncSession, + user_id: uuid.UUID, +) -> UserConfig: + """Get existing user config or create a new one.""" + result = await session.execute( + select(UserConfig).where(UserConfig.user_id == user_id) + ) + user_config = result.scalar_one_or_none() + if user_config is None: + user_config = UserConfig(user_id=user_id, config={}) + session.add(user_config) + return user_config + + +async def _validate_default_profiles( + session: AsyncSession, + user_id: uuid.UUID, + default_profiles: dict[str, str], +) -> None: + """Validate that all profile IDs in default_profiles belong to the user.""" + for tool_type_id, profile_id_str in default_profiles.items(): + try: + profile_uuid = uuid.UUID(profile_id_str) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}", + ) + profile = await session.get(ConfigProfile, profile_uuid) + if profile is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Profile not found: {profile_id_str}", + ) + if profile.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Profile does not belong to user: {profile_id_str}", + ) + + +@router.get("/defaults") +async def get_default_profiles_endpoint( + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get all default profile mappings for the current user.""" + result = await session.execute( + select(UserConfig).where(UserConfig.user_id == user_id) + ) + user_config = result.scalar_one_or_none() + return {"default_profiles": user_config.default_profiles if user_config else {}} + + +@router.put("/defaults") +async def set_default_profiles_endpoint( + data: DefaultProfilesUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Set default profile mappings for the current user.""" + await _validate_default_profiles(session, user_id, data.default_profiles) + user_config = await _get_or_create_user_config(session, user_id) + user_config.config = { + **user_config.config, + "default_profiles": data.default_profiles, + } + await session.commit() + await session.refresh(user_config) + return {"default_profiles": user_config.default_profiles} + + +@router.get("/defaults/{tool_type_id}") +async def get_default_profile_for_tool_type_endpoint( + tool_type_id: str, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get the default profile ID for a specific tool type.""" + result = await session.execute( + select(UserConfig).where(UserConfig.user_id == user_id) + ) + user_config = result.scalar_one_or_none() + profile_id = ( + user_config.default_profiles.get(tool_type_id) if user_config else None + ) + return {"tool_type_id": tool_type_id, "profile_id": profile_id} + + class ValidateGitUrlRequest(BaseModel): url: str = Field(description="Git remote URL to validate") ssh_key_id: str | None = Field( diff --git a/apps/api/src/main.py b/apps/api/src/main.py index d94f8a5..17362f9 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -37,6 +37,7 @@ from src.logging_config import ( RequestLoggingMiddleware, configure_logging, ) +from src.seeds.builtin_tool_types import seed_builtin_tool_types from src.services.correlation import CorrelationIdMiddleware from src.services.event_bus import InstanceEventBus from src.services.health_monitor import HealthMonitor @@ -135,6 +136,10 @@ async def on_startup(): _health_monitor.start() logger.info("Health monitor started") + # Seed built-in tool types + await seed_builtin_tool_types() + logger.info("Built-in tool types seeded") + logger.info("Startup complete.") diff --git a/apps/api/src/models/config_profile.py b/apps/api/src/models/config_profile.py index 04eae6f..6853ef8 100644 --- a/apps/api/src/models/config_profile.py +++ b/apps/api/src/models/config_profile.py @@ -1,7 +1,15 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean +from sqlalchemy import ( + Boolean, + ForeignKey, + JSON, + Integer, + String, + Text, + UniqueConstraint, +) from sqlalchemy import Uuid as UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -15,6 +23,9 @@ if TYPE_CHECKING: class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "config_profiles" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"), + ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False diff --git a/apps/api/src/models/user_config.py b/apps/api/src/models/user_config.py index 169de24..89a702c 100644 --- a/apps/api/src/models/user_config.py +++ b/apps/api/src/models/user_config.py @@ -1,8 +1,8 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey -from sqlalchemy import JSON, Uuid as UUID +from sqlalchemy import ForeignKey, JSON +from sqlalchemy import Uuid as UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin @@ -18,3 +18,30 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base): config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False) user: Mapped["User"] = relationship(back_populates="user_config") + + @property + def default_profile_id(self) -> uuid.UUID | None: + """Return the legacy global default profile ID from config JSON.""" + profile_id = self.config.get("default_profile_id") + if isinstance(profile_id, str): + return uuid.UUID(profile_id) + return None + + @default_profile_id.setter + def default_profile_id(self, value: uuid.UUID | None) -> None: + if value is not None: + self.config["default_profile_id"] = str(value) + elif "default_profile_id" in self.config: + del self.config["default_profile_id"] + + @property + def default_profiles(self) -> dict[str, str]: + """Return per-tool-type default profile IDs from config JSON.""" + value = self.config.get("default_profiles", {}) + if isinstance(value, dict): + return {str(k): str(v) for k, v in value.items()} + return {} + + @default_profiles.setter + def default_profiles(self, value: dict[str, str]) -> None: + self.config["default_profiles"] = value diff --git a/apps/api/src/seeds/__init__.py b/apps/api/src/seeds/__init__.py new file mode 100644 index 0000000..eb902ba --- /dev/null +++ b/apps/api/src/seeds/__init__.py @@ -0,0 +1 @@ +"""Database seeding utilities.""" diff --git a/apps/api/src/seeds/builtin_tool_types.py b/apps/api/src/seeds/builtin_tool_types.py new file mode 100644 index 0000000..abf0f1d --- /dev/null +++ b/apps/api/src/seeds/builtin_tool_types.py @@ -0,0 +1,168 @@ +"""Seed built-in tool types into the database.""" + +import logging + +from sqlalchemy import select, text + +from src.database import SessionLocal +from src.models.tool_type import ToolType + +logger = logging.getLogger(__name__) + + +async def _table_exists(session, table_name: str) -> bool: + """Check if a table exists in the database.""" + try: + result = await session.execute( + text( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = :table_name + ) + """ + ), + {"table_name": table_name}, + ) + return result.scalar() or False + except Exception: + return False + + +async def seed_builtin_tool_types(): + """Create or update built-in tool types in the database. + + Built-in tool types have no creator (created_by_id=None) and provide + out-of-the-box tools for users without requiring manual tool creation. + """ + async with SessionLocal() as session: + # Check if tool_types table exists before attempting to seed + if not await _table_exists(session, "tool_types"): + logger.warning( + "tool_types table does not exist. Skipping seeding. " + "Migrations may not have run yet." + ) + return + + builtin_types = [ + { + "name": "code-server", + "display_name": "VS Code Server", + "description": "VS Code running in the browser via code-server", + "category": "editor", + "interface_type": "web", + "compose_template": """version: "3.8" +services: + code-server: + image: lscr.io/linuxserver/code-server:latest + container_name: {{TOOL_NAME}} + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/London + volumes: + - {{REPO_PATH}}:/config/workspace + ports: + - "8443:8443" + restart: unless-stopped""", + "default_port": 8443, + "required_variables": ["REPO_PATH", "TOOL_NAME"], + }, + { + "name": "jupyter-notebook", + "display_name": "Jupyter Notebook", + "description": "Jupyter Lab for interactive development", + "category": "notebook", + "interface_type": "web", + "default_port": 8888, + "compose_template": """version: "3.8" +services: + jupyter: + image: jupyter/scipy-notebook:latest + container_name: {{TOOL_NAME}} + environment: + - JUPYTER_ENABLE_LAB=yes + volumes: + - {{REPO_PATH}}:/home/jovyan/work + ports: + - "8888:8888" + restart: unless-stopped""", + "required_variables": ["REPO_PATH", "TOOL_NAME"], + }, + { + "name": "opencode", + "display_name": "OpenCode", + "description": "AI coding assistant - run opencode in terminal", + "category": "ai-assistant", + "interface_type": "terminal", + "default_port": 3000, + "compose_template": """version: "3.8" +services: + opencode: + image: node:20-slim + container_name: {{TOOL_NAME}} + working_dir: /workspace + environment: + - HOME=/tmp + volumes: + - {{REPO_PATH}}:/workspace + - opencode_home:/tmp + ports: + - "3000:3000" + command: > + sh -c "set -x && + apt-get update && apt-get install -y git ca-certificates && + echo 'Installing opencode...' && + npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' && + which opencode || echo 'ERROR: opencode not in PATH' && + npm bin -g && + ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' && + echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc && + echo 'cd /workspace' >> /root/.bashrc && + echo 'OpenCode installation complete' && + cd /workspace && + exec tail -f /dev/null" + stdin_open: true + tty: true + restart: unless-stopped + +volumes: + opencode_home:""", + "required_variables": ["REPO_PATH", "TOOL_NAME"], + }, + ] + + for tool_data in builtin_types: + existing = await session.scalar( + select(ToolType).where(ToolType.name == tool_data["name"]) + ) + if not existing: + tool_type = ToolType( + name=tool_data["name"], + display_name=tool_data["display_name"], + description=tool_data["description"], + category=tool_data["category"], + interface_type=tool_data["interface_type"], + definition_type="compose", + compose_template=tool_data["compose_template"], + required_variables=tool_data["required_variables"], + default_port=tool_data.get("default_port", 0), + created_by_id=None, + ) + session.add(tool_type) + logger.info("Created built-in tool type: %s", tool_data["name"]) + else: + # Update existing built-in tool types to reflect code changes + existing.display_name = tool_data["display_name"] + existing.description = tool_data["description"] + existing.category = tool_data["category"] + existing.interface_type = tool_data["interface_type"] + existing.definition_type = "compose" + existing.compose_template = tool_data["compose_template"] + existing.required_variables = tool_data["required_variables"] + existing.default_port = tool_data.get("default_port", 0) + logger.info("Updated built-in tool type: %s", tool_data["name"]) + + await session.commit() + logger.info("Built-in tool types seeded successfully.")