feat: workspace backend foundation (PR-1)
- 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
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user