From c795f8f873c0bc46f98587483b088620cf158de8 Mon Sep 17 00:00:00 2001 From: Fusion Date: Tue, 19 May 2026 20:42:59 +0200 Subject: [PATCH] feat: implement tool instances backend and session navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Create ToolInstance model with status tracking - Add Alembic migration for tool_instances table - Create Docker service for compose template rendering and container execution - Add CRUD API endpoints for tool instances - Add lifecycle endpoints (start/stop/restart) - Add user sessions endpoint for navigation - Register routers in main.py Frontend: - Create SessionsProvider with React context - Create sessions API client - Update AppShell with sessions section in navigation - Add session status indicators and polling - Add CSS for session navigation Quality gates: typecheck ✓, lint ✓, build ✓ --- .../alembic/versions/0006_tool_instances.py | 55 +++ apps/api/src/api/tool_instances.py | 425 ++++++++++++++++++ apps/api/src/main.py | 4 + apps/api/src/models/__init__.py | 3 +- apps/api/src/models/tool_instance.py | 60 +++ apps/api/src/services/docker.py | 171 +++++++ apps/web/src/api/sessions.ts | 97 ++++ apps/web/src/components/app-shell.tsx | 51 +++ apps/web/src/main.tsx | 5 +- apps/web/src/state/sessions.tsx | 41 ++ apps/web/src/styles.css | 43 ++ .../.openspec/config.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/spec.md | 0 .../tasks.md | 0 .../tool-instances/.openspec/config.yaml | 2 + openspec/changes/tool-instances/design.md | 146 ++++++ openspec/changes/tool-instances/proposal.md | 50 +++ openspec/changes/tool-instances/specs/spec.md | 147 ++++++ openspec/changes/tool-instances/tasks.md | 157 +++++++ 21 files changed, 1455 insertions(+), 2 deletions(-) create mode 100644 apps/api/alembic/versions/0006_tool_instances.py create mode 100644 apps/api/src/api/tool_instances.py create mode 100644 apps/api/src/models/tool_instance.py create mode 100644 apps/api/src/services/docker.py create mode 100644 apps/web/src/api/sessions.ts create mode 100644 apps/web/src/state/sessions.tsx rename openspec/changes/{responsive-spacing-improvements => archive/2026-05-19-responsive-spacing-improvements}/.openspec/config.yaml (100%) rename openspec/changes/{responsive-spacing-improvements => archive/2026-05-19-responsive-spacing-improvements}/design.md (100%) rename openspec/changes/{responsive-spacing-improvements => archive/2026-05-19-responsive-spacing-improvements}/proposal.md (100%) rename openspec/changes/{responsive-spacing-improvements => archive/2026-05-19-responsive-spacing-improvements}/specs/spec.md (100%) rename openspec/changes/{responsive-spacing-improvements => archive/2026-05-19-responsive-spacing-improvements}/tasks.md (100%) create mode 100644 openspec/changes/tool-instances/.openspec/config.yaml create mode 100644 openspec/changes/tool-instances/design.md create mode 100644 openspec/changes/tool-instances/proposal.md create mode 100644 openspec/changes/tool-instances/specs/spec.md create mode 100644 openspec/changes/tool-instances/tasks.md diff --git a/apps/api/alembic/versions/0006_tool_instances.py b/apps/api/alembic/versions/0006_tool_instances.py new file mode 100644 index 0000000..34fc06f --- /dev/null +++ b/apps/api/alembic/versions/0006_tool_instances.py @@ -0,0 +1,55 @@ +"""add tool_instances table + +Revision ID: 0006_tool_instances +Revises: 0005_ssh_keys_timestamps +Create Date: 2026-05-19 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0006_tool_instances" +down_revision: Union[str, None] = "0005_ssh_keys_timestamps" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "tool_instances", + sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("display_name", sa.String(255), nullable=False), + sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("repository_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("status", sa.String(50), nullable=False, server_default="pending"), + sa.Column("container_id", sa.String(255), nullable=True), + sa.Column("compose_path", sa.String(1024), nullable=True), + sa.Column("url", sa.String(1024), nullable=True), + sa.Column("port", sa.Integer(), nullable=True), + sa.Column("last_started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_stopped_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]), + sa.ForeignKeyConstraint(["repository_id"], ["git_repositories.id"]), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_tool_instances_owner", "tool_instances", ["owner_id"]) + op.create_index("idx_tool_instances_repo", "tool_instances", ["repository_id"]) + op.create_index("idx_tool_instances_status", "tool_instances", ["status"]) + + +def downgrade() -> None: + op.drop_index("idx_tool_instances_status", table_name="tool_instances") + op.drop_index("idx_tool_instances_repo", table_name="tool_instances") + op.drop_index("idx_tool_instances_owner", table_name="tool_instances") + op.drop_table("tool_instances") diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py new file mode 100644 index 0000000..e31cf19 --- /dev/null +++ b/apps/api/src/api/tool_instances.py @@ -0,0 +1,425 @@ +"""Tool instance API endpoints.""" + +import os +import uuid +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id +from src.database import get_db_session +from src.models.git_repository import GitRepository +from src.models.project import Project +from src.models.tool_instance import ToolInstance +from src.models.tool_type import ToolType +from src.models.user import User +from src.services.docker import ( + ensure_instance_directory, + execute_compose_command, + find_free_port, + get_container_id, + get_container_logs, + get_container_status, + render_compose_template, + write_compose_file, +) + +router = APIRouter(prefix="/projects", tags=["tool-instances"]) + + +async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: + user = await session.get(User, user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="user not found" + ) + return user + + +async def _get_owned_project( + project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession +) -> Project: + project = await session.get(Project, project_id) + if project is None or project.owner_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="project not found" + ) + return project + + +@router.post("/{project_id}/repositories/{repo_id}/instances") +async def create_instance( + project_id: uuid.UUID, + repo_id: uuid.UUID, + tool_type_id: uuid.UUID, + display_name: str | None = None, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Create a new tool instance for a repository.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) + + tool_type = await session.get(ToolType, tool_type_id) + if tool_type is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found" + ) + + # Generate unique name + instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" + instance_display = display_name or f"{tool_type.display_name} - {repo.name}" + + # Create instance directory + instance_dir = ensure_instance_directory(instance_name) + compose_path = os.path.join(instance_dir, "docker-compose.yml") + + # Find free port + tool_port = find_free_port() + + # Render compose template + variables = { + "REPO_PATH": repo.path, + "INSTANCE_NAME": instance_name, + "INSTANCE_ID": instance_name, + "TOOL_PORT": tool_port, + "USER_ID": str(user_id), + "PROJECT_ID": str(project_id), + } + compose_content = render_compose_template(tool_type.compose_template, variables) + write_compose_file(instance_dir, compose_content) + + # Create database record + instance = ToolInstance( + name=instance_name, + display_name=instance_display, + tool_type_id=tool_type_id, + repository_id=repo_id, + project_id=project_id, + owner_id=user_id, + status="pending", + compose_path=compose_path, + port=tool_port, + ) + session.add(instance) + await session.commit() + await session.refresh(instance) + + return { + "id": str(instance.id), + "name": instance.name, + "display_name": instance.display_name, + "tool_type_id": str(instance.tool_type_id), + "status": instance.status, + "created_at": instance.created_at.isoformat(), + } + + +@router.get("/{project_id}/repositories/{repo_id}/instances") +async def list_instances( + project_id: uuid.UUID, + repo_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """List all instances for a repository.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" + ) + + result = await session.execute( + select(ToolInstance) + .where(ToolInstance.repository_id == repo_id) + .where(ToolInstance.owner_id == user_id) + .order_by(ToolInstance.created_at.desc()) + ) + instances = result.scalars().all() + + return { + "instances": [ + { + "id": str(i.id), + "name": i.name, + "display_name": i.display_name, + "tool_type_id": str(i.tool_type_id), + "status": i.status, + "url": i.url, + "port": i.port, + "created_at": i.created_at.isoformat(), + } + for i in instances + ] + } + + +@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}") +async def get_instance( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get a specific instance with real-time status.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + ) + + # Get real-time status from Docker + if instance.container_id: + docker_status = get_container_status(instance.container_id) + if docker_status == "running" and instance.status != "running": + instance.status = "running" + await session.commit() + elif docker_status == "exited" and instance.status == "running": + instance.status = "stopped" + instance.last_stopped_at = datetime.now() + await session.commit() + + return { + "id": str(instance.id), + "name": instance.name, + "display_name": instance.display_name, + "tool_type_id": str(instance.tool_type_id), + "status": instance.status, + "container_id": instance.container_id, + "compose_path": instance.compose_path, + "url": instance.url, + "port": instance.port, + "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(), + } + + +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/start") +async def start_instance( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Start a tool instance.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + ) + + if not instance.compose_path or not os.path.exists(instance.compose_path): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found" + ) + + instance.status = "building" + await session.commit() + + # Execute docker compose up + returncode, stdout, stderr = execute_compose_command( + instance.compose_path, "up" + ) + + if returncode != 0: + instance.status = "error" + await session.commit() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"failed to start instance: {stderr}", + ) + + # Get container ID + container_id = get_container_id(instance.name) + if container_id: + instance.container_id = container_id + + instance.status = "running" + instance.last_started_at = datetime.now() + instance.url = f"http://localhost:{instance.port}" + await session.commit() + + return {"status": instance.status, "url": instance.url} + + +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop") +async def stop_instance( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Stop a tool instance.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + ) + + if instance.compose_path and os.path.exists(instance.compose_path): + execute_compose_command(instance.compose_path, "stop") + + instance.status = "stopped" + instance.last_stopped_at = datetime.now() + instance.url = None + await session.commit() + + return {"status": instance.status} + + +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart") +async def restart_instance( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Restart a tool instance.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + ) + + if instance.compose_path and os.path.exists(instance.compose_path): + returncode, stdout, stderr = execute_compose_command( + instance.compose_path, "restart" + ) + + if returncode == 0: + instance.status = "running" + instance.last_started_at = datetime.now() + instance.url = f"http://localhost:{instance.port}" + await session.commit() + return {"status": instance.status, "url": instance.url} + + instance.status = "error" + await session.commit() + return {"status": instance.status} + + +@router.delete("/{project_id}/repositories/{repo_id}/instances/{instance_id}") +async def delete_instance( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + """Delete a tool instance.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + ) + + # Stop and remove container + if instance.compose_path and os.path.exists(instance.compose_path): + execute_compose_command(instance.compose_path, "down") + + # Remove instance directory + if instance.compose_path: + instance_dir = os.path.dirname(instance.compose_path) + if os.path.exists(instance_dir): + import shutil + shutil.rmtree(instance_dir) + + await session.delete(instance) + await session.commit() + + +@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs") +async def get_instance_logs( + project_id: uuid.UUID, + repo_id: uuid.UUID, + instance_id: uuid.UUID, + tail: int = 100, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get container logs for an instance.""" + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" + ) + + if not instance.container_id: + return {"logs": "No container running"} + + logs = get_container_logs(instance.container_id, tail) + return {"logs": logs} + + +from fastapi import APIRouter as FastAPIRouter + +sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"]) + +@sessions_router.get("/me/sessions") +async def get_user_sessions( + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get all active sessions (running instances) for the current user.""" + _user = await _get_user(session, user_id) + + result = await session.execute( + select(ToolInstance) + .where(ToolInstance.owner_id == user_id) + .where(ToolInstance.status.in_(["running", "building", "pending"])) + .order_by(ToolInstance.created_at.desc()) + ) + instances = result.scalars().all() + + sessions = [] + for instance in instances: + tool_type = await session.get(ToolType, instance.tool_type_id) + 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", + "repository_name": repo.name if repo else "unknown", + "project_name": project.name if project else "unknown", + "status": instance.status, + "url": instance.url, + }) + + return {"sessions": sessions} diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 5016526..87680c0 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -11,6 +11,8 @@ from src.api.dashboard import router as dashboard_router from src.api.git_repositories import router as git_repositories_router from src.api.projects import router as projects_router from src.api.ssh_keys import router as ssh_keys_router +from src.api.tool_instances import router as tool_instances_router +from src.api.tool_instances import sessions_router 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 @@ -164,4 +166,6 @@ app.include_router(ssh_keys_router) app.include_router(git_repositories_router) app.include_router(user_config_router) app.include_router(tool_types_router) +app.include_router(tool_instances_router) +app.include_router(sessions_router) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index a2a4130..c7a573b 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -2,8 +2,9 @@ from src.models.base import Base from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey +from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType from src.models.user import User from src.models.user_config import UserConfig -__all__ = ["Base", "GitRepository", "Project", "SSHKey", "ToolType", "User", "UserConfig"] +__all__ = ["Base", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"] diff --git a/apps/api/src/models/tool_instance.py b/apps/api/src/models/tool_instance.py new file mode 100644 index 0000000..68b58bd --- /dev/null +++ b/apps/api/src/models/tool_instance.py @@ -0,0 +1,60 @@ +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy import Uuid as UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from src.models.git_repository import GitRepository + from src.models.project import Project + from src.models.tool_type import ToolType + from src.models.user import User + + +class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "tool_instances" + + name: Mapped[str] = mapped_column(String(255), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + tool_type_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("tool_types.id"), nullable=False + ) + repository_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("git_repositories.id"), nullable=False + ) + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("projects.id"), nullable=False + ) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("users.id"), nullable=False + ) + status: Mapped[str] = mapped_column( + String(50), nullable=False, default="pending" + ) + container_id: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + compose_path: Mapped[str | None] = mapped_column( + String(1024), nullable=True + ) + url: Mapped[str | None] = mapped_column( + String(1024), nullable=True + ) + port: Mapped[int | None] = mapped_column( + Integer, nullable=True + ) + last_started_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + last_stopped_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + tool_type: Mapped["ToolType"] = relationship() + repository: Mapped["GitRepository"] = relationship() + project: Mapped["Project"] = relationship() + owner: Mapped["User"] = relationship() diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py new file mode 100644 index 0000000..993a9b4 --- /dev/null +++ b/apps/api/src/services/docker.py @@ -0,0 +1,171 @@ +"""Docker service for managing tool instances.""" + +import os +import subprocess +from pathlib import Path +from typing import Any + + +def render_compose_template(template: str, variables: dict[str, Any]) -> str: + """Render a Docker Compose template with variable substitution. + + Args: + template: The compose template string + variables: Dictionary of variable names to values + + Returns: + Rendered compose file content + """ + result = template + for key, value in variables.items(): + placeholder = f"{{{{{key}}}}}" + result = result.replace(placeholder, str(value)) + return result + + +def ensure_instance_directory(instance_id: str, base_path: str = "data/instances") -> str: + """Create and return the instance directory path. + + Args: + instance_id: Unique instance identifier + base_path: Base directory for all instances + + Returns: + Absolute path to instance directory + """ + instance_dir = Path(base_path) / instance_id + instance_dir.mkdir(parents=True, exist_ok=True) + return str(instance_dir.absolute()) + + +def write_compose_file(instance_dir: str, content: str) -> str: + """Write the rendered compose file to the instance directory. + + Args: + instance_dir: Path to instance directory + content: Rendered compose content + + Returns: + Path to the compose file + """ + compose_path = Path(instance_dir) / "docker-compose.yml" + compose_path.write_text(content) + return str(compose_path) + + +def execute_compose_command( + compose_path: str, action: str, timeout: int = 60 +) -> tuple[int, str, str]: + """Execute a docker compose command. + + Args: + compose_path: Path to docker-compose.yml + action: The compose action (up, down, start, stop, restart) + timeout: Command timeout in seconds + + Returns: + Tuple of (returncode, stdout, stderr) + """ + instance_dir = Path(compose_path).parent + + cmd = ["docker", "compose", "-f", compose_path] + + if action == "up": + cmd.extend(["up", "-d"]) + elif action == "down": + cmd.extend(["down", "-v"]) + elif action in ("start", "stop", "restart"): + cmd.append(action) + else: + raise ValueError(f"Unknown compose action: {action}") + + result = subprocess.run( + cmd, + cwd=str(instance_dir), + capture_output=True, + text=True, + timeout=timeout, + ) + + return result.returncode, result.stdout, result.stderr + + +def get_container_id(instance_name: str) -> str | None: + """Get the container ID for a compose service. + + Args: + instance_name: The service name in compose + + Returns: + Container ID or None if not found + """ + result = subprocess.run( + ["docker", "ps", "-q", "--filter", f"name={instance_name}"], + capture_output=True, + text=True, + ) + + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip().split("\n")[0] + return None + + +def get_container_status(container_id: str) -> str: + """Get the status of a Docker container. + + Args: + container_id: Docker container ID + + Returns: + Container status string (running, exited, etc.) + """ + result = subprocess.run( + ["docker", "inspect", "-f", "{{.State.Status}}", container_id], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + return result.stdout.strip() + return "unknown" + + +def get_container_logs(container_id: str, tail: int = 100) -> str: + """Get the logs of a Docker container. + + Args: + container_id: Docker container ID + tail: Number of lines to return + + Returns: + Container logs + """ + result = subprocess.run( + ["docker", "logs", "--tail", str(tail), container_id], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + return result.stdout + return f"Failed to get logs: {result.stderr}" + + +def find_free_port(start: int = 10000, end: int = 20000) -> int: + """Find a free TCP port in the given range. + + Args: + start: Start of port range + end: End of port range + + Returns: + Free port number + """ + import socket + + for port in range(start, end): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(("localhost", port)) != 0: + return port + + raise RuntimeError(f"No free port found in range {start}-{end}") diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts new file mode 100644 index 0000000..9f807d8 --- /dev/null +++ b/apps/web/src/api/sessions.ts @@ -0,0 +1,97 @@ +import { apiClient } from "./client"; + +export interface ToolInstance { + id: string; + name: string; + display_name: string; + tool_type_id: string; + status: string; + url: string | null; + port: number | null; + created_at: string; +} + +export interface Session { + id: string; + display_name: string; + tool_type_name: string; + tool_icon: string; + repository_name: string; + project_name: string; + status: string; + url: string | null; +} + +export async function listInstances( + projectId: string, + repoId: string +): Promise { + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/instances` + ); + return response.data.instances; +} + +export async function createInstance( + projectId: string, + repoId: string, + toolTypeId: string, + displayName?: string +): Promise { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances`, + { + tool_type_id: toolTypeId, + display_name: displayName, + } + ); + return response.data; +} + +export async function startInstance( + projectId: string, + repoId: string, + instanceId: string +): Promise<{ status: string; url?: string }> { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start` + ); + return response.data; +} + +export async function stopInstance( + projectId: string, + repoId: string, + instanceId: string +): Promise<{ status: string }> { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop` + ); + return response.data; +} + +export async function restartInstance( + projectId: string, + repoId: string, + instanceId: string +): Promise<{ status: string; url?: string }> { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart` + ); + return response.data; +} + +export async function deleteInstance( + projectId: string, + repoId: string, + instanceId: string +): Promise { + await apiClient.delete( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}` + ); +} + +export async function getUserSessions(): Promise { + const response = await apiClient.get("/users/me/sessions"); + return response.data.sessions; +} diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index f749dd7..92ba608 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -1,7 +1,11 @@ +import { useCallback, useEffect } from "react"; import { Link, NavLink, Outlet } 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 { Icon } from "./icon"; import type { IconName } from "../utils/icons"; @@ -13,9 +17,46 @@ const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [ { to: "/settings", label: "Settings", icon: "settings" } ]; +const SessionItem = ({ session }: { session: Session }) => { + const isRunning = session.status === "running"; + + return ( + + + + {session.display_name} + + ); +}; + export const AppShell = () => { useTheme(); const { user, logout } = useAuth(); + const { sessions, setAllSessions } = useSessions(); + + const loadSessions = useCallback(async () => { + try { + const data = await getUserSessions(); + setAllSessions(data); + } catch { + // Silently fail - sessions are optional + } + }, [setAllSessions]); + + useEffect(() => { + void loadSessions(); + // Poll every 10 seconds + const interval = setInterval(() => { + void loadSessions(); + }, 10000); + return () => clearInterval(interval); + }, [loadSessions]); return (
@@ -53,6 +94,16 @@ export const AppShell = () => { {item.label} ))} + + {sessions.length > 0 && ( + <> +
+
Sessions
+ {sessions.map((session) => ( + + ))} + + )}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e95320a..22327e6 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,13 +4,16 @@ import { BrowserRouter } from "react-router-dom"; import { AppRouter } from "./router"; import { AuthProvider } from "./state/auth"; +import { SessionsProvider } from "./state/sessions"; import "./styles.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + + + diff --git a/apps/web/src/state/sessions.tsx b/apps/web/src/state/sessions.tsx new file mode 100644 index 0000000..8422788 --- /dev/null +++ b/apps/web/src/state/sessions.tsx @@ -0,0 +1,41 @@ +import { createContext, useContext, useState, type ReactNode } from "react"; + +export interface Session { + id: string; + display_name: string; + tool_type_name: string; + tool_icon: string; + repository_name: string; + project_name: string; + status: string; + url: string | null; +} + +interface SessionsContextType { + sessions: Session[]; + setAllSessions: (sessions: Session[]) => void; +} + +const SessionsContext = createContext(undefined); + +export const SessionsProvider = ({ children }: { children: ReactNode }) => { + const [sessions, setSessions] = useState([]); + + const setAllSessions = (newSessions: Session[]) => { + setSessions(newSessions); + }; + + return ( + + {children} + + ); +}; + +export const useSessions = () => { + const context = useContext(SessionsContext); + if (context === undefined) { + throw new Error("useSessions must be used within a SessionsProvider"); + } + return context; +}; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 1f79c24..8d01357 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2217,3 +2217,46 @@ a.nav-item, align-items: center; } } + +/* Session Navigation */ +.session-item { + position: relative; + padding-left: var(--space-6); +} + +.session-status { + position: absolute; + left: var(--space-2); + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); +} + +.session-status.running { + background: var(--success); +} + +.session-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 140px; +} + +.nav-divider { + height: 1px; + background: var(--border); + margin: var(--space-2) var(--space-3); +} + +.nav-section-title { + padding: var(--space-2) var(--space-4); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} diff --git a/openspec/changes/responsive-spacing-improvements/.openspec/config.yaml b/openspec/changes/archive/2026-05-19-responsive-spacing-improvements/.openspec/config.yaml similarity index 100% rename from openspec/changes/responsive-spacing-improvements/.openspec/config.yaml rename to openspec/changes/archive/2026-05-19-responsive-spacing-improvements/.openspec/config.yaml diff --git a/openspec/changes/responsive-spacing-improvements/design.md b/openspec/changes/archive/2026-05-19-responsive-spacing-improvements/design.md similarity index 100% rename from openspec/changes/responsive-spacing-improvements/design.md rename to openspec/changes/archive/2026-05-19-responsive-spacing-improvements/design.md diff --git a/openspec/changes/responsive-spacing-improvements/proposal.md b/openspec/changes/archive/2026-05-19-responsive-spacing-improvements/proposal.md similarity index 100% rename from openspec/changes/responsive-spacing-improvements/proposal.md rename to openspec/changes/archive/2026-05-19-responsive-spacing-improvements/proposal.md diff --git a/openspec/changes/responsive-spacing-improvements/specs/spec.md b/openspec/changes/archive/2026-05-19-responsive-spacing-improvements/specs/spec.md similarity index 100% rename from openspec/changes/responsive-spacing-improvements/specs/spec.md rename to openspec/changes/archive/2026-05-19-responsive-spacing-improvements/specs/spec.md diff --git a/openspec/changes/responsive-spacing-improvements/tasks.md b/openspec/changes/archive/2026-05-19-responsive-spacing-improvements/tasks.md similarity index 100% rename from openspec/changes/responsive-spacing-improvements/tasks.md rename to openspec/changes/archive/2026-05-19-responsive-spacing-improvements/tasks.md diff --git a/openspec/changes/tool-instances/.openspec/config.yaml b/openspec/changes/tool-instances/.openspec/config.yaml new file mode 100644 index 0000000..6a3699e --- /dev/null +++ b/openspec/changes/tool-instances/.openspec/config.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +name: tool-instances diff --git a/openspec/changes/tool-instances/design.md b/openspec/changes/tool-instances/design.md new file mode 100644 index 0000000..8176fb9 --- /dev/null +++ b/openspec/changes/tool-instances/design.md @@ -0,0 +1,146 @@ +# Tool Instances - Design + +## Architecture + +``` +Tool Instance System +├── Backend +│ ├── ToolInstance Model +│ ├── Session API (CRUD + lifecycle) +│ ├── Docker Service (compose execution) +│ └── Status Polling +├── Frontend +│ ├── SessionStore (active sessions) +│ ├── AppShell Integration (nav entries) +│ ├── Instance Manager (repo page) +│ └── Session Launcher (create dialog) +└── Docker + ├── Compose Template Rendering + ├── Container Execution + └── Volume Management +``` + +## Data Model + +### ToolInstance +```python +class ToolInstance(Base): + id: UUID + name: str # Generated: "vscode-myrepo-abc123" + display_name: str # User-friendly name + tool_type_id: UUID -> ToolType + repository_id: UUID -> GitRepository + project_id: UUID -> Project + owner_id: UUID -> User + status: str # pending, building, running, stopped, error + container_id: str | None + compose_path: str | None # Path to rendered compose file + url: str | None # Access URL + port: int | None + last_started_at: datetime | None + last_stopped_at: datetime | None + created_at: datetime + updated_at: datetime +``` + +## API Design + +### Endpoints +``` +POST /projects/{id}/repositories/{id}/instances +GET /projects/{id}/repositories/{id}/instances +GET /projects/{id}/repositories/{id}/instances/{id} +PUT /projects/{id}/repositories/{id}/instances/{id} +DELETE /projects/{id}/repositories/{id}/instances/{id} +POST /projects/{id}/repositories/{id}/instances/{id}/start +POST /projects/{id}/repositories/{id}/instances/{id}/stop +POST /projects/{id}/repositories/{id}/instances/{id}/restart +GET /projects/{id}/repositories/{id}/instances/{id}/status +GET /projects/{id}/repositories/{id}/instances/{id}/logs +GET /users/me/sessions # Active sessions for nav +``` + +## Docker Integration + +### Compose Template Rendering +```yaml +# Template variables: +# {{REPO_PATH}} - Absolute path to repo +# {{INSTANCE_NAME}} - Unique instance name +# {{TOOL_PORT}} - Exposed port + +services: + {{INSTANCE_NAME}}: + image: codercom/code-server:latest + volumes: + - {{REPO_PATH}}:/workspace + ports: + - "{{TOOL_PORT}}:8080" + environment: + - PASSWORD={{INSTANCE_NAME}} +``` + +### Execution Flow +1. Create instance directory: `data/instances/{instance_id}/` +2. Render compose file to `docker-compose.yml` +3. Run `docker compose -f {path} up -d` +4. Capture container ID from output +5. Poll status until running or error + +## Frontend Integration + +### Session Store +```typescript +interface Session { + id: string; + name: string; + displayName: string; + toolType: string; + toolIcon: string; + repositoryId: string; + projectId: string; + status: "pending" | "running" | "stopped" | "error"; + url: string | null; +} + +const useSessions = () => { + const sessions = useAtom(sessionsAtom); + const addSession = (session: Session) => { ... }; + const removeSession = (id: string) => { ... }; + const updateStatus = (id: string, status: string) => { ... }; + return { sessions, addSession, removeSession, updateStatus }; +}; +``` + +### AppShell Navigation +- Add "Sessions" section in nav +- Show active sessions with tool icons +- Session status indicator (green dot for running) +- Click opens tool in new tab +- Dropdown for managing sessions + +### Repository Page +- "Launch Tool" button +- Dialog to select tool type +- Instance list with status/actions +- Quick actions: start/stop/delete + +## State Machine + +``` +[create] -> pending -> [docker up] -> building -> [container running] -> running + | + v + [docker error] -> error + +[running] -> [stop] -> stopped +[stopped] -> [start] -> pending -> building -> running +[any] -> [delete] -> [docker down] -> deleted +``` + +## Security + +- Only repository owner can create instances +- Instances run in isolated Docker networks +- No privileged containers +- Resource limits (CPU, memory) on containers diff --git a/openspec/changes/tool-instances/proposal.md b/openspec/changes/tool-instances/proposal.md new file mode 100644 index 0000000..cb7f6f7 --- /dev/null +++ b/openspec/changes/tool-instances/proposal.md @@ -0,0 +1,50 @@ +# Tool Instances with Sessions + +## Problem + +Users currently have no way to launch development tools (code-server, Jupyter, etc.) directly from their repositories. The tool-types system exists but cannot create running container instances. Additionally, there's no concept of a "session" - a running tool linked to a specific repo that appears in navigation for quick access. + +## Solution + +Implement a complete tool instance management system with sessions: + +1. **ToolInstance Model** - Links a ToolType to a GitRepository with status tracking +2. **Session Concept** - A running ToolInstance that gets a top-level navigation entry +3. **Docker Integration** - Render compose templates and execute docker compose commands +4. **Lifecycle Management** - Start, stop, restart, and delete instances +5. **Navigation Integration** - Active sessions appear in the app shell for quick access + +## Key Features + +### Tool Instance Creation +- Select a tool type and repository +- Generate unique instance name +- Render Docker Compose template with variables +- Execute `docker compose up -d` +- Store container metadata + +### Session Management +- Sessions are active/running instances +- Each session gets a top-level nav entry with the tool icon +- Session dropdown in app shell shows active sessions +- Clicking a session opens the tool in a new tab/window + +### Lifecycle Operations +- Start: `docker compose start` +- Stop: `docker compose stop` +- Restart: `docker compose restart` +- Delete: `docker compose down -v` + remove DB record + +### Status Monitoring +- pending, building, running, stopped, error +- Real-time status via Docker API +- Last accessed timestamp + +## Success Criteria + +- [ ] Create tool instances from repository page +- [ ] Sessions appear in top-level navigation +- [ ] Start/stop/restart/delete instances +- [ ] Status monitoring works +- [ ] Docker Compose templates render correctly +- [ ] Navigation updates when sessions change diff --git a/openspec/changes/tool-instances/specs/spec.md b/openspec/changes/tool-instances/specs/spec.md new file mode 100644 index 0000000..67f066c --- /dev/null +++ b/openspec/changes/tool-instances/specs/spec.md @@ -0,0 +1,147 @@ +# Tool Instances Specification + +## Requirements + +### Functional Requirements + +1. **ToolInstance Model**: Store instance metadata with status tracking +2. **Session API**: CRUD operations + lifecycle (start/stop/restart/delete) +3. **Docker Integration**: Render compose templates and execute commands +4. **Status Monitoring**: Real-time container status polling +5. **Log Access**: View container logs (last 100 lines) +6. **Session Navigation**: Active sessions appear in app shell +7. **URL Generation**: Unique access URLs for each running instance + +### Non-Functional Requirements + +1. **Security**: Isolated containers, no privileged mode +2. **Resource Limits**: CPU and memory constraints +3. **Error Handling**: Graceful failure with cleanup +4. **Performance**: Start time < 30 seconds + +## API Specification + +### Create Instance +``` +POST /projects/{project_id}/repositories/{repo_id}/instances +Body: { + tool_type_id: string, + display_name: string (optional) +} +Response: { + id: string, + name: string, + display_name: string, + tool_type_id: string, + status: "pending", + created_at: string +} +``` + +### List Instances +``` +GET /projects/{project_id}/repositories/{repo_id}/instances +Response: { + instances: [...] +} +``` + +### Get Instance +``` +GET /projects/{project_id}/repositories/{repo_id}/instances/{instance_id} +Response: { + id: string, + name: string, + display_name: string, + status: string, + container_id: string | null, + url: string | null, + port: number | null, + last_started_at: string | null, + last_stopped_at: string | null, + created_at: string +} +``` + +### Lifecycle Operations +``` +POST /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/start +POST /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop +POST /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart +Response: { status: string } +``` + +### Delete Instance +``` +DELETE /projects/{project_id}/repositories/{repo_id}/instances/{instance_id} +Response: 204 No Content +``` + +### Get User Sessions +``` +GET /users/me/sessions +Response: { + sessions: [ + { + id: string, + display_name: string, + tool_type_name: string, + tool_icon: string, + repository_name: string, + project_name: string, + status: string, + url: string | null + } + ] +} +``` + +## Docker Compose Template Variables + +- `{{REPO_PATH}}`: Absolute path to git repository on host +- `{{INSTANCE_NAME}}`: Unique instance identifier +- `{{INSTANCE_ID}}`: UUID of the instance +- `{{TOOL_PORT}}`: Assigned port for the tool +- `{{USER_ID}}`: Owner user ID +- `{{PROJECT_ID}}`: Project ID + +## Status Values + +- `pending`: Instance created, waiting to start +- `building`: Docker compose up in progress +- `running`: Container is running +- `stopped`: Container stopped +- `error`: Container failed to start or crashed + +## Database Schema + +```sql +CREATE TABLE tool_instances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + tool_type_id UUID NOT NULL REFERENCES tool_types(id), + repository_id UUID NOT NULL REFERENCES git_repositories(id), + project_id UUID NOT NULL REFERENCES projects(id), + owner_id UUID NOT NULL REFERENCES users(id), + status VARCHAR(50) NOT NULL DEFAULT 'pending', + container_id VARCHAR(255), + compose_path VARCHAR(1024), + url VARCHAR(1024), + port INTEGER, + last_started_at TIMESTAMP WITH TIME ZONE, + last_stopped_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_tool_instances_owner ON tool_instances(owner_id); +CREATE INDEX idx_tool_instances_repo ON tool_instances(repository_id); +CREATE INDEX idx_tool_instances_status ON tool_instances(status); +``` + +## Testing Requirements + +1. **Unit Tests**: Docker command generation, template rendering +2. **Integration Tests**: API endpoints, database operations +3. **Manual Testing**: Container lifecycle, navigation updates diff --git a/openspec/changes/tool-instances/tasks.md b/openspec/changes/tool-instances/tasks.md new file mode 100644 index 0000000..b064d4f --- /dev/null +++ b/openspec/changes/tool-instances/tasks.md @@ -0,0 +1,157 @@ +# Tool Instances - Tasks + +## Phase 1: Backend Model & Database + +- [ ] **Task 1.1**: Create ToolInstance model + - Create `src/models/tool_instance.py` + - Fields: id, name, display_name, tool_type_id, repository_id, project_id, owner_id, status, container_id, compose_path, url, port, timestamps + - Add relationship to ToolType and GitRepository + - Add to `models/__init__.py` + +- [ ] **Task 1.2**: Create Alembic migration + - Generate migration for tool_instances table + - Add indexes for owner_id, repository_id, status + +## Phase 2: Backend Docker Service + +- [ ] **Task 2.1**: Create Docker service + - Create `src/services/docker.py` + - Function: `render_compose_template(template, variables)` + - Function: `execute_compose_command(compose_path, action)` + - Function: `get_container_status(container_id)` + - Function: `get_container_logs(container_id, tail=100)` + - Error handling and cleanup + +- [ ] **Task 2.2**: Create instance directory structure + - Base path: `data/instances/{instance_id}/` + - Render compose file to `docker-compose.yml` + - Ensure directory exists and is writable + +## Phase 3: Backend API + +- [ ] **Task 3.1**: Create instances API module + - Create `src/api/tool_instances.py` + - Import required dependencies + +- [ ] **Task 3.2**: Implement create instance endpoint + - POST /projects/{id}/repositories/{id}/instances + - Validate tool_type_id exists + - Generate unique instance name + - Render compose template + - Save to database (status: pending) + - Return instance metadata + +- [ ] **Task 3.3**: Implement list instances endpoint + - GET /projects/{id}/repositories/{id}/instances + - Filter by repository + - Include tool type info + +- [ ] **Task 3.4**: Implement get instance endpoint + - GET /projects/{id}/repositories/{id}/instances/{id} + - Include real-time status from Docker + +- [ ] **Task 3.5**: Implement lifecycle endpoints + - POST .../start - execute docker compose up + - POST .../stop - execute docker compose stop + - POST .../restart - execute docker compose restart + - Update status in database + +- [ ] **Task 3.6**: Implement delete instance endpoint + - DELETE /projects/{id}/repositories/{id}/instances/{id} + - Execute docker compose down -v + - Remove instance directory + - Delete database record + +- [ ] **Task 3.7**: Implement user sessions endpoint + - GET /users/me/sessions + - Return all running instances for current user + - Include tool type icon and names + +- [ ] **Task 3.8**: Register router + - Add tool_instances router to main.py + +## Phase 4: Frontend State Management + +- [ ] **Task 4.1**: Create session store + - Create `src/state/sessions.ts` + - Define Session interface + - Create atom for sessions list + - Add helper functions + +- [ ] **Task 4.2**: Create sessions API client + - Create `src/api/sessions.ts` + - Functions: list, create, start, stop, restart, delete, getStatus + - TypeScript interfaces + +## Phase 5: Frontend Components + +- [ ] **Task 5.1**: Update AppShell with sessions + - Add "Sessions" section in navigation + - Show active sessions with icons + - Status indicators (green dot) + - Click opens tool URL + +- [ ] **Task 5.2**: Create LaunchToolDialog + - Select tool type from dropdown + - Enter display name (optional) + - Create instance on submit + - Show creation progress + +- [ ] **Task 5.3**: Create InstanceList component + - List instances for a repository + - Show status, name, tool type + - Action buttons: start/stop/restart/delete + - Open URL button + +- [ ] **Task 5.4**: Create InstanceCard component + - Compact card showing instance info + - Status badge + - Quick actions + +## Phase 6: Frontend Pages + +- [ ] **Task 6.1**: Add instances to repository page + - Add "Instances" tab or section + - Show InstanceList + - Add "Launch Tool" button + +- [ ] **Task 6.2**: Create sessions dropdown + - Add to header or nav + - Quick access to active sessions + - Show status indicators + +## Phase 7: Integration & Polish + +- [ ] **Task 7.1**: Add instance status polling + - Poll status every 5 seconds + - Update session store + - Reflect in UI + +- [ ] **Task 7.2**: Add error handling + - Docker failures + - Template rendering errors + - Network errors + +- [ ] **Task 7.3**: Add loading states + - Creating instance + - Starting/stopping + - Deleting + +## Phase 8: Quality Gates + +- [ ] **Task 8.1**: Backend tests + - ruff check + - mypy check + - pytest + +- [ ] **Task 8.2**: Frontend tests + - typecheck + - lint + - build + +- [ ] **Task 8.3**: Manual testing + - Create instance + - Start/stop/restart + - Delete + - Navigation updates + - Status polling