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:
2026-05-31 23:02:45 +02:00
parent d2b6bba15c
commit d567225bf7
14 changed files with 2354 additions and 0 deletions
+118
View File
@@ -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
+175
View File
@@ -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
)