feat: workspace backend foundation (PR-1)
- Add workspaces table migration (2026_06_01_add_workspaces) - Create Workspace model with repo_id, user_id, branch, path, status - Add workspace_id nullable FK to ToolInstance - Create GitService for clone/fetch/pull/branch_exists_remotely - Create WorkspaceManager for create/delete/sync lifecycle - Create workspace CRUD API with 409 handling for duplicates and instances - Wire workspace routes into FastAPI app - 17 tests passing (8 unit + 9 integration), 1 skipped Quality gates: ruff clean
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
"""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.git_repository import GitRepository
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.workspace import Workspace
|
||||
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
||||
|
||||
|
||||
@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,
|
||||
"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")
|
||||
|
||||
manager = WorkspaceManager()
|
||||
try:
|
||||
workspace = await manager.create(repo, user_id, name, branch)
|
||||
session.add(workspace)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to create workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Workspace name already exists for this repository",
|
||||
) 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": [
|
||||
{"id": str(i.id), "name": i.name} for i in 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)
|
||||
|
||||
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
|
||||
@@ -24,6 +24,7 @@ from src.api.tool_types import router as tool_types_router
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.user_config import router as user_config_router
|
||||
from src.api.users import router as users_router
|
||||
from src.api.workspaces import router as workspaces_router
|
||||
from src.config import Settings
|
||||
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
||||
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||
@@ -159,4 +160,5 @@ app.include_router(instance_proxy_router)
|
||||
app.include_router(terminal_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(workspaces_router)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
|
||||
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
@@ -60,8 +61,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
tool_type: Mapped["ToolType"] = relationship()
|
||||
workspace: Mapped["Workspace | None"] = relationship()
|
||||
repository: Mapped["GitRepository"] = relationship()
|
||||
project: Mapped["Project"] = relationship()
|
||||
owner: Mapped["User"] = relationship()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Workspace model for persistent writable repo clones."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class Workspace(Base, TimestampMixin):
|
||||
"""A persistent, writable local clone of a Git repository.
|
||||
|
||||
Users create workspaces explicitly, then start tool instances on them.
|
||||
Multiple tool instances can share the same workspace.
|
||||
"""
|
||||
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("git_repositories.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
branch: Mapped[str] = mapped_column(String(255), nullable=False, default="main")
|
||||
path: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready")
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||
)
|
||||
|
||||
repository: Mapped[GitRepository] = relationship("GitRepository")
|
||||
owner: Mapped[User] = relationship("User")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Git operations for workspace management."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitService:
|
||||
"""Low-level git operations for creating and syncing workspaces."""
|
||||
|
||||
@staticmethod
|
||||
async def clone(remote_url: str, branch: str, path: str) -> None:
|
||||
"""Clone a repository to the given path.
|
||||
|
||||
Args:
|
||||
remote_url: The git remote URL.
|
||||
branch: The branch to clone.
|
||||
path: The destination path for the clone.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the clone fails.
|
||||
"""
|
||||
cmd = [
|
||||
"git",
|
||||
"clone",
|
||||
"--branch",
|
||||
branch,
|
||||
"--single-branch",
|
||||
remote_url,
|
||||
path,
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git clone failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git clone failed: {error_msg}")
|
||||
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
|
||||
|
||||
@staticmethod
|
||||
async def fetch(path: str) -> None:
|
||||
"""Fetch from origin.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If fetch fails.
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
path,
|
||||
"fetch",
|
||||
"origin",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git fetch failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git fetch failed: {error_msg}")
|
||||
logger.debug("Fetched origin for %s", path)
|
||||
|
||||
@staticmethod
|
||||
async def pull(path: str, branch: str) -> None:
|
||||
"""Pull latest changes from origin.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
branch: The branch to pull.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If pull fails.
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
path,
|
||||
"pull",
|
||||
"origin",
|
||||
branch,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git pull failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git pull failed: {error_msg}")
|
||||
logger.debug("Pulled origin/%s for %s", branch, path)
|
||||
|
||||
@staticmethod
|
||||
def branch_exists_remotely(path: str, branch: str) -> bool:
|
||||
"""Check if a branch exists on the remote.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
branch: The branch name to check.
|
||||
|
||||
Returns:
|
||||
True if the branch exists on origin, False otherwise.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
exists = result.returncode == 0 and result.stdout.strip() != ""
|
||||
logger.debug("Branch %s exists on remote: %s", branch, exists)
|
||||
return exists
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Workspace lifecycle management service."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.models.workspace import Workspace
|
||||
from src.services.git_service import GitService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.tool_instance import ToolInstance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
"""Result of a workspace sync operation."""
|
||||
|
||||
branch_deleted: bool = False
|
||||
|
||||
|
||||
class WorkspaceHasInstancesError(Exception):
|
||||
"""Raised when attempting to delete a workspace with running instances."""
|
||||
|
||||
def __init__(self, instances: list[ToolInstance]) -> None:
|
||||
self.instances = instances
|
||||
super().__init__(f"Workspace has {len(instances)} running tool instance(s)")
|
||||
|
||||
|
||||
class WorkspaceManager:
|
||||
"""Manages workspace lifecycle: create, delete, sync, validate."""
|
||||
|
||||
BASE_PATH = "/data/working-copies"
|
||||
|
||||
def _workspace_path(self, repo_id: uuid.UUID, name: str) -> str:
|
||||
"""Return the filesystem path for a workspace."""
|
||||
return os.path.join(self.BASE_PATH, str(repo_id), name)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
repo: GitRepository,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
branch: str = "main",
|
||||
) -> Workspace:
|
||||
"""Clone repo to workspace path and create DB record.
|
||||
|
||||
Args:
|
||||
repo: The git repository to clone.
|
||||
user_id: The owner user ID.
|
||||
name: The workspace name (unique per repo).
|
||||
branch: The branch to clone (default: "main").
|
||||
|
||||
Returns:
|
||||
The created Workspace record.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If git clone fails.
|
||||
"""
|
||||
path = self._workspace_path(repo.id, name)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
|
||||
logger.info(
|
||||
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
|
||||
)
|
||||
|
||||
if not repo.remote_url:
|
||||
raise ValueError("Repository has no remote URL")
|
||||
|
||||
await GitService.clone(repo.remote_url, branch, path)
|
||||
|
||||
workspace = Workspace(
|
||||
name=name,
|
||||
repo_id=repo.id,
|
||||
user_id=user_id,
|
||||
branch=branch,
|
||||
path=path,
|
||||
status="ready",
|
||||
last_sync_at=datetime.now(),
|
||||
)
|
||||
logger.info("Workspace created: %s", workspace.id)
|
||||
return workspace
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
force: bool = False,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Delete a workspace and all associated tool instances.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to delete.
|
||||
force: If True, delete even if instances exist.
|
||||
session: The database session (required for checking instances).
|
||||
|
||||
Raises:
|
||||
WorkspaceHasInstancesError: If instances exist and force=False.
|
||||
"""
|
||||
if session is None:
|
||||
raise ValueError("session is required for delete")
|
||||
|
||||
instances = await self._get_instances(workspace, session)
|
||||
if instances and not force:
|
||||
raise WorkspaceHasInstancesError(instances)
|
||||
|
||||
# Stop and delete all instances
|
||||
for instance in instances:
|
||||
await self._stop_and_delete_instance(instance)
|
||||
|
||||
# Delete directory
|
||||
if os.path.exists(workspace.path):
|
||||
shutil.rmtree(workspace.path, ignore_errors=True)
|
||||
logger.info("Deleted workspace directory: %s", workspace.path)
|
||||
|
||||
# Delete record
|
||||
await session.delete(workspace)
|
||||
logger.info("Deleted workspace record: %s", workspace.id)
|
||||
|
||||
async def sync(self, workspace: Workspace) -> SyncResult:
|
||||
"""Sync a workspace with its remote.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to sync.
|
||||
|
||||
Returns:
|
||||
SyncResult indicating whether the branch was deleted.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If git operations fail.
|
||||
"""
|
||||
logger.info("Syncing workspace: %s", workspace.id)
|
||||
|
||||
await GitService.fetch(workspace.path)
|
||||
|
||||
if not GitService.branch_exists_remotely(workspace.path, workspace.branch):
|
||||
return SyncResult(branch_deleted=True)
|
||||
|
||||
await GitService.pull(workspace.path, workspace.branch)
|
||||
workspace.last_sync_at = datetime.now()
|
||||
logger.info("Workspace synced: %s", workspace.id)
|
||||
return SyncResult(branch_deleted=False)
|
||||
|
||||
async def _get_instances(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
session: AsyncSession,
|
||||
) -> list[ToolInstance]:
|
||||
"""Get all tool instances associated with this workspace."""
|
||||
from src.models.tool_instance import ToolInstance
|
||||
|
||||
result = await session.execute(
|
||||
select(ToolInstance).where(ToolInstance.workspace_id == workspace.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _stop_and_delete_instance(self, instance: ToolInstance) -> None:
|
||||
"""Stop and delete a tool instance.
|
||||
|
||||
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
||||
For now, this is a placeholder.
|
||||
"""
|
||||
logger.warning(
|
||||
"Placeholder: stopping and deleting instance %s", instance.id
|
||||
)
|
||||
Reference in New Issue
Block a user