Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c051929f8c |
@@ -1,81 +0,0 @@
|
||||
"""add workspaces table
|
||||
|
||||
Revision ID: 2026_06_01_add_workspaces
|
||||
Revises: 2026_05_29_fix_code_server_bind_addr_port
|
||||
Create Date: 2026-06-01 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_06_01_add_workspaces"
|
||||
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr_port"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create workspaces table
|
||||
op.create_table(
|
||||
"workspaces",
|
||||
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
"repo_id",
|
||||
sa.Uuid(as_uuid=True),
|
||||
sa.ForeignKey("git_repositories.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Uuid(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("branch", sa.String(255), nullable=False, server_default="main"),
|
||||
sa.Column("path", sa.String(2048), nullable=False),
|
||||
sa.Column("status", sa.String(16), nullable=False, server_default="ready"),
|
||||
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
op.create_index("idx_workspaces_repo_id", "workspaces", ["repo_id"])
|
||||
op.create_index("idx_workspaces_user_id", "workspaces", ["user_id"])
|
||||
op.create_index("idx_workspaces_status", "workspaces", ["status"])
|
||||
|
||||
# Add workspace_id to tool_instances
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
sa.Uuid(as_uuid=True),
|
||||
sa.ForeignKey("workspaces.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_tool_instances_workspace_id", "tool_instances", ["workspace_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_tool_instances_workspace_id", table_name="tool_instances")
|
||||
op.drop_column("tool_instances", "workspace_id")
|
||||
op.drop_table("workspaces")
|
||||
@@ -45,29 +45,24 @@ from src.services.config_profile_resolver import (
|
||||
resolve_profile,
|
||||
)
|
||||
from src.services.docker import (
|
||||
check_tunnel_health,
|
||||
connect_container_to_network,
|
||||
ensure_instance_directory,
|
||||
execute_compose_command,
|
||||
find_free_port,
|
||||
get_backend_network_name,
|
||||
get_container_id,
|
||||
get_container_ip_on_network,
|
||||
get_container_logs,
|
||||
get_container_status,
|
||||
is_container_on_network,
|
||||
recreate_tunnel,
|
||||
render_compose_template,
|
||||
sort_volumes_by_specificity,
|
||||
start_cloudflared_tunnel,
|
||||
stop_cloudflared_tunnel,
|
||||
wait_for_container_running,
|
||||
write_compose_file,
|
||||
write_config_files,
|
||||
write_env_file,
|
||||
)
|
||||
from src.services.tunnel import (
|
||||
check_tunnel_health,
|
||||
recreate_tunnel,
|
||||
start_tunnel,
|
||||
stop_tunnel,
|
||||
)
|
||||
from src.services.docker_build import build_image
|
||||
from src.services.manifest_compiler import (
|
||||
compile_compose,
|
||||
@@ -427,9 +422,6 @@ 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'"
|
||||
)
|
||||
@@ -756,49 +748,6 @@ def _ensure_web_bind_address(
|
||||
return
|
||||
|
||||
|
||||
def _ensure_backend_network_in_compose(compose_path: str) -> None:
|
||||
"""Inject the backend network into the compose file so compose up attaches it.
|
||||
|
||||
Instead of running 'docker network connect' after container creation (which
|
||||
is prone to race conditions and silent failures), we declare the network in
|
||||
the compose file itself. Docker Compose then connects the container to the
|
||||
network atomically during 'docker compose up'.
|
||||
"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
compose_file = Path(compose_path)
|
||||
if not compose_file.exists():
|
||||
return
|
||||
|
||||
content = compose_file.read_text()
|
||||
compose_data = yaml.safe_load(content)
|
||||
|
||||
if not compose_data or "services" not in compose_data:
|
||||
return
|
||||
|
||||
network_name = get_backend_network_name()
|
||||
modified = False
|
||||
|
||||
for svc_config in compose_data["services"].values():
|
||||
existing = svc_config.get("networks", [])
|
||||
if network_name not in existing:
|
||||
svc_config["networks"] = existing + [network_name]
|
||||
modified = True
|
||||
break # Only modify first service
|
||||
|
||||
# Declare the network as external at the top level
|
||||
if "networks" not in compose_data:
|
||||
compose_data["networks"] = {}
|
||||
if network_name not in compose_data["networks"]:
|
||||
compose_data["networks"][network_name] = {"external": True}
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||
logger.info("Injected backend network '%s' into compose file", network_name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories/{repo_id}/instances",
|
||||
summary="Create tool instance",
|
||||
@@ -852,34 +801,9 @@ 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 (legacy path)
|
||||
if data.clone_mode == "clone" and not workspace:
|
||||
# Validate clone mode requirements
|
||||
if data.clone_mode == "clone":
|
||||
if not repo.remote_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -904,10 +828,8 @@ async def create_instance(
|
||||
# Find free port
|
||||
tool_port = find_free_port()
|
||||
|
||||
# Determine repo path based on workspace or clone mode
|
||||
if workspace:
|
||||
repo_path = workspace.path
|
||||
elif data.clone_mode == "clone":
|
||||
# Determine repo path based on clone mode
|
||||
if data.clone_mode == "clone":
|
||||
# Get SSH key for cloning
|
||||
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||
if ssh_key is None:
|
||||
@@ -1173,7 +1095,6 @@ 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
|
||||
@@ -1680,18 +1601,11 @@ 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 (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")
|
||||
# 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")
|
||||
|
||||
try:
|
||||
(
|
||||
@@ -1724,8 +1638,8 @@ async def start_instance(
|
||||
)
|
||||
else:
|
||||
# ── LEGACY FLOW ──────────────────────────────────────────
|
||||
# Mount SSH key for clone-mode instances (skip for workspace-based)
|
||||
if instance.clone_mode == "clone" and not instance.workspace_id:
|
||||
# Mount SSH key for clone-mode instances
|
||||
if instance.clone_mode == "clone":
|
||||
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)
|
||||
@@ -1774,7 +1688,6 @@ async def start_instance(
|
||||
|
||||
# Ensure predictable container name for tunnel connectivity
|
||||
_ensure_container_name_in_compose(instance.compose_path, instance.name)
|
||||
_ensure_backend_network_in_compose(instance.compose_path)
|
||||
|
||||
# Execute docker compose up with env file
|
||||
logger.debug(
|
||||
@@ -1810,9 +1723,15 @@ 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)
|
||||
connected = connect_container_to_network(expected_container_name, "backend")
|
||||
if connected:
|
||||
logger.debug("Successfully connected %s to backend network", expected_container_name)
|
||||
else:
|
||||
logger.warning("Failed to connect %s to backend network", expected_container_name)
|
||||
|
||||
# Verify container reached running state
|
||||
if instance.container_id:
|
||||
@@ -2033,11 +1952,12 @@ async def start_instance(
|
||||
"error": f"Tool type '{instance.tool_type_id}' not found",
|
||||
}
|
||||
|
||||
instance_port = tool_type.default_port or 0
|
||||
logger.debug(
|
||||
"Tool type for instance %s: name=%s, container_port=%s, interface_type=%s",
|
||||
"Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
|
||||
instance.id,
|
||||
tool_type.name,
|
||||
tool_type.default_port or 0,
|
||||
instance_port,
|
||||
tool_type.interface_type,
|
||||
)
|
||||
|
||||
@@ -2046,22 +1966,23 @@ async def start_instance(
|
||||
# Create temporary Cloudflare tunnel for public access
|
||||
try:
|
||||
logger.debug(
|
||||
"Creating tunnel for instance %s (container_port=%d)",
|
||||
"Creating temporary tunnel for instance %s (container=%s, port=%d)",
|
||||
instance.id,
|
||||
tool_type.default_port or 0,
|
||||
instance.container_name,
|
||||
instance_port,
|
||||
)
|
||||
tunnel_info = start_tunnel(
|
||||
instance_name=instance.name,
|
||||
container_port=tool_type.default_port or 0,
|
||||
tunnel_info = start_cloudflared_tunnel(
|
||||
container_name=instance.container_name or instance.name,
|
||||
port=instance_port,
|
||||
)
|
||||
instance.tunnel_id = tunnel_info["container_name"]
|
||||
instance.tunnel_id = tunnel_info["pid"]
|
||||
instance.public_url = tunnel_info["url"]
|
||||
instance.url = tunnel_info["url"]
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"Created tunnel for instance %s: container=%s, url=%s",
|
||||
"Created temporary tunnel for instance %s: pid=%s, url=%s",
|
||||
instance.id,
|
||||
tunnel_info["container_name"],
|
||||
tunnel_info["pid"],
|
||||
tunnel_info["url"],
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -2131,9 +2052,9 @@ async def stop_instance(
|
||||
# Stop Cloudflare tunnel if exists
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
stop_tunnel(instance.name)
|
||||
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
logger.debug(
|
||||
"Stopped tunnel for instance %s (container=%s)",
|
||||
"Stopped tunnel for instance %s (pid=%s)",
|
||||
instance.id,
|
||||
instance.tunnel_id,
|
||||
)
|
||||
@@ -2200,9 +2121,9 @@ async def restart_instance(
|
||||
# Stop old tunnel if exists
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
stop_tunnel(instance.name)
|
||||
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
logger.debug(
|
||||
"Stopped old tunnel for instance %s (container=%s)",
|
||||
"Stopped old tunnel for instance %s (pid=%s)",
|
||||
instance.id,
|
||||
instance.tunnel_id,
|
||||
)
|
||||
@@ -2245,7 +2166,6 @@ async def restart_instance(
|
||||
instance.compose_path, tool_type.name, tool_type.default_port
|
||||
)
|
||||
_ensure_container_name_in_compose(instance.compose_path, instance.name)
|
||||
_ensure_backend_network_in_compose(instance.compose_path)
|
||||
|
||||
returncode, stdout, stderr = execute_compose_command(
|
||||
instance.compose_path, "restart"
|
||||
@@ -2269,15 +2189,17 @@ async def restart_instance(
|
||||
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
|
||||
}
|
||||
|
||||
instance_port = tool_type.default_port
|
||||
|
||||
# Only create tunnel for web-enabled tools
|
||||
if tool_type.interface_type == "web":
|
||||
# Create new tunnel
|
||||
# Create new temporary tunnel
|
||||
try:
|
||||
tunnel_info = start_tunnel(
|
||||
instance_name=instance.name,
|
||||
container_port=tool_type.default_port or 0,
|
||||
tunnel_info = start_cloudflared_tunnel(
|
||||
container_name=instance.name.lower(),
|
||||
port=instance_port,
|
||||
)
|
||||
instance.tunnel_id = tunnel_info["container_name"]
|
||||
instance.tunnel_id = tunnel_info["pid"]
|
||||
instance.public_url = tunnel_info["url"]
|
||||
instance.url = tunnel_info["url"]
|
||||
logger.debug(
|
||||
@@ -2376,9 +2298,9 @@ async def delete_instance(
|
||||
# Stop Cloudflare tunnel if exists
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
stop_tunnel(instance.name)
|
||||
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
logger.debug(
|
||||
"Stopped tunnel for instance %s (container=%s)",
|
||||
"Stopped tunnel for instance %s (pid=%s)",
|
||||
instance.id,
|
||||
instance.tunnel_id,
|
||||
)
|
||||
@@ -2493,117 +2415,43 @@ async def recreate_tunnel_endpoint(
|
||||
detail="instance must be running to recreate tunnel",
|
||||
)
|
||||
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
if not tool_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tool type not found for this instance",
|
||||
)
|
||||
|
||||
expected_name = instance.name.lower()
|
||||
logger.info(
|
||||
"Recreate tunnel for instance %s (expected container name: %s, default_port: %s)",
|
||||
instance.id,
|
||||
expected_name,
|
||||
tool_type.default_port,
|
||||
)
|
||||
|
||||
# Find the tool container — try stored ID first, then fall back to name lookup
|
||||
tool_container_id = instance.container_id
|
||||
if tool_container_id:
|
||||
logger.info("Using stored container_id: %s", tool_container_id)
|
||||
else:
|
||||
tool_container_id = get_container_id(expected_name)
|
||||
if tool_container_id:
|
||||
logger.info("Found container by name: %s", tool_container_id)
|
||||
else:
|
||||
logger.error("Container %s not found", expected_name)
|
||||
# Validate tunnel is actually broken before recreating
|
||||
if instance.url:
|
||||
tunnel_health = check_tunnel_health(instance.url)
|
||||
if tunnel_health["tunnel_status"] == "error_response":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Could not find running container for this instance",
|
||||
detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.",
|
||||
)
|
||||
elif tunnel_health["tunnel_status"] == "healthy":
|
||||
return {
|
||||
"status": "healthy",
|
||||
"url": instance.url,
|
||||
"message": "Tunnel is already healthy",
|
||||
}
|
||||
|
||||
# Ensure the tool container is on the backend network so the tunnel can reach it
|
||||
network_name = get_backend_network_name()
|
||||
on_network = is_container_on_network(tool_container_id, network_name)
|
||||
logger.info(
|
||||
"Container %s on network %s: %s",
|
||||
tool_container_id,
|
||||
network_name,
|
||||
on_network,
|
||||
# Get tool type for default port
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
instance_port = (
|
||||
tool_type.default_port if tool_type and tool_type.default_port else 8080
|
||||
)
|
||||
if not on_network:
|
||||
logger.info(
|
||||
"Connecting container %s to network %s",
|
||||
tool_container_id,
|
||||
network_name,
|
||||
)
|
||||
connected = connect_container_to_network(tool_container_id, network_name)
|
||||
logger.info("Network connect result: %s", connected)
|
||||
|
||||
# Get the container's IP on the backend network
|
||||
target_ip = get_container_ip_on_network(tool_container_id, network_name)
|
||||
if target_ip:
|
||||
target_url = f"http://{target_ip}:{tool_type.default_port or 0}"
|
||||
logger.info(
|
||||
"Tunnel target for instance %s: %s (IP %s on %s)",
|
||||
instance.id,
|
||||
target_url,
|
||||
target_ip,
|
||||
network_name,
|
||||
)
|
||||
else:
|
||||
target_url = f"http://{expected_name}:{tool_type.default_port or 0}"
|
||||
logger.warning(
|
||||
"Could not get container IP, falling back to name-based target: %s",
|
||||
target_url,
|
||||
)
|
||||
|
||||
try:
|
||||
tunnel_info = recreate_tunnel(
|
||||
instance_name=instance.name,
|
||||
container_port=tool_type.default_port or 0,
|
||||
target_url=target_url,
|
||||
container_name=instance.container_name or instance.name,
|
||||
port=instance_port,
|
||||
old_pid=instance.tunnel_id,
|
||||
)
|
||||
logger.info(
|
||||
"Tunnel recreated: container=%s, url=%s",
|
||||
tunnel_info["container_name"],
|
||||
tunnel_info["url"],
|
||||
)
|
||||
|
||||
# Verify the tunnel can actually reach the origin
|
||||
health = check_tunnel_health(tunnel_info["url"], timeout=10)
|
||||
logger.info(
|
||||
"Tunnel health check: status=%s, code=%s, error=%s",
|
||||
health.get("tunnel_status"),
|
||||
health.get("status_code"),
|
||||
health.get("error"),
|
||||
)
|
||||
|
||||
# Also probe from inside the API container directly to the target
|
||||
probe = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"--max-time",
|
||||
"5",
|
||||
target_url,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
logger.info(
|
||||
"Direct probe from API to %s: HTTP %s", target_url, probe.stdout.strip()
|
||||
)
|
||||
|
||||
instance.tunnel_id = tunnel_info["container_name"]
|
||||
instance.tunnel_id = tunnel_info["pid"]
|
||||
instance.public_url = tunnel_info["url"]
|
||||
instance.url = tunnel_info["url"]
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"Recreated tunnel for instance %s: pid=%s, url=%s",
|
||||
instance.id,
|
||||
tunnel_info["pid"],
|
||||
tunnel_info["url"],
|
||||
)
|
||||
return {"status": "healthy", "url": instance.url}
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to recreate tunnel for instance %s", instance.id)
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
"""Workspace CRUD API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.workspace import Workspace
|
||||
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_workspaces(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[dict]:
|
||||
"""List workspaces for a repository, with instance counts."""
|
||||
# Verify repo belongs to project and user
|
||||
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||
|
||||
# Build subquery for instance counts
|
||||
instance_count = (
|
||||
select(func.count(ToolInstance.id))
|
||||
.where(ToolInstance.workspace_id == Workspace.id)
|
||||
.correlate(Workspace)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(
|
||||
Workspace,
|
||||
instance_count.label("instance_count"),
|
||||
)
|
||||
.where(Workspace.repo_id == repo_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,
|
||||
"project_name": repo.project.name if repo.project else "",
|
||||
"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, count in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_workspace(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a new workspace by cloning a repository branch."""
|
||||
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||
|
||||
name = data.get("name", "").strip()
|
||||
branch = data.get("branch", "main").strip()
|
||||
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Workspace name is required")
|
||||
if not branch:
|
||||
raise HTTPException(status_code=400, detail="Branch 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("/{workspace_id}")
|
||||
async def get_workspace_detail(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get workspace details."""
|
||||
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||
|
||||
# Count instances
|
||||
result = await session.execute(
|
||||
select(func.count(ToolInstance.id)).where(
|
||||
ToolInstance.workspace_id == workspace_id
|
||||
)
|
||||
)
|
||||
instance_count = result.scalar() or 0
|
||||
|
||||
return {
|
||||
"id": str(workspace.id),
|
||||
"name": workspace.name,
|
||||
"repo_id": str(workspace.repo_id),
|
||||
"repo_name": repo.name,
|
||||
"user_id": str(workspace.user_id),
|
||||
"branch": workspace.branch,
|
||||
"path": workspace.path,
|
||||
"status": workspace.status,
|
||||
"last_sync_at": workspace.last_sync_at.isoformat()
|
||||
if workspace.last_sync_at
|
||||
else None,
|
||||
"created_at": workspace.created_at.isoformat()
|
||||
if workspace.created_at
|
||||
else None,
|
||||
"updated_at": workspace.updated_at.isoformat()
|
||||
if workspace.updated_at
|
||||
else None,
|
||||
"instance_count": instance_count,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{workspace_id}")
|
||||
async def update_workspace(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
data: dict,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update workspace name or branch."""
|
||||
await _get_repo(session, repo_id, project_id, user_id)
|
||||
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||
|
||||
new_name = data.get("name", "").strip()
|
||||
new_branch = data.get("branch", "").strip()
|
||||
|
||||
if new_name:
|
||||
workspace.name = new_name
|
||||
if new_branch:
|
||||
workspace.branch = new_branch
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to update workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Workspace name already exists for this repository",
|
||||
) from exc
|
||||
|
||||
return {
|
||||
"id": str(workspace.id),
|
||||
"name": workspace.name,
|
||||
"branch": workspace.branch,
|
||||
"status": workspace.status,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}")
|
||||
async def delete_workspace(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
force: bool = Query(False),
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Delete a workspace. Returns 409 if instances exist and force=False."""
|
||||
await _get_repo(session, repo_id, project_id, user_id)
|
||||
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||
|
||||
manager = WorkspaceManager()
|
||||
try:
|
||||
await manager.delete(workspace, force=force, session=session)
|
||||
await session.commit()
|
||||
except WorkspaceHasInstancesError as exc:
|
||||
await session.rollback()
|
||||
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
|
||||
],
|
||||
},
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Failed to delete workspace: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to delete workspace"
|
||||
) from exc
|
||||
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/sync")
|
||||
async def sync_workspace(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Sync workspace with remote. Returns 409 if branch was deleted."""
|
||||
await _get_repo(session, repo_id, project_id, user_id)
|
||||
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||
|
||||
manager = WorkspaceManager()
|
||||
result = await manager.sync(workspace)
|
||||
|
||||
if result.branch_deleted:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"Branch '{workspace.branch}' was deleted from remote",
|
||||
"branch_deleted": True,
|
||||
},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return {
|
||||
"branch_deleted": False,
|
||||
"pulled": True,
|
||||
"last_sync_at": workspace.last_sync_at.isoformat()
|
||||
if workspace.last_sync_at
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
async def _get_repo(
|
||||
session: AsyncSession,
|
||||
repo_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> GitRepository:
|
||||
"""Fetch and validate repository access."""
|
||||
result = await session.execute(
|
||||
select(GitRepository)
|
||||
.where(
|
||||
GitRepository.id == repo_id,
|
||||
GitRepository.project_id == project_id,
|
||||
)
|
||||
.options(selectinload(GitRepository.project))
|
||||
)
|
||||
repo = result.scalar_one_or_none()
|
||||
if not repo:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return repo
|
||||
|
||||
|
||||
async def _get_workspace(
|
||||
session: AsyncSession,
|
||||
workspace_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
) -> Workspace:
|
||||
"""Fetch and validate workspace."""
|
||||
result = await session.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.id == workspace_id,
|
||||
Workspace.repo_id == repo_id,
|
||||
)
|
||||
)
|
||||
workspace = result.scalar_one_or_none()
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
return workspace
|
||||
@@ -24,7 +24,6 @@ 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.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 +159,4 @@ app.include_router(instance_proxy_router)
|
||||
app.include_router(terminal_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(workspaces_router)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
@@ -14,7 +14,6 @@ if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.workspace import Workspace
|
||||
|
||||
|
||||
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
@@ -61,12 +60,8 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
tool_type: Mapped["ToolType"] = relationship()
|
||||
workspace: Mapped["Workspace | None"] = relationship()
|
||||
repository: Mapped["GitRepository"] = relationship()
|
||||
project: Mapped["Project"] = relationship()
|
||||
owner: Mapped["User"] = relationship()
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Workspace model for persistent writable repo clones."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class Workspace(Base, TimestampMixin):
|
||||
"""A persistent, writable local clone of a Git repository.
|
||||
|
||||
Users create workspaces explicitly, then start tool instances on them.
|
||||
Multiple tool instances can share the same workspace.
|
||||
"""
|
||||
|
||||
__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", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
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
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||
)
|
||||
|
||||
repository: Mapped[GitRepository] = relationship("GitRepository")
|
||||
owner: Mapped[User] = relationship("User")
|
||||
+357
-124
@@ -1,6 +1,8 @@
|
||||
"""Docker service for managing tool instances."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from collections import Counter
|
||||
@@ -179,113 +181,69 @@ def execute_compose_command(
|
||||
def get_container_id(instance_name: str) -> str | None:
|
||||
"""Get the container ID for a compose service.
|
||||
|
||||
Uses exact name matching to avoid substring collisions with tunnel
|
||||
containers (e.g. tunnel-code-server-... matching code-server-...).
|
||||
Falls back to case-insensitive matching since Docker DNS is case-
|
||||
insensitive but docker inspect is case-sensitive.
|
||||
Searches all containers including stopped/exited ones.
|
||||
|
||||
Args:
|
||||
instance_name: The expected container name.
|
||||
instance_name: The service name in compose
|
||||
|
||||
Returns:
|
||||
Container ID or None if not found.
|
||||
Container ID or None if not found
|
||||
"""
|
||||
expected = instance_name.lower()
|
||||
|
||||
# Fast path: exact match via docker inspect
|
||||
# Docker container names are lowercase internally; normalize to ensure match
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.Id}}", expected],
|
||||
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name.lower()}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
|
||||
# Fallback: list all containers and do case-insensitive exact match
|
||||
ps_result = subprocess.run(
|
||||
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.ID}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ps_result.returncode == 0:
|
||||
for line in ps_result.stdout.strip().splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) == 2:
|
||||
name, cid = parts
|
||||
if name.lower() == expected:
|
||||
return cid
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().split("\n")[0]
|
||||
return None
|
||||
|
||||
|
||||
def get_container_name(instance_name: str) -> str | None:
|
||||
"""Get the full container name for a compose service.
|
||||
|
||||
Uses exact name matching via docker inspect to avoid substring collisions.
|
||||
Searches all containers including stopped/exited ones.
|
||||
|
||||
Args:
|
||||
instance_name: The exact container name (case-insensitive for Docker).
|
||||
instance_name: The service name in compose
|
||||
|
||||
Returns:
|
||||
Container name or None if not found.
|
||||
Container name or None if not found
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().lstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
def get_backend_network_name() -> str:
|
||||
"""Auto-detect the actual Docker network name for the backend network.
|
||||
|
||||
Docker Compose prefixes network names with the project directory name
|
||||
(e.g. 'headquarter_backend' instead of 'backend'). We inspect the API
|
||||
container itself to find the real network name it's connected to.
|
||||
|
||||
Returns:
|
||||
The actual Docker network name, or 'backend' as fallback.
|
||||
"""
|
||||
# Try to find the API container by its known name
|
||||
api_container = "hq-api"
|
||||
# Docker container names are lowercase internally; normalize to ensure match
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
|
||||
api_container,
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
f"name={instance_name.lower()}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
networks = result.stdout.strip().split()
|
||||
for net in networks:
|
||||
if "backend" in net.lower():
|
||||
return net
|
||||
# API container is on some network — return the first one
|
||||
return networks[0]
|
||||
return "backend"
|
||||
return result.stdout.strip().split("\n")[0]
|
||||
return None
|
||||
|
||||
|
||||
def connect_container_to_network(
|
||||
container_name: str, network_name: str | None = None
|
||||
container_name: str, network_name: str = "backend"
|
||||
) -> bool:
|
||||
"""Connect a Docker container to an existing network.
|
||||
|
||||
Args:
|
||||
container_name: Name or ID of the container
|
||||
network_name: Name of the Docker network. If None, auto-detects
|
||||
from the API container's own network membership.
|
||||
network_name: Name of the Docker network (default: backend)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
["docker", "network", "connect", network_name, container_name],
|
||||
capture_output=True,
|
||||
@@ -294,64 +252,6 @@ def connect_container_to_network(
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_container_ip_on_network(
|
||||
container_id: str, network_name: str | None = None
|
||||
) -> str | None:
|
||||
"""Get a container's IP address on a specific Docker network.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
network_name: Network name. If None, auto-detects from the API container.
|
||||
|
||||
Returns:
|
||||
IP address string, or None if the container is not on that network.
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
ip = result.stdout.strip()
|
||||
if ip and ip != "<no value>":
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
def is_container_on_network(container_id: str, network_name: str | None = None) -> bool:
|
||||
"""Check whether a container is already attached to a Docker network.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
network_name: Network name. If None, auto-detects from the API container.
|
||||
|
||||
Returns:
|
||||
True if the container is on the network.
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0 and "<no value>" not in result.stdout
|
||||
|
||||
|
||||
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
"""Get the status of a Docker container.
|
||||
|
||||
@@ -482,3 +382,336 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||
return port
|
||||
|
||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||
|
||||
|
||||
def _check_app_binding(container_name: str, port: int) -> dict[str, str | bool]:
|
||||
"""Diagnose whether the app is bound to 127.0.0.1 or 0.0.0.0.
|
||||
|
||||
Checks from both inside the container (localhost) and outside
|
||||
(via Docker network) to detect binding issues.
|
||||
|
||||
Returns:
|
||||
Dict with 'internal_ok', 'external_ok', 'internal_status',
|
||||
'external_status', and 'diagnosis'.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"internal_ok": False,
|
||||
"external_ok": False,
|
||||
"internal_status": None,
|
||||
"external_status": None,
|
||||
"diagnosis": "unknown",
|
||||
}
|
||||
|
||||
# Check from inside the container (loopback)
|
||||
internal = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
container_name,
|
||||
"sh",
|
||||
"-c",
|
||||
f"curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{port}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if internal.returncode == 0:
|
||||
try:
|
||||
result["internal_status"] = int(internal.stdout.strip())
|
||||
result["internal_ok"] = result["internal_status"] > 0
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Check from outside the container (Docker network)
|
||||
external = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
f"http://{container_name}:{port}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if external.returncode == 0:
|
||||
try:
|
||||
result["external_status"] = int(external.stdout.strip())
|
||||
result["external_ok"] = result["external_status"] > 0
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Diagnose binding issue
|
||||
if result["internal_ok"] and not result["external_ok"]:
|
||||
result["diagnosis"] = (
|
||||
f"App appears to be bound to 127.0.0.1:{port} inside the container. "
|
||||
f"It must bind to 0.0.0.0:{port} to be accessible from the tunnel."
|
||||
)
|
||||
elif result["internal_ok"] and result["external_ok"]:
|
||||
result["diagnosis"] = "App is accessible on both interfaces."
|
||||
elif not result["internal_ok"] and not result["external_ok"]:
|
||||
result["diagnosis"] = f"App is not responding on port {port} at all."
|
||||
else:
|
||||
result["diagnosis"] = "Unexpected binding state."
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def start_cloudflared_tunnel(
|
||||
container_name: str, port: int, timeout: int = 30
|
||||
) -> dict[str, str]:
|
||||
"""Start a temporary Cloudflare tunnel for a container.
|
||||
|
||||
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
||||
with a random trycloudflare.com URL.
|
||||
|
||||
Args:
|
||||
container_name: Name of the Docker container to tunnel to
|
||||
port: Port number the container listens on
|
||||
timeout: Maximum seconds to wait for tunnel URL
|
||||
|
||||
Returns:
|
||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||
"""
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# First verify the container is accessible from the Docker network
|
||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||
accessible = False
|
||||
last_status = None
|
||||
for attempt in range(30): # 30 attempts × 1s = 30s max wait for app startup
|
||||
check = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"--max-time",
|
||||
"3",
|
||||
f"http://{container_name}:{port}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
status_str = check.stdout.strip()
|
||||
logger.info(
|
||||
"Connectivity check %d/%d: http_code=%s (rc=%d)",
|
||||
attempt + 1,
|
||||
30,
|
||||
status_str,
|
||||
check.returncode,
|
||||
)
|
||||
try:
|
||||
last_status = int(status_str)
|
||||
# Accept 2xx, 3xx, 401, 403 as "app is listening"
|
||||
if last_status in (401, 403) or 200 <= last_status < 400:
|
||||
accessible = True
|
||||
logger.info(
|
||||
"App on %s:%d is ready (HTTP %d)",
|
||||
container_name,
|
||||
port,
|
||||
last_status,
|
||||
)
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if check.returncode != 0:
|
||||
logger.debug(
|
||||
"curl failed: stderr=%s", check.stderr.strip() if check.stderr else ""
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
if not accessible:
|
||||
logger.warning(
|
||||
"Container %s:%d not responding after 30s (last status: %s). "
|
||||
"Running binding diagnostics...",
|
||||
container_name,
|
||||
port,
|
||||
last_status,
|
||||
)
|
||||
diagnosis = _check_app_binding(container_name, port)
|
||||
logger.warning(
|
||||
"Binding diagnosis: internal=%s (HTTP %s), external=%s (HTTP %s). %s",
|
||||
diagnosis["internal_ok"],
|
||||
diagnosis["internal_status"],
|
||||
diagnosis["external_ok"],
|
||||
diagnosis["external_status"],
|
||||
diagnosis["diagnosis"],
|
||||
)
|
||||
|
||||
# Run cloudflared in background, capture output
|
||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||
proc = subprocess.Popen(
|
||||
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Wait for the URL to appear in output
|
||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||
start_time = time.time()
|
||||
url = None
|
||||
|
||||
if proc.stdout is None:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
raise RuntimeError("Failed to capture cloudflared output")
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
# Read available output
|
||||
import select
|
||||
|
||||
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||
if readable:
|
||||
line = proc.stdout.readline()
|
||||
if line:
|
||||
match = url_pattern.search(line)
|
||||
if match:
|
||||
url = match.group(0)
|
||||
break
|
||||
|
||||
if not url:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
raise RuntimeError(
|
||||
f"Failed to get tunnel URL within {timeout}s. "
|
||||
f"cloudflared output may contain errors."
|
||||
)
|
||||
|
||||
return {"url": url, "pid": str(proc.pid)}
|
||||
|
||||
|
||||
def stop_cloudflared_tunnel(pid: str) -> None:
|
||||
"""Stop a cloudflared tunnel process.
|
||||
|
||||
Args:
|
||||
pid: Process ID of the cloudflared tunnel
|
||||
"""
|
||||
import signal
|
||||
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass # Already stopped
|
||||
|
||||
|
||||
def recreate_tunnel(
|
||||
container_name: str, port: int, old_pid: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Recreate a temporary Cloudflare tunnel.
|
||||
|
||||
Stops the old tunnel (if pid provided) and starts a new one.
|
||||
|
||||
Args:
|
||||
container_name: Name of the Docker container to tunnel to
|
||||
port: Port number the container listens on
|
||||
old_pid: Optional PID of the old tunnel process to stop
|
||||
|
||||
Returns:
|
||||
Dict with 'url' and 'pid' for the new tunnel
|
||||
"""
|
||||
if old_pid:
|
||||
stop_cloudflared_tunnel(old_pid)
|
||||
|
||||
return start_cloudflared_tunnel(container_name, port)
|
||||
|
||||
|
||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
"""Check if a tunnel URL is healthy with smart error classification.
|
||||
|
||||
Args:
|
||||
url: The tunnel URL to check
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
|
||||
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"--max-time",
|
||||
str(timeout),
|
||||
url,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
status_code = int(result.stdout.strip())
|
||||
|
||||
if 200 <= status_code < 400:
|
||||
return {
|
||||
"tunnel_status": "healthy",
|
||||
"status_code": status_code,
|
||||
"healthy": True,
|
||||
"error": None,
|
||||
}
|
||||
elif status_code in (502, 503, 504):
|
||||
# Application error, not tunnel error
|
||||
return {
|
||||
"tunnel_status": "error_response",
|
||||
"status_code": status_code,
|
||||
"healthy": False,
|
||||
"error": f"Application returned HTTP {status_code}",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"tunnel_status": "error_response",
|
||||
"status_code": status_code,
|
||||
"healthy": False,
|
||||
"error": f"HTTP {status_code}",
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": "Tunnel request timed out",
|
||||
}
|
||||
except (ValueError, Exception) as e:
|
||||
error_str = str(e).lower()
|
||||
# Classify connection errors
|
||||
if any(
|
||||
err in error_str
|
||||
for err in [
|
||||
"connection refused",
|
||||
"econnrefused",
|
||||
"could not resolve",
|
||||
"nodename",
|
||||
]
|
||||
):
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": f"Tunnel unreachable: {e}",
|
||||
}
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Git operations for workspace management."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitService:
|
||||
"""Low-level git operations for creating and syncing workspaces."""
|
||||
|
||||
@staticmethod
|
||||
async def clone(remote_url: str, branch: str, path: str) -> None:
|
||||
"""Clone a repository to the given path.
|
||||
|
||||
Args:
|
||||
remote_url: The git remote URL.
|
||||
branch: The branch to clone.
|
||||
path: The destination path for the clone.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the clone fails.
|
||||
"""
|
||||
cmd = [
|
||||
"git",
|
||||
"clone",
|
||||
"--branch",
|
||||
branch,
|
||||
"--single-branch",
|
||||
remote_url,
|
||||
path,
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git clone failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git clone failed: {error_msg}")
|
||||
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
|
||||
|
||||
@staticmethod
|
||||
async def fetch(path: str) -> None:
|
||||
"""Fetch from origin.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If fetch fails.
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
path,
|
||||
"fetch",
|
||||
"origin",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git fetch failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git fetch failed: {error_msg}")
|
||||
logger.debug("Fetched origin for %s", path)
|
||||
|
||||
@staticmethod
|
||||
async def pull(path: str, branch: str) -> None:
|
||||
"""Pull latest changes from origin.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
branch: The branch to pull.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If pull fails.
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
path,
|
||||
"pull",
|
||||
"origin",
|
||||
branch,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||
logger.error("Git pull failed: %s", error_msg)
|
||||
raise RuntimeError(f"Git pull failed: {error_msg}")
|
||||
logger.debug("Pulled origin/%s for %s", branch, path)
|
||||
|
||||
@staticmethod
|
||||
def branch_exists_remotely(path: str, branch: str) -> bool:
|
||||
"""Check if a branch exists on the remote.
|
||||
|
||||
Args:
|
||||
path: The path to the local git repository.
|
||||
branch: The branch name to check.
|
||||
|
||||
Returns:
|
||||
True if the branch exists on origin, False otherwise.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
exists = result.returncode == 0 and result.stdout.strip() != ""
|
||||
logger.debug("Branch %s exists on remote: %s", branch, exists)
|
||||
return exists
|
||||
@@ -13,8 +13,7 @@ from src.database import SessionLocal
|
||||
from src.models.health_check import HealthCheck
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.services.correlation import get_correlation_id
|
||||
from src.services.docker import get_container_status
|
||||
from src.services.tunnel import check_tunnel_health
|
||||
from src.services.docker import check_tunnel_health, get_container_status
|
||||
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||
from src.services.notification_service import notification_service
|
||||
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
"""Clean tunnel service using cloudflared containers on the backend network.
|
||||
|
||||
Design:
|
||||
- Each tunnel runs as a Docker container on the same 'backend' network as the API.
|
||||
- cloudflared connects to the tool container by its Docker Compose service name
|
||||
(e.g. http://code-server-headquarter-34837cd3:8443).
|
||||
- This avoids host port conflicts and DNS resolution issues.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from src.services.docker import get_backend_network_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
|
||||
|
||||
|
||||
def _tunnel_container_name(instance_name: str) -> str:
|
||||
return f"tunnel-{instance_name.lower()}"
|
||||
|
||||
|
||||
def _ensure_image() -> None:
|
||||
"""Pull cloudflared image if not already present."""
|
||||
result = subprocess.run(
|
||||
["docker", "images", "-q", TUNNEL_IMAGE],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if not result.stdout.strip():
|
||||
logger.info("Pulling %s ...", TUNNEL_IMAGE)
|
||||
pull = subprocess.run(
|
||||
["docker", "pull", TUNNEL_IMAGE],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if pull.returncode != 0:
|
||||
logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr)
|
||||
|
||||
|
||||
def _cleanup_stale_tunnel(tunnel_name: str) -> None:
|
||||
"""Remove any existing tunnel container with this name."""
|
||||
subprocess.run(
|
||||
["docker", "stop", "-t", "3", tunnel_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", tunnel_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _get_container_logs(tunnel_name: str) -> tuple[str, str]:
|
||||
"""Get stdout and stderr logs from a container."""
|
||||
result = subprocess.run(
|
||||
["docker", "logs", tunnel_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout, result.stderr
|
||||
|
||||
|
||||
def _get_container_exit_code(tunnel_name: str) -> int | None:
|
||||
"""Get exit code of a container if it has exited."""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
return int(result.stdout.strip())
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def start_tunnel(
|
||||
instance_name: str,
|
||||
container_port: int,
|
||||
timeout: int = 30,
|
||||
target_url: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Start a temporary Cloudflare tunnel for an instance.
|
||||
|
||||
Args:
|
||||
instance_name: The tool instance name (used for tunnel naming).
|
||||
container_port: The port the tool container listens on internally.
|
||||
timeout: Seconds to wait for the tunnel URL.
|
||||
target_url: Optional explicit URL to proxy to. If omitted, derives
|
||||
http://{instance_name.lower()}:{container_port}.
|
||||
|
||||
Returns:
|
||||
Dict with 'url' and 'container_name'.
|
||||
"""
|
||||
_ensure_image()
|
||||
|
||||
tunnel_name = _tunnel_container_name(instance_name)
|
||||
_cleanup_stale_tunnel(tunnel_name)
|
||||
|
||||
# Target the tool container by name on the backend network
|
||||
if target_url is None:
|
||||
target_url = f"http://{instance_name.lower()}:{container_port}"
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--network",
|
||||
get_backend_network_name(),
|
||||
"--name",
|
||||
tunnel_name,
|
||||
TUNNEL_IMAGE,
|
||||
"tunnel",
|
||||
"--no-autoupdate",
|
||||
"--url",
|
||||
target_url,
|
||||
]
|
||||
|
||||
logger.debug("Running: %s", " ".join(cmd))
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to start tunnel container {tunnel_name}: {proc.stderr}"
|
||||
)
|
||||
|
||||
container_id = proc.stdout.strip()
|
||||
logger.debug("Tunnel container started: %s", container_id)
|
||||
|
||||
# Wait for URL to appear in logs
|
||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||
start_time = __import__("time").time()
|
||||
url: str | None = None
|
||||
combined_logs = ""
|
||||
|
||||
while __import__("time").time() - start_time < timeout:
|
||||
stdout, stderr = _get_container_logs(tunnel_name)
|
||||
combined_logs = stdout + "\n" + stderr
|
||||
|
||||
match = url_pattern.search(combined_logs)
|
||||
if match:
|
||||
url = match.group(0)
|
||||
break
|
||||
|
||||
# Check if container exited early
|
||||
exit_code = _get_container_exit_code(tunnel_name)
|
||||
if exit_code is not None and exit_code != 0:
|
||||
_cleanup_stale_tunnel(tunnel_name)
|
||||
raise RuntimeError(
|
||||
f"Tunnel container {tunnel_name} exited with code {exit_code}. "
|
||||
f"Logs:\n{combined_logs[-3000:]}"
|
||||
)
|
||||
|
||||
__import__("time").sleep(0.5)
|
||||
|
||||
if not url:
|
||||
stdout, stderr = _get_container_logs(tunnel_name)
|
||||
combined_logs = stdout + "\n" + stderr
|
||||
exit_code = _get_container_exit_code(tunnel_name)
|
||||
|
||||
_cleanup_stale_tunnel(tunnel_name)
|
||||
raise RuntimeError(
|
||||
f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. "
|
||||
f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}"
|
||||
)
|
||||
|
||||
# Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain
|
||||
__import__("time").sleep(2)
|
||||
|
||||
logger.info(
|
||||
"Tunnel %s started for %s → %s (%s)",
|
||||
tunnel_name,
|
||||
instance_name,
|
||||
target_url,
|
||||
url,
|
||||
)
|
||||
return {"url": url, "container_name": tunnel_name}
|
||||
|
||||
|
||||
def stop_tunnel(instance_name: str) -> None:
|
||||
"""Stop and remove the tunnel container for an instance."""
|
||||
tunnel_name = _tunnel_container_name(instance_name)
|
||||
_cleanup_stale_tunnel(tunnel_name)
|
||||
logger.debug("Stopped and removed tunnel container %s", tunnel_name)
|
||||
|
||||
|
||||
def recreate_tunnel(
|
||||
instance_name: str, container_port: int, target_url: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Recreate a tunnel for an instance.
|
||||
|
||||
Args:
|
||||
instance_name: The tool instance name.
|
||||
container_port: The port the tool container listens on internally.
|
||||
target_url: Optional explicit origin URL. If omitted, derives
|
||||
http://{instance_name.lower()}:{container_port}.
|
||||
"""
|
||||
stop_tunnel(instance_name)
|
||||
return start_tunnel(instance_name, container_port, target_url=target_url)
|
||||
|
||||
|
||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
"""Check if a tunnel URL is healthy.
|
||||
|
||||
Returns:
|
||||
Dict with 'tunnel_status', 'status_code', 'healthy', 'error'.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"--max-time",
|
||||
str(timeout),
|
||||
url,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
status_code = int(result.stdout.strip())
|
||||
|
||||
if 200 <= status_code < 400:
|
||||
return {
|
||||
"tunnel_status": "healthy",
|
||||
"status_code": status_code,
|
||||
"healthy": True,
|
||||
"error": None,
|
||||
}
|
||||
if status_code in (502, 503, 504):
|
||||
return {
|
||||
"tunnel_status": "error_response",
|
||||
"status_code": status_code,
|
||||
"healthy": False,
|
||||
"error": f"Application returned HTTP {status_code}",
|
||||
}
|
||||
return {
|
||||
"tunnel_status": "error_response",
|
||||
"status_code": status_code,
|
||||
"healthy": False,
|
||||
"error": f"HTTP {status_code}",
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": "Tunnel request timed out",
|
||||
}
|
||||
except (ValueError, Exception) as exc:
|
||||
error_str = str(exc).lower()
|
||||
if any(
|
||||
err in error_str
|
||||
for err in [
|
||||
"connection refused",
|
||||
"econnrefused",
|
||||
"could not resolve",
|
||||
"nodename",
|
||||
]
|
||||
):
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": f"Tunnel unreachable: {exc}",
|
||||
}
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Workspace lifecycle management service."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.models.workspace import Workspace
|
||||
from src.services.git_service import GitService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.tool_instance import ToolInstance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncResult:
|
||||
"""Result of a workspace sync operation."""
|
||||
|
||||
branch_deleted: bool = False
|
||||
|
||||
|
||||
class WorkspaceHasInstancesError(Exception):
|
||||
"""Raised when attempting to delete a workspace with running instances."""
|
||||
|
||||
def __init__(self, instances: list[ToolInstance]) -> None:
|
||||
self.instances = instances
|
||||
super().__init__(f"Workspace has {len(instances)} running tool instance(s)")
|
||||
|
||||
|
||||
class WorkspaceManager:
|
||||
"""Manages workspace lifecycle: create, delete, sync, validate."""
|
||||
|
||||
BASE_PATH = "/data/working-copies"
|
||||
|
||||
def _workspace_path(self, repo_id: uuid.UUID, name: str) -> str:
|
||||
"""Return the filesystem path for a workspace."""
|
||||
return os.path.join(self.BASE_PATH, str(repo_id), name)
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
repo: The git repository to clone.
|
||||
user_id: The owner user ID.
|
||||
name: The workspace name (unique per repo).
|
||||
branch: The branch to clone (default: "main").
|
||||
|
||||
Returns:
|
||||
The created Workspace record.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If git clone fails.
|
||||
"""
|
||||
path = self._workspace_path(repo.id, name)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
|
||||
logger.info(
|
||||
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
|
||||
)
|
||||
|
||||
if not repo.remote_url:
|
||||
raise ValueError("Repository has no remote URL")
|
||||
|
||||
await GitService.clone(repo.remote_url, branch, path)
|
||||
|
||||
workspace = Workspace(
|
||||
name=name,
|
||||
repo_id=repo.id,
|
||||
user_id=user_id,
|
||||
branch=branch,
|
||||
path=path,
|
||||
status="ready",
|
||||
last_sync_at=datetime.now(),
|
||||
)
|
||||
logger.info("Workspace created: %s", workspace.id)
|
||||
return workspace
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
force: bool = False,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Delete a workspace and all associated tool instances.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to delete.
|
||||
force: If True, delete even if instances exist.
|
||||
session: The database session (required for checking instances).
|
||||
|
||||
Raises:
|
||||
WorkspaceHasInstancesError: If instances exist and force=False.
|
||||
"""
|
||||
if session is None:
|
||||
raise ValueError("session is required for delete")
|
||||
|
||||
instances = await self._get_instances(workspace, session)
|
||||
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
|
||||
if os.path.exists(workspace.path):
|
||||
shutil.rmtree(workspace.path, ignore_errors=True)
|
||||
logger.info("Deleted workspace directory: %s", workspace.path)
|
||||
|
||||
# Delete record
|
||||
await session.delete(workspace)
|
||||
logger.info("Deleted workspace record: %s", workspace.id)
|
||||
|
||||
async def sync(self, workspace: Workspace) -> SyncResult:
|
||||
"""Sync a workspace with its remote.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to sync.
|
||||
|
||||
Returns:
|
||||
SyncResult indicating whether the branch was deleted.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If git operations fail.
|
||||
"""
|
||||
logger.info("Syncing workspace: %s", workspace.id)
|
||||
|
||||
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()
|
||||
logger.info("Workspace synced: %s", workspace.id)
|
||||
return SyncResult(branch_deleted=False)
|
||||
|
||||
async def _get_instances(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
session: AsyncSession,
|
||||
) -> list[ToolInstance]:
|
||||
"""Get all tool instances associated with this workspace."""
|
||||
from src.models.tool_instance import ToolInstance
|
||||
|
||||
result = await session.execute(
|
||||
select(ToolInstance).where(ToolInstance.workspace_id == workspace.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _stop_and_delete_instance(self, instance: ToolInstance) -> None:
|
||||
"""Stop and delete a tool instance.
|
||||
|
||||
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
|
||||
)
|
||||
@@ -1,328 +0,0 @@
|
||||
"""Integration tests for workspace API endpoints."""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.workspace import Workspace
|
||||
from src.services.workspace_manager import WorkspaceManager
|
||||
|
||||
|
||||
def _get_user_id_from_client(client: TestClient) -> uuid.UUID:
|
||||
"""Extract user ID from authenticated client session cookie."""
|
||||
from src.auth.session import decode_session_cookie
|
||||
from src.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
session_cookie = client.cookies.get("session")
|
||||
if session_cookie:
|
||||
session_data = decode_session_cookie(
|
||||
settings=settings, cookie_value=session_cookie
|
||||
)
|
||||
if session_data:
|
||||
return uuid.UUID(session_data["user_id"])
|
||||
raise RuntimeError("Could not get user ID from authenticated client")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_repo(db_session: AsyncSession, authenticated_client: TestClient):
|
||||
"""Create a test repository."""
|
||||
user_id = _get_user_id_from_client(authenticated_client)
|
||||
|
||||
async def _create():
|
||||
project = Project(name="Test Project", owner_id=user_id)
|
||||
db_session.add(project)
|
||||
await db_session.flush()
|
||||
|
||||
repo = GitRepository(
|
||||
name="test-repo",
|
||||
path="/tmp/test-repo",
|
||||
remote_url="https://github.com/test/repo.git",
|
||||
project_id=project.id,
|
||||
owner_id=user_id,
|
||||
)
|
||||
db_session.add(repo)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(repo)
|
||||
return repo
|
||||
|
||||
return asyncio.run(_create())
|
||||
|
||||
|
||||
class TestListWorkspaces:
|
||||
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
|
||||
|
||||
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"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
def test_list_with_workspaces(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
):
|
||||
"""Returns workspaces with instance counts."""
|
||||
ws = Workspace(
|
||||
name="dev",
|
||||
repo_id=test_repo.id,
|
||||
user_id=test_repo.owner_id,
|
||||
branch="main",
|
||||
path="/data/working-copies/test/dev",
|
||||
)
|
||||
db_session.add(ws)
|
||||
|
||||
async def _commit():
|
||||
await db_session.commit()
|
||||
|
||||
asyncio.run(_commit())
|
||||
|
||||
response = authenticated_client.get(
|
||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "dev"
|
||||
assert data[0]["instance_count"] == 0
|
||||
|
||||
|
||||
class TestCreateWorkspace:
|
||||
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
|
||||
|
||||
def test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository):
|
||||
"""Creates a workspace and clones the repo."""
|
||||
mock_ws = Workspace(
|
||||
id=uuid.uuid4(),
|
||||
name="feature-branch",
|
||||
repo_id=test_repo.id,
|
||||
user_id=test_repo.owner_id,
|
||||
branch="feature",
|
||||
path="/data/working-copies/test/feature-branch",
|
||||
)
|
||||
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "feature-branch"
|
||||
assert data["branch"] == "feature"
|
||||
mock_create.assert_called_once()
|
||||
|
||||
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",
|
||||
json={"branch": "main"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "name" in response.json()["detail"]
|
||||
|
||||
def test_create_duplicate_name(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
):
|
||||
"""Returns 409 when workspace name already exists."""
|
||||
ws = Workspace(
|
||||
name="dev",
|
||||
repo_id=test_repo.id,
|
||||
user_id=test_repo.owner_id,
|
||||
branch="main",
|
||||
path="/data/working-copies/test/dev",
|
||||
)
|
||||
db_session.add(ws)
|
||||
|
||||
async def _commit():
|
||||
await db_session.commit()
|
||||
|
||||
asyncio.run(_commit())
|
||||
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
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
|
||||
):
|
||||
"""Deletes workspace when no instances exist."""
|
||||
ws = Workspace(
|
||||
name="dev",
|
||||
repo_id=test_repo.id,
|
||||
user_id=test_repo.owner_id,
|
||||
branch="main",
|
||||
path="/data/working-copies/test/dev",
|
||||
)
|
||||
db_session.add(ws)
|
||||
|
||||
async def _commit_refresh():
|
||||
await db_session.commit()
|
||||
await db_session.refresh(ws)
|
||||
|
||||
asyncio.run(_commit_refresh())
|
||||
|
||||
with patch.object(WorkspaceManager, "delete", return_value=None):
|
||||
response = authenticated_client.delete(
|
||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "deleted"
|
||||
|
||||
@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
|
||||
):
|
||||
"""Returns 409 when workspace has instances and force=False."""
|
||||
ws = Workspace(
|
||||
name="dev",
|
||||
repo_id=test_repo.id,
|
||||
user_id=test_repo.owner_id,
|
||||
branch="main",
|
||||
path="/data/working-copies/test/dev",
|
||||
)
|
||||
db_session.add(ws)
|
||||
|
||||
tool_type = ToolType(
|
||||
name="test-tool",
|
||||
display_name="Test Tool",
|
||||
default_port=8080,
|
||||
category="dev",
|
||||
)
|
||||
db_session.add(tool_type)
|
||||
|
||||
async def _flush():
|
||||
await db_session.flush()
|
||||
|
||||
asyncio.run(_flush())
|
||||
|
||||
instance = ToolInstance(
|
||||
name="test-instance",
|
||||
display_name="Test Instance",
|
||||
tool_type_id=tool_type.id,
|
||||
repository_id=test_repo.id,
|
||||
project_id=test_repo.project_id,
|
||||
owner_id=test_repo.owner_id,
|
||||
workspace_id=ws.id,
|
||||
status="running",
|
||||
)
|
||||
db_session.add(instance)
|
||||
|
||||
async def _commit_refresh():
|
||||
await db_session.commit()
|
||||
await db_session.refresh(ws)
|
||||
|
||||
asyncio.run(_commit_refresh())
|
||||
|
||||
response = authenticated_client.delete(
|
||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}"
|
||||
)
|
||||
assert response.status_code == 409
|
||||
detail = response.json()["detail"]
|
||||
assert detail["message"] == "Workspace has running tool instances"
|
||||
assert len(detail["instances"]) == 1
|
||||
|
||||
def test_delete_with_instances_force(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
):
|
||||
"""Deletes workspace when force=True even with instances."""
|
||||
ws = Workspace(
|
||||
name="dev",
|
||||
repo_id=test_repo.id,
|
||||
user_id=test_repo.owner_id,
|
||||
branch="main",
|
||||
path="/data/working-copies/test/dev",
|
||||
)
|
||||
db_session.add(ws)
|
||||
|
||||
async def _commit_refresh():
|
||||
await db_session.commit()
|
||||
await db_session.refresh(ws)
|
||||
|
||||
asyncio.run(_commit_refresh())
|
||||
|
||||
with patch.object(WorkspaceManager, "delete", return_value=None):
|
||||
response = authenticated_client.delete(
|
||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}?force=true"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
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
|
||||
):
|
||||
"""Sync succeeds and updates last_sync_at."""
|
||||
ws = Workspace(
|
||||
name="dev",
|
||||
repo_id=test_repo.id,
|
||||
user_id=test_repo.owner_id,
|
||||
branch="main",
|
||||
path="/data/working-copies/test/dev",
|
||||
)
|
||||
db_session.add(ws)
|
||||
|
||||
async def _commit_refresh():
|
||||
await db_session.commit()
|
||||
await db_session.refresh(ws)
|
||||
|
||||
asyncio.run(_commit_refresh())
|
||||
|
||||
with patch.object(
|
||||
WorkspaceManager, "sync", return_value=MagicMock(branch_deleted=False)
|
||||
):
|
||||
response = authenticated_client.post(
|
||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}/sync"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["branch_deleted"] is False
|
||||
assert data["pulled"] is True
|
||||
|
||||
def test_sync_branch_deleted(
|
||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
||||
):
|
||||
"""Returns 409 when branch was deleted from remote."""
|
||||
ws = Workspace(
|
||||
name="dev",
|
||||
repo_id=test_repo.id,
|
||||
user_id=test_repo.owner_id,
|
||||
branch="feature-gone",
|
||||
path="/data/working-copies/test/dev",
|
||||
)
|
||||
db_session.add(ws)
|
||||
|
||||
async def _commit_refresh():
|
||||
await db_session.commit()
|
||||
await db_session.refresh(ws)
|
||||
|
||||
asyncio.run(_commit_refresh())
|
||||
|
||||
with patch.object(
|
||||
WorkspaceManager, "sync", return_value=MagicMock(branch_deleted=True)
|
||||
):
|
||||
response = authenticated_client.post(
|
||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}/sync"
|
||||
)
|
||||
assert response.status_code == 409
|
||||
detail = response.json()["detail"]
|
||||
assert "deleted from remote" in detail["message"]
|
||||
assert detail["branch_deleted"] is True
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Unit tests for GitService."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.git_service import GitService
|
||||
|
||||
|
||||
class TestGitServiceClone:
|
||||
"""Tests for GitService.clone."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clone_success(self):
|
||||
"""Clone succeeds when git returns 0."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.returncode = 0
|
||||
mock_proc.communicate.return_value = (b"", b"")
|
||||
|
||||
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")
|
||||
|
||||
mock_exec.assert_called_once_with(
|
||||
"git",
|
||||
"clone",
|
||||
"--branch",
|
||||
"main",
|
||||
"--single-branch",
|
||||
"https://github.com/test/repo.git",
|
||||
"/tmp/ws",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clone_failure(self):
|
||||
"""Clone raises RuntimeError when git fails."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.returncode = 1
|
||||
mock_proc.communicate.return_value = (b"", b"fatal: repository not found")
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
|
||||
with pytest.raises(RuntimeError, match="Git clone failed"):
|
||||
await GitService.clone("https://bad/url.git", "main", "/tmp/ws")
|
||||
|
||||
|
||||
class TestGitServiceFetch:
|
||||
"""Tests for GitService.fetch."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_success(self):
|
||||
"""Fetch succeeds when git returns 0."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.returncode = 0
|
||||
mock_proc.communicate.return_value = (b"", b"")
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", return_value=mock_proc
|
||||
) as mock_exec:
|
||||
await GitService.fetch("/tmp/repo")
|
||||
|
||||
mock_exec.assert_called_once_with(
|
||||
"git",
|
||||
"-C",
|
||||
"/tmp/repo",
|
||||
"fetch",
|
||||
"origin",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_failure(self):
|
||||
"""Fetch raises RuntimeError when git fails."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.returncode = 128
|
||||
mock_proc.communicate.return_value = (b"", b"fatal: not a git repository")
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
|
||||
with pytest.raises(RuntimeError, match="Git fetch failed"):
|
||||
await GitService.fetch("/not/a/repo")
|
||||
|
||||
|
||||
class TestGitServicePull:
|
||||
"""Tests for GitService.pull."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pull_success(self):
|
||||
"""Pull succeeds when git returns 0."""
|
||||
mock_proc = AsyncMock()
|
||||
mock_proc.returncode = 0
|
||||
mock_proc.communicate.return_value = (b"Already up to date.", b"")
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", return_value=mock_proc
|
||||
) as mock_exec:
|
||||
await GitService.pull("/tmp/repo", "feature-branch")
|
||||
|
||||
mock_exec.assert_called_once_with(
|
||||
"git",
|
||||
"-C",
|
||||
"/tmp/repo",
|
||||
"pull",
|
||||
"origin",
|
||||
"feature-branch",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
|
||||
class TestGitServiceBranchExistsRemotely:
|
||||
"""Tests for GitService.branch_exists_remotely."""
|
||||
|
||||
def test_branch_exists(self):
|
||||
"""Returns True when branch exists on remote."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "abc123 refs/heads/main\n"
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = GitService.branch_exists_remotely("/tmp/repo", "main")
|
||||
|
||||
assert result is True
|
||||
mock_run.assert_called_once_with(
|
||||
["git", "-C", "/tmp/repo", "ls-remote", "--heads", "origin", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def test_branch_not_exists(self):
|
||||
"""Returns False when branch does not exist on remote."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = ""
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = GitService.branch_exists_remotely("/tmp/repo", "deleted-branch")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_ls_remote_fails(self):
|
||||
"""Returns False when ls-remote fails."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 128
|
||||
mock_result.stdout = ""
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = GitService.branch_exists_remotely("/tmp/repo", "main")
|
||||
|
||||
assert result is False
|
||||
@@ -411,8 +411,9 @@ class TestStartInstanceLegacyFallback:
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@@ -423,8 +424,9 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -440,7 +442,6 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -509,8 +510,9 @@ class TestStartInstanceLegacyFallback:
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@@ -521,8 +523,9 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -538,7 +541,6 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -606,8 +608,9 @@ class TestStartInstanceLegacyFallback:
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@@ -618,8 +621,9 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -635,7 +639,6 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -705,12 +708,14 @@ class TestStartInstanceSshPermissions:
|
||||
"""SSH key mounts trigger permission fixes after container starts."""
|
||||
|
||||
@patch("src.api.tool_instances.write_compose_file")
|
||||
@patch("src.api.tool_instances.prepare_ssh_key_files")
|
||||
@patch("src.api.tool_instances.apply_ssh_permissions")
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
@@ -719,12 +724,14 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
mock_apply_ssh,
|
||||
mock_prepare_ssh,
|
||||
mock_write_compose,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
@@ -743,7 +750,6 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -836,12 +842,14 @@ class TestStartInstanceSshPermissions:
|
||||
assert result["status"] == "running"
|
||||
mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user")
|
||||
|
||||
@patch("src.api.tool_instances.prepare_ssh_key_files")
|
||||
@patch("src.api.tool_instances.apply_ssh_permissions")
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@patch("src.api.tool_instances._get_owned_project")
|
||||
@@ -850,12 +858,14 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
mock_apply_ssh,
|
||||
mock_prepare_ssh,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
@@ -870,7 +880,6 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -933,14 +942,15 @@ class TestStartInstanceSshPermissions:
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
result = await start_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
instance_id=fake_instance_id,
|
||||
data=None,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
with patch("src.api.tool_instances._modify_compose_file"):
|
||||
result = await start_instance(
|
||||
project_id=fake_project_id,
|
||||
repo_id=fake_repo_id,
|
||||
instance_id=fake_instance_id,
|
||||
data=None,
|
||||
user_id=fake_user_id,
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert result["status"] == "running"
|
||||
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
|
||||
@@ -952,8 +962,9 @@ class TestStartInstanceManifestBranch:
|
||||
@patch("src.api.tool_instances.wait_for_container_running")
|
||||
@patch("src.api.tool_instances.execute_compose_command")
|
||||
@patch("src.api.tool_instances.get_container_id")
|
||||
@patch("src.api.tool_instances.get_container_name")
|
||||
@patch("src.api.tool_instances.connect_container_to_network")
|
||||
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances.write_compose_file")
|
||||
@@ -966,8 +977,9 @@ class TestStartInstanceManifestBranch:
|
||||
mock_write_compose,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -987,7 +999,6 @@ class TestStartInstanceManifestBranch:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
|
||||
@@ -54,15 +54,13 @@ export async function createInstance(
|
||||
branch?: string,
|
||||
newBranch?: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
workspaceId?: string
|
||||
sshKeyIds?: string[]
|
||||
): Promise<ToolInstance> {
|
||||
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,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/** 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<Workspace[]> {
|
||||
const response = await apiClient.get<Workspace[]>(workspaceUrl(projectId, repoId));
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createWorkspace(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
data: CreateWorkspaceRequest,
|
||||
): Promise<Workspace> {
|
||||
const response = await apiClient.post<Workspace>(workspaceUrl(projectId, repoId), data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getWorkspace(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
): Promise<Workspace> {
|
||||
const response = await apiClient.get<Workspace>(workspaceUrl(projectId, repoId, workspaceId));
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateWorkspace(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
data: Partial<CreateWorkspaceRequest>,
|
||||
): Promise<Workspace> {
|
||||
const response = await apiClient.patch<Workspace>(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<SyncResult> {
|
||||
const response = await apiClient.post<SyncResult>(
|
||||
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -24,7 +24,6 @@ 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" },
|
||||
|
||||
@@ -229,13 +229,12 @@ export function SessionCard({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isTerminalOnly && onRecreateTunnel && (
|
||||
{hasTunnelError && onRecreateTunnel && (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
className="secondary-button small"
|
||||
onClick={() => onRecreateTunnel(session)}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
title="Recreate Cloudflare tunnel"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
<span className="action-label">Tunnel</span>
|
||||
@@ -324,7 +323,7 @@ export function SessionCard({
|
||||
onClose={() => setShowActionSheet(false)}
|
||||
title={session.display_name}
|
||||
actions={[
|
||||
...(isActive && !isTerminalOnly && onRecreateTunnel
|
||||
...(isActive && hasTunnelError && onRecreateTunnel
|
||||
? [
|
||||
{
|
||||
id: "tunnel",
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/** 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<void>;
|
||||
}
|
||||
|
||||
export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) {
|
||||
const [toolTypeId, setToolTypeId] = useState("");
|
||||
const [configProfileId, setConfigProfileId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
<Icon name="play" size="sm" /> Start Tool on {workspace.name}
|
||||
</h3>
|
||||
<button className="btn btn-icon" onClick={onClose}>
|
||||
<Icon name="cancel" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type">Tool Type</label>
|
||||
<select
|
||||
id="tool-type"
|
||||
value={toolTypeId}
|
||||
onChange={(e) => setToolTypeId(e.target.value)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="">Select a tool...</option>
|
||||
<option value="code-server">Code Server</option>
|
||||
<option value="jupyter-notebook">Jupyter Notebook</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-profile">Config Profile (optional)</label>
|
||||
<input
|
||||
id="config-profile"
|
||||
type="text"
|
||||
value={configProfileId}
|
||||
onChange={(e) => setConfigProfileId(e.target.value)}
|
||||
placeholder="Profile ID"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Starting..." : "Start Tool"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/** 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 (
|
||||
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
||||
<div className="workspace-header">
|
||||
<h4>{workspace.name}</h4>
|
||||
<span className={`status-badge ${statusClass}`}>{workspace.status}</span>
|
||||
</div>
|
||||
<div className="workspace-meta">
|
||||
<p className="workspace-project">
|
||||
{workspace.project_name} / {workspace.repo_name}
|
||||
</p>
|
||||
<p className="workspace-branch">
|
||||
<Icon name="branch" size="sm" /> {workspace.branch}
|
||||
</p>
|
||||
{workspace.instance_count > 0 && (
|
||||
<p className="workspace-instances">
|
||||
{workspace.instance_count} active tool
|
||||
{workspace.instance_count > 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => onStartTool(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="play" size="sm" /> Start Tool
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onSync(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="refresh" size="sm" /> Sync
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => onDelete(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/** 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<void>;
|
||||
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<string | null>(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 (
|
||||
<form className="workspace-create-form card" onSubmit={handleSubmit}>
|
||||
<h3>
|
||||
<Icon name="add" size="sm" /> Create Workspace
|
||||
</h3>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ws-name">Name</label>
|
||||
<input
|
||||
id="ws-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., feature-branch"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ws-branch">
|
||||
<Icon name="branch" size="sm" /> Branch
|
||||
</label>
|
||||
<input
|
||||
id="ws-branch"
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,187 +1,158 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
stopInstance,
|
||||
deleteInstance,
|
||||
startInstance,
|
||||
recreateInstanceTunnel,
|
||||
stopInstance,
|
||||
deleteInstance,
|
||||
startInstance,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import type { Session } from "../api/sessions";
|
||||
|
||||
interface UseInstanceActionsOptions {
|
||||
onRefresh: () => Promise<void>;
|
||||
onRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseInstanceActionsReturn {
|
||||
loadingSessionId: string | null;
|
||||
dirtyDeleteSession: Session | null;
|
||||
dirtyDeleteFiles: string[];
|
||||
handleOpen: (session: Session) => void;
|
||||
handleStart: (session: Session) => Promise<void>;
|
||||
handleStop: (session: Session) => Promise<void>;
|
||||
handleDelete: (session: Session) => Promise<void>;
|
||||
handleForceDelete: (session: Session) => Promise<void>;
|
||||
handleRecreateTunnel: (session: Session) => Promise<void>;
|
||||
clearDirtyDelete: () => void;
|
||||
loadingSessionId: string | null;
|
||||
dirtyDeleteSession: Session | null;
|
||||
dirtyDeleteFiles: string[];
|
||||
handleOpen: (session: Session) => void;
|
||||
handleStart: (session: Session) => Promise<void>;
|
||||
handleStop: (session: Session) => Promise<void>;
|
||||
handleDelete: (session: Session) => Promise<void>;
|
||||
handleForceDelete: (session: Session) => Promise<void>;
|
||||
handleRecreateTunnel: (session: Session) => Promise<void>;
|
||||
clearDirtyDelete: () => void;
|
||||
}
|
||||
|
||||
export function useInstanceActions(
|
||||
options: UseInstanceActionsOptions,
|
||||
options: UseInstanceActionsOptions
|
||||
): UseInstanceActionsReturn {
|
||||
const { onRefresh } = options;
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
|
||||
null,
|
||||
);
|
||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||
const { onRefresh } = options;
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||
|
||||
const handleOpen = useCallback((session: Session) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||
window.location.href = `/instances/${session.id}/terminal`;
|
||||
return;
|
||||
}
|
||||
window.location.href = `/projects/${session.project_id}`;
|
||||
}, []);
|
||||
const handleOpen = useCallback((session: Session) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||
window.location.href = `/instances/${session.id}/terminal`;
|
||||
return;
|
||||
}
|
||||
window.location.href = `/projects/${session.project_id}`;
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await startInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh],
|
||||
);
|
||||
const handleStart = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await startInstance(session.project_id, session.repository_id, session.id);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh]
|
||||
);
|
||||
|
||||
const handleStop = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await stopInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh],
|
||||
);
|
||||
const handleStop = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await deleteInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: { detail?: { changed_files?: string[] } };
|
||||
};
|
||||
};
|
||||
if (axiosError.response?.status === 409) {
|
||||
const detail = axiosError.response.data?.detail;
|
||||
if (detail?.changed_files) {
|
||||
setDirtyDeleteSession(session);
|
||||
setDirtyDeleteFiles(detail.changed_files);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh],
|
||||
);
|
||||
const handleDelete = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
const axiosError = error as {
|
||||
response?: { status?: number; data?: { detail?: { changed_files?: string[] } } };
|
||||
};
|
||||
if (axiosError.response?.status === 409) {
|
||||
const detail = axiosError.response.data?.detail;
|
||||
if (detail?.changed_files) {
|
||||
setDirtyDeleteSession(session);
|
||||
setDirtyDeleteFiles(detail.changed_files);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh]
|
||||
);
|
||||
|
||||
const handleForceDelete = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await deleteInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
true,
|
||||
);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh],
|
||||
);
|
||||
const handleForceDelete = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await deleteInstance(session.project_id, session.repository_id, session.id, true);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh]
|
||||
);
|
||||
|
||||
const handleRecreateTunnel = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const message =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail || "Failed to recreate tunnel";
|
||||
alert(message);
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh],
|
||||
);
|
||||
const handleRecreateTunnel = useCallback(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh]
|
||||
);
|
||||
|
||||
const clearDirtyDelete = useCallback(() => {
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
}, []);
|
||||
const clearDirtyDelete = useCallback(() => {
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loadingSessionId,
|
||||
dirtyDeleteSession,
|
||||
dirtyDeleteFiles,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
clearDirtyDelete,
|
||||
};
|
||||
return {
|
||||
loadingSessionId,
|
||||
dirtyDeleteSession,
|
||||
dirtyDeleteFiles,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
clearDirtyDelete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
/** 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<Workspace>;
|
||||
delete: (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: Workspace,
|
||||
onRefresh: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
sync: (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: Workspace,
|
||||
onRefresh: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
update: (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspaceId: string,
|
||||
data: Partial<CreateWorkspaceRequest>,
|
||||
) => Promise<Workspace>;
|
||||
}
|
||||
|
||||
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<string | null>(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<void>,
|
||||
) => {
|
||||
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<void>,
|
||||
) => {
|
||||
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<CreateWorkspaceRequest>,
|
||||
) => {
|
||||
return updateWorkspace(projectId, repoId, workspaceId, data);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
loadingId,
|
||||
create,
|
||||
delete: deleteAction,
|
||||
sync,
|
||||
update,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/** 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<void>;
|
||||
}
|
||||
|
||||
export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult {
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/** 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 { createInstance, startInstance } from "../api/sessions";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
export function WorkspacesPage() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [startWorkspace, setStartWorkspace] = useState<Workspace | 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 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;
|
||||
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 (
|
||||
<div className="page workspaces-page">
|
||||
<header className="page-header">
|
||||
<h1>Workspaces</h1>
|
||||
<div className="header-actions">
|
||||
<button className="btn btn-secondary" onClick={refresh} disabled={loading}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{showCreate && (
|
||||
<WorkspaceCreateForm
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading && workspaces.length === 0 ? (
|
||||
<div className="loading-state">Loading workspaces...</div>
|
||||
) : workspaces.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No workspaces yet.</p>
|
||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
<Icon name="add" size="sm" /> Create your first workspace
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspaces-grid">
|
||||
{workspaces.map((ws) => (
|
||||
<WorkspaceCard
|
||||
key={ws.id}
|
||||
workspace={ws}
|
||||
loading={actions.loadingId === ws.id}
|
||||
onStartTool={setStartWorkspace}
|
||||
onSync={handleSync}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{startWorkspace && (
|
||||
<StartToolModal
|
||||
workspace={startWorkspace}
|
||||
onClose={() => setStartWorkspace(null)}
|
||||
onStart={handleStartTool}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ 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 (
|
||||
@@ -46,7 +45,6 @@ export const AppRouter = () => {
|
||||
<Route path="*" element={<Navigate to="general" replace />} />
|
||||
</Route>
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/** 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;
|
||||
}
|
||||
@@ -13,7 +13,11 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
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
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
# Design: Workspace-Based Tool Instances
|
||||
|
||||
## Status
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Phase | **Design** |
|
||||
| Based on | [Spec](spec.md) |
|
||||
| Next | Tasks |
|
||||
|
||||
## Decision: No Migration
|
||||
|
||||
Existing tool instances will be left as-is. Users will create new workspaces and new tool instances. Old instances remain functional but read-only (no migration path). This simplifies the implementation significantly.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||
│ │ Sidebar │ │Workspaces│ │Create WS │ │Start Tool │ │
|
||||
│ │ (new) │ │ List │ │ Flow │ │Modal │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backend API │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │Workspace CRUD│ │Workspace Sync│ │Instance Start (refact)│ │
|
||||
│ │ /workspaces │ │ /sync │ │ /start │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────┼──────────────────────────────┐ │
|
||||
│ │ GitService │ WorkspaceService │ │
|
||||
│ │ (clone, fetch, pull) │ (create, delete, sync) │ │
|
||||
│ └───────────────────────────┼──────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────┼──────────────────────────────┐ │
|
||||
│ │ Docker Compose │ File System │ │
|
||||
│ │ (mount workspace path) │ /data/working-copies/... │ │
|
||||
│ └───────────────────────────┴──────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Backend Design
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
apps/api/src/
|
||||
├── api/
|
||||
│ ├── workspaces.py # NEW: Workspace CRUD endpoints
|
||||
│ └── tool_instances.py # MODIFIED: use workspace_id
|
||||
├── models/
|
||||
│ ├── workspace.py # NEW: Workspace model
|
||||
│ └── tool_instance.py # MODIFIED: add workspace_id
|
||||
├── services/
|
||||
│ ├── workspace_manager.py # NEW: Workspace lifecycle
|
||||
│ ├── git_service.py # NEW: Git operations (clone, fetch, pull)
|
||||
│ └── docker.py # EXISTING: mount workspace path
|
||||
└── alembic/versions/
|
||||
└── 2026_06_01_add_workspaces.py # NEW migration
|
||||
```
|
||||
|
||||
### Model: Workspace
|
||||
|
||||
```python
|
||||
class Workspace(Base):
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("git_repositories.id"), nullable=False)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
branch: Mapped[str] = mapped_column(String(255), nullable=False, default="main")
|
||||
path: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready")
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||
)
|
||||
```
|
||||
|
||||
### Service: WorkspaceManager
|
||||
|
||||
```python
|
||||
class WorkspaceManager:
|
||||
"""Manages workspace lifecycle: create, delete, sync, validate."""
|
||||
|
||||
BASE_PATH = "/data/working-copies"
|
||||
|
||||
async def create(
|
||||
self,
|
||||
repo: GitRepository,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
branch: str = "main",
|
||||
) -> Workspace:
|
||||
"""Clone repo to workspace path and create DB record."""
|
||||
path = f"{self.BASE_PATH}/{repo.id}/{name}"
|
||||
# Clone repo
|
||||
await GitService.clone(repo.remote_url, branch, path)
|
||||
# Create record
|
||||
workspace = Workspace(...)
|
||||
return workspace
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Delete workspace and all associated tool instances."""
|
||||
instances = await self._get_running_instances(workspace)
|
||||
if instances and not force:
|
||||
raise WorkspaceHasInstancesError(instances)
|
||||
|
||||
# Stop and delete all instances
|
||||
for instance in instances:
|
||||
await self._stop_and_delete_instance(instance)
|
||||
|
||||
# Delete directory
|
||||
shutil.rmtree(workspace.path, ignore_errors=True)
|
||||
|
||||
# Delete record
|
||||
await session.delete(workspace)
|
||||
|
||||
async def sync(self, workspace: Workspace) -> SyncResult:
|
||||
"""Fetch remote and detect deleted branches."""
|
||||
result = await GitService.fetch(workspace.path)
|
||||
if not GitService.branch_exists_remotely(workspace.path, workspace.branch):
|
||||
return SyncResult(branch_deleted=True)
|
||||
|
||||
await GitService.pull(workspace.path, workspace.branch)
|
||||
workspace.last_sync_at = datetime.now()
|
||||
return SyncResult(branch_deleted=False)
|
||||
```
|
||||
|
||||
### Service: GitService
|
||||
|
||||
```python
|
||||
class GitService:
|
||||
"""Git operations for workspace management."""
|
||||
|
||||
@staticmethod
|
||||
async def clone(remote_url: str, branch: str, path: str) -> None:
|
||||
"""Clone a repo to the given path."""
|
||||
cmd = ["git", "clone", "--branch", branch, "--single-branch", remote_url, path]
|
||||
# Run via asyncio subprocess
|
||||
|
||||
@staticmethod
|
||||
async def fetch(path: str) -> None:
|
||||
"""Fetch from origin."""
|
||||
cmd = ["git", "-C", path, "fetch", "origin"]
|
||||
|
||||
@staticmethod
|
||||
async def pull(path: str, branch: str) -> None:
|
||||
"""Pull latest changes."""
|
||||
cmd = ["git", "-C", path, "pull", "origin", branch]
|
||||
|
||||
@staticmethod
|
||||
def branch_exists_remotely(path: str, branch: str) -> bool:
|
||||
"""Check if a branch exists on the remote."""
|
||||
cmd = ["git", "-C", path, "ls-remote", "--heads", "origin", branch]
|
||||
# Return True if output is not empty
|
||||
```
|
||||
|
||||
### API: Workspaces
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
||||
|
||||
@router.post("/")
|
||||
async def create_workspace(
|
||||
project_id: uuid.UUID,
|
||||
repo_id: uuid.UUID,
|
||||
data: CreateWorkspaceRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> WorkspaceResponse:
|
||||
repo = await get_repo(repo_id, user_id, session)
|
||||
workspace = await WorkspaceManager().create(repo, user_id, data.name, data.branch)
|
||||
session.add(workspace)
|
||||
await session.commit()
|
||||
return workspace
|
||||
|
||||
@router.delete("/{workspace_id}")
|
||||
async def delete_workspace(
|
||||
workspace_id: uuid.UUID,
|
||||
force: bool = Query(False),
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
workspace = await get_workspace(workspace_id, user_id, session)
|
||||
try:
|
||||
await WorkspaceManager().delete(workspace, force=force)
|
||||
except WorkspaceHasInstancesError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Workspace has running tool instances",
|
||||
"instances": [{"id": str(i.id), "name": i.name} for i in exc.instances],
|
||||
},
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
|
||||
@router.post("/{workspace_id}/sync")
|
||||
async def sync_workspace(
|
||||
workspace_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> SyncResult:
|
||||
workspace = await get_workspace(workspace_id, user_id, session)
|
||||
result = await WorkspaceManager().sync(workspace)
|
||||
if result.branch_deleted:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"Branch '{workspace.branch}' was deleted from remote",
|
||||
"branch_deleted": True,
|
||||
},
|
||||
)
|
||||
return result
|
||||
```
|
||||
|
||||
### Updated: Instance Start
|
||||
|
||||
```python
|
||||
@router.post("/{instance_id}/start")
|
||||
async def start_instance(
|
||||
instance_id: uuid.UUID,
|
||||
data: StartInstanceRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
instance = await get_instance(instance_id, user_id, session)
|
||||
|
||||
# Get workspace
|
||||
workspace = await session.get(Workspace, instance.workspace_id)
|
||||
if not workspace:
|
||||
raise HTTPException(400, "Workspace not found")
|
||||
|
||||
# Mount workspace path instead of repo path
|
||||
repo_path = workspace.path
|
||||
|
||||
# Generate compose with workspace mount
|
||||
compose_content = generate_compose(workspace, instance, tool_type)
|
||||
|
||||
# ... rest of start logic
|
||||
```
|
||||
|
||||
## Frontend Design
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
apps/web/src/
|
||||
├── pages/
|
||||
│ ├── workspaces.tsx # NEW: Workspaces list page
|
||||
│ └── workspace-detail.tsx # NEW: Workspace detail page
|
||||
├── components/
|
||||
│ ├── workspace-card.tsx # NEW: Workspace card component
|
||||
│ ├── workspace-create-form.tsx # NEW: Create workspace form
|
||||
│ ├── start-tool-modal.tsx # NEW: Start tool on workspace modal
|
||||
│ └── sidebar.tsx # MODIFIED: add Workspaces nav
|
||||
├── hooks/
|
||||
│ ├── use-workspaces.ts # NEW: Workspace data hook
|
||||
│ └── use-workspace-actions.ts # NEW: Workspace CRUD actions
|
||||
├── api/
|
||||
│ └── workspaces.ts # NEW: Workspace API client
|
||||
└── types/
|
||||
└── workspace.ts # NEW: Workspace types
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
repo_id: string;
|
||||
repo_name: string;
|
||||
project_name: string;
|
||||
user_id: string;
|
||||
branch: string;
|
||||
path: string;
|
||||
status: "ready" | "syncing" | "error";
|
||||
last_sync_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
instance_count: number;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceRequest {
|
||||
name: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
branch_deleted: boolean;
|
||||
pulled: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Component: WorkspaceCard
|
||||
|
||||
```tsx
|
||||
export function WorkspaceCard({
|
||||
workspace,
|
||||
onStartTool,
|
||||
onSync,
|
||||
onDelete,
|
||||
}: WorkspaceCardProps) {
|
||||
return (
|
||||
<article className="card workspace-card">
|
||||
<div className="workspace-header">
|
||||
<h4>{workspace.name}</h4>
|
||||
<span className={`status-badge ${workspace.status}`}>
|
||||
{workspace.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="workspace-meta">
|
||||
<p>{workspace.project_name} / {workspace.repo_name}</p>
|
||||
<p><Icon name="branch" /> {workspace.branch}</p>
|
||||
{workspace.instance_count > 0 && (
|
||||
<p>{workspace.instance_count} active tool{workspace.instance_count > 1 ? "s" : ""}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<button onClick={() => onStartTool(workspace)}>
|
||||
<Icon name="play" /> Start Tool
|
||||
</button>
|
||||
<button onClick={() => onSync(workspace)}>
|
||||
<Icon name="refresh" /> Sync
|
||||
</button>
|
||||
<button onClick={() => onDelete(workspace)} className="danger">
|
||||
<Icon name="delete" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component: Sidebar (updated)
|
||||
|
||||
```tsx
|
||||
const navItems = [
|
||||
{ path: "/dashboard", label: "Dashboard", icon: "home" },
|
||||
{ path: "/projects", label: "Projects", icon: "folder" },
|
||||
{ path: "/workspaces", label: "Workspaces", icon: "workspace" },
|
||||
{ path: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
```
|
||||
|
||||
### Hook: useWorkspaceActions
|
||||
|
||||
```typescript
|
||||
export function useWorkspaceActions(options: { onRefresh: () => Promise<void> }) {
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = useCallback(async (workspace: Workspace, force = false) => {
|
||||
setLoadingId(workspace.id);
|
||||
try {
|
||||
await deleteWorkspace(workspace.repo_id, workspace.id, force);
|
||||
await options.onRefresh();
|
||||
} catch (err) {
|
||||
const error = err as AxiosError<{ detail?: { instances?: Array<{id: string, name: string}> } }>;
|
||||
if (error.response?.status === 409 && !force) {
|
||||
const instances = error.response.data?.detail?.instances || [];
|
||||
const confirmed = confirm(
|
||||
`This workspace has ${instances.length} running tool instance(s):\n` +
|
||||
instances.map(i => `- ${i.name}`).join("\n") +
|
||||
`\n\nDelete workspace and all instances?`
|
||||
);
|
||||
if (confirmed) {
|
||||
await handleDelete(workspace, true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}, [options.onRefresh]);
|
||||
|
||||
const handleSync = useCallback(async (workspace: Workspace) => {
|
||||
setLoadingId(workspace.id);
|
||||
try {
|
||||
const result = await syncWorkspace(workspace.repo_id, workspace.id);
|
||||
await options.onRefresh();
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err as AxiosError<{ detail?: { branch_deleted?: boolean; message?: string } }>;
|
||||
if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
|
||||
const confirmed = confirm(
|
||||
`${error.response.data.detail.message}\n\nDelete this workspace?`
|
||||
);
|
||||
if (confirmed) {
|
||||
await handleDelete(workspace, true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}, [options.onRefresh, handleDelete]);
|
||||
|
||||
return { loadingId, handleDelete, handleSync };
|
||||
}
|
||||
```
|
||||
|
||||
## Compose Template Updates
|
||||
|
||||
### Workspace Mount
|
||||
|
||||
All compose templates will mount the workspace path instead of the repo path:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
container_name: ${INSTANCE_NAME}
|
||||
volumes:
|
||||
- ${WORKSPACE_PATH}:/workspace
|
||||
working_dir: /workspace
|
||||
# ... rest of config
|
||||
```
|
||||
|
||||
The `${WORKSPACE_PATH}` variable replaces `${REPO_PATH}` in all templates.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | HTTP Status | Frontend Behavior |
|
||||
|---|---|---|
|
||||
| Workspace name not unique per repo | 409 | Show inline validation error |
|
||||
| Workspace has running instances | 409 | Show confirmation dialog |
|
||||
| Branch deleted from remote | 409 | Show confirmation dialog to delete workspace |
|
||||
| Repo not found | 404 | Show error toast |
|
||||
| Git clone failed | 500 | Show error toast with git stderr |
|
||||
| Workspace path missing | 500 | Show error toast |
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Git clone** is synchronous and slow; run in background with status polling
|
||||
- **Workspace list** should include `instance_count` via subquery (not N+1)
|
||||
- **Sync** is fast (fetch only), but pull may be slow; run async
|
||||
- **Delete with instances** stops instances sequentially; consider parallel
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Workspace paths must be validated to prevent path traversal
|
||||
- Users can only access their own workspaces
|
||||
- Git credentials (SSH keys) must be available during clone
|
||||
- Workspace directories must have correct ownership for container users
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Backend
|
||||
- Unit: WorkspaceManager.create, delete, sync
|
||||
- Unit: GitService.clone, fetch, pull, branch_exists_remotely
|
||||
- Integration: Create workspace → start tool → verify mount
|
||||
- Integration: Delete workspace with running instances
|
||||
- Integration: Sync with deleted branch
|
||||
|
||||
### Frontend
|
||||
- Component: WorkspaceCard renders correctly
|
||||
- Component: Create form validates name uniqueness
|
||||
- Hook: useWorkspaceActions handles 409 confirmation
|
||||
- E2E: Create workspace → start tool → delete workspace
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Auto-sync on schedule
|
||||
- Workspace sharing between users
|
||||
- Git push/pull/branch UI
|
||||
- Pre-created default workspaces
|
||||
- Read-only workspace mode
|
||||
- Workspace backup/restore
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New Files
|
||||
- `apps/api/src/models/workspace.py`
|
||||
- `apps/api/src/api/workspaces.py`
|
||||
- `apps/api/src/services/workspace_manager.py`
|
||||
- `apps/api/src/services/git_service.py`
|
||||
- `apps/api/alembic/versions/2026_06_01_add_workspaces.py`
|
||||
- `apps/web/src/pages/workspaces.tsx`
|
||||
- `apps/web/src/pages/workspace-detail.tsx`
|
||||
- `apps/web/src/components/workspace-card.tsx`
|
||||
- `apps/web/src/components/workspace-create-form.tsx`
|
||||
- `apps/web/src/components/start-tool-modal.tsx`
|
||||
- `apps/web/src/hooks/use-workspaces.ts`
|
||||
- `apps/web/src/hooks/use-workspace-actions.ts`
|
||||
- `apps/web/src/api/workspaces.ts`
|
||||
- `apps/web/src/types/workspace.ts`
|
||||
|
||||
### Modified Files
|
||||
- `apps/api/src/models/tool_instance.py` (add workspace_id)
|
||||
- `apps/api/src/api/tool_instances.py` (use workspace path)
|
||||
- `apps/web/src/components/sidebar.tsx` (add nav item)
|
||||
- `apps/web/src/pages/dashboard.tsx` (add workspaces section)
|
||||
- `apps/web/src/api/sessions.ts` (add workspace endpoints)
|
||||
@@ -1,103 +0,0 @@
|
||||
# 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/<repo>`) 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/<repo-id>/<copy-name>`)
|
||||
- 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
|
||||
@@ -1,132 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,261 +0,0 @@
|
||||
# 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
|
||||
@@ -1,138 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user