refactor: store workspaces as {workspace_id}/{repo_name} for natural git clone layout

Working copies were stored as /data/working-copies/{repo_id}/{workspace_name}/,
so git clone was forced into a user-named directory. That meant the container
mount basename was the workspace name (e.g. main) instead of the repo name.

- Generate the workspace UUID before cloning and clone into
  /data/working-copies/{workspace_id}/ so git creates {repo_name}/ naturally
- Set workspace.path to /data/working-copies/{workspace_id}/{repo_name}/
- Update _migrate_clone_into_workspace() to use the same layout
- _get_repository_mount_name() now prefers workspace.path basename and only
  falls back to remote URL / repo.name for legacy repo-only instances
- Update unit tests to assert workspace path basename is used for mounts

Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
This commit is contained in:
Developer
2026-06-15 09:40:59 +00:00
parent 6e33e8e4e9
commit b26ed7c3e4
27 changed files with 176 additions and 87 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/services/tool
## role
Provides containerized execution environment for tools with git repository access, configuration management, and secure remote connectivity.
Provides infrastructure orchestration for isolated tool instances, managing their complete lifecycle from provisioning to teardown.
## parent
index: apps/api/src/services/.pi-map.index.md
map: apps/api/src/services/.pi-map.md
File diff suppressed because one or more lines are too long
+25 -11
View File
@@ -20,6 +20,7 @@ from src.models import (
SSHKey,
ToolInstance,
ToolType,
Workspace,
)
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
from src.services.git.clone import check_dirty_state, clone_repository
@@ -79,13 +80,21 @@ logger = logging.getLogger(__name__)
_event_bus = InstanceEventBus()
def _get_repository_mount_name(repo: GitRepository) -> str:
"""Return the directory name a standard git clone would create.
def _get_repository_mount_name(
repo: GitRepository,
workspace: "Workspace | None" = None,
) -> str:
"""Return the directory name the repository should appear under in the container.
Prefers the repository name parsed from the remote URL so the container
mount matches what users expect from ``git clone``. Falls back to the
user-provided repository name when no remote URL is available.
When a workspace exists, the on-disk layout is
``/data/working-copies/{workspace_id}/{repo_name}/``, so the repo-named
directory is already available as the basename of ``workspace.path``. For
legacy repo-only instances we fall back to parsing the remote URL like a
standard ``git clone`` would, then to the user-provided repository name.
"""
if workspace is not None and workspace.path:
return os.path.basename(os.path.normpath(workspace.path))
if repo.remote_url:
base_url = extract_base_repo_url(repo.remote_url) or repo.remote_url
name = base_url.rstrip("/").split("/")[-1]
@@ -907,10 +916,14 @@ async def prepare_manifest_instance(
# Use the repository name for the workspace/repo mount target, not the
# directory name of a workspace/clone path (which may be "main" or similar).
# Prefer the name parsed from the remote URL so it matches a standard clone.
# Prefer the actual on-disk workspace directory name when a workspace is
# mounted, otherwise fall back to parsing the remote URL like git clone.
repo = await session.get(GitRepository, instance.repository_id)
workspace: Workspace | None = None
if instance.workspace_id:
workspace = await session.get(Workspace, instance.workspace_id)
repo_name = (
_get_repository_mount_name(repo)
_get_repository_mount_name(repo, workspace)
if repo
else os.path.basename(os.path.normpath(repo_path))
)
@@ -1075,7 +1088,7 @@ async def create_tool_instance(
)
home_dir = tool_type.home_directory or "/home/user"
mount_name = _get_repository_mount_name(repo)
mount_name = _get_repository_mount_name(repo, workspace)
workspace_target = f"{home_dir}/{mount_name}"
compose_content = f"""version: "3.8"\nservices:
@@ -1112,14 +1125,15 @@ async def create_tool_instance(
# Manifest templates use WORKSPACE_PATH; REPO_PATH is retained as a
# deprecated alias for backward compatibility with older templates.
mount_name = _get_repository_mount_name(repo, workspace)
variables = {
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance_name.lower(),
"INSTANCE_DIR": instance_dir,
"WORKSPACE_PATH": repo_path,
"REPO_PATH": repo_path,
"REPO_NAME": _get_repository_mount_name(repo),
"WORKSPACE_NAME": _get_repository_mount_name(repo),
"REPO_NAME": mount_name,
"WORKSPACE_NAME": mount_name,
"SSH_PATH": "",
"TOOL_PORT": tool_port,
"EXTRA_ENV": {},
@@ -1140,7 +1154,7 @@ async def create_tool_instance(
"TOOL_PORT": tool_port,
"USER_ID": str(user_id),
"PROJECT_ID": str(project_id),
"WORKSPACE_NAME": _get_repository_mount_name(repo),
"WORKSPACE_NAME": _get_repository_mount_name(repo, workspace),
"HOME_DIRECTORY": tool_type.home_directory or "/home/user",
}
compose_content = render_compose_template(tool_type.compose_template, variables)