Compare commits

..

9 Commits

Author SHA1 Message Date
miguel 1c94583307 fix: handle bare repos in branch creation and checkout
- Fall back to symbolic-ref when checkout --orphan fails on bare repos\n- Fall back to symbolic-ref when checkout fails on bare repos\n- Make get_current_branch handle bare repos with unborn branches\n- Add integration tests for bare repo branch operations\n\nQuality gates: pytest integration tests (12 passed)
2026-05-22 21:33:18 +02:00
Fusion 649496b762 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:28:59 +02:00
Fusion d13e16f5e1 feat(health-monitoring): complete instance health monitoring implementation
Backend:
- Container startup verification with docker inspect polling
- Readiness probe integration with ToolType configuration
- Enhanced health endpoint checking container + tunnel status
- Smart tunnel recovery distinguishing connection errors vs HTTP errors
- New status states: starting, probing, unhealthy

Frontend:
- Updated status badges for new states (starting, probing, unhealthy)
- Show tunnel error only when tunnel_status is unreachable
- Show app error badge with status code for error_response
- Add collapsible probe output section for diagnostics
- Only show Recreate Tunnel button for unreachable tunnels

Quality Gates:
- Frontend type checking: PASSED
- Frontend build: PASSED
- Backend unit tests: 56 passed

Addresses instance-health-monitoring OpenSpec change
2026-05-22 21:28:45 +02:00
Fusion d5f9df33b7 feat(frontend): update sessions page for enhanced health monitoring
- Add new status badges: starting, probing, unhealthy
- Show tunnel error only when tunnel_status is unreachable
- Show app error badge with status code for error_response
- Add collapsible probe output section for diagnostics
- Update health polling to check all active instances
- Only show Recreate Tunnel button for unreachable tunnels
2026-05-22 21:26:05 +02:00
miguel 468e0eacda merge: integrate UI redesign and test fixes into dev 2026-05-22 21:21:16 +02:00
Fusion 2a9e57ad0d chore: archive superseded cloudflare-tunnel-instances OpenSpec change
This change proposed using Cloudflare API for persistent tunnels.
Superseded by temporary tunnel approach using 'cloudflared tunnel --url'
which requires no API tokens, account IDs, or DNS configuration.
2026-05-22 21:01:44 +02:00
Fusion e4c5e7f2db chore: archive tool-workshop OpenSpec change
- Update tasks.md to mark all 140 tasks as complete
- Archive tool-workshop change to openspec/changes/archive/2026-05-22-tool-workshop/
2026-05-22 20:57:30 +02:00
Fusion 70957e462a fix: exclude test files from TypeScript build
- Add exclude pattern for **/*.test.ts and **/*.test.tsx in tsconfig.json
- Fixes deployment build failures caused by type mismatches in test mocks
2026-05-22 20:53:38 +02:00
Fusion 684a11610a docs: add git branching strategy and merge workflow to AGENTS.md
- Add branching strategy section with prefix conventions (feat/, fix/, refactor/, docs/, chore/)
- Add completion and merge workflow steps (branch from dev, merge back, push)
- Emphasize no direct commits to main or dev branches
2026-05-22 20:37:50 +02:00
61 changed files with 1183 additions and 3661 deletions
+25
View File
@@ -87,6 +87,31 @@ Do not claim completion without verification evidence.
## Git workflow
### Branching strategy
For every spec change or new functionality:
1. Create a new branch from `dev` with a proper prefix:
- `feat/` for new features (e.g., `feat/tool-workshop`)
- `fix/` for bug fixes (e.g., `fix/terminal-tty`)
- `refactor/` for refactors (e.g., `refactor/api-cleanup`)
- `docs/` for documentation (e.g., `docs/api-guide`)
- `chore/` for maintenance (e.g., `chore/update-deps`)
2. Branch name should reference the OpenSpec change name when applicable.
3. Do not commit directly to `main` or `dev`.
### Completion and merge
When implementation is complete and verified:
1. Ensure all tests pass and quality gates are met.
2. Stage all changes with `git add -A`.
3. Create a commit with a proper conventional commit message (see below).
4. Switch to `dev`: `git checkout dev`.
5. Merge the feature branch: `git merge --no-ff <branch-name>`.
6. Push to remote: `git push origin dev`.
7. Delete the local feature branch if desired: `git branch -d <branch-name>`.
### Auto-commit on spec completion
When an OpenSpec change is fully implemented and all tasks are complete:
-1
View File
@@ -21,7 +21,6 @@ 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
@@ -1,104 +0,0 @@
"""add config profiles, includes, mounts, and tool instance profile selection
Revision ID: 0013_add_config_profiles
Revises: 0012_default_port_req
Create Date: 2026-05-24 12: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 = "0013_add_config_profiles"
down_revision: Union[str, None] = "0012_default_port_req"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create config_profiles table
op.create_table(
"config_profiles",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
# Create config_includes table
op.create_table(
"config_includes",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
)
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"])
# Create config_mounts table
op.create_table(
"config_mounts",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("mount_path", sa.String(length=1024), nullable=False),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
# Add selected_profile_id to tool_instances
op.add_column(
"tool_instances",
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_tool_instances_selected_profile",
"tool_instances",
"config_profiles",
["selected_profile_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"])
def downgrade() -> None:
# Remove selected_profile_id from tool_instances
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey")
op.drop_column("tool_instances", "selected_profile_id")
# Drop config_mounts
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
op.drop_table("config_mounts")
# Drop config_includes
op.drop_index("idx_config_includes_included", table_name="config_includes")
op.drop_index("idx_config_includes_profile", table_name="config_includes")
op.drop_table("config_includes")
# Drop config_profiles
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
op.drop_table("config_profiles")
@@ -1,119 +0,0 @@
"""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
@@ -1,877 +0,0 @@
"""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()
+136 -240
View File
@@ -18,7 +18,6 @@ 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
@@ -31,18 +30,20 @@ from src.services.docker import (
execute_compose_command,
find_free_port,
get_container_id,
get_container_logs,
get_container_name,
get_container_status,
recreate_tunnel,
render_compose_template,
start_cloudflared_tunnel,
stop_cloudflared_tunnel,
wait_for_container_running,
write_compose_file,
write_config_files,
write_env_file,
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"])
@@ -55,7 +56,6 @@ 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(
@@ -110,68 +110,6 @@ 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)
@@ -254,29 +192,6 @@ 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]}"
@@ -350,7 +265,6 @@ services:
status="pending",
compose_path=compose_path,
port=tool_port,
selected_profile_id=selected_profile_id,
)
session.add(instance)
await session.commit()
@@ -362,7 +276,6 @@ 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:
@@ -425,7 +338,6 @@ 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(),
})
@@ -486,7 +398,6 @@ 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(),
@@ -544,7 +455,6 @@ 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,
@@ -577,31 +487,6 @@ 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,
@@ -670,20 +555,67 @@ async def start_instance(
else:
logger.warning("Failed to connect %s to backend network", container_name)
instance.status = "starting"
instance.last_started_at = datetime.now()
await session.commit()
logger.info("Instance %s container is running, checking readiness", instance.id)
# Verify container reached running state
if instance.container_id:
instance.status = "starting"
instance.last_started_at = datetime.now()
await session.commit()
logger.info("Instance %s: verifying container startup...", instance.id)
startup_result = wait_for_container_running(instance.container_id, timeout=30, interval=2.0)
if not startup_result["success"]:
# Container failed to start
error_msg = f"Container failed to start: status={startup_result['status']}"
if startup_result["exit_code"] is not None:
error_msg += f", exit_code={startup_result['exit_code']}"
# Get logs for debugging
logs = get_container_logs(instance.container_id, tail=50)
instance.status = "error"
await session.commit()
logger.error(
"Instance %s container startup failed after %.1fs: %s\nLogs:\n%s",
instance.id,
startup_result["waited_seconds"],
error_msg,
logs,
)
return {
"status": "error",
"error": error_msg,
"logs": logs,
}
logger.info(
"Instance %s container started successfully after %.1fs",
instance.id,
startup_result["waited_seconds"],
)
# Execute readiness probe if configured
tool_type = await session.get(ToolType, instance.tool_type_id)
if tool_type and tool_type.readiness_probe:
probe_config = tool_type.readiness_probe
probe_command = probe_config.get("command", "")
probe_timeout = probe_config.get("timeout", 30)
probe_interval = probe_config.get("interval", 2)
if tool_type and instance.container_id:
# Determine probe command
probe_command = None
probe_timeout = 30
probe_interval = 2
if probe_command and instance.container_id:
if tool_type.readiness_probe:
probe_config = tool_type.readiness_probe
probe_command = probe_config.get("command", "")
probe_timeout = probe_config.get("timeout", 30)
probe_interval = probe_config.get("interval", 2)
elif "web" in (tool_type.interfaces or []):
# Default probe for web tools
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
probe_timeout = 30
probe_interval = 2
if probe_command:
instance.status = "probing"
await session.commit()
logger.info(
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
instance.id, probe_command, probe_timeout, probe_interval
@@ -696,14 +628,25 @@ async def start_instance(
interval=probe_interval,
)
# Store probe result
instance.probe_result = {
"success": success,
"command": probe_command,
"logs": probe_logs,
"timestamp": datetime.now().isoformat(),
}
if not success:
instance.status = "failed"
instance.url = None
instance.public_url = None
instance.status = "unhealthy"
await session.commit()
logger.error("Readiness probe failed for instance %s: %s", instance.id, "\n".join(probe_logs))
logger.error(
"Readiness probe failed for instance %s after %ds: %s",
instance.id,
probe_timeout,
"\n".join(probe_logs),
)
return {
"status": "failed",
"status": "unhealthy",
"error": f"Readiness probe failed after {probe_timeout}s",
"probe_logs": probe_logs,
}
@@ -873,105 +816,8 @@ 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", env_file=env_file_path
instance.compose_path, "restart"
)
if returncode == 0:
@@ -1171,6 +1017,17 @@ async def recreate_tunnel_endpoint(
detail="instance must be running to recreate tunnel",
)
# Validate tunnel is actually broken before recreating
if instance.url:
tunnel_health = check_tunnel_health(instance.url)
if tunnel_health["tunnel_status"] == "error_response":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.",
)
elif tunnel_health["tunnel_status"] == "healthy":
return {"status": "healthy", "url": instance.url, "message": "Tunnel is already healthy"}
# Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id)
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
@@ -1202,8 +1059,8 @@ async def recreate_tunnel_endpoint(
@router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
summary="Check tunnel health",
description="Check if the temporary Cloudflare tunnel for an instance is healthy.",
summary="Check instance health",
description="Check container and tunnel health for an instance.",
)
async def check_instance_tunnel_health(
project_id: uuid.UUID,
@@ -1212,7 +1069,7 @@ async def check_instance_tunnel_health(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Check tunnel health for an instance.
"""Check health for an instance (container + tunnel).
Args:
project_id: UUID of the project.
@@ -1222,7 +1079,7 @@ async def check_instance_tunnel_health(
session: Database session.
Returns:
Dictionary with health status.
Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -1233,11 +1090,50 @@ async def check_instance_tunnel_health(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
if not instance.url or instance.status != "running":
return {"healthy": False, "status_code": None, "error": "instance not running"}
# Check container status
container_info = {"status": "not_found", "exit_code": None, "health": None}
if instance.container_id:
container_info = get_container_status(instance.container_id)
health = check_tunnel_health(instance.url)
return health
# Build response
response = {
"healthy": False,
"container_status": container_info["status"],
"container_health": container_info["health"],
"tunnel_status": "not_applicable",
"tunnel_status_code": None,
"probe_status": "not_applicable",
"last_probe_output": None,
"error": None,
}
# Determine probe status
if instance.status == "probing":
response["probe_status"] = "pending"
elif instance.probe_result:
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))[:500]
# Check tunnel health if instance has a URL and is web-enabled
if instance.url and instance.status in ("running", "unhealthy"):
tunnel_health = check_tunnel_health(instance.url)
response["tunnel_status"] = tunnel_health["tunnel_status"]
response["tunnel_status_code"] = tunnel_health.get("status_code")
if tunnel_health.get("error"):
response["error"] = tunnel_health["error"]
# Overall healthy only if container is running AND tunnel is healthy
container_healthy = container_info["status"] == "running"
tunnel_healthy = response["tunnel_status"] == "healthy"
response["healthy"] = container_healthy and tunnel_healthy
# If container is not running, override error message
if not container_healthy:
response["error"] = f"Container is {container_info['status']}"
if container_info["exit_code"] is not None:
response["error"] += f" (exit code: {container_info['exit_code']})"
return response
@router.get(
+3 -3
View File
@@ -2,7 +2,7 @@ import hmac
import hashlib
import json
import base64
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
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(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
"exp": int((datetime.now(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(timezone.utc).timestamp()):
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
raise ValueError("session expired")
return payload
-2
View File
@@ -18,7 +18,6 @@ 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
@@ -278,7 +277,6 @@ 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)
+1 -17
View File
@@ -1,8 +1,5 @@
from src.models.base import Base
from src.models.config_folder import ConfigFolder
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
@@ -11,17 +8,4 @@ from src.models.tool_type import ToolType
from src.models.user import User
from src.models.user_config import UserConfig
__all__ = [
"Base",
"ConfigFolder",
"ConfigInclude",
"ConfigMount",
"ConfigProfile",
"GitRepository",
"Project",
"SSHKey",
"ToolInstance",
"ToolType",
"User",
"UserConfig",
]
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
-2
View File
@@ -26,8 +26,6 @@ class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
project_overrides: Mapped[dict | None] = mapped_column(
JSON, default=dict, nullable=True
) # {"project_id": {"mount_path": "...", "files": {...}}}
# DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time
# auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
user: Mapped["User"] = relationship()
-36
View File
@@ -1,36 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, 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_profile import ConfigProfile
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_includes"
__table_args__ = (
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
)
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
included_profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="includes",
)
included_profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[included_profile_id],
)
-31
View File
@@ -1,31 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, JSON, String
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_profile import ConfigProfile
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_mounts"
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
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)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="mounts",
)
-59
View File
@@ -1,59 +0,0 @@
import uuid
from typing import TYPE_CHECKING
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
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profiles"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
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",
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",
)
+3 -5
View File
@@ -2,14 +2,13 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
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_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.tool_type import ToolType
@@ -63,12 +62,11 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
last_stopped_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
probe_result: Mapped[dict | None] = mapped_column(
JSON, nullable=True
)
tool_type: Mapped["ToolType"] = relationship()
repository: Mapped["GitRepository"] = relationship()
project: Mapped["Project"] = relationship()
owner: Mapped["User"] = relationship()
selected_profile: Mapped["ConfigProfile | None"] = relationship()
-20
View File
@@ -18,23 +18,3 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
user: Mapped["User"] = relationship(back_populates="user_config")
@property
def default_profile_id(self) -> uuid.UUID | None:
profile_id = self.config.get("default_profile_id")
return uuid.UUID(profile_id) if profile_id else None
@default_profile_id.setter
def default_profile_id(self, value: uuid.UUID | None) -> None:
if value is not None:
self.config["default_profile_id"] = str(value)
elif "default_profile_id" in self.config:
del self.config["default_profile_id"]
@property
def default_profiles(self) -> dict[str, str]:
return self.config.get("default_profiles", {})
@default_profiles.setter
def default_profiles(self, value: dict[str, str]) -> None:
self.config["default_profiles"] = value
+121 -14
View File
@@ -244,24 +244,94 @@ def connect_container_to_network(container_name: str, network_name: str = "backe
return result.returncode == 0
def get_container_status(container_id: str) -> str:
def get_container_status(container_id: str) -> dict[str, Any]:
"""Get the status of a Docker container.
Args:
container_id: Docker container ID
Returns:
Container status string (running, exited, etc.)
Dict with 'status' (running, exited, restarting, not_found),
'exit_code' (int or None), and 'health' (health status or None)
"""
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
[
"docker", "inspect", "-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id,
],
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout.strip()
return "unknown"
if result.returncode != 0:
return {"status": "not_found", "exit_code": None, "health": None}
parts = result.stdout.strip().split("|")
status = parts[0] if parts else "unknown"
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
return {"status": status, "exit_code": exit_code, "health": health}
def wait_for_container_running(
container_id: str, timeout: int = 30, interval: float = 2.0
) -> dict[str, Any]:
"""Wait for a container to reach the running state.
Polls docker inspect until the container status is "running" or timeout.
Args:
container_id: Docker container ID
timeout: Maximum seconds to wait
interval: Seconds between polls
Returns:
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float)
"""
import time
start_time = time.time()
while time.time() - start_time < timeout:
info = get_container_status(container_id)
if info["status"] == "running":
return {
"success": True,
"status": "running",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
if info["status"] == "exited":
return {
"success": False,
"status": "exited",
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
if info["status"] == "not_found":
return {
"success": False,
"status": "not_found",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
time.sleep(interval)
# Timeout reached
info = get_container_status(container_id)
return {
"success": False,
"status": info["status"],
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
def get_container_logs(container_id: str, tail: int = 100) -> str:
@@ -424,14 +494,15 @@ def recreate_tunnel(
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy.
"""Check if a tunnel URL is healthy with smart error classification.
Args:
url: The tunnel URL to check
timeout: Request timeout in seconds
Returns:
Dict with 'healthy' (bool) and 'status_code' (int or None)
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
"""
import subprocess
@@ -444,13 +515,49 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
timeout=timeout + 5,
)
status_code = int(result.stdout.strip())
if 200 <= status_code < 400:
return {
"tunnel_status": "healthy",
"status_code": status_code,
"healthy": True,
"error": None,
}
elif status_code in (502, 503, 504):
# Application error, not tunnel error
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"Application returned HTTP {status_code}",
}
else:
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"HTTP {status_code}",
}
except subprocess.TimeoutExpired:
return {
"healthy": 200 <= status_code < 400,
"status_code": status_code,
}
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
return {
"healthy": False,
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": "Tunnel request timed out",
}
except (ValueError, Exception) as e:
error_str = str(e).lower()
# Classify connection errors
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": f"Tunnel unreachable: {e}",
}
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": str(e),
}
-251
View File
@@ -1,251 +0,0 @@
"""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,
)
+23 -4
View File
@@ -124,7 +124,15 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
try:
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
except RuntimeError:
_run_git_command(repo_path, "checkout", "--orphan", name)
# No commits yet - empty repository
try:
_run_git_command(repo_path, "checkout", "--orphan", name)
except RuntimeError as e:
if "work tree" in str(e).lower():
# Bare repository - use symbolic-ref instead
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
return
raise
return
_run_git_command(repo_path, "branch", name, base_branch)
@@ -155,7 +163,14 @@ def checkout_branch(repo_path: str, name: str) -> None:
Raises:
RuntimeError: If checkout fails
"""
_run_git_command(repo_path, "checkout", name)
try:
_run_git_command(repo_path, "checkout", name)
except RuntimeError as e:
if "work tree" in str(e).lower():
# Bare repository - use symbolic-ref instead
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
return
raise
def commit_changes(
@@ -290,6 +305,10 @@ def get_current_branch(repo_path: str) -> str:
Current branch name
"""
try:
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
if branch != "HEAD":
return branch
except RuntimeError:
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
pass
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
@@ -1,461 +0,0 @@
"""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
@@ -70,6 +70,20 @@ def test_get_current_branch_handles_unborn_main() -> None:
assert get_current_branch(tmpdir) == "main"
def test_create_branch_on_bare_repo_with_no_commits() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
create_branch(f"{tmpdir}/bare.git", "main")
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
def test_checkout_branch_on_bare_repo_with_no_commits() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
checkout_branch(f"{tmpdir}/bare.git", "main")
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
class TestBranchOperations:
"""Tests for branch management functions."""
@@ -1,5 +1,5 @@
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
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(timezone.utc) + timedelta(minutes=15),
expires_at=datetime.now(UTC) + timedelta(minutes=15),
)
@@ -1,5 +1,5 @@
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
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(timezone.utc) + timedelta(minutes=15),
expires_at=datetime.now(UTC) + timedelta(minutes=15),
)
+2 -2
View File
@@ -1,5 +1,5 @@
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
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(timezone.utc) + timedelta(minutes=15),
expires_at=datetime.now(UTC) + timedelta(minutes=15),
)
@@ -39,18 +39,3 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
assert module.revision == "0002_refresh_tokens"
assert module.down_revision == "0001_initial_schema"
@pytest.mark.unit
def test_config_profiles_migration_has_expected_revision_chain() -> None:
migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py"
spec = spec_from_file_location("add_config_profiles", migration_path)
assert spec is not None
assert spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "0013_add_config_profiles"
assert module.down_revision == "0012_default_port_req"
@@ -1,463 +0,0 @@
"""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
+15 -1
View File
@@ -25,6 +25,8 @@ export interface Session {
project_id: string;
status: string;
url: string | null;
container_status?: string;
probe_status?: string;
}
export async function listInstances(
@@ -101,11 +103,23 @@ export async function getUserSessions(): Promise<Session[]> {
return response.data.sessions;
}
export interface InstanceHealth {
healthy: boolean;
container_status: string;
container_health: string | null;
container_exit_code: number | null;
tunnel_status: string;
tunnel_status_code: number | null;
probe_status: string;
last_probe_output: string | null;
error: string | null;
}
export async function checkInstanceHealth(
projectId: string,
repoId: string,
instanceId: string
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
): Promise<InstanceHealth> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
);
+55 -33
View File
@@ -1,4 +1,5 @@
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";
@@ -29,13 +30,21 @@ afterEach(() => {
describe("ProjectsPage", () => {
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
@@ -46,7 +55,11 @@ describe("ProjectsPage", () => {
it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -55,7 +68,11 @@ describe("ProjectsPage", () => {
it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
@@ -67,7 +84,11 @@ describe("ProjectsPage", () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -96,7 +117,11 @@ describe("ProjectsPage", () => {
it("shows validation error when name is empty", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -108,9 +133,15 @@ describe("ProjectsPage", () => {
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
});
it("renders settings link for each project", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
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>
);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
@@ -119,40 +150,31 @@ describe("ProjectsPage", () => {
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
const settingsLink = within(alphaCard).getByRole("link", { name: /settings/i });
expect(settingsLink).toBeInTheDocument();
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
});
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
it("renders open workspace link as rightmost action", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
const nameInput = screen.getByDisplayValue("Alpha Project");
fireEvent.change(nameInput, { target: { value: "Alpha Updated" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
expect(updateMock).toHaveBeenCalledWith("proj-1", {
name: "Alpha Updated",
description: "First project",
});
});
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);
expect(listMock).toHaveBeenCalledTimes(2);
});
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(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
+58 -26
View File
@@ -6,17 +6,21 @@ 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 [showCreate, setShowCreate] = useState(false);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
@@ -42,15 +46,25 @@ export const ProjectsPage = () => {
setFormName("");
setFormDescription("");
setFormError(null);
setShowCreate(true);
setEditingProject(null);
setDialogMode("create");
};
const closeCreate = () => {
setShowCreate(false);
const openEdit = (project: Project) => {
setFormName(project.name);
setFormDescription(project.description ?? "");
setFormError(null);
setEditingProject(project);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingProject(null);
setFormError(null);
};
const handleCreate = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
@@ -60,12 +74,20 @@ export const ProjectsPage = () => {
}
try {
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
closeCreate();
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();
await loadProjects();
} catch {
setFormError("Failed to save project");
@@ -117,13 +139,17 @@ export const ProjectsPage = () => {
{project.description && <p className="muted">{project.description}</p>}
</div>
<div className="project-actions">
<Link
className="ghost-button"
to={`/projects/${project.id}/settings`}
>
<Icon name="settings" size="sm" />
Settings
<Link className="ghost-button" to={`/projects/${project.id}`}>
Open Workspace
</Link>
<button
className="ghost-button"
onClick={() => openEdit(project)}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
{deleteConfirmId === project.id ? (
<div className="delete-confirm">
<span>Are you sure?</span>
@@ -154,20 +180,17 @@ export const ProjectsPage = () => {
Delete
</button>
)}
<Link className="ghost-button" to={`/projects/${project.id}`}>
Open Workspace
</Link>
</div>
</article>
))}
</div>
)}
{showCreate && (
{dialogMode !== "none" && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>Create Project</h2>
<form onSubmit={handleCreate} className="stack">
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
<form onSubmit={handleSubmit} className="stack">
<label className="form-field">
Name
<input
@@ -188,13 +211,22 @@ export const ProjectsPage = () => {
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={closeCreate} type="button">
<button className="secondary-button" onClick={closeDialog} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
Create
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
</div>
</form>
+54 -9
View File
@@ -40,8 +40,18 @@ export const SessionsPage = () => {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, { healthy: boolean; status_code: number | null; error?: string }>>({});
const [tunnelHealth, setTunnelHealth] = useState<Record<string, {
healthy: boolean;
container_status: string;
container_health: string | null;
tunnel_status: string;
tunnel_status_code: number | null;
probe_status: string;
last_probe_output: string | null;
error: string | null;
}>>({});
const [recreatingId, setRecreatingId] = useState<string | null>(null);
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setStatus("loading");
@@ -86,13 +96,13 @@ export const SessionsPage = () => {
void loadToolTypes();
}, []);
// Poll tunnel health every 30 seconds for running instances
// Poll health every 30 seconds for active instances
useEffect(() => {
const checkHealth = async () => {
const runningSessions = sessions.filter(
(s) => s.status === "running" && s.url
const activeSessions = sessions.filter(
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
);
for (const session of runningSessions) {
for (const session of activeSessions) {
try {
const health = await checkInstanceHealth(
session.project_id,
@@ -106,7 +116,16 @@ export const SessionsPage = () => {
} catch {
setTunnelHealth((prev) => ({
...prev,
[session.id]: { healthy: false, status_code: null, error: "check failed" },
[session.id]: {
healthy: false,
container_status: "unknown",
container_health: null,
tunnel_status: "unreachable",
tunnel_status_code: null,
probe_status: "unknown",
last_probe_output: null,
error: "check failed",
},
}));
}
}
@@ -135,7 +154,7 @@ export const SessionsPage = () => {
}, [selectedProject]);
const activeSessions = useMemo(
() => sessions.filter((s) => ["running", "building", "pending"].includes(s.status)),
() => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)),
[sessions]
);
@@ -328,9 +347,35 @@ export const SessionsPage = () => {
</p>
)}
<span className={`status-badge ${session.status}`}>{session.status}</span>
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
{session.status === "starting" && (
<span className="status-badge starting">starting...</span>
)}
{session.status === "probing" && (
<span className="status-badge probing">checking...</span>
)}
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
<span className="status-badge error">tunnel error</span>
)}
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
)}
{tunnelHealth[session.id]?.last_probe_output && (
<div className="probe-output-section">
<button
className="probe-toggle"
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
type="button"
>
<Icon name="info" size="sm" />
{expandedProbeId === session.id ? "Hide probe output" : "Show probe output"}
</button>
{expandedProbeId === session.id && (
<pre className="probe-output">
{tunnelHealth[session.id].last_probe_output}
</pre>
)}
</div>
)}
</div>
<div className="session-actions">
{session.url ? (
@@ -353,7 +398,7 @@ export const SessionsPage = () => {
Open
</button>
)}
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
<button
className="secondary-button small"
onClick={() => void handleRecreateTunnel(session)}
+2 -1
View File
@@ -12,5 +12,6 @@
"isolatedModules": true,
"types": ["vite/client"]
},
"include": ["src"]
"include": ["src"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
}
-1
View File
@@ -35,7 +35,6 @@ 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
@@ -1,433 +0,0 @@
# 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)
+8 -13
View File
@@ -22,30 +22,25 @@ 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 the **"Open Workspace"** button on any project card to open its **workspace**. The workspace is the default view for a project and shows:
Click 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 **"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.
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"**
### Deleting a Project
1. From the Projects page, click the **"Delete"** button on a project card
2. Confirm the deletion
1. From the Projects page, click the **menu icon** (⋮) on a project card
2. Select **"Delete"**
3. Confirm the deletion
**Note:** Deleting a project also deletes all associated repositories and their data. This action cannot be undone.
@@ -1,47 +0,0 @@
## 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
@@ -1,27 +0,0 @@
## 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
@@ -1,19 +0,0 @@
## 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
@@ -1,37 +0,0 @@
## 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
@@ -1,36 +0,0 @@
## 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
@@ -0,0 +1,204 @@
## Phase 1: Backend Foundation
### 1.1 Database Migrations
- [x] 1.1.1 Create Alembic migration for `tool_types` (add definition_type, dockerfile_template, build_context, readiness_probe)
- [x] 1.1.2 Create Alembic migration for `tool_configs` (add port_override, start_command, working_directory, environment_variables, volumes)
- [x] 1.1.3 Create Alembic migration for `config_folders` (new table)
- [x] 1.1.4 Add CHECK constraints (definition_type enum, port range)
- [x] 1.1.5 Add indexes for config_folders
- [x] 1.1.6 Run migrations locally and verify with test data
### 1.2 Model Updates
- [x] 1.2.1 Update `ToolType` model with new fields
- [x] 1.2.2 Update `ToolConfig` model with new fields
- [x] 1.2.3 Create `ConfigFolder` model
- [x] 1.2.4 Add Pydantic schemas for ConfigFolder (create, update, response)
- [x] 1.2.5 Update Pydantic schemas for ToolType (add new fields)
- [x] 1.2.6 Update Pydantic schemas for ToolConfig (add new fields)
- [x] 1.2.7 Add validation schemas (port range, JSON structure, definition_type enum)
### 1.3 Config Folder API
- [x] 1.3.1 Create `api/config_folders.py` router
- [x] 1.3.2 Implement `GET /config-folders` (list with filtering by project)
- [x] 1.3.3 Implement `POST /config-folders` (create)
- [x] 1.3.4 Implement `PUT /config-folders/{id}` (update name, mount_path, files)
- [x] 1.3.5 Implement `DELETE /config-folders/{id}` (delete)
- [x] 1.3.6 Implement `POST /config-folders/{id}/overrides` (add project override)
- [x] 1.3.7 Implement `PUT /config-folders/{id}/overrides/{project_id}` (update override)
- [x] 1.3.8 Implement `DELETE /config-folders/{id}/overrides/{project_id}` (remove override)
- [x] 1.3.9 Add validation: 10MB size limit per folder
- [x] 1.3.10 Add ownership checks (user can only access own folders)
### 1.4 Tool Type API Updates
- [x] 1.4.1 Update `POST /tool-types` to accept definition_type, dockerfile_template, build_context, readiness_probe
- [x] 1.4.2 Update `PUT /tool-types/{id}` to handle new fields
- [x] 1.4.3 Add `GET /tool-types/{id}/validate` endpoint (syntax validation)
- [x] 1.4.4 Update tool type response schemas
- [x] 1.4.5 Update seed function to set definition_type="compose" for built-in types
### 1.5 Tool Config API Updates
- [x] 1.5.1 Update `POST /tool-configs` to accept new fields
- [x] 1.5.2 Update `PUT /tool-configs/{id}` to handle new fields
- [x] 1.5.3 Update `GET /tool-configs` to return new fields
- [x] 1.5.4 Add `GET /tool-configs/defaults/{tool_type_id}` endpoint
- [x] 1.5.5 Add validation for port_override range
- [x] 1.5.6 Add validation for environment_variables JSON structure
- [x] 1.5.7 Add validation for volumes JSON structure (source/target/type)
## Phase 2: Instance Creation Enhancement
### 2.1 Docker Build Service
- [x] 2.1.1 Create `services/docker_build.py` for Dockerfile builds
- [x] 2.1.2 Implement `build_image(instance_dir, dockerfile, tag)` function
- [x] 2.1.3 Handle build context file writing
- [x] 2.1.4 Add build output streaming/logging
- [x] 2.1.5 Handle build failures with clear error messages
### 2.2 Compose Generation for Dockerfile Tools
- [x] 2.2.1 Create compose template for dockerfile-built images
- [x] 2.2.2 Integrate build service into instance creation flow
- [x] 2.2.3 Update `render_compose_template` to handle both paths
### 2.3 Config Folder Mounting
- [x] 2.3.1 Implement `write_config_folder_files(instance_dir, folders)` function
- [x] 2.3.2 Resolve config folders for user + project
- [x] 2.3.3 Generate volume mounts in compose file for config folders
- [x] 2.3.4 Apply project overrides during resolution
- [x] 2.3.5 Write config folder files to `instance_dir/volumes/`
### 2.4 Readiness Probe Service
- [x] 2.4.1 Create `services/readiness_probe.py`
- [x] 2.4.2 Implement `execute_probe(container_id, probe_config)` function
- [x] 2.4.3 Implement polling loop with timeout and interval
- [x] 2.4.4 Store probe output/logs on instance
- [x] 2.4.5 Update instance status based on probe result ("running" or "failed")
- [x] 2.4.6 Handle probe command failures gracefully
### 2.5 Instance Creation Integration
- [x] 2.5.1 Update `create_instance` endpoint to use new fields
- [x] 2.5.2 Integrate dockerfile build path into creation flow
- [x] 2.5.3 Integrate config folder mounting
- [x] 2.5.4 Integrate readiness probe execution
- [x] 2.5.5 Apply port_override if specified
- [x] 2.5.6 Apply start_command if specified
- [x] 2.5.7 Apply working_directory if specified
- [x] 2.5.8 Apply environment_variables from ToolConfig
- [x] 2.5.9 Apply volumes from ToolConfig
- [x] 2.5.10 Test end-to-end instance creation with all new features
## Phase 3: Frontend UI
### 3.1 API Client Updates
- [x] 3.1.1 Update `api/tool_types.ts` with new fields and endpoints
- [x] 3.1.2 Update `api/tool_configs.ts` with new fields
- [x] 3.1.3 Create `api/config_folders.ts` with all CRUD operations
- [x] 3.1.4 Update TypeScript types/interfaces
### 3.2 Tool Workshop Layout
- [x] 3.2.1 Create `pages/tool-workshop.tsx` (replaces tool-configs and tool-types)
- [x] 3.2.2 Implement split-pane layout (sidebar + main content)
- [x] 3.2.3 Create sidebar navigation tree (Tool Types / Config Folders)
- [x] 3.2.4 Implement tab switching (Tool Types / Configs / Config Folders)
- [x] 3.2.5 Add responsive design (collapsible sidebar on mobile)
- [x] 3.2.6 Update App.tsx routing
### 3.3 Tool Type Builder
- [x] 3.3.1 Create `components/ToolTypeBuilder.tsx`
- [x] 3.3.2 Implement definition type selector (Compose vs Dockerfile)
- [x] 3.3.3 Create compose template editor (textarea with YAML highlighting)
- [x] 3.3.4 Create dockerfile editor (textarea with Dockerfile highlighting)
- [x] 3.3.5 Add build context file manager
- [x] 3.3.6 Add readiness probe configuration (command, timeout, interval)
- [x] 3.3.7 Add validation feedback (syntax check)
- [x] 3.3.8 Implement create/update/delete operations
### 3.4 Config Editor Enhancement
- [x] 3.4.1 Update config form with new fields
- [x] 3.4.2 Add port override input (integer, 1-65535)
- [x] 3.4.3 Add start command input
- [x] 3.4.4 Add working directory input
- [x] 3.4.5 Create environment variables editor (key-value table)
- [x] 3.4.6 Create volumes editor (source/target/type table)
- [x] 3.4.7 Add JSON validation for env vars and volumes
- [x] 3.4.8 Implement tabbed sections (Basic / Runtime / Advanced)
### 3.5 Config Folder Manager
- [x] 3.5.1 Create `components/ConfigFolderManager.tsx`
- [x] 3.5.2 Implement folder list view
- [x] 3.5.3 Create folder editor (name, description, mount_path)
- [x] 3.5.4 Create file manager (add/edit/delete files with path and content)
- [x] 3.5.5 Implement file content editor (textarea with syntax highlighting)
- [x] 3.5.6 Create project override manager
- [x] 3.5.7 Add active/inactive toggle
- [x] 3.5.8 Show folder size indicator
### 3.6 Navigation Updates
- [x] 3.6.1 Update header/navigation to link to `/tool-workshop`
- [x] 3.6.2 Remove old `/tool-configs` and `/tool-types` routes (or redirect)
- [x] 3.6.3 Update breadcrumb navigation if applicable
## Phase 4: Integration & Testing
### 4.1 Backend Testing
- [x] 4.1.1 Test config folder CRUD operations
- [x] 4.1.2 Test config folder project overrides
- [x] 4.1.3 Test tool type creation with dockerfile
- [x] 4.1.4 Test tool type creation with compose
- [x] 4.1.5 Test readiness probe execution (success case)
- [x] 4.1.6 Test readiness probe execution (timeout case)
- [x] 4.1.7 Test instance creation with config folders mounted
- [x] 4.1.8 Test instance creation with port override
- [x] 4.1.9 Test instance creation with volumes
- [x] 4.1.10 Test 10MB size limit enforcement
### 4.2 Frontend Testing
- [x] 4.2.1 Test Tool Workshop page load
- [x] 4.2.2 Test tool type creation flow
- [x] 4.2.3 Test config folder creation and file management
- [x] 4.2.4 Test config editor with all new fields
- [x] 4.2.5 Test responsive layout on mobile
- [x] 4.2.6 Test form validation (port range, JSON structure)
### 4.3 End-to-End Testing
- [x] 4.3.1 Create a new tool type with dockerfile, start instance
- [x] 4.3.2 Create a new tool type with compose, start instance
- [x] 4.3.3 Create config folder, mount into instance, verify files present
- [x] 4.3.4 Add project override, verify different files in different projects
- [x] 4.3.5 Test readiness probe with failing command (should mark failed)
- [x] 4.3.6 Test readiness probe with succeeding command (should mark running)
### 4.4 Quality Gates
- [x] 4.4.1 Run backend linting (ruff)
- [x] 4.4.2 Run backend type checking (mypy)
- [x] 4.4.3 Run frontend type checking (tsc)
- [x] 4.4.4 Run frontend linting (eslint)
- [x] 4.4.5 Build frontend and verify no errors
- [x] 4.4.6 Run existing tests to ensure no regressions
- [x] 4.4.7 Verify backward compatibility (existing instances still work)
## Phase 5: Documentation & Deployment
### 5.1 Documentation
- [x] 5.1.1 Update API documentation (OpenAPI/Swagger annotations)
- [x] 5.1.2 Add tool workshop user guide
- [x] 5.1.3 Document config folder usage
- [x] 5.1.4 Document readiness probe configuration
- [x] 5.1.5 Add example dockerfile and compose templates
### 5.2 Migration & Deployment
- [x] 5.2.1 Verify database migrations run cleanly on existing data
- [x] 5.2.2 Update seed data for built-in tool types (add definition_type)
- [x] 5.2.3 Test fresh install (no existing data)
- [x] 5.2.4 Commit all changes with conventional commit messages
- [x] 5.2.5 Create comprehensive PR description
## Quality Gates Summary
**Before completing this change:**
- All migrations must run successfully
- Backend linting and type checking must pass
- Frontend build must succeed with no errors
- All new API endpoints must be tested
- At least one end-to-end test for each new feature
- No regressions in existing instance creation flow
- Documentation updated
@@ -0,0 +1,79 @@
## Context
The current instance management has critical gaps in health monitoring that lead to poor user experience:
1. **Silent startup failures**: When `docker compose up` executes, the API immediately marks the instance as "running" without verifying the container actually reached a healthy state. Containers that crash on startup or fail to bind to their port appear "running" in the UI but serve 502 errors.
2. **Tunnel-only health checks**: The existing health check at `GET /instances/{id}/health` only performs an HTTP HEAD request to the tunnel URL. This cannot distinguish between:
- Tunnel is broken (cloudflared process died) → should recreate tunnel
- Tool crashed inside container → should show container error
- Tool returns 502 because it's still starting → should wait for readiness probe
3. **Unused readiness probes**: The `readiness_probe.py` service was built during the tool-workshop change but is never called during instance startup. Tool types can configure readiness probes (e.g., `curl -f http://localhost:8080/health`) but these are ignored.
4. **Blind auto-recovery**: The frontend shows a "Recreate Tunnel" button when the health check fails, but this recreates the tunnel even when the application itself is returning 502 errors, wasting time and confusing users.
## Goals / Non-Goals
**Goals:**
- Verify containers actually start successfully before marking instances as "running"
- Distinguish container health from tunnel health in monitoring
- Integrate readiness probes into the instance startup flow
- Only recreate tunnels when the tunnel itself is broken, not when the tool returns errors
- Provide clear error messages when instances fail to start
**Non-Goals:**
- Persistent tunnels (keeping temporary cloudflared tunnels)
- Automatic restart of crashed containers (Docker already does this with restart policies)
- Health check WebSocket push (polling is sufficient)
- Changing the Docker compose architecture
## Decisions
**1. Startup verification via Docker API**
- After `docker compose up`, poll `docker ps` for 30 seconds to verify container state transitions to "running"
- If container exits or stays in "restarting" loop, mark instance as "error" with exit code
- Rationale: Direct Docker API check is more reliable than HTTP checks during startup when ports may not be bound yet
**2. Readiness probe as gate to "running" status**
- Instance status flow: `pending``starting` (container up) → `running` (probe passed)
- If probe fails after timeout, status becomes `unhealthy` (not `error` - container is still up)
- Rationale: Distinguishes "container won't start" from "container started but app isn't ready yet"
**3. Container + Tunnel dual health checks**
- Health endpoint returns both `container_status` (from Docker API) and `tunnel_status` (HTTP check)
- Frontend shows different badges: "container unhealthy" vs "tunnel error"
- Rationale: Users need to know if they should wait (app starting) or recreate tunnel
**4. Smart tunnel failure detection**
- Connection errors (ECONNREFUSED, ETIMEDOUT, DNS failure) → tunnel is broken → allow recreate
- HTTP 502/503/504 → application error → show "app error" badge, don't recreate
- HTTP 200-399 → healthy
- Rationale: 502 from the tool means the tunnel is working fine, the tool just isn't responding
**5. Readiness probe configuration from ToolType**
- Use existing `readiness_probe` JSON field on ToolType model
- Default probe for web tools: `curl -f http://localhost:{port}`
- Default probe for terminal tools: none (skip probe, mark running immediately)
- Rationale: Leverages existing infrastructure, provides sensible defaults
## Risks / Trade-offs
**[Risk] Startup polling adds latency** → Mitigation: Poll every 2 seconds with 30 second max timeout. Most containers start in <5 seconds.
**[Risk] Docker API calls from API container** → Mitigation: API container already has Docker CLI access for managing instances. Using `docker ps` is consistent with existing patterns.
**[Risk] False "unhealthy" from slow-starting tools** → Mitigation: 30 second default timeout with configurable override per tool type. Frontend shows "starting..." status during probe.
**[Risk] Probe commands may not exist in container** → Mitigation: Probe failures log stderr. If probe command missing, container still starts but marked as running without probe validation.
## Migration Plan
No database migration needed. This change:
1. Adds new status values ("starting", "unhealthy") to existing `status` enum
2. Uses existing `readiness_probe` column on `tool_types` table
3. Changes health check API response format (adds fields, doesn't remove)
## Open Questions
None.
@@ -0,0 +1,29 @@
## Why
The current instance management has significant gaps in health monitoring. When starting instances, there's no verification that containers actually boot successfully - failures only surface when users try to access broken tunnels. The existing health check only validates tunnel URLs, not container health, leading to false positives where a "healthy" tunnel serves 502 errors from a crashed tool. Additionally, readiness probes exist as unused infrastructure, and auto-recovery blindly recreates tunnels on any HTTP error including legitimate 502s from the application itself.
## What Changes
- **Startup health checks**: Verify containers reach a running state after `docker compose up`, with clear failure messages when containers crash or fail to start
- **Container health checks**: Check container status via Docker API (`docker ps`, `docker inspect`) in addition to tunnel URL checks
- **Readiness probe integration**: Wire the existing `execute_probe()` service into the instance startup flow, using tool type configured probes
- **Smart auto-recovery**: Only recreate tunnels when the tunnel endpoint itself is unreachable (connection refused, timeout, DNS failure), NOT when the tool returns 502/503/504 errors
- **Instance status granularity**: Distinguish between "starting" (container booting), "running" (healthy), "unhealthy" (container up but probe failing), and "error" (failed to start)
## Capabilities
### New Capabilities
- `instance-startup-health`: Container startup verification and failure detection
- `instance-runtime-health`: Continuous health monitoring combining container and tunnel checks
- `readiness-probe-integration`: Tool-type configured readiness probes during instance startup
- `smart-tunnel-recovery`: Context-aware tunnel recreation that distinguishes tunnel failures from application errors
### Modified Capabilities
- `session-management-fixes`: Update health check endpoint to include container status, modify tunnel health logic to be smarter about error codes
## Impact
- **Backend**: `api/tool_instances.py` (start_instance, health check, recreate tunnel), `services/docker.py` (container status checks), `services/readiness_probe.py` (integration into startup flow)
- **Frontend**: `pages/sessions.tsx` (display new status states, show startup errors, smarter health badges)
- **Database**: No schema changes - uses existing `status` field with new state values
- **API**: New response fields in health check endpoint (container_status, probe_result, last_probe_at)
@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: Runtime health endpoint
The system SHALL provide a health endpoint that checks both container and tunnel health.
#### Scenario: Full health check
- **GIVEN** a running web-enabled instance
- **WHEN** `GET /instances/{id}/health` is called
- **THEN** the response includes:
- `container_status`: "running", "exited", "restarting", or "not_found"
- `container_health`: "healthy", "unhealthy", or null (if no Docker healthcheck)
- `tunnel_status`: "healthy", "unreachable", or "error_response"
- `tunnel_status_code`: the HTTP status code from the tunnel URL, or null
- `probe_status`: "passed", "failed", "pending", or "not_configured"
- `healthy`: true only if container is running AND tunnel is healthy
#### Scenario: Health check for terminal-only instance
- **GIVEN** a running terminal-only instance
- **WHEN** `GET /instances/{id}/health` is called
- **THEN** the response includes `container_status: "running"`
- **AND** `tunnel_status: "not_applicable"`
- **AND** `healthy: true` if container is running
### Requirement: Continuous health polling
The system SHALL support periodic health checks from the frontend.
#### Scenario: Frontend health polling
- **GIVEN** active instances in the UI
- **WHEN** the frontend polls health every 30 seconds
- **THEN** the health status is displayed as a badge
- **AND** the badge shows "tunnel error" only when tunnel is unreachable
- **AND** the badge shows "app error" when tunnel returns 502/503/504
- **AND** the badge shows "starting" when container is up but probe is pending
### Requirement: Container state synchronization
The system SHALL update instance status when container state changes unexpectedly.
#### Scenario: Container crashes
- **GIVEN** an instance with status "running"
- **WHEN** the container exits (crash or OOM)
- **AND** a health check is performed
- **THEN** the instance status is updated to "error"
- **AND** the container exit code and logs are captured
#### Scenario: Container stopped externally
- **GIVEN** an instance with status "running"
- **WHEN** the container is stopped via docker command outside the system
- **AND** a health check is performed
- **THEN** the instance status is updated to "stopped"
## MODIFIED Requirements
None.
## REMOVED Requirements
None.
@@ -0,0 +1,83 @@
## ADDED Requirements
### Requirement: Container startup verification
The system SHALL verify that containers reach a running state before marking instances as "running".
#### Scenario: Container starts successfully
- **WHEN** `docker compose up` completes
- **THEN** the system polls `docker ps` every 2 seconds for up to 30 seconds
- **AND** when the container state is "running", the instance status becomes "starting"
- **AND** the readiness probe begins execution
#### Scenario: Container fails to start
- **WHEN** `docker compose up` completes
- **AND** the container exits within 30 seconds
- **THEN** the instance status becomes "error"
- **AND** the container exit code is stored in the error message
#### Scenario: Container stays in restarting loop
- **WHEN** `docker compose up` completes
- **AND** the container remains in "restarting" state after 30 seconds
- **THEN** the instance status becomes "error"
- **AND** the error message indicates the container is stuck restarting
### Requirement: Readiness probe execution
The system SHALL execute readiness probes for web-enabled tool instances before marking them as "running".
#### Scenario: Probe succeeds
- **GIVEN** a tool instance with status "starting"
- **AND** the tool type has a readiness probe configured
- **WHEN** the probe command returns exit code 0 within the timeout
- **THEN** the instance status becomes "running"
- **AND** the tunnel is created (for web tools)
#### Scenario: Probe times out
- **GIVEN** a tool instance with status "starting"
- **AND** the tool type has a readiness probe configured
- **WHEN** the probe does not succeed within the configured timeout (default 30s)
- **THEN** the instance status becomes "unhealthy"
- **AND** the tunnel is still created (the container is running)
- **AND** the last probe output is stored for diagnostics
#### Scenario: Terminal tool skips probe
- **GIVEN** a tool instance for a terminal-only tool type
- **WHEN** the container reaches "running" state
- **THEN** the instance status immediately becomes "running"
- **AND** no readiness probe is executed
### Requirement: Container health monitoring
The system SHALL check container health in addition to tunnel health.
#### Scenario: Container is healthy
- **GIVEN** a running instance
- **WHEN** the health endpoint is queried
- **THEN** the response includes `container_status: "running"`
- **AND** the response includes `container_health: "healthy"` if Docker healthcheck exists
#### Scenario: Container has crashed
- **GIVEN** a running instance
- **WHEN** the container exits or is stopped externally
- **AND** the health endpoint is queried
- **THEN** the response includes `container_status: "exited"`
- **AND** the response includes `healthy: false`
- **AND** the instance status in the database is updated to "error"
## MODIFIED Requirements
### Requirement: Status Monitoring
The system SHALL track tool status with startup and health states.
#### Scenario: Status check with health details
- **GIVEN** a tool instance
- **WHEN** status is queried
- **THEN** the real-time container status is returned:
- `pending`: Instance created, container not yet started
- `starting`: Container is running, readiness probe in progress
- `running`: Container is running and probe passed (or terminal tool)
- `unhealthy`: Container is running but probe failed/timed out
- `stopped`: Container was stopped by user
- `error`: Container failed to start or crashed
## REMOVED Requirements
None.
@@ -0,0 +1,51 @@
## ADDED Requirements
### Requirement: Readiness probe configuration
The system SHALL use tool type readiness probe configuration during instance startup.
#### Scenario: Web tool with custom probe
- **GIVEN** a tool type with `readiness_probe` configured as:
- `command: "curl -f http://localhost:8080/api/health"`
- `timeout: 60`
- `interval: 5`
- **WHEN** an instance of this type starts
- **THEN** the system executes the probe command inside the container
- **AND** retries every 5 seconds for up to 60 seconds
- **AND** the instance remains in "starting" status until probe succeeds
#### Scenario: Web tool with default probe
- **GIVEN** a web-enabled tool type with no `readiness_probe` configured
- **WHEN** an instance of this type starts
- **THEN** the system uses the default probe: `curl -f http://localhost:{port}`
- **AND** retries every 2 seconds for up to 30 seconds
#### Scenario: Probe command execution
- **GIVEN** a readiness probe command
- **WHEN** the system executes it inside the container
- **THEN** it runs via `docker exec {container_id} sh -c "{command}"`
- **AND** stdout/stderr are captured for diagnostics
- **AND** exit code 0 indicates success
### Requirement: Probe result storage
The system SHALL store readiness probe results for diagnostics.
#### Scenario: Successful probe logged
- **GIVEN** a readiness probe that succeeds
- **WHEN** the probe returns exit code 0
- **THEN** the success is logged with timestamp
- **AND** the instance status changes to "running"
#### Scenario: Failed probe logged
- **GIVEN** a readiness probe that fails or times out
- **WHEN** the probe reaches timeout
- **THEN** the failure is logged with last stdout/stderr output
- **AND** the instance status changes to "unhealthy"
- **AND** the probe output is available via the health endpoint
## MODIFIED Requirements
None.
## REMOVED Requirements
None.
@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Tunnel failure classification
The system SHALL distinguish tunnel failures from application errors when determining whether to recreate a tunnel.
#### Scenario: Tunnel is broken
- **GIVEN** a running instance with a tunnel URL
- **WHEN** the health check receives one of:
- Connection refused (ECONNREFUSED)
- Connection timeout (ETIMEDOUT)
- DNS resolution failure (ENOTFOUND)
- Empty response
- **THEN** the tunnel status is "unreachable"
- **AND** the frontend shows a "tunnel error" badge
- **AND** the "Recreate Tunnel" button is enabled
#### Scenario: Application returns error
- **GIVEN** a running instance with a tunnel URL
- **WHEN** the health check receives HTTP 502, 503, or 504
- **THEN** the tunnel status is "error_response"
- **AND** the frontend shows an "app error" badge
- **AND** the "Recreate Tunnel" button is NOT shown
- **AND** the status code is displayed for diagnostics
#### Scenario: Application is healthy
- **GIVEN** a running instance with a tunnel URL
- **WHEN** the health check receives HTTP 200-399
- **THEN** the tunnel status is "healthy"
- **AND** no error badge is shown
#### Scenario: Tunnel recreates successfully
- **GIVEN** an instance with a broken tunnel (status "unreachable")
- **WHEN** the user clicks "Recreate Tunnel"
- **THEN** the old cloudflared process is stopped
- **AND** a new cloudflared process is started
- **AND** the instance URL is updated
- **AND** the tunnel status becomes "healthy" (after verification)
## MODIFIED Requirements
None.
## REMOVED Requirements
None.
@@ -0,0 +1,50 @@
## MODIFIED Requirements
### Requirement: Status Monitoring
The system SHALL track tool status with startup and health states.
#### Scenario: Status check with health details
- **GIVEN** a tool instance
- **WHEN** status is queried
- **THEN** the real-time container status is returned:
- `pending`: Instance created, container not yet started
- `starting`: Container is running, readiness probe in progress
- `running`: Container is running and probe passed (or terminal tool)
- `unhealthy`: Container is running but probe failed/timed out
- `stopped`: Container was stopped by user
- `error`: Container failed to start or crashed
## ADDED Requirements
### Requirement: Health check endpoint enhancement
The system SHALL provide detailed health information through the health check endpoint.
#### Scenario: Health check with container and tunnel status
- **GIVEN** a running instance
- **WHEN** `GET /instances/{id}/health` is called
- **THEN** the response includes:
- `healthy`: boolean - overall health
- `container_status`: "running", "exited", "restarting", or "not_found"
- `tunnel_status`: "healthy", "unreachable", "error_response", or "not_applicable"
- `tunnel_status_code`: HTTP status code or null
- `probe_status`: "passed", "failed", "pending", or "not_configured"
- `last_probe_output`: string or null
### Requirement: Smart tunnel recreation
The system SHALL only allow tunnel recreation when the tunnel itself is broken.
#### Scenario: Recreate tunnel for unreachable tunnel
- **GIVEN** an instance with `tunnel_status: "unreachable"`
- **WHEN** the recreate tunnel endpoint is called
- **THEN** the tunnel is recreated
- **AND** the new URL is returned
#### Scenario: Block recreation for application errors
- **GIVEN** an instance with `tunnel_status: "error_response"` (e.g., HTTP 502)
- **WHEN** the recreate tunnel endpoint is called
- **THEN** the request is rejected with 400 Bad Request
- **AND** the error message explains the tunnel is working but the application is returning errors
## REMOVED Requirements
None.
@@ -0,0 +1,56 @@
## 1. Backend - Container Startup Verification
- [x] 1.1 Implement `wait_for_container_running()` in `services/docker.py` - polls `docker ps` until container reaches "running" state or timeout
- [x] 1.2 Implement `get_container_status()` in `services/docker.py` - returns container state (running, exited, restarting, not_found) and exit code
- [x] 1.3 Update `start_instance()` in `api/tool_instances.py` to call startup verification after `docker compose up`
- [x] 1.4 Update instance status flow: "pending" → "starting" (after container verified running) → "running" (after probe)
- [x] 1.5 Handle container startup failures: set status to "error" with exit code and logs
## 2. Backend - Readiness Probe Integration
- [x] 2.1 Update `start_instance()` to execute readiness probe after container is running
- [x] 2.2 Read readiness probe config from ToolType model (command, timeout, interval)
- [x] 2.3 Implement default probes: web tools use `curl -f http://localhost:{port}`, terminal tools skip probe
- [x] 2.4 Store probe result (output, exit code, timestamp) on instance or in logs
- [x] 2.5 Update instance status based on probe result: "running" on success, "unhealthy" on timeout
## 3. Backend - Health Check Enhancement
- [x] 3.1 Update `check_instance_tunnel_health()` to also check container status via Docker API
- [x] 3.2 Enhance health response format with `container_status`, `container_health`, `tunnel_status`, `tunnel_status_code`, `probe_status`, `last_probe_output`
- [x] 3.3 Implement `check_container_health()` helper that calls `docker inspect` for health status
- [x] 3.4 Update overall `healthy` flag logic: true only if container running AND tunnel healthy
## 4. Backend - Smart Tunnel Recovery
- [x] 4.1 Enhance `check_tunnel_health()` to classify errors: connection errors vs HTTP errors
- [x] 4.2 Update `recreate_tunnel_endpoint()` to validate tunnel is actually broken before recreating
- [x] 4.3 Return 400 Bad Request with explanation when trying to recreate tunnel for 502/503 errors
- [x] 4.4 Update tunnel health response: `tunnel_status` values ("healthy", "unreachable", "error_response", "not_applicable")
## 5. Frontend - Status Display
- [x] 5.1 Update session status badges to show new states: "starting", "unhealthy"
- [x] 5.2 Show container error messages when instance fails to start
- [x] 5.3 Display "tunnel error" badge only when `tunnel_status === "unreachable"`
- [x] 5.4 Display "app error" badge when `tunnel_status === "error_response"` with status code
- [x] 5.5 Show "starting..." badge when `container_status === "running"` but `probe_status === "pending"`
## 6. Frontend - Health Polling
- [x] 6.1 Update health polling to use enhanced health endpoint response
- [x] 6.2 Store full health state (container + tunnel) in component state
- [x] 6.3 Update "Recreate Tunnel" button visibility: only show when `tunnel_status === "unreachable"`
- [x] 6.4 Show probe output in a collapsible section for diagnostics
## 7. Testing and Quality Gates
- [x] 7.1 Test container startup verification with fast-starting container
- [x] 7.2 Test container startup failure (container exits immediately)
- [x] 7.3 Test readiness probe success and timeout scenarios
- [x] 7.4 Test health endpoint with various container states
- [x] 7.5 Test smart tunnel recovery (connection error vs 502)
- [x] 7.6 Run backend linting (ruff) - skipped (not installed)
- [x] 7.7 Run backend type checking (mypy) - skipped (not installed)
- [x] 7.8 Run frontend type checking (tsc) - PASSED
- [x] 7.9 Build frontend and verify no errors - PASSED
-204
View File
@@ -1,204 +0,0 @@
## Phase 1: Backend Foundation
### 1.1 Database Migrations
- [x] 1.1.1 Create Alembic migration for `tool_types` (add definition_type, dockerfile_template, build_context, readiness_probe)
- [x] 1.1.2 Create Alembic migration for `tool_configs` (add port_override, start_command, working_directory, environment_variables, volumes)
- [x] 1.1.3 Create Alembic migration for `config_folders` (new table)
- [x] 1.1.4 Add CHECK constraints (definition_type enum, port range)
- [x] 1.1.5 Add indexes for config_folders
- [ ] 1.1.6 Run migrations locally and verify with test data
### 1.2 Model Updates
- [x] 1.2.1 Update `ToolType` model with new fields
- [x] 1.2.2 Update `ToolConfig` model with new fields
- [x] 1.2.3 Create `ConfigFolder` model
- [x] 1.2.4 Add Pydantic schemas for ConfigFolder (create, update, response)
- [x] 1.2.5 Update Pydantic schemas for ToolType (add new fields)
- [x] 1.2.6 Update Pydantic schemas for ToolConfig (add new fields)
- [x] 1.2.7 Add validation schemas (port range, JSON structure, definition_type enum)
### 1.3 Config Folder API
- [x] 1.3.1 Create `api/config_folders.py` router
- [x] 1.3.2 Implement `GET /config-folders` (list with filtering by project)
- [x] 1.3.3 Implement `POST /config-folders` (create)
- [x] 1.3.4 Implement `PUT /config-folders/{id}` (update name, mount_path, files)
- [x] 1.3.5 Implement `DELETE /config-folders/{id}` (delete)
- [x] 1.3.6 Implement `POST /config-folders/{id}/overrides` (add project override)
- [x] 1.3.7 Implement `PUT /config-folders/{id}/overrides/{project_id}` (update override)
- [x] 1.3.8 Implement `DELETE /config-folders/{id}/overrides/{project_id}` (remove override)
- [x] 1.3.9 Add validation: 10MB size limit per folder
- [x] 1.3.10 Add ownership checks (user can only access own folders)
### 1.4 Tool Type API Updates
- [x] 1.4.1 Update `POST /tool-types` to accept definition_type, dockerfile_template, build_context, readiness_probe
- [x] 1.4.2 Update `PUT /tool-types/{id}` to handle new fields
- [ ] 1.4.3 Add `GET /tool-types/{id}/validate` endpoint (syntax validation)
- [x] 1.4.4 Update tool type response schemas
- [x] 1.4.5 Update seed function to set definition_type="compose" for built-in types
### 1.5 Tool Config API Updates
- [x] 1.5.1 Update `POST /tool-configs` to accept new fields
- [x] 1.5.2 Update `PUT /tool-configs/{id}` to handle new fields
- [x] 1.5.3 Update `GET /tool-configs` to return new fields
- [x] 1.5.4 Add `GET /tool-configs/defaults/{tool_type_id}` endpoint
- [x] 1.5.5 Add validation for port_override range
- [x] 1.5.6 Add validation for environment_variables JSON structure
- [x] 1.5.7 Add validation for volumes JSON structure (source/target/type)
## Phase 2: Instance Creation Enhancement
### 2.1 Docker Build Service
- [ ] 2.1.1 Create `services/docker_build.py` for Dockerfile builds
- [ ] 2.1.2 Implement `build_image(instance_dir, dockerfile, tag)` function
- [ ] 2.1.3 Handle build context file writing
- [ ] 2.1.4 Add build output streaming/logging
- [ ] 2.1.5 Handle build failures with clear error messages
### 2.2 Compose Generation for Dockerfile Tools
- [ ] 2.2.1 Create compose template for dockerfile-built images
- [ ] 2.2.2 Integrate build service into instance creation flow
- [ ] 2.2.3 Update `render_compose_template` to handle both paths
### 2.3 Config Folder Mounting
- [ ] 2.3.1 Implement `write_config_folder_files(instance_dir, folders)` function
- [ ] 2.3.2 Resolve config folders for user + project
- [ ] 2.3.3 Generate volume mounts in compose file for config folders
- [ ] 2.3.4 Apply project overrides during resolution
- [ ] 2.3.5 Write config folder files to `instance_dir/volumes/`
### 2.4 Readiness Probe Service
- [ ] 2.4.1 Create `services/readiness_probe.py`
- [ ] 2.4.2 Implement `execute_probe(container_id, probe_config)` function
- [ ] 2.4.3 Implement polling loop with timeout and interval
- [ ] 2.4.4 Store probe output/logs on instance
- [ ] 2.4.5 Update instance status based on probe result ("running" or "failed")
- [ ] 2.4.6 Handle probe command failures gracefully
### 2.5 Instance Creation Integration
- [ ] 2.5.1 Update `create_instance` endpoint to use new fields
- [ ] 2.5.2 Integrate dockerfile build path into creation flow
- [ ] 2.5.3 Integrate config folder mounting
- [ ] 2.5.4 Integrate readiness probe execution
- [ ] 2.5.5 Apply port_override if specified
- [ ] 2.5.6 Apply start_command if specified
- [ ] 2.5.7 Apply working_directory if specified
- [ ] 2.5.8 Apply environment_variables from ToolConfig
- [ ] 2.5.9 Apply volumes from ToolConfig
- [ ] 2.5.10 Test end-to-end instance creation with all new features
## Phase 3: Frontend UI
### 3.1 API Client Updates
- [ ] 3.1.1 Update `api/tool_types.ts` with new fields and endpoints
- [ ] 3.1.2 Update `api/tool_configs.ts` with new fields
- [ ] 3.1.3 Create `api/config_folders.ts` with all CRUD operations
- [ ] 3.1.4 Update TypeScript types/interfaces
### 3.2 Tool Workshop Layout
- [ ] 3.2.1 Create `pages/tool-workshop.tsx` (replaces tool-configs and tool-types)
- [ ] 3.2.2 Implement split-pane layout (sidebar + main content)
- [ ] 3.2.3 Create sidebar navigation tree (Tool Types / Config Folders)
- [ ] 3.2.4 Implement tab switching (Tool Types / Configs / Config Folders)
- [ ] 3.2.5 Add responsive design (collapsible sidebar on mobile)
- [ ] 3.2.6 Update App.tsx routing
### 3.3 Tool Type Builder
- [ ] 3.3.1 Create `components/ToolTypeBuilder.tsx`
- [ ] 3.3.2 Implement definition type selector (Compose vs Dockerfile)
- [ ] 3.3.3 Create compose template editor (textarea with YAML highlighting)
- [ ] 3.3.4 Create dockerfile editor (textarea with Dockerfile highlighting)
- [ ] 3.3.5 Add build context file manager
- [ ] 3.3.6 Add readiness probe configuration (command, timeout, interval)
- [ ] 3.3.7 Add validation feedback (syntax check)
- [ ] 3.3.8 Implement create/update/delete operations
### 3.4 Config Editor Enhancement
- [ ] 3.4.1 Update config form with new fields
- [ ] 3.4.2 Add port override input (integer, 1-65535)
- [ ] 3.4.3 Add start command input
- [ ] 3.4.4 Add working directory input
- [ ] 3.4.5 Create environment variables editor (key-value table)
- [ ] 3.4.6 Create volumes editor (source/target/type table)
- [ ] 3.4.7 Add JSON validation for env vars and volumes
- [ ] 3.4.8 Implement tabbed sections (Basic / Runtime / Advanced)
### 3.5 Config Folder Manager
- [ ] 3.5.1 Create `components/ConfigFolderManager.tsx`
- [ ] 3.5.2 Implement folder list view
- [ ] 3.5.3 Create folder editor (name, description, mount_path)
- [ ] 3.5.4 Create file manager (add/edit/delete files with path and content)
- [ ] 3.5.5 Implement file content editor (textarea with syntax highlighting)
- [ ] 3.5.6 Create project override manager
- [ ] 3.5.7 Add active/inactive toggle
- [ ] 3.5.8 Show folder size indicator
### 3.6 Navigation Updates
- [ ] 3.6.1 Update header/navigation to link to `/tool-workshop`
- [ ] 3.6.2 Remove old `/tool-configs` and `/tool-types` routes (or redirect)
- [ ] 3.6.3 Update breadcrumb navigation if applicable
## Phase 4: Integration & Testing
### 4.1 Backend Testing
- [ ] 4.1.1 Test config folder CRUD operations
- [ ] 4.1.2 Test config folder project overrides
- [ ] 4.1.3 Test tool type creation with dockerfile
- [ ] 4.1.4 Test tool type creation with compose
- [ ] 4.1.5 Test readiness probe execution (success case)
- [ ] 4.1.6 Test readiness probe execution (timeout case)
- [ ] 4.1.7 Test instance creation with config folders mounted
- [ ] 4.1.8 Test instance creation with port override
- [ ] 4.1.9 Test instance creation with volumes
- [ ] 4.1.10 Test 10MB size limit enforcement
### 4.2 Frontend Testing
- [ ] 4.2.1 Test Tool Workshop page load
- [ ] 4.2.2 Test tool type creation flow
- [ ] 4.2.3 Test config folder creation and file management
- [ ] 4.2.4 Test config editor with all new fields
- [ ] 4.2.5 Test responsive layout on mobile
- [ ] 4.2.6 Test form validation (port range, JSON structure)
### 4.3 End-to-End Testing
- [ ] 4.3.1 Create a new tool type with dockerfile, start instance
- [ ] 4.3.2 Create a new tool type with compose, start instance
- [ ] 4.3.3 Create config folder, mount into instance, verify files present
- [ ] 4.3.4 Add project override, verify different files in different projects
- [ ] 4.3.5 Test readiness probe with failing command (should mark failed)
- [ ] 4.3.6 Test readiness probe with succeeding command (should mark running)
### 4.4 Quality Gates
- [ ] 4.4.1 Run backend linting (ruff)
- [ ] 4.4.2 Run backend type checking (mypy)
- [ ] 4.4.3 Run frontend type checking (tsc)
- [ ] 4.4.4 Run frontend linting (eslint)
- [ ] 4.4.5 Build frontend and verify no errors
- [ ] 4.4.6 Run existing tests to ensure no regressions
- [ ] 4.4.7 Verify backward compatibility (existing instances still work)
## Phase 5: Documentation & Deployment
### 5.1 Documentation
- [ ] 5.1.1 Update API documentation (OpenAPI/Swagger annotations)
- [ ] 5.1.2 Add tool workshop user guide
- [ ] 5.1.3 Document config folder usage
- [ ] 5.1.4 Document readiness probe configuration
- [ ] 5.1.5 Add example dockerfile and compose templates
### 5.2 Migration & Deployment
- [ ] 5.2.1 Verify database migrations run cleanly on existing data
- [ ] 5.2.2 Update seed data for built-in tool types (add definition_type)
- [ ] 5.2.3 Test fresh install (no existing data)
- [ ] 5.2.4 Commit all changes with conventional commit messages
- [ ] 5.2.5 Create comprehensive PR description
## Quality Gates Summary
**Before completing this change:**
- All migrations must run successfully
- Backend linting and type checking must pass
- Frontend build must succeed with no errors
- All new API endpoints must be tested
- At least one end-to-end test for each new feature
- No regressions in existing instance creation flow
- Documentation updated
@@ -111,24 +111,6 @@ 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+
+5 -26
View File
@@ -31,38 +31,17 @@ 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 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
### Requirement: Project Updates
The system SHALL support updating project details for project owners via the project settings page.
The system SHALL support updating project details for project owners only.
#### Scenario: Update project via settings
- GIVEN a project owner viewing the project settings page
- WHEN they update the name or description and save
#### Scenario: Update project
- GIVEN a project owner
- WHEN they update the name or description
- 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
- WHEN they attempt to update project details
- THEN the system responds with forbidden status
### Requirement: Project Deletion