feat: implement tool instances backend and session navigation

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 ✓
This commit is contained in:
Fusion
2026-05-19 20:42:59 +02:00
parent c52367401b
commit c795f8f873
21 changed files with 1455 additions and 2 deletions
@@ -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")
+425
View File
@@ -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}
+4
View File
@@ -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")
+2 -1
View File
@@ -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"]
+60
View File
@@ -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()
+171
View File
@@ -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}")