diff --git a/apps/api/alembic/versions/2026_05_23_remove_is_builtin.py b/apps/api/alembic/versions/2026_05_23_remove_is_builtin.py new file mode 100644 index 0000000..12121a4 --- /dev/null +++ b/apps/api/alembic/versions/2026_05_23_remove_is_builtin.py @@ -0,0 +1,25 @@ +"""remove_is_builtin_from_tool_types + +Revision ID: 2026_05_23_remove_is_builtin +Revises: 2026_05_22_add_clone_mode +Create Date: 2026-05-23 14:30:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '2026_05_23_remove_is_builtin' +down_revision = '2026_05_22_add_clone_mode' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Drop the is_builtin column from tool_types + op.drop_column('tool_types', 'is_builtin') + + +def downgrade() -> None: + # Add the is_builtin column back to tool_types + op.add_column('tool_types', sa.Column('is_builtin', sa.Boolean(), nullable=False, server_default='false')) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 7774823..4e3880b 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -411,7 +411,7 @@ async def list_instances( "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_interface_type": tool_type.interface_type if tool_type else "", + "tool_type_interfaces": [tool_type.interface_type] if tool_type else [], "status": i.status, "url": i.url, "port": i.port, @@ -757,17 +757,16 @@ async def start_instance( # 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) + if not tool_type: + logger.error("Tool type %s not found", 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", + "error": f"Tool type '{instance.tool_type_id}' not found", } - instance_port = tool_type.default_port + instance_port = tool_type.default_port or 0 logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s", instance.id, tool_type.name, instance_port, tool_type.interface_type) @@ -1228,7 +1227,7 @@ async def check_instance_tunnel_health( response["probe_status"] = "pending" elif instance.probe_result: response["probe_status"] = "success" if instance.probe_result.get("success") else "failed" - response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))[:500] + response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", [])) # Check tunnel health if instance has a URL and is web-enabled if instance.url and instance.status in ("running", "unhealthy"): @@ -1238,10 +1237,14 @@ async def check_instance_tunnel_health( if tunnel_health.get("error"): response["error"] = tunnel_health["error"] - # Overall healthy only if container is running AND tunnel is healthy + # Overall healthy: web tools need running container + healthy tunnel; + # terminal tools only need running container container_healthy = container_info["status"] == "running" - tunnel_healthy = response["tunnel_status"] == "healthy" - response["healthy"] = container_healthy and tunnel_healthy + if instance.url: + tunnel_healthy = response["tunnel_status"] == "healthy" + response["healthy"] = container_healthy and tunnel_healthy + else: + response["healthy"] = container_healthy # If container is not running, override error message if not container_healthy: @@ -1425,13 +1428,15 @@ async def get_user_sessions( "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_interface_type": tool_type.interface_type if tool_type else "", + "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, }) return {"sessions": sessions} diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index 0f279cb..a475e5e 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -279,7 +279,6 @@ class ToolTypeResponse(BaseModel): build_context: dict | None readiness_probe: dict | None required_variables: list[str] - is_builtin: bool created_by_id: uuid.UUID | None created_at: datetime updated_at: datetime @@ -329,7 +328,6 @@ async def create_tool_type( category=data.category, interface_type=data.interface_type, requires_port=data.requires_port, - is_builtin=False, created_by_id=user.id, ) session.add(tool_type) diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 06fe5dd..fa3372d 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -7,7 +7,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from sqlalchemy import select, text +from sqlalchemy import text from src.api.auth import router as auth_router from src.api.dashboard import router as dashboard_router @@ -25,13 +25,12 @@ from src.api.tool_types import router as tool_types_router from src.api.user_config import router as user_config_router from src.api.users import router as users_router from src.config import Settings -from src.database import SessionLocal, init_database +from src.database import init_database from src.logging_config import ( ExceptionLoggingMiddleware, RequestLoggingMiddleware, configure_logging, ) -from src.models.tool_type import ToolType # Configure logging early log_level = os.getenv("LOG_LEVEL", "INFO").upper() @@ -103,159 +102,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE ) -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(): - 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", - "requires_port": True, - "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", - "requires_port": True, - "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", - "requires_port": False, - "default_port": 0, - "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 - 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"], - requires_port=tool_data["requires_port"], - definition_type="compose", - compose_template=tool_data["compose_template"], - required_variables=tool_data["required_variables"], - default_port=tool_data.get("default_port"), - is_builtin=True, - ) - 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.requires_port = tool_data["requires_port"] - existing.definition_type = "compose" - existing.compose_template = tool_data["compose_template"] - existing.required_variables = tool_data["required_variables"] - if "default_port" in tool_data: - existing.default_port = tool_data["default_port"] - logger.info("Updated built-in tool type: %s", tool_data["name"]) - - await session.commit() - logger.info("Built-in tool types seeded successfully.") - - @app.on_event("startup") async def on_startup(): logger.info("Starting up Headquarter API...") @@ -267,8 +113,6 @@ async def on_startup(): import sys sys.exit(1) - # Seed built-in data - await seed_builtin_tool_types() logger.info("Startup complete.") app.include_router(health_router) diff --git a/apps/api/src/models/tool_type.py b/apps/api/src/models/tool_type.py index a8ae655..530fc9f 100644 --- a/apps/api/src/models/tool_type.py +++ b/apps/api/src/models/tool_type.py @@ -31,7 +31,6 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base): ) readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True) required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) - is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) created_by_id: Mapped[uuid.UUID | None] = mapped_column( UUID(), ForeignKey("users.id"), diff --git a/apps/api/tests/integration/test_tool_types_api.py b/apps/api/tests/integration/test_tool_types_api.py index 01532f1..b9df8dc 100644 --- a/apps/api/tests/integration/test_tool_types_api.py +++ b/apps/api/tests/integration/test_tool_types_api.py @@ -98,7 +98,6 @@ def _insert_tool_type( name: str, display_name: str, compose_template: str, - is_builtin: bool = False, created_by_id: str | None = None, ) -> None: async def _run() -> None: @@ -120,7 +119,6 @@ def _insert_tool_type( description="A test tool type", compose_template=compose_template, required_variables=["REPO_PATH", "TOOL_NAME"], - is_builtin=is_builtin, created_by_id=uuid.UUID(created_by_id) if created_by_id else None, ) await session.merge(tool_type) @@ -234,7 +232,6 @@ def test_create_tool_type_successfully() -> None: assert data["name"] == "my-custom-tool" assert data["display_name"] == "My Custom Tool" assert data["description"] == "A custom development tool" - assert data["is_builtin"] == False assert data["created_by_id"] == user_id assert "id" in data @@ -376,28 +373,7 @@ def test_update_tool_type_not_found() -> None: assert response.status_code == 404 -@pytest.mark.integration -def test_update_builtin_tool_type_fails() -> None: - _prepare_test_db() - user_id = "11111111-1111-1111-1111-111111111111" - tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - _insert_user(user_id) - _insert_tool_type( - tool_type_id, - "builtin-tool", - "Built-in Tool", - "version: '3.8'\nservices:\n app:\n image: builtin", - is_builtin=True, - ) - - app = _load_app() - client = TestClient(app) - client.cookies.set("access_token", _mint_token(user_id)) - payload = {"display_name": "Updated"} - response = client.put(f"/tool-types/{tool_type_id}", json=payload) - - assert response.status_code == 403 @pytest.mark.integration @@ -442,53 +418,4 @@ def test_delete_tool_type_not_found() -> None: assert response.status_code == 404 -@pytest.mark.integration -def test_delete_builtin_tool_type_fails() -> None: - _prepare_test_db() - user_id = "11111111-1111-1111-1111-111111111111" - tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - _insert_user(user_id) - _insert_tool_type( - tool_type_id, - "builtin-tool", - "Built-in Tool", - "version: '3.8'\nservices:\n app:\n image: builtin", - is_builtin=True, - ) - - app = _load_app() - client = TestClient(app) - client.cookies.set("access_token", _mint_token(user_id)) - response = client.delete(f"/tool-types/{tool_type_id}") - - assert response.status_code == 403 - - -@pytest.mark.integration -def test_builtin_tool_types_seeded_on_startup() -> None: - _prepare_test_db() - user_id = "11111111-1111-1111-1111-111111111111" - _insert_user(user_id) - - # Load app triggers startup event which seeds built-in types - app = _load_app() - client = TestClient(app) - client.cookies.set("access_token", _mint_token(user_id)) - - response = client.get("/tool-types") - - assert response.status_code == 200 - data = response.json() - - # Check that built-in types exist - builtin_names = [t["name"] for t in data if t["is_builtin"]] - assert "code-server" in builtin_names - assert "jupyter-notebook" in builtin_names - - # Verify built-in types have correct attributes - code_server = next((t for t in data if t["name"] == "code-server"), None) - assert code_server is not None - assert code_server["display_name"] == "VS Code Server" - assert "services" in code_server["compose_template"] - assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"] diff --git a/apps/web/src/api/tool_types.ts b/apps/web/src/api/tool_types.ts index 599b646..50f8807 100644 --- a/apps/web/src/api/tool_types.ts +++ b/apps/web/src/api/tool_types.ts @@ -21,7 +21,6 @@ export interface ToolType { build_context: Record | null; readiness_probe: ReadinessProbe | null; required_variables: string[]; - is_builtin: boolean; created_by_id: string | null; created_at: string; updated_at: string; diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 8636e49..d01b798 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -1,11 +1,12 @@ -import { useCallback, useEffect } from "react"; -import { Link, NavLink, Outlet } from "react-router-dom"; +import { useCallback, useEffect, useState } from "react"; +import { Link, NavLink, Outlet, useLocation } from "react-router-dom"; import { getUserSessions } from "../api/sessions"; import type { Session } from "../api/sessions"; import { useTheme } from "../hooks/use-theme"; import { useAuth } from "../state/auth"; import { useSessions } from "../state/sessions"; +import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { Icon } from "./icon"; import type { IconName } from "../utils/icons"; @@ -39,6 +40,11 @@ export const AppShell = () => { useTheme(); const { user, logout } = useAuth(); const { sessions, setAllSessions } = useSessions(); + const location = useLocation(); + const isMobile = useMobileViewport(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal"); const loadSessions = useCallback(async () => { try { @@ -58,6 +64,19 @@ export const AppShell = () => { return () => clearInterval(interval); }, [loadSessions]); + // Close mobile menu on route change + useEffect(() => { + setMobileMenuOpen(false); + }, [location.pathname]); + + if (isMobileTerminal) { + return ( +
+ +
+ ); + } + return (
@@ -82,7 +101,17 @@ export const AppShell = () => {
-