d567225bf7
- Add workspaces table migration (2026_06_01_add_workspaces) - Create Workspace model with repo_id, user_id, branch, path, status - Add workspace_id nullable FK to ToolInstance - Create GitService for clone/fetch/pull/branch_exists_remotely - Create WorkspaceManager for create/delete/sync lifecycle - Create workspace CRUD API with 409 handling for duplicates and instances - Wire workspace routes into FastAPI app - 17 tests passing (8 unit + 9 integration), 1 skipped Quality gates: ruff clean
505 lines
18 KiB
Markdown
505 lines
18 KiB
Markdown
# 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)
|