38c51ed95e
Moves 21 API router files into 6 domain subpackages (max 6 files each): - api/tool/ — tool_instances, tool_types, tool_definitions, sessions - api/config/ — config_profiles, user_config - api/workspace/ — workspaces, workspace_files, workspace_git, workspace_instances - api/user/ — users, auth, ssh_keys - api/project/ — projects, git_repositories - api/system/ — health, events, notifications, dashboard, terminal, instance_proxy sessions_router extracted from tool_instances.py into tool/sessions.py. main.py now imports from subpackage __init__.py re-exports. Cross-router imports updated to use new paths. Fixed pre-existing E712 in tool_definitions.py (is_base == False → is_(False)). Quality gates: py_compile passed on all files, ruff passed.
425 lines
13 KiB
Python
425 lines
13 KiB
Python
"""Tool definition API endpoints."""
|
|
|
|
import logging
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
|
from src.models import ToolDefinitionManifest
|
|
from src.models import ToolType
|
|
from src.services.build.manifest_compiler import (
|
|
compile_compose,
|
|
compile_dockerfile,
|
|
compile_entrypoint,
|
|
compute_image_tag,
|
|
deep_merge,
|
|
resolve_base,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/tool-definitions", tags=["tool-definitions"])
|
|
|
|
|
|
class CreateToolDefinitionRequest(BaseModel):
|
|
"""Request body for creating a tool definition manifest."""
|
|
|
|
model_config = {"extra": "ignore"}
|
|
|
|
name: str = Field(description="Unique identifier (kebab-case)")
|
|
display_name: str = Field(description="Human-readable name")
|
|
description: str | None = Field(default=None)
|
|
category: str = Field(default="development")
|
|
interface_type: str = Field(default="terminal", description="web or terminal")
|
|
base_image: str | None = Field(default=None, description="Direct base image")
|
|
base_definition_id: str | None = Field(
|
|
default=None, description="Reference to a base definition"
|
|
)
|
|
base_version: str = Field(default="latest")
|
|
manifest: dict = Field(description="The full manifest JSON")
|
|
|
|
|
|
class UpdateToolDefinitionRequest(BaseModel):
|
|
"""Request body for updating a tool definition manifest."""
|
|
|
|
model_config = {"extra": "ignore"}
|
|
|
|
display_name: str | None = Field(default=None)
|
|
description: str | None = Field(default=None)
|
|
category: str | None = Field(default=None)
|
|
manifest: dict | None = Field(default=None)
|
|
base_version: str | None = Field(default=None)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
summary="Create tool definition",
|
|
description="Create a new tool definition manifest.",
|
|
)
|
|
async def create_tool_definition(
|
|
data: CreateToolDefinitionRequest,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Create a new tool definition manifest.
|
|
|
|
Args:
|
|
data: Manifest data.
|
|
user_id: Authenticated user ID.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
Dictionary with created definition details.
|
|
"""
|
|
# Validate base reference
|
|
if not data.base_image and not data.base_definition_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Either base_image or base_definition_id is required",
|
|
)
|
|
|
|
base_def_id = None
|
|
if data.base_definition_id:
|
|
try:
|
|
base_def_id = uuid.UUID(data.base_definition_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid base_definition_id: {data.base_definition_id}",
|
|
)
|
|
|
|
base_def = await session.get(ToolDefinitionManifest, base_def_id)
|
|
if not base_def:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Base definition not found: {data.base_definition_id}",
|
|
)
|
|
if not base_def.is_base:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Referenced definition is not a base definition",
|
|
)
|
|
|
|
# Check name uniqueness
|
|
existing = await session.execute(
|
|
select(ToolDefinitionManifest).where(ToolDefinitionManifest.name == data.name)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Tool definition '{data.name}' already exists",
|
|
)
|
|
|
|
definition = ToolDefinitionManifest(
|
|
name=data.name,
|
|
display_name=data.display_name,
|
|
description=data.description,
|
|
category=data.category,
|
|
interface_type=data.interface_type,
|
|
base_image=data.base_image,
|
|
base_definition_id=base_def_id,
|
|
base_version=data.base_version,
|
|
manifest=data.manifest,
|
|
created_by_id=user_id,
|
|
)
|
|
|
|
session.add(definition)
|
|
await session.commit()
|
|
await session.refresh(definition)
|
|
|
|
logger.info("Created tool definition %s (%s)", definition.id, definition.name)
|
|
|
|
return {
|
|
"id": str(definition.id),
|
|
"name": definition.name,
|
|
"display_name": definition.display_name,
|
|
"description": definition.description,
|
|
"category": definition.category,
|
|
"interface_type": definition.interface_type,
|
|
"base_image": definition.base_image,
|
|
"base_definition_id": str(definition.base_definition_id)
|
|
if definition.base_definition_id
|
|
else None,
|
|
"base_version": definition.base_version,
|
|
"manifest": definition.manifest,
|
|
"is_base": definition.is_base,
|
|
"created_at": definition.created_at.isoformat(),
|
|
}
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
summary="List tool definitions",
|
|
description="List all tool definition manifests.",
|
|
)
|
|
async def list_tool_definitions(
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
include_bases: bool = True,
|
|
) -> dict:
|
|
"""List all tool definition manifests.
|
|
|
|
Args:
|
|
user_id: Authenticated user ID.
|
|
session: Database session.
|
|
include_bases: Whether to include base definitions.
|
|
|
|
Returns:
|
|
Dictionary containing list of definitions.
|
|
"""
|
|
query = select(ToolDefinitionManifest)
|
|
if not include_bases:
|
|
query = query.where(ToolDefinitionManifest.is_base.is_(False))
|
|
|
|
result = await session.execute(
|
|
query.order_by(ToolDefinitionManifest.created_at.desc())
|
|
)
|
|
definitions = result.scalars().all()
|
|
|
|
return {
|
|
"definitions": [
|
|
{
|
|
"id": str(d.id),
|
|
"name": d.name,
|
|
"display_name": d.display_name,
|
|
"description": d.description,
|
|
"category": d.category,
|
|
"interface_type": d.interface_type,
|
|
"is_base": d.is_base,
|
|
"base_image": d.base_image,
|
|
"base_definition_id": str(d.base_definition_id)
|
|
if d.base_definition_id
|
|
else None,
|
|
"base_version": d.base_version,
|
|
"version": d.version,
|
|
"created_at": d.created_at.isoformat(),
|
|
}
|
|
for d in definitions
|
|
]
|
|
}
|
|
|
|
|
|
@router.get(
|
|
"/{definition_id}",
|
|
summary="Get tool definition",
|
|
description="Get a specific tool definition manifest.",
|
|
)
|
|
async def get_tool_definition(
|
|
definition_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Get a specific tool definition manifest.
|
|
|
|
Args:
|
|
definition_id: UUID of the definition.
|
|
user_id: Authenticated user ID.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
Dictionary with definition details.
|
|
"""
|
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
|
if not definition:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Tool definition not found: {definition_id}",
|
|
)
|
|
|
|
return {
|
|
"id": str(definition.id),
|
|
"name": definition.name,
|
|
"display_name": definition.display_name,
|
|
"description": definition.description,
|
|
"category": definition.category,
|
|
"interface_type": definition.interface_type,
|
|
"base_image": definition.base_image,
|
|
"base_definition_id": str(definition.base_definition_id)
|
|
if definition.base_definition_id
|
|
else None,
|
|
"base_version": definition.base_version,
|
|
"manifest": definition.manifest,
|
|
"dockerfile_cache": definition.dockerfile_cache,
|
|
"compose_cache": definition.compose_cache,
|
|
"version": definition.version,
|
|
"is_base": definition.is_base,
|
|
"created_at": definition.created_at.isoformat(),
|
|
"updated_at": definition.updated_at.isoformat(),
|
|
}
|
|
|
|
|
|
@router.put(
|
|
"/{definition_id}",
|
|
summary="Update tool definition",
|
|
description="Update a tool definition manifest.",
|
|
)
|
|
async def update_tool_definition(
|
|
definition_id: uuid.UUID,
|
|
data: UpdateToolDefinitionRequest,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Update a tool definition manifest.
|
|
|
|
Args:
|
|
definition_id: UUID of the definition.
|
|
data: Update data.
|
|
user_id: Authenticated user ID.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
Dictionary with updated definition details.
|
|
"""
|
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
|
if not definition:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Tool definition not found: {definition_id}",
|
|
)
|
|
|
|
if data.display_name is not None:
|
|
definition.display_name = data.display_name
|
|
if data.description is not None:
|
|
definition.description = data.description
|
|
if data.category is not None:
|
|
definition.category = data.category
|
|
if data.manifest is not None:
|
|
definition.manifest = data.manifest
|
|
if data.base_version is not None:
|
|
definition.base_version = data.base_version
|
|
|
|
await session.commit()
|
|
await session.refresh(definition)
|
|
|
|
logger.info("Updated tool definition %s (%s)", definition.id, definition.name)
|
|
|
|
return {
|
|
"id": str(definition.id),
|
|
"name": definition.name,
|
|
"display_name": definition.display_name,
|
|
"manifest": definition.manifest,
|
|
"updated_at": definition.updated_at.isoformat(),
|
|
}
|
|
|
|
|
|
@router.delete(
|
|
"/{definition_id}",
|
|
summary="Delete tool definition",
|
|
description="Delete a tool definition manifest.",
|
|
)
|
|
async def delete_tool_definition(
|
|
definition_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Delete a tool definition manifest.
|
|
|
|
Args:
|
|
definition_id: UUID of the definition.
|
|
user_id: Authenticated user ID.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
Dictionary with deletion status.
|
|
"""
|
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
|
if not definition:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Tool definition not found: {definition_id}",
|
|
)
|
|
|
|
# Check if any tool types reference this manifest
|
|
result = await session.execute(
|
|
select(ToolType).where(ToolType.manifest_id == definition_id)
|
|
)
|
|
referencing = result.scalars().all()
|
|
if referencing:
|
|
tool_names = ", ".join(t.name for t in referencing)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Cannot delete: referenced by tool types: {tool_names}",
|
|
)
|
|
|
|
await session.delete(definition)
|
|
await session.commit()
|
|
|
|
logger.info("Deleted tool definition %s (%s)", definition.id, definition.name)
|
|
|
|
return {"status": "deleted", "id": str(definition_id)}
|
|
|
|
|
|
@router.post(
|
|
"/{definition_id}/compile",
|
|
summary="Compile tool definition",
|
|
description="Compile a manifest to Dockerfile + Compose preview without building.",
|
|
)
|
|
async def compile_tool_definition(
|
|
definition_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Compile a manifest to Dockerfile + Compose preview.
|
|
|
|
Args:
|
|
definition_id: UUID of the definition.
|
|
user_id: Authenticated user ID.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
Dictionary with compiled Dockerfile, Compose, and image tag.
|
|
"""
|
|
definition = await session.get(ToolDefinitionManifest, definition_id)
|
|
if not definition:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Tool definition not found: {definition_id}",
|
|
)
|
|
|
|
manifest = dict(definition.manifest)
|
|
|
|
# Resolve base if referenced
|
|
if definition.base_definition_id:
|
|
base_def = await session.get(
|
|
ToolDefinitionManifest, definition.base_definition_id
|
|
)
|
|
if base_def:
|
|
base_manifest = dict(base_def.manifest)
|
|
manifest = resolve_base(deep_merge(base_manifest, manifest))
|
|
|
|
# Compile
|
|
dockerfile = compile_dockerfile(manifest)
|
|
entrypoint = compile_entrypoint(manifest)
|
|
image_tag = compute_image_tag(definition.name, manifest)
|
|
|
|
# Dummy compose with placeholder variables
|
|
dummy_vars = {
|
|
"IMAGE_TAG": image_tag,
|
|
"INSTANCE_NAME": f"{definition.name}-preview",
|
|
"INSTANCE_DIR": "/data/instances/preview",
|
|
"REPO_PATH": "/data/repos/preview",
|
|
"SSH_PATH": "/data/instances/preview/.ssh",
|
|
"TOOL_PORT": "8080",
|
|
"EXTRA_ENV": {},
|
|
"EXTRA_VOLUMES": [],
|
|
}
|
|
compose = compile_compose(manifest, dummy_vars)
|
|
|
|
# Update cache
|
|
definition.dockerfile_cache = dockerfile
|
|
definition.compose_cache = compose
|
|
await session.commit()
|
|
|
|
return {
|
|
"id": str(definition.id),
|
|
"name": definition.name,
|
|
"dockerfile": dockerfile,
|
|
"entrypoint": entrypoint,
|
|
"compose": compose,
|
|
"image_tag": image_tag,
|
|
}
|