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,261 @@
|
||||
# Spec: Workspace-Based Tool Instances
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Spec** |
|
||||
| Based on | [Proposal](proposal.md) |
|
||||
| Next | Design |
|
||||
|
||||
## Overview
|
||||
|
||||
Workspaces are persistent, writable local clones of Git repositories. Users create workspaces explicitly, then start tool instances on them. This replaces the current "mount vs clone" decision with a simple "pick a workspace" flow.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Value |
|
||||
|---|---|
|
||||
| **Name** | "Workspace" |
|
||||
| **Scope** | Unlimited per repository |
|
||||
| **Auto-create** | No — explicit creation only |
|
||||
| **Delete with instances** | Allowed with confirmation; stops and deletes all associated tool instances |
|
||||
| **Name uniqueness** | Unique per project+repo; derived from project and repo names |
|
||||
| **Deleted remote branch** | On sync/update, detect and ask for confirmation to delete local workspace/branch |
|
||||
|
||||
## Database Schema
|
||||
|
||||
### New Table: `workspaces`
|
||||
|
||||
```sql
|
||||
CREATE TABLE workspaces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
repo_id UUID NOT NULL REFERENCES git_repositories(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
branch VARCHAR(255) NOT NULL DEFAULT 'main',
|
||||
path VARCHAR(2048) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ready',
|
||||
last_sync_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||
|
||||
UNIQUE (repo_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workspaces_repo_id ON workspaces(repo_id);
|
||||
CREATE INDEX idx_workspaces_user_id ON workspaces(user_id);
|
||||
CREATE INDEX idx_workspaces_status ON workspaces(status);
|
||||
```
|
||||
|
||||
### Updated Table: `tool_instances`
|
||||
|
||||
```sql
|
||||
ALTER TABLE tool_instances
|
||||
ADD COLUMN workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
ADD COLUMN clone_mode VARCHAR(16); -- deprecated, nullable for migration
|
||||
|
||||
-- Drop existing clone_mode column after all instances are migrated
|
||||
-- ALTER TABLE tool_instances DROP COLUMN clone_mode;
|
||||
```
|
||||
|
||||
Note: `tool_instances.branch` remains for now but is deprecated; the canonical branch lives on the workspace.
|
||||
|
||||
## Backend API
|
||||
|
||||
### Workspaces API
|
||||
|
||||
```
|
||||
GET /projects/{project_id}/repositories/{repo_id}/workspaces
|
||||
→ List workspaces for a repository
|
||||
|
||||
POST /projects/{project_id}/repositories/{repo_id}/workspaces
|
||||
→ Create a new workspace
|
||||
Body: { name: string, branch: string }
|
||||
|
||||
GET /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}
|
||||
→ Get workspace details
|
||||
|
||||
PATCH /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}
|
||||
→ Update workspace (rename, change branch)
|
||||
Body: { name?: string, branch?: string }
|
||||
|
||||
DELETE /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}
|
||||
→ Delete workspace (with ?force=true to skip confirmation)
|
||||
→ Stops and deletes all associated tool instances
|
||||
|
||||
POST /projects/{project_id}/repositories/{repo_id}/workspaces/{workspace_id}/sync
|
||||
→ Sync workspace with remote (detect deleted branches)
|
||||
```
|
||||
|
||||
### Tool Instances API (Updated)
|
||||
|
||||
```
|
||||
POST /projects/{project_id}/repositories/{repo_id}/instances
|
||||
Body: { tool_type_id, workspace_id, display_name?, config_profile_id? }
|
||||
→ Create instance on workspace
|
||||
|
||||
POST /projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/start
|
||||
→ Start instance (creates workspace if mount_mode, migrates if clone_mode)
|
||||
```
|
||||
|
||||
### Instance Start Logic
|
||||
|
||||
```python
|
||||
def start_instance(instance, workspace_id=None):
|
||||
if instance.clone_mode == "clone":
|
||||
# Migrate: extract clone to workspace
|
||||
workspace = migrate_clone_to_workspace(instance)
|
||||
instance.workspace_id = workspace.id
|
||||
instance.clone_mode = None
|
||||
elif instance.workspace_id:
|
||||
# Already using a workspace
|
||||
workspace = get_workspace(instance.workspace_id)
|
||||
else:
|
||||
# Legacy mount_mode: create workspace on first start
|
||||
workspace = create_workspace_from_repo(instance.repo)
|
||||
instance.workspace_id = workspace.id
|
||||
|
||||
# Mount workspace path into container
|
||||
mount_path = workspace.path
|
||||
# ... rest of start logic
|
||||
```
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
```
|
||||
/workspaces → Workspaces list page
|
||||
/workspaces/new → Create workspace flow
|
||||
/workspaces/{id} → Workspace detail page
|
||||
/workspaces/{id}/tools → Start tool on workspace
|
||||
```
|
||||
|
||||
## UI Components
|
||||
|
||||
### Sidebar Navigation
|
||||
|
||||
```
|
||||
Projects
|
||||
└── [project list]
|
||||
Workspaces (NEW)
|
||||
└── All Workspaces
|
||||
└── [recent workspaces]
|
||||
Settings
|
||||
```
|
||||
|
||||
### Workspaces Page
|
||||
|
||||
- Grid/list of workspace cards
|
||||
- Each card shows: name, repo, branch, status, active instances count
|
||||
- Actions: Start Tool, Sync, Settings, Delete
|
||||
|
||||
### Create Workspace Flow
|
||||
|
||||
1. **Repo picker**: Select from existing repositories
|
||||
2. **Branch picker**: Select branch (default: repo's default branch)
|
||||
3. **Name input**: Auto-suggested as `{project-name}-{repo-name}-{branch}` but editable
|
||||
4. **Create**: Clone repo to `/data/working-copies/{repo-id}/{name}/`
|
||||
|
||||
### Start Tool from Workspace
|
||||
|
||||
1. **Tool picker**: Select tool type
|
||||
2. **Config**: Optional config profile
|
||||
3. **Create**: Instance created with workspace mounted
|
||||
|
||||
## File System Layout
|
||||
|
||||
```
|
||||
/data/working-copies/
|
||||
└── {repo-id}/
|
||||
└── {workspace-name}/
|
||||
└── .git/
|
||||
└── [repo files]
|
||||
```
|
||||
|
||||
## Workspace Lifecycle
|
||||
|
||||
### Creation
|
||||
|
||||
1. Validate name uniqueness per repo
|
||||
2. Clone repo: `git clone --branch {branch} {remote_url} {path}`
|
||||
3. Set status to `ready`
|
||||
4. Return workspace record
|
||||
|
||||
### Deletion
|
||||
|
||||
1. Check for running tool instances
|
||||
2. If instances exist and no `?force=true`:
|
||||
- Return 409 Conflict with `{ instances: [...] }`
|
||||
- Frontend shows confirmation dialog
|
||||
3. If confirmed:
|
||||
- Stop all associated instances
|
||||
- Delete all associated instances
|
||||
- Delete workspace directory
|
||||
- Delete workspace record
|
||||
|
||||
### Sync
|
||||
|
||||
1. Fetch from remote: `git fetch origin`
|
||||
2. Check if workspace branch still exists on remote
|
||||
3. If branch deleted:
|
||||
- Return 409 with `{ branch_deleted: true }`
|
||||
- Frontend asks: "Branch '{branch}' was deleted. Delete this workspace?"
|
||||
4. If branch exists:
|
||||
- Pull changes: `git pull origin {branch}`
|
||||
- Update `last_sync_at`
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Existing clone_mode Instances
|
||||
|
||||
```python
|
||||
def migrate_clone_to_workspace(instance):
|
||||
# Find the cloned repo inside the instance directory
|
||||
clone_path = find_clone_in_instance_dir(instance)
|
||||
|
||||
# Create workspace
|
||||
workspace = Workspace(
|
||||
name=f"{instance.name}-migrated",
|
||||
repo_id=instance.repository_id,
|
||||
user_id=instance.owner_id,
|
||||
branch=instance.branch or "main",
|
||||
path=f"/data/working-copies/{instance.repository_id}/{instance.name}-migrated",
|
||||
)
|
||||
|
||||
# Move clone to workspace path
|
||||
move(clone_path, workspace.path)
|
||||
|
||||
return workspace
|
||||
```
|
||||
|
||||
### Existing mount_mode Instances
|
||||
|
||||
On first start after deployment:
|
||||
1. Create workspace from canonical repo
|
||||
2. Update instance to use workspace
|
||||
3. Remove clone_mode flag
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Database migration creates `workspaces` table
|
||||
- [ ] Database migration adds `workspace_id` to `tool_instances`
|
||||
- [ ] API endpoints for CRUD operations on workspaces
|
||||
- [ ] Workspace creation clones repo to `/data/working-copies/...`
|
||||
- [ ] Workspace deletion stops and deletes associated tool instances
|
||||
- [ ] Workspace sync detects deleted branches and asks for confirmation
|
||||
- [ ] Tool instance creation accepts `workspace_id` instead of `clone_mode`
|
||||
- [ ] Tool instance start mounts workspace path into container
|
||||
- [ ] Frontend has "Workspaces" sidebar entry
|
||||
- [ ] Frontend workspaces list page
|
||||
- [ ] Frontend create workspace flow
|
||||
- [ ] Frontend start tool from workspace
|
||||
- [ ] Existing clone_mode instances migrate on restart
|
||||
- [ ] Existing mount_mode instances create workspace on restart
|
||||
|
||||
## Quality Gates
|
||||
|
||||
- [ ] Backend tests: workspace CRUD, sync, deletion with instances
|
||||
- [ ] Frontend tests: workspace list, create, start tool
|
||||
- [ ] Integration tests: instance creation with workspace
|
||||
- [ ] ruff clean
|
||||
- [ ] TypeScript compilation clean
|
||||
Reference in New Issue
Block a user