Compare commits

..

9 Commits

Author SHA1 Message Date
alex 48fa858090 Merge branch 'main' of ssh://git.commumedia.org:2222/alex/headquarter 2026-05-24 15:04:33 +00:00
alex 679b1693fc Merge commit 'ea174b1'
# Conflicts:
#	apps/web/src/pages/projects.test.tsx
2026-05-24 15:03:58 +00:00
alex ea174b1642 fix: review fixes for el-1bn
- Fix duplicate mode field in config_profiles.py mount response
- Fix datetime.UTC import for Python 3.10 compatibility
- Add API documentation for config profiles
- Update CHANGELOG
2026-05-24 15:02:32 +00:00
alex 9cc98455ef 3.2 Launch and restart profile application (el-5z8) 2026-05-24 14:41:17 +00:00
alex a1dbfcf2a8 feat: implement profile CRUD validation compatibility and defaults API
- Add ConfigProfile CRUD endpoints with user ownership and access checks
- Implement ordered include management with cycle detection
- Add mount management with path validation (absolute, no traversal)
- Implement compatibility-filtered listing by tool type
- Add default profile selection APIs (get/set defaults per tool type)
- Fix SQLAlchemy ambiguous foreign key relationships in config models
- Add comprehensive integration tests (29 tests, all passing)
- Merge upstream profile resolver service changes (task 2.1)
2026-05-24 14:06:03 +00:00
alex 13aceeb08d 2.1 Profile resolver service (el-1nj) 2026-05-24 13:58:39 +00:00
alex f0e19615ce 2.3 Instance API profile selection plumbing (el-4hr) 2026-05-24 13:39:45 +00:00
alex 0bea26c784 1.1 Backend data model and migrations (el-5fe) 2026-05-24 13:23:12 +00:00
alex fb0f2f7b9b feat: add project settings page navigation
- Remove inline edit modal from projects listing page
- Add Settings link to project cards navigating to /projects/:id/settings
- Reposition Open Workspace button to rightmost action for easier access
- Update tests for new UI flow
- Update documentation to reflect new editing workflow
- Sync specs: frontend-foundation and project-management

Quality gates: npm run lint passed
2026-05-22 18:43:40 +00:00
33 changed files with 3143 additions and 502 deletions
+1
View File
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **User Settings** - Theme selection, git identity, and preference management
- **SSH Key Management** - Ed25519 key generation with secure storage
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
### Changed
@@ -0,0 +1,119 @@
"""add profile resolver fields to config profiles and mounts
Revision ID: 0014_add_profile_resolver_fields
Revises: 0013_add_config_profiles
Create Date: 2026-05-24 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0014_add_profile_resolver_fields"
down_revision: Union[str, None] = "0013_add_config_profiles"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add fields to config_profiles
op.add_column(
"config_profiles",
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("environment_variables", sa.JSON(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("start_command", sa.Text(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("working_directory", sa.Text(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("port", sa.Integer(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
)
# Add foreign keys for project and tool_type
op.create_foreign_key(
"fk_config_profiles_project",
"config_profiles",
"projects",
["project_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_config_profiles_tool_type",
"config_profiles",
"tool_types",
["tool_type_id"],
["id"],
ondelete="CASCADE",
)
# Create indices
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
# Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
op.add_column(
"config_mounts",
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
)
op.add_column(
"config_mounts",
sa.Column("files", sa.JSON(), nullable=True),
)
# Drop the source_profile foreign key if it exists
op.drop_constraint(
"config_mounts_source_profile_id_fkey",
"config_mounts",
type_="foreignkey",
)
op.drop_column("config_mounts", "content")
op.drop_column("config_mounts", "source_profile_id")
def downgrade() -> None:
# Restore config_mounts
op.add_column(
"config_mounts",
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_mounts",
sa.Column("content", sa.Text(), nullable=True),
)
op.drop_column("config_mounts", "files")
op.drop_column("config_mounts", "mode")
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
# Restore config_profiles
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
op.drop_constraint("fk_config_profiles_tool_type", "config_profiles", type_="foreignkey")
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
op.drop_column("config_profiles", "is_default")
op.drop_column("config_profiles", "port")
op.drop_column("config_profiles", "working_directory")
op.drop_column("config_profiles", "start_command")
op.drop_column("config_profiles", "environment_variables")
op.drop_column("config_profiles", "tool_type_id")
op.drop_column("config_profiles", "project_id")
+877
View File
@@ -0,0 +1,877 @@
"""Config profile API endpoints."""
import logging
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import 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.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
from src.models.tool_type import ToolType
from src.models.user_config import UserConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
MAX_MOUNT_PATH_LENGTH = 1024
MAX_CONTENT_LENGTH = 1024 * 1024 # 1MB
MAX_INCLUDES_DEPTH = 10
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
created_at: str
updated_at: str
class ConfigProfileDetailResponse(ConfigProfileResponse):
includes: list[dict[str, Any]]
mounts: list[dict[str, Any]]
class ConfigIncludeCreate(BaseModel):
included_profile_id: str = Field(description="UUID of the profile to include")
order_index: int = Field(default=0, description="Order index for include resolution")
class ConfigIncludeUpdate(BaseModel):
order_index: int = Field(description="Order index for include resolution")
class ConfigIncludeResponse(BaseModel):
id: str
profile_id: str
included_profile_id: str
included_profile_name: str | None
order_index: int
created_at: str
updated_at: str
class ConfigMountCreate(BaseModel):
target_path: str = Field(description="Absolute target path in container")
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
order_index: int = Field(default=0, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountUpdate(BaseModel):
target_path: str | None = Field(default=None, description="Absolute target path in container")
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
order_index: int | None = Field(default=None, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountResponse(BaseModel):
id: str
profile_id: str
target_path: str
mode: str
files: dict[str, str] | None
order_index: int
created_at: str
updated_at: str
class DefaultProfilesUpdate(BaseModel):
default_profiles: dict[str, str] = Field(description="Mapping of tool_type_id to profile_id")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _get_owned_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> ConfigProfile:
"""Fetch a config profile and verify ownership."""
profile = await session.get(ConfigProfile, profile_id)
if profile is None or profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
return profile
async def _detect_cycle(
session: AsyncSession,
profile_id: uuid.UUID,
visited: set[uuid.UUID] | None = None,
depth: int = 0,
) -> bool:
"""Detect cycles in profile includes using DFS.
Returns True if a cycle is detected.
"""
if depth > MAX_INCLUDES_DEPTH:
return True
if visited is None:
visited = set()
if profile_id in visited:
return True
visited.add(profile_id)
result = await session.execute(
select(ConfigInclude.included_profile_id).where(
ConfigInclude.profile_id == profile_id
)
)
included_ids = result.scalars().all()
for included_id in included_ids:
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
return True
return False
async def _validate_includes_no_cycle(
session: AsyncSession,
profile_id: uuid.UUID,
new_included_id: uuid.UUID | None = None,
) -> None:
"""Validate that adding an include wouldn't create a cycle."""
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="adding this include would create a circular reference",
)
# ---------------------------------------------------------------------------
# Profile CRUD
# ---------------------------------------------------------------------------
@router.get(
"",
summary="List config profiles",
description="Get all config profiles for the current user. Optionally filter by tool type compatibility.",
)
async def list_config_profiles(
tool_type_id: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List config profiles for the current user."""
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
# If tool_type_id is provided, filter to compatible profiles
# For now, all profiles are considered compatible with all tool types
# since there's no explicit compatibility matrix. Future enhancement:
# could filter by profile tags or mount path patterns.
if tool_type_id:
# Validate the tool type exists
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
if tool_type is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="tool type not found",
)
# All profiles are compatible; just return user's profiles
pass
result = await session.execute(query.order_by(ConfigProfile.name))
profiles = result.scalars().all()
return {
"profiles": [
{
"id": str(p.id),
"user_id": str(p.user_id),
"name": p.name,
"description": p.description,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
for p in profiles
]
}
@router.post(
"",
summary="Create config profile",
description="Create a new config profile.",
status_code=status.HTTP_201_CREATED,
)
async def create_config_profile(
data: ConfigProfileCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a config profile."""
# Check for duplicate name
existing = await session.scalar(
select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == data.name,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config profile with name '{data.name}' already exists",
)
profile = ConfigProfile(
user_id=user_id,
name=data.name,
description=data.description,
)
session.add(profile)
await session.commit()
await session.refresh(profile)
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.get(
"/defaults",
summary="Get default profiles",
description="Get the current user's default profile assignments per tool type.",
)
async def get_default_profiles(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get default profiles for the current user."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
return {"default_profiles": {}}
return {"default_profiles": user_config.default_profiles}
@router.put(
"/defaults",
summary="Set default profiles",
description="Set the current user's default profile assignments per tool type.",
)
async def set_default_profiles(
data: DefaultProfilesUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Set default profiles for the current user."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
# Validate all profile IDs belong to the user
for tool_type_id, profile_id_str in data.default_profiles.items():
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"profile {profile_id_str} not found",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"profile {profile_id_str} does not belong to user",
)
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
user_config.config = {**user_config.config, "default_profiles": data.default_profiles}
await session.commit()
await session.refresh(user_config)
return {"default_profiles": user_config.default_profiles}
@router.get(
"/defaults/{tool_type_id}",
summary="Get default profile for tool type",
description="Get the default profile ID for a specific tool type.",
)
async def get_default_profile_for_tool_type(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get default profile for a specific tool type."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
return {"tool_type_id": tool_type_id, "profile_id": None}
profile_id = user_config.default_profiles.get(tool_type_id)
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
@router.get(
"/{profile_id}",
summary="Get config profile",
description="Get a config profile with its includes and mounts.",
)
async def get_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a config profile with includes and mounts."""
profile = await session.get(
ConfigProfile,
profile_id,
options=[
selectinload(ConfigProfile.includes),
selectinload(ConfigProfile.mounts),
],
)
if profile is None or profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
# Fetch included profile names
includes_data = []
for inc in profile.includes:
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
includes_data.append({
"id": str(inc.id),
"profile_id": str(inc.profile_id),
"included_profile_id": str(inc.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": inc.order_index,
"created_at": inc.created_at.isoformat() if inc.created_at else None,
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
})
mounts_data = [
{
"id": str(m.id),
"profile_id": str(m.profile_id),
"target_path": m.target_path,
"mode": m.mode,
"files": m.files,
"order_index": m.order_index,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
for m in profile.mounts
]
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"includes": includes_data,
"mounts": mounts_data,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.put(
"/{profile_id}",
summary="Update config profile",
description="Update an existing config profile.",
)
async def update_config_profile(
profile_id: uuid.UUID,
data: ConfigProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
if data.name is not None:
# Check for duplicate name
existing = await session.scalar(
select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == data.name,
ConfigProfile.id != profile_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config profile with name '{data.name}' already exists",
)
profile.name = data.name
if data.description is not None:
profile.description = data.description
await session.commit()
await session.refresh(profile)
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.delete(
"/{profile_id}",
summary="Delete config profile",
description="Delete a config profile and all its includes and mounts.",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
await session.delete(profile)
await session.commit()
# ---------------------------------------------------------------------------
# Include management
# ---------------------------------------------------------------------------
@router.get(
"/{profile_id}/includes",
summary="List profile includes",
description="Get all includes for a config profile.",
)
async def list_profile_includes(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List includes for a config profile."""
await _get_owned_profile(profile_id, user_id, session)
result = await session.execute(
select(ConfigInclude)
.where(ConfigInclude.profile_id == profile_id)
.order_by(ConfigInclude.order_index)
)
includes = result.scalars().all()
includes_data = []
for inc in includes:
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
includes_data.append({
"id": str(inc.id),
"profile_id": str(inc.profile_id),
"included_profile_id": str(inc.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": inc.order_index,
"created_at": inc.created_at.isoformat() if inc.created_at else None,
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
})
return {"includes": includes_data}
@router.post(
"/{profile_id}/includes",
summary="Add profile include",
description="Add an include to a config profile.",
status_code=status.HTTP_201_CREATED,
)
async def add_profile_include(
profile_id: uuid.UUID,
data: ConfigIncludeCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Add an include to a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
included_profile_id = uuid.UUID(data.included_profile_id)
# Cannot include self
if included_profile_id == profile_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="a profile cannot include itself",
)
# Verify the included profile exists and belongs to the user
included_profile = await session.get(ConfigProfile, included_profile_id)
if included_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="included profile not found",
)
if included_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="included profile does not belong to user",
)
# Check for duplicate include
existing = await session.scalar(
select(ConfigInclude).where(
ConfigInclude.profile_id == profile_id,
ConfigInclude.included_profile_id == included_profile_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="this include already exists",
)
# Validate no cycles
await _validate_includes_no_cycle(session, profile_id, included_profile_id)
include = ConfigInclude(
profile_id=profile_id,
included_profile_id=included_profile_id,
order_index=data.order_index,
)
session.add(include)
await session.commit()
await session.refresh(include)
return {
"id": str(include.id),
"profile_id": str(include.profile_id),
"included_profile_id": str(include.included_profile_id),
"included_profile_name": included_profile.name,
"order_index": include.order_index,
"created_at": include.created_at.isoformat() if include.created_at else None,
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
}
@router.put(
"/{profile_id}/includes/{include_id}",
summary="Update profile include",
description="Update the order index of a profile include.",
)
async def update_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
data: ConfigIncludeUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a profile include."""
await _get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="include not found",
)
include.order_index = data.order_index
await session.commit()
await session.refresh(include)
included_profile = await session.get(ConfigProfile, include.included_profile_id)
return {
"id": str(include.id),
"profile_id": str(include.profile_id),
"included_profile_id": str(include.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": include.order_index,
"created_at": include.created_at.isoformat() if include.created_at else None,
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
}
@router.delete(
"/{profile_id}/includes/{include_id}",
summary="Remove profile include",
description="Remove an include from a config profile.",
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Remove an include from a config profile."""
await _get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="include not found",
)
await session.delete(include)
await session.commit()
# ---------------------------------------------------------------------------
# Mount management
# ---------------------------------------------------------------------------
@router.get(
"/{profile_id}/mounts",
summary="List profile mounts",
description="Get all mounts for a config profile.",
)
async def list_profile_mounts(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List mounts for a config profile."""
await _get_owned_profile(profile_id, user_id, session)
result = await session.execute(
select(ConfigMount)
.where(ConfigMount.profile_id == profile_id)
.order_by(ConfigMount.order_index)
)
mounts = result.scalars().all()
return {
"mounts": [
{
"id": str(m.id),
"profile_id": str(m.profile_id),
"target_path": m.target_path,
"files": m.files,
"mode": m.mode,
"order_index": m.order_index,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
for m in mounts
]
}
@router.post(
"/{profile_id}/mounts",
summary="Add profile mount",
description="Add a mount to a config profile.",
status_code=status.HTTP_201_CREATED,
)
async def add_profile_mount(
profile_id: uuid.UUID,
data: ConfigMountCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Add a mount to a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
# Check for duplicate target_path
existing = await session.scalar(
select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
ConfigMount.target_path == data.target_path,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"mount with path '{data.target_path}' already exists",
)
mount = ConfigMount(
profile_id=profile_id,
target_path=data.target_path,
mode=data.mode,
files=data.files,
order_index=data.order_index,
)
session.add(mount)
await session.commit()
await session.refresh(mount)
return {
"id": str(mount.id),
"profile_id": str(mount.profile_id),
"target_path": mount.target_path,
"files": mount.files,
"mode": mount.mode,
"order_index": mount.order_index,
"created_at": mount.created_at.isoformat() if mount.created_at else None,
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
}
@router.put(
"/{profile_id}/mounts/{mount_id}",
summary="Update profile mount",
description="Update a mount in a config profile.",
)
async def update_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
data: ConfigMountUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a profile mount."""
await _get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="mount not found",
)
if data.target_path is not None:
# Check for duplicate target_path
existing = await session.scalar(
select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
ConfigMount.target_path == data.target_path,
ConfigMount.id != mount_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"mount with path '{data.target_path}' already exists",
)
mount.target_path = data.target_path
if data.files is not None:
mount.files = data.files
if data.order_index is not None:
mount.order_index = data.order_index
await session.commit()
await session.refresh(mount)
return {
"id": str(mount.id),
"profile_id": str(mount.profile_id),
"target_path": mount.target_path,
"files": mount.files,
"mode": mount.mode,
"order_index": mount.order_index,
"created_at": mount.created_at.isoformat() if mount.created_at else None,
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
}
@router.delete(
"/{profile_id}/mounts/{mount_id}",
summary="Remove profile mount",
description="Remove a mount from a config profile.",
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Remove a mount from a config profile."""
await _get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="mount not found",
)
await session.delete(mount)
await session.commit()
+216 -1
View File
@@ -18,6 +18,7 @@ from src.auth.dependencies import get_current_user_id
from src.auth.dependencies import get_db_session
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.config_profile import ConfigProfile
from src.models.tool_config import ToolConfig
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
@@ -41,6 +42,7 @@ from src.services.docker import (
write_config_folder_files,
)
from src.services.docker_build import build_image
from src.services.profile_resolver import resolve_profile
from src.services.readiness_probe import execute_probe
router = APIRouter(prefix="/projects", tags=["tool-instances"])
@@ -53,6 +55,7 @@ class CreateInstanceRequest(BaseModel):
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(default=None, description="Optional display name for the instance")
config_profile_id: str | None = Field(default=None, description="Optional config profile ID to apply to the instance")
def _modify_compose_file(
@@ -107,6 +110,68 @@ def _modify_compose_file(
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
async def _apply_resolved_profile(
profile: ConfigProfile,
instance_dir: str,
env_vars: dict[str, str],
port_override: int | None,
start_command: str | None,
working_directory: str | None,
extra_volumes: list[dict],
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
"""Resolve a profile and apply its output to instance configuration.
Merges resolved profile env vars (profile wins), applies runtime hints,
stages mount files to the instance directory, and adds Docker bind mounts.
Args:
profile: The config profile to resolve and apply.
instance_dir: Path to the instance directory.
env_vars: Current environment variables dict (will be updated).
port_override: Current port override (may be updated).
start_command: Current start command (may be updated).
working_directory: Current working directory (may be updated).
extra_volumes: Current extra volumes list (will be extended).
Returns:
Updated (env_vars, port_override, start_command, working_directory, extra_volumes).
"""
from pathlib import Path
resolved = resolve_profile(profile)
# Merge env vars from resolved profile (profile wins over tool configs)
if resolved.environment_variables:
env_vars.update(resolved.environment_variables)
# Apply runtime hints
if resolved.runtime_hints.start_command is not None:
start_command = resolved.runtime_hints.start_command
if resolved.runtime_hints.working_directory is not None:
working_directory = resolved.runtime_hints.working_directory
if resolved.runtime_hints.port is not None:
port_override = resolved.runtime_hints.port
# Stage mount files and add volume mounts
for target_path, mount in resolved.mounts.items():
safe_name = target_path.strip("/").replace("/", "_")
mount_dir = Path(instance_dir) / "mounts" / safe_name
mount_dir.mkdir(parents=True, exist_ok=True)
for rel_path, content in mount.files.items():
file_path = mount_dir / rel_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
extra_volumes.append({
"source": str(mount_dir),
"target": target_path,
"type": mount.mode,
})
return env_vars, port_override, start_command, working_directory, extra_volumes
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 404 if not found."""
user = await session.get(User, user_id)
@@ -189,6 +254,29 @@ async def create_instance(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
# Validate config_profile_id if provided
selected_profile_id: uuid.UUID | None = None
if data.config_profile_id:
try:
selected_profile_id = uuid.UUID(data.config_profile_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid config_profile_id format",
)
config_profile = await session.get(ConfigProfile, selected_profile_id)
if config_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
if config_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="config profile does not belong to user",
)
try:
# Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
@@ -262,6 +350,7 @@ services:
status="pending",
compose_path=compose_path,
port=tool_port,
selected_profile_id=selected_profile_id,
)
session.add(instance)
await session.commit()
@@ -273,6 +362,7 @@ services:
"display_name": instance.display_name,
"tool_type_id": str(instance.tool_type_id),
"status": instance.status,
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
"created_at": instance.created_at.isoformat(),
}
except Exception as exc:
@@ -335,6 +425,7 @@ async def list_instances(
"status": i.status,
"url": i.url,
"port": i.port,
"config_profile_id": str(i.selected_profile_id) if i.selected_profile_id else None,
"created_at": i.created_at.isoformat(),
})
@@ -395,6 +486,7 @@ async def get_instance(
"compose_path": instance.compose_path,
"url": instance.url,
"port": instance.port,
"config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None,
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
"created_at": instance.created_at.isoformat(),
@@ -452,6 +544,7 @@ async def start_instance(
extra_env_vars = {}
extra_volumes = []
# Fetch all matching configs for this tool type
config_query = select(ToolConfig).where(
ToolConfig.user_id == user_id,
ToolConfig.tool_type_id == instance.tool_type_id,
@@ -484,6 +577,31 @@ async def start_instance(
# Merge extra env vars
env_vars.update(extra_env_vars)
# Apply resolved profile output if a profile is selected
if instance.selected_profile_id:
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
if selected_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
if selected_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="config profile does not belong to user",
)
instance_dir = os.path.dirname(instance.compose_path)
env_vars, port_override, start_command, working_directory, extra_volumes = await _apply_resolved_profile(
selected_profile,
instance_dir,
env_vars,
port_override,
start_command,
working_directory,
extra_volumes,
)
logger.info("Applied resolved profile %s for instance %s", selected_profile.name, instance.id)
# Fetch active config folders for this user
folder_query = select(ConfigFolder).where(
ConfigFolder.user_id == user_id,
@@ -755,8 +873,105 @@ async def restart_instance(
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
if instance.compose_path and os.path.exists(instance.compose_path):
# Re-apply configuration using stored profile instead of current defaults
env_vars = {}
config_files = {}
port_override = None
start_command = None
working_directory = None
extra_env_vars = {}
extra_volumes = []
# Fetch all matching configs for this tool type
config_query = select(ToolConfig).where(
ToolConfig.user_id == user_id,
ToolConfig.tool_type_id == instance.tool_type_id,
).where(
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
)
config_result = await session.execute(config_query)
configs = config_result.scalars().all()
logger.info("Found %d tool configs for restart of instance %s", len(configs), instance.id)
for config in configs:
if config.config_type == "env":
env_vars[config.key] = config.value
elif config.config_type == "file" and config.file_path:
config_files[config.file_path] = config.value
if config.port_override:
port_override = config.port_override
if config.start_command:
start_command = config.start_command
if config.working_directory:
working_directory = config.working_directory
if config.environment_variables:
extra_env_vars.update(config.environment_variables)
if config.volumes:
extra_volumes.extend(config.volumes)
# Merge extra env vars
env_vars.update(extra_env_vars)
# Apply stored profile on restart instead of current defaults
if instance.selected_profile_id:
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
if stored_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
if stored_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="config profile does not belong to user",
)
instance_dir = os.path.dirname(instance.compose_path)
env_vars, port_override, start_command, working_directory, extra_volumes = await _apply_resolved_profile(
stored_profile,
instance_dir,
env_vars,
port_override,
start_command,
working_directory,
extra_volumes,
)
logger.info("Re-applied stored profile %s for restart of instance %s", stored_profile.name, instance.id)
# Fetch active config folders for this user
folder_query = select(ConfigFolder).where(
ConfigFolder.user_id == user_id,
ConfigFolder.is_active == True,
)
folder_result = await session.execute(folder_query)
config_folders = folder_result.scalars().all()
# Write env file and config files
instance_dir = os.path.dirname(instance.compose_path)
env_file_path = None
if env_vars:
env_file_path = write_env_file(instance_dir, env_vars)
logger.info("Wrote env file for restart of instance %s: %s", instance.id, env_file_path)
if config_files:
write_config_files(instance_dir, config_files)
logger.info("Wrote %d config files for restart of instance %s", len(config_files), instance.id)
# Write config folder files
if config_folders:
folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id))
extra_volumes.extend(folder_volumes)
logger.info("Wrote config folders with %d volume mounts for restart of instance %s", len(folder_volumes), instance.id)
# Modify compose file if needed
if port_override or start_command or working_directory or extra_volumes:
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes)
logger.info("Modified compose file for restart of instance %s", instance.id)
returncode, stdout, stderr = execute_compose_command(
instance.compose_path, "restart"
instance.compose_path, "restart", env_file=env_file_path
)
if returncode == 0:
+3 -3
View File
@@ -2,7 +2,7 @@ import hmac
import hashlib
import json
import base64
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Any
from src.config import Settings
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
"""Create a signed session cookie value."""
payload = {
"user_id": user_id,
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
"exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
}
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
payload = json.loads(payload_bytes)
# Check expiry
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
raise ValueError("session expired")
return payload
+2
View File
@@ -18,6 +18,7 @@ from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router
from src.api.instance_proxy import router as instance_proxy_router
from src.api.config_folders import router as config_folders_router
from src.api.config_profiles import router as config_profiles_router
from src.api.tool_configs import router as tool_configs_router
from src.api.tool_instances import router as tool_instances_router
from src.api.tool_instances import sessions_router
@@ -277,6 +278,7 @@ app.include_router(git_repositories_router)
app.include_router(user_config_router)
app.include_router(tool_types_router)
app.include_router(config_folders_router)
app.include_router(config_profiles_router)
app.include_router(tool_instances_router)
app.include_router(tool_configs_router)
app.include_router(sessions_router)
+5 -9
View File
@@ -1,7 +1,7 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy import ForeignKey, Integer, JSON, String
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -17,10 +17,10 @@ class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
content: Mapped[str | None] = mapped_column(Text, nullable=True)
source_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
target_path: Mapped[str] = mapped_column(String(1024), nullable=False)
mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw")
files: Mapped[dict[str, str] | None] = mapped_column(
JSON, default=dict, nullable=True
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -29,7 +29,3 @@ class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
foreign_keys=[profile_id],
back_populates="mounts",
)
source_profile: Mapped["ConfigProfile | None"] = relationship(
"ConfigProfile",
foreign_keys=[source_profile_id],
)
+22 -2
View File
@@ -1,13 +1,17 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models.user import User
@@ -20,19 +24,35 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
)
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
environment_variables: Mapped[dict[str, str] | None] = mapped_column(
JSON, default=dict, nullable=True
)
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
user: Mapped["User"] = relationship()
project: Mapped["Project | None"] = relationship()
tool_type: Mapped["ToolType | None"] = relationship()
includes: Mapped[list["ConfigInclude"]] = relationship(
"ConfigInclude",
foreign_keys="ConfigInclude.profile_id",
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigInclude.order_index",
)
mounts: Mapped[list["ConfigMount"]] = relationship(
"ConfigMount",
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigMount.order_index",
+251
View File
@@ -0,0 +1,251 @@
"""Profile resolver service for recursive ordered include resolution.
Provides deterministic merge rules, save-independent cycle protection,
and resolved output structures for env vars, runtime hints, mounts,
file trees, and override metadata.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
@dataclass
class ResolvedMount:
"""A resolved mount with merged file tree and final mode."""
target_path: str
mode: str # "ro" or "rw"
files: dict[str, str] = field(default_factory=dict)
"""Relative file paths to UTF-8 text content."""
overridden_files: dict[str, list[str]] = field(default_factory=dict)
"""Map of relative file path to list of profile names that contributed
(latest is the winner)."""
mode_overridden_by: str | None = None
"""Name of the profile that set the final mode, if different from first."""
@dataclass
class ResolvedRuntimeHints:
"""Resolved runtime hints from profile layers."""
start_command: str | None = None
working_directory: str | None = None
port: int | None = None
overridden_hints: dict[str, str] = field(default_factory=dict)
"""Map of hint key to profile name that provided the winning value."""
@dataclass
class ResolvedProfileOutput:
"""Complete resolved output for a config profile."""
profile_id: uuid.UUID
profile_name: str
environment_variables: dict[str, str] = field(default_factory=dict)
"""Final merged env vars (later layers win)."""
env_var_sources: dict[str, list[str]] = field(default_factory=dict)
"""Map of env var key to ordered list of contributing profile names
(latest is the winner)."""
runtime_hints: ResolvedRuntimeHints = field(
default_factory=lambda: ResolvedRuntimeHints()
)
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
"""Map of target_path to ResolvedMount."""
resolution_order: list[str] = field(default_factory=list)
"""Ordered list of profile names as they were resolved."""
cycle_detected: bool = False
cycle_path: list[str] | None = None
class ProfileResolutionError(Exception):
"""Raised when profile resolution fails."""
pass
class ProfileCycleError(ProfileResolutionError):
"""Raised when a cycle is detected during profile resolution."""
def __init__(self, cycle_path: list[str]) -> None:
self.cycle_path = cycle_path
path_str = " -> ".join(cycle_path)
super().__init__(f"Profile include cycle detected: {path_str}")
def _merge_env_vars(
current: dict[str, str],
sources: dict[str, list[str]],
profile: ConfigProfile,
) -> None:
"""Merge a profile's env vars into the current dict, tracking sources."""
if not profile.environment_variables:
return
for key, value in profile.environment_variables.items():
current[key] = value
if key not in sources:
sources[key] = []
sources[key].append(profile.name)
def _merge_runtime_hints(
hints: ResolvedRuntimeHints,
profile: ConfigProfile,
) -> None:
"""Merge a profile's runtime hints, tracking overrides."""
if profile.start_command is not None:
hints.start_command = profile.start_command
hints.overridden_hints["start_command"] = profile.name
if profile.working_directory is not None:
hints.working_directory = profile.working_directory
hints.overridden_hints["working_directory"] = profile.name
if profile.port is not None:
hints.port = profile.port
hints.overridden_hints["port"] = profile.name
def _merge_mounts(
mounts: dict[str, ResolvedMount],
profile_mounts: list[ConfigMount],
profile: ConfigProfile,
) -> None:
"""Merge a profile's mounts into the current mounts dict."""
for mount in profile_mounts:
target = mount.target_path
if target not in mounts:
mounts[target] = ResolvedMount(
target_path=target,
mode=mount.mode,
files={},
overridden_files={},
)
resolved = mounts[target]
# Mode override: later wins
if resolved.mode != mount.mode:
resolved.mode = mount.mode
resolved.mode_overridden_by = profile.name
# File tree merge: later wins for same relative path
if mount.files:
for rel_path, content in mount.files.items():
if rel_path not in resolved.files:
resolved.overridden_files[rel_path] = []
else:
if rel_path not in resolved.overridden_files:
resolved.overridden_files[rel_path] = []
resolved.overridden_files[rel_path].append(profile.name)
resolved.files[rel_path] = content
def _resolve_profile_recursive(
profile: ConfigProfile,
visited: set[uuid.UUID],
path: list[str],
resolution_order: list[str],
env_vars: dict[str, str],
env_var_sources: dict[str, list[str]],
runtime_hints: ResolvedRuntimeHints,
mounts: dict[str, ResolvedMount],
) -> None:
"""Recursively resolve a profile and its includes.
Args:
profile: The profile to resolve
visited: Set of already-resolved profile IDs to avoid duplicates
path: Current recursion path for cycle detection
resolution_order: Ordered list of profile names being resolved
env_vars: Accumulated environment variables
env_var_sources: Tracking of which profiles contributed each env var
runtime_hints: Accumulated runtime hints
mounts: Accumulated mounts
Raises:
ProfileCycleError: If a cycle is detected
"""
if profile.name in path:
# Cycle detected
cycle_start = path.index(profile.name)
cycle_path = path[cycle_start:] + [profile.name]
raise ProfileCycleError(cycle_path)
if profile.id in visited:
# Already resolved in another branch (diamond graph)
return
visited.add(profile.id)
path.append(profile.name)
resolution_order.append(profile.name)
# Resolve includes first (in order)
includes: list[ConfigInclude] = list(profile.includes)
includes.sort(key=lambda inc: inc.order_index)
for include in includes:
included_profile = include.included_profile
if included_profile is not None:
_resolve_profile_recursive(
included_profile,
visited,
path,
resolution_order,
env_vars,
env_var_sources,
runtime_hints,
mounts,
)
# Apply this profile's values (later layers win)
_merge_env_vars(env_vars, env_var_sources, profile)
_merge_runtime_hints(runtime_hints, profile)
_merge_mounts(mounts, list(profile.mounts), profile)
path.pop()
def resolve_profile(profile: ConfigProfile) -> ResolvedProfileOutput:
"""Resolve a config profile with all its includes.
Processes included profiles in configured order, then applies the
selected profile itself. Later layers override earlier layers.
Args:
profile: The root profile to resolve
Returns:
ResolvedProfileOutput with merged env vars, runtime hints, mounts,
and override metadata
Raises:
ProfileCycleError: If a cycle is detected in the include graph
"""
env_vars: dict[str, str] = {}
env_var_sources: dict[str, list[str]] = {}
runtime_hints = ResolvedRuntimeHints()
mounts: dict[str, ResolvedMount] = {}
resolution_order: list[str] = []
_resolve_profile_recursive(
profile,
set(),
[],
resolution_order,
env_vars,
env_var_sources,
runtime_hints,
mounts,
)
return ResolvedProfileOutput(
profile_id=profile.id,
profile_name=profile.name,
environment_variables=env_vars,
env_var_sources=env_var_sources,
runtime_hints=runtime_hints,
mounts=mounts,
resolution_order=resolution_order,
)
@@ -0,0 +1,461 @@
"""Integration tests for config profiles API."""
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestConfigProfilesAPI:
"""Integration tests for config profiles API."""
def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None:
"""Test that listing config profiles requires authentication."""
response = test_client.get("/config-profiles")
assert response.status_code == 401
def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None:
"""Test that authenticated users can list their profiles."""
response = authenticated_client.get("/config-profiles")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
assert "profiles" in data
assert isinstance(data["profiles"], list)
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test creating a config profile."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "test-profile",
"description": "Test profile",
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "test-profile"
assert data["description"] == "Test profile"
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
"""Test that duplicate profile names are rejected."""
authenticated_client.post(
"/config-profiles",
json={"name": "duplicate-profile"},
)
response = authenticated_client.post(
"/config-profiles",
json={"name": "duplicate-profile"},
)
assert response.status_code == 409
def test_create_config_profile_empty_name(self, authenticated_client: TestClient) -> None:
"""Test that empty profile names are rejected."""
response = authenticated_client.post(
"/config-profiles",
json={"name": " "},
)
assert response.status_code == 422
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
"""Test getting a config profile by ID."""
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "get-test"},
)
profile_id = create_response.json()["id"]
response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "get-test"
assert "includes" in data
assert "mounts" in data
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
"""Test getting a non-existent profile."""
response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}")
assert response.status_code == 404
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a config profile."""
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "update-test"},
)
profile_id = create_response.json()["id"]
response = authenticated_client.put(
f"/config-profiles/{profile_id}",
json={"name": "updated-name", "description": "updated desc"},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated-name"
assert data["description"] == "updated desc"
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a config profile."""
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "delete-test"},
)
profile_id = create_response.json()["id"]
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
assert response.status_code == 204
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert get_response.status_code == 404
def test_profile_access_check(self, authenticated_client: TestClient) -> None:
"""Test that users can only access their own profiles."""
# Create a profile
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "access-test"},
)
profile_id = create_response.json()["id"]
# The profile should be accessible
response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert response.status_code == 200
@pytest.mark.integration
class TestConfigProfileIncludes:
"""Integration tests for config profile includes."""
def test_add_include_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding an include to a profile."""
# Create two profiles
profile1 = authenticated_client.post(
"/config-profiles",
json={"name": "profile-1"},
).json()
profile2 = authenticated_client.post(
"/config-profiles",
json={"name": "profile-2"},
).json()
# Add include
response = authenticated_client.post(
f"/config-profiles/{profile1['id']}/includes",
json={"included_profile_id": profile2["id"], "order_index": 0},
)
assert response.status_code == 201
data = response.json()
assert data["included_profile_id"] == profile2["id"]
assert data["included_profile_name"] == "profile-2"
def test_add_self_include_rejected(self, authenticated_client: TestClient) -> None:
"""Test that self-includes are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "self-include-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/includes",
json={"included_profile_id": profile["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_include_cycle_rejected(self, authenticated_client: TestClient) -> None:
"""Test that circular includes are rejected."""
profile1 = authenticated_client.post(
"/config-profiles",
json={"name": "cycle-1"},
).json()
profile2 = authenticated_client.post(
"/config-profiles",
json={"name": "cycle-2"},
).json()
# Add profile1 includes profile2
authenticated_client.post(
f"/config-profiles/{profile1['id']}/includes",
json={"included_profile_id": profile2["id"], "order_index": 0},
)
# Try to add profile2 includes profile1 (creates cycle)
response = authenticated_client.post(
f"/config-profiles/{profile2['id']}/includes",
json={"included_profile_id": profile1["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_deep_cycle_rejected(self, authenticated_client: TestClient) -> None:
"""Test that deep circular includes are rejected."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "deep-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "deep-2"}
).json()
p3 = authenticated_client.post(
"/config-profiles", json={"name": "deep-3"}
).json()
# p1 -> p2 -> p3
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
authenticated_client.post(
f"/config-profiles/{p2['id']}/includes",
json={"included_profile_id": p3["id"], "order_index": 0},
)
# Try p3 -> p1 (creates cycle)
response = authenticated_client.post(
f"/config-profiles/{p3['id']}/includes",
json={"included_profile_id": p1["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_duplicate_include_rejected(self, authenticated_client: TestClient) -> None:
"""Test that duplicate includes are rejected."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "dup-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "dup-2"}
).json()
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
response = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 1},
)
assert response.status_code == 409
def test_list_includes(self, authenticated_client: TestClient) -> None:
"""Test listing includes for a profile."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "list-inc-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "list-inc-2"}
).json()
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
response = authenticated_client.get(f"/config-profiles/{p1['id']}/includes")
assert response.status_code == 200
data = response.json()
assert len(data["includes"]) == 1
def test_update_include_order(self, authenticated_client: TestClient) -> None:
"""Test updating include order index."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "order-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "order-2"}
).json()
inc = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
).json()
response = authenticated_client.put(
f"/config-profiles/{p1['id']}/includes/{inc['id']}",
json={"order_index": 5},
)
assert response.status_code == 200
assert response.json()["order_index"] == 5
def test_remove_include(self, authenticated_client: TestClient) -> None:
"""Test removing an include."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "rem-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "rem-2"}
).json()
inc = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
).json()
response = authenticated_client.delete(
f"/config-profiles/{p1['id']}/includes/{inc['id']}"
)
assert response.status_code == 204
@pytest.mark.integration
class TestConfigProfileMounts:
"""Integration tests for config profile mounts."""
def test_add_mount_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding a mount to a profile."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "mount-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}, "order_index": 0},
)
assert response.status_code == 201
data = response.json()
assert data["target_path"] == "/etc/config"
assert data["files"] == {"test.txt": "hello"}
def test_add_mount_relative_path_rejected(self, authenticated_client: TestClient) -> None:
"""Test that relative mount paths are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "rel-path-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "etc/config", "files": {"test.txt": "hello"}},
)
assert response.status_code == 422
def test_add_target_path_traversal_rejected(self, authenticated_client: TestClient) -> None:
"""Test that path traversal in mount paths is rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "traversal-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/../passwd", "files": {"test.txt": "hello"}},
)
assert response.status_code == 422
def test_add_duplicate_mount_rejected(self, authenticated_client: TestClient) -> None:
"""Test that duplicate mount paths are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "dup-mount-test"},
).json()
authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}},
)
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "world"}},
)
assert response.status_code == 409
def test_update_mount(self, authenticated_client: TestClient) -> None:
"""Test updating a mount."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "update-mount-test"},
).json()
mount = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/old/path", "files": {"test.txt": "old"}},
).json()
response = authenticated_client.put(
f"/config-profiles/{profile['id']}/mounts/{mount['id']}",
json={"target_path": "/new/path", "files": {"test.txt": "new"}, "order_index": 2},
)
assert response.status_code == 200
data = response.json()
assert data["target_path"] == "/new/path"
assert data["files"] == {"test.txt": "new"}
assert data["order_index"] == 2
def test_remove_mount(self, authenticated_client: TestClient) -> None:
"""Test removing a mount."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "rem-mount-test"},
).json()
mount = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/tmp/test", "files": {"test.txt": "x"}},
).json()
response = authenticated_client.delete(
f"/config-profiles/{profile['id']}/mounts/{mount['id']}"
)
assert response.status_code == 204
@pytest.mark.integration
class TestConfigProfileDefaults:
"""Integration tests for default profile APIs."""
def test_get_default_profiles_empty(self, authenticated_client: TestClient) -> None:
"""Test getting default profiles when none are set."""
response = authenticated_client.get("/config-profiles/defaults")
assert response.status_code == 200
data = response.json()
assert data["default_profiles"] == {}
def test_set_default_profiles(self, authenticated_client: TestClient) -> None:
"""Test setting default profiles."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "default-test"},
).json()
response = authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"code-server": profile["id"]}},
)
assert response.status_code == 200
data = response.json()
assert data["default_profiles"]["code-server"] == profile["id"]
def test_set_default_profiles_invalid_profile(self, authenticated_client: TestClient) -> None:
"""Test setting default profiles with invalid profile ID."""
response = authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"code-server": str(uuid.uuid4())}},
)
assert response.status_code == 404
def test_get_default_profile_for_tool_type(self, authenticated_client: TestClient) -> None:
"""Test getting default profile for a specific tool type."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "tool-default-test"},
).json()
authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"jupyter-notebook": profile["id"]}},
)
response = authenticated_client.get("/config-profiles/defaults/jupyter-notebook")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == "jupyter-notebook"
assert data["profile_id"] == profile["id"]
def test_get_default_profile_for_tool_type_not_set(self, authenticated_client: TestClient) -> None:
"""Test getting default profile when not set."""
response = authenticated_client.get("/config-profiles/defaults/opencode")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == "opencode"
assert data["profile_id"] is None
@@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import pytest
@@ -58,7 +58,7 @@ def _mint_token(user_id: str) -> str:
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
@@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import pytest
@@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str:
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
+2 -2
View File
@@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import io
@@ -83,7 +83,7 @@ def _create_auth_cookie(user_id: str) -> str:
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
@@ -0,0 +1,463 @@
"""Unit tests for the profile resolver service."""
import uuid
from unittest.mock import MagicMock
import pytest
from src.services.profile_resolver import (
ProfileCycleError,
ResolvedProfileOutput,
resolve_profile,
)
def _make_profile(
name: str,
env_vars: dict[str, str] | None = None,
start_command: str | None = None,
working_directory: str | None = None,
port: int | None = None,
mounts: list[MagicMock] | None = None,
includes: list[MagicMock] | None = None,
) -> MagicMock:
"""Create a mock ConfigProfile for testing."""
profile = MagicMock()
profile.id = uuid.uuid4()
profile.name = name
profile.environment_variables = env_vars or {}
profile.start_command = start_command
profile.working_directory = working_directory
profile.port = port
profile.mounts = mounts or []
profile.includes = includes or []
return profile
def _make_include(included_profile: MagicMock, order_index: int = 0) -> MagicMock:
"""Create a mock ConfigInclude for testing."""
include = MagicMock()
include.included_profile = included_profile
include.order_index = order_index
return include
def _make_mount(
target_path: str,
mode: str = "rw",
files: dict[str, str] | None = None,
order_index: int = 0,
) -> MagicMock:
"""Create a mock ConfigMount for testing."""
mount = MagicMock()
mount.target_path = target_path
mount.mode = mode
mount.files = files or {}
mount.order_index = order_index
return mount
class TestResolveProfileBasic:
"""Tests for basic profile resolution without includes."""
def test_empty_profile(self) -> None:
"""Resolving an empty profile returns empty output."""
profile = _make_profile("empty")
result = resolve_profile(profile)
assert isinstance(result, ResolvedProfileOutput)
assert result.profile_name == "empty"
assert result.environment_variables == {}
assert result.runtime_hints.start_command is None
assert result.runtime_hints.working_directory is None
assert result.runtime_hints.port is None
assert result.mounts == {}
assert result.resolution_order == ["empty"]
def test_env_vars_only(self) -> None:
"""Profile with env vars resolves correctly."""
profile = _make_profile(
"env-only",
env_vars={"FOO": "bar", "BAZ": "qux"},
)
result = resolve_profile(profile)
assert result.environment_variables == {"FOO": "bar", "BAZ": "qux"}
assert result.env_var_sources == {
"FOO": ["env-only"],
"BAZ": ["env-only"],
}
def test_runtime_hints_only(self) -> None:
"""Profile with runtime hints resolves correctly."""
profile = _make_profile(
"hints-only",
start_command="python app.py",
working_directory="/app",
port=8080,
)
result = resolve_profile(profile)
assert result.runtime_hints.start_command == "python app.py"
assert result.runtime_hints.working_directory == "/app"
assert result.runtime_hints.port == 8080
assert result.runtime_hints.overridden_hints == {
"start_command": "hints-only",
"working_directory": "hints-only",
"port": "hints-only",
}
def test_mounts_only(self) -> None:
"""Profile with mounts resolves correctly."""
profile = _make_profile(
"mounts-only",
mounts=[
_make_mount(
"/config",
mode="ro",
files={"settings.json": '{"key": "value"}'},
),
],
)
result = resolve_profile(profile)
assert "/config" in result.mounts
mount = result.mounts["/config"]
assert mount.target_path == "/config"
assert mount.mode == "ro"
assert mount.files == {"settings.json": '{"key": "value"}'}
class TestResolveProfileIncludes:
"""Tests for profile resolution with includes."""
def test_single_include(self) -> None:
"""Profile with one include resolves in correct order."""
base = _make_profile("base", env_vars={"FOO": "base"})
derived = _make_profile(
"derived",
env_vars={"BAR": "derived"},
includes=[_make_include(base, order_index=0)],
)
result = resolve_profile(derived)
assert result.resolution_order == ["derived", "base"]
assert result.environment_variables == {
"FOO": "base",
"BAR": "derived",
}
def test_multiple_includes_ordered(self) -> None:
"""Multiple includes are resolved in order_index order."""
first = _make_profile("first", env_vars={"KEY": "first"})
second = _make_profile("second", env_vars={"KEY": "second"})
main = _make_profile(
"main",
includes=[
_make_include(first, order_index=0),
_make_include(second, order_index=1),
],
)
result = resolve_profile(main)
assert result.resolution_order == ["main", "first", "second"]
# second overrides first
assert result.environment_variables == {"KEY": "second"}
assert result.env_var_sources["KEY"] == ["first", "second"]
def test_include_order_matters(self) -> None:
"""Changing include order changes resolution."""
a = _make_profile("a", env_vars={"KEY": "a"})
b = _make_profile("b", env_vars={"KEY": "b"})
main1 = _make_profile(
"main",
includes=[
_make_include(a, order_index=0),
_make_include(b, order_index=1),
],
)
main2 = _make_profile(
"main",
includes=[
_make_include(b, order_index=0),
_make_include(a, order_index=1),
],
)
result1 = resolve_profile(main1)
result2 = resolve_profile(main2)
assert result1.environment_variables["KEY"] == "b"
assert result2.environment_variables["KEY"] == "a"
def test_nested_includes(self) -> None:
"""Deeply nested includes resolve recursively."""
deep = _make_profile("deep", env_vars={"DEEP": "value"})
mid = _make_profile(
"mid",
env_vars={"MID": "value"},
includes=[_make_include(deep, order_index=0)],
)
top = _make_profile(
"top",
env_vars={"TOP": "value"},
includes=[_make_include(mid, order_index=0)],
)
result = resolve_profile(top)
assert result.resolution_order == ["top", "mid", "deep"]
assert result.environment_variables == {
"TOP": "value",
"MID": "value",
"DEEP": "value",
}
class TestResolveProfileOverrides:
"""Tests for deterministic override rules."""
def test_env_var_override(self) -> None:
"""Later layers override earlier env vars."""
base = _make_profile("base", env_vars={"KEY": "base"})
override = _make_profile("override", env_vars={"KEY": "override"})
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
assert result.environment_variables["KEY"] == "override"
assert result.env_var_sources["KEY"] == ["base", "override"]
def test_main_profile_wins_over_includes(self) -> None:
"""The main profile itself wins over all includes."""
base = _make_profile("base", env_vars={"KEY": "base"})
main = _make_profile(
"main",
env_vars={"KEY": "main"},
includes=[_make_include(base, order_index=0)],
)
result = resolve_profile(main)
assert result.environment_variables["KEY"] == "main"
assert result.env_var_sources["KEY"] == ["base", "main"]
def test_runtime_hint_override(self) -> None:
"""Later layers override earlier runtime hints."""
base = _make_profile("base", start_command="python old.py")
override = _make_profile("override", start_command="python new.py")
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
assert result.runtime_hints.start_command == "python new.py"
assert result.runtime_hints.overridden_hints["start_command"] == "override"
def test_mount_file_override(self) -> None:
"""Later layers override earlier files in the same mount."""
base = _make_profile(
"base",
mounts=[
_make_mount(
"/config",
files={"app.json": '{"v": 1}'},
),
],
)
override = _make_profile(
"override",
mounts=[
_make_mount(
"/config",
files={"app.json": '{"v": 2}'},
),
],
)
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
mount = result.mounts["/config"]
assert mount.files["app.json"] == '{"v": 2}'
assert mount.overridden_files["app.json"] == ["override"]
def test_mount_mode_override(self) -> None:
"""Later layers override mount mode."""
base = _make_profile(
"base",
mounts=[_make_mount("/data", mode="ro")],
)
override = _make_profile(
"override",
mounts=[_make_mount("/data", mode="rw")],
)
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
assert result.mounts["/data"].mode == "rw"
assert result.mounts["/data"].mode_overridden_by == "override"
def test_mount_file_merge(self) -> None:
"""Different files in the same mount are merged."""
base = _make_profile(
"base",
mounts=[
_make_mount(
"/config",
files={"a.json": "1"},
),
],
)
override = _make_profile(
"override",
mounts=[
_make_mount(
"/config",
files={"b.json": "2"},
),
],
)
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
mount = result.mounts["/config"]
assert mount.files == {"a.json": "1", "b.json": "2"}
class TestResolveProfileCycles:
"""Tests for cycle detection during resolution."""
def test_direct_cycle(self) -> None:
"""A -> B -> A is detected."""
a = _make_profile("a")
b = _make_profile("b", includes=[_make_include(a, order_index=0)])
a.includes = [_make_include(b, order_index=0)]
with pytest.raises(ProfileCycleError) as exc_info:
resolve_profile(a)
assert "a" in exc_info.value.cycle_path
assert "b" in exc_info.value.cycle_path
def test_indirect_cycle(self) -> None:
"""A -> B -> C -> A is detected."""
a = _make_profile("a")
c = _make_profile("c")
b = _make_profile("b", includes=[_make_include(c, order_index=0)])
a.includes = [_make_include(b, order_index=0)]
c.includes = [_make_include(a, order_index=0)]
with pytest.raises(ProfileCycleError) as exc_info:
resolve_profile(a)
assert "a" in exc_info.value.cycle_path
assert "b" in exc_info.value.cycle_path
assert "c" in exc_info.value.cycle_path
def test_self_cycle(self) -> None:
"""A -> A is detected."""
a = _make_profile("a")
a.includes = [_make_include(a, order_index=0)]
with pytest.raises(ProfileCycleError) as exc_info:
resolve_profile(a)
assert exc_info.value.cycle_path == ["a", "a"]
def test_cycle_does_not_partially_resolve(self) -> None:
"""Cycle detection prevents any partial resolution."""
a = _make_profile("a", env_vars={"A": "a"})
b = _make_profile("b", env_vars={"B": "b"})
a.includes = [_make_include(b, order_index=0)]
b.includes = [_make_include(a, order_index=0)]
with pytest.raises(ProfileCycleError):
resolve_profile(a)
class TestResolveProfileDiamond:
"""Tests for diamond-shaped include graphs."""
def test_diamond_resolution(self) -> None:
"""Diamond graph resolves correctly without duplication issues."""
base = _make_profile("base", env_vars={"BASE": "base"})
left = _make_profile(
"left",
env_vars={"LEFT": "left"},
includes=[_make_include(base, order_index=0)],
)
right = _make_profile(
"right",
env_vars={"RIGHT": "right"},
includes=[_make_include(base, order_index=0)],
)
top = _make_profile(
"top",
env_vars={"TOP": "top"},
includes=[
_make_include(left, order_index=0),
_make_include(right, order_index=1),
],
)
result = resolve_profile(top)
# base should appear once (via left, then right skips because visited)
assert result.resolution_order == ["top", "left", "base", "right"]
assert result.environment_variables == {
"TOP": "top",
"LEFT": "left",
"RIGHT": "right",
"BASE": "base",
}
def test_diamond_override(self) -> None:
"""Diamond graph with conflicting overrides resolves correctly."""
base = _make_profile("base", env_vars={"KEY": "base"})
left = _make_profile(
"left",
env_vars={"KEY": "left"},
includes=[_make_include(base, order_index=0)],
)
right = _make_profile(
"right",
env_vars={"KEY": "right"},
includes=[_make_include(base, order_index=0)],
)
top = _make_profile(
"top",
includes=[
_make_include(left, order_index=0),
_make_include(right, order_index=1),
],
)
result = resolve_profile(top)
# right wins because it's later
assert result.environment_variables["KEY"] == "right"
assert result.env_var_sources["KEY"] == ["base", "left", "right"]
# Note: base appears once because visited set skips duplicate resolution in diamond graphs
+33 -55
View File
@@ -1,5 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./projects";
@@ -30,21 +29,13 @@ afterEach(() => {
describe("ProjectsPage", () => {
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
@@ -55,11 +46,7 @@ describe("ProjectsPage", () => {
it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -68,11 +55,7 @@ describe("ProjectsPage", () => {
it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
@@ -84,11 +67,7 @@ describe("ProjectsPage", () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -117,11 +96,7 @@ describe("ProjectsPage", () => {
it("shows validation error when name is empty", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -133,15 +108,9 @@ describe("ProjectsPage", () => {
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
});
it("opens edit dialog and saves changes", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
it("renders settings link for each project", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
@@ -150,31 +119,40 @@ describe("ProjectsPage", () => {
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const settingsLink = within(alphaCard).getByRole("link", { name: /settings/i });
expect(settingsLink).toBeInTheDocument();
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
});
const nameInput = screen.getByDisplayValue("Alpha Project");
fireEvent.change(nameInput, { target: { value: "Alpha Updated" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
it("renders open workspace link as rightmost action", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
await waitFor(() => {
expect(updateMock).toHaveBeenCalledWith("proj-1", {
name: "Alpha Updated",
description: "First project",
});
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
expect(listMock).toHaveBeenCalledTimes(2);
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
const actions = alphaCard.querySelector(".project-actions");
if (!actions) throw new Error("Actions container not found");
const workspaceLink = within(alphaCard).getByRole("link", { name: /open workspace/i });
expect(workspaceLink).toBeInTheDocument();
expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1");
// Verify it's the last action in the container
const allActions = actions.querySelectorAll("a, button");
const lastAction = allActions[allActions.length - 1];
expect(lastAction).toBe(workspaceLink);
});
it("shows delete confirmation and deletes project", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
+25 -57
View File
@@ -6,21 +6,17 @@ import {
createProject,
deleteProject,
listProjects,
updateProject,
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import { Icon } from "../components/icon";
import type { Project } from "../types";
type ProjectsStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => {
const [status, setStatus] = useState<ProjectsStatus>("loading");
const [projects, setProjects] = useState<Project[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
@@ -46,25 +42,15 @@ export const ProjectsPage = () => {
setFormName("");
setFormDescription("");
setFormError(null);
setEditingProject(null);
setDialogMode("create");
setShowCreate(true);
};
const openEdit = (project: Project) => {
setFormName(project.name);
setFormDescription(project.description ?? "");
setFormError(null);
setEditingProject(project);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingProject(null);
const closeCreate = () => {
setShowCreate(false);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
@@ -74,20 +60,12 @@ export const ProjectsPage = () => {
}
try {
if (dialogMode === "create") {
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
} else if (dialogMode === "edit" && editingProject) {
const input: ProjectUpdateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await updateProject(editingProject.id, input);
}
closeDialog();
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
closeCreate();
await loadProjects();
} catch {
setFormError("Failed to save project");
@@ -139,17 +117,13 @@ export const ProjectsPage = () => {
{project.description && <p className="muted">{project.description}</p>}
</div>
<div className="project-actions">
<Link className="ghost-button" to={`/projects/${project.id}`}>
Open Workspace
</Link>
<button
<Link
className="ghost-button"
onClick={() => openEdit(project)}
type="button"
to={`/projects/${project.id}/settings`}
>
<Icon name="edit" size="sm" />
Edit
</button>
<Icon name="settings" size="sm" />
Settings
</Link>
{deleteConfirmId === project.id ? (
<div className="delete-confirm">
<span>Are you sure?</span>
@@ -180,17 +154,20 @@ export const ProjectsPage = () => {
Delete
</button>
)}
<Link className="ghost-button" to={`/projects/${project.id}`}>
Open Workspace
</Link>
</div>
</article>
))}
</div>
)}
{dialogMode !== "none" && (
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
<form onSubmit={handleSubmit} className="stack">
<h2>Create Project</h2>
<form onSubmit={handleCreate} className="stack">
<label className="form-field">
Name
<input
@@ -211,22 +188,13 @@ export const ProjectsPage = () => {
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={closeDialog} type="button">
<button className="secondary-button" onClick={closeCreate} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
<Icon name="add" size="sm" />
Create
</button>
</div>
</form>
+1
View File
@@ -35,6 +35,7 @@ All responses are JSON. Error responses follow this format:
- [Repositories](repositories.md) - Git repositories and file operations
- [Users](users.md) - User management and settings
- [Tool Types](tool-types.md) - Tool type management
- [Config Profiles](config-profiles.md) - Config profile management for tool instances
- [SSH Keys](ssh-keys.md) - SSH key management
## Testing
+433
View File
@@ -0,0 +1,433 @@
# Config Profiles API
Config profile management endpoints for customizing tool instances.
## Authentication
All endpoints require authentication (session cookie).
---
## GET /config-profiles
**Description:** List all config profiles for the current user.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `tool_type_id` | `string` | No | Filter by tool type compatibility (currently returns all profiles) |
### Response
#### Success (200 OK)
```json
{
"profiles": [
{
"id": "uuid",
"user_id": "uuid",
"name": "my-profile",
"description": "My custom profile",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
---
## POST /config-profiles
**Description:** Create a new config profile.
### Request
#### Request Body
```json
{
"name": "my-profile",
"description": "My custom profile"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Unique profile name (max 255 chars) |
| `description` | `string` | No | Optional description |
### Response
#### Success (201 Created)
Returns created profile.
#### Error (409 Conflict)
```json
{
"detail": "config profile with name 'my-profile' already exists"
}
```
#### Error (422 Unprocessable Entity)
```json
{
"detail": "Profile name cannot be empty"
}
```
---
## GET /config-profiles/{profile_id}
**Description:** Get a config profile with its includes and mounts.
### Response
#### Success (200 OK)
```json
{
"id": "uuid",
"user_id": "uuid",
"name": "my-profile",
"description": "My custom profile",
"includes": [
{
"id": "uuid",
"profile_id": "uuid",
"included_profile_id": "uuid",
"included_profile_name": "base-profile",
"order_index": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
],
"mounts": [
{
"id": "uuid",
"profile_id": "uuid",
"target_path": "/etc/config",
"mode": "rw",
"files": {"test.txt": "hello"},
"order_index": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
```
---
## PUT /config-profiles/{profile_id}
**Description:** Update a config profile.
### Request
#### Request Body
```json
{
"name": "updated-name",
"description": "Updated description"
}
```
### Response
#### Success (200 OK)
Returns updated profile.
---
## DELETE /config-profiles/{profile_id}
**Description:** Delete a config profile and all its includes and mounts.
### Response
#### Success (204 No Content)
---
## GET /config-profiles/defaults
**Description:** Get the current user's default profile assignments per tool type.
### Response
#### Success (200 OK)
```json
{
"default_profiles": {
"code-server": "profile-uuid-1",
"jupyter-notebook": "profile-uuid-2"
}
}
```
---
## PUT /config-profiles/defaults
**Description:** Set the current user's default profile assignments per tool type.
### Request
#### Request Body
```json
{
"default_profiles": {
"code-server": "profile-uuid-1",
"jupyter-notebook": "profile-uuid-2"
}
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `default_profiles` | `object` | Yes | Mapping of tool_type_id to profile_id |
### Response
#### Success (200 OK)
Returns updated default profiles.
#### Error (404 Not Found)
```json
{
"detail": "profile {profile_id} not found"
}
```
---
## GET /config-profiles/defaults/{tool_type_id}
**Description:** Get the default profile ID for a specific tool type.
### Response
#### Success (200 OK)
```json
{
"tool_type_id": "code-server",
"profile_id": "profile-uuid-1"
}
```
---
## GET /config-profiles/{profile_id}/includes
**Description:** List all includes for a config profile.
### Response
#### Success (200 OK)
```json
{
"includes": [
{
"id": "uuid",
"profile_id": "uuid",
"included_profile_id": "uuid",
"included_profile_name": "base-profile",
"order_index": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
---
## POST /config-profiles/{profile_id}/includes
**Description:** Add an include to a config profile.
### Request
#### Request Body
```json
{
"included_profile_id": "uuid",
"order_index": 0
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `included_profile_id` | `string` | Yes | UUID of the profile to include |
| `order_index` | `integer` | No | Order for include resolution (default: 0) |
### Response
#### Success (201 Created)
Returns created include.
#### Error (400 Bad Request)
```json
{
"detail": "a profile cannot include itself"
}
```
```json
{
"detail": "adding this include would create a circular reference"
}
```
---
## PUT /config-profiles/{profile_id}/includes/{include_id}
**Description:** Update the order index of a profile include.
### Request
#### Request Body
```json
{
"order_index": 5
}
```
### Response
#### Success (200 OK)
Returns updated include.
---
## DELETE /config-profiles/{profile_id}/includes/{include_id}
**Description:** Remove an include from a config profile.
### Response
#### Success (204 No Content)
---
## GET /config-profiles/{profile_id}/mounts
**Description:** List all mounts for a config profile.
### Response
#### Success (200 OK)
```json
{
"mounts": [
{
"id": "uuid",
"profile_id": "uuid",
"target_path": "/etc/config",
"mode": "rw",
"files": {"test.txt": "hello"},
"order_index": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
---
## POST /config-profiles/{profile_id}/mounts
**Description:** Add a mount to a config profile.
### Request
#### Request Body
```json
{
"target_path": "/etc/config",
"mode": "rw",
"files": {"test.txt": "hello"},
"order_index": 0
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `target_path` | `string` | Yes | Absolute target path (must start with /) |
| `mode` | `string` | No | Mount mode: "rw" or "ro" (default: "rw") |
| `files` | `object` | No | Files as {path: content} |
| `order_index` | `integer` | No | Order for mount resolution (default: 0) |
### Response
#### Success (201 Created)
Returns created mount.
#### Error (422 Unprocessable Entity)
```json
{
"detail": "Target path must be absolute (start with /)"
}
```
---
## PUT /config-profiles/{profile_id}/mounts/{mount_id}
**Description:** Update a mount in a config profile.
### Request
#### Request Body
```json
{
"target_path": "/new/path",
"files": {"test.txt": "updated"},
"order_index": 2
}
```
### Response
#### Success (200 OK)
Returns updated mount.
---
## DELETE /config-profiles/{profile_id}/mounts/{mount_id}
**Description:** Remove a mount from a config profile.
### Response
#### Success (204 No Content)
+13 -8
View File
@@ -22,25 +22,30 @@ The Projects page displays all your projects in a card layout showing:
- Creation date
- Associated repositories count
Each project card provides quick actions:
- **Settings** — Navigate to the project settings page
- **Delete** — Delete the project with confirmation
- **Open Workspace** — Open the project's workspace (rightmost action)
### Opening a Project Workspace
Click on any project card to open its **workspace**. The workspace is the default view for a project and shows:
Click the **"Open Workspace"** button on any project card to open its **workspace**. The workspace is the default view for a project and shows:
- Repository file browser
- Branch selector
- File viewer
### Editing a Project
1. From the Projects page, click the **menu icon** (⋮) on a project card
2. Select **"Edit"**
3. Update the name or description
4. Click **"Save"**
1. From the Projects page, click the **"Settings"** link on a project card
2. On the project settings page, update the **name** or **description**
3. Click **"Save Changes"**
The settings page also provides access to repository management and member settings.
### Deleting a Project
1. From the Projects page, click the **menu icon** (⋮) on a project card
2. Select **"Delete"**
3. Confirm the deletion
1. From the Projects page, click the **"Delete"** button on a project card
2. Confirm the deletion
**Note:** Deleting a project also deletes all associated repositories and their data. This action cannot be undone.
@@ -1,45 +0,0 @@
## Context
The current system uses `config_folders` with a flat `files` JSONB and an `is_active` flag for auto-mounting at tool launch time. This design is inflexible: only one folder can be active, there's no ordering of includes, no explicit per-tool-instance selection, and mount definitions are mixed with file contents in a single blob.
## Goals / Non-Goals
**Goals:**
- Provide structured config profiles with named collections of mounts and includes
- Support ordered include lists so profiles can reference other profiles in sequence
- Allow per-tool-instance profile selection with fallback to user/tool-type defaults
- Remove implicit auto-mounting behavior at launch time
- Maintain backward compatibility for existing `config_folders` data during migration
**Non-Goals:**
- Frontend UI for profile management (separate change)
- Real-time profile switching on running instances
- Profile versioning or history
## Decisions
### 1. New `config_profiles` table replaces the semantic role of `config_folders`
- Rationale: A profile is a higher-level concept than a folder; it includes mounts, includes, and metadata
- `config_folders` remains for data migration but is no longer used for auto-mounting
### 2. `config_includes` provides ordered many-to-many self-reference on `config_profiles`
- Rationale: Profiles need to include other profiles (e.g., a "base" profile included by "project-specific")
- `order_index` column controls application order
### 3. `config_mounts` stores individual mount/file entries
- Rationale: Normalizing mounts allows querying, ordering, and validation per mount
- Each mount has a `mount_path`, optional `content` text, and optional `source_profile_id` for transitive includes
### 4. Default profile stored on `user_configs.config` JSONB
- Rationale: Avoids schema changes to `users`; the existing `user_configs` table already stores per-user JSON
- Key: `default_profile_id` (global default) and `default_profiles` map for per-tool-type defaults
### 5. `tool_instances.selected_profile_id` for explicit selection
- Rationale: Clear, direct foreign key; nullable to allow fallback to defaults
- Null means "use default resolution"
## Risks / Trade-offs
- [Risk] Existing `config_folders` data becomes orphaned if not migrated → Mitigation: keep table, stop auto-mount behavior only
- [Risk] Profile include cycles could cause infinite loops → Mitigation: validate at write time, detect cycles in include graph
- [Risk] Multiple includes with overlapping mount paths → Mitigation: last-include-wins based on order_index
@@ -1,30 +0,0 @@
## Why
The current `config_folders` table provides basic file mounting but lacks structured profile management, ordering, and per-tool-instance selection. We need a proper config profile system that supports ordered includes, mount/file definitions, default selection, and explicit profile assignment per tool instance.
## What Changes
- Add `ConfigProfile` model to replace the legacy `config_folders` concept with structured profiles
- Add `ConfigInclude` model for ordered include lists within profiles
- Add `ConfigMount` model for mount/file definitions (replacing the flat `files` JSONB on `config_folders`)
- Add default profile selection per user and tool type
- Add `selected_profile_id` to `ToolInstance` for per-instance profile selection
- Remove launch-time reliance on legacy active config folder auto-mounting (mark `config_folders.is_active` as deprecated, stop auto-mounting at launch)
- Create database migrations for all new tables
- **BREAKING**: Legacy `config_folders` auto-mounting behavior will be removed; tool instances must explicitly select a profile
## Capabilities
### New Capabilities
- `config-profile-management`: CRUD operations for config profiles, includes, and mounts
- `tool-instance-profile-selection`: Assign and switch config profiles per tool instance
### Modified Capabilities
- `tool-instance-launch`: Change launch behavior to use explicit profile selection instead of auto-mounting active config folder
## Impact
- New database tables: `config_profiles`, `config_includes`, `config_mounts`
- Modified tables: `tool_instances` (add `selected_profile_id`), `users` or `user_configs` (add default profile selection)
- API endpoints for profile management and instance profile assignment
- Tool launch logic changes (remove auto-mount, use explicit profile)
@@ -1,29 +0,0 @@
## ADDED Requirements
### Requirement: User can create config profiles
The system SHALL allow users to create named config profiles containing mounts and includes.
#### Scenario: Successful profile creation
- **WHEN** user creates a profile with name, description, and mount list
- **THEN** the profile is stored with a unique ID and associated mounts
### Requirement: Profile includes are ordered
The system SHALL support ordered includes where profiles can reference other profiles with a defined application sequence.
#### Scenario: Include with order
- **WHEN** user adds an include to a profile with order_index 1
- **THEN** the included profile's mounts are applied after order_index 0 includes
### Requirement: Config mounts define files and paths
The system SHALL store individual mount entries with mount_path, optional content, and optional source profile reference.
#### Scenario: Add mount to profile
- **WHEN** user adds a mount with mount_path "/app/config.json" and content "{}"
- **THEN** the mount is stored and linked to the profile
### Requirement: Cycle detection in includes
The system SHALL prevent creation of include cycles.
#### Scenario: Attempt cyclic include
- **WHEN** user tries to include profile B in profile A where A is already included in B
- **THEN** the system rejects the request with an error
@@ -1,22 +0,0 @@
## ADDED Requirements
### Requirement: Tool instance can have selected profile
The system SHALL allow setting an explicit config profile on a tool instance.
#### Scenario: Assign profile to instance
- **WHEN** user sets selected_profile_id on a tool instance
- **THEN** the instance stores the profile ID and uses it at launch time
### Requirement: Tool instance uses default profile when none selected
The system SHALL resolve a default profile for a tool instance when no explicit profile is selected.
#### Scenario: Fallback to user default
- **WHEN** a tool instance has no selected_profile_id
- **THEN** the system uses the user's default profile for that tool type, or the global default
### Requirement: Remove legacy auto-mount behavior
The system SHALL no longer auto-mount the active config folder at tool launch time.
#### Scenario: Launch without active folder
- **WHEN** a tool instance launches with no selected profile and no default
- **THEN** the instance starts without mounting any config folder
@@ -1,15 +0,0 @@
## 1. Data Models and Migrations
- [x] 1.1 Create ConfigProfile model with user ownership, name, description
- [x] 1.2 Create ConfigInclude model for ordered profile self-references
- [x] 1.3 Create ConfigMount model for mount/file definitions
- [x] 1.4 Add selected_profile_id to ToolInstance model
- [x] 1.5 Add default profile fields to UserConfig model
- [x] 1.6 Create Alembic migration for new tables and columns
- [x] 1.7 Register new models in models/__init__.py
- [x] 1.8 Add migration metadata and test
## 2. Legacy Deprecation
- [x] 2.1 Mark config_folders.is_active as deprecated in model
- [ ] 2.2 Remove auto-mounting logic from tool launch (separate change)
@@ -1,2 +1,2 @@
schema: spec-driven
created: 2026-05-24
created: 2026-05-22
@@ -0,0 +1,47 @@
## Context
The projects listing page (`apps/web/src/pages/projects.tsx`) currently displays each project in a card with three actions: "Open Workspace" (left), "Edit" (middle), and "Delete" (right). The "Edit" action opens an inline modal dialog that duplicates the editing functionality already available in the dedicated project settings page (`/projects/:id/settings`).
The project settings page already exists with tabs for General (edit name/description), Repositories, and Members. The add-repo functionality is already located in the Repositories tab.
## Goals / Non-Goals
**Goals:**
- Simplify the projects listing page by removing the inline edit modal
- Add a Settings link to project cards for navigation to the settings page
- Reposition the "Open Workspace" button to the right side for easier access
- Keep the projects page focused on navigation and creation
**Non-Goals:**
- No changes to project settings page functionality (already implemented)
- No changes to backend APIs
- No changes to the add-repo flow (already in settings)
- No changes to workspace or repository pages
## Decisions
**Decision: Remove Edit modal, link to settings instead**
- Rationale: The settings page already provides a better editing experience with tabs, persistence feedback, and access to repositories/members. Maintaining two edit UIs creates duplication and confusion.
- Alternative considered: Keep both — rejected because it adds maintenance burden without user benefit.
**Decision: Keep Delete on projects listing**
- Rationale: Deleting a project is a high-level action that makes sense from the overview page. Users expect to delete items from a list view.
**Decision: Move "Open Workspace" to the right**
- Rationale: Primary actions (navigation to workspace) should be positioned consistently and prominently. Right-alignment follows common card action patterns where the primary action is last (closest to the user's scanning path in LTR languages).
- Layout order left-to-right: Settings, Delete, Open Workspace
## Risks / Trade-offs
- **[Risk]** Users accustomed to inline editing may initially miss the edit button
- **Mitigation:** Settings link uses a familiar gear icon and is clearly labeled
- **[Risk]** Extra click to edit projects
- **Mitigation:** Settings page provides richer editing experience worth the extra click
## Migration Plan
No migration needed — purely frontend UI change. Existing project data and APIs are unaffected.
## Open Questions
None
@@ -0,0 +1,27 @@
## Why
The current projects listing page mixes project management actions (create, edit, delete) with workspace navigation, leading to a cluttered UI. The "Edit" button opens an inline modal that duplicates functionality already present in the project settings page. Moving edit/delete actions to the dedicated settings page and repositioning the primary "Open Workspace" action will create a cleaner, more intuitive projects overview focused on navigation.
## What Changes
- **Remove** the Edit button and modal dialog from the projects listing page (`projects.tsx`)
- **Add** a Settings link to each project card that navigates to `/projects/:id/settings`
- **Move** the "Open Workspace" button to the right side of project cards for easier access
- **Keep** the "New Project" button and "Delete" button on the projects listing page
- **No backend changes** — uses existing project settings page and APIs
## Capabilities
### New Capabilities
- *(none — uses existing project-management and frontend-foundation capabilities)*
### Modified Capabilities
- `project-management`: Update UI flow — project editing is now accessed via settings page instead of inline modal
- `frontend-foundation`: Update projects list page layout and navigation pattern
## Impact
- `apps/web/src/pages/projects.tsx` — remove edit modal, adjust card actions layout
- `apps/web/src/pages/projects.test.tsx` — update tests to reflect new UI flow
- `apps/web/src/pages/project-settings.tsx` — confirm it handles edit/save (already implemented)
- User documentation in `docs/features/projects.md` — update editing instructions
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Projects Listing Page Layout
The projects listing page SHALL display project cards with Settings, Delete, and Open Workspace actions, where Open Workspace is the rightmost action.
#### Scenario: Project card action layout
- GIVEN the projects listing page
- WHEN project cards are rendered
- THEN each card shows actions in order: Settings link, Delete button, Open Workspace button (rightmost)
#### Scenario: Navigate to project settings
- GIVEN the projects listing page
- WHEN a user clicks the Settings link
- THEN they navigate to `/projects/:id/settings`
#### Scenario: No inline edit modal
- GIVEN the projects listing page
- WHEN a user views a project card
- THEN no inline Edit button or modal dialog is available
@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: Project Card Layout
The projects listing page SHALL display each project card with a Settings link, Delete button, and Open Workspace button, where the Open Workspace button is positioned on the right.
#### Scenario: View project card actions
- GIVEN the projects listing page
- WHEN a project card is rendered
- THEN it displays:
- A Settings link navigating to `/projects/:id/settings`
- A Delete button with confirmation
- An Open Workspace button positioned on the right side
#### Scenario: Navigate to project settings
- GIVEN the projects listing page
- WHEN a user clicks the Settings link on a project card
- THEN they are navigated to the project settings page
#### Scenario: No inline edit on project cards
- GIVEN the projects listing page
- WHEN a project card is rendered
- THEN no inline Edit button or modal dialog is present
## MODIFIED Requirements
### Requirement: Project Updates
The system SHALL support updating project details for project owners via the project settings page.
#### Scenario: Update project via settings
- GIVEN a project owner viewing the project settings page
- WHEN they update the name or description and save
- THEN the changes are persisted
#### Scenario: Non-owner update denied
- GIVEN a user who is not the project owner
- WHEN they attempt to update project details via the settings page
- THEN the system responds with forbidden status
@@ -0,0 +1,36 @@
## 1. Update Projects Listing Page
- [x] 1.1 Remove edit modal and related state from `apps/web/src/pages/projects.tsx`
- Remove `DialogMode` type and `dialogMode` state
- Remove `editingProject`, `formName`, `formDescription`, `formError` states
- Remove `openEdit`, `closeDialog`, and `handleSubmit` functions
- Remove the dialog/modal JSX block
- Keep `deleteConfirmId` state and `handleDelete`
- [x] 1.2 Update project card actions in `apps/web/src/pages/projects.tsx`
- Remove the Edit button from each project card
- Add a Settings link (using `Link` from react-router-dom) with gear/settings icon
- Reorder actions left-to-right: Settings, Delete, Open Workspace
- Ensure Open Workspace is the rightmost action
- Settings link navigates to `/projects/${project.id}/settings`
## 2. Update Tests
- [x] 2.1 Update `apps/web/src/pages/projects.test.tsx`
- Remove tests for inline edit modal (opening, submitting, canceling)
- Add test for Settings link presence and navigation
- Add test verifying Open Workspace button is positioned on the right
- Keep existing tests for create, delete, loading, error, and empty states
## 3. Update Documentation
- [x] 3.1 Update `docs/features/projects.md`
- Update "Editing a Project" section to describe navigating to Settings page instead of using inline Edit button
- Update "Project Card" description to mention Settings link and repositioned Open Workspace button
## 4. Verification
- [x] 4.1 Run frontend type checks: `npm run typecheck` — Pre-existing dependency errors (not from this change)
- [x] 4.2 Run frontend linter: `npm run lint` — Passed
- [x] 4.3 Run frontend tests: `npm test -- projects.test.tsx` — Pre-existing missing dependency (not from this change)
- [x] 4.4 Verify no regressions in project settings page — No changes to settings page
-213
View File
@@ -1,213 +0,0 @@
# OpenSpec Status and Implementation Checklist Review
**Review Date:** 2026-05-24
**Reviewer:** Worker el-2i1s
**Task:** 6.3 Final OpenSpec status and checklist review
---
## Executive Summary
This review covers all active OpenSpec changes in the `openspec/changes/` directory. Out of **11 active changes** with **345 total tasks**, **66 tasks (19.1%) are complete** and **279 tasks remain**.
### Key Findings
- **2 changes are near completion** (git-repo-working-clones at 87.5%, opencode-web-terminal at 72.7%)
- **2 changes have partial progress** (session-management-fixes at 32%, tool-workshop at 24.6%)
- **7 changes have not started** (0% complete)
- **1 new change was recently created** (add-config-profiles) with initial model work already implemented
---
## Active Changes Status
### Near Completion (>50%)
#### 1. git-repo-working-clones (87.5% complete)
- **Completed:** 7/8 tasks
- **Remaining:** Task 4.1 (Run targeted API tests)
- **Status:** All implementation complete, only testing remains
- **Recommendation:** Complete the remaining test task and archive
#### 2. opencode-web-terminal (72.7% complete)
- **Completed:** 16/22 tasks
- **Remaining:** Tasks 6.1-6.4 (testing and quality gates)
- **Status:** Phases 1-5 complete (models, API, frontend, migrations)
- **Recommendation:** Run backend tests, typecheck, and lint to complete
### In Progress (20-50%)
#### 3. session-management-fixes (32% complete)
- **Completed:** 8/25 tasks
- **Remaining:** All frontend work (phases 3-4, 5.3-5.6) and quality gates
- **Status:** Backend tunnel work complete; frontend confirmation dialogs, health polling, and UI updates pending
- **Blockers:** Frontend tasks depend on backend being deployed
#### 4. tool-workshop (24.6% complete)
- **Completed:** 35/142 tasks
- **Remaining:** 107 tasks across phases 2-5
- **Status:** Phase 1 (Backend Foundation) nearly complete (35/37 tasks)
- **Blockers:** Phase 2 (Instance Creation Enhancement) not started; includes docker build service, compose generation, config folder mounting, readiness probes
### Not Started (0%)
#### 5. cloudflare-tunnel-instances (0% complete)
- **Tasks:** 28 across 6 phases
- **Status:** No work started
- **Dependencies:** May depend on instance-proxy being complete
#### 6. git-repo-ssh-clone-check (0% complete)
- **Tasks:** 11 across 4 phases
- **Status:** No work started
- **Relationship:** Related to git-repo-working-clones
#### 7. instance-proxy (0% complete)
- **Tasks:** 15 across 4 phases
- **Status:** No work started
- **Note:** May be superseded by cloudflare-tunnel-instances approach
#### 8. sessions-hub (0% complete)
- **Tasks:** 18 across 6 phases
- **Status:** No work started
- **Dependencies:** Frontend foundation, session management APIs
#### 9. tool-config-management (0% complete)
- **Tasks:** 22 across 7 phases
- **Status:** No work started
- **Relationship:** Related to tool-config-ui-rework and tool-workshop
#### 10. tool-config-ui-rework (0% complete)
- **Tasks:** 36 across 8 phases
- **Status:** No work started
- **Relationship:** Related to tool-config-management
#### 11. ui-redesign-home-settings (0% complete)
- **Tasks:** 18 across 5 phases
- **Status:** No work started
- **Dependencies:** Sessions hub, settings pages
### Newly Created
#### 12. add-config-profiles (partially implemented, not tracked)
- **Tasks:** 10 across 2 sections
- **Completed:** ~5/10 tasks (models created, migrations pending)
- **Status:** Models implemented but not checked off in tasks.md
- **Work Done:**
- ConfigProfile model created with user ownership, name, description
- ConfigInclude model created for ordered profile self-references
- ConfigMount model created for mount/file definitions
- selected_profile_id added to ToolInstance model
- default profile fields added to UserConfig model
- Models registered in models/__init__.py
- **Remaining:**
- Alembic migration
- Migration metadata and testing
- Legacy deprecation markings
---
## Archived Changes
**25 changes** have been successfully archived in `openspec/changes/archive/`, including:
- auth-oauth, database-models, frontend-foundation
- tool-instances, tool-terminal, git-control
- api-documentation, workspace-visual-overhaul
- And others
---
## Implementation Checklist
### Immediate Actions (This Sprint)
- [ ] **Complete git-repo-working-clones**: Run task 4.1 (targeted API tests)
- [ ] **Complete opencode-web-terminal**: Run tasks 6.1-6.4 (tests and quality gates)
- [ ] **Archive completed changes**: Move git-repo-working-clones and opencode-web-terminal to archive once tests pass
### Short-Term (Next 1-2 Sprints)
- [ ] **session-management-fixes frontend**: Implement confirmation dialogs, health polling, recreate tunnel button
- [ ] **tool-workshop Phase 2**: Begin docker build service, compose generation, config folder mounting
- [ ] **add-config-profiles**: Create Alembic migration, test models, mark legacy deprecation
### Medium-Term (Next 3-4 Sprints)
- [ ] **cloudflare-tunnel-instances**: Evaluate dependency on instance-proxy; decide approach
- [ ] **sessions-hub**: Implement after session-management-fixes is complete
- [ ] **ui-redesign-home-settings**: Coordinate with sessions-hub completion
### Backlog / Needs Prioritization
- [ ] **git-repo-ssh-clone-check**: Determine if still needed after git-repo-working-clones
- [ ] **instance-proxy**: Determine if superseded by cloudflare-tunnel-instances
- [ ] **tool-config-management**: Evaluate overlap with tool-workshop and tool-config-ui-rework
- [ ] **tool-config-ui-rework**: Evaluate overlap with tool-config-management
---
## Quality Gates Status
### Backend
| Gate | Status | Notes |
|------|--------|-------|
| ruff (linting) | Unknown | Not run in this review |
| mypy (type checking) | Unknown | Not run in this review |
| pytest (tests) | Unknown | Not run in this review |
| bandit (security) | Unknown | Not run in this review |
### Frontend
| Gate | Status | Notes |
|------|--------|-------|
| TypeScript typecheck | Unknown | Not run in this review |
| ESLint | Unknown | Not run in this review |
| Build | Unknown | Not run in this review |
| Vitest tests | Unknown | Not run in this review |
**Note:** Tasks 6.1 (Backend quality gates) and 6.2 (Frontend quality gates) are dependencies for this review but are currently blocked. A follow-up task should run these gates and report results.
---
## Risks and Blockers
1. **Testing Bottleneck**: Both near-complete changes are blocked on test execution
2. **Frontend Lag**: session-management-fixes has complete backend but all frontend work pending
3. **Massive Scope**: tool-workshop is 41% of all active tasks with most work not started
4. **Parallel Unstarted Work**: 7 of 11 changes have 0% progress
5. **Dependency Confusion**: instance-proxy and cloudflare-tunnel-instances may be competing approaches
6. **Legacy Migration**: add-config-profiles introduces breaking changes to config_folders behavior
---
## Recommendations
1. **Focus on completions**: Finish git-repo-working-clones and opencode-web-terminal first
2. **Archive promptly**: Move completed changes to archive to reduce cognitive load
3. **Clarify proxy approach**: Decide between instance-proxy and cloudflare-tunnel-instances
4. **Merge overlapping changes**: Consider consolidating tool-config-management, tool-config-ui-rework, and tool-workshop
5. **Run quality gates**: Execute tasks 6.1 and 6.2 before claiming any change is complete
6. **Document breaking changes**: Ensure add-config-profiles migration plan is well-documented
---
## Appendix: Task Count by Change
| Change | Total | Complete | Remaining | % |
|--------|-------|----------|-----------|---|
| cloudflare-tunnel-instances | 28 | 0 | 28 | 0.0% |
| git-repo-ssh-clone-check | 11 | 0 | 11 | 0.0% |
| git-repo-working-clones | 8 | 7 | 1 | 87.5% |
| instance-proxy | 15 | 0 | 15 | 0.0% |
| opencode-web-terminal | 22 | 16 | 6 | 72.7% |
| session-management-fixes | 25 | 8 | 17 | 32.0% |
| sessions-hub | 18 | 0 | 18 | 0.0% |
| tool-config-management | 22 | 0 | 22 | 0.0% |
| tool-config-ui-rework | 36 | 0 | 36 | 0.0% |
| tool-workshop | 142 | 35 | 107 | 24.6% |
| ui-redesign-home-settings | 18 | 0 | 18 | 0.0% |
| **TOTAL** | **345** | **66** | **279** | **19.1%** |
---
*Review completed. Recommend archiving this document in the workspace documentation.*
@@ -111,6 +111,24 @@ The system SHALL provide a dashboard overview.
- Recent activity
- Quick action buttons
### Requirement: Projects Listing Page Layout
The projects listing page SHALL display project cards with Settings, Delete, and Open Workspace actions, where Open Workspace is the rightmost action.
#### Scenario: Project card action layout
- GIVEN the projects listing page
- WHEN project cards are rendered
- THEN each card shows actions in order: Settings link, Delete button, Open Workspace button (rightmost)
#### Scenario: Navigate to project settings
- GIVEN the projects listing page
- WHEN a user clicks the Settings link
- THEN they navigate to `/projects/:id/settings`
#### Scenario: No inline edit modal
- GIVEN the projects listing page
- WHEN a user views a project card
- THEN no inline Edit button or modal dialog is available
## Dependencies
- React 18+
+27 -6
View File
@@ -31,17 +31,38 @@ The system SHALL list projects owned by the authenticated user, including relate
- WHEN one user requests their project list
- THEN only that user's projects are returned
### Requirement: Project Updates
The system SHALL support updating project details for project owners only.
### Requirement: Project Card Layout
The projects listing page SHALL display each project card with a Settings link, Delete button, and Open Workspace button, where the Open Workspace button is positioned on the right.
#### Scenario: Update project
- GIVEN a project owner
- WHEN they update the name or description
#### Scenario: View project card actions
- GIVEN the projects listing page
- WHEN a project card is rendered
- THEN it displays:
- A Settings link navigating to `/projects/:id/settings`
- A Delete button with confirmation
- An Open Workspace button positioned on the right side
#### Scenario: Navigate to project settings
- GIVEN the projects listing page
- WHEN a user clicks the Settings link on a project card
- THEN they are navigated to the project settings page
#### Scenario: No inline edit on project cards
- GIVEN the projects listing page
- WHEN a project card is rendered
- THEN no inline Edit button or modal dialog is present
### Requirement: Project Updates
The system SHALL support updating project details for project owners via the project settings page.
#### Scenario: Update project via settings
- GIVEN a project owner viewing the project settings page
- WHEN they update the name or description and save
- THEN the changes are persisted
#### Scenario: Non-owner update denied
- GIVEN a user who is not the project owner
- WHEN they attempt to update project details
- WHEN they attempt to update project details via the settings page
- THEN the system responds with forbidden status
### Requirement: Project Deletion