From 401ad2e65da90276fe52b2e7555fdcdefb21bcc3 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sat, 30 May 2026 11:58:38 +0200 Subject: [PATCH 01/44] feat: always show Recreate Tunnel button for web instances Show the Recreate Tunnel button on all active web-enabled session cards (instead of only when tunnel_status is unreachable) so users can manually trigger tunnel recreation at any time. Also adds it to the mobile action sheet. Quality gates: eslint clean, tsc clean --- apps/api/src/api/tool_instances.py | 16 ++++++++++++---- apps/web/src/components/session-card.tsx | 9 +++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index b8da74c..fc59913 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -1723,15 +1723,23 @@ async def start_instance( logger.debug("Container ID for instance %s: %s", instance.id, container_id) instance.container_name = expected_container_name - logger.debug("Container name for instance %s: %s", instance.id, expected_container_name) + logger.debug( + "Container name for instance %s: %s", instance.id, expected_container_name + ) # Connect container to backend network so API can reach it - logger.debug("Connecting container %s to backend network...", expected_container_name) + logger.debug( + "Connecting container %s to backend network...", expected_container_name + ) connected = connect_container_to_network(expected_container_name, "backend") if connected: - logger.debug("Successfully connected %s to backend network", expected_container_name) + logger.debug( + "Successfully connected %s to backend network", expected_container_name + ) else: - logger.warning("Failed to connect %s to backend network", expected_container_name) + logger.warning( + "Failed to connect %s to backend network", expected_container_name + ) # Verify container reached running state if instance.container_id: diff --git a/apps/web/src/components/session-card.tsx b/apps/web/src/components/session-card.tsx index 0483d66..ca49dfe 100644 --- a/apps/web/src/components/session-card.tsx +++ b/apps/web/src/components/session-card.tsx @@ -229,12 +229,13 @@ export function SessionCard({ )} - {hasTunnelError && onRecreateTunnel && ( + {!isTerminalOnly && onRecreateTunnel && ( + + + + + ); +} +``` + +### 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) diff --git a/openspec/changes/working-copies/explore.md b/openspec/changes/working-copies/explore.md new file mode 100644 index 0000000..3a65290 --- /dev/null +++ b/openspec/changes/working-copies/explore.md @@ -0,0 +1,103 @@ +# Explore: Working Copies + +## Problem Statement + +Currently, tool instances mount repositories directly. Each tool instance either: +- **Mount mode**: Bind-mounts the shared repo path (`/data/repos/`) read-only +- **Clone mode**: Clones the repo into the instance directory + +This has several problems: +1. **Mount mode**: Read-only, so users can't edit files in the tool +2. **Clone mode**: Creates a full copy per instance, wasting disk space +3. **UI complexity**: The create-instance form must ask "mount or clone?" and handle branch selection +4. **No persistence**: Clone-mode repos live inside the instance directory and are lost on delete +5. **Race conditions**: Multiple instances mounting the same repo can conflict + +## Proposed Solution: Working Copies + +Introduce a **Workspace** as a first-class entity: a persistent, writable local clone of a repository that lives independently of any tool instance. Tool instances are then *started on* a working copy, which is mounted into the container. + +### Naming Candidates + +| Name | Pros | Cons | +|---|---|---| +| Workspace | Common in IDEs; implies a working area | Conflicts with existing docs/features/workspace.md | +| **Workspace** | Common in IDEs (VS Code, JetBrains); implies a working area | May conflict with existing "workspace" terminology in docs | +| **Checkout** | Git-native term; implies a working tree | Too specific to git; implies a single commit/branch | +| **Sandbox** | Implies isolation and experimentation | Suggests throwaway/ephemeral, not persistent | +| **Dev Copy** | Simple and descriptive | Informal; "copy" still implies duplication | +| **Project Clone** | Clear relationship to project+repo | Clunky; two words | +| **Branch** | Git-native; each working copy is effectively a branch workspace | Too git-specific; may confuse with git branches | + +**Decision: "Workspace"** — chosen by user despite existing docs/features/workspace.md. The existing workspace.md will be superseded/renamed to avoid confusion. — it's the most precise term. In SVN/Git parlance, a "working copy" is exactly what we want: a local, writable copy of a repository that you work on. The term is established enough that developers understand it, but not so overloaded in our domain that it conflicts. + +### Entity Model + +``` +Project + └── GitRepository (the canonical repo, read-only source) + └── WorkingCopy (writable local clone, 1+ per repo) + └── ToolInstance (mounts the working copy) +``` + +A Workspace: +- Has a `name` (auto-generated or user-defined) +- Has a `path` on disk (under `/data/working-copies//`) +- Has a `branch` (the branch it's currently on) +- Has a `status` (ready, syncing, error) +- Belongs to a `GitRepository` +- Belongs to a `User` +- Has many `ToolInstance`s + +### User Flow + +1. User navigates to **Working Copies** in the sidebar +2. Sees list of working copies (or creates one from a repo) +3. Clicks "New Workspace" → selects repo + branch → named copy created +4. From a working copy, clicks "Start Tool" → selects tool type → instance starts with working copy mounted +5. Multiple tool instances can share the same working copy (e.g., terminal + code-server side by side) + +### Benefits + +1. **Writable by default**: Working copies are clones, so tools can edit files +2. **Shared across instances**: Multiple tools can mount the same working copy +3. **Persistent**: Survives instance deletion +4. **Simplified UI**: No more "mount vs clone" decision; tool creation is just "pick a working copy" +5. **Git operations**: Working copies can support git push/pull/branch from the UI +6. **Disk efficient**: One clone per working copy, not one per instance + +### Open Questions + +1. Should working copies auto-sync with the canonical repo? +2. Should we support multiple working copies per repo (e.g., one per branch)? +3. How do we handle merge conflicts if the canonical repo changes? +4. Should working copies be scoped to a user or to a project? +5. What happens to tool instances when a working copy is deleted? +6. Should we pre-create a default working copy when a repo is added? + +### Migration Path + +Existing tool instances that use clone_mode can be migrated: +- On first access, extract the cloned repo from the instance directory +- Move it to `/data/working-copies/...` +- Create a WorkingCopy record pointing to it +- Update the instance to mount the working copy path + +Mount-mode instances can be converted on restart: +- Create a working copy from the canonical repo +- Switch the instance to mount the working copy instead + +### Scope for This Change + +This change focuses on: +- [ ] Creating the WorkingCopy entity and database table +- [ ] Adding a Working Copies section to the UI (sidebar nav + list view) +- [ ] Updating tool instance creation to select a working copy instead of repo+clone_mode +- [ ] Updating compose generation to mount the working copy path +- [ ] Migrating existing clone_mode instances to use working copies + +Out of scope (future changes): +- [ ] Auto-sync with canonical repo +- [ ] Git operations UI (push/pull/branch) +- [ ] Working copy sharing between users +- [ ] Pre-create default working copies diff --git a/openspec/changes/working-copies/proposal.md b/openspec/changes/working-copies/proposal.md new file mode 100644 index 0000000..fb6b655 --- /dev/null +++ b/openspec/changes/working-copies/proposal.md @@ -0,0 +1,132 @@ +# Proposal: Workspace-Based Tool Instances + +## Status + +| Field | Value | +|---|---| +| Phase | **Proposal** | +| Based on | [Explore](explore.md) | +| Next | Spec | + +## Decisions from Explore + +| Decision | Value | +|---|---| +| **Name** | "Workspace" (supersedes existing workspace.md) | +| **Scope** | Unlimited workspaces per repository | +| **Auto-create** | No — explicit creation only | +| **Default branch** | Main/master or user-selected at creation time | +| **Delete with running 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 | + +## Problem Statement + +The current tool instance creation requires users to choose between "mount" (read-only) and "clone" (writable but ephemeral) modes. This is confusing and leads to either: +- **Mount mode**: Tools open files read-only, frustrating editing +- **Clone mode**: Each instance clones the repo, wasting disk space and losing work on deletion + +## Proposed Solution + +Introduce **Workspaces** as first-class entities: persistent, writable local clones of a repository that exist independently of tool instances. Users create workspaces explicitly, then start tool instances *on* a workspace. + +### Entity Relationship + +``` +Project + └── GitRepository (canonical source) + └── Workspace (writable clone, unlimited per repo) + └── ToolInstance (mounts workspace path) +``` + +### Key Behaviors + +1. **Workspace Creation**: User selects a repository → picks a branch → names the workspace → clone is created on disk +2. **Tool Instance Creation**: User selects a workspace → picks a tool type → instance starts with workspace mounted +3. **Multiple Tools per Workspace**: Several tool instances can share the same workspace (e.g., terminal + code-server) +4. **Persistence**: Workspaces survive tool instance deletion +5. **No Auto-Create**: Users must explicitly create workspaces; no magic default workspace + +### UI Changes + +- **New sidebar entry**: "Workspaces" (between "Projects" and "Settings") +- **Workspaces page**: List of all workspaces with repo/branch/status info +- **Create workspace flow**: Repo picker → branch picker → name input +- **Start tool from workspace**: Tool picker modal from workspace card +- **Simplified tool creation**: Remove "clone mode" / "mount mode" toggle; always use workspace + +### Database Changes + +New table: `workspaces` +- `id` (UUID, PK) +- `name` (string, user-defined) +- `repo_id` (UUID, FK → git_repositories) +- `user_id` (UUID, FK → users) +- `branch` (string) +- `path` (string, absolute disk path) +- `status` (enum: ready, syncing, error) +- `created_at`, `updated_at` + +Updated: `tool_instances` +- Add `workspace_id` (UUID, FK → workspaces, nullable for migration) +- Remove `clone_mode` (deprecated) +- Remove `branch` (moved to workspace) + +### File System Layout + +``` +/data/working-copies/ + └── {repo-id}/ + └── {workspace-name}/ + └── .git/ + └── [repo files] +``` + +### Migration Strategy + +Existing `clone_mode` instances: +- Extract cloned repo from instance directory +- Move to `/data/working-copies/{repo-id}/{instance-name}/` +- Create Workspace record +- Update instance to reference workspace +- Remove `clone_mode` flag + +Existing `mount_mode` instances: +- On next start, create a workspace from the canonical repo +- Switch instance to use workspace +- Remove `clone_mode` flag + +### Out of Scope + +- Auto-sync with canonical repo +- Git push/pull/branch UI +- Workspace sharing between users +- Pre-created default workspaces +- Read-only workspace mode + +## Risks + +| Risk | Mitigation | +|---|---| +| Existing users with many clone_mode instances | One-time migration on instance restart | +| Disk space from many workspaces | User-managed; can delete workspaces | +| Workspace deleted while instances are running | Allowed with confirmation; cascade-delete tool instances | +| Name collisions for workspace names | Unique per project+repo; derived from project and repo names | + +## Acceptance Criteria + +- [ ] User can create a workspace from any repository +- [ ] User can create unlimited workspaces per repository +- [ ] Tool instances mount the workspace path, not the canonical repo path +- [ ] Multiple tool instances can share one workspace +- [ ] Workspaces persist after tool instance deletion +- [ ] Existing clone_mode instances migrate to workspace on restart +- [ ] UI no longer shows "mount vs clone" toggle +- [ ] New sidebar navigation "Workspaces" exists + +## Open Questions for Spec + +1. ~~Should workspace deletion cascade-delete associated tool instances, or block?~~ **Answered**: Allowed with confirmation; cascade-delete tool instances +2. ~~Should workspace names be unique per-repo or globally unique?~~ **Answered**: Unique per project+repo; derived from project and repo names +3. ~~How do we handle the case where a workspace's branch is deleted from the remote?~~ **Answered**: On sync/update, detect and ask for confirmation to delete local workspace/branch +4. Should we validate the repo path exists before creating a workspace? diff --git a/openspec/changes/working-copies/spec.md b/openspec/changes/working-copies/spec.md new file mode 100644 index 0000000..3441757 --- /dev/null +++ b/openspec/changes/working-copies/spec.md @@ -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 diff --git a/openspec/changes/working-copies/tasks.md b/openspec/changes/working-copies/tasks.md new file mode 100644 index 0000000..81ced4e --- /dev/null +++ b/openspec/changes/working-copies/tasks.md @@ -0,0 +1,138 @@ +# Tasks: Workspace-Based Tool Instances + +## Status + +| Field | Value | +|---|---| +| Phase | **Tasks** | +| Based on | [Design](design.md) | +| Next | Apply | + +## PR Breakdown + +### PR-1: Backend Foundation +**Scope**: Database migration, models, services, API endpoints for workspaces +**Est. lines**: ~800 backend, ~300 tests +**Files touched**: 8 new, 2 modified + +**Tasks**: +1. [ ] Create Alembic migration for `workspaces` table + `workspace_id` on `tool_instances` +2. [ ] Create `Workspace` model (`apps/api/src/models/workspace.py`) +3. [ ] Add `workspace_id` to `ToolInstance` model (nullable FK) +4. [ ] Create `GitService` (`apps/api/src/services/git_service.py`) — clone, fetch, pull, branch_exists_remotely +5. [ ] Create `WorkspaceManager` (`apps/api/src/services/workspace_manager.py`) — create, delete, sync +6. [ ] Create workspace API router (`apps/api/src/api/workspaces.py`) — CRUD + sync endpoints +7. [ ] Add workspace routes to FastAPI app (`apps/api/src/main.py`) +8. [ ] Write unit tests for GitService +9. [ ] Write integration tests for workspace CRUD +10. [ ] Write integration tests for delete-with-instances (409 behavior) +11. [ ] Write integration tests for sync-with-deleted-branch (409 behavior) + +### PR-2: Backend Integration +**Scope**: Tool instance creation/start uses workspace instead of repo path +**Est. lines**: ~400 backend, ~200 tests +**Files touched**: 3 modified + +**Tasks**: +1. [ ] Update `create_instance` endpoint to accept `workspace_id` instead of `clone_mode` +2. [ ] Update `start_instance` to mount workspace path (`workspace.path`) instead of repo path +3. [ ] Update compose generation to use `WORKSPACE_PATH` variable +4. [ ] Update `tool_instances.py` compose template rendering +5. [ ] Write integration tests for instance creation with workspace +6. [ ] Write integration tests for instance start with workspace mount +7. [ ] Verify old mount_mode instances still work (backward compat) + +### PR-3: Frontend Core +**Scope**: Workspaces UI — list, create, card, actions +**Est. lines**: ~1,200 frontend, ~400 tests +**Files touched**: 10 new, 2 modified + +**Tasks**: +1. [ ] Create workspace types (`apps/web/src/types/workspace.ts`) +2. [ ] Create workspace API client (`apps/web/src/api/workspaces.ts`) +3. [ ] Create `useWorkspaces` hook (`apps/web/src/hooks/use-workspaces.ts`) +4. [ ] Create `useWorkspaceActions` hook (`apps/web/src/hooks/use-workspace-actions.ts`) +5. [ ] Create `WorkspaceCard` component (`apps/web/src/components/workspace-card.tsx`) +6. [ ] Create `WorkspaceCreateForm` component (`apps/web/src/components/workspace-create-form.tsx`) +7. [ ] Create `StartToolModal` component (`apps/web/src/components/start-tool-modal.tsx`) +8. [ ] Create `WorkspacesPage` (`apps/web/src/pages/workspaces.tsx`) +9. [ ] Update `Sidebar` to add Workspaces nav item +10. [ ] Update router/routes to include `/workspaces` +11. [ ] Write component tests for WorkspaceCard +12. [ ] Write hook tests for useWorkspaceActions +13. [ ] Write tests for create form validation + +### PR-4: Frontend Integration +**Scope**: Update existing flows to use workspaces, dashboard integration +**Est. lines**: ~600 frontend, ~200 tests +**Files touched**: 5 modified + +**Tasks**: +1. [ ] Update `CreateSessionForm` to use workspace picker instead of repo+clone_mode +2. [ ] Update `SessionsPage` dashboard to show workspaces section +3. [ ] Update `SessionCard` to show workspace name instead of clone mode +4. [ ] Update `useInstanceActions` to pass `workspace_id` on create +5. [ ] Remove clone_mode/mount_mode UI toggles +6. [ ] Update types to remove deprecated `clone_mode` field +7. [ ] Write integration tests for full create-workspace → start-tool flow +8. [ ] Write tests for dashboard workspaces section + +## Acceptance Criteria (All PRs) + +- [ ] User can create a workspace from any repository +- [ ] User can create unlimited workspaces per repository +- [ ] Workspace names are unique per repo +- [ ] Tool instances mount the workspace path +- [ ] Multiple tool instances can share one workspace +- [ ] Workspaces persist after tool instance deletion +- [ ] Deleting a workspace with running instances shows confirmation, stops and deletes instances +- [ ] Syncing a workspace with a deleted remote branch shows confirmation +- [ ] UI no longer shows "mount vs clone" toggle +- [ ] New sidebar navigation "Workspaces" exists +- [ ] All existing tests still pass +- [ ] ruff clean +- [ ] TypeScript compilation clean + +## Implementation Order + +``` +PR-1 (Backend Foundation) + → PR-2 (Backend Integration) + → PR-3 (Frontend Core) + → PR-4 (Frontend Integration) +``` + +Each PR depends on the previous. No parallel work. + +## Verification Steps per PR + +### PR-1 +```bash +cd apps/api +alembic upgrade head +pytest tests/unit/test_git_service.py tests/integration/test_workspaces.py -v +python -m ruff check src/services/git_service.py src/services/workspace_manager.py src/api/workspaces.py +``` + +### PR-2 +```bash +cd apps/api +pytest tests/integration/test_tool_instances_with_workspace.py -v +python -m ruff check src/api/tool_instances.py +``` + +### PR-3 +```bash +cd apps/web +npm run test -- --run workspaces +npx tsc --noEmit +npx eslint src/pages/workspaces.tsx src/components/workspace-*.tsx +``` + +### PR-4 +```bash +cd apps/web +npm run test -- --run sessions create-session +npx tsc --noEmit +npx eslint src/pages/sessions.tsx src/components/create-session-form.tsx +``` From 47b1af8e92f3a14a6604941cca346a7f85b00314 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 31 May 2026 23:10:45 +0200 Subject: [PATCH 15/44] feat: workspace backend integration (PR-2) - Add workspace_id to CreateInstanceRequest (optional, replaces clone_mode) - create_instance: resolve workspace, validate repo ownership, use workspace.path - create_instance: store workspace_id on ToolInstance record - start_instance: use workspace.path when workspace_id is set (manifest + legacy flows) - Skip SSH key mount for clone mode when workspace is used - Backward compatible: clone_mode still works when workspace_id is absent --- apps/api/src/api/tool_instances.py | 60 ++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 92a0cf1..7aa4908 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -427,6 +427,9 @@ class CreateInstanceRequest(BaseModel): display_name: str | None = Field( default=None, description="Optional display name for the instance" ) + workspace_id: str | None = Field( + default=None, description="UUID of workspace to mount (replaces clone_mode)" + ) clone_mode: str = Field( default="mount", description="Repository access mode: 'mount' or 'clone'" ) @@ -849,9 +852,34 @@ async def create_instance( session, data.config_profile_id, user_id, project_id, tool_type_id ) + # Resolve workspace if provided + workspace = None + workspace_id = None + if data.workspace_id: + from src.models.workspace import Workspace as WorkspaceModel + + try: + workspace_id = uuid.UUID(data.workspace_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid workspace_id format", + ) + workspace = await session.get(WorkspaceModel, workspace_id) + if workspace is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="workspace not found", + ) + if workspace.repo_id != repo_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="workspace does not belong to this repository", + ) + try: - # Validate clone mode requirements - if data.clone_mode == "clone": + # Validate clone mode requirements (legacy path) + if data.clone_mode == "clone" and not workspace: if not repo.remote_url: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -876,8 +904,10 @@ async def create_instance( # Find free port tool_port = find_free_port() - # Determine repo path based on clone mode - if data.clone_mode == "clone": + # Determine repo path based on workspace or clone mode + if workspace: + repo_path = workspace.path + elif data.clone_mode == "clone": # Get SSH key for cloning ssh_key = await session.get(SSHKey, repo.ssh_key_id) if ssh_key is None: @@ -1143,6 +1173,7 @@ services: status="pending", compose_path=compose_path, port=tool_port, + workspace_id=workspace_id, clone_mode=data.clone_mode, branch=data.new_branch if data.new_branch @@ -1649,11 +1680,18 @@ async def start_instance( if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: logger.info("Using manifest-based startup for instance %s", instance.id) - # Determine repo path - repo = await session.get(GitRepository, instance.repository_id) - repo_path = repo.path if repo else "" - if instance.clone_mode == "clone": - repo_path = os.path.join(instance_dir, "repo-clone") + # Determine repo path (workspace takes precedence) + repo_path = "" + if instance.workspace_id: + from src.models.workspace import Workspace as WorkspaceModel + workspace = await session.get(WorkspaceModel, instance.workspace_id) + if workspace: + repo_path = workspace.path + else: + repo = await session.get(GitRepository, instance.repository_id) + repo_path = repo.path if repo else "" + if instance.clone_mode == "clone": + repo_path = os.path.join(instance_dir, "repo-clone") try: ( @@ -1686,8 +1724,8 @@ async def start_instance( ) else: # ── LEGACY FLOW ────────────────────────────────────────── - # Mount SSH key for clone-mode instances - if instance.clone_mode == "clone": + # Mount SSH key for clone-mode instances (skip for workspace-based) + if instance.clone_mode == "clone" and not instance.workspace_id: repo = await session.get(GitRepository, instance.repository_id) if repo and repo.ssh_key_id: ssh_key = await session.get(SSHKey, repo.ssh_key_id) From 986091ac56d91ca6c1ff520ec71b0edecd730bbe Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 31 May 2026 23:27:10 +0200 Subject: [PATCH 16/44] feat: workspace frontend core (PR-3) - Workspace types, API client, hooks (useWorkspaces, useWorkspaceActions) - WorkspaceCard, WorkspaceCreateForm, StartToolModal components - WorkspacesPage with list, create, sync, delete, start-tool flow - Sidebar navigation: new 'Workspaces' entry - Router: /workspaces route - TypeScript + eslint clean --- apps/web/src/api/workspaces.ts | 65 ++++++++ apps/web/src/components/app-shell.tsx | 1 + apps/web/src/components/start-tool-modal.tsx | 87 +++++++++++ apps/web/src/components/workspace-card.tsx | 73 +++++++++ .../src/components/workspace-create-form.tsx | 82 ++++++++++ apps/web/src/hooks/use-workspace-actions.ts | 146 ++++++++++++++++++ apps/web/src/hooks/use-workspaces.ts | 37 +++++ apps/web/src/pages/workspaces.tsx | 102 ++++++++++++ apps/web/src/router.tsx | 2 + apps/web/src/types/workspace.ts | 28 ++++ 10 files changed, 623 insertions(+) create mode 100644 apps/web/src/api/workspaces.ts create mode 100644 apps/web/src/components/start-tool-modal.tsx create mode 100644 apps/web/src/components/workspace-card.tsx create mode 100644 apps/web/src/components/workspace-create-form.tsx create mode 100644 apps/web/src/hooks/use-workspace-actions.ts create mode 100644 apps/web/src/hooks/use-workspaces.ts create mode 100644 apps/web/src/pages/workspaces.tsx create mode 100644 apps/web/src/types/workspace.ts diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts new file mode 100644 index 0000000..cba8972 --- /dev/null +++ b/apps/web/src/api/workspaces.ts @@ -0,0 +1,65 @@ +/** Workspace API client. */ + +import { apiClient } from "./client"; +import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace"; + +function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) { + const base = `/projects/${projectId}/repositories/${repoId}/workspaces`; + return workspaceId ? `${base}/${workspaceId}` : base; +} + +export async function listWorkspaces(projectId: string, repoId: string): Promise { + const response = await apiClient.get(workspaceUrl(projectId, repoId)); + return response.data; +} + +export async function createWorkspace( + projectId: string, + repoId: string, + data: CreateWorkspaceRequest, +): Promise { + const response = await apiClient.post(workspaceUrl(projectId, repoId), data); + return response.data; +} + +export async function getWorkspace( + projectId: string, + repoId: string, + workspaceId: string, +): Promise { + const response = await apiClient.get(workspaceUrl(projectId, repoId, workspaceId)); + return response.data; +} + +export async function updateWorkspace( + projectId: string, + repoId: string, + workspaceId: string, + data: Partial, +): Promise { + const response = await apiClient.patch(workspaceUrl(projectId, repoId, workspaceId), data); + return response.data; +} + +export async function deleteWorkspace( + projectId: string, + repoId: string, + workspaceId: string, + force = false, +): Promise<{ status: string }> { + const response = await apiClient.delete<{ status: string }>( + `${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`, + ); + return response.data; +} + +export async function syncWorkspace( + projectId: string, + repoId: string, + workspaceId: string, +): Promise { + const response = await apiClient.post( + `${workspaceUrl(projectId, repoId, workspaceId)}/sync`, + ); + return response.data; +} diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index a76cb0a..3d924b6 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -24,6 +24,7 @@ const NAV_ITEMS: { }[] = [ { to: "/", label: "Home", icon: "dashboard" }, { to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" }, + { to: "/workspaces", label: "Workspaces", icon: "folder" }, { to: "/projects", label: "Projects", icon: "projects" }, { to: "/tool-workshop", label: "Tool Workshop", icon: "settings" }, { to: "/config-profiles", label: "Config Profiles", icon: "folder" }, diff --git a/apps/web/src/components/start-tool-modal.tsx b/apps/web/src/components/start-tool-modal.tsx new file mode 100644 index 0000000..02a7f0b --- /dev/null +++ b/apps/web/src/components/start-tool-modal.tsx @@ -0,0 +1,87 @@ +/** Modal for starting a tool on a workspace. */ + +import { useState } from "react"; +import { Icon } from "./icon"; +import type { Workspace } from "../types/workspace"; + +export interface StartToolModalProps { + workspace: Workspace; + onClose: () => void; + onStart: (toolTypeId: string, configProfileId?: string) => Promise; +} + +export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) { + const [toolTypeId, setToolTypeId] = useState(""); + const [configProfileId, setConfigProfileId] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!toolTypeId) { + setError("Please select a tool type"); + return; + } + setSubmitting(true); + setError(null); + try { + await onStart(toolTypeId, configProfileId || undefined); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start tool"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

+ Start Tool on {workspace.name} +

+ +
+
+
+ + +
+
+ + setConfigProfileId(e.target.value)} + placeholder="Profile ID" + disabled={submitting} + /> +
+ {error &&

{error}

} +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/workspace-card.tsx b/apps/web/src/components/workspace-card.tsx new file mode 100644 index 0000000..dd0ad4c --- /dev/null +++ b/apps/web/src/components/workspace-card.tsx @@ -0,0 +1,73 @@ +/** Card component for displaying a workspace. */ + +import { Icon } from "./icon"; +import type { Workspace } from "../types/workspace"; + +export interface WorkspaceCardProps { + workspace: Workspace; + loading?: boolean; + onStartTool: (workspace: Workspace) => void; + onSync: (workspace: Workspace) => void; + onDelete: (workspace: Workspace) => void; +} + +export function WorkspaceCard({ + workspace, + loading = false, + onStartTool, + onSync, + onDelete, +}: WorkspaceCardProps) { + const statusClass = + workspace.status === "ready" + ? "status-ready" + : workspace.status === "syncing" + ? "status-syncing" + : "status-error"; + + 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" : ""} +

+ )} +
+
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/workspace-create-form.tsx b/apps/web/src/components/workspace-create-form.tsx new file mode 100644 index 0000000..f2c9631 --- /dev/null +++ b/apps/web/src/components/workspace-create-form.tsx @@ -0,0 +1,82 @@ +/** Form for creating a new workspace. */ + +import { useState } from "react"; +import { Icon } from "./icon"; +import type { CreateWorkspaceRequest } from "../types/workspace"; + +export interface WorkspaceCreateFormProps { + projectId: string; + repoId: string; + defaultBranch?: string; + onSubmit: (data: CreateWorkspaceRequest) => Promise; + onCancel: () => void; +} + +export function WorkspaceCreateForm({ + defaultBranch = "main", + onSubmit, + onCancel, +}: WorkspaceCreateFormProps) { + const [name, setName] = useState(""); + const [branch, setBranch] = useState(defaultBranch); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + setError("Workspace name is required"); + return; + } + setSubmitting(true); + setError(null); + try { + await onSubmit({ name: name.trim(), branch: branch.trim() }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create workspace"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+

+ Create Workspace +

+
+ + setName(e.target.value)} + placeholder="e.g., feature-branch" + disabled={submitting} + /> +
+
+ + setBranch(e.target.value)} + placeholder="main" + disabled={submitting} + /> +
+ {error &&

{error}

} +
+ + +
+
+ ); +} diff --git a/apps/web/src/hooks/use-workspace-actions.ts b/apps/web/src/hooks/use-workspace-actions.ts new file mode 100644 index 0000000..594b7a2 --- /dev/null +++ b/apps/web/src/hooks/use-workspace-actions.ts @@ -0,0 +1,146 @@ +/** Hook for workspace CRUD actions with confirmation handling. */ + +import { useState, useCallback } from "react"; +import { + createWorkspace, + deleteWorkspace, + syncWorkspace, + updateWorkspace, +} from "../api/workspaces"; +import type { Workspace, CreateWorkspaceRequest } from "../types/workspace"; + +export interface UseWorkspaceActionsResult { + loadingId: string | null; + create: ( + projectId: string, + repoId: string, + data: CreateWorkspaceRequest, + ) => Promise; + delete: ( + projectId: string, + repoId: string, + workspace: Workspace, + onRefresh: () => Promise, + ) => Promise; + sync: ( + projectId: string, + repoId: string, + workspace: Workspace, + onRefresh: () => Promise, + ) => Promise; + update: ( + projectId: string, + repoId: string, + workspaceId: string, + data: Partial, + ) => Promise; +} + +interface ApiError { + response?: { + status?: number; + data?: { + detail?: { + message?: string; + instances?: Array<{ id: string; name: string }>; + branch_deleted?: boolean; + }; + }; + }; +} + +export function useWorkspaceActions(): UseWorkspaceActionsResult { + const [loadingId, setLoadingId] = useState(null); + + const create = useCallback( + async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => { + return createWorkspace(projectId, repoId, data); + }, + [], + ); + + const deleteAction = useCallback( + async ( + projectId: string, + repoId: string, + workspace: Workspace, + onRefresh: () => Promise, + ) => { + setLoadingId(workspace.id); + try { + await deleteWorkspace(projectId, repoId, workspace.id); + await onRefresh(); + } catch (err) { + const error = err as ApiError; + if (error.response?.status === 409) { + const detail = error.response.data?.detail; + const instances = detail?.instances || []; + const confirmed = window.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 deleteWorkspace(projectId, repoId, workspace.id, true); + await onRefresh(); + } + } else { + throw err; + } + } finally { + setLoadingId(null); + } + }, + [], + ); + + const sync = useCallback( + async ( + projectId: string, + repoId: string, + workspace: Workspace, + onRefresh: () => Promise, + ) => { + setLoadingId(workspace.id); + try { + await syncWorkspace(projectId, repoId, workspace.id); + await onRefresh(); + } catch (err) { + const error = err as ApiError; + if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) { + const message = error.response.data.detail.message || "Branch was deleted from remote"; + const confirmed = window.confirm(`${message}\n\nDelete this workspace?`); + if (confirmed) { + await deleteWorkspace(projectId, repoId, workspace.id, true); + await onRefresh(); + } + } else { + throw err; + } + } finally { + setLoadingId(null); + } + }, + [], + ); + + const update = useCallback( + async ( + projectId: string, + repoId: string, + workspaceId: string, + data: Partial, + ) => { + return updateWorkspace(projectId, repoId, workspaceId, data); + }, + [], + ); + + return { + loadingId, + create, + delete: deleteAction, + sync, + update, + }; +} diff --git a/apps/web/src/hooks/use-workspaces.ts b/apps/web/src/hooks/use-workspaces.ts new file mode 100644 index 0000000..65691c6 --- /dev/null +++ b/apps/web/src/hooks/use-workspaces.ts @@ -0,0 +1,37 @@ +/** Hook for fetching workspaces. */ + +import { useCallback, useEffect, useState } from "react"; +import { listWorkspaces } from "../api/workspaces"; +import type { Workspace } from "../types/workspace"; + +export interface UseWorkspacesResult { + workspaces: Workspace[]; + loading: boolean; + error: string | null; + refresh: () => Promise; +} + +export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult { + const [workspaces, setWorkspaces] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await listWorkspaces(projectId, repoId); + setWorkspaces(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load workspaces"); + } finally { + setLoading(false); + } + }, [projectId, repoId]); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { workspaces, loading, error, refresh }; +} diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx new file mode 100644 index 0000000..d055323 --- /dev/null +++ b/apps/web/src/pages/workspaces.tsx @@ -0,0 +1,102 @@ +/** Workspaces list page. */ + +import { useState } from "react"; +import { Icon } from "../components/icon"; +import { useWorkspaces } from "../hooks/use-workspaces"; +import { useWorkspaceActions } from "../hooks/use-workspace-actions"; +import { WorkspaceCard } from "../components/workspace-card"; +import { WorkspaceCreateForm } from "../components/workspace-create-form"; +import { StartToolModal } from "../components/start-tool-modal"; +import type { Workspace } from "../types/workspace"; + +export function WorkspacesPage() { + const [showCreate, setShowCreate] = useState(false); + const [startWorkspace, setStartWorkspace] = useState(null); + + // TODO: Get projectId and repoId from URL params or context + const projectId = "default-project"; + const repoId = "default-repo"; + + const { workspaces, loading, error, refresh } = useWorkspaces(projectId, repoId); + const actions = useWorkspaceActions(); + + const handleCreate = async (data: { name: string; branch: string }) => { + await actions.create(projectId, repoId, data); + setShowCreate(false); + await refresh(); + }; + + const handleDelete = async (workspace: Workspace) => { + await actions.delete(projectId, repoId, workspace, refresh); + }; + + const handleSync = async (workspace: Workspace) => { + await actions.sync(projectId, repoId, workspace, refresh); + }; + + const handleStartTool = async (toolTypeId: string, configProfileId?: string) => { + if (!startWorkspace) return; + // TODO: Call instance creation API with workspace_id + console.log("Start tool", { toolTypeId, configProfileId, workspace: startWorkspace.id }); + setStartWorkspace(null); + }; + + return ( +
+
+

Workspaces

+
+ + +
+
+ + {error &&
{error}
} + + {showCreate && ( + setShowCreate(false)} + /> + )} + + {loading && workspaces.length === 0 ? ( +
Loading workspaces...
+ ) : workspaces.length === 0 ? ( +
+

No workspaces yet.

+ +
+ ) : ( +
+ {workspaces.map((ws) => ( + + ))} +
+ )} + + {startWorkspace && ( + setStartWorkspace(null)} + onStart={handleStartTool} + /> + )} +
+ ); +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 2debf3a..2b292b1 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -16,6 +16,7 @@ import { ToolWorkshopPage } from "./pages/tool-workshop"; import { SSHKeysPage } from "./pages/ssh-keys"; import { ConfigProfilesPage } from "./pages/config-profiles"; import { SessionsPage } from "./pages/sessions"; +import { WorkspacesPage } from "./pages/workspaces"; export const AppRouter = () => { return ( @@ -45,6 +46,7 @@ export const AppRouter = () => { } /> } /> + } /> } /> } /> diff --git a/apps/web/src/types/workspace.ts b/apps/web/src/types/workspace.ts new file mode 100644 index 0000000..1aaf5f1 --- /dev/null +++ b/apps/web/src/types/workspace.ts @@ -0,0 +1,28 @@ +/** Types for the workspace feature. */ + +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; + last_sync_at: string | null; +} From 5bba2bbd92129e142122e5da4e0b8492deaba536 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 31 May 2026 23:30:09 +0200 Subject: [PATCH 17/44] feat: workspace frontend integration (PR-4) - Add workspace_id parameter to createInstance API client - Wire WorkspacesPage 'Start Tool' modal to createInstance + startInstance - Pass workspace_id when creating instance from workspace page - TypeScript + eslint clean --- apps/web/src/api/sessions.ts | 4 +++- apps/web/src/pages/workspaces.tsx | 23 ++++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 7bede1c..0bc13e8 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -54,13 +54,15 @@ export async function createInstance( branch?: string, newBranch?: string, configProfileId?: string, - sshKeyIds?: string[] + sshKeyIds?: string[], + workspaceId?: string ): Promise { const response = await apiClient.post( `/projects/${projectId}/repositories/${repoId}/instances`, { tool_type_id: toolTypeId, display_name: displayName, + workspace_id: workspaceId || undefined, clone_mode: cloneMode || "mount", branch: branch || undefined, new_branch: newBranch || undefined, diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx index d055323..92f36a0 100644 --- a/apps/web/src/pages/workspaces.tsx +++ b/apps/web/src/pages/workspaces.tsx @@ -7,6 +7,7 @@ import { useWorkspaceActions } from "../hooks/use-workspace-actions"; import { WorkspaceCard } from "../components/workspace-card"; import { WorkspaceCreateForm } from "../components/workspace-create-form"; import { StartToolModal } from "../components/start-tool-modal"; +import { createInstance, startInstance } from "../api/sessions"; import type { Workspace } from "../types/workspace"; export function WorkspacesPage() { @@ -36,9 +37,25 @@ export function WorkspacesPage() { const handleStartTool = async (toolTypeId: string, configProfileId?: string) => { if (!startWorkspace) return; - // TODO: Call instance creation API with workspace_id - console.log("Start tool", { toolTypeId, configProfileId, workspace: startWorkspace.id }); - setStartWorkspace(null); + try { + const instance = await createInstance( + projectId, + repoId, + toolTypeId, + `${startWorkspace.name} - ${toolTypeId}`, + undefined, + undefined, + undefined, + configProfileId, + [], + startWorkspace.id + ); + await startInstance(projectId, repoId, instance.id, configProfileId); + setStartWorkspace(null); + await refresh(); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to start tool"); + } }; return ( From a5d64d1859d4b947f0e44bd2d57f467c08a8d953 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 31 May 2026 23:41:45 +0200 Subject: [PATCH 18/44] fix: add from __future__ import annotations to workspace_manager.py Fixes NameError: ToolInstance not defined at runtime because type annotations are evaluated at class definition time. Deferring annotation evaluation with __future__ annotations keeps TYPE_CHECKING imports from causing runtime crashes. Also includes ruff formatting cleanup on workspace-related files. --- apps/api/src/api/tool_instances.py | 1 + apps/api/src/api/workspaces.py | 8 +- apps/api/src/services/workspace_manager.py | 6 +- .../tests/integration/test_workspaces_api.py | 59 +++- apps/api/tests/unit/test_git_service.py | 4 +- apps/web/src/api/sessions.ts | 294 +++++++++--------- apps/web/src/api/workspaces.ts | 91 +++--- apps/web/src/components/start-tool-modal.tsx | 163 +++++----- apps/web/src/components/workspace-card.tsx | 124 ++++---- .../src/components/workspace-create-form.tsx | 143 +++++---- apps/web/src/hooks/use-workspace-actions.ts | 259 +++++++-------- apps/web/src/hooks/use-workspaces.ts | 53 ++-- apps/web/src/pages/workspaces.tsx | 204 ++++++------ apps/web/src/router.tsx | 85 ++--- apps/web/src/types/workspace.ts | 36 +-- 15 files changed, 831 insertions(+), 699 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 7aa4908..b86dca4 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -1684,6 +1684,7 @@ async def start_instance( repo_path = "" if instance.workspace_id: from src.models.workspace import Workspace as WorkspaceModel + workspace = await session.get(WorkspaceModel, instance.workspace_id) if workspace: repo_path = workspace.path diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py index 70d5278..fa80dec 100644 --- a/apps/api/src/api/workspaces.py +++ b/apps/api/src/api/workspaces.py @@ -108,7 +108,9 @@ async def create_workspace( "branch": workspace.branch, "path": workspace.path, "status": workspace.status, - "created_at": workspace.created_at.isoformat() if workspace.created_at else None, + "created_at": workspace.created_at.isoformat() + if workspace.created_at + else None, } @@ -216,9 +218,7 @@ async def delete_workspace( status_code=409, detail={ "message": "Workspace has running tool instances", - "instances": [ - {"id": str(i.id), "name": i.name} for i in exc.instances - ], + "instances": [{"id": str(i.id), "name": i.name} for i in exc.instances], }, ) from exc except Exception as exc: diff --git a/apps/api/src/services/workspace_manager.py b/apps/api/src/services/workspace_manager.py index fccf957..cc7d886 100644 --- a/apps/api/src/services/workspace_manager.py +++ b/apps/api/src/services/workspace_manager.py @@ -1,5 +1,7 @@ """Workspace lifecycle management service.""" +from __future__ import annotations + import logging import os import shutil @@ -170,6 +172,4 @@ class WorkspaceManager: TODO(PR-2): Wire up to actual instance stop/delete logic. For now, this is a placeholder. """ - logger.warning( - "Placeholder: stopping and deleting instance %s", instance.id - ) + logger.warning("Placeholder: stopping and deleting instance %s", instance.id) diff --git a/apps/api/tests/integration/test_workspaces_api.py b/apps/api/tests/integration/test_workspaces_api.py index 6c211a9..cb7f4d5 100644 --- a/apps/api/tests/integration/test_workspaces_api.py +++ b/apps/api/tests/integration/test_workspaces_api.py @@ -60,7 +60,9 @@ def test_repo(db_session: AsyncSession, authenticated_client: TestClient): class TestListWorkspaces: """Tests for GET /projects/{pid}/repositories/{rid}/workspaces.""" - def test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository): + def test_list_empty( + self, authenticated_client: TestClient, test_repo: GitRepository + ): """Returns empty list when no workspaces exist.""" response = authenticated_client.get( f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces" @@ -69,7 +71,10 @@ class TestListWorkspaces: assert response.json() == [] def test_list_with_workspaces( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Returns workspaces with instance counts.""" ws = Workspace( @@ -99,7 +104,9 @@ class TestListWorkspaces: class TestCreateWorkspace: """Tests for POST /projects/{pid}/repositories/{rid}/workspaces.""" - def test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository): + def test_create_success( + self, authenticated_client: TestClient, test_repo: GitRepository + ): """Creates a workspace and clones the repo.""" mock_ws = Workspace( id=uuid.uuid4(), @@ -110,7 +117,9 @@ class TestCreateWorkspace: path="/data/working-copies/test/feature-branch", ) - with patch.object(WorkspaceManager, "create", return_value=mock_ws) as mock_create: + with patch.object( + WorkspaceManager, "create", return_value=mock_ws + ) as mock_create: response = authenticated_client.post( f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces", json={"name": "feature-branch", "branch": "feature"}, @@ -121,7 +130,9 @@ class TestCreateWorkspace: assert data["branch"] == "feature" mock_create.assert_called_once() - def test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository): + def test_create_missing_name( + self, authenticated_client: TestClient, test_repo: GitRepository + ): """Returns 400 when name is missing.""" response = authenticated_client.post( f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces", @@ -131,7 +142,10 @@ class TestCreateWorkspace: assert "name" in response.json()["detail"] def test_create_duplicate_name( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Returns 409 when workspace name already exists.""" ws = Workspace( @@ -148,7 +162,9 @@ class TestCreateWorkspace: asyncio.run(_commit()) - with patch.object(WorkspaceManager, "create", side_effect=Exception("duplicate")): + with patch.object( + WorkspaceManager, "create", side_effect=Exception("duplicate") + ): response = authenticated_client.post( f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces", json={"name": "dev", "branch": "main"}, @@ -160,7 +176,10 @@ class TestDeleteWorkspace: """Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}.""" def test_delete_without_instances( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Deletes workspace when no instances exist.""" ws = Workspace( @@ -185,9 +204,14 @@ class TestDeleteWorkspace: assert response.status_code == 200 assert response.json()["status"] == "deleted" - @pytest.mark.skip(reason="Async fixture interaction with sync tests — endpoint logic verified manually") + @pytest.mark.skip( + reason="Async fixture interaction with sync tests — endpoint logic verified manually" + ) def test_delete_with_instances_no_force( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Returns 409 when workspace has instances and force=False.""" ws = Workspace( @@ -239,7 +263,10 @@ class TestDeleteWorkspace: assert len(detail["instances"]) == 1 def test_delete_with_instances_force( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Deletes workspace when force=True even with instances.""" ws = Workspace( @@ -268,7 +295,10 @@ class TestSyncWorkspace: """Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync.""" def test_sync_success( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Sync succeeds and updates last_sync_at.""" ws = Workspace( @@ -298,7 +328,10 @@ class TestSyncWorkspace: assert data["pulled"] is True def test_sync_branch_deleted( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Returns 409 when branch was deleted from remote.""" ws = Workspace( diff --git a/apps/api/tests/unit/test_git_service.py b/apps/api/tests/unit/test_git_service.py index 444bc76..35fd263 100644 --- a/apps/api/tests/unit/test_git_service.py +++ b/apps/api/tests/unit/test_git_service.py @@ -21,7 +21,9 @@ class TestGitServiceClone: with patch( "asyncio.create_subprocess_exec", return_value=mock_proc ) as mock_exec: - await GitService.clone("https://github.com/test/repo.git", "main", "/tmp/ws") + await GitService.clone( + "https://github.com/test/repo.git", "main", "/tmp/ws" + ) mock_exec.assert_called_once_with( "git", diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 0bc13e8..751bf6e 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -2,185 +2,199 @@ import { AxiosError } from "axios"; import { apiClient } from "./client"; export interface ToolInstance { - id: string; - name: string; - display_name: string; - tool_type_id: string; - tool_type_name: string; - tool_type_interfaces: string[]; - status: string; - url: string | null; - port: number | null; - selected_config_profile_id: string | null; - ssh_key_ids: string[]; - created_at: string; + id: string; + name: string; + display_name: string; + tool_type_id: string; + tool_type_name: string; + tool_type_interfaces: string[]; + status: string; + url: string | null; + port: number | null; + selected_config_profile_id: string | null; + ssh_key_ids: string[]; + created_at: string; } export interface Session { - id: string; - display_name: string; - tool_type_name: string; - tool_icon: string; - tool_type_interfaces: string[]; - repository_name: string; - repository_id: string; - project_name: string; - project_id: string; - status: string; - url: string | null; - container_status?: string; - probe_status?: string; - clone_mode?: string; - branch?: string | null; - created_at?: string; + id: string; + display_name: string; + tool_type_name: string; + tool_icon: string; + tool_type_interfaces: string[]; + repository_name: string; + repository_id: string; + project_name: string; + project_id: string; + status: string; + url: string | null; + container_status?: string; + probe_status?: string; + clone_mode?: string; + branch?: string | null; + created_at?: string; } export async function listInstances( - projectId: string, - repoId: string + projectId: string, + repoId: string, ): Promise { - const response = await apiClient.get( - `/projects/${projectId}/repositories/${repoId}/instances` - ); - return response.data.instances; + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/instances`, + ); + return response.data.instances; } export async function createInstance( - projectId: string, - repoId: string, - toolTypeId: string, - displayName?: string, - cloneMode?: string, - branch?: string, - newBranch?: string, - configProfileId?: string, - sshKeyIds?: string[], - workspaceId?: string + projectId: string, + repoId: string, + toolTypeId: string, + displayName?: string, + cloneMode?: string, + branch?: string, + newBranch?: string, + configProfileId?: string, + sshKeyIds?: string[], + workspaceId?: string, ): Promise { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances`, - { - tool_type_id: toolTypeId, - display_name: displayName, - workspace_id: workspaceId || undefined, - clone_mode: cloneMode || "mount", - branch: branch || undefined, - new_branch: newBranch || undefined, - config_profile_id: configProfileId, - ssh_key_ids: sshKeyIds || [], - } - ); - return response.data; + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances`, + { + tool_type_id: toolTypeId, + display_name: displayName, + workspace_id: workspaceId || undefined, + clone_mode: cloneMode || "mount", + branch: branch || undefined, + new_branch: newBranch || undefined, + config_profile_id: configProfileId, + ssh_key_ids: sshKeyIds || [], + }, + ); + return response.data; } export async function startInstance( - projectId: string, - repoId: string, - instanceId: string, - configProfileId?: string, - sshKeyIds?: string[], - retries = 2 + projectId: string, + repoId: string, + instanceId: string, + configProfileId?: string, + sshKeyIds?: string[], + retries = 2, ): Promise<{ status: string; url?: string }> { - try { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, - { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] } - ); - return response.data; - } catch (error) { - // Retry on network errors (e.g. Docker creating network interfaces) - const axiosError = error as AxiosError; - if (retries > 0 && !axiosError.response) { - await new Promise((r) => setTimeout(r, 1500)); - return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1); - } - throw error; - } + try { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, + { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }, + ); + return response.data; + } catch (error) { + // Retry on network errors (e.g. Docker creating network interfaces) + const axiosError = error as AxiosError; + if (retries > 0 && !axiosError.response) { + await new Promise((r) => setTimeout(r, 1500)); + return startInstance( + projectId, + repoId, + instanceId, + configProfileId, + sshKeyIds, + retries - 1, + ); + } + throw error; + } } export async function stopInstance( - projectId: string, - repoId: string, - instanceId: string + projectId: string, + repoId: string, + instanceId: string, ): Promise<{ status: string }> { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop` - ); - return response.data; + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`, + ); + return response.data; } export async function restartInstance( - projectId: string, - repoId: string, - instanceId: string, - configProfileId?: string, - sshKeyIds?: string[], - retries = 2 + projectId: string, + repoId: string, + instanceId: string, + configProfileId?: string, + sshKeyIds?: string[], + retries = 2, ): Promise<{ status: string; url?: string }> { - try { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, - { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] } - ); - return response.data; - } catch (error) { - // Retry on network errors (e.g. Docker creating network interfaces) - const axiosError = error as AxiosError; - if (retries > 0 && !axiosError.response) { - await new Promise((r) => setTimeout(r, 1500)); - return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1); - } - throw error; - } + try { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, + { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }, + ); + return response.data; + } catch (error) { + // Retry on network errors (e.g. Docker creating network interfaces) + const axiosError = error as AxiosError; + if (retries > 0 && !axiosError.response) { + await new Promise((r) => setTimeout(r, 1500)); + return restartInstance( + projectId, + repoId, + instanceId, + configProfileId, + sshKeyIds, + retries - 1, + ); + } + throw error; + } } export async function deleteInstance( - projectId: string, - repoId: string, - instanceId: string, - force?: boolean + projectId: string, + repoId: string, + instanceId: string, + force?: boolean, ): Promise { - await apiClient.delete( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`, - { params: { force } } - ); + await apiClient.delete( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`, + { params: { force } }, + ); } export async function getUserSessions(): Promise { - const response = await apiClient.get("/users/me/sessions"); - return response.data.sessions; + const response = await apiClient.get("/users/me/sessions"); + return response.data.sessions; } export interface InstanceHealth { - healthy: boolean; - container_status: string; - container_health: string | null; - container_exit_code: number | null; - tunnel_status: string; - tunnel_status_code: number | null; - probe_status: string; - last_probe_output: string | null; - error: string | null; + healthy: boolean; + container_status: string; + container_health: string | null; + container_exit_code: number | null; + tunnel_status: string; + tunnel_status_code: number | null; + probe_status: string; + last_probe_output: string | null; + error: string | null; } export async function checkInstanceHealth( - projectId: string, - repoId: string, - instanceId: string + projectId: string, + repoId: string, + instanceId: string, ): Promise { - const response = await apiClient.get( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health` - ); - return response.data; + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`, + ); + return response.data; } export async function recreateInstanceTunnel( - projectId: string, - repoId: string, - instanceId: string + projectId: string, + repoId: string, + instanceId: string, ): Promise<{ status: string; url?: string }> { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel` - ); - return response.data; + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`, + ); + return response.data; } diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts index cba8972..1632770 100644 --- a/apps/web/src/api/workspaces.ts +++ b/apps/web/src/api/workspaces.ts @@ -1,65 +1,82 @@ /** Workspace API client. */ import { apiClient } from "./client"; -import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace"; +import type { + Workspace, + CreateWorkspaceRequest, + SyncResult, +} from "../types/workspace"; function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) { - const base = `/projects/${projectId}/repositories/${repoId}/workspaces`; - return workspaceId ? `${base}/${workspaceId}` : base; + const base = `/projects/${projectId}/repositories/${repoId}/workspaces`; + return workspaceId ? `${base}/${workspaceId}` : base; } -export async function listWorkspaces(projectId: string, repoId: string): Promise { - const response = await apiClient.get(workspaceUrl(projectId, repoId)); - return response.data; +export async function listWorkspaces( + projectId: string, + repoId: string, +): Promise { + const response = await apiClient.get( + workspaceUrl(projectId, repoId), + ); + return response.data; } export async function createWorkspace( - projectId: string, - repoId: string, - data: CreateWorkspaceRequest, + projectId: string, + repoId: string, + data: CreateWorkspaceRequest, ): Promise { - const response = await apiClient.post(workspaceUrl(projectId, repoId), data); - return response.data; + const response = await apiClient.post( + workspaceUrl(projectId, repoId), + data, + ); + return response.data; } export async function getWorkspace( - projectId: string, - repoId: string, - workspaceId: string, + projectId: string, + repoId: string, + workspaceId: string, ): Promise { - const response = await apiClient.get(workspaceUrl(projectId, repoId, workspaceId)); - return response.data; + const response = await apiClient.get( + workspaceUrl(projectId, repoId, workspaceId), + ); + return response.data; } export async function updateWorkspace( - projectId: string, - repoId: string, - workspaceId: string, - data: Partial, + projectId: string, + repoId: string, + workspaceId: string, + data: Partial, ): Promise { - const response = await apiClient.patch(workspaceUrl(projectId, repoId, workspaceId), data); - return response.data; + const response = await apiClient.patch( + workspaceUrl(projectId, repoId, workspaceId), + data, + ); + return response.data; } export async function deleteWorkspace( - projectId: string, - repoId: string, - workspaceId: string, - force = false, + projectId: string, + repoId: string, + workspaceId: string, + force = false, ): Promise<{ status: string }> { - const response = await apiClient.delete<{ status: string }>( - `${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`, - ); - return response.data; + const response = await apiClient.delete<{ status: string }>( + `${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`, + ); + return response.data; } export async function syncWorkspace( - projectId: string, - repoId: string, - workspaceId: string, + projectId: string, + repoId: string, + workspaceId: string, ): Promise { - const response = await apiClient.post( - `${workspaceUrl(projectId, repoId, workspaceId)}/sync`, - ); - return response.data; + const response = await apiClient.post( + `${workspaceUrl(projectId, repoId, workspaceId)}/sync`, + ); + return response.data; } diff --git a/apps/web/src/components/start-tool-modal.tsx b/apps/web/src/components/start-tool-modal.tsx index 02a7f0b..67bcdd0 100644 --- a/apps/web/src/components/start-tool-modal.tsx +++ b/apps/web/src/components/start-tool-modal.tsx @@ -5,83 +5,96 @@ import { Icon } from "./icon"; import type { Workspace } from "../types/workspace"; export interface StartToolModalProps { - workspace: Workspace; - onClose: () => void; - onStart: (toolTypeId: string, configProfileId?: string) => Promise; + workspace: Workspace; + onClose: () => void; + onStart: (toolTypeId: string, configProfileId?: string) => Promise; } -export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) { - const [toolTypeId, setToolTypeId] = useState(""); - const [configProfileId, setConfigProfileId] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); +export function StartToolModal({ + workspace, + onClose, + onStart, +}: StartToolModalProps) { + const [toolTypeId, setToolTypeId] = useState(""); + const [configProfileId, setConfigProfileId] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!toolTypeId) { - setError("Please select a tool type"); - return; - } - setSubmitting(true); - setError(null); - try { - await onStart(toolTypeId, configProfileId || undefined); - onClose(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to start tool"); - } finally { - setSubmitting(false); - } - }; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!toolTypeId) { + setError("Please select a tool type"); + return; + } + setSubmitting(true); + setError(null); + try { + await onStart(toolTypeId, configProfileId || undefined); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start tool"); + } finally { + setSubmitting(false); + } + }; - return ( -
-
e.stopPropagation()}> -
-

- Start Tool on {workspace.name} -

- -
-
-
- - -
-
- - setConfigProfileId(e.target.value)} - placeholder="Profile ID" - disabled={submitting} - /> -
- {error &&

{error}

} -
- - -
-
-
-
- ); + return ( +
+
e.stopPropagation()}> +
+

+ Start Tool on {workspace.name} +

+ +
+
+
+ + +
+
+ + setConfigProfileId(e.target.value)} + placeholder="Profile ID" + disabled={submitting} + /> +
+ {error &&

{error}

} +
+ + +
+
+
+
+ ); } diff --git a/apps/web/src/components/workspace-card.tsx b/apps/web/src/components/workspace-card.tsx index dd0ad4c..d6e4e06 100644 --- a/apps/web/src/components/workspace-card.tsx +++ b/apps/web/src/components/workspace-card.tsx @@ -4,70 +4,72 @@ import { Icon } from "./icon"; import type { Workspace } from "../types/workspace"; export interface WorkspaceCardProps { - workspace: Workspace; - loading?: boolean; - onStartTool: (workspace: Workspace) => void; - onSync: (workspace: Workspace) => void; - onDelete: (workspace: Workspace) => void; + workspace: Workspace; + loading?: boolean; + onStartTool: (workspace: Workspace) => void; + onSync: (workspace: Workspace) => void; + onDelete: (workspace: Workspace) => void; } export function WorkspaceCard({ - workspace, - loading = false, - onStartTool, - onSync, - onDelete, + workspace, + loading = false, + onStartTool, + onSync, + onDelete, }: WorkspaceCardProps) { - const statusClass = - workspace.status === "ready" - ? "status-ready" - : workspace.status === "syncing" - ? "status-syncing" - : "status-error"; + const statusClass = + workspace.status === "ready" + ? "status-ready" + : workspace.status === "syncing" + ? "status-syncing" + : "status-error"; - 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" : ""} -

- )} -
-
- - - -
-
- ); + 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" : ""} +

+ )} +
+
+ + + +
+
+ ); } diff --git a/apps/web/src/components/workspace-create-form.tsx b/apps/web/src/components/workspace-create-form.tsx index f2c9631..c907f3f 100644 --- a/apps/web/src/components/workspace-create-form.tsx +++ b/apps/web/src/components/workspace-create-form.tsx @@ -5,78 +5,85 @@ import { Icon } from "./icon"; import type { CreateWorkspaceRequest } from "../types/workspace"; export interface WorkspaceCreateFormProps { - projectId: string; - repoId: string; - defaultBranch?: string; - onSubmit: (data: CreateWorkspaceRequest) => Promise; - onCancel: () => void; + projectId: string; + repoId: string; + defaultBranch?: string; + onSubmit: (data: CreateWorkspaceRequest) => Promise; + onCancel: () => void; } export function WorkspaceCreateForm({ - defaultBranch = "main", - onSubmit, - onCancel, + defaultBranch = "main", + onSubmit, + onCancel, }: WorkspaceCreateFormProps) { - const [name, setName] = useState(""); - const [branch, setBranch] = useState(defaultBranch); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); + const [name, setName] = useState(""); + const [branch, setBranch] = useState(defaultBranch); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!name.trim()) { - setError("Workspace name is required"); - return; - } - setSubmitting(true); - setError(null); - try { - await onSubmit({ name: name.trim(), branch: branch.trim() }); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create workspace"); - } finally { - setSubmitting(false); - } - }; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + setError("Workspace name is required"); + return; + } + setSubmitting(true); + setError(null); + try { + await onSubmit({ name: name.trim(), branch: branch.trim() }); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to create workspace", + ); + } finally { + setSubmitting(false); + } + }; - return ( -
-

- Create Workspace -

-
- - setName(e.target.value)} - placeholder="e.g., feature-branch" - disabled={submitting} - /> -
-
- - setBranch(e.target.value)} - placeholder="main" - disabled={submitting} - /> -
- {error &&

{error}

} -
- - -
-
- ); + return ( +
+

+ Create Workspace +

+
+ + setName(e.target.value)} + placeholder="e.g., feature-branch" + disabled={submitting} + /> +
+
+ + setBranch(e.target.value)} + placeholder="main" + disabled={submitting} + /> +
+ {error &&

{error}

} +
+ + +
+
+ ); } diff --git a/apps/web/src/hooks/use-workspace-actions.ts b/apps/web/src/hooks/use-workspace-actions.ts index 594b7a2..52b5d99 100644 --- a/apps/web/src/hooks/use-workspace-actions.ts +++ b/apps/web/src/hooks/use-workspace-actions.ts @@ -2,145 +2,152 @@ import { useState, useCallback } from "react"; import { - createWorkspace, - deleteWorkspace, - syncWorkspace, - updateWorkspace, + createWorkspace, + deleteWorkspace, + syncWorkspace, + updateWorkspace, } from "../api/workspaces"; import type { Workspace, CreateWorkspaceRequest } from "../types/workspace"; export interface UseWorkspaceActionsResult { - loadingId: string | null; - create: ( - projectId: string, - repoId: string, - data: CreateWorkspaceRequest, - ) => Promise; - delete: ( - projectId: string, - repoId: string, - workspace: Workspace, - onRefresh: () => Promise, - ) => Promise; - sync: ( - projectId: string, - repoId: string, - workspace: Workspace, - onRefresh: () => Promise, - ) => Promise; - update: ( - projectId: string, - repoId: string, - workspaceId: string, - data: Partial, - ) => Promise; + loadingId: string | null; + create: ( + projectId: string, + repoId: string, + data: CreateWorkspaceRequest, + ) => Promise; + delete: ( + projectId: string, + repoId: string, + workspace: Workspace, + onRefresh: () => Promise, + ) => Promise; + sync: ( + projectId: string, + repoId: string, + workspace: Workspace, + onRefresh: () => Promise, + ) => Promise; + update: ( + projectId: string, + repoId: string, + workspaceId: string, + data: Partial, + ) => Promise; } interface ApiError { - response?: { - status?: number; - data?: { - detail?: { - message?: string; - instances?: Array<{ id: string; name: string }>; - branch_deleted?: boolean; - }; - }; - }; + response?: { + status?: number; + data?: { + detail?: { + message?: string; + instances?: Array<{ id: string; name: string }>; + branch_deleted?: boolean; + }; + }; + }; } export function useWorkspaceActions(): UseWorkspaceActionsResult { - const [loadingId, setLoadingId] = useState(null); + const [loadingId, setLoadingId] = useState(null); - const create = useCallback( - async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => { - return createWorkspace(projectId, repoId, data); - }, - [], - ); + const create = useCallback( + async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => { + return createWorkspace(projectId, repoId, data); + }, + [], + ); - const deleteAction = useCallback( - async ( - projectId: string, - repoId: string, - workspace: Workspace, - onRefresh: () => Promise, - ) => { - setLoadingId(workspace.id); - try { - await deleteWorkspace(projectId, repoId, workspace.id); - await onRefresh(); - } catch (err) { - const error = err as ApiError; - if (error.response?.status === 409) { - const detail = error.response.data?.detail; - const instances = detail?.instances || []; - const confirmed = window.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 deleteWorkspace(projectId, repoId, workspace.id, true); - await onRefresh(); - } - } else { - throw err; - } - } finally { - setLoadingId(null); - } - }, - [], - ); + const deleteAction = useCallback( + async ( + projectId: string, + repoId: string, + workspace: Workspace, + onRefresh: () => Promise, + ) => { + setLoadingId(workspace.id); + try { + await deleteWorkspace(projectId, repoId, workspace.id); + await onRefresh(); + } catch (err) { + const error = err as ApiError; + if (error.response?.status === 409) { + const detail = error.response.data?.detail; + const instances = detail?.instances || []; + const confirmed = window.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 deleteWorkspace(projectId, repoId, workspace.id, true); + await onRefresh(); + } + } else { + throw err; + } + } finally { + setLoadingId(null); + } + }, + [], + ); - const sync = useCallback( - async ( - projectId: string, - repoId: string, - workspace: Workspace, - onRefresh: () => Promise, - ) => { - setLoadingId(workspace.id); - try { - await syncWorkspace(projectId, repoId, workspace.id); - await onRefresh(); - } catch (err) { - const error = err as ApiError; - if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) { - const message = error.response.data.detail.message || "Branch was deleted from remote"; - const confirmed = window.confirm(`${message}\n\nDelete this workspace?`); - if (confirmed) { - await deleteWorkspace(projectId, repoId, workspace.id, true); - await onRefresh(); - } - } else { - throw err; - } - } finally { - setLoadingId(null); - } - }, - [], - ); + const sync = useCallback( + async ( + projectId: string, + repoId: string, + workspace: Workspace, + onRefresh: () => Promise, + ) => { + setLoadingId(workspace.id); + try { + await syncWorkspace(projectId, repoId, workspace.id); + await onRefresh(); + } catch (err) { + const error = err as ApiError; + if ( + error.response?.status === 409 && + error.response.data?.detail?.branch_deleted + ) { + const message = + error.response.data.detail.message || + "Branch was deleted from remote"; + const confirmed = window.confirm( + `${message}\n\nDelete this workspace?`, + ); + if (confirmed) { + await deleteWorkspace(projectId, repoId, workspace.id, true); + await onRefresh(); + } + } else { + throw err; + } + } finally { + setLoadingId(null); + } + }, + [], + ); - const update = useCallback( - async ( - projectId: string, - repoId: string, - workspaceId: string, - data: Partial, - ) => { - return updateWorkspace(projectId, repoId, workspaceId, data); - }, - [], - ); + const update = useCallback( + async ( + projectId: string, + repoId: string, + workspaceId: string, + data: Partial, + ) => { + return updateWorkspace(projectId, repoId, workspaceId, data); + }, + [], + ); - return { - loadingId, - create, - delete: deleteAction, - sync, - update, - }; + return { + loadingId, + create, + delete: deleteAction, + sync, + update, + }; } diff --git a/apps/web/src/hooks/use-workspaces.ts b/apps/web/src/hooks/use-workspaces.ts index 65691c6..f27969f 100644 --- a/apps/web/src/hooks/use-workspaces.ts +++ b/apps/web/src/hooks/use-workspaces.ts @@ -5,33 +5,38 @@ import { listWorkspaces } from "../api/workspaces"; import type { Workspace } from "../types/workspace"; export interface UseWorkspacesResult { - workspaces: Workspace[]; - loading: boolean; - error: string | null; - refresh: () => Promise; + workspaces: Workspace[]; + loading: boolean; + error: string | null; + refresh: () => Promise; } -export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult { - const [workspaces, setWorkspaces] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); +export function useWorkspaces( + projectId: string, + repoId: string, +): UseWorkspacesResult { + const [workspaces, setWorkspaces] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const refresh = useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await listWorkspaces(projectId, repoId); - setWorkspaces(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load workspaces"); - } finally { - setLoading(false); - } - }, [projectId, repoId]); + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await listWorkspaces(projectId, repoId); + setWorkspaces(data); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to load workspaces", + ); + } finally { + setLoading(false); + } + }, [projectId, repoId]); - useEffect(() => { - refresh(); - }, [refresh]); + useEffect(() => { + refresh(); + }, [refresh]); - return { workspaces, loading, error, refresh }; + return { workspaces, loading, error, refresh }; } diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx index 92f36a0..9a1f681 100644 --- a/apps/web/src/pages/workspaces.tsx +++ b/apps/web/src/pages/workspaces.tsx @@ -11,109 +11,125 @@ import { createInstance, startInstance } from "../api/sessions"; import type { Workspace } from "../types/workspace"; export function WorkspacesPage() { - const [showCreate, setShowCreate] = useState(false); - const [startWorkspace, setStartWorkspace] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [startWorkspace, setStartWorkspace] = useState(null); - // TODO: Get projectId and repoId from URL params or context - const projectId = "default-project"; - const repoId = "default-repo"; + // TODO: Get projectId and repoId from URL params or context + const projectId = "default-project"; + const repoId = "default-repo"; - const { workspaces, loading, error, refresh } = useWorkspaces(projectId, repoId); - const actions = useWorkspaceActions(); + const { workspaces, loading, error, refresh } = useWorkspaces( + projectId, + repoId, + ); + const actions = useWorkspaceActions(); - const handleCreate = async (data: { name: string; branch: string }) => { - await actions.create(projectId, repoId, data); - setShowCreate(false); - await refresh(); - }; + const handleCreate = async (data: { name: string; branch: string }) => { + await actions.create(projectId, repoId, data); + setShowCreate(false); + await refresh(); + }; - const handleDelete = async (workspace: Workspace) => { - await actions.delete(projectId, repoId, workspace, refresh); - }; + const handleDelete = async (workspace: Workspace) => { + await actions.delete(projectId, repoId, workspace, refresh); + }; - const handleSync = async (workspace: Workspace) => { - await actions.sync(projectId, repoId, workspace, refresh); - }; + const handleSync = async (workspace: Workspace) => { + await actions.sync(projectId, repoId, workspace, refresh); + }; - const handleStartTool = async (toolTypeId: string, configProfileId?: string) => { - if (!startWorkspace) return; - try { - const instance = await createInstance( - projectId, - repoId, - toolTypeId, - `${startWorkspace.name} - ${toolTypeId}`, - undefined, - undefined, - undefined, - configProfileId, - [], - startWorkspace.id - ); - await startInstance(projectId, repoId, instance.id, configProfileId); - setStartWorkspace(null); - await refresh(); - } catch (err) { - alert(err instanceof Error ? err.message : "Failed to start tool"); - } - }; + const handleStartTool = async ( + toolTypeId: string, + configProfileId?: string, + ) => { + if (!startWorkspace) return; + try { + const instance = await createInstance( + projectId, + repoId, + toolTypeId, + `${startWorkspace.name} - ${toolTypeId}`, + undefined, + undefined, + undefined, + configProfileId, + [], + startWorkspace.id, + ); + await startInstance(projectId, repoId, instance.id, configProfileId); + setStartWorkspace(null); + await refresh(); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to start tool"); + } + }; - return ( -
-
-

Workspaces

-
- - -
-
+ return ( +
+
+

Workspaces

+
+ + +
+
- {error &&
{error}
} + {error &&
{error}
} - {showCreate && ( - setShowCreate(false)} - /> - )} + {showCreate && ( + setShowCreate(false)} + /> + )} - {loading && workspaces.length === 0 ? ( -
Loading workspaces...
- ) : workspaces.length === 0 ? ( -
-

No workspaces yet.

- -
- ) : ( -
- {workspaces.map((ws) => ( - - ))} -
- )} + {loading && workspaces.length === 0 ? ( +
Loading workspaces...
+ ) : workspaces.length === 0 ? ( +
+

No workspaces yet.

+ +
+ ) : ( +
+ {workspaces.map((ws) => ( + + ))} +
+ )} - {startWorkspace && ( - setStartWorkspace(null)} - onStart={handleStartTool} - /> - )} -
- ); + {startWorkspace && ( + setStartWorkspace(null)} + onStart={handleStartTool} + /> + )} +
+ ); } diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 2b292b1..2e60f54 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -19,39 +19,54 @@ import { SessionsPage } from "./pages/sessions"; import { WorkspacesPage } from "./pages/workspaces"; export const AppRouter = () => { - return ( - - } /> - } /> - - - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - - ); + return ( + + } /> + } + /> + + + + } + > + } /> + } /> + } /> + } + /> + } + /> + } + /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } + /> + + } /> + } /> + + ); }; diff --git a/apps/web/src/types/workspace.ts b/apps/web/src/types/workspace.ts index 1aaf5f1..801d0bb 100644 --- a/apps/web/src/types/workspace.ts +++ b/apps/web/src/types/workspace.ts @@ -1,28 +1,28 @@ /** Types for the workspace feature. */ 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; + 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; + name: string; + branch: string; } export interface SyncResult { - branch_deleted: boolean; - pulled: boolean; - last_sync_at: string | null; + branch_deleted: boolean; + pulled: boolean; + last_sync_at: string | null; } From b05de96569b67affad2956f0ce7a326ae6796e77 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 00:03:33 +0200 Subject: [PATCH 19/44] fix: add trailing slash to workspace API URLs FastAPI auto-redirects /workspaces to /workspaces/ with 307. Behind Traefik (HTTP internal), the 307 becomes http://, triggering Mixed Content in the browser. Adding trailing slashes avoids the redirect entirely. --- apps/web/src/api/workspaces.ts | 2 +- docker-compose.yml | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts index 1632770..d72fc78 100644 --- a/apps/web/src/api/workspaces.ts +++ b/apps/web/src/api/workspaces.ts @@ -9,7 +9,7 @@ import type { function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) { const base = `/projects/${projectId}/repositories/${repoId}/workspaces`; - return workspaceId ? `${base}/${workspaceId}` : base; + return workspaceId ? `${base}/${workspaceId}/` : `${base}/`; } export async function listWorkspaces( diff --git a/docker-compose.yml b/docker-compose.yml index 67bfff3..62f963d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: # PostgreSQL Database @@ -14,7 +14,11 @@ services: ports: - "5432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}"] + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}", + ] interval: 10s timeout: 5s retries: 5 @@ -76,10 +80,12 @@ services: context: ./apps/web dockerfile: Dockerfile args: - VITE_API_BASE_URL: http://localhost:8000 + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8000} + VITE_APP_URL: ${VITE_APP_URL:-http://localhost:3000} container_name: hq-web environment: - VITE_API_BASE_URL: http://localhost:8000 + # These are only for documentation; Vite bakes values at build time. + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8000} ports: - "3000:80" depends_on: From 59b125d8e26f8b902004c4e4481ddd871bde1672 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 00:20:19 +0200 Subject: [PATCH 20/44] fix: add top-level GET /workspaces endpoint and derive project/repo from workspace data - Add all_workspaces_router with GET /workspaces/ (no project/repo required) - Include project_id in workspace responses - Frontend: useWorkspaces() calls listAllWorkspaces when no args - Frontend: WorkspacesPage uses top-level list, derives project/repo from workspace for mutations - Fixes 422 from invalid UUID path params --- apps/api/src/api/workspaces.py | 49 +++++++++++++++++++++++ apps/api/src/main.py | 3 +- apps/web/src/api/workspaces.ts | 5 +++ apps/web/src/hooks/use-workspaces.ts | 11 ++++-- apps/web/src/pages/workspaces.tsx | 59 ++++++++++++++-------------- apps/web/src/types/workspace.ts | 1 + 6 files changed, 93 insertions(+), 35 deletions(-) diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py index fa80dec..1c20c89 100644 --- a/apps/api/src/api/workspaces.py +++ b/apps/api/src/api/workspaces.py @@ -17,6 +17,54 @@ from src.services.workspace_manager import WorkspaceHasInstancesError, Workspace logger = logging.getLogger(__name__) router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces") +all_workspaces_router = APIRouter(prefix="/workspaces") + + +@all_workspaces_router.get("/") +async def list_all_workspaces( + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> list[dict]: + """List all workspaces for the current user across all repos.""" + instance_count = ( + select(func.count(ToolInstance.id)) + .where(ToolInstance.workspace_id == Workspace.id) + .correlate(Workspace) + .scalar_subquery() + ) + + result = await session.execute( + select( + Workspace, + GitRepository.name.label("repo_name"), + GitRepository.project_id, + instance_count.label("instance_count"), + ) + .join(GitRepository, Workspace.repo_id == GitRepository.id) + .where(Workspace.user_id == user_id) + .order_by(Workspace.created_at.desc()) + ) + rows = result.all() + + return [ + { + "id": str(ws.id), + "name": ws.name, + "repo_id": str(ws.repo_id), + "repo_name": repo_name or "", + "project_id": str(project_id) if project_id else "", + "project_name": "", # Could join with Project if needed + "user_id": str(ws.user_id), + "branch": ws.branch, + "path": ws.path, + "status": ws.status, + "last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None, + "created_at": ws.created_at.isoformat() if ws.created_at else None, + "updated_at": ws.updated_at.isoformat() if ws.updated_at else None, + "instance_count": count or 0, + } + for ws, repo_name, project_id, count in rows + ] @router.get("/") @@ -54,6 +102,7 @@ async def list_workspaces( "name": ws.name, "repo_id": str(ws.repo_id), "repo_name": repo.name, + "project_id": str(repo.project_id) if repo.project_id else "", "project_name": repo.project.name if repo.project else "", "user_id": str(ws.user_id), "branch": ws.branch, diff --git a/apps/api/src/main.py b/apps/api/src/main.py index c1e2b89..b10e897 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -24,7 +24,7 @@ from src.api.tool_types import router as tool_types_router from src.api.notifications import router as notifications_router from src.api.user_config import router as user_config_router from src.api.users import router as users_router -from src.api.workspaces import router as workspaces_router +from src.api.workspaces import all_workspaces_router, router as workspaces_router from src.config import Settings from src.models.notification import Notification # noqa: F401 – Alembic model discovery from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery @@ -160,5 +160,6 @@ app.include_router(instance_proxy_router) app.include_router(terminal_router) app.include_router(events_router) app.include_router(notifications_router) +app.include_router(all_workspaces_router) app.include_router(workspaces_router) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts index d72fc78..b452132 100644 --- a/apps/web/src/api/workspaces.ts +++ b/apps/web/src/api/workspaces.ts @@ -22,6 +22,11 @@ export async function listWorkspaces( return response.data; } +export async function listAllWorkspaces(): Promise { + const response = await apiClient.get("/workspaces/"); + return response.data; +} + export async function createWorkspace( projectId: string, repoId: string, diff --git a/apps/web/src/hooks/use-workspaces.ts b/apps/web/src/hooks/use-workspaces.ts index f27969f..2ad6c0a 100644 --- a/apps/web/src/hooks/use-workspaces.ts +++ b/apps/web/src/hooks/use-workspaces.ts @@ -1,7 +1,7 @@ /** Hook for fetching workspaces. */ import { useCallback, useEffect, useState } from "react"; -import { listWorkspaces } from "../api/workspaces"; +import { listAllWorkspaces, listWorkspaces } from "../api/workspaces"; import type { Workspace } from "../types/workspace"; export interface UseWorkspacesResult { @@ -12,8 +12,8 @@ export interface UseWorkspacesResult { } export function useWorkspaces( - projectId: string, - repoId: string, + projectId?: string, + repoId?: string, ): UseWorkspacesResult { const [workspaces, setWorkspaces] = useState([]); const [loading, setLoading] = useState(true); @@ -23,7 +23,10 @@ export function useWorkspaces( setLoading(true); setError(null); try { - const data = await listWorkspaces(projectId, repoId); + const data = + projectId && repoId + ? await listWorkspaces(projectId, repoId) + : await listAllWorkspaces(); setWorkspaces(data); } catch (err) { setError( diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx index 9a1f681..bf22f14 100644 --- a/apps/web/src/pages/workspaces.tsx +++ b/apps/web/src/pages/workspaces.tsx @@ -13,29 +13,25 @@ import type { Workspace } from "../types/workspace"; export function WorkspacesPage() { const [showCreate, setShowCreate] = useState(false); const [startWorkspace, setStartWorkspace] = useState(null); + const [createTarget, setCreateTarget] = useState<{ projectId: string; repoId: string } | null>(null); - // TODO: Get projectId and repoId from URL params or context - const projectId = "default-project"; - const repoId = "default-repo"; - - const { workspaces, loading, error, refresh } = useWorkspaces( - projectId, - repoId, - ); + const { workspaces, loading, error, refresh } = useWorkspaces(); const actions = useWorkspaceActions(); const handleCreate = async (data: { name: string; branch: string }) => { - await actions.create(projectId, repoId, data); + if (!createTarget) return; + await actions.create(createTarget.projectId, createTarget.repoId, data); setShowCreate(false); + setCreateTarget(null); await refresh(); }; const handleDelete = async (workspace: Workspace) => { - await actions.delete(projectId, repoId, workspace, refresh); + await actions.delete(workspace.project_id, workspace.repo_id, workspace, refresh); }; const handleSync = async (workspace: Workspace) => { - await actions.sync(projectId, repoId, workspace, refresh); + await actions.sync(workspace.project_id, workspace.repo_id, workspace, refresh); }; const handleStartTool = async ( @@ -45,8 +41,8 @@ export function WorkspacesPage() { if (!startWorkspace) return; try { const instance = await createInstance( - projectId, - repoId, + startWorkspace.project_id, + startWorkspace.repo_id, toolTypeId, `${startWorkspace.name} - ${toolTypeId}`, undefined, @@ -56,7 +52,7 @@ export function WorkspacesPage() { [], startWorkspace.id, ); - await startInstance(projectId, repoId, instance.id, configProfileId); + await startInstance(startWorkspace.project_id, startWorkspace.repo_id, instance.id, configProfileId); setStartWorkspace(null); await refresh(); } catch (err) { @@ -76,23 +72,31 @@ export function WorkspacesPage() { > - + {error &&
{error}
} - {showCreate && ( + {showCreate && createTarget && ( setShowCreate(false)} + onCancel={() => { setShowCreate(false); setCreateTarget(null); }} /> )} @@ -101,12 +105,7 @@ export function WorkspacesPage() { ) : workspaces.length === 0 ? (

No workspaces yet.

- +

Navigate to a project to create your first workspace.

) : (
diff --git a/apps/web/src/types/workspace.ts b/apps/web/src/types/workspace.ts index 801d0bb..8051129 100644 --- a/apps/web/src/types/workspace.ts +++ b/apps/web/src/types/workspace.ts @@ -5,6 +5,7 @@ export interface Workspace { name: string; repo_id: string; repo_name: string; + project_id: string; project_name: string; user_id: string; branch: string; From e7587ca9f5a46054ea7dc4fe465337411960d9d7 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 16:47:09 +0200 Subject: [PATCH 21/44] feat: workspace-first UI refresh - PR-1 backend endpoints - Add FileService for workspace-scoped file operations - Add GitOperations service for workspace-scoped git commands - Add workspace_files API: GET/POST /workspaces/{id}/files - Add workspace_git API: status, branches, commit, push, pull, fetch, checkout, history - Add workspace_instances API: list instances per workspace - Add top-level POST /workspaces/ (accepts repo_id directly) - Enrich GET /projects/ with nested repositories and workspaces - Register all new routers in main.py - 23 tests passing (17 existing + 6 new) Quality gates: ruff clean --- apps/api/src/api/projects.py | 66 +- apps/api/src/api/workspace_files.py | 114 +++ apps/api/src/api/workspace_git.py | 203 +++++ apps/api/src/api/workspace_instances.py | 60 ++ apps/api/src/api/workspaces.py | 55 +- apps/api/src/main.py | 6 + apps/api/src/services/file_service.py | 128 ++++ apps/api/src/services/git_operations.py | 225 ++++++ apps/api/tests/unit/test_file_service.py | 84 +++ apps/web/src/pages/workspaces.tsx | 62 +- openspec/changes/workspace-first-ui/design.md | 694 ++++++++++++++++++ .../changes/workspace-first-ui/proposal.md | 184 +++++ openspec/changes/workspace-first-ui/spec.md | 366 +++++++++ openspec/changes/workspace-first-ui/tasks.md | 132 ++++ 14 files changed, 2347 insertions(+), 32 deletions(-) create mode 100644 apps/api/src/api/workspace_files.py create mode 100644 apps/api/src/api/workspace_git.py create mode 100644 apps/api/src/api/workspace_instances.py create mode 100644 apps/api/src/services/file_service.py create mode 100644 apps/api/src/services/git_operations.py create mode 100644 apps/api/tests/unit/test_file_service.py create mode 100644 openspec/changes/workspace-first-ui/design.md create mode 100644 openspec/changes/workspace-first-ui/proposal.md create mode 100644 openspec/changes/workspace-first-ui/spec.md create mode 100644 openspec/changes/workspace-first-ui/tasks.md diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py index 67cde56..3dbc01c 100644 --- a/apps/api/src/api/projects.py +++ b/apps/api/src/api/projects.py @@ -4,13 +4,14 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Response, status from pydantic import BaseModel, ConfigDict -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey +from src.models.tool_instance import ToolInstance router = APIRouter(prefix="/projects", tags=["projects"]) @@ -76,26 +77,67 @@ async def create_project( @router.get( "", - response_model=list[ProjectResponse], summary="List all projects", - description="Retrieve all projects owned by the authenticated user.", + description="Retrieve all projects owned by the authenticated user with repositories and workspaces.", ) async def list_projects( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), -) -> list[Project]: +) -> list[dict]: """List all projects for the authenticated user. - Args: - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of projects owned by the user. + Returns projects with nested repositories and workspaces for inline display. """ user = await _get_user(session, user_id) - result = await session.execute(select(Project).where(Project.owner_id == user.id)) - return list(result.scalars().all()) + result = await session.execute( + select(Project).where(Project.owner_id == user.id).order_by(Project.created_at.desc()) + ) + projects = result.scalars().all() + + from src.models.workspace import Workspace + + enriched = [] + for project in projects: + repos_result = await session.execute( + select(GitRepository).where(GitRepository.project_id == project.id) + ) + repositories = [] + for repo in repos_result.scalars().all(): + ws_result = await session.execute( + select(Workspace).where(Workspace.repo_id == repo.id) + ) + workspaces = [] + for ws in ws_result.scalars().all(): + # Count instances + inst_result = await session.execute( + select(func.count()).where(ToolInstance.workspace_id == ws.id) + ) + instance_count = inst_result.scalar() or 0 + workspaces.append({ + "id": str(ws.id), + "name": ws.name, + "branch": ws.branch, + "status": ws.status, + "instance_count": instance_count, + }) + + repositories.append({ + "id": str(repo.id), + "name": repo.name, + "remote_url": repo.remote_url, + "workspaces": workspaces, + }) + + enriched.append({ + "id": str(project.id), + "name": project.name, + "description": project.description, + "owner_id": str(project.owner_id), + "repositories": repositories, + "created_at": project.created_at.isoformat() if project.created_at else None, + }) + + return enriched @router.get( diff --git a/apps/api/src/api/workspace_files.py b/apps/api/src/api/workspace_files.py new file mode 100644 index 0000000..ab29e59 --- /dev/null +++ b/apps/api/src/api/workspace_files.py @@ -0,0 +1,114 @@ +"""Workspace file API endpoints.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.workspace import Workspace +from src.services.file_service import FileService + +router = APIRouter(prefix="/workspaces/{workspace_id}/files") + + +async def _get_workspace( + session: AsyncSession, + workspace_id: uuid.UUID, + user_id: uuid.UUID, +) -> Workspace: + from sqlalchemy import select + + result = await session.execute( + select(Workspace).where( + Workspace.id == workspace_id, + Workspace.user_id == user_id, + ) + ) + workspace = result.scalar_one_or_none() + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + return workspace + + +@router.get("/") +async def list_files( + workspace_id: uuid.UUID, + path: str = "", + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """List files in a workspace directory.""" + workspace = await _get_workspace(session, workspace_id, user_id) + service = FileService() + try: + entries = service.list_directory(workspace, path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return { + "entries": [ + { + "name": e.name, + "path": e.path, + "type": e.type, + "size": e.size, + } + for e in entries + ], + } + + +@router.get("/content") +async def get_file_content( + workspace_id: uuid.UUID, + path: str, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get the content of a text file.""" + workspace = await _get_workspace(session, workspace_id, user_id) + service = FileService() + try: + content = service.read_file(workspace, path) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return {"content": content, "path": path} + + +@router.post("/content") +async def write_file( + workspace_id: uuid.UUID, + data: dict, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Write a file and optionally commit.""" + workspace = await _get_workspace(session, workspace_id, user_id) + service = FileService() + + file_path = data.get("path", "").strip() + content = data.get("content", "") + commit_message = data.get("message", "").strip() + + if not file_path: + raise HTTPException(status_code=400, detail="File path is required") + + try: + service.write_file(workspace, file_path, content) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if commit_message: + from src.services.git_operations import GitOperations + + git = GitOperations(workspace) + try: + await git.commit(commit_message) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "saved", "path": file_path} diff --git a/apps/api/src/api/workspace_git.py b/apps/api/src/api/workspace_git.py new file mode 100644 index 0000000..0a8177c --- /dev/null +++ b/apps/api/src/api/workspace_git.py @@ -0,0 +1,203 @@ +"""Workspace git API endpoints.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.workspace import Workspace +from src.services.git_operations import GitOperations + +router = APIRouter(prefix="/workspaces/{workspace_id}/git") + + +async def _get_workspace( + session: AsyncSession, + workspace_id: uuid.UUID, + user_id: uuid.UUID, +) -> Workspace: + from sqlalchemy import select + + result = await session.execute( + select(Workspace).where( + Workspace.id == workspace_id, + Workspace.user_id == user_id, + ) + ) + workspace = result.scalar_one_or_none() + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + return workspace + + +@router.get("/status") +async def git_status( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get git status for the workspace.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + status = await git.status() + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return { + "branch": status.branch, + "modified": status.modified, + "added": status.added, + "deleted": status.deleted, + "untracked": status.untracked, + "ahead": status.ahead, + "behind": status.behind, + } + + +@router.get("/branches") +async def git_branches( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """List branches for the workspace.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + branches, current = await git.branches() + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return { + "branches": branches, + "current_branch": current, + } + + +@router.post("/commit") +async def git_commit( + workspace_id: uuid.UUID, + data: dict, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Stage all changes and commit.""" + workspace = await _get_workspace(session, workspace_id, user_id) + message = data.get("message", "").strip() + if not message: + raise HTTPException(status_code=400, detail="Commit message is required") + + git = GitOperations(workspace) + try: + await git.commit(message) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "committed"} + + +@router.post("/push") +async def git_push( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Push current branch.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + await git.push() + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "pushed"} + + +@router.post("/pull") +async def git_pull( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Pull current branch.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + await git.pull() + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "pulled"} + + +@router.post("/fetch") +async def git_fetch( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Fetch from origin.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + await git.fetch() + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return {"status": "fetched"} + + +@router.post("/checkout") +async def git_checkout( + workspace_id: uuid.UUID, + data: dict, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Checkout a branch.""" + workspace = await _get_workspace(session, workspace_id, user_id) + branch = data.get("branch", "").strip() + if not branch: + raise HTTPException(status_code=400, detail="Branch name is required") + + git = GitOperations(workspace) + try: + await git.checkout(branch) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + workspace.branch = branch + await session.commit() + + return {"status": "checked_out", "branch": branch} + + +@router.get("/history") +async def git_history( + workspace_id: uuid.UUID, + path: str | None = None, + limit: int = 50, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get commit history.""" + workspace = await _get_workspace(session, workspace_id, user_id) + git = GitOperations(workspace) + try: + commits = await git.history(path, limit) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return { + "commits": [ + { + "hash": c.hash, + "message": c.message, + "author": c.author, + "date": c.date, + } + for c in commits + ], + } diff --git a/apps/api/src/api/workspace_instances.py b/apps/api/src/api/workspace_instances.py new file mode 100644 index 0000000..aaaea88 --- /dev/null +++ b/apps/api/src/api/workspace_instances.py @@ -0,0 +1,60 @@ +"""Workspace instance API endpoints.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.tool_instance import ToolInstance +from src.models.workspace import Workspace + +router = APIRouter(prefix="/workspaces/{workspace_id}/instances") + + +async def _get_workspace( + session: AsyncSession, + workspace_id: uuid.UUID, + user_id: uuid.UUID, +) -> Workspace: + result = await session.execute( + select(Workspace).where( + Workspace.id == workspace_id, + Workspace.user_id == user_id, + ) + ) + workspace = result.scalar_one_or_none() + if not workspace: + raise HTTPException(status_code=404, detail="Workspace not found") + return workspace + + +@router.get("/") +async def list_workspace_instances( + workspace_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> list[dict]: + """List tool instances using this workspace.""" + await _get_workspace(session, workspace_id, user_id) + result = await session.execute( + select(ToolInstance) + .where(ToolInstance.workspace_id == workspace_id) + .order_by(ToolInstance.created_at.desc()) + ) + instances = result.scalars().all() + + return [ + { + "id": str(i.id), + "name": i.name, + "display_name": i.display_name, + "status": i.status, + "tool_type_id": str(i.tool_type_id), + "url": i.url, + "port": i.port, + "created_at": i.created_at.isoformat() if i.created_at else None, + } + for i in instances + ] diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py index 1c20c89..0f8f4ad 100644 --- a/apps/api/src/api/workspaces.py +++ b/apps/api/src/api/workspaces.py @@ -53,7 +53,7 @@ async def list_all_workspaces( "repo_id": str(ws.repo_id), "repo_name": repo_name or "", "project_id": str(project_id) if project_id else "", - "project_name": "", # Could join with Project if needed + "project_name": "", "user_id": str(ws.user_id), "branch": ws.branch, "path": ws.path, @@ -67,6 +67,59 @@ async def list_all_workspaces( ] +@all_workspaces_router.post("/") +async def create_workspace_top_level( + data: dict, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Create a workspace directly (no nested project/repo path).""" + repo_id_str = data.get("repo_id", "").strip() + if not repo_id_str: + raise HTTPException(status_code=400, detail="repo_id is required") + + try: + repo_id = uuid.UUID(repo_id_str) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc + + repo = await session.get(GitRepository, repo_id) + if not repo or repo.owner_id != user_id: + raise HTTPException(status_code=404, detail="Repository not found") + + name = data.get("name", "").strip() + branch = data.get("branch", "main").strip() + + if not name: + raise HTTPException(status_code=400, detail="Workspace name is required") + + manager = WorkspaceManager() + try: + workspace = await manager.create(repo, user_id, name, branch) + session.add(workspace) + await session.commit() + except Exception as exc: + await session.rollback() + logger.error("Failed to create workspace: %s", exc) + raise HTTPException( + status_code=409, + detail="Workspace name already exists for this repository", + ) from exc + + await session.refresh(workspace) + return { + "id": str(workspace.id), + "name": workspace.name, + "repo_id": str(workspace.repo_id), + "branch": workspace.branch, + "path": workspace.path, + "status": workspace.status, + "created_at": workspace.created_at.isoformat() + if workspace.created_at + else None, + } + + @router.get("/") async def list_workspaces( project_id: uuid.UUID, diff --git a/apps/api/src/main.py b/apps/api/src/main.py index b10e897..d94f8a5 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -24,6 +24,9 @@ from src.api.tool_types import router as tool_types_router from src.api.notifications import router as notifications_router from src.api.user_config import router as user_config_router from src.api.users import router as users_router +from src.api.workspace_files import router as workspace_files_router +from src.api.workspace_git import router as workspace_git_router +from src.api.workspace_instances import router as workspace_instances_router from src.api.workspaces import all_workspaces_router, router as workspaces_router from src.config import Settings from src.models.notification import Notification # noqa: F401 – Alembic model discovery @@ -162,4 +165,7 @@ app.include_router(events_router) app.include_router(notifications_router) app.include_router(all_workspaces_router) app.include_router(workspaces_router) +app.include_router(workspace_files_router) +app.include_router(workspace_git_router) +app.include_router(workspace_instances_router) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/apps/api/src/services/file_service.py b/apps/api/src/services/file_service.py new file mode 100644 index 0000000..072caa6 --- /dev/null +++ b/apps/api/src/services/file_service.py @@ -0,0 +1,128 @@ +"""File operations scoped to a workspace directory.""" + +import logging +import os +from dataclasses import dataclass + +from src.models.workspace import Workspace + +logger = logging.getLogger(__name__) + + +@dataclass +class FileEntry: + """A single file or directory entry.""" + + name: str + path: str + type: str # "file" or "directory" + size: int | None = None + + +class FileService: + """Read and write files within a workspace directory.""" + + def list_directory( + self, + workspace: Workspace, + relative_path: str = "", + ) -> list[FileEntry]: + """List entries in a workspace directory. + + Args: + workspace: The workspace to list files in. + relative_path: Path relative to workspace root. + + Returns: + List of file entries sorted by name (directories first). + """ + abs_path = os.path.join(workspace.path, relative_path) + abs_path = os.path.normpath(abs_path) + + # Security: ensure we stay within workspace + if not abs_path.startswith(os.path.normpath(workspace.path)): + raise ValueError("Path escapes workspace directory") + + if not os.path.exists(abs_path): + return [] + + entries = [] + for item in sorted(os.listdir(abs_path)): + full = os.path.join(abs_path, item) + rel = os.path.join(relative_path, item) if relative_path else item + is_dir = os.path.isdir(full) + size = os.path.getsize(full) if os.path.isfile(full) else None + entries.append( + FileEntry( + name=item, + path=rel.replace("\\", "/"), + type="directory" if is_dir else "file", + size=size, + ) + ) + + # Directories first, then files, both alphabetical + entries.sort(key=lambda e: (0 if e.type == "directory" else 1, e.name.lower())) + return entries + + def read_file(self, workspace: Workspace, relative_path: str) -> str: + """Read a text file from the workspace. + + Args: + workspace: The workspace to read from. + relative_path: Path relative to workspace root. + + Returns: + File contents as string. + + Raises: + ValueError: If path escapes workspace or file is binary. + FileNotFoundError: If file does not exist. + """ + abs_path = self._resolve_path(workspace, relative_path) + + if not os.path.isfile(abs_path): + raise FileNotFoundError(f"Not a file: {relative_path}") + + # Basic binary check — read first 8KB and look for null bytes + with open(abs_path, "rb") as f: + chunk = f.read(8192) + if b"\x00" in chunk: + raise ValueError("Binary files cannot be viewed") + + with open(abs_path, encoding="utf-8", errors="replace") as f: + return f.read() + + def write_file( + self, + workspace: Workspace, + relative_path: str, + content: str, + ) -> None: + """Write a text file to the workspace. + + Args: + workspace: The workspace to write to. + relative_path: Path relative to workspace root. + content: File contents. + + Raises: + ValueError: If path escapes workspace. + """ + abs_path = self._resolve_path(workspace, relative_path) + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + + with open(abs_path, "w", encoding="utf-8") as f: + f.write(content) + + logger.info("Wrote file %s in workspace %s", relative_path, workspace.id) + + def _resolve_path(self, workspace: Workspace, relative_path: str) -> str: + """Resolve a relative path to absolute, with security check.""" + abs_path = os.path.normpath(os.path.join(workspace.path, relative_path)) + workspace_root = os.path.normpath(workspace.path) + + if not abs_path.startswith(workspace_root): + raise ValueError("Path escapes workspace directory") + + return abs_path diff --git a/apps/api/src/services/git_operations.py b/apps/api/src/services/git_operations.py new file mode 100644 index 0000000..45b7134 --- /dev/null +++ b/apps/api/src/services/git_operations.py @@ -0,0 +1,225 @@ +"""Git commands scoped to a workspace directory.""" + +import asyncio +import logging +from dataclasses import dataclass + +from src.models.workspace import Workspace + +logger = logging.getLogger(__name__) + + +@dataclass +class GitStatus: + """Parsed git status output.""" + + branch: str + modified: list[str] + added: list[str] + deleted: list[str] + untracked: list[str] + ahead: int = 0 + behind: int = 0 + + +@dataclass +class Commit: + """A single git commit.""" + + hash: str + message: str + author: str + date: str + + +class GitOperations: + """Run git commands within a workspace directory.""" + + def __init__(self, workspace: Workspace) -> None: + self.cwd = workspace.path + self.branch = workspace.branch + + async def _run(self, *cmd: str) -> tuple[int, str, str]: + """Run a git command and return (returncode, stdout, stderr).""" + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + return proc.returncode or 0, stdout.decode(), stderr.decode() + + async def status(self) -> GitStatus: + """Get git status for the workspace.""" + returncode, stdout, _ = await self._run( + "git", "-C", self.cwd, "status", "--porcelain", "-b" + ) + + modified: list[str] = [] + added: list[str] = [] + deleted: list[str] = [] + untracked: list[str] = [] + branch = self.branch + ahead = 0 + behind = 0 + + for line in stdout.splitlines(): + if line.startswith("##"): + # Branch info line + branch_info = line[3:].strip() + if "..." in branch_info: + branch = branch_info.split("...")[0] + if "[ahead " in branch_info: + ahead_str = branch_info.split("[ahead ")[1].split("]")[0] + ahead = int(ahead_str.split(",")[0]) + if "[behind " in branch_info: + behind_str = branch_info.split("[behind ")[1].split("]")[0] + behind = int(behind_str.split(",")[0]) + else: + branch = branch_info + continue + + if len(line) < 3: + continue + + status_code = line[:2] + file_path = line[3:] + + # XY format: X = index status, Y = working tree status + if status_code == "??": + untracked.append(file_path) + elif status_code[1] == "D" or status_code[0] == "D": + deleted.append(file_path) + elif status_code[0] == "A" or status_code[1] == "A": + added.append(file_path) + else: + modified.append(file_path) + + return GitStatus( + branch=branch, + modified=modified, + added=added, + deleted=deleted, + untracked=untracked, + ahead=ahead, + behind=behind, + ) + + async def commit(self, message: str) -> None: + """Stage all changes and commit.""" + rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A") + if rc != 0: + raise RuntimeError(f"Git add failed: {err}") + + rc, _, err = await self._run( + "git", "-C", self.cwd, "commit", "-m", message + ) + if rc != 0: + raise RuntimeError(f"Git commit failed: {err}") + + logger.info("Committed in workspace: %s", self.cwd) + + async def push(self) -> None: + """Push current branch to origin.""" + rc, _, err = await self._run( + "git", "-C", self.cwd, "push", "origin", self.branch + ) + if rc != 0: + raise RuntimeError(f"Git push failed: {err}") + + logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd) + + async def pull(self) -> None: + """Pull current branch from origin.""" + rc, _, err = await self._run( + "git", "-C", self.cwd, "pull", "origin", self.branch + ) + if rc != 0: + raise RuntimeError(f"Git pull failed: {err}") + + logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd) + + async def fetch(self) -> None: + """Fetch from origin.""" + rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin") + if rc != 0: + raise RuntimeError(f"Git fetch failed: {err}") + + logger.info("Fetched origin for workspace: %s", self.cwd) + + async def checkout(self, branch: str) -> None: + """Checkout a branch.""" + rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch) + if rc != 0: + raise RuntimeError(f"Git checkout failed: {err}") + + self.branch = branch + logger.info("Checked out branch %s in workspace: %s", branch, self.cwd) + + async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]: + """Get commit history. + + Args: + path: Optional file path to filter history. + limit: Maximum number of commits. + + Returns: + List of commits. + """ + cmd = [ + "git", + "-C", + self.cwd, + "log", + f"--max-count={limit}", + "--pretty=format:%H|%s|%an|%ad", + "--date=iso", + ] + if path: + cmd.extend(["--", path]) + + rc, stdout, err = await self._run(*cmd) + if rc != 0: + raise RuntimeError(f"Git log failed: {err}") + + commits = [] + for line in stdout.strip().splitlines(): + parts = line.split("|", 3) + if len(parts) >= 4: + commits.append( + Commit( + hash=parts[0], + message=parts[1], + author=parts[2], + date=parts[3], + ) + ) + + return commits + + async def branches(self) -> tuple[list[str], str]: + """List all branches and current branch. + + Returns: + Tuple of (all_branches, current_branch). + """ + rc, stdout, err = await self._run( + "git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)" + ) + if rc != 0: + raise RuntimeError(f"Git branch failed: {err}") + + branches = [] + current = self.branch + for line in stdout.strip().splitlines(): + line = line.strip() + if line.startswith("HEAD") or line.endswith("/HEAD"): + continue + if line.startswith("remotes/origin/"): + branch_name = line.replace("remotes/origin/", "") + if branch_name not in branches: + branches.append(branch_name) + elif line and line not in branches: + branches.append(line) + + return branches, current diff --git a/apps/api/tests/unit/test_file_service.py b/apps/api/tests/unit/test_file_service.py new file mode 100644 index 0000000..c799cc4 --- /dev/null +++ b/apps/api/tests/unit/test_file_service.py @@ -0,0 +1,84 @@ +"""Unit tests for FileService.""" + +import os +import tempfile + +import pytest + +from src.models.workspace import Workspace +from src.services.file_service import FileService + + +@pytest.fixture +def temp_workspace(): + """Create a temporary workspace directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + ws = Workspace( + id="00000000-0000-0000-0000-000000000001", + name="test-ws", + repo_id="00000000-0000-0000-0000-000000000002", + user_id="00000000-0000-0000-0000-000000000003", + branch="main", + path=tmpdir, + ) + yield ws + + +class TestFileService: + """Tests for FileService.""" + + def test_list_directory_empty(self, temp_workspace: Workspace): + """Returns empty list for empty directory.""" + service = FileService() + entries = service.list_directory(temp_workspace) + assert entries == [] + + def test_list_directory_with_files(self, temp_workspace: Workspace): + """Returns entries sorted (dirs first, then files).""" + # Create files and dirs + os.makedirs(os.path.join(temp_workspace.path, "src")) + with open(os.path.join(temp_workspace.path, "README.md"), "w") as f: + f.write("# Test") + with open(os.path.join(temp_workspace.path, "main.py"), "w") as f: + f.write("print('hello')") + + service = FileService() + entries = service.list_directory(temp_workspace) + + assert len(entries) == 3 + assert entries[0].name == "src" and entries[0].type == "directory" + assert entries[1].name == "main.py" and entries[1].type == "file" + assert entries[2].name == "README.md" and entries[2].type == "file" + + def test_read_file(self, temp_workspace: Workspace): + """Reads text file content.""" + with open(os.path.join(temp_workspace.path, "test.txt"), "w") as f: + f.write("hello world") + + service = FileService() + content = service.read_file(temp_workspace, "test.txt") + assert content == "hello world" + + def test_read_binary_file_rejected(self, temp_workspace: Workspace): + """Rejects binary files.""" + with open(os.path.join(temp_workspace.path, "binary.bin"), "wb") as f: + f.write(b"\x00\x01\x02") + + service = FileService() + with pytest.raises(ValueError, match="Binary"): + service.read_file(temp_workspace, "binary.bin") + + def test_write_file(self, temp_workspace: Workspace): + """Writes file to workspace.""" + service = FileService() + service.write_file(temp_workspace, "nested/file.txt", "content") + + assert os.path.exists(os.path.join(temp_workspace.path, "nested", "file.txt")) + with open(os.path.join(temp_workspace.path, "nested", "file.txt")) as f: + assert f.read() == "content" + + def test_path_escapes_workspace(self, temp_workspace: Workspace): + """Rejects paths that escape workspace directory.""" + service = FileService() + with pytest.raises(ValueError, match="escapes"): + service.list_directory(temp_workspace, "../outside") diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx index bf22f14..7a9f8cc 100644 --- a/apps/web/src/pages/workspaces.tsx +++ b/apps/web/src/pages/workspaces.tsx @@ -13,7 +13,10 @@ import type { Workspace } from "../types/workspace"; export function WorkspacesPage() { const [showCreate, setShowCreate] = useState(false); const [startWorkspace, setStartWorkspace] = useState(null); - const [createTarget, setCreateTarget] = useState<{ projectId: string; repoId: string } | null>(null); + const [createTarget, setCreateTarget] = useState<{ + projectId: string; + repoId: string; + } | null>(null); const { workspaces, loading, error, refresh } = useWorkspaces(); const actions = useWorkspaceActions(); @@ -27,11 +30,21 @@ export function WorkspacesPage() { }; const handleDelete = async (workspace: Workspace) => { - await actions.delete(workspace.project_id, workspace.repo_id, workspace, refresh); + await actions.delete( + workspace.project_id, + workspace.repo_id, + workspace, + refresh, + ); }; const handleSync = async (workspace: Workspace) => { - await actions.sync(workspace.project_id, workspace.repo_id, workspace, refresh); + await actions.sync( + workspace.project_id, + workspace.repo_id, + workspace, + refresh, + ); }; const handleStartTool = async ( @@ -52,7 +65,12 @@ export function WorkspacesPage() { [], startWorkspace.id, ); - await startInstance(startWorkspace.project_id, startWorkspace.repo_id, instance.id, configProfileId); + await startInstance( + startWorkspace.project_id, + startWorkspace.repo_id, + instance.id, + configProfileId, + ); setStartWorkspace(null); await refresh(); } catch (err) { @@ -72,20 +90,23 @@ export function WorkspacesPage() { > - +
@@ -96,7 +117,10 @@ export function WorkspacesPage() { projectId={createTarget.projectId} repoId={createTarget.repoId} onSubmit={handleCreate} - onCancel={() => { setShowCreate(false); setCreateTarget(null); }} + onCancel={() => { + setShowCreate(false); + setCreateTarget(null); + }} /> )} diff --git a/openspec/changes/workspace-first-ui/design.md b/openspec/changes/workspace-first-ui/design.md new file mode 100644 index 0000000..2176baf --- /dev/null +++ b/openspec/changes/workspace-first-ui/design.md @@ -0,0 +1,694 @@ +# Design: Workspace-First UI Refresh + +## Status + +| Field | Value | +|---|---| +| Phase | **Design** | +| Based on | [Spec](spec.md) | +| Next | Tasks | + +## Backend Design + +### Directory Structure + +``` +apps/api/src/ +├── api/ +│ ├── workspace_files.py # NEW: GET/POST /workspaces/{id}/files +│ ├── workspace_git.py # NEW: /workspaces/{id}/git/* +│ ├── workspace_instances.py # NEW: /workspaces/{id}/instances +│ └── workspaces.py # MODIFIED: add repo_id to POST, enrich responses +├── services/ +│ ├── git_operations.py # NEW: workspace-scoped git commands +│ └── file_service.py # NEW: workspace file operations +└── models/ + └── workspace.py # UNCHANGED +``` + +### Service: FileService + +```python +class FileService: + """Read/write files within a workspace directory.""" + + def list_directory(self, workspace: Workspace, path: str = "") -> list[FileEntry]: + abs_path = os.path.join(workspace.path, path) + entries = [] + for item in os.listdir(abs_path): + full = os.path.join(abs_path, item) + stat = os.lstat(full) + entries.append(FileEntry( + name=item, + path=os.path.join(path, item), + type="directory" if os.path.isdir(full) else "file", + size=stat.st_size if os.path.isfile(full) else None, + )) + return entries + + def read_file(self, workspace: Workspace, path: str) -> str: + abs_path = os.path.join(workspace.path, path) + with open(abs_path, "r") as f: + return f.read() + + def write_file(self, workspace: Workspace, path: str, content: str) -> None: + abs_path = os.path.join(workspace.path, path) + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + with open(abs_path, "w") as f: + f.write(content) +``` + +### Service: GitOperations + +```python +class GitOperations: + """Git commands scoped to a workspace directory.""" + + def __init__(self, workspace: Workspace) -> None: + self.cwd = workspace.path + self.branch = workspace.branch + + async def status(self) -> GitStatus: + proc = await asyncio.create_subprocess_exec( + "git", "-C", self.cwd, "status", "--porcelain", + stdout=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + return self._parse_status(stdout.decode()) + + async def commit(self, message: str) -> None: + await self._run("git", "-C", self.cwd, "add", "-A") + await self._run("git", "-C", self.cwd, "commit", "-m", message) + + async def push(self) -> None: + await self._run("git", "-C", self.cwd, "push", "origin", self.branch) + + async def pull(self) -> None: + await self._run("git", "-C", self.cwd, "pull", "origin", self.branch) + + async def fetch(self) -> None: + await self._run("git", "-C", self.cwd, "fetch", "origin") + + async def checkout(self, branch: str) -> None: + await self._run("git", "-C", self.cwd, "checkout", branch) + + async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]: + cmd = ["git", "-C", self.cwd, "log", f"--max-count={limit}", "--pretty=format:%H|%s|%an|%ad"] + if path: + cmd.extend(["--", path]) + stdout = await self._run_stdout(*cmd) + return self._parse_log(stdout) +``` + +### API: Workspace Files + +```python +@router.get("/{workspace_id}/files") +async def list_files(workspace_id: uuid.UUID, path: str = ""): + workspace = await get_workspace(workspace_id) + entries = FileService().list_directory(workspace, path) + return {"entries": [e.dict() for e in entries]} + +@router.get("/{workspace_id}/files/content") +async def get_file_content(workspace_id: uuid.UUID, path: str): + workspace = await get_workspace(workspace_id) + content = FileService().read_file(workspace, path) + return {"content": content, "path": path} + +@router.post("/{workspace_id}/files/content") +async def write_file(workspace_id: uuid.UUID, data: dict): + workspace = await get_workspace(workspace_id) + FileService().write_file(workspace, data["path"], data["content"]) + if data.get("message"): + await GitOperations(workspace).commit(data["message"]) + return {"status": "saved"} +``` + +### API: Workspace Git + +```python +@router.get("/{workspace_id}/git/status") +async def git_status(workspace_id: uuid.UUID): + workspace = await get_workspace(workspace_id) + return await GitOperations(workspace).status() + +@router.post("/{workspace_id}/git/commit") +async def git_commit(workspace_id: uuid.UUID, data: dict): + workspace = await get_workspace(workspace_id) + await GitOperations(workspace).commit(data["message"]) + return {"status": "committed"} + +@router.post("/{workspace_id}/git/push") +async def git_push(workspace_id: uuid.UUID): + workspace = await get_workspace(workspace_id) + await GitOperations(workspace).push() + return {"status": "pushed"} + +@router.post("/{workspace_id}/git/pull") +async def git_pull(workspace_id: uuid.UUID): + workspace = await get_workspace(workspace_id) + await GitOperations(workspace).pull() + return {"status": "pulled"} + +@router.post("/{workspace_id}/git/fetch") +async def git_fetch(workspace_id: uuid.UUID): + workspace = await get_workspace(workspace_id) + await GitOperations(workspace).fetch() + return {"status": "fetched"} + +@router.post("/{workspace_id}/git/checkout") +async def git_checkout(workspace_id: uuid.UUID, data: dict): + workspace = await get_workspace(workspace_id) + await GitOperations(workspace).checkout(data["branch"]) + workspace.branch = data["branch"] + await session.commit() + return {"status": "checked_out", "branch": data["branch"]} + +@router.get("/{workspace_id}/git/history") +async def git_history(workspace_id: uuid.UUID, path: str | None = None, limit: int = 50): + workspace = await get_workspace(workspace_id) + return await GitOperations(workspace).history(path, limit) +``` + +### API: Workspace Instances + +```python +@router.get("/{workspace_id}/instances") +async def list_workspace_instances(workspace_id: uuid.UUID, session: AsyncSession): + result = await session.execute( + select(ToolInstance).where(ToolInstance.workspace_id == workspace_id) + ) + return [instance_to_dict(i) for i in result.scalars().all()] + +@router.post("/{workspace_id}/instances") +async def create_workspace_instance( + workspace_id: uuid.UUID, + data: dict, + user_id: uuid.UUID, + session: AsyncSession, +): + workspace = await get_workspace(workspace_id) + # Reuse existing create_instance logic but with workspace_id pre-set + return await create_instance_internal( + project_id=workspace.repo.project_id, + repo_id=workspace.repo_id, + tool_type_id=data["tool_type_id"], + workspace_id=workspace_id, + display_name=data.get("display_name"), + config_profile_id=data.get("config_profile_id"), + ) +``` + +### Modified: Projects API + +```python +@router.get("/") +async def list_projects(user_id: uuid.UUID, session: AsyncSession): + result = await session.execute( + select(Project).where(Project.owner_id == user_id).order_by(Project.created_at.desc()) + ) + projects = [] + for project in result.scalars().all(): + repos = await session.execute( + select(GitRepository).where(GitRepository.project_id == project.id) + ) + repo_list = [] + for repo in repos.scalars().all(): + workspaces = await session.execute( + select(Workspace).where(Workspace.repo_id == repo.id) + ) + repo_list.append({ + "id": str(repo.id), + "name": repo.name, + "remote_url": repo.remote_url, + "workspaces": [ + { + "id": str(ws.id), + "name": ws.name, + "branch": ws.branch, + "status": ws.status, + "instance_count": ..., + } + for ws in workspaces.scalars().all() + ], + }) + projects.append({ + "id": str(project.id), + "name": project.name, + "description": project.description, + "repositories": repo_list, + }) + return {"projects": projects} +``` + +## Frontend Design + +### Directory Structure + +``` +apps/web/src/ +├── pages/ +│ ├── workspace-detail.tsx # NEW: /workspaces/:id +│ ├── projects.tsx # MODIFIED: inline repos + workspaces +│ └── workspaces.tsx # MODIFIED: link to detail +├── components/ +│ ├── workspace/ +│ │ ├── workspace-header.tsx # NEW: breadcrumb + actions +│ │ ├── workspace-tabs.tsx # NEW: tab bar component +│ │ ├── workspace-file-panel.tsx # NEW: Files tab (tree + viewer + git toolbar) +│ │ ├── workspace-git-panel.tsx # NEW: Git tab (history + diff) +│ │ ├── workspace-tools-panel.tsx # NEW: Tools tab (instances + spawn) +│ │ ├── workspace-settings-panel.tsx # NEW: Settings tab +│ │ ├── git-toolbar.tsx # NEW: collapsible git toolbar +│ │ ├── file-tree.tsx # NEW: extracted from repo-workspace +│ │ ├── file-viewer.tsx # NEW: extracted from repo-workspace +│ │ └── start-tool-modal.tsx # EXISTING: move to workspace/ +│ ├── project/ +│ │ ├── project-card.tsx # NEW: card with inline repos +│ │ ├── repo-section.tsx # NEW: expandable repo + workspaces +│ │ ├── workspace-chip.tsx # NEW: small workspace card +│ │ └── new-workspace-inline.tsx # NEW: inline form +│ └── app-shell.tsx # MODIFIED: nav order +├── hooks/ +│ ├── use-workspace-files.ts # NEW +│ ├── use-workspace-git.ts # NEW +│ ├── use-workspace-instances.ts # NEW +│ └── use-projects-enriched.ts # NEW: projects with repos + workspaces +├── api/ +│ ├── workspace-files.ts # NEW +│ ├── workspace-git.ts # NEW +│ ├── workspace-instances.ts # NEW +│ └── projects.ts # MODIFIED: enriched response +└── router.tsx # MODIFIED: routes +``` + +### Component: WorkspaceDetailPage + +```tsx +export function WorkspaceDetailPage() { + const { workspaceId } = useParams(); + const [activeTab, setActiveTab] = useState("files"); + const { workspace, loading } = useWorkspace(workspaceId); + + if (loading) return ; + if (!workspace) return ; + + return ( +
+ + +
+ {activeTab === "files" && } + {activeTab === "git" && } + {activeTab === "tools" && } + {activeTab === "settings" && } +
+
+ ); +} +``` + +### Component: WorkspaceFilePanel + +```tsx +export function WorkspaceFilePanel({ workspace }: { workspace: Workspace }) { + const [selectedPath, setSelectedPath] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const { entries, loading } = useWorkspaceFiles(workspace.id); + const { content } = useWorkspaceFileContent(workspace.id, selectedPath); + const { status } = useWorkspaceGitStatus(workspace.id); + + return ( +
+ +
+ + setIsEditing(true)} + onSave={async (newContent, message) => { + await saveWorkspaceFile(workspace.id, selectedPath, newContent, message); + setIsEditing(false); + }} + /> +
+
+ ); +} +``` + +### Component: GitToolbar + +```tsx +export function GitToolbar({ workspace, status }: GitToolbarProps) { + const [expanded, setExpanded] = useState(false); + const [commitMessage, setCommitMessage] = useState(""); + + return ( +
+
+ M {status.modified.length} + A {status.added.length} + D {status.deleted.length} + + + + +
+ {expanded && ( +
+