Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22c035984e | |||
| d35037df01 |
@@ -87,31 +87,6 @@ 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:
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""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")
|
||||
@@ -30,14 +30,11 @@ 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,
|
||||
@@ -555,67 +552,20 @@ async def start_instance(
|
||||
else:
|
||||
logger.warning("Failed to connect %s to backend network", container_name)
|
||||
|
||||
# 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"],
|
||||
)
|
||||
|
||||
instance.status = "starting"
|
||||
instance.last_started_at = datetime.now()
|
||||
await session.commit()
|
||||
logger.info("Instance %s container is running, checking readiness", instance.id)
|
||||
|
||||
# Execute readiness probe if configured
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
if tool_type and instance.container_id:
|
||||
# Determine probe command
|
||||
probe_command = None
|
||||
probe_timeout = 30
|
||||
probe_interval = 2
|
||||
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.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()
|
||||
if probe_command and instance.container_id:
|
||||
logger.info(
|
||||
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
|
||||
instance.id, probe_command, probe_timeout, probe_interval
|
||||
@@ -628,25 +578,14 @@ 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 = "unhealthy"
|
||||
instance.status = "failed"
|
||||
instance.url = None
|
||||
instance.public_url = None
|
||||
await session.commit()
|
||||
logger.error(
|
||||
"Readiness probe failed for instance %s after %ds: %s",
|
||||
instance.id,
|
||||
probe_timeout,
|
||||
"\n".join(probe_logs),
|
||||
)
|
||||
logger.error("Readiness probe failed for instance %s: %s", instance.id, "\n".join(probe_logs))
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"status": "failed",
|
||||
"error": f"Readiness probe failed after {probe_timeout}s",
|
||||
"probe_logs": probe_logs,
|
||||
}
|
||||
@@ -1017,17 +956,6 @@ 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
|
||||
@@ -1059,8 +987,8 @@ async def recreate_tunnel_endpoint(
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
|
||||
summary="Check instance health",
|
||||
description="Check container and tunnel health for an instance.",
|
||||
summary="Check tunnel health",
|
||||
description="Check if the temporary Cloudflare tunnel for an instance is healthy.",
|
||||
)
|
||||
async def check_instance_tunnel_health(
|
||||
project_id: uuid.UUID,
|
||||
@@ -1069,7 +997,7 @@ async def check_instance_tunnel_health(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Check health for an instance (container + tunnel).
|
||||
"""Check tunnel health for an instance.
|
||||
|
||||
Args:
|
||||
project_id: UUID of the project.
|
||||
@@ -1079,7 +1007,7 @@ async def check_instance_tunnel_health(
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag.
|
||||
Dictionary with health status.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
_project = await _get_owned_project(project_id, user_id, session)
|
||||
@@ -1090,50 +1018,11 @@ async def check_instance_tunnel_health(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
# 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)
|
||||
if not instance.url or instance.status != "running":
|
||||
return {"healthy": False, "status_code": None, "error": "instance not running"}
|
||||
|
||||
# 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
|
||||
health = check_tunnel_health(instance.url)
|
||||
return health
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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
|
||||
@@ -8,4 +11,17 @@ from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ConfigFolder",
|
||||
"ConfigInclude",
|
||||
"ConfigMount",
|
||||
"ConfigProfile",
|
||||
"GitRepository",
|
||||
"Project",
|
||||
"SSHKey",
|
||||
"ToolInstance",
|
||||
"ToolType",
|
||||
"User",
|
||||
"UserConfig",
|
||||
]
|
||||
|
||||
@@ -26,6 +26,8 @@ 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()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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],
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
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
|
||||
)
|
||||
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
profile: Mapped["ConfigProfile"] = relationship(
|
||||
"ConfigProfile",
|
||||
foreign_keys=[profile_id],
|
||||
back_populates="mounts",
|
||||
)
|
||||
source_profile: Mapped["ConfigProfile | None"] = relationship(
|
||||
"ConfigProfile",
|
||||
foreign_keys=[source_profile_id],
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, 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.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
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
includes: Mapped[list["ConfigInclude"]] = relationship(
|
||||
"ConfigInclude",
|
||||
foreign_keys="ConfigInclude.profile_id",
|
||||
back_populates="profile",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ConfigInclude.order_index",
|
||||
)
|
||||
mounts: Mapped[list["ConfigMount"]] = relationship(
|
||||
"ConfigMount",
|
||||
back_populates="profile",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ConfigMount.order_index",
|
||||
)
|
||||
@@ -2,13 +2,14 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, 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
|
||||
@@ -62,11 +63,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
probe_result: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True
|
||||
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), 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()
|
||||
|
||||
@@ -18,3 +18,23 @@ 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
|
||||
|
||||
+12
-119
@@ -244,94 +244,24 @@ def connect_container_to_network(container_name: str, network_name: str = "backe
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
def get_container_status(container_id: str) -> str:
|
||||
"""Get the status of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
'exit_code' (int or None), and 'health' (health status or None)
|
||||
Container status string (running, exited, etc.)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "inspect", "-f",
|
||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||
container_id,
|
||||
],
|
||||
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||
@@ -494,15 +424,14 @@ def recreate_tunnel(
|
||||
|
||||
|
||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
"""Check if a tunnel URL is healthy with smart error classification.
|
||||
"""Check if a tunnel URL is healthy.
|
||||
|
||||
Args:
|
||||
url: The tunnel URL to check
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
|
||||
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
|
||||
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
@@ -515,49 +444,13 @@ 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 {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"error": "Tunnel request timed out",
|
||||
"healthy": 200 <= status_code < 400,
|
||||
"status_code": status_code,
|
||||
}
|
||||
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}",
|
||||
}
|
||||
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
"healthy": False,
|
||||
"status_code": None,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
@@ -124,15 +124,7 @@ 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:
|
||||
# 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
|
||||
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||
return
|
||||
|
||||
_run_git_command(repo_path, "branch", name, base_branch)
|
||||
@@ -163,14 +155,7 @@ def checkout_branch(repo_path: str, name: str) -> None:
|
||||
Raises:
|
||||
RuntimeError: If checkout fails
|
||||
"""
|
||||
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
|
||||
_run_git_command(repo_path, "checkout", name)
|
||||
|
||||
|
||||
def commit_changes(
|
||||
@@ -305,10 +290,6 @@ def get_current_branch(repo_path: str) -> str:
|
||||
Current branch name
|
||||
"""
|
||||
try:
|
||||
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||
if branch != "HEAD":
|
||||
return branch
|
||||
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||
|
||||
@@ -70,20 +70,6 @@ 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."""
|
||||
|
||||
|
||||
@@ -39,3 +39,18 @@ 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"
|
||||
|
||||
@@ -25,8 +25,6 @@ export interface Session {
|
||||
project_id: string;
|
||||
status: string;
|
||||
url: string | null;
|
||||
container_status?: string;
|
||||
probe_status?: string;
|
||||
}
|
||||
|
||||
export async function listInstances(
|
||||
@@ -103,23 +101,11 @@ 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<InstanceHealth> {
|
||||
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
||||
);
|
||||
|
||||
@@ -40,18 +40,8 @@ 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;
|
||||
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 [tunnelHealth, setTunnelHealth] = useState<Record<string, { healthy: boolean; status_code: number | null; error?: string }>>({});
|
||||
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
@@ -96,13 +86,13 @@ export const SessionsPage = () => {
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
// Poll health every 30 seconds for active instances
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const activeSessions = sessions.filter(
|
||||
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
||||
const runningSessions = sessions.filter(
|
||||
(s) => s.status === "running" && s.url
|
||||
);
|
||||
for (const session of activeSessions) {
|
||||
for (const session of runningSessions) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
session.project_id,
|
||||
@@ -116,16 +106,7 @@ export const SessionsPage = () => {
|
||||
} catch {
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[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",
|
||||
},
|
||||
[session.id]: { healthy: false, status_code: null, error: "check failed" },
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -154,7 +135,7 @@ export const SessionsPage = () => {
|
||||
}, [selectedProject]);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)),
|
||||
() => sessions.filter((s) => ["running", "building", "pending"].includes(s.status)),
|
||||
[sessions]
|
||||
);
|
||||
|
||||
@@ -347,35 +328,9 @@ export const SessionsPage = () => {
|
||||
</p>
|
||||
)}
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
{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" && (
|
||||
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
||||
<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 ? (
|
||||
@@ -398,7 +353,7 @@ export const SessionsPage = () => {
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
||||
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => void handleRecreateTunnel(session)}
|
||||
|
||||
@@ -12,6 +12,5 @@
|
||||
"isolatedModules": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-22
|
||||
created: 2026-05-24
|
||||
@@ -0,0 +1,45 @@
|
||||
## Context
|
||||
|
||||
The current system uses `config_folders` with a flat `files` JSONB and an `is_active` flag for auto-mounting at tool launch time. This design is inflexible: only one folder can be active, there's no ordering of includes, no explicit per-tool-instance selection, and mount definitions are mixed with file contents in a single blob.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Provide structured config profiles with named collections of mounts and includes
|
||||
- Support ordered include lists so profiles can reference other profiles in sequence
|
||||
- Allow per-tool-instance profile selection with fallback to user/tool-type defaults
|
||||
- Remove implicit auto-mounting behavior at launch time
|
||||
- Maintain backward compatibility for existing `config_folders` data during migration
|
||||
|
||||
**Non-Goals:**
|
||||
- Frontend UI for profile management (separate change)
|
||||
- Real-time profile switching on running instances
|
||||
- Profile versioning or history
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. New `config_profiles` table replaces the semantic role of `config_folders`
|
||||
- Rationale: A profile is a higher-level concept than a folder; it includes mounts, includes, and metadata
|
||||
- `config_folders` remains for data migration but is no longer used for auto-mounting
|
||||
|
||||
### 2. `config_includes` provides ordered many-to-many self-reference on `config_profiles`
|
||||
- Rationale: Profiles need to include other profiles (e.g., a "base" profile included by "project-specific")
|
||||
- `order_index` column controls application order
|
||||
|
||||
### 3. `config_mounts` stores individual mount/file entries
|
||||
- Rationale: Normalizing mounts allows querying, ordering, and validation per mount
|
||||
- Each mount has a `mount_path`, optional `content` text, and optional `source_profile_id` for transitive includes
|
||||
|
||||
### 4. Default profile stored on `user_configs.config` JSONB
|
||||
- Rationale: Avoids schema changes to `users`; the existing `user_configs` table already stores per-user JSON
|
||||
- Key: `default_profile_id` (global default) and `default_profiles` map for per-tool-type defaults
|
||||
|
||||
### 5. `tool_instances.selected_profile_id` for explicit selection
|
||||
- Rationale: Clear, direct foreign key; nullable to allow fallback to defaults
|
||||
- Null means "use default resolution"
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Existing `config_folders` data becomes orphaned if not migrated → Mitigation: keep table, stop auto-mount behavior only
|
||||
- [Risk] Profile include cycles could cause infinite loops → Mitigation: validate at write time, detect cycles in include graph
|
||||
- [Risk] Multiple includes with overlapping mount paths → Mitigation: last-include-wins based on order_index
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
The current `config_folders` table provides basic file mounting but lacks structured profile management, ordering, and per-tool-instance selection. We need a proper config profile system that supports ordered includes, mount/file definitions, default selection, and explicit profile assignment per tool instance.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `ConfigProfile` model to replace the legacy `config_folders` concept with structured profiles
|
||||
- Add `ConfigInclude` model for ordered include lists within profiles
|
||||
- Add `ConfigMount` model for mount/file definitions (replacing the flat `files` JSONB on `config_folders`)
|
||||
- Add default profile selection per user and tool type
|
||||
- Add `selected_profile_id` to `ToolInstance` for per-instance profile selection
|
||||
- Remove launch-time reliance on legacy active config folder auto-mounting (mark `config_folders.is_active` as deprecated, stop auto-mounting at launch)
|
||||
- Create database migrations for all new tables
|
||||
- **BREAKING**: Legacy `config_folders` auto-mounting behavior will be removed; tool instances must explicitly select a profile
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `config-profile-management`: CRUD operations for config profiles, includes, and mounts
|
||||
- `tool-instance-profile-selection`: Assign and switch config profiles per tool instance
|
||||
|
||||
### Modified Capabilities
|
||||
- `tool-instance-launch`: Change launch behavior to use explicit profile selection instead of auto-mounting active config folder
|
||||
|
||||
## Impact
|
||||
|
||||
- New database tables: `config_profiles`, `config_includes`, `config_mounts`
|
||||
- Modified tables: `tool_instances` (add `selected_profile_id`), `users` or `user_configs` (add default profile selection)
|
||||
- API endpoints for profile management and instance profile assignment
|
||||
- Tool launch logic changes (remove auto-mount, use explicit profile)
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: User can create config profiles
|
||||
The system SHALL allow users to create named config profiles containing mounts and includes.
|
||||
|
||||
#### Scenario: Successful profile creation
|
||||
- **WHEN** user creates a profile with name, description, and mount list
|
||||
- **THEN** the profile is stored with a unique ID and associated mounts
|
||||
|
||||
### Requirement: Profile includes are ordered
|
||||
The system SHALL support ordered includes where profiles can reference other profiles with a defined application sequence.
|
||||
|
||||
#### Scenario: Include with order
|
||||
- **WHEN** user adds an include to a profile with order_index 1
|
||||
- **THEN** the included profile's mounts are applied after order_index 0 includes
|
||||
|
||||
### Requirement: Config mounts define files and paths
|
||||
The system SHALL store individual mount entries with mount_path, optional content, and optional source profile reference.
|
||||
|
||||
#### Scenario: Add mount to profile
|
||||
- **WHEN** user adds a mount with mount_path "/app/config.json" and content "{}"
|
||||
- **THEN** the mount is stored and linked to the profile
|
||||
|
||||
### Requirement: Cycle detection in includes
|
||||
The system SHALL prevent creation of include cycles.
|
||||
|
||||
#### Scenario: Attempt cyclic include
|
||||
- **WHEN** user tries to include profile B in profile A where A is already included in B
|
||||
- **THEN** the system rejects the request with an error
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tool instance can have selected profile
|
||||
The system SHALL allow setting an explicit config profile on a tool instance.
|
||||
|
||||
#### Scenario: Assign profile to instance
|
||||
- **WHEN** user sets selected_profile_id on a tool instance
|
||||
- **THEN** the instance stores the profile ID and uses it at launch time
|
||||
|
||||
### Requirement: Tool instance uses default profile when none selected
|
||||
The system SHALL resolve a default profile for a tool instance when no explicit profile is selected.
|
||||
|
||||
#### Scenario: Fallback to user default
|
||||
- **WHEN** a tool instance has no selected_profile_id
|
||||
- **THEN** the system uses the user's default profile for that tool type, or the global default
|
||||
|
||||
### Requirement: Remove legacy auto-mount behavior
|
||||
The system SHALL no longer auto-mount the active config folder at tool launch time.
|
||||
|
||||
#### Scenario: Launch without active folder
|
||||
- **WHEN** a tool instance launches with no selected profile and no default
|
||||
- **THEN** the instance starts without mounting any config folder
|
||||
@@ -0,0 +1,15 @@
|
||||
## 1. Data Models and Migrations
|
||||
|
||||
- [x] 1.1 Create ConfigProfile model with user ownership, name, description
|
||||
- [x] 1.2 Create ConfigInclude model for ordered profile self-references
|
||||
- [x] 1.3 Create ConfigMount model for mount/file definitions
|
||||
- [x] 1.4 Add selected_profile_id to ToolInstance model
|
||||
- [x] 1.5 Add default profile fields to UserConfig model
|
||||
- [x] 1.6 Create Alembic migration for new tables and columns
|
||||
- [x] 1.7 Register new models in models/__init__.py
|
||||
- [x] 1.8 Add migration metadata and test
|
||||
|
||||
## 2. Legacy Deprecation
|
||||
|
||||
- [x] 2.1 Mark config_folders.is_active as deprecated in model
|
||||
- [ ] 2.2 Remove auto-mounting logic from tool launch (separate change)
|
||||
@@ -1,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
|
||||
- [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
|
||||
@@ -1,79 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,29 +0,0 @@
|
||||
## 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)
|
||||
@@ -1,57 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,83 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,51 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,45 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,50 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,56 +0,0 @@
|
||||
## 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
|
||||
@@ -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
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,213 @@
|
||||
# OpenSpec Status and Implementation Checklist Review
|
||||
|
||||
**Review Date:** 2026-05-24
|
||||
**Reviewer:** Worker el-2i1s
|
||||
**Task:** 6.3 Final OpenSpec status and checklist review
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This review covers all active OpenSpec changes in the `openspec/changes/` directory. Out of **11 active changes** with **345 total tasks**, **66 tasks (19.1%) are complete** and **279 tasks remain**.
|
||||
|
||||
### Key Findings
|
||||
|
||||
- **2 changes are near completion** (git-repo-working-clones at 87.5%, opencode-web-terminal at 72.7%)
|
||||
- **2 changes have partial progress** (session-management-fixes at 32%, tool-workshop at 24.6%)
|
||||
- **7 changes have not started** (0% complete)
|
||||
- **1 new change was recently created** (add-config-profiles) with initial model work already implemented
|
||||
|
||||
---
|
||||
|
||||
## Active Changes Status
|
||||
|
||||
### Near Completion (>50%)
|
||||
|
||||
#### 1. git-repo-working-clones (87.5% complete)
|
||||
- **Completed:** 7/8 tasks
|
||||
- **Remaining:** Task 4.1 (Run targeted API tests)
|
||||
- **Status:** All implementation complete, only testing remains
|
||||
- **Recommendation:** Complete the remaining test task and archive
|
||||
|
||||
#### 2. opencode-web-terminal (72.7% complete)
|
||||
- **Completed:** 16/22 tasks
|
||||
- **Remaining:** Tasks 6.1-6.4 (testing and quality gates)
|
||||
- **Status:** Phases 1-5 complete (models, API, frontend, migrations)
|
||||
- **Recommendation:** Run backend tests, typecheck, and lint to complete
|
||||
|
||||
### In Progress (20-50%)
|
||||
|
||||
#### 3. session-management-fixes (32% complete)
|
||||
- **Completed:** 8/25 tasks
|
||||
- **Remaining:** All frontend work (phases 3-4, 5.3-5.6) and quality gates
|
||||
- **Status:** Backend tunnel work complete; frontend confirmation dialogs, health polling, and UI updates pending
|
||||
- **Blockers:** Frontend tasks depend on backend being deployed
|
||||
|
||||
#### 4. tool-workshop (24.6% complete)
|
||||
- **Completed:** 35/142 tasks
|
||||
- **Remaining:** 107 tasks across phases 2-5
|
||||
- **Status:** Phase 1 (Backend Foundation) nearly complete (35/37 tasks)
|
||||
- **Blockers:** Phase 2 (Instance Creation Enhancement) not started; includes docker build service, compose generation, config folder mounting, readiness probes
|
||||
|
||||
### Not Started (0%)
|
||||
|
||||
#### 5. cloudflare-tunnel-instances (0% complete)
|
||||
- **Tasks:** 28 across 6 phases
|
||||
- **Status:** No work started
|
||||
- **Dependencies:** May depend on instance-proxy being complete
|
||||
|
||||
#### 6. git-repo-ssh-clone-check (0% complete)
|
||||
- **Tasks:** 11 across 4 phases
|
||||
- **Status:** No work started
|
||||
- **Relationship:** Related to git-repo-working-clones
|
||||
|
||||
#### 7. instance-proxy (0% complete)
|
||||
- **Tasks:** 15 across 4 phases
|
||||
- **Status:** No work started
|
||||
- **Note:** May be superseded by cloudflare-tunnel-instances approach
|
||||
|
||||
#### 8. sessions-hub (0% complete)
|
||||
- **Tasks:** 18 across 6 phases
|
||||
- **Status:** No work started
|
||||
- **Dependencies:** Frontend foundation, session management APIs
|
||||
|
||||
#### 9. tool-config-management (0% complete)
|
||||
- **Tasks:** 22 across 7 phases
|
||||
- **Status:** No work started
|
||||
- **Relationship:** Related to tool-config-ui-rework and tool-workshop
|
||||
|
||||
#### 10. tool-config-ui-rework (0% complete)
|
||||
- **Tasks:** 36 across 8 phases
|
||||
- **Status:** No work started
|
||||
- **Relationship:** Related to tool-config-management
|
||||
|
||||
#### 11. ui-redesign-home-settings (0% complete)
|
||||
- **Tasks:** 18 across 5 phases
|
||||
- **Status:** No work started
|
||||
- **Dependencies:** Sessions hub, settings pages
|
||||
|
||||
### Newly Created
|
||||
|
||||
#### 12. add-config-profiles (partially implemented, not tracked)
|
||||
- **Tasks:** 10 across 2 sections
|
||||
- **Completed:** ~5/10 tasks (models created, migrations pending)
|
||||
- **Status:** Models implemented but not checked off in tasks.md
|
||||
- **Work Done:**
|
||||
- ConfigProfile model created with user ownership, name, description
|
||||
- ConfigInclude model created for ordered profile self-references
|
||||
- ConfigMount model created for mount/file definitions
|
||||
- selected_profile_id added to ToolInstance model
|
||||
- default profile fields added to UserConfig model
|
||||
- Models registered in models/__init__.py
|
||||
- **Remaining:**
|
||||
- Alembic migration
|
||||
- Migration metadata and testing
|
||||
- Legacy deprecation markings
|
||||
|
||||
---
|
||||
|
||||
## Archived Changes
|
||||
|
||||
**25 changes** have been successfully archived in `openspec/changes/archive/`, including:
|
||||
- auth-oauth, database-models, frontend-foundation
|
||||
- tool-instances, tool-terminal, git-control
|
||||
- api-documentation, workspace-visual-overhaul
|
||||
- And others
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Immediate Actions (This Sprint)
|
||||
|
||||
- [ ] **Complete git-repo-working-clones**: Run task 4.1 (targeted API tests)
|
||||
- [ ] **Complete opencode-web-terminal**: Run tasks 6.1-6.4 (tests and quality gates)
|
||||
- [ ] **Archive completed changes**: Move git-repo-working-clones and opencode-web-terminal to archive once tests pass
|
||||
|
||||
### Short-Term (Next 1-2 Sprints)
|
||||
|
||||
- [ ] **session-management-fixes frontend**: Implement confirmation dialogs, health polling, recreate tunnel button
|
||||
- [ ] **tool-workshop Phase 2**: Begin docker build service, compose generation, config folder mounting
|
||||
- [ ] **add-config-profiles**: Create Alembic migration, test models, mark legacy deprecation
|
||||
|
||||
### Medium-Term (Next 3-4 Sprints)
|
||||
|
||||
- [ ] **cloudflare-tunnel-instances**: Evaluate dependency on instance-proxy; decide approach
|
||||
- [ ] **sessions-hub**: Implement after session-management-fixes is complete
|
||||
- [ ] **ui-redesign-home-settings**: Coordinate with sessions-hub completion
|
||||
|
||||
### Backlog / Needs Prioritization
|
||||
|
||||
- [ ] **git-repo-ssh-clone-check**: Determine if still needed after git-repo-working-clones
|
||||
- [ ] **instance-proxy**: Determine if superseded by cloudflare-tunnel-instances
|
||||
- [ ] **tool-config-management**: Evaluate overlap with tool-workshop and tool-config-ui-rework
|
||||
- [ ] **tool-config-ui-rework**: Evaluate overlap with tool-config-management
|
||||
|
||||
---
|
||||
|
||||
## Quality Gates Status
|
||||
|
||||
### Backend
|
||||
|
||||
| Gate | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| ruff (linting) | Unknown | Not run in this review |
|
||||
| mypy (type checking) | Unknown | Not run in this review |
|
||||
| pytest (tests) | Unknown | Not run in this review |
|
||||
| bandit (security) | Unknown | Not run in this review |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Gate | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| TypeScript typecheck | Unknown | Not run in this review |
|
||||
| ESLint | Unknown | Not run in this review |
|
||||
| Build | Unknown | Not run in this review |
|
||||
| Vitest tests | Unknown | Not run in this review |
|
||||
|
||||
**Note:** Tasks 6.1 (Backend quality gates) and 6.2 (Frontend quality gates) are dependencies for this review but are currently blocked. A follow-up task should run these gates and report results.
|
||||
|
||||
---
|
||||
|
||||
## Risks and Blockers
|
||||
|
||||
1. **Testing Bottleneck**: Both near-complete changes are blocked on test execution
|
||||
2. **Frontend Lag**: session-management-fixes has complete backend but all frontend work pending
|
||||
3. **Massive Scope**: tool-workshop is 41% of all active tasks with most work not started
|
||||
4. **Parallel Unstarted Work**: 7 of 11 changes have 0% progress
|
||||
5. **Dependency Confusion**: instance-proxy and cloudflare-tunnel-instances may be competing approaches
|
||||
6. **Legacy Migration**: add-config-profiles introduces breaking changes to config_folders behavior
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Focus on completions**: Finish git-repo-working-clones and opencode-web-terminal first
|
||||
2. **Archive promptly**: Move completed changes to archive to reduce cognitive load
|
||||
3. **Clarify proxy approach**: Decide between instance-proxy and cloudflare-tunnel-instances
|
||||
4. **Merge overlapping changes**: Consider consolidating tool-config-management, tool-config-ui-rework, and tool-workshop
|
||||
5. **Run quality gates**: Execute tasks 6.1 and 6.2 before claiming any change is complete
|
||||
6. **Document breaking changes**: Ensure add-config-profiles migration plan is well-documented
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Task Count by Change
|
||||
|
||||
| Change | Total | Complete | Remaining | % |
|
||||
|--------|-------|----------|-----------|---|
|
||||
| cloudflare-tunnel-instances | 28 | 0 | 28 | 0.0% |
|
||||
| git-repo-ssh-clone-check | 11 | 0 | 11 | 0.0% |
|
||||
| git-repo-working-clones | 8 | 7 | 1 | 87.5% |
|
||||
| instance-proxy | 15 | 0 | 15 | 0.0% |
|
||||
| opencode-web-terminal | 22 | 16 | 6 | 72.7% |
|
||||
| session-management-fixes | 25 | 8 | 17 | 32.0% |
|
||||
| sessions-hub | 18 | 0 | 18 | 0.0% |
|
||||
| tool-config-management | 22 | 0 | 22 | 0.0% |
|
||||
| tool-config-ui-rework | 36 | 0 | 36 | 0.0% |
|
||||
| tool-workshop | 142 | 35 | 107 | 24.6% |
|
||||
| ui-redesign-home-settings | 18 | 0 | 18 | 0.0% |
|
||||
| **TOTAL** | **345** | **66** | **279** | **19.1%** |
|
||||
|
||||
---
|
||||
|
||||
*Review completed. Recommend archiving this document in the workspace documentation.*
|
||||
Reference in New Issue
Block a user