WIP: working-copies backend cleanup
- Remove clone_mode/branch from API responses and make DB columns nullable - Remove legacy clone-mode branches from create_tool_instance - Add WORKSPACE_PATH compose variable alongside REPO_PATH - Add workspace migration helpers in WorkspaceManager Remaining: POST /workspaces/:id/instances, frontend clone_mode cleanup, tests
This commit is contained in:
@@ -150,7 +150,7 @@ class WorkspaceManager:
|
||||
|
||||
# Stop and delete all instances
|
||||
for instance in instances:
|
||||
await self._stop_and_delete_instance(instance)
|
||||
await self._stop_and_delete_instance(instance, session)
|
||||
|
||||
# Delete directory
|
||||
if os.path.exists(workspace.path):
|
||||
@@ -248,10 +248,154 @@ class WorkspaceManager:
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _stop_and_delete_instance(self, instance: ToolInstance) -> None:
|
||||
async def _stop_and_delete_instance(
|
||||
self,
|
||||
instance: ToolInstance,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""Stop and delete a tool instance.
|
||||
|
||||
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
||||
For now, this is a placeholder.
|
||||
Delegates to the instance lifecycle service to ensure containers,
|
||||
tunnels, and on-disk files are cleaned up.
|
||||
"""
|
||||
logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
|
||||
# Local import avoids a circular dependency between workspace and
|
||||
# instance lifecycle modules.
|
||||
from src.services.tool.instance_service import delete_tool_instance
|
||||
|
||||
try:
|
||||
await delete_tool_instance(
|
||||
session=session,
|
||||
user_id=instance.owner_id,
|
||||
project_id=instance.project_id,
|
||||
repo_id=instance.repository_id,
|
||||
instance_id=instance.id,
|
||||
force=True,
|
||||
)
|
||||
logger.info("Stopped and deleted instance %s", instance.id)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to stop/delete instance %s during workspace cleanup: %s",
|
||||
instance.id,
|
||||
exc,
|
||||
)
|
||||
|
||||
async def ensure_instance_workspace(
|
||||
self,
|
||||
instance: ToolInstance,
|
||||
session: AsyncSession,
|
||||
) -> Workspace:
|
||||
"""Return the workspace for an instance, creating/binding one if needed.
|
||||
|
||||
This migrates legacy instances that were created before workspaces
|
||||
existed. Clone-mode instances have their existing clone moved into a
|
||||
workspace, while mount-mode instances get a fresh workspace from the
|
||||
canonical repository.
|
||||
|
||||
The operation is best-effort: failures are re-raised so the caller can
|
||||
decide whether to continue with a legacy fallback path.
|
||||
"""
|
||||
if instance.workspace_id is not None:
|
||||
workspace = await session.get(Workspace, instance.workspace_id)
|
||||
if workspace is not None:
|
||||
return workspace
|
||||
|
||||
from src.models import GitRepository
|
||||
|
||||
repo = await session.get(GitRepository, instance.repository_id)
|
||||
if repo is None:
|
||||
raise RuntimeError(f"Repository {instance.repository_id} not found")
|
||||
|
||||
base_name = (
|
||||
f"{instance.name}-migrated"
|
||||
if instance.clone_mode == "clone"
|
||||
else f"{instance.name}-legacy"
|
||||
)
|
||||
name = base_name
|
||||
counter = 1
|
||||
while await self._workspace_name_exists(session, repo.id, name):
|
||||
name = f"{base_name}-{counter}"
|
||||
counter += 1
|
||||
|
||||
if instance.clone_mode == "clone":
|
||||
workspace = await self._migrate_clone_into_workspace(
|
||||
instance, repo, session, name
|
||||
)
|
||||
else:
|
||||
workspace = await self.create(
|
||||
repo=repo,
|
||||
user_id=instance.owner_id,
|
||||
name=name,
|
||||
branch=instance.branch or "main",
|
||||
session=session,
|
||||
)
|
||||
|
||||
instance.workspace_id = workspace.id
|
||||
instance.clone_mode = None
|
||||
session.add(instance)
|
||||
await session.commit()
|
||||
await session.refresh(instance)
|
||||
logger.info(
|
||||
"Migrated instance %s to workspace %s (%s)",
|
||||
instance.id,
|
||||
workspace.id,
|
||||
name,
|
||||
)
|
||||
return workspace
|
||||
|
||||
async def _workspace_name_exists(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
repo_id: uuid.UUID,
|
||||
name: str,
|
||||
) -> bool:
|
||||
"""Check whether a workspace name already exists for a repository."""
|
||||
result = await session.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.repo_id == repo_id,
|
||||
Workspace.name == name,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def _migrate_clone_into_workspace(
|
||||
self,
|
||||
instance: ToolInstance,
|
||||
repo: "GitRepository",
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
) -> Workspace:
|
||||
"""Move an existing clone-mode repo into a new workspace path."""
|
||||
import shutil
|
||||
|
||||
if not instance.compose_path:
|
||||
raise RuntimeError("Instance has no compose path")
|
||||
|
||||
instance_dir = os.path.dirname(instance.compose_path)
|
||||
clone_path = os.path.join(instance_dir, "repo-clone")
|
||||
if not os.path.exists(clone_path):
|
||||
raise RuntimeError(f"Clone path not found: {clone_path}")
|
||||
|
||||
path = self._workspace_path(repo.id, name)
|
||||
parent = os.path.dirname(path)
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(parent, 0o777)
|
||||
|
||||
if os.path.exists(path):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
shutil.move(clone_path, path)
|
||||
self._make_world_writable(path)
|
||||
|
||||
workspace = Workspace(
|
||||
name=name,
|
||||
repo_id=repo.id,
|
||||
user_id=instance.owner_id,
|
||||
branch=instance.branch or "main",
|
||||
path=path,
|
||||
status="ready",
|
||||
last_sync_at=datetime.now(),
|
||||
)
|
||||
session.add(workspace)
|
||||
await session.flush()
|
||||
return workspace
|
||||
|
||||
Reference in New Issue
Block a user