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}")
+97
View File
@@ -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;
}
+51
View File
@@ -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 -1
View File
@@ -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>
+41
View File
@@ -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;
};
+43
View File
@@ -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
+146
View File
@@ -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
+157
View File
@@ -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