feat: enforce single tool type with port config

- Replace interfaces array with interface_type string and requires_port boolean
- Add database migration for schema change
- Update backend model, API schemas, and validation
- Update frontend types and tool workshop UI
- Add dropdown for interface type selection
- Conditionally show/hide port fields based on requires_port
- Update tests and mock data
- All frontend tests pass (37/37)
- Frontend typecheck and lint pass
This commit is contained in:
2026-05-22 20:32:10 +00:00
parent 5c17de0c3c
commit 0fa926284c
18 changed files with 558 additions and 232 deletions
@@ -0,0 +1,67 @@
"""replace interfaces with interface_type and add requires_port
Revision ID: 0015_interface_type_requires_port
Revises: 0014_merge_heads
Create Date: 2026-05-22 22: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 = "0015_interface_type_requires_port"
down_revision: Union[str, Sequence[str], None] = "0014_merge_heads"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add new columns
op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True))
op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true'))
# Migrate data: take first element from interfaces JSON array
op.execute("""
UPDATE tool_types
SET interface_type = COALESCE(
(SELECT value->>0 FROM jsonb_array_elements_text(interfaces) AS value LIMIT 1),
'web'
),
requires_port = CASE
WHEN COALESCE(
(SELECT value->>0 FROM jsonb_array_elements_text(interfaces) AS value LIMIT 1),
'web'
) = 'web' THEN true
ELSE false
END
""")
# Make interface_type non-nullable after data migration
op.alter_column('tool_types', 'interface_type', nullable=False)
# Drop old interfaces column
op.drop_column('tool_types', 'interfaces')
# Add CHECK constraint for interface_type
op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')"))
def downgrade() -> None:
# Drop CHECK constraint
op.drop_constraint('chk_interface_type', 'tool_types', type_='check')
# Add back interfaces column
op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]'))
# Migrate data back: wrap interface_type in array
op.execute("""
UPDATE tool_types
SET interfaces = jsonb_build_array(interface_type)
""")
# Drop new columns
op.drop_column('tool_types', 'requires_port')
op.drop_column('tool_types', 'interface_type')
+7 -7
View File
@@ -334,7 +334,7 @@ async def list_instances(
"display_name": i.display_name,
"tool_type_id": str(i.tool_type_id),
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
"tool_type_interface_type": tool_type.interface_type if tool_type else "",
"status": i.status,
"url": i.url,
"port": i.port,
@@ -607,7 +607,7 @@ async def start_instance(
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 []):
elif tool_type.interface_type == "web":
# Default probe for web tools
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
probe_timeout = 30
@@ -670,11 +670,11 @@ async def start_instance(
}
instance_port = tool_type.default_port
logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s",
instance.id, tool_type.name, instance_port, tool_type.interfaces)
logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
instance.id, tool_type.name, instance_port, tool_type.interface_type)
# Only create Cloudflare tunnel for web-enabled tools
if "web" in tool_type.interfaces:
if tool_type.interface_type == "web":
# Create temporary Cloudflare tunnel for public access
try:
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
@@ -839,7 +839,7 @@ async def restart_instance(
instance_port = tool_type.default_port
# Only create tunnel for web-enabled tools
if "web" in tool_type.interfaces:
if tool_type.interface_type == "web":
# Create new temporary tunnel
try:
tunnel_info = start_cloudflared_tunnel(
@@ -1309,7 +1309,7 @@ async def get_user_sessions(
"display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_icon": tool_type.name if tool_type else "code",
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
"tool_type_interface_type": tool_type.interface_type if tool_type else "",
"repository_name": repo.name if repo else "unknown",
"repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown",
+33 -8
View File
@@ -45,7 +45,8 @@ class ToolTypeCreate(BaseModel):
readiness_probe: dict | None = None
required_variables: list[str] = []
category: str = "other"
interfaces: list[str] = ["web"]
interface_type: str = "web"
requires_port: bool = True
@field_validator("definition_type")
@classmethod
@@ -95,9 +96,20 @@ class ToolTypeCreate(BaseModel):
return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str) -> str:
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("default_port")
@classmethod
def validate_default_port(cls, v: int) -> int:
def validate_default_port(cls, v: int, info) -> int:
data = info.data
requires_port = data.get("requires_port", True)
if not requires_port:
return v
if v <= 0 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
return v
@@ -130,8 +142,8 @@ class ToolTypeCreate(BaseModel):
if self.definition_type == "compose" and self.compose_template is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
# Validate that default_port is exposed in compose template
if self.definition_type == "compose" and self.compose_template:
# Validate that default_port is exposed in compose template (only if requires_port)
if self.requires_port and self.definition_type == "compose" and self.compose_template:
try:
parsed = yaml.safe_load(self.compose_template)
except yaml.YAMLError:
@@ -171,7 +183,8 @@ class ToolTypeUpdate(BaseModel):
readiness_probe: dict | None = None
required_variables: list[str] | None = None
category: str | None = None
interfaces: list[str] | None = None
interface_type: str | None = None
requires_port: bool | None = None
@field_validator("definition_type")
@classmethod
@@ -182,6 +195,15 @@ class ToolTypeUpdate(BaseModel):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
@@ -234,7 +256,8 @@ class ToolTypeResponse(BaseModel):
display_name: str
description: str | None
category: str
interfaces: list[str]
interface_type: str
requires_port: bool
default_port: int
definition_type: str
compose_template: str | None
@@ -290,7 +313,8 @@ async def create_tool_type(
readiness_probe=data.readiness_probe,
required_variables=data.required_variables,
category=data.category,
interfaces=data.interfaces,
interface_type=data.interface_type,
requires_port=data.requires_port,
is_builtin=False,
created_by_id=user.id,
)
@@ -388,7 +412,8 @@ async def update_tool_type(
update_data = data.model_dump(exclude_unset=True)
# Validate port if being updated
if "default_port" in update_data:
requires_port = update_data.get("requires_port", tool_type.requires_port)
if "default_port" in update_data and requires_port:
new_port = update_data["default_port"]
if new_port <= 0 or new_port > 65535:
raise HTTPException(
+10 -5
View File
@@ -137,7 +137,8 @@ async def seed_builtin_tool_types():
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interfaces": ["web"],
"interface_type": "web",
"requires_port": True,
"compose_template": """version: "3.8"
services:
code-server:
@@ -160,7 +161,8 @@ services:
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interfaces": ["web"],
"interface_type": "web",
"requires_port": True,
"default_port": 8888,
"compose_template": """version: "3.8"
services:
@@ -181,7 +183,8 @@ services:
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interfaces": ["terminal"],
"interface_type": "terminal",
"requires_port": False,
"default_port": 3000,
"compose_template": """version: "3.8"
services:
@@ -227,7 +230,8 @@ volumes:
display_name=tool_data["display_name"],
description=tool_data["description"],
category=tool_data["category"],
interfaces=tool_data["interfaces"],
interface_type=tool_data["interface_type"],
requires_port=tool_data["requires_port"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
@@ -241,7 +245,8 @@ volumes:
existing.display_name = tool_data["display_name"]
existing.description = tool_data["description"]
existing.category = tool_data["category"]
existing.interfaces = tool_data["interfaces"]
existing.interface_type = tool_data["interface_type"]
existing.requires_port = tool_data["requires_port"]
existing.definition_type = "compose"
existing.compose_template = tool_data["compose_template"]
existing.required_variables = tool_data["required_variables"]
+2 -1
View File
@@ -18,7 +18,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
default_port: Mapped[int] = mapped_column(nullable=False)
definition_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="compose"