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:
Developer
2026-06-12 15:17:01 +00:00
parent 40ac931c65
commit 79aabd6f43
6 changed files with 178 additions and 124 deletions
@@ -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
+9 -104
View File
@@ -830,8 +830,6 @@ async def prepare_manifest_instance(
# Prepare SSH path for mount resolution
ssh_path = ""
if instance.clone_mode == "clone":
ssh_path = os.path.join(instance_dir, ".ssh")
# Resolve git mount variables from config profile
git_mount_vars = {}
@@ -848,6 +846,7 @@ async def prepare_manifest_instance(
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance.name.lower(),
"INSTANCE_DIR": instance_dir,
"WORKSPACE_PATH": repo_path,
"REPO_PATH": repo_path,
"SSH_PATH": ssh_path,
"TOOL_PORT": instance.port or 0,
@@ -922,13 +921,6 @@ async def create_tool_instance(
if workspace.repo_id != repo_id:
raise ValueError("workspace does not belong to this repository")
# Validate clone mode requirements (legacy path)
if data.clone_mode == "clone" and not workspace:
if not repo.remote_url:
raise ValueError("repository does not have a remote URL for cloning")
if not repo.ssh_key_id:
raise ValueError("repository must have an SSH key assigned for clone mode")
# Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
@@ -970,71 +962,13 @@ async def create_tool_instance(
# Find free port
tool_port = find_free_port()
# Determine repo path based on workspace or clone mode
# Determine the source path to mount. Workspaces are the canonical path;
# legacy instances without a workspace fall back to the repository path.
if workspace:
repo_path = workspace.path
elif data.clone_mode == "clone":
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key is None:
raise ValueError("repository SSH key not found")
ssh_key_path = None
try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
ssh_key_path = os.path.join(ssh_dir, "id_ed25519")
clone_path = clone_repository(
remote_url=repo.remote_url,
ssh_key_path=ssh_key_path,
instance_dir=instance_dir,
branch=data.branch or "main",
)
repo_path = clone_path
except Exception as exc:
logger.exception("Failed to clone repository: %s", exc)
cleanup_ssh_key_files(instance_dir)
raise RuntimeError(f"Failed to clone repository: {exc}")
else:
repo_path = repo.path
# Verify cloned repo has files
if data.clone_mode == "clone" and repo_path:
try:
repo_contents = os.listdir(repo_path)
if not repo_contents or (
len(repo_contents) == 1 and repo_contents[0] == ".git"
):
logger.error("Cloned repository at %s appears empty", repo_path)
raise RuntimeError("Cloned repository is empty")
logger.debug(
"Verified cloned repo at %s has %d items",
repo_path,
len(repo_contents),
)
except Exception as exc:
logger.exception("Failed to verify cloned repository: %s", exc)
raise RuntimeError(f"Cloned repository verification failed: {exc}")
# Create new local branch if requested
if data.clone_mode == "clone" and data.new_branch:
try:
result = subprocess.run(
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error(
"Failed to create branch %s: %s", data.new_branch, result.stderr
)
raise RuntimeError(f"Failed to create branch: {result.stderr}")
logger.debug(
"Created local branch %s in cloned repository", data.new_branch
)
except Exception as exc:
logger.exception("Failed to create local branch: %s", exc)
raise RuntimeError(f"Failed to create local branch: {exc}")
# Handle based on definition type
if tool_type.definition_type == "dockerfile":
image_tag = f"headquarter/{instance_name}:latest".lower()
@@ -1092,10 +1026,13 @@ async def create_tool_instance(
image_tag = compute_image_tag(tool_type.name, manifest)
# Manifest templates use WORKSPACE_PATH; REPO_PATH is retained as a
# deprecated alias for backward compatibility with older templates.
variables = {
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance_name.lower(),
"INSTANCE_DIR": instance_dir,
"WORKSPACE_PATH": repo_path,
"REPO_PATH": repo_path,
"SSH_PATH": "",
"TOOL_PORT": tool_port,
@@ -1109,6 +1046,7 @@ async def create_tool_instance(
if not tool_type.compose_template:
raise ValueError("Tool type has no compose template configured")
variables = {
"WORKSPACE_PATH": repo_path,
"REPO_PATH": repo_path,
"INSTANCE_NAME": instance_name,
"INSTANCE_ID": instance_name,
@@ -1121,37 +1059,6 @@ async def create_tool_instance(
tool_type.compose_template, variables
)
if data.clone_mode == "clone" and repo_path:
import yaml
compose_data = yaml.safe_load(compose_content)
repo_mounted = False
if compose_data and "services" in compose_data:
for svc in compose_data["services"].values():
volumes = svc.get("volumes", [])
for vol in volumes:
vol_str = str(vol)
if repo_path in vol_str:
repo_mounted = True
break
if repo_mounted:
break
if not repo_mounted:
logger.warning(
"Compose template for tool type %s does not mount repo path; adding default mount",
tool_type.name,
)
if compose_data and "services" in compose_data:
for svc in compose_data["services"].values():
if "volumes" not in svc:
svc["volumes"] = []
svc["volumes"].append(f"{repo_path}:/workspace")
break
compose_content = yaml.dump(
compose_data, default_flow_style=False
)
write_compose_file(instance_dir, compose_content)
# Create database record
@@ -1166,10 +1073,8 @@ async def create_tool_instance(
compose_path=compose_path,
port=tool_port,
workspace_id=workspace_id,
clone_mode=data.clone_mode,
branch=data.new_branch
if data.new_branch
else (data.branch if data.clone_mode == "clone" else None),
clone_mode=None,
branch=None,
selected_config_profile_id=selected_profile_id,
ssh_key_ids=data.ssh_key_ids or None,
)