diff --git a/apps/api/src/api/tool/sessions.py b/apps/api/src/api/tool/sessions.py index 280d741..ed389f7 100644 --- a/apps/api/src/api/tool/sessions.py +++ b/apps/api/src/api/tool/sessions.py @@ -74,8 +74,6 @@ async def get_user_sessions( "workspace_name": workspace_name, "status": instance.status, "url": instance.url, - "clone_mode": instance.clone_mode, - "branch": instance.branch, "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, diff --git a/apps/api/src/api/tool/tool_instances.py b/apps/api/src/api/tool/tool_instances.py index 40f26dc..f17231e 100644 --- a/apps/api/src/api/tool/tool_instances.py +++ b/apps/api/src/api/tool/tool_instances.py @@ -68,8 +68,7 @@ async def create_instance( "display_name": instance.display_name, "tool_type_id": str(instance.tool_type_id), "status": instance.status, - "clone_mode": instance.clone_mode, - "branch": instance.branch, + "workspace_id": str(instance.workspace_id) if instance.workspace_id else None, "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "created_at": instance.created_at.isoformat(), } @@ -143,8 +142,6 @@ async def get_instance( "container_id": instance.container_id, "container_name": instance.container_name, "compose_path": instance.compose_path, - "clone_mode": instance.clone_mode, - "branch": instance.branch, "workspace_id": str(instance.workspace_id) if instance.workspace_id else None, "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "ssh_key_ids": instance.ssh_key_ids, diff --git a/apps/api/src/models/tool/tool_instance.py b/apps/api/src/models/tool/tool_instance.py index 772b267..119bf50 100644 --- a/apps/api/src/models/tool/tool_instance.py +++ b/apps/api/src/models/tool/tool_instance.py @@ -53,9 +53,11 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): ) image_tag: Mapped[str | None] = mapped_column(String(256), nullable=True) probe_result: Mapped[dict | None] = mapped_column(JSON, nullable=True) - clone_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mount") + clone_mode: Mapped[str | None] = mapped_column( + String(20), nullable=True, default=None + ) branch: Mapped[str | None] = mapped_column( - String(255), nullable=True, default="main" + String(255), nullable=True, default=None ) selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column( UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True diff --git a/apps/api/src/schemas/tool/tool_instance.py b/apps/api/src/schemas/tool/tool_instance.py index 72af055..7645481 100644 --- a/apps/api/src/schemas/tool/tool_instance.py +++ b/apps/api/src/schemas/tool/tool_instance.py @@ -13,16 +13,24 @@ class CreateInstanceRequest(BaseModel): default=None, description="Optional display name for the instance" ) workspace_id: str | None = Field( - default=None, description="UUID of workspace to mount (replaces clone_mode)" + default=None, description="UUID of workspace to mount" ) - clone_mode: str = Field( - default="mount", description="Repository access mode: 'mount' or 'clone'" + config_profile_id: str | None = Field( + default=None, description="Optional config profile ID for launch" ) - branch: str | None = Field( - default="main", description="Branch to clone (when clone_mode='clone')" + ssh_key_ids: list[str] = Field( + default_factory=list, description="SSH key IDs to mount into container ~/.ssh" ) - new_branch: str | None = Field( - default=None, description="Create a new local branch after cloning" + + +class CreateWorkspaceInstanceRequest(BaseModel): + """Request body for creating a tool instance directly on a workspace.""" + + model_config = {"extra": "ignore"} + + tool_type_id: str = Field(description="UUID of the tool type to instantiate") + display_name: str | None = Field( + default=None, description="Optional display name for the instance" ) config_profile_id: str | None = Field( default=None, description="Optional config profile ID for launch" diff --git a/apps/api/src/services/shared/workspace_manager.py b/apps/api/src/services/shared/workspace_manager.py index 972ddaf..4d0fc4b 100644 --- a/apps/api/src/services/shared/workspace_manager.py +++ b/apps/api/src/services/shared/workspace_manager.py @@ -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 diff --git a/apps/api/src/services/tool/instance_service.py b/apps/api/src/services/tool/instance_service.py index 344a105..5ca711f 100644 --- a/apps/api/src/services/tool/instance_service.py +++ b/apps/api/src/services/tool/instance_service.py @@ -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, )