463 lines
15 KiB
Python
463 lines
15 KiB
Python
"""Workspace CRUD API endpoints."""
|
|
|
|
import logging
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
|
from src.models import GitRepository
|
|
from src.models import Project
|
|
from src.models import ToolInstance
|
|
from src.models import Workspace
|
|
from src.services.shared.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
|
all_workspaces_router = APIRouter(prefix="/workspaces")
|
|
|
|
|
|
@all_workspaces_router.get("/")
|
|
async def list_all_workspaces(
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> list[dict]:
|
|
"""List all workspaces for the current user across all repos."""
|
|
instance_count = (
|
|
select(func.count(ToolInstance.id))
|
|
.where(ToolInstance.workspace_id == Workspace.id)
|
|
.correlate(Workspace)
|
|
.scalar_subquery()
|
|
)
|
|
|
|
result = await session.execute(
|
|
select(
|
|
Workspace,
|
|
GitRepository,
|
|
instance_count.label("instance_count"),
|
|
)
|
|
.join(GitRepository, Workspace.repo_id == GitRepository.id)
|
|
.options(selectinload(GitRepository.project))
|
|
.where(Workspace.user_id == user_id)
|
|
.order_by(Workspace.created_at.desc())
|
|
)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
"id": str(ws.id),
|
|
"name": ws.name,
|
|
"repo_id": str(repo.id),
|
|
"repo_name": repo.name or "",
|
|
"repo_ssh_key_id": str(repo.ssh_key_id) if repo.ssh_key_id else None,
|
|
"project_id": str(repo.project_id) if repo.project_id else "",
|
|
"project_name": repo.project.name if repo.project else "",
|
|
"user_id": str(ws.user_id),
|
|
"branch": ws.branch,
|
|
"path": ws.path,
|
|
"status": ws.status,
|
|
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
|
|
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
|
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
|
"instance_count": count or 0,
|
|
}
|
|
for ws, repo, count in rows
|
|
]
|
|
|
|
|
|
@all_workspaces_router.delete("/{workspace_id}")
|
|
async def delete_workspace_top_level(
|
|
workspace_id: uuid.UUID,
|
|
force: bool = Query(False),
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Delete a workspace via top-level path."""
|
|
workspace = await session.get(Workspace, workspace_id)
|
|
if not workspace or workspace.user_id != user_id:
|
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
|
|
|
manager = WorkspaceManager()
|
|
try:
|
|
await manager.delete(workspace, force=force, session=session)
|
|
await session.commit()
|
|
except WorkspaceHasInstancesError as exc:
|
|
await session.rollback()
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"message": "Workspace has running tool instances",
|
|
"instances": exc.instances,
|
|
},
|
|
) from exc
|
|
except Exception as exc:
|
|
await session.rollback()
|
|
logger.error("Failed to delete workspace: %s", exc)
|
|
raise HTTPException(
|
|
status_code=500, detail="Failed to delete workspace"
|
|
) from exc
|
|
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@all_workspaces_router.post("/")
|
|
async def create_workspace_top_level(
|
|
data: dict,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Create a workspace directly (no nested project/repo path)."""
|
|
repo_id_str = data.get("repo_id", "").strip()
|
|
if not repo_id_str:
|
|
raise HTTPException(status_code=400, detail="repo_id is required")
|
|
|
|
try:
|
|
repo_id = uuid.UUID(repo_id_str)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc
|
|
|
|
repo = await session.get(GitRepository, repo_id)
|
|
if not repo or repo.owner_id != user_id:
|
|
raise HTTPException(status_code=404, detail="Repository not found")
|
|
|
|
name = data.get("name", "").strip()
|
|
branch = data.get("branch", "main").strip()
|
|
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Workspace name is required")
|
|
|
|
project = await session.get(Project, repo.project_id) if repo.project_id else None
|
|
manager = WorkspaceManager()
|
|
try:
|
|
workspace = await manager.create(
|
|
repo, user_id, name, branch, session=session, project=project
|
|
)
|
|
session.add(workspace)
|
|
await session.commit()
|
|
except HTTPException:
|
|
raise
|
|
except ValueError as exc:
|
|
await session.rollback()
|
|
logger.error("Failed to create workspace: %s", exc)
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
await session.rollback()
|
|
logger.error("Failed to create workspace: %s", exc)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to create workspace: {exc}",
|
|
) from exc
|
|
|
|
await session.refresh(workspace)
|
|
return {
|
|
"id": str(workspace.id),
|
|
"name": workspace.name,
|
|
"repo_id": str(workspace.repo_id),
|
|
"branch": workspace.branch,
|
|
"path": workspace.path,
|
|
"status": workspace.status,
|
|
"created_at": workspace.created_at.isoformat()
|
|
if workspace.created_at
|
|
else None,
|
|
}
|
|
|
|
|
|
@router.get("/")
|
|
async def list_workspaces(
|
|
project_id: uuid.UUID,
|
|
repo_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> list[dict]:
|
|
"""List workspaces for a repository, with instance counts."""
|
|
# Verify repo belongs to project and user
|
|
repo = await _get_repo(session, repo_id, project_id, user_id)
|
|
|
|
# Build subquery for instance counts
|
|
instance_count = (
|
|
select(func.count(ToolInstance.id))
|
|
.where(ToolInstance.workspace_id == Workspace.id)
|
|
.correlate(Workspace)
|
|
.scalar_subquery()
|
|
)
|
|
|
|
result = await session.execute(
|
|
select(
|
|
Workspace,
|
|
instance_count.label("instance_count"),
|
|
)
|
|
.where(Workspace.repo_id == repo_id)
|
|
.order_by(Workspace.created_at.desc())
|
|
)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
"id": str(ws.id),
|
|
"name": ws.name,
|
|
"repo_id": str(ws.repo_id),
|
|
"repo_name": repo.name,
|
|
"repo_ssh_key_id": str(repo.ssh_key_id) if repo.ssh_key_id else None,
|
|
"project_id": str(repo.project_id) if repo.project_id else "",
|
|
"project_name": repo.project.name if repo.project else "",
|
|
"user_id": str(ws.user_id),
|
|
"branch": ws.branch,
|
|
"path": ws.path,
|
|
"status": ws.status,
|
|
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
|
|
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
|
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
|
"instance_count": count or 0,
|
|
}
|
|
for ws, count in rows
|
|
]
|
|
|
|
|
|
@router.post("/")
|
|
async def create_workspace(
|
|
project_id: uuid.UUID,
|
|
repo_id: uuid.UUID,
|
|
data: dict,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Create a new workspace by cloning a repository branch."""
|
|
repo = await _get_repo(session, repo_id, project_id, user_id)
|
|
|
|
name = data.get("name", "").strip()
|
|
branch = data.get("branch", "main").strip()
|
|
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Workspace name is required")
|
|
if not branch:
|
|
raise HTTPException(status_code=400, detail="Branch is required")
|
|
|
|
project = await session.get(Project, project_id)
|
|
manager = WorkspaceManager()
|
|
try:
|
|
workspace = await manager.create(
|
|
repo, user_id, name, branch, session=session, project=project
|
|
)
|
|
session.add(workspace)
|
|
await session.commit()
|
|
except HTTPException:
|
|
raise
|
|
except ValueError as exc:
|
|
await session.rollback()
|
|
logger.error("Failed to create workspace: %s", exc)
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
await session.rollback()
|
|
logger.error("Failed to create workspace: %s", exc)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to create workspace: {exc}",
|
|
) from exc
|
|
|
|
await session.refresh(workspace)
|
|
return {
|
|
"id": str(workspace.id),
|
|
"name": workspace.name,
|
|
"repo_id": str(workspace.repo_id),
|
|
"branch": workspace.branch,
|
|
"path": workspace.path,
|
|
"status": workspace.status,
|
|
"created_at": workspace.created_at.isoformat()
|
|
if workspace.created_at
|
|
else None,
|
|
}
|
|
|
|
|
|
@router.get("/{workspace_id}")
|
|
async def get_workspace_detail(
|
|
project_id: uuid.UUID,
|
|
repo_id: uuid.UUID,
|
|
workspace_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Get workspace details."""
|
|
repo = await _get_repo(session, repo_id, project_id, user_id)
|
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
|
|
|
# Count instances
|
|
result = await session.execute(
|
|
select(func.count(ToolInstance.id)).where(
|
|
ToolInstance.workspace_id == workspace_id
|
|
)
|
|
)
|
|
instance_count = result.scalar() or 0
|
|
|
|
return {
|
|
"id": str(workspace.id),
|
|
"name": workspace.name,
|
|
"repo_id": str(workspace.repo_id),
|
|
"repo_name": repo.name,
|
|
"user_id": str(workspace.user_id),
|
|
"branch": workspace.branch,
|
|
"path": workspace.path,
|
|
"status": workspace.status,
|
|
"last_sync_at": workspace.last_sync_at.isoformat()
|
|
if workspace.last_sync_at
|
|
else None,
|
|
"created_at": workspace.created_at.isoformat()
|
|
if workspace.created_at
|
|
else None,
|
|
"updated_at": workspace.updated_at.isoformat()
|
|
if workspace.updated_at
|
|
else None,
|
|
"instance_count": instance_count,
|
|
}
|
|
|
|
|
|
@router.patch("/{workspace_id}")
|
|
async def update_workspace(
|
|
project_id: uuid.UUID,
|
|
repo_id: uuid.UUID,
|
|
workspace_id: uuid.UUID,
|
|
data: dict,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Update workspace name or branch."""
|
|
await _get_repo(session, repo_id, project_id, user_id)
|
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
|
|
|
new_name = data.get("name", "").strip()
|
|
new_branch = data.get("branch", "").strip()
|
|
|
|
if new_name:
|
|
workspace.name = new_name
|
|
if new_branch:
|
|
workspace.branch = new_branch
|
|
|
|
try:
|
|
await session.commit()
|
|
except Exception as exc:
|
|
await session.rollback()
|
|
logger.error("Failed to update workspace: %s", exc)
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Workspace name already exists for this repository",
|
|
) from exc
|
|
|
|
return {
|
|
"id": str(workspace.id),
|
|
"name": workspace.name,
|
|
"branch": workspace.branch,
|
|
"status": workspace.status,
|
|
}
|
|
|
|
|
|
@router.delete("/{workspace_id}")
|
|
async def delete_workspace(
|
|
project_id: uuid.UUID,
|
|
repo_id: uuid.UUID,
|
|
workspace_id: uuid.UUID,
|
|
force: bool = Query(False),
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Delete a workspace. Returns 409 if instances exist and force=False."""
|
|
await _get_repo(session, repo_id, project_id, user_id)
|
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
|
|
|
manager = WorkspaceManager()
|
|
try:
|
|
await manager.delete(workspace, force=force, session=session)
|
|
await session.commit()
|
|
except WorkspaceHasInstancesError as exc:
|
|
await session.rollback()
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"message": "Workspace has running tool instances",
|
|
"instances": exc.instances,
|
|
},
|
|
) from exc
|
|
except Exception as exc:
|
|
await session.rollback()
|
|
logger.error("Failed to delete workspace: %s", exc)
|
|
raise HTTPException(
|
|
status_code=500, detail="Failed to delete workspace"
|
|
) from exc
|
|
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.post("/{workspace_id}/sync")
|
|
async def sync_workspace(
|
|
project_id: uuid.UUID,
|
|
repo_id: uuid.UUID,
|
|
workspace_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Sync workspace with remote. Returns 409 if branch was deleted."""
|
|
await _get_repo(session, repo_id, project_id, user_id)
|
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
|
|
|
manager = WorkspaceManager()
|
|
result = await manager.sync(workspace, session=session)
|
|
|
|
if result.branch_deleted:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"message": f"Branch '{workspace.branch}' was deleted from remote",
|
|
"branch_deleted": True,
|
|
},
|
|
)
|
|
|
|
await session.commit()
|
|
return {
|
|
"branch_deleted": False,
|
|
"pulled": True,
|
|
"last_sync_at": workspace.last_sync_at.isoformat()
|
|
if workspace.last_sync_at
|
|
else None,
|
|
}
|
|
|
|
|
|
async def _get_repo(
|
|
session: AsyncSession,
|
|
repo_id: uuid.UUID,
|
|
project_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
) -> GitRepository:
|
|
"""Fetch and validate repository access."""
|
|
result = await session.execute(
|
|
select(GitRepository)
|
|
.where(
|
|
GitRepository.id == repo_id,
|
|
GitRepository.project_id == project_id,
|
|
)
|
|
.options(selectinload(GitRepository.project))
|
|
)
|
|
repo = result.scalar_one_or_none()
|
|
if not repo:
|
|
raise HTTPException(status_code=404, detail="Repository not found")
|
|
return repo
|
|
|
|
|
|
async def _get_workspace(
|
|
session: AsyncSession,
|
|
workspace_id: uuid.UUID,
|
|
repo_id: uuid.UUID,
|
|
) -> Workspace:
|
|
"""Fetch and validate workspace."""
|
|
result = await session.execute(
|
|
select(Workspace).where(
|
|
Workspace.id == workspace_id,
|
|
Workspace.repo_id == repo_id,
|
|
)
|
|
)
|
|
workspace = result.scalar_one_or_none()
|
|
if not workspace:
|
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
|
return workspace
|