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:
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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,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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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<ToolInstance[]> {
|
||||
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<ToolInstance> {
|
||||
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<void> {
|
||||
await apiClient.delete(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserSessions(): Promise<Session[]> {
|
||||
const response = await apiClient.get("/users/me/sessions");
|
||||
return response.data.sessions;
|
||||
}
|
||||
@@ -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 (
|
||||
<a
|
||||
href={session.url || "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-item session-item"
|
||||
title={`${session.display_name} (${session.status})`}
|
||||
>
|
||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||
<span className="session-name">{session.display_name}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="shell">
|
||||
@@ -53,6 +94,16 @@ export const AppShell = () => {
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Sessions</div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="shell-content">
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRouter />
|
||||
<SessionsProvider>
|
||||
<AppRouter />
|
||||
</SessionsProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -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<SessionsContextType | undefined>(undefined);
|
||||
|
||||
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
|
||||
const setAllSessions = (newSessions: Session[]) => {
|
||||
setSessions(newSessions);
|
||||
};
|
||||
|
||||
return (
|
||||
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
|
||||
{children}
|
||||
</SessionsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSessions = () => {
|
||||
const context = useContext(SessionsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSessions must be used within a SessionsProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
name: tool-instances
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user