# Design: Workspace-Based Tool Instances ## Status | Field | Value | |---|---| | Phase | **Design** | | Based on | [Spec](spec.md) | | Next | Tasks | ## Decision: No Migration Existing tool instances will be left as-is. Users will create new workspaces and new tool instances. Old instances remain functional but read-only (no migration path). This simplifies the implementation significantly. ## Architecture Overview ``` ┌─────────────────────────────────────────────────────────────────┐ │ Frontend │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │ Sidebar │ │Workspaces│ │Create WS │ │Start Tool │ │ │ │ (new) │ │ List │ │ Flow │ │Modal │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Backend API │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │ │Workspace CRUD│ │Workspace Sync│ │Instance Start (refact)│ │ │ │ /workspaces │ │ /sync │ │ /start │ │ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │ │ │ │ ┌───────────────────────────┼──────────────────────────────┐ │ │ │ GitService │ WorkspaceService │ │ │ │ (clone, fetch, pull) │ (create, delete, sync) │ │ │ └───────────────────────────┼──────────────────────────────┘ │ │ │ │ │ ┌───────────────────────────┼──────────────────────────────┐ │ │ │ Docker Compose │ File System │ │ │ │ (mount workspace path) │ /data/working-copies/... │ │ │ └───────────────────────────┴──────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ## Backend Design ### Directory Structure ``` apps/api/src/ ├── api/ │ ├── workspaces.py # NEW: Workspace CRUD endpoints │ └── tool_instances.py # MODIFIED: use workspace_id ├── models/ │ ├── workspace.py # NEW: Workspace model │ └── tool_instance.py # MODIFIED: add workspace_id ├── services/ │ ├── workspace_manager.py # NEW: Workspace lifecycle │ ├── git_service.py # NEW: Git operations (clone, fetch, pull) │ └── docker.py # EXISTING: mount workspace path └── alembic/versions/ └── 2026_06_01_add_workspaces.py # NEW migration ``` ### Model: Workspace ```python class Workspace(Base): __tablename__ = "workspaces" id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(255), nullable=False) repo_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("git_repositories.id"), nullable=False) user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False) branch: Mapped[str] = mapped_column(String(255), nullable=False, default="main") path: Mapped[str] = mapped_column(String(2048), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready") last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now, onupdate=datetime.now) __table_args__ = ( UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"), ) ``` ### Service: WorkspaceManager ```python class WorkspaceManager: """Manages workspace lifecycle: create, delete, sync, validate.""" BASE_PATH = "/data/working-copies" 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.""" path = f"{self.BASE_PATH}/{repo.id}/{name}" # Clone repo await GitService.clone(repo.remote_url, branch, path) # Create record workspace = Workspace(...) return workspace async def delete( self, workspace: Workspace, force: bool = False, ) -> None: """Delete workspace and all associated tool instances.""" instances = await self._get_running_instances(workspace) 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 shutil.rmtree(workspace.path, ignore_errors=True) # Delete record await session.delete(workspace) async def sync(self, workspace: Workspace) -> SyncResult: """Fetch remote and detect deleted branches.""" result = 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() return SyncResult(branch_deleted=False) ``` ### Service: GitService ```python class GitService: """Git operations for workspace management.""" @staticmethod async def clone(remote_url: str, branch: str, path: str) -> None: """Clone a repo to the given path.""" cmd = ["git", "clone", "--branch", branch, "--single-branch", remote_url, path] # Run via asyncio subprocess @staticmethod async def fetch(path: str) -> None: """Fetch from origin.""" cmd = ["git", "-C", path, "fetch", "origin"] @staticmethod async def pull(path: str, branch: str) -> None: """Pull latest changes.""" cmd = ["git", "-C", path, "pull", "origin", branch] @staticmethod def branch_exists_remotely(path: str, branch: str) -> bool: """Check if a branch exists on the remote.""" cmd = ["git", "-C", path, "ls-remote", "--heads", "origin", branch] # Return True if output is not empty ``` ### API: Workspaces ```python router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces") @router.post("/") async def create_workspace( project_id: uuid.UUID, repo_id: uuid.UUID, data: CreateWorkspaceRequest, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> WorkspaceResponse: repo = await get_repo(repo_id, user_id, session) workspace = await WorkspaceManager().create(repo, user_id, data.name, data.branch) session.add(workspace) await session.commit() return workspace @router.delete("/{workspace_id}") async def delete_workspace( workspace_id: uuid.UUID, force: bool = Query(False), user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: workspace = await get_workspace(workspace_id, user_id, session) try: await WorkspaceManager().delete(workspace, force=force) except WorkspaceHasInstancesError as exc: raise HTTPException( status_code=409, detail={ "message": "Workspace has running tool instances", "instances": [{"id": str(i.id), "name": i.name} for i in exc.instances], }, ) return {"status": "deleted"} @router.post("/{workspace_id}/sync") async def sync_workspace( workspace_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> SyncResult: workspace = await get_workspace(workspace_id, user_id, session) result = await WorkspaceManager().sync(workspace) if result.branch_deleted: raise HTTPException( status_code=409, detail={ "message": f"Branch '{workspace.branch}' was deleted from remote", "branch_deleted": True, }, ) return result ``` ### Updated: Instance Start ```python @router.post("/{instance_id}/start") async def start_instance( instance_id: uuid.UUID, data: StartInstanceRequest, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: instance = await get_instance(instance_id, user_id, session) # Get workspace workspace = await session.get(Workspace, instance.workspace_id) if not workspace: raise HTTPException(400, "Workspace not found") # Mount workspace path instead of repo path repo_path = workspace.path # Generate compose with workspace mount compose_content = generate_compose(workspace, instance, tool_type) # ... rest of start logic ``` ## Frontend Design ### Directory Structure ``` apps/web/src/ ├── pages/ │ ├── workspaces.tsx # NEW: Workspaces list page │ └── workspace-detail.tsx # NEW: Workspace detail page ├── components/ │ ├── workspace-card.tsx # NEW: Workspace card component │ ├── workspace-create-form.tsx # NEW: Create workspace form │ ├── start-tool-modal.tsx # NEW: Start tool on workspace modal │ └── sidebar.tsx # MODIFIED: add Workspaces nav ├── hooks/ │ ├── use-workspaces.ts # NEW: Workspace data hook │ └── use-workspace-actions.ts # NEW: Workspace CRUD actions ├── api/ │ └── workspaces.ts # NEW: Workspace API client └── types/ └── workspace.ts # NEW: Workspace types ``` ### Types ```typescript export interface Workspace { id: string; name: string; repo_id: string; repo_name: string; project_name: string; user_id: string; branch: string; path: string; status: "ready" | "syncing" | "error"; last_sync_at: string | null; created_at: string; updated_at: string; instance_count: number; } export interface CreateWorkspaceRequest { name: string; branch: string; } export interface SyncResult { branch_deleted: boolean; pulled: boolean; } ``` ### Component: WorkspaceCard ```tsx export function WorkspaceCard({ workspace, onStartTool, onSync, onDelete, }: WorkspaceCardProps) { return (

{workspace.name}

{workspace.status}

{workspace.project_name} / {workspace.repo_name}

{workspace.branch}

{workspace.instance_count > 0 && (

{workspace.instance_count} active tool{workspace.instance_count > 1 ? "s" : ""}

)}
); } ``` ### Component: Sidebar (updated) ```tsx const navItems = [ { path: "/dashboard", label: "Dashboard", icon: "home" }, { path: "/projects", label: "Projects", icon: "folder" }, { path: "/workspaces", label: "Workspaces", icon: "workspace" }, { path: "/settings", label: "Settings", icon: "settings" }, ]; ``` ### Hook: useWorkspaceActions ```typescript export function useWorkspaceActions(options: { onRefresh: () => Promise }) { const [loadingId, setLoadingId] = useState(null); const handleDelete = useCallback(async (workspace: Workspace, force = false) => { setLoadingId(workspace.id); try { await deleteWorkspace(workspace.repo_id, workspace.id, force); await options.onRefresh(); } catch (err) { const error = err as AxiosError<{ detail?: { instances?: Array<{id: string, name: string}> } }>; if (error.response?.status === 409 && !force) { const instances = error.response.data?.detail?.instances || []; const confirmed = confirm( `This workspace has ${instances.length} running tool instance(s):\n` + instances.map(i => `- ${i.name}`).join("\n") + `\n\nDelete workspace and all instances?` ); if (confirmed) { await handleDelete(workspace, true); } } } finally { setLoadingId(null); } }, [options.onRefresh]); const handleSync = useCallback(async (workspace: Workspace) => { setLoadingId(workspace.id); try { const result = await syncWorkspace(workspace.repo_id, workspace.id); await options.onRefresh(); return result; } catch (err) { const error = err as AxiosError<{ detail?: { branch_deleted?: boolean; message?: string } }>; if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) { const confirmed = confirm( `${error.response.data.detail.message}\n\nDelete this workspace?` ); if (confirmed) { await handleDelete(workspace, true); } } } finally { setLoadingId(null); } }, [options.onRefresh, handleDelete]); return { loadingId, handleDelete, handleSync }; } ``` ## Compose Template Updates ### Workspace Mount All compose templates will mount the workspace path instead of the repo path: ```yaml services: app: image: ${IMAGE_TAG} container_name: ${INSTANCE_NAME} volumes: - ${WORKSPACE_PATH}:/workspace working_dir: /workspace # ... rest of config ``` The `${WORKSPACE_PATH}` variable replaces `${REPO_PATH}` in all templates. ## Error Handling | Error | HTTP Status | Frontend Behavior | |---|---|---| | Workspace name not unique per repo | 409 | Show inline validation error | | Workspace has running instances | 409 | Show confirmation dialog | | Branch deleted from remote | 409 | Show confirmation dialog to delete workspace | | Repo not found | 404 | Show error toast | | Git clone failed | 500 | Show error toast with git stderr | | Workspace path missing | 500 | Show error toast | ## Performance Considerations - **Git clone** is synchronous and slow; run in background with status polling - **Workspace list** should include `instance_count` via subquery (not N+1) - **Sync** is fast (fetch only), but pull may be slow; run async - **Delete with instances** stops instances sequentially; consider parallel ## Security Considerations - Workspace paths must be validated to prevent path traversal - Users can only access their own workspaces - Git credentials (SSH keys) must be available during clone - Workspace directories must have correct ownership for container users ## Testing Strategy ### Backend - Unit: WorkspaceManager.create, delete, sync - Unit: GitService.clone, fetch, pull, branch_exists_remotely - Integration: Create workspace → start tool → verify mount - Integration: Delete workspace with running instances - Integration: Sync with deleted branch ### Frontend - Component: WorkspaceCard renders correctly - Component: Create form validates name uniqueness - Hook: useWorkspaceActions handles 409 confirmation - E2E: Create workspace → start tool → delete workspace ## Out of Scope - Auto-sync on schedule - Workspace sharing between users - Git push/pull/branch UI - Pre-created default workspaces - Read-only workspace mode - Workspace backup/restore ## Files Changed ### New Files - `apps/api/src/models/workspace.py` - `apps/api/src/api/workspaces.py` - `apps/api/src/services/workspace_manager.py` - `apps/api/src/services/git_service.py` - `apps/api/alembic/versions/2026_06_01_add_workspaces.py` - `apps/web/src/pages/workspaces.tsx` - `apps/web/src/pages/workspace-detail.tsx` - `apps/web/src/components/workspace-card.tsx` - `apps/web/src/components/workspace-create-form.tsx` - `apps/web/src/components/start-tool-modal.tsx` - `apps/web/src/hooks/use-workspaces.ts` - `apps/web/src/hooks/use-workspace-actions.ts` - `apps/web/src/api/workspaces.ts` - `apps/web/src/types/workspace.ts` ### Modified Files - `apps/api/src/models/tool_instance.py` (add workspace_id) - `apps/api/src/api/tool_instances.py` (use workspace path) - `apps/web/src/components/sidebar.tsx` (add nav item) - `apps/web/src/pages/dashboard.tsx` (add workspaces section) - `apps/web/src/api/sessions.ts` (add workspace endpoints)