Files
headquarter/openspec/changes/archive/2026-06-12-completed-changes-archive/working-copies/design.md
T
Developer 8c1948d226 chore: archive final 4 completed OpenSpec changes
Move the following completed changes from openspec/changes/ to
openspec/changes/archive/2026-06-12-completed-changes-archive/:
- multi-session-terminal-ux
- reorganize-long-files
- working-copies
- workspace-first-ui

Update parent and archive .pi-map*.md indexes to reflect the move and
remove the transient active-changes-archive grouping.

openspec/changes/ now contains only the archive/ directory.
2026-06-12 21:11:11 +00:00

18 KiB

Design: Workspace-Based Tool Instances

Status

Field Value
Phase Design
Based on Spec
Next Tasks

Decision: No Migration

Existing tool instances will be left as-is. Users will create new workspaces and new tool instances. Old instances remain functional but read-only (no migration path). This simplifies the implementation significantly.

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                         Frontend                                 │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────────┐   │
│  │ Sidebar  │  │Workspaces│  │Create WS │  │Start Tool    │   │
│  │ (new)    │  │ List     │  │ Flow     │  │Modal         │   │
│  └──────────┘  └──────────┘  └──────────┘  └──────────────┘   │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                         Backend API                              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐ │
│  │Workspace CRUD│  │Workspace Sync│  │Instance Start (refact)│ │
│  │  /workspaces │  │  /sync       │  │  /start               │ │
│  └──────────────┘  └──────────────┘  └──────────────────────┘ │
│                              │                                  │
│  ┌───────────────────────────┼──────────────────────────────┐  │
│  │         GitService        │         WorkspaceService      │  │
│  │   (clone, fetch, pull)    │   (create, delete, sync)     │  │
│  └───────────────────────────┼──────────────────────────────┘  │
│                              │                                  │
│  ┌───────────────────────────┼──────────────────────────────┐  │
│  │         Docker Compose    │         File System           │  │
│  │   (mount workspace path)  │   /data/working-copies/...    │  │
│  └───────────────────────────┴──────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

Backend Design

Directory Structure

apps/api/src/
├── api/
│   ├── workspaces.py          # NEW: Workspace CRUD endpoints
│   └── tool_instances.py      # MODIFIED: use workspace_id
├── models/
│   ├── workspace.py           # NEW: Workspace model
│   └── tool_instance.py       # MODIFIED: add workspace_id
├── services/
│   ├── workspace_manager.py   # NEW: Workspace lifecycle
│   ├── git_service.py         # NEW: Git operations (clone, fetch, pull)
│   └── docker.py              # EXISTING: mount workspace path
└── alembic/versions/
    └── 2026_06_01_add_workspaces.py  # NEW migration

Model: Workspace

class Workspace(Base):
    __tablename__ = "workspaces"

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    repo_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("git_repositories.id"), nullable=False)
    user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
    branch: Mapped[str] = mapped_column(String(255), nullable=False, default="main")
    path: Mapped[str] = mapped_column(String(2048), nullable=False)
    status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready")
    last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now)
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now, onupdate=datetime.now)

    __table_args__ = (
        UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
    )

Service: WorkspaceManager

class WorkspaceManager:
    """Manages workspace lifecycle: create, delete, sync, validate."""

    BASE_PATH = "/data/working-copies"

    async def create(
        self,
        repo: GitRepository,
        user_id: uuid.UUID,
        name: str,
        branch: str = "main",
    ) -> Workspace:
        """Clone repo to workspace path and create DB record."""
        path = f"{self.BASE_PATH}/{repo.id}/{name}"
        # Clone repo
        await GitService.clone(repo.remote_url, branch, path)
        # Create record
        workspace = Workspace(...)
        return workspace

    async def delete(
        self,
        workspace: Workspace,
        force: bool = False,
    ) -> None:
        """Delete workspace and all associated tool instances."""
        instances = await self._get_running_instances(workspace)
        if instances and not force:
            raise WorkspaceHasInstancesError(instances)
        
        # Stop and delete all instances
        for instance in instances:
            await self._stop_and_delete_instance(instance)
        
        # Delete directory
        shutil.rmtree(workspace.path, ignore_errors=True)
        
        # Delete record
        await session.delete(workspace)

    async def sync(self, workspace: Workspace) -> SyncResult:
        """Fetch remote and detect deleted branches."""
        result = await GitService.fetch(workspace.path)
        if not GitService.branch_exists_remotely(workspace.path, workspace.branch):
            return SyncResult(branch_deleted=True)
        
        await GitService.pull(workspace.path, workspace.branch)
        workspace.last_sync_at = datetime.now()
        return SyncResult(branch_deleted=False)

Service: GitService

class GitService:
    """Git operations for workspace management."""

    @staticmethod
    async def clone(remote_url: str, branch: str, path: str) -> None:
        """Clone a repo to the given path."""
        cmd = ["git", "clone", "--branch", branch, "--single-branch", remote_url, path]
        # Run via asyncio subprocess

    @staticmethod
    async def fetch(path: str) -> None:
        """Fetch from origin."""
        cmd = ["git", "-C", path, "fetch", "origin"]

    @staticmethod
    async def pull(path: str, branch: str) -> None:
        """Pull latest changes."""
        cmd = ["git", "-C", path, "pull", "origin", branch]

    @staticmethod
    def branch_exists_remotely(path: str, branch: str) -> bool:
        """Check if a branch exists on the remote."""
        cmd = ["git", "-C", path, "ls-remote", "--heads", "origin", branch]
        # Return True if output is not empty

API: Workspaces

router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")

@router.post("/")
async def create_workspace(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    data: CreateWorkspaceRequest,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> WorkspaceResponse:
    repo = await get_repo(repo_id, user_id, session)
    workspace = await WorkspaceManager().create(repo, user_id, data.name, data.branch)
    session.add(workspace)
    await session.commit()
    return workspace

@router.delete("/{workspace_id}")
async def delete_workspace(
    workspace_id: uuid.UUID,
    force: bool = Query(False),
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    workspace = await get_workspace(workspace_id, user_id, session)
    try:
        await WorkspaceManager().delete(workspace, force=force)
    except WorkspaceHasInstancesError as exc:
        raise HTTPException(
            status_code=409,
            detail={
                "message": "Workspace has running tool instances",
                "instances": [{"id": str(i.id), "name": i.name} for i in exc.instances],
            },
        )
    return {"status": "deleted"}

@router.post("/{workspace_id}/sync")
async def sync_workspace(
    workspace_id: uuid.UUID,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> SyncResult:
    workspace = await get_workspace(workspace_id, user_id, session)
    result = await WorkspaceManager().sync(workspace)
    if result.branch_deleted:
        raise HTTPException(
            status_code=409,
            detail={
                "message": f"Branch '{workspace.branch}' was deleted from remote",
                "branch_deleted": True,
            },
        )
    return result

Updated: Instance Start

@router.post("/{instance_id}/start")
async def start_instance(
    instance_id: uuid.UUID,
    data: StartInstanceRequest,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    instance = await get_instance(instance_id, user_id, session)
    
    # Get workspace
    workspace = await session.get(Workspace, instance.workspace_id)
    if not workspace:
        raise HTTPException(400, "Workspace not found")
    
    # Mount workspace path instead of repo path
    repo_path = workspace.path
    
    # Generate compose with workspace mount
    compose_content = generate_compose(workspace, instance, tool_type)
    
    # ... rest of start logic

Frontend Design

Directory Structure

apps/web/src/
├── pages/
│   ├── workspaces.tsx           # NEW: Workspaces list page
│   └── workspace-detail.tsx     # NEW: Workspace detail page
├── components/
│   ├── workspace-card.tsx       # NEW: Workspace card component
│   ├── workspace-create-form.tsx # NEW: Create workspace form
│   ├── start-tool-modal.tsx     # NEW: Start tool on workspace modal
│   └── sidebar.tsx              # MODIFIED: add Workspaces nav
├── hooks/
│   ├── use-workspaces.ts        # NEW: Workspace data hook
│   └── use-workspace-actions.ts # NEW: Workspace CRUD actions
├── api/
│   └── workspaces.ts            # NEW: Workspace API client
└── types/
    └── workspace.ts             # NEW: Workspace types

Types

export interface Workspace {
  id: string;
  name: string;
  repo_id: string;
  repo_name: string;
  project_name: string;
  user_id: string;
  branch: string;
  path: string;
  status: "ready" | "syncing" | "error";
  last_sync_at: string | null;
  created_at: string;
  updated_at: string;
  instance_count: number;
}

export interface CreateWorkspaceRequest {
  name: string;
  branch: string;
}

export interface SyncResult {
  branch_deleted: boolean;
  pulled: boolean;
}

Component: WorkspaceCard

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

Component: Sidebar (updated)

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

export function useWorkspaceActions(options: { onRefresh: () => Promise<void> }) {
  const [loadingId, setLoadingId] = useState<string | null>(null);

  const handleDelete = useCallback(async (workspace: Workspace, force = false) => {
    setLoadingId(workspace.id);
    try {
      await deleteWorkspace(workspace.repo_id, workspace.id, force);
      await options.onRefresh();
    } catch (err) {
      const error = err as AxiosError<{ detail?: { instances?: Array<{id: string, name: string}> } }>;
      if (error.response?.status === 409 && !force) {
        const instances = error.response.data?.detail?.instances || [];
        const confirmed = confirm(
          `This workspace has ${instances.length} running tool instance(s):\n` +
          instances.map(i => `- ${i.name}`).join("\n") +
          `\n\nDelete workspace and all instances?`
        );
        if (confirmed) {
          await handleDelete(workspace, true);
        }
      }
    } finally {
      setLoadingId(null);
    }
  }, [options.onRefresh]);

  const handleSync = useCallback(async (workspace: Workspace) => {
    setLoadingId(workspace.id);
    try {
      const result = await syncWorkspace(workspace.repo_id, workspace.id);
      await options.onRefresh();
      return result;
    } catch (err) {
      const error = err as AxiosError<{ detail?: { branch_deleted?: boolean; message?: string } }>;
      if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
        const confirmed = confirm(
          `${error.response.data.detail.message}\n\nDelete this workspace?`
        );
        if (confirmed) {
          await handleDelete(workspace, true);
        }
      }
    } finally {
      setLoadingId(null);
    }
  }, [options.onRefresh, handleDelete]);

  return { loadingId, handleDelete, handleSync };
}

Compose Template Updates

Workspace Mount

All compose templates will mount the workspace path instead of the repo path:

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)