# 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 (
    <article className="card workspace-card">
      <div className="workspace-header">
        <h4>{workspace.name}</h4>
        <span className={`status-badge ${workspace.status}`}>
          {workspace.status}
        </span>
      </div>
      <div className="workspace-meta">
        <p>{workspace.project_name} / {workspace.repo_name}</p>
        <p><Icon name="branch" /> {workspace.branch}</p>
        {workspace.instance_count > 0 && (
          <p>{workspace.instance_count} active tool{workspace.instance_count > 1 ? "s" : ""}</p>
        )}
      </div>
      <div className="workspace-actions">
        <button onClick={() => onStartTool(workspace)}>
          <Icon name="play" /> Start Tool
        </button>
        <button onClick={() => onSync(workspace)}>
          <Icon name="refresh" /> Sync
        </button>
        <button onClick={() => onDelete(workspace)} className="danger">
          <Icon name="delete" /> Delete
        </button>
      </div>
    </article>
  );
}
```

### 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<void> }) {
  const [loadingId, setLoadingId] = useState<string | null>(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)
