From 6b947b75933d17f4a8d38d0dc0efb00f7c91fdbe Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 12 Jun 2026 15:47:26 +0000 Subject: [PATCH] feat: complete working-copies workspace-first cleanup - Remove clone_mode/branch/new_branch from frontend create session flow. - Add workspace picker to CreateSessionForm; auto-create default workspace when repo selected. - Fix tool-starter.tsx and use-start-tool.ts createInstance signatures after API change. - Remove clone mode badge from SessionCard. - Delete stale backend unit tests referencing removed clone_mode schema fields. - Update OpenSpec working-copies tasks and mark change completed. - Regenerate project maps. Quality gates: npm run typecheck, npm run lint, npm test -- --run (82 passed), python3 -m py_compile on changed backend files. --- .pi-map.index.md | 2 +- .pi-map.md | 6 +- apps/.pi-map.index.md | 2 +- apps/.pi-map.md | 4 +- .../unit/test_session_branch_selection.py | 179 --- .../tests/unit/test_tool_instances_legacy.py | 1101 ----------------- apps/web/.pi-map.index.md | 2 +- apps/web/.pi-map.md | 4 +- apps/web/src/.pi-map.index.md | 2 +- apps/web/src/.pi-map.md | 4 +- apps/web/src/components/.pi-map.index.md | 2 +- apps/web/src/components/.pi-map.md | 4 +- .../src/components/features/.pi-map.index.md | 2 +- apps/web/src/components/features/.pi-map.md | 4 +- .../features/session/.pi-map.index.md | 2 +- .../components/features/session/.pi-map.md | 10 +- .../features/session/create-session-form.tsx | 86 +- .../features/session/session-card.tsx | 8 - .../components/features/tool/.pi-map.index.md | 2 +- .../src/components/features/tool/.pi-map.md | 6 +- .../components/features/tool/tool-starter.tsx | 5 +- apps/web/src/hooks/.pi-map.index.md | 2 +- apps/web/src/hooks/.pi-map.md | 6 +- apps/web/src/hooks/use-start-tool.ts | 5 +- openspec/.pi-map.index.md | 2 +- openspec/.pi-map.md | 4 +- openspec/changes/.pi-map.index.md | 2 +- openspec/changes/.pi-map.md | 4 +- .../changes/working-copies/.openspec.yaml | 4 + .../changes/working-copies/.pi-map.index.md | 15 +- openspec/changes/working-copies/.pi-map.md | 15 +- openspec/changes/working-copies/tasks.md | 72 +- 32 files changed, 182 insertions(+), 1386 deletions(-) delete mode 100644 apps/api/tests/unit/test_session_branch_selection.py delete mode 100644 apps/api/tests/unit/test_tool_instances_legacy.py create mode 100644 openspec/changes/working-copies/.openspec.yaml diff --git a/.pi-map.index.md b/.pi-map.index.md index ea22442..0594587 100644 --- a/.pi-map.index.md +++ b/.pi-map.index.md @@ -16,7 +16,7 @@ dir: . Trust boundary: index routes, map orients, source decides. ## role -Infrastructure and deployment configuration for a self-hosted project management platform with OAuth2 authentication, providing Docker Compose orchestration, environment templates, and development tooling. +Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing Docker orchestration, environment templates, and development tooling. ## parent - ## children diff --git a/.pi-map.md b/.pi-map.md index 61d8d07..13e05e0 100644 --- a/.pi-map.md +++ b/.pi-map.md @@ -18,10 +18,10 @@ index: ./.pi-map.index.md Trust boundary: index routes, map orients, source decides. ## role -Infrastructure and deployment configuration for a self-hosted project management platform with OAuth2 authentication, providing Docker Compose orchestration, environment templates, and development tooling. +Infrastructure and deployment configuration package for a self-hosted project management platform with OAuth2 authentication, providing Docker orchestration, environment templates, and development tooling. ## files - .env.example | Provides a template of environment variables for configuring a Headquarter application with PostgreSQL, Redis, Authentik SSO, and Docker/Traefik deployment -- .gitignore | Specifies files and directories for Git to ignore across a multi-language project with Python, Node, and various tooling | dep: git +- .gitignore | Specifies files and directories for Git to ignore across a multi-language project with Python, Node, and custom tooling | dep: Git - AGENTS.md | Defines operational rules, workflows, and constraints for AI agents working within an OpenSpec-driven software development project. | dep: OpenSpec, superpowers, git, docker compose, conventional commits - CHANGELOG.md | Documents version history and notable changes for a Git-based project management web application - Makefile | Provides standard development commands for containerized web application lifecycle management via Docker Compose | dep: docker compose, alembic, pytest, ruff, mypy, playwright, npm, postgres, redis @@ -31,7 +31,7 @@ Infrastructure and deployment configuration for a self-hosted project management - progress.md | Tracks completed and remaining tasks for a backend-frontend code refactoring project organized in 7 phases - swap-pane | Empty file with no functionality ## arch -Containerized microservices architecture using Docker Compose with separate frontend/API/PostgreSQL/Redis services, Traefik reverse proxy integration, and environment-driven configuration; includes AI agent governance via OpenSpec and Makefile-driven development workflows. +Containerized microservices architecture using Docker Compose with PostgreSQL/Redis data layer, Traefik reverse proxy for TLS/ingress, and environment-driven configuration; includes Python/Node multi-language backend-frontend split with Makefile-driven lifecycle management. ## tags docker, redis, git, application, postgresql, compose, traefik, project ## symbols diff --git a/apps/.pi-map.index.md b/apps/.pi-map.index.md index fe888c5..169ed5a 100644 --- a/apps/.pi-map.index.md +++ b/apps/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps ## role -Contains the main application entry points and executable modules for the project. +Contains the top-level application entry points and executable binaries for the project. ## parent index: ./.pi-map.index.md map: ./.pi-map.md diff --git a/apps/.pi-map.md b/apps/.pi-map.md index c94de1e..89e62dd 100644 --- a/apps/.pi-map.md +++ b/apps/.pi-map.md @@ -4,10 +4,10 @@ dir: apps index: apps/.pi-map.index.md ## role -Contains the main application entry points and executable modules for the project. +Contains the top-level application entry points and executable binaries for the project. ## files ## arch -Typically follows a modular architecture where each subdirectory represents a separate deployable application sharing common domain libraries. +Follows a workspace/monorepo pattern where each subdirectory is a distinct deployable application sharing common libraries. ## tags - ## symbols diff --git a/apps/api/tests/unit/test_session_branch_selection.py b/apps/api/tests/unit/test_session_branch_selection.py deleted file mode 100644 index ed8b940..0000000 --- a/apps/api/tests/unit/test_session_branch_selection.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Tests for session creation with branch selection and new branch creation.""" - -import os -import subprocess -import tempfile - - -from src.api.tool_instances import CreateInstanceRequest - - -class TestCreateInstanceRequest: - """Tests for CreateInstanceRequest model.""" - - def test_default_values(self): - """Test default values for CreateInstanceRequest.""" - request = CreateInstanceRequest(tool_type_id="123") - assert request.clone_mode == "mount" - assert request.branch == "main" - assert request.new_branch is None - assert request.display_name is None - - def test_clone_mode_with_branch(self): - """Test CreateInstanceRequest with clone mode and branch.""" - request = CreateInstanceRequest( - tool_type_id="123", - clone_mode="clone", - branch="dev", - ) - assert request.clone_mode == "clone" - assert request.branch == "dev" - - def test_new_branch_field(self): - """Test CreateInstanceRequest with new_branch field.""" - request = CreateInstanceRequest( - tool_type_id="123", - clone_mode="clone", - branch="main", - new_branch="feature/test", - ) - assert request.new_branch == "feature/test" - - -class TestBranchCreationInClone: - """Tests for branch creation logic in clone process.""" - - def test_create_local_branch_success(self): - """Test successful local branch creation.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Initialize repo - subprocess.run( - ["git", "init", tmpdir], - capture_output=True, - check=True, - ) - subprocess.run( - ["git", "-C", tmpdir, "config", "user.email", "test@test.com"], - capture_output=True, - check=True, - ) - subprocess.run( - ["git", "-C", tmpdir, "config", "user.name", "Test User"], - capture_output=True, - check=True, - ) - - # Create initial commit - readme = os.path.join(tmpdir, "README.md") - with open(readme, "w") as f: - f.write("# Test\n") - subprocess.run( - ["git", "-C", tmpdir, "add", "README.md"], - capture_output=True, - check=True, - ) - subprocess.run( - ["git", "-C", tmpdir, "commit", "-m", "Initial commit"], - capture_output=True, - check=True, - ) - - # Create new branch - result = subprocess.run( - ["git", "-C", tmpdir, "checkout", "-b", "feature/new-branch"], - capture_output=True, - text=True, - ) - - assert result.returncode == 0 - - # Verify branch exists - branches_result = subprocess.run( - ["git", "-C", tmpdir, "branch", "--show-current"], - capture_output=True, - text=True, - ) - assert branches_result.stdout.strip() == "feature/new-branch" - - def test_create_local_branch_invalid_name(self): - """Test local branch creation with invalid name fails.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Initialize repo - subprocess.run( - ["git", "init", tmpdir], - capture_output=True, - check=True, - ) - subprocess.run( - ["git", "-C", tmpdir, "config", "user.email", "test@test.com"], - capture_output=True, - check=True, - ) - subprocess.run( - ["git", "-C", tmpdir, "config", "user.name", "Test User"], - capture_output=True, - check=True, - ) - - # Create initial commit - readme = os.path.join(tmpdir, "README.md") - with open(readme, "w") as f: - f.write("# Test\n") - subprocess.run( - ["git", "-C", tmpdir, "add", "README.md"], - capture_output=True, - check=True, - ) - subprocess.run( - ["git", "-C", tmpdir, "commit", "-m", "Initial commit"], - capture_output=True, - check=True, - ) - - # Try to create branch with invalid name (contains spaces) - result = subprocess.run( - ["git", "-C", tmpdir, "checkout", "-b", "invalid branch name"], - capture_output=True, - text=True, - ) - - # Git accepts branch names with spaces but it's not recommended - # This test verifies the command structure - assert result.returncode == 0 or "fatal" in result.stderr - - -class TestCreateInstanceAPI: - """Tests for create instance API endpoint with branch options.""" - - def test_create_instance_request_validation(self): - """Test that CreateInstanceRequest validates correctly.""" - # Valid request with new_branch - request = CreateInstanceRequest( - tool_type_id="550e8400-e29b-41d4-a716-446655440000", - clone_mode="clone", - branch="main", - new_branch="feature/test", - ) - assert request.new_branch == "feature/test" - - # Valid request without new_branch - request2 = CreateInstanceRequest( - tool_type_id="550e8400-e29b-41d4-a716-446655440000", - clone_mode="clone", - branch="dev", - ) - assert request2.new_branch is None - - def test_create_instance_with_new_branch_sets_instance_branch(self): - """Test that instance branch is set to new_branch when provided.""" - # This tests the logic: data.new_branch if data.new_branch else data.branch - new_branch = "feature/test" - base_branch = "main" - - # Simulate the logic from create_instance - stored_branch = new_branch if new_branch else base_branch - assert stored_branch == "feature/test" - - # Without new_branch - stored_branch2 = None if None else base_branch - assert stored_branch2 == "main" diff --git a/apps/api/tests/unit/test_tool_instances_legacy.py b/apps/api/tests/unit/test_tool_instances_legacy.py deleted file mode 100644 index 2bcc1e2..0000000 --- a/apps/api/tests/unit/test_tool_instances_legacy.py +++ /dev/null @@ -1,1101 +0,0 @@ -"""Unit tests for legacy tool instance fallback paths. - -Verifies that tool types with definition_type "dockerfile", "compose", -and "legacy" continue to use the original startup flow after the -manifest-based flow was introduced. -""" - -import os -import uuid -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.api.tool_instances import create_instance, start_instance -from src.models.git_repository import GitRepository -from src.models.tool_instance import ToolInstance -from src.models.tool_type import ToolType -from src.models.ssh_key import SSHKey -from src.models.user import User - - -@pytest.fixture -def fake_user_id() -> uuid.UUID: - return uuid.uuid4() - - -@pytest.fixture -def fake_project_id() -> uuid.UUID: - return uuid.uuid4() - - -@pytest.fixture -def fake_repo_id() -> uuid.UUID: - return uuid.uuid4() - - -@pytest.fixture -def fake_tool_type_id() -> uuid.UUID: - return uuid.uuid4() - - -@pytest.fixture -def fake_instance_id() -> uuid.UUID: - return uuid.uuid4() - - -@pytest.fixture -def mock_session(fake_user_id, fake_project_id, fake_repo_id, fake_tool_type_id): - """Return an async SQLAlchemy session with basic mocks.""" - session = AsyncMock() - - user = User(id=fake_user_id, email="test@example.com") - project = MagicMock() - project.id = fake_project_id - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - - async def _get(model, pk): - if model is User and pk == fake_user_id: - return user - if model is GitRepository and pk == fake_repo_id: - return repo - return None - - def _add(instance): - if getattr(instance, "created_at", None) is None: - instance.created_at = datetime.now() - if getattr(instance, "updated_at", None) is None: - instance.updated_at = datetime.now() - - session.get.side_effect = _get - session.add = MagicMock(side_effect=_add) - session.execute.return_value = MagicMock( - scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))) - ) - return session - - -class TestCreateInstanceDockerfileLegacy: - """Legacy dockerfile definition type in create_instance.""" - - @patch("src.api.tool_instances.ensure_instance_directory") - @patch("src.api.tool_instances.find_free_port") - @patch("src.api.tool_instances.build_image") - @patch("src.api.tool_instances.write_compose_file") - @patch("src.api.tool_instances._get_user") - @patch("src.api.tool_instances._get_owned_project") - async def test_builds_from_dockerfile_template( - self, - mock_get_project, - mock_get_user, - mock_write_compose, - mock_build_image, - mock_find_port, - mock_ensure_dir, - mock_session, - fake_user_id, - fake_project_id, - fake_repo_id, - fake_tool_type_id, - ) -> None: - """When definition_type is 'dockerfile', build_image is called with the template.""" - mock_get_user.return_value = AsyncMock() - mock_get_project.return_value = AsyncMock() - mock_find_port.return_value = 12345 - mock_ensure_dir.return_value = "/data/instances/test-instance" - mock_build_image.return_value = (0, "built", "") - - tool_type = ToolType( - id=fake_tool_type_id, - name="legacy-df-tool", - display_name="Legacy DF Tool", - default_port=8080, - definition_type="dockerfile", - dockerfile_template="FROM python:3.11\nRUN echo hi", - compose_template=None, - ) - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - - async def _get(model, pk): - if model is ToolType and pk == fake_tool_type_id: - return tool_type - if model is GitRepository and pk == fake_repo_id: - return repo - if model is User and pk == fake_user_id: - return User(id=fake_user_id, email="test@example.com") - return None - - mock_session.get.side_effect = _get - - data = MagicMock() - data.tool_type_id = str(fake_tool_type_id) - data.display_name = None - data.workspace_id = None - data.clone_mode = "mount" - data.branch = None - data.new_branch = None - data.config_profile_id = None - data.ssh_key_ids = [] - - result = await create_instance( - project_id=fake_project_id, - repo_id=fake_repo_id, - data=data, - user_id=fake_user_id, - session=mock_session, - ) - - assert result["status"] == "pending" - mock_build_image.assert_called_once() - call_kwargs = mock_build_image.call_args.kwargs - assert call_kwargs["dockerfile"] == "FROM python:3.11\nRUN echo hi" - mock_write_compose.assert_called_once() - - @patch("src.api.tool_instances.ensure_instance_directory") - @patch("src.api.tool_instances.find_free_port") - @patch("src.api.tool_instances.build_image") - @patch("src.api.tool_instances.write_compose_file") - @patch("src.api.tool_instances._get_user") - @patch("src.api.tool_instances._get_owned_project") - async def test_dockerfile_build_failure_raises_500( - self, - mock_get_project, - mock_get_user, - mock_write_compose, - mock_build_image, - mock_find_port, - mock_ensure_dir, - mock_session, - fake_user_id, - fake_project_id, - fake_repo_id, - fake_tool_type_id, - ) -> None: - """Failed dockerfile build should raise HTTP 500.""" - from fastapi import HTTPException - - mock_get_user.return_value = AsyncMock() - mock_get_project.return_value = AsyncMock() - mock_find_port.return_value = 12345 - mock_ensure_dir.return_value = "/data/instances/test-instance" - mock_build_image.return_value = (1, "", "build failed") - - tool_type = ToolType( - id=fake_tool_type_id, - name="legacy-df-tool", - display_name="Legacy DF Tool", - default_port=8080, - definition_type="dockerfile", - dockerfile_template="FROM invalid", - compose_template=None, - ) - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - - async def _get(model, pk): - if model is ToolType and pk == fake_tool_type_id: - return tool_type - if model is GitRepository and pk == fake_repo_id: - return repo - if model is User and pk == fake_user_id: - return User(id=fake_user_id, email="test@example.com") - return None - - mock_session.get.side_effect = _get - - data = MagicMock() - data.tool_type_id = str(fake_tool_type_id) - data.display_name = None - data.workspace_id = None - data.clone_mode = "mount" - data.branch = None - data.new_branch = None - data.config_profile_id = None - data.ssh_key_ids = [] - - with pytest.raises(HTTPException) as exc_info: - await create_instance( - project_id=fake_project_id, - repo_id=fake_repo_id, - data=data, - user_id=fake_user_id, - session=mock_session, - ) - - assert exc_info.value.status_code == 500 - assert "Failed to build Docker image" in exc_info.value.detail - - -class TestCreateInstanceComposeLegacy: - """Legacy compose definition type in create_instance.""" - - @patch("src.api.tool_instances.ensure_instance_directory") - @patch("src.api.tool_instances.find_free_port") - @patch("src.api.tool_instances.render_compose_template") - @patch("src.api.tool_instances.write_compose_file") - @patch("src.api.tool_instances._get_user") - @patch("src.api.tool_instances._get_owned_project") - async def test_renders_compose_template( - self, - mock_get_project, - mock_get_user, - mock_write_compose, - mock_render_compose, - mock_find_port, - mock_ensure_dir, - mock_session, - fake_user_id, - fake_project_id, - fake_repo_id, - fake_tool_type_id, - ) -> None: - """When definition_type is 'compose', render_compose_template is called.""" - mock_get_user.return_value = AsyncMock() - mock_get_project.return_value = AsyncMock() - mock_find_port.return_value = 12345 - mock_ensure_dir.return_value = "/data/instances/test-instance" - mock_render_compose.return_value = "services:\n app:\n image: nginx" - - tool_type = ToolType( - id=fake_tool_type_id, - name="legacy-compose-tool", - display_name="Legacy Compose Tool", - default_port=80, - definition_type="compose", - dockerfile_template=None, - compose_template="services:\n app:\n image: nginx\n ports:\n - ${TOOL_PORT}:80", - ) - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - - async def _get(model, pk): - if model is ToolType and pk == fake_tool_type_id: - return tool_type - if model is GitRepository and pk == fake_repo_id: - return repo - if model is User and pk == fake_user_id: - return User(id=fake_user_id, email="test@example.com") - return None - - mock_session.get.side_effect = _get - - data = MagicMock() - data.tool_type_id = str(fake_tool_type_id) - data.display_name = None - data.workspace_id = None - data.clone_mode = "mount" - data.branch = None - data.new_branch = None - data.config_profile_id = None - data.ssh_key_ids = [] - - result = await create_instance( - project_id=fake_project_id, - repo_id=fake_repo_id, - data=data, - user_id=fake_user_id, - session=mock_session, - ) - - assert result["status"] == "pending" - mock_render_compose.assert_called_once() - args = mock_render_compose.call_args[0] - assert "services:" in args[0] - mock_write_compose.assert_called_once() - - -class TestCreateInstanceManifestNotCalledForLegacy: - """Ensure manifest compiler is never invoked for legacy types.""" - - @patch("src.api.tool_instances.ensure_instance_directory") - @patch("src.api.tool_instances.find_free_port") - @patch("src.api.tool_instances.build_image") - @patch("src.api.tool_instances.write_compose_file") - @patch("src.api.tool_instances._prepare_manifest_instance") - @patch("src.api.tool_instances._get_user") - @patch("src.api.tool_instances._get_owned_project") - async def test_dockerfile_does_not_call_manifest_compiler( - self, - mock_get_project, - mock_get_user, - mock_prepare_manifest, - mock_write_compose, - mock_build_image, - mock_find_port, - mock_ensure_dir, - mock_session, - fake_user_id, - fake_project_id, - fake_repo_id, - fake_tool_type_id, - ) -> None: - """Legacy dockerfile type must not trigger manifest compilation.""" - mock_get_user.return_value = AsyncMock() - mock_get_project.return_value = AsyncMock() - mock_find_port.return_value = 12345 - mock_ensure_dir.return_value = "/data/instances/test-instance" - mock_build_image.return_value = (0, "built", "") - - tool_type = ToolType( - id=fake_tool_type_id, - name="legacy-df", - display_name="Legacy", - default_port=8080, - definition_type="dockerfile", - dockerfile_template="FROM alpine", - compose_template=None, - ) - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - - async def _get(model, pk): - if model is ToolType and pk == fake_tool_type_id: - return tool_type - if model is GitRepository and pk == fake_repo_id: - return repo - if model is User and pk == fake_user_id: - return User(id=fake_user_id, email="test@example.com") - return None - - mock_session.get.side_effect = _get - - data = MagicMock() - data.tool_type_id = str(fake_tool_type_id) - data.display_name = None - data.workspace_id = None - data.clone_mode = "mount" - data.branch = None - data.new_branch = None - data.config_profile_id = None - data.ssh_key_ids = [] - - await create_instance( - project_id=fake_project_id, - repo_id=fake_repo_id, - data=data, - user_id=fake_user_id, - session=mock_session, - ) - - mock_prepare_manifest.assert_not_called() - - -class TestStartInstanceLegacyFallback: - """Legacy paths in start_instance must NOT call manifest compiler.""" - - @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.connect_container_to_network") - @patch("src.api.tool_instances._ensure_backend_network_in_compose") - @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") - @patch("src.api.tool_instances._get_owned_project") - async def test_legacy_type_skips_manifest_flow( - self, - mock_get_project, - mock_get_user, - mock_prepare_manifest, - mock_sanitize, - mock_ensure_web_bind, - mock_ensure_container_name, - mock_backend_network, - mock_connect_network, - mock_get_container_id, - mock_execute_compose, - mock_wait_container, - mock_session, - fake_user_id, - fake_project_id, - fake_repo_id, - fake_instance_id, - fake_tool_type_id, - ) -> None: - """When definition_type is 'legacy', start_instance uses old flow.""" - mock_get_user.return_value = AsyncMock() - mock_get_project.return_value = AsyncMock() - mock_execute_compose.return_value = (0, "started", "") - mock_get_container_id.return_value = "abc123" - mock_connect_network.return_value = True - mock_wait_container.return_value = { - "success": True, - "status": "running", - "waited_seconds": 0.5, - } - - instance = ToolInstance( - id=fake_instance_id, - name="legacy-instance", - repository_id=fake_repo_id, - tool_type_id=fake_tool_type_id, - compose_path="/data/instances/legacy-instance/docker-compose.yml", - status="stopped", - clone_mode="mount", - created_at=datetime.now(), - updated_at=datetime.now(), - ) - tool_type = ToolType( - id=fake_tool_type_id, - name="legacy-tool", - display_name="Legacy Tool", - default_port=8080, - definition_type="legacy", - manifest_id=None, - dockerfile_template=None, - compose_template="services:\n app:\n image: nginx", - ) - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - - async def _get(model, pk): - if model is ToolInstance and pk == fake_instance_id: - return instance - if model is ToolType and pk == fake_tool_type_id: - return tool_type - if model is GitRepository and pk == fake_repo_id: - return repo - if model is User and pk == fake_user_id: - return User(id=fake_user_id, email="test@example.com") - return None - - mock_session.get.side_effect = _get - - # Ensure compose file exists so the check passes - 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, - ) - - assert result["status"] == "running" - mock_prepare_manifest.assert_not_called() - mock_execute_compose.assert_called_once() - - @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.connect_container_to_network") - @patch("src.api.tool_instances._ensure_backend_network_in_compose") - @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") - @patch("src.api.tool_instances._get_owned_project") - async def test_compose_type_skips_manifest_flow( - self, - mock_get_project, - mock_get_user, - mock_prepare_manifest, - mock_sanitize, - mock_ensure_web_bind, - mock_ensure_container_name, - mock_backend_network, - mock_connect_network, - mock_get_container_id, - mock_execute_compose, - mock_wait_container, - mock_session, - fake_user_id, - fake_project_id, - fake_repo_id, - fake_instance_id, - fake_tool_type_id, - ) -> None: - """When definition_type is 'compose', start_instance uses old flow.""" - mock_get_user.return_value = AsyncMock() - mock_get_project.return_value = AsyncMock() - mock_execute_compose.return_value = (0, "started", "") - mock_get_container_id.return_value = "abc123" - mock_connect_network.return_value = True - mock_wait_container.return_value = { - "success": True, - "status": "running", - "waited_seconds": 0.5, - } - - instance = ToolInstance( - id=fake_instance_id, - name="compose-instance", - repository_id=fake_repo_id, - tool_type_id=fake_tool_type_id, - compose_path="/data/instances/compose-instance/docker-compose.yml", - status="stopped", - clone_mode="mount", - created_at=datetime.now(), - updated_at=datetime.now(), - ) - tool_type = ToolType( - id=fake_tool_type_id, - name="compose-tool", - display_name="Compose Tool", - default_port=8080, - definition_type="compose", - manifest_id=None, - dockerfile_template=None, - compose_template="services:\n app:\n image: nginx", - ) - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - - async def _get(model, pk): - if model is ToolInstance and pk == fake_instance_id: - return instance - if model is ToolType and pk == fake_tool_type_id: - return tool_type - if model is GitRepository and pk == fake_repo_id: - return repo - if model is User and pk == fake_user_id: - return User(id=fake_user_id, email="test@example.com") - return None - - 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, - ) - - assert result["status"] == "running" - mock_prepare_manifest.assert_not_called() - mock_execute_compose.assert_called_once() - - @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.connect_container_to_network") - @patch("src.api.tool_instances._ensure_backend_network_in_compose") - @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") - @patch("src.api.tool_instances._get_owned_project") - async def test_dockerfile_type_skips_manifest_flow( - self, - mock_get_project, - mock_get_user, - mock_prepare_manifest, - mock_sanitize, - mock_ensure_web_bind, - mock_ensure_container_name, - mock_backend_network, - mock_connect_network, - mock_get_container_id, - mock_execute_compose, - mock_wait_container, - mock_session, - fake_user_id, - fake_project_id, - fake_repo_id, - fake_instance_id, - fake_tool_type_id, - ) -> None: - """When definition_type is 'dockerfile', start_instance uses old flow.""" - mock_get_user.return_value = AsyncMock() - mock_get_project.return_value = AsyncMock() - mock_execute_compose.return_value = (0, "started", "") - mock_get_container_id.return_value = "abc123" - mock_connect_network.return_value = True - mock_wait_container.return_value = { - "success": True, - "status": "running", - "waited_seconds": 0.5, - } - - instance = ToolInstance( - id=fake_instance_id, - name="df-instance", - repository_id=fake_repo_id, - tool_type_id=fake_tool_type_id, - compose_path="/data/instances/df-instance/docker-compose.yml", - status="stopped", - clone_mode="mount", - created_at=datetime.now(), - updated_at=datetime.now(), - ) - tool_type = ToolType( - id=fake_tool_type_id, - name="df-tool", - display_name="Dockerfile Tool", - default_port=8080, - definition_type="dockerfile", - manifest_id=None, - dockerfile_template="FROM alpine", - compose_template=None, - ) - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - - async def _get(model, pk): - if model is ToolInstance and pk == fake_instance_id: - return instance - if model is ToolType and pk == fake_tool_type_id: - return tool_type - if model is GitRepository and pk == fake_repo_id: - return repo - if model is User and pk == fake_user_id: - return User(id=fake_user_id, email="test@example.com") - return None - - 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, - ) - - assert result["status"] == "running" - mock_prepare_manifest.assert_not_called() - mock_execute_compose.assert_called_once() - - -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.connect_container_to_network") - @patch("src.api.tool_instances._ensure_backend_network_in_compose") - @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") - async def test_manifest_instance_applies_ssh_permissions( - self, - mock_get_project, - mock_get_user, - mock_sanitize, - mock_ensure_web_bind, - mock_ensure_container_name, - mock_backend_network, - mock_connect_network, - mock_get_container_id, - mock_execute_compose, - mock_wait_container, - mock_apply_ssh, - mock_prepare_ssh, - mock_write_compose, - mock_session, - fake_user_id, - fake_project_id, - fake_repo_id, - fake_instance_id, - fake_tool_type_id, - ) -> None: - """Manifest instance with SSH keys calls apply_ssh_permissions.""" - from src.models.tool_definition_manifest import ToolDefinitionManifest - - manifest_id = uuid.uuid4() - ssh_key_id = str(uuid.uuid4()) - - mock_get_user.return_value = AsyncMock() - mock_get_project.return_value = AsyncMock() - mock_execute_compose.return_value = (0, "started", "") - mock_get_container_id.return_value = "abc123" - mock_connect_network.return_value = True - mock_wait_container.return_value = { - "success": True, - "status": "running", - "waited_seconds": 0.5, - } - mock_apply_ssh.return_value = {"success": True, "error": None} - - instance = ToolInstance( - id=fake_instance_id, - name="manifest-instance", - repository_id=fake_repo_id, - tool_type_id=fake_tool_type_id, - compose_path="/data/instances/manifest-instance/docker-compose.yml", - status="stopped", - clone_mode="mount", - ssh_key_ids=[ssh_key_id], - created_at=datetime.now(), - updated_at=datetime.now(), - ) - tool_type = ToolType( - id=fake_tool_type_id, - name="manifest-tool", - display_name="Manifest Tool", - default_port=8080, - definition_type="manifest", - manifest_id=manifest_id, - dockerfile_template=None, - compose_template=None, - ) - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - manifest_def = ToolDefinitionManifest( - id=manifest_id, - name="test-manifest", - display_name="Test Manifest", - interface_type="web", - manifest={"user": {"name": "user", "uid": 1001, "gid": 1001}}, - ) - ssh_key = SSHKey( - id=uuid.UUID(ssh_key_id), - user_id=fake_user_id, - name="test-key", - public_key="ssh-ed25519 AAA test@test", - private_key_encrypted="enc", - ) - - async def _get(model, pk): - if model is ToolInstance and pk == fake_instance_id: - return instance - if model is ToolType and pk == fake_tool_type_id: - return tool_type - if model is GitRepository and pk == fake_repo_id: - return repo - if model is User and pk == fake_user_id: - return User(id=fake_user_id, email="test@example.com") - if model is ToolDefinitionManifest and pk == manifest_id: - return manifest_def - if model is SSHKey and pk == uuid.UUID(ssh_key_id): - return ssh_key - return None - - mock_session.get.side_effect = _get - - with patch("os.path.exists", return_value=True): - with patch("os.makedirs"): - with patch( - "src.api.tool_instances._prepare_manifest_instance" - ) as mock_prepare: - mock_prepare.return_value = ( - "headquarter/test:latest", - "services:\n app:\n image: test", - {"name": "test-manifest", "user": {"name": "user"}}, - "/home/user", - ) - 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", "/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.connect_container_to_network") - @patch("src.api.tool_instances._ensure_backend_network_in_compose") - @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") - async def test_legacy_instance_applies_ssh_permissions( - self, - mock_get_project, - mock_get_user, - mock_sanitize, - mock_ensure_web_bind, - mock_ensure_container_name, - mock_backend_network, - mock_connect_network, - mock_get_container_id, - mock_execute_compose, - mock_wait_container, - mock_apply_ssh, - mock_prepare_ssh, - mock_session, - fake_user_id, - fake_project_id, - fake_repo_id, - fake_instance_id, - fake_tool_type_id, - ) -> None: - """Legacy instance with SSH keys calls apply_ssh_permissions.""" - ssh_key_id = str(uuid.uuid4()) - - mock_get_user.return_value = AsyncMock() - mock_get_project.return_value = AsyncMock() - mock_execute_compose.return_value = (0, "started", "") - mock_get_container_id.return_value = "abc123" - mock_connect_network.return_value = True - mock_wait_container.return_value = { - "success": True, - "status": "running", - "waited_seconds": 0.5, - } - mock_apply_ssh.return_value = {"success": True, "error": None} - - instance = ToolInstance( - id=fake_instance_id, - name="legacy-instance", - repository_id=fake_repo_id, - tool_type_id=fake_tool_type_id, - compose_path="/data/instances/legacy-instance/docker-compose.yml", - status="stopped", - clone_mode="mount", - ssh_key_ids=[ssh_key_id], - created_at=datetime.now(), - updated_at=datetime.now(), - ) - tool_type = ToolType( - id=fake_tool_type_id, - name="legacy-tool", - display_name="Legacy Tool", - default_port=8080, - definition_type="legacy", - manifest_id=None, - dockerfile_template=None, - compose_template="services:\n app:\n image: nginx", - ) - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - ssh_key = SSHKey( - id=uuid.UUID(ssh_key_id), - user_id=fake_user_id, - name="test-key", - public_key="ssh-ed25519 AAA test@test", - private_key_encrypted="enc", - ) - - async def _get(model, pk): - if model is ToolInstance and pk == fake_instance_id: - return instance - if model is ToolType and pk == fake_tool_type_id: - return tool_type - if model is GitRepository and pk == fake_repo_id: - return repo - if model is User and pk == fake_user_id: - return User(id=fake_user_id, email="test@example.com") - if model is SSHKey and pk == uuid.UUID(ssh_key_id): - return ssh_key - return None - - mock_session.get.side_effect = _get - - with patch("os.path.exists", return_value=True): - with patch("os.makedirs"): - 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") - - -class TestStartInstanceManifestBranch: - """Manifest branch is taken ONLY when definition_type == 'manifest'.""" - - @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.connect_container_to_network") - @patch("src.api.tool_instances._ensure_backend_network_in_compose") - @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") - @patch("src.api.tool_instances._get_user") - @patch("src.api.tool_instances._get_owned_project") - async def test_manifest_type_calls_compiler( - self, - mock_get_project, - mock_get_user, - mock_write_compose, - mock_prepare_manifest, - mock_sanitize, - mock_ensure_web_bind, - mock_ensure_container_name, - mock_backend_network, - mock_connect_network, - mock_get_container_id, - mock_execute_compose, - mock_wait_container, - mock_session, - fake_user_id, - fake_project_id, - fake_repo_id, - fake_instance_id, - fake_tool_type_id, - ) -> None: - """When definition_type is 'manifest' and manifest_id is set, compiler runs.""" - from src.models.tool_definition_manifest import ToolDefinitionManifest - - manifest_id = uuid.uuid4() - - mock_get_user.return_value = AsyncMock() - mock_get_project.return_value = AsyncMock() - mock_execute_compose.return_value = (0, "started", "") - mock_get_container_id.return_value = "abc123" - mock_connect_network.return_value = True - mock_wait_container.return_value = { - "success": True, - "status": "running", - "waited_seconds": 0.5, - } - mock_prepare_manifest.return_value = ( - "headquarter/test:latest", - "services:\n app:\n image: test", - {"name": "test-manifest"}, - "/root", - ) - - instance = ToolInstance( - id=fake_instance_id, - name="manifest-instance", - repository_id=fake_repo_id, - tool_type_id=fake_tool_type_id, - compose_path="/data/instances/manifest-instance/docker-compose.yml", - status="stopped", - clone_mode="mount", - created_at=datetime.now(), - updated_at=datetime.now(), - ) - tool_type = ToolType( - id=fake_tool_type_id, - name="manifest-tool", - display_name="Manifest Tool", - default_port=8080, - definition_type="manifest", - manifest_id=manifest_id, - dockerfile_template=None, - compose_template=None, - ) - repo = GitRepository( - id=fake_repo_id, - project_id=fake_project_id, - name="test-repo", - path="/data/repos/test-repo", - remote_url=None, - ssh_key_id=None, - ) - manifest_def = ToolDefinitionManifest( - id=manifest_id, - name="test-manifest", - display_name="Test Manifest", - interface_type="web", - manifest={"base_image": "alpine"}, - ) - - async def _get(model, pk): - if model is ToolInstance and pk == fake_instance_id: - return instance - if model is ToolType and pk == fake_tool_type_id: - return tool_type - if model is GitRepository and pk == fake_repo_id: - return repo - if model is User and pk == fake_user_id: - return User(id=fake_user_id, email="test@example.com") - if model is ToolDefinitionManifest and pk == manifest_id: - return manifest_def - return None - - 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, - ) - - assert result["status"] == "running" - mock_prepare_manifest.assert_called_once() - mock_execute_compose.assert_called_once() diff --git a/apps/web/.pi-map.index.md b/apps/web/.pi-map.index.md index e30ec98..44a1bb2 100644 --- a/apps/web/.pi-map.index.md +++ b/apps/web/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps/web ## role -Browser-based web frontend providing the user-facing React application for code editing, terminal access, and routing functionality. +Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project. ## parent index: apps/.pi-map.index.md map: apps/.pi-map.md diff --git a/apps/web/.pi-map.md b/apps/web/.pi-map.md index 58708ac..5abeae7 100644 --- a/apps/web/.pi-map.md +++ b/apps/web/.pi-map.md @@ -4,7 +4,7 @@ dir: apps/web index: apps/web/.pi-map.index.md ## role -Browser-based web frontend providing the user-facing React application for code editing, terminal access, and routing functionality. +Frontend web application providing a React-based UI with code editing, terminal, and routing capabilities for the "headquarter" project. ## files - .env.example | Template file defining example environment variables for frontend API and application URL configuration - .eslintrc.cjs | Configures ESLint for a TypeScript browser project with modern ECMAScript module support | dep: @typescript-eslint/parser, @typescript-eslint/eslint-plugin, eslint @@ -16,7 +16,7 @@ Browser-based web frontend providing the user-facing React application for code - tsconfig.json | TypeScript configuration file for a React project using Vite with modern ES2020 target and bundler module resolution | dep: typescript, react, vite - vite.config.ts | Configures Vite build tool for a React project with custom dev server port and Vitest test settings. | dep: vite, @vitejs/plugin-react ## arch -Modern React SPA built with Vite and TypeScript, containerized via multi-stage Docker/nginx deployment with client-side routing and optimized static asset delivery. +Modern React SPA built with Vite and TypeScript, containerized via multi-stage Docker with nginx serving, featuring client-side routing, optimized static asset delivery, and development tooling (ESLint, Vitest). ## tags react, eslint, vite, typescript, dom, application, nginx, web ## symbols diff --git a/apps/web/src/.pi-map.index.md b/apps/web/src/.pi-map.index.md index 70409a3..c175698 100644 --- a/apps/web/src/.pi-map.index.md +++ b/apps/web/src/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps/web/src ## role -Frontend web application entry point and core infrastructure for a React-based single-page application with authentication, routing, and domain type definitions. +Frontend web application entry point and core infrastructure for a React single-page application with authentication, routing, and domain type definitions. ## parent index: apps/web/.pi-map.index.md map: apps/web/.pi-map.md diff --git a/apps/web/src/.pi-map.md b/apps/web/src/.pi-map.md index e7bab6b..115c146 100644 --- a/apps/web/src/.pi-map.md +++ b/apps/web/src/.pi-map.md @@ -4,13 +4,13 @@ dir: apps/web/src index: apps/web/src/.pi-map.index.md ## role -Frontend web application entry point and core infrastructure for a React-based single-page application with authentication, routing, and domain type definitions. +Frontend web application entry point and core infrastructure for a React single-page application with authentication, routing, and domain type definitions. ## files - main.tsx | Entry point that bootstraps a React SPA with routing, authentication, and session management context providers. | dep: react, react-dom/client, react-router-dom, ./router, ./state/auth, ./state/sessions, ./styles/tokens.css, ./styles/global.css, ./styles/utilities.css, ./styles/syntax-highlight.css, ./styles/pages/git-history.css, ./styles/pages/repo-workspace.css, ./styles/pages/projects.css, ./styles/pages/sessions.css, ./styles/pages/ssh-keys.css, ./styles/pages/workspace-detail.css, ./styles/pages/workspaces.css, react-dom, ./styles/* - router.tsx | Defines the React Router configuration for a web application with protected routes, nested layouts, and redirects. | exp: AppRouter | dep: react-router-dom, ./components/app-shell, ./components/protected-route, ./pages/DashboardPage, ./pages/PlaceholderPage, ./pages/ProfilePage, ./pages/ProjectsPage, ./pages/GitRepositoriesPage, ./pages/GitHistoryPage, ./pages/ProjectSettingsPage, ./pages/SettingsPage, ./pages/TerminalPage, ./pages/ToolWorkshopPage, ./pages/SshKeysPage, ./pages/ConfigProfilesPage, ./pages/SessionsPage, ./pages/WorkspacesPage, ./pages/WorkspaceDetailPage - types.ts | Defines TypeScript type definitions for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos ## arch -Layered React SPA architecture using context providers for cross-cutting concerns (auth/session), declarative routing with protected route guards and nested layouts, and centralized TypeScript type definitions for domain models. +Layered React SPA architecture using context providers for cross-cutting concerns (auth/session), declarative routing with nested layouts and route guards, and centralized TypeScript type definitions for domain models. ## tags pages, styles, css, router, react, session, dom, workspace ## symbols diff --git a/apps/web/src/components/.pi-map.index.md b/apps/web/src/components/.pi-map.index.md index 726cc8c..cb8226a 100644 --- a/apps/web/src/components/.pi-map.index.md +++ b/apps/web/src/components/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps/web/src/components ## role -Provides reusable, foundational React UI components and utilities for the web application, including layout shell, data visualization, navigation guards, and user feedback systems. +Provides foundational, reusable UI components and utilities for the web application, including layout shell, data states, icon system, code display, routing guards, and toast notification rules. ## parent index: apps/web/src/.pi-map.index.md map: apps/web/src/.pi-map.md diff --git a/apps/web/src/components/.pi-map.md b/apps/web/src/components/.pi-map.md index ac6a0bc..626623b 100644 --- a/apps/web/src/components/.pi-map.md +++ b/apps/web/src/components/.pi-map.md @@ -4,7 +4,7 @@ dir: apps/web/src/components index: apps/web/src/components/.pi-map.index.md ## role -Provides reusable, foundational React UI components and utilities for the web application, including layout shell, data visualization, navigation guards, and user feedback systems. +Provides foundational, reusable UI components and utilities for the web application, including layout shell, data states, icon system, code display, routing guards, and toast notification rules. ## files - app-shell.tsx | Renders the main application shell layout with navigation, session management, and responsive mobile/desktop views for a React Router-based app. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ../state/session-operations, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./features/session/session-progress-panel, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons - code-editor.tsx | A React component that renders a syntax-highlighted code editor with line numbers using react-simple-code-editor. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language @@ -16,7 +16,7 @@ Provides reusable, foundational React UI components and utilities for the web ap - toast-rules.test.ts | Unit tests for mapping instance events to toast notification categories and severities | dep: vitest, ./toast-rules, ../types/events - toast-rules.ts | Maps instance events to toast notifications with deduplication logic to prevent spam | exp: func:mapEventToCategory(event: InstanceEventPayload) → string, call:event.event.startsWith, func:mapEventToSeverity(event: InstanceEventPayload) → "info" | "warning" | "error" | "success", func:handleEventToast(event: InstanceEventPayload) → void, call:shouldShowToast, call:toast.info, call:toast.success, call:toast.warning, call:toast.error, func:clearToastDedup() → void, call:lastToastTime.clear | dep: ../state/toast, ../types/events, toast state module, InstanceEventPayload type ## arch -Component-based architecture with functional React patterns, composition of specialized sub-components (icon, editor, syntax highlighter), separation of concerns via dedicated state-management components (data states, protected routes), and utility modules with pure logic for cross-cutting concerns (toast rules with deduplication). +Follows a component-based React architecture with separation of concerns between presentational components (icon, data-states, code-editor), layout/app-shell orchestration, auth-guarded routing (protected-route), and domain-specific utility modules (toast-rules) with colocated unit tests. ## tags toast, state, react, icon, code, event, editor, protected ## symbols diff --git a/apps/web/src/components/features/.pi-map.index.md b/apps/web/src/components/features/.pi-map.index.md index ef721ff..054480e 100644 --- a/apps/web/src/components/features/.pi-map.index.md +++ b/apps/web/src/components/features/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps/web/src/components/features ## role -Contains reusable React components that implement specific product features and business logic for the web application. +Contains specialized UI components for major feature areas of the web application, organizing components by business domain rather than by atomic design level. ## parent index: apps/web/src/components/.pi-map.index.md map: apps/web/src/components/.pi-map.md diff --git a/apps/web/src/components/features/.pi-map.md b/apps/web/src/components/features/.pi-map.md index 5593dd0..4704bae 100644 --- a/apps/web/src/components/features/.pi-map.md +++ b/apps/web/src/components/features/.pi-map.md @@ -4,10 +4,10 @@ dir: apps/web/src/components/features index: apps/web/src/components/features/.pi-map.index.md ## role -Contains reusable React components that implement specific product features and business logic for the web application. +Contains specialized UI components for major feature areas of the web application, organizing components by business domain rather than by atomic design level. ## files ## arch -Feature-based component organization with domain-specific UI building blocks, likely composed of atomic design elements (from components/ui) and consumed by page-level routes. +Feature-based colocation pattern where components are grouped by product functionality (e.g., checkout, dashboard, settings) rather than by component type, typically combining multiple atomic components with domain-specific logic and data fetching. ## tags - ## symbols diff --git a/apps/web/src/components/features/session/.pi-map.index.md b/apps/web/src/components/features/session/.pi-map.index.md index e88a379..4809a75 100644 --- a/apps/web/src/components/features/session/.pi-map.index.md +++ b/apps/web/src/components/features/session/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps/web/src/components/features/session ## role -Provides UI components for managing development environment sessions including creation, listing, monitoring progress, and interacting with individual sessions. +Provides UI components for managing development sessions including creation, listing, monitoring progress, and interacting with individual sessions. ## parent index: apps/web/src/components/features/.pi-map.index.md map: apps/web/src/components/features/.pi-map.md diff --git a/apps/web/src/components/features/session/.pi-map.md b/apps/web/src/components/features/session/.pi-map.md index ecd20c5..7329737 100644 --- a/apps/web/src/components/features/session/.pi-map.md +++ b/apps/web/src/components/features/session/.pi-map.md @@ -4,16 +4,16 @@ dir: apps/web/src/components/features/session index: apps/web/src/components/features/session/.pi-map.index.md ## role -Provides UI components for managing development environment sessions including creation, listing, monitoring progress, and interacting with individual sessions. +Provides UI components for managing development sessions including creation, listing, monitoring progress, and interacting with individual sessions. ## files -- create-session-form.tsx | React form component for creating development sessions with configurable project, repository, tool type, branch, SSH keys, and config profile options | exp: CreateSessionForm | dep: react, ../../icon, ../../../api/sessions, ../../../types, ../../../api/git-repositories, ../../../api/tool-types, ../../../api/ssh-keys, ../../../api/config-profiles, icon component, sessions API, git-repositories API, ssh-keys API, config-profiles API, tool-types API, types -- session-card.tsx | Renders a React card component displaying session information with status badges, inline editing, action buttons, and responsive mobile/desktop layouts. | exp: SessionCardProps, func:SessionCard({ session, onOpen, onStart, onStop, onDelete, onRecreateTunnel, onRename, isBusy = false, tunnelHealth = null, }: SessionCardProps), call:useState, call:useRef, call:useMobileViewport, call:session.tool_type_interfaces?.includes, call:[ "running", "building", "starting", "probing", "pending", "unhealthy", ].includes, call:useEffect, call:optionsRef.current.contains, call:setOptionsOpen, call:document.addEventListener, call:document.removeEventListener, call:window.confirm, call:onDelete, call:setEditName, call:onRename, call:setIsEditingName, call:editName.trim, call:onOpen, call:new Date(session.created_at).toLocaleString, call:setShowActionSheet, call:onStart, call:onStop, call:onRecreateTunnel | dep: react, ../../../api/sessions, ../../icon, ../../../hooks/use-mobile-viewport, ../mobile/mobile-action-sheet, icon, use-mobile-viewport, mobile-action-sheet, sessions api types +- create-session-form.tsx | A React form component for creating and starting a new development session with configurable project, repository, workspace, tool type, config profile, and SSH key options. | exp: CreateSessionForm | dep: react, ../../icon, ../../../api/sessions, ../../../types, ../../../api/git-repositories, ../../../api/tool-types, ../../../api/ssh-keys, ../../../api/config-profiles, ../../../api/workspaces, ../../../types/workspace, icon, sessions API, git-repositories API, tool-types API, ssh-keys API, config-profiles API, workspaces API, types +- session-card.tsx | Renders a card component displaying session information with status badges, inline rename editing, action buttons, and responsive mobile/desktop layouts including a dropdown options menu and mobile action sheet. | exp: SessionCardProps, func:SessionCard({ session, onOpen, onStart, onStop, onDelete, onRecreateTunnel, onRename, isBusy = false, tunnelHealth = null, }: SessionCardProps), call:useState, call:useRef, call:useMobileViewport, call:session.tool_type_interfaces?.includes, call:[ "running", "building", "starting", "probing", "pending", "unhealthy", ].includes, call:useEffect, call:optionsRef.current.contains, call:setOptionsOpen, call:document.addEventListener, call:document.removeEventListener, call:window.confirm, call:onDelete, call:setEditName, call:onRename, call:setIsEditingName, call:editName.trim, call:onOpen, call:new Date(session.created_at).toLocaleString, call:setShowActionSheet, call:onStart, call:onStop, call:onRecreateTunnel | dep: react, ../../../api/sessions, ../../icon, ../../../hooks/use-mobile-viewport, ../mobile/mobile-action-sheet - session-list.tsx | Renders a list of sessions grouped by active/recent status or as a flat grid, delegating to SessionCard for individual session display. | exp: SessionListProps, func:SessionList({ sessions, onOpen, onStart, onStop, onDelete, onRecreateTunnel, onRename, actionBusyId = null, tunnelHealth = {}, showGrouping = true, activeTitle = "Active Sessions", recentTitle = "Recent Sessions", maxRecent = 5, emptyMessage = "No sessions", }: SessionListProps), call:sessions.filter, call:activeStatuses.includes, call:sessions .filter((s) => recentStatuses.includes(s.status)) .slice, call:recentStatuses.includes, call:sessions.map, call:activeSessions.map, call:recentSessions.map | dep: ../../../api/sessions, ./session-card, Session, SessionCard, InstanceHealth - session-progress-panel.tsx | Renders a panel displaying active and recently completed session operations with step-by-step progress indicators and dismissible notifications. | exp: func:SessionProgressPanel(), call:useSessionOperations, call:useEventContext, call:useEffect, call:updateOperationFromEvent, call:operations.filter, call:Date.now, call:visibleOperations.map, call:dismissOperation | dep: react, ../../../state/session-operations, ../../../state/events, ../../icon, useSessionOperations, useEventContext, Icon ## arch -Feature-based component composition with presentational components following a container/presenter pattern, using status-driven conditional rendering and responsive layout adaptations. +React component composition with feature-specific grouping, responsive design patterns (mobile/desktop layouts), and status-driven conditional rendering with delegated sub-components. ## tags -session, call:use, call:on, card, mobile, call:set, event, api +session, call:use, call:on, api, card, call:set, event, mobile ## symbols - SessionCard - SessionList diff --git a/apps/web/src/components/features/session/create-session-form.tsx b/apps/web/src/components/features/session/create-session-form.tsx index e7b72f1..d32b1c0 100644 --- a/apps/web/src/components/features/session/create-session-form.tsx +++ b/apps/web/src/components/features/session/create-session-form.tsx @@ -13,6 +13,8 @@ import { listConfigProfiles, type ConfigProfile, } from "../../../api/config-profiles"; +import { listWorkspaces, createWorkspace } from "../../../api/workspaces"; +import type { Workspace } from "../../../types/workspace"; interface CreateSessionFormProps { projects: Project[]; @@ -54,6 +56,10 @@ export const CreateSessionForm = ({ const [selectedConfigProfile, setSelectedConfigProfile] = useState(""); const [selectedSshKeyIds, setSelectedSshKeyIds] = useState([]); + const [workspaces, setWorkspaces] = useState([]); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(""); + const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -94,6 +100,50 @@ export const CreateSessionForm = ({ void loadProfiles(); }, [selectedToolType, selectedProject, fixedProjectId]); + // Load workspaces when repository is selected + useEffect(() => { + const projectId = fixedProjectId || selectedProject; + const repoId = fixedRepoId || selectedRepo; + if (!projectId || !repoId) { + setWorkspaces([]); + setSelectedWorkspaceId(""); + return; + } + const loadWorkspaces = async () => { + setIsLoadingWorkspaces(true); + try { + const data = await listWorkspaces(projectId, repoId); + setWorkspaces(data); + if (data.length > 0) { + setSelectedWorkspaceId(data[0].id); + } else { + // Auto-create a default workspace so the user can start a tool + const workspace = await createWorkspace(projectId, repoId, { + name: "default", + branch: "main", + }); + setWorkspaces([workspace]); + setSelectedWorkspaceId(workspace.id); + } + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to load workspaces", + ); + setWorkspaces([]); + setSelectedWorkspaceId(""); + } finally { + setIsLoadingWorkspaces(false); + } + }; + void loadWorkspaces(); + }, [ + fixedProjectId, + selectedProject, + fixedRepoId, + selectedRepo, + repositories, + ]); + // Filter repositories by selected project const availableRepos = selectedProject ? repositories.filter((r) => r.project_id === selectedProject) @@ -106,6 +156,8 @@ export const CreateSessionForm = ({ setDisplayName(""); setSelectedSshKeyIds([]); setSelectedConfigProfile(""); + setWorkspaces([]); + setSelectedWorkspaceId(""); }; const handleSubmit = async (event: React.FormEvent) => { @@ -123,12 +175,19 @@ export const CreateSessionForm = ({ setIsSubmitting(true); try { + const workspaceId = selectedWorkspaceId || undefined; + if (!workspaceId) { + setError("No workspace available for the selected repository"); + setIsSubmitting(false); + return; + } + const instance = await createInstance( projectId, repoId, selectedToolType, displayName || undefined, - undefined, + workspaceId, selectedConfigProfile || undefined, selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined, ); @@ -214,6 +273,7 @@ export const CreateSessionForm = ({ onChange={(e) => { setSelectedRepo(e.target.value); setSelectedToolType(""); + setSelectedWorkspaceId(""); }} disabled={!hasProject || isSubmitting} > @@ -228,6 +288,30 @@ export const CreateSessionForm = ({ )} + {/* Workspace */} + {hasRepo && ( +
+ + {isLoadingWorkspaces ? ( + Loading workspaces... + ) : workspaces.length === 0 ? ( + No workspace available + ) : ( + + )} +
+ )} + {/* Tool Type */} {hasRepo && (
diff --git a/apps/web/src/components/features/session/session-card.tsx b/apps/web/src/components/features/session/session-card.tsx index 53d330e..81856b1 100644 --- a/apps/web/src/components/features/session/session-card.tsx +++ b/apps/web/src/components/features/session/session-card.tsx @@ -176,14 +176,6 @@ export function SessionCard({ )}{" "} · {session.tool_type_name}

- {session.clone_mode && ( -

- - {session.clone_mode === "clone" - ? `Clone${session.branch ? ` (${session.branch})` : ""}` - : "Mount"} -

- )} {session.url && (