Compare commits

..

10 Commits

Author SHA1 Message Date
alex 8816ee02ce refactor: extract Pydantic schemas into schemas/ subpackages
Extract inline Pydantic models from 9 API routers into dedicated
schema modules under schemas/:

- schemas/tool/tool_type.py — ToolTypeCreate, ToolTypeUpdate, etc.
- schemas/tool/tool_instance.py — CreateInstanceRequest, StartInstanceRequest
- schemas/config/config_profile.py — ConfigProfileCreate, ConfigProfileUpdate,
  ConfigProfileResponse, DefaultProfilesUpdate, ValidateGitUrlRequest, etc.
- schemas/system/health.py — DatabaseHealth, DiskHealth, HealthResponse, etc.
- schemas/user/user.py — UserProfileResponse, UserProfileUpdate
- schemas/user/user_config.py — UserConfigResponse, UserConfigUpdate
- schemas/project/project.py — ProjectCreate, ProjectUpdate, etc.
- schemas/project/ssh_key.py — SSHKeyCreate, SSHKeyResponse, etc.
- schemas/project/git_repository.py — GitRepositoryCreate, etc.

API routers now import from src.schemas.* instead of defining inline.
Net change: -548 lines across 16 files.

Quality gates: py_compile passed, ruff passed on all 18 files.
2026-06-04 12:15:26 +02:00
alex 6104f592eb refactor: split services/docker.py into docker/ package
Split monolithic docker.py into focused modules:
- docker/compose.py — compose generation, execute_compose_command, volume sorting
- docker/container.py — container status, IP, logs, network, port finding
- docker/config_staging.py — instance dir, env file, config file staging
- docker/tunnel.py — cloudflared tunnel lifecycle (moved from services/tunnel.py)
- docker/__init__.py — re-exports all public symbols for backward compatibility
- services/tunnel.py — thin re-export wrapper for backward compatibility

Also includes schema extraction files created in prior work:
- schemas/config/config_profile.py
- schemas/project/*.py
- schemas/system/health.py
- schemas/tool/*.py
- schemas/user/*.py

All existing imports like 'from src.services.docker import X' and
'from src.services.tunnel import X' continue to work unchanged.

Quality gates: py_compile passed, ruff passed, import test passed.
2026-06-04 10:12:41 +02:00
alex 0591b00ded refactor: reorganize models into subpackages
Move models into domain subpackages (max 4 files each):
- models/tool/ — tool_type, tool_instance, tool_definition_manifest
- models/config/ — config_profile
- models/user/ — user, user_config, ssh_key
- models/project/ — project, git_repository, workspace
- models/system/ — health_check, notification, instance_event, terminal_session

models/__init__.py continues to re-export all symbols, so consumers
using 'from src.models import X' are unaffected. Updated direct file
imports across the backend to use the new paths.

Quality gates: py_compile passed, ruff passed.
2026-06-04 10:00:10 +02:00
alex 0127d283a6 docs: update refactoring spec with submodule architecture
Enforces max 5-10 files per directory using proper subpackages:
- api/tool/, api/config/, api/workspace/, api/user/, api/project/, api/system/
- services/docker/, services/instance/, services/config/, services/git/,
  services/build/, services/terminal/, services/shared/
- models/tool/, models/config/, models/user/, models/project/, models/system/
- schemas/tool/, schemas/config/, schemas/user/, schemas/project/, schemas/system/

Updated design.md module map and tasks.md with 9 phases.
2026-06-04 09:49:55 +02:00
alex 2757ef3b4f docs: add OpenSpec change for backend-frontend refactoring
Recovers and adapts the structural refactoring from overwritten
main merge (b6f89f9) to current dev reality.

Scope:
- Schema extraction into apps/api/src/schemas/
- Docker service split into services/docker/ package
- Instance lifecycle extraction from api/tool_instances.py
- Config profile service extraction from api/config_profiles.py
- Auth dependency refactor (get_current_user)
- Frontend reorganization into features/ dirs + kebab-case naming

Exclusions (already in dev): seeding, defaults, unique constraint,
SSH key mounting, terminal backend, tunnel regex, session auto-numbering.
2026-06-04 09:42:03 +02:00
alex ab55da280c fix: include wait-for-db.sh in Docker build context
The .dockerignore added in 8a0d82f incorrectly excluded wait-for-db.sh,
but the Dockerfile copies it as the container entrypoint. This caused
the Docker build to fail with 'failed to compute cache key: not found'.

Quality gates: verified file exists, py_compile passed.
2026-06-04 00:04:25 +02:00
alex 4e076c36d2 feat: add missing features from main merge
1. Built-in tool type seeding (apps/api/src/seeds/builtin_tool_types.py):
   - Seeds code-server, jupyter-notebook, and opencode on startup.
   - Adapts to current dev model: uses interface_type (single string)
     instead of interfaces array, and created_by_id=None instead of
     is_builtin flag.
   - Called from main.py startup event.

2. Config profile default management:
   - Adds default_profile_id and default_profiles properties to
     UserConfig model for JSON-backed per-tool-type defaults.
   - Adds GET /config-profiles/defaults, PUT /config-profiles/defaults,
     and GET /config-profiles/defaults/{tool_type_id} endpoints.
   - Validates that all profile IDs in default mappings belong to the
     authenticated user before persisting.

3. Config profile unique constraint:
   - Adds __table_args__ with UniqueConstraint(user_id, name) to
     ConfigProfile model. The constraint already exists in the DB
     from migration 2026_05_24_add_config_profiles.py; this just
     aligns the SQLAlchemy model with the schema.

Quality gates: py_compile passed, ruff passed on all modified files.
2026-06-04 00:00:23 +02:00
alex c6d62f84da fix: port useful fixes from overwritten main merge
From ae02e97 ('fix: tunnel URLs, session naming, git control bar placement'):

1. Tunnel URL regex: exclude api.trycloudflare.com from pattern.
   Real tunnel subdomains are 10+ random chars. Prevents matching the
   Cloudflare API endpoint instead of the actual tunnel URL.

2. Session auto-numbering: when user doesn't provide a display_name,
   auto-generate 'project / repo / tool_type #N' where N increments
   for each existing instance with the same project/repo/tool_type.
   Prevents confusing duplicate display names in the sidebar.

These fixes were lost when main's merge was overwritten. Ported to
our clean dev codebase.

Quality gates: py_compile passed, ruff passed on tool_instances.py and tunnel.py.
2026-06-03 23:35:16 +02:00
alex 8a0d82f49b fix: add .dockerignore and src volume mount for live code sync
Add .dockerignore to exclude __pycache__, .venv, test artifacts, and
other host-only files from Docker build context. Prevents stale .pyc
cache pollution in container images.

Add read-only bind mount for ./apps/api/src:/app/src in docker-compose.yml
so code changes on the host are reflected in the running container
without requiring image rebuild. This is a dev convenience that resolves
the persistent 'tool_configs' import error from stale container images.

Quality gates: docker-compose.yml syntax valid, .dockerignore parsed.
2026-06-03 23:19:45 +02:00
alex 0c74997cfe fix: add missing alembic merge migration for DB convergence
The production DB was already migrated to 86cec91fdb00 (merge of
0014_add_profile_resolver_fields and 2026_06_01_add_workspaces) during
earlier fixes. The clean base branch lacked these migration files,
causing startup failure: 'Can't locate revision identified by 86cec91fdb00'.

Copy the idempotent 0013/0014 migrations and the no-op merge revision
from the fix commits so the Alembic graph matches the DB state.

Quality gates: alembic heads returns single head (86cec91fdb00),
py_compile and ruff passed on all three files.
2026-06-03 22:23:19 +02:00
92 changed files with 3020 additions and 1288 deletions
+42
View File
@@ -0,0 +1,42 @@
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
# Virtual environments
.venv/
venv/
env/
# Test artifacts
.pytest_cache/
.coverage
htmlcov/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Git
.git/
.gitignore
# Local env files
.env
.env.local
# Alembic cache
alembic/versions/__pycache__/
# Pi lens cache
.pi-lens/
# Documentation
docs/
*.md
# Scripts not needed in container
scripts/
@@ -0,0 +1,204 @@
"""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 collections.abc import Sequence
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: str | None = "0012_default_port_req"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
return column_name in {
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
return index_name in {
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
}
def _foreign_key_exists(
table_name: str,
constrained_columns: list[str],
referred_table: str,
) -> bool:
if not _table_exists(table_name):
return False
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if (
foreign_key.get("constrained_columns") == constrained_columns
and foreign_key.get("referred_table") == referred_table
):
return True
return False
def upgrade() -> None:
# Earlier branches may already have created config_profiles. Keep this
# migration defensive so databases can converge onto the current graph.
if not _table_exists("config_profiles"):
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"
),
)
if not _index_exists("config_profiles", "idx_config_profiles_user"):
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
if not _table_exists("config_includes"):
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"
),
)
if not _index_exists("config_includes", "idx_config_includes_profile"):
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
if not _index_exists("config_includes", "idx_config_includes_included"):
op.create_index(
"idx_config_includes_included", "config_includes", ["included_profile_id"]
)
if not _table_exists("config_mounts"):
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"),
)
if not _index_exists("config_mounts", "idx_config_mounts_profile"):
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
if not _column_exists("tool_instances", "selected_profile_id"):
op.add_column(
"tool_instances",
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _foreign_key_exists(
"tool_instances", ["selected_profile_id"], "config_profiles"
):
op.create_foreign_key(
"fk_tool_instances_selected_profile",
"tool_instances",
"config_profiles",
["selected_profile_id"],
["id"],
ondelete="SET NULL",
)
if not _index_exists("tool_instances", "idx_tool_instances_selected_profile"):
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")
@@ -0,0 +1,180 @@
"""add profile resolver fields to config profiles and mounts
Revision ID: 0014_add_profile_resolver_fields
Revises: 0013_add_config_profiles
Create Date: 2026-05-24 14:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0014_add_profile_resolver_fields"
down_revision: str | None = "0013_add_config_profiles"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
return column_name in {
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
return index_name in {
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
}
def _foreign_key_exists(
table_name: str,
constrained_columns: list[str],
referred_table: str,
) -> bool:
if not _table_exists(table_name):
return False
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if (
foreign_key.get("constrained_columns") == constrained_columns
and foreign_key.get("referred_table") == referred_table
):
return True
return False
def _foreign_key_names_for_column(table_name: str, column_name: str) -> list[str]:
if not _table_exists(table_name):
return []
names: list[str] = []
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if column_name in foreign_key.get("constrained_columns", []):
name = foreign_key.get("name")
if name:
names.append(name)
return names
def upgrade() -> None:
if not _column_exists("config_profiles", "project_id"):
op.add_column(
"config_profiles",
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _column_exists("config_profiles", "tool_type_id"):
op.add_column(
"config_profiles",
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _column_exists("config_profiles", "environment_variables"):
op.add_column(
"config_profiles",
sa.Column("environment_variables", sa.JSON(), nullable=True),
)
if not _column_exists("config_profiles", "start_command"):
op.add_column(
"config_profiles",
sa.Column("start_command", sa.Text(), nullable=True),
)
if not _column_exists("config_profiles", "working_directory"):
op.add_column(
"config_profiles",
sa.Column("working_directory", sa.Text(), nullable=True),
)
if not _column_exists("config_profiles", "port"):
op.add_column("config_profiles", sa.Column("port", sa.Integer(), nullable=True))
if not _column_exists("config_profiles", "is_default"):
op.add_column(
"config_profiles",
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
)
if not _foreign_key_exists("config_profiles", ["project_id"], "projects"):
op.create_foreign_key(
"fk_config_profiles_project",
"config_profiles",
"projects",
["project_id"],
["id"],
ondelete="CASCADE",
)
if not _foreign_key_exists("config_profiles", ["tool_type_id"], "tool_types"):
op.create_foreign_key(
"fk_config_profiles_tool_type",
"config_profiles",
"tool_types",
["tool_type_id"],
["id"],
ondelete="CASCADE",
)
if not _index_exists("config_profiles", "idx_config_profiles_project"):
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
if not _index_exists("config_profiles", "idx_config_profiles_tool_type"):
op.create_index(
"idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"]
)
if _column_exists("config_mounts", "mount_path") and not _column_exists(
"config_mounts", "target_path"
):
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
if not _column_exists("config_mounts", "mode"):
op.add_column(
"config_mounts",
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
)
if not _column_exists("config_mounts", "files"):
op.add_column(
"config_mounts",
sa.Column("files", sa.JSON(), nullable=True),
)
for constraint_name in _foreign_key_names_for_column(
"config_mounts", "source_profile_id"
):
op.drop_constraint(constraint_name, "config_mounts", type_="foreignkey")
if _column_exists("config_mounts", "content"):
op.drop_column("config_mounts", "content")
if _column_exists("config_mounts", "source_profile_id"):
op.drop_column("config_mounts", "source_profile_id")
def downgrade() -> None:
# Restore config_mounts
op.add_column(
"config_mounts",
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_mounts",
sa.Column("content", sa.Text(), nullable=True),
)
op.drop_column("config_mounts", "files")
op.drop_column("config_mounts", "mode")
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
# Restore config_profiles
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
op.drop_constraint(
"fk_config_profiles_tool_type", "config_profiles", type_="foreignkey"
)
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
op.drop_column("config_profiles", "is_default")
op.drop_column("config_profiles", "port")
op.drop_column("config_profiles", "working_directory")
op.drop_column("config_profiles", "start_command")
op.drop_column("config_profiles", "environment_variables")
op.drop_column("config_profiles", "tool_type_id")
op.drop_column("config_profiles", "project_id")
@@ -0,0 +1,20 @@
"""merge profile resolver and workspaces heads
Revision ID: 86cec91fdb00
Revises: 0014_add_profile_resolver_fields, 2026_06_01_add_workspaces
Create Date: 2026-06-03 12:48:36.145702
"""
# revision identifiers, used by Alembic.
revision = "86cec91fdb00"
down_revision = ("0014_add_profile_resolver_fields", "2026_06_01_add_workspaces")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+1
View File
@@ -0,0 +1 @@
"""Config module."""
+101 -267
View File
@@ -7,16 +7,24 @@ import uuid
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.api.shared_validators import validate_env_vars as _validate_env_vars
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_profile import ConfigProfile, ConfigProfileInclude from src.models import ConfigProfile, ConfigProfileInclude
from src.models.project import Project from src.models.project import Project
from src.models.tool_type import ToolType from src.models import ToolType
from src.models import UserConfig
from src.schemas.config import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
ConfigProfileResponse,
ConfigProfileUpdate,
DefaultProfilesUpdate,
ValidateGitUrlRequest,
ValidateGitUrlResponse,
)
from src.services.config_profile_resolver import ( from src.services.config_profile_resolver import (
ConfigProfileCycleError, ConfigProfileCycleError,
check_include_cycle, check_include_cycle,
@@ -33,16 +41,6 @@ MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024 MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def _validate_uuid(v: str | None) -> str | None:
if v is None:
return v
try:
uuid.UUID(v)
except ValueError:
raise ValueError(f"Invalid UUID: {v}")
return v
def _calculate_profile_size(data: dict) -> int: def _calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data.""" """Calculate approximate serialized size of profile data."""
total = 0 total = 0
@@ -60,247 +58,6 @@ def _calculate_profile_size(data: dict) -> int:
return total return total
class GitMountMapping(BaseModel):
source_path: str = Field(
description="Path within repository (supports glob patterns)"
)
target_path: str = Field(description="Absolute path inside container")
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str) -> str:
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
source_path: str | None = Field(
default=None, description="Path within repository (legacy single mapping)"
)
target_path: str | None = Field(
default=None,
description="Absolute path inside container (legacy single mapping)",
)
branch: str | None = Field(default=None, description="Optional branch or tag name")
mappings: list[GitMountMapping] | None = Field(
default=None, description="Multiple source/target mappings from the same repo"
)
@field_validator("remote_url")
@classmethod
def validate_remote_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://", "git@", "ssh://")):
raise ValueError(
"remote_url must be a valid git URL (https://, git@, or ssh://)"
)
return v
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str | None) -> str | None:
if v is None:
return v
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
@model_validator(mode="after")
def check_mappings_or_legacy(self):
has_legacy = self.source_path is not None and self.target_path is not None
has_mappings = self.mappings is not None and len(self.mappings) > 0
if not has_legacy and not has_mappings:
raise ValueError(
"Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'"
)
return self
class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
@field_validator("target")
@classmethod
def validate_target(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount target must be absolute (start with /)")
return v
@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
if v not in ("ro", "rw"):
raise ValueError("Mount mode must be 'ro' or 'rw'")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v.keys():
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"Mount file paths must be relative (got: {path}). "
f"The mount target defines the absolute container path."
)
return v
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict = Field(default_factory=dict, description="Environment variables")
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
mounts: list[MountItem] = Field(
default_factory=list, description="Mount definitions"
)
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] = Field(
default_factory=list, description="Git repository mounts"
)
is_default: bool = Field(
default=False, description="Whether this is the default profile for its scope"
)
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v.keys():
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"File paths must be relative (got: {path}). "
f"Use Mounts for absolute container paths."
)
return v
@field_validator("env_vars")
@classmethod
def validate_env_vars(cls, v: dict) -> dict:
result = _validate_env_vars(v)
if result is None:
raise ValueError("env_vars must be a JSON object")
return result
@field_validator("runtime_hints")
@classmethod
def validate_runtime_hints(cls, v: dict) -> dict:
if not isinstance(v, dict):
raise ValueError("runtime_hints must be a JSON object")
return v
@field_validator("mounts")
@classmethod
def validate_mounts(cls, v: list) -> list:
if not isinstance(v, list):
raise ValueError("mounts must be a JSON array")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict | None = Field(default=None, description="Environment variables")
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
mounts: list[MountItem] | None = Field(
default=None, description="Mount definitions"
)
files: dict | None = Field(
default=None, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] | None = Field(
default=None, description="Git repository mounts"
)
is_default: bool | None = Field(
default=None, description="Whether this is the default profile"
)
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
if v is None:
return v
for path in v.keys():
if ".." in path or path.startswith("/") or not path:
raise ValueError(f"Invalid file path: {path}")
return v
class ConfigProfileIncludeUpdate(BaseModel):
includes: list[str] = Field(description="Ordered list of included profile IDs")
@field_validator("includes")
@classmethod
def validate_includes(cls, v: list) -> list:
for item in v:
try:
uuid.UUID(item)
except ValueError:
raise ValueError(f"Invalid UUID in includes: {item}")
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
project_id: str | None
tool_type_id: str | None
env_vars: dict
runtime_hints: dict
mounts: list
files: dict
git_mounts: list
is_default: bool
includes: list[dict]
created_at: str
updated_at: str
async def _get_profile_with_includes( async def _get_profile_with_includes(
session: AsyncSession, profile_id: uuid.UUID session: AsyncSession, profile_id: uuid.UUID
) -> ConfigProfile | None: ) -> ConfigProfile | None:
@@ -840,20 +597,97 @@ async def resolve_default_profile(
return {"profile_id": str(first.id), "profile_name": first.name} return {"profile_id": str(first.id), "profile_name": first.name}
class ValidateGitUrlRequest(BaseModel): # ---------------------------------------------------------------------------
url: str = Field(description="Git remote URL to validate") # Default profile management
ssh_key_id: str | None = Field( # ---------------------------------------------------------------------------
default=None, description="Optional SSH key ID for private repos"
async def _get_or_create_user_config(
session: AsyncSession,
user_id: uuid.UUID,
) -> UserConfig:
"""Get existing user config or create a new one."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
return user_config
async def _validate_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> None:
"""Validate that all profile IDs in default_profiles belong to the user."""
for tool_type_id, profile_id_str in default_profiles.items():
try:
profile_uuid = uuid.UUID(profile_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
)
profile = await session.get(ConfigProfile, profile_uuid)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Profile not found: {profile_id_str}",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Profile does not belong to user: {profile_id_str}",
) )
class ValidateGitUrlResponse(BaseModel): @router.get("/defaults")
valid: bool async def get_default_profiles_endpoint(
suggested_url: str | None = None user_id: uuid.UUID = Depends(get_current_user_id),
branches: list[str] | None = None session: AsyncSession = Depends(get_db_session),
default_branch: str | None = None ) -> dict:
error: str | None = None """Get all default profile mappings for the current user."""
error_code: str | None = None result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
return {"default_profiles": user_config.default_profiles if user_config else {}}
@router.put("/defaults")
async def set_default_profiles_endpoint(
data: DefaultProfilesUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Set default profile mappings for the current user."""
await _validate_default_profiles(session, user_id, data.default_profiles)
user_config = await _get_or_create_user_config(session, user_id)
user_config.config = {
**user_config.config,
"default_profiles": data.default_profiles,
}
await session.commit()
await session.refresh(user_config)
return {"default_profiles": user_config.default_profiles}
@router.get("/defaults/{tool_type_id}")
async def get_default_profile_for_tool_type_endpoint(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get the default profile ID for a specific tool type."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse) @router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
@@ -891,7 +725,7 @@ async def validate_git_url(
env = None env = None
key_path = None key_path = None
if data.ssh_key_id: if data.ssh_key_id:
from src.models.ssh_key import SSHKey from src.models import SSHKey
from src.services.ssh_keys import _get_fernet from src.services.ssh_keys import _get_fernet
try: try:
+2 -2
View File
@@ -5,9 +5,9 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.git_repository import GitRepository from src.models import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models import SSHKey
router = APIRouter(prefix="/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/dashboard", tags=["dashboard"])
+10 -45
View File
@@ -3,10 +3,9 @@ import os
import shutil import shutil
import subprocess import subprocess
import uuid import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,8 +16,15 @@ from src.auth.dependencies import (
get_db_session, get_db_session,
) )
from src.config import Settings from src.config import Settings
from src.models.git_repository import GitRepository from src.models import GitRepository
from src.models.ssh_key import SSHKey from src.models import SSHKey
from src.schemas.project import (
GitRepositoryCreate,
GitRepositoryResponse,
URLParseRequest,
URLParseResponse,
UpdateSSHKeyRequest,
)
from src.utils.git_files import ( from src.utils.git_files import (
commit_file, commit_file,
get_file_content, get_file_content,
@@ -218,43 +224,6 @@ def _init_working_repository(repo_path: str) -> None:
) )
class GitRepositoryCreate(BaseModel):
name: str
remote_url: str | None = None
force_original_url: bool = False
ssh_key_id: str | None = None
class URLParseRequest(BaseModel):
url: str
class URLParseResponse(BaseModel):
original_url: str
base_url: str | None
is_valid_clone_url: bool
needs_parsing: bool
host: str | None
message: str
error_code: str | None
class GitRepositoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID | None
owner_id: uuid.UUID
is_mirror: bool
remote_url: str | None
last_push: datetime | None
ssh_key_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@router.get( @router.get(
"/repositories", "/repositories",
response_model=list[GitRepositoryResponse], response_model=list[GitRepositoryResponse],
@@ -599,10 +568,6 @@ async def create_repository(
return repo return repo
class UpdateSSHKeyRequest(BaseModel):
ssh_key_id: str | None = None
@router.patch( @router.patch(
"/{project_id}/repositories/{repo_id}/ssh-key", "/{project_id}/repositories/{repo_id}/ssh-key",
response_model=GitRepositoryResponse, response_model=GitRepositoryResponse,
+7 -40
View File
@@ -5,10 +5,16 @@ from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import text from sqlalchemy import text
from src.database import SessionLocal from src.database import SessionLocal
from src.schemas.system import (
DatabaseHealth,
DatabaseHealthResponse,
DiskHealth,
HealthChecks,
HealthResponse,
)
router = APIRouter() router = APIRouter()
@@ -16,45 +22,6 @@ router = APIRouter()
_start_time = time.time() _start_time = time.time()
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
@router.get( @router.get(
"/health", "/health",
response_model=HealthResponse, response_model=HealthResponse,
+2 -2
View File
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_instance import ToolInstance from src.models import ToolInstance
from src.models.tool_type import ToolType from src.models import ToolType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+1 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session from src.auth.dependencies import get_current_user, get_db_session
from src.models.user import User from src.models.user import User
from src.models.user_config import UserConfig from src.models import UserConfig
from src.services.notification_service import notification_service from src.services.notification_service import notification_service
router = APIRouter(prefix="/notifications", tags=["notifications"]) router = APIRouter(prefix="/notifications", tags=["notifications"])
+1
View File
@@ -0,0 +1 @@
"""Project module."""
+10 -29
View File
@@ -3,7 +3,6 @@ import shutil
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,38 +12,20 @@ from src.auth.dependencies import (
get_current_user_id, get_current_user_id,
get_db_session, get_db_session,
) )
from src.models.git_repository import GitRepository from src.models import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models import SSHKey
from src.models.tool_instance import ToolInstance from src.models import ToolInstance
from src.schemas.project import (
ProjectCreate,
ProjectResponse,
ProjectUpdate,
SetDefaultSSHKeyRequest,
)
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
class ProjectCreate(BaseModel):
name: str
description: str | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
default_ssh_key_id: uuid.UUID | None
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
@router.post( @router.post(
"", "",
response_model=ProjectResponse, response_model=ProjectResponse,
@@ -101,7 +82,7 @@ async def list_projects(
) )
projects = result.scalars().all() projects = result.scalars().all()
from src.models.workspace import Workspace from src.models import Workspace
enriched = [] enriched = []
for project in projects: for project in projects:
+18 -36
View File
@@ -1,18 +1,24 @@
import base64 import base64
import uuid import uuid
from datetime import datetime
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.config import Settings from src.config import Settings
from src.models.ssh_key import SSHKey from src.models import SSHKey
from src.schemas.project import (
SSHKeyCreate,
SSHKeyResponse,
SignPayloadRequest,
SignatureResponse,
VerifySignatureRequest,
VerifySignatureResponse,
)
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
@@ -53,36 +59,6 @@ def generate_ssh_key_pair() -> tuple[str, str]:
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8") return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
class SSHKeyCreate(BaseModel):
name: str
class SSHKeyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
public_key: str
created_at: datetime
class SignPayloadRequest(BaseModel):
payload: str
class SignatureResponse(BaseModel):
signature: str
class VerifySignatureRequest(BaseModel):
payload: str
signature: str
class VerifySignatureResponse(BaseModel):
valid: bool
@router.post( @router.post(
"", "",
response_model=SSHKeyResponse, response_model=SSHKeyResponse,
@@ -171,7 +147,9 @@ async def delete_ssh_key(
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id) ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id: if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
await session.delete(ssh_key) await session.delete(ssh_key)
await session.commit() await session.commit()
@@ -203,7 +181,9 @@ async def sign_payload(
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id) ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id: if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
fernet = _get_fernet() fernet = _get_fernet()
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
@@ -242,7 +222,9 @@ async def verify_signature(
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id) ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id: if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode()) public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
+1
View File
@@ -0,0 +1 @@
"""System module."""
+3 -3
View File
@@ -12,9 +12,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.terminal_session import TerminalSessionModel from src.models import TerminalSessionModel
from src.models.tool_instance import ToolInstance from src.models import ToolInstance
from src.models.tool_type import ToolType from src.models import ToolType
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
router = APIRouter() router = APIRouter()
+1
View File
@@ -0,0 +1 @@
"""Tool module."""
+2 -2
View File
@@ -9,8 +9,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_definition_manifest import ToolDefinitionManifest from src.models import ToolDefinitionManifest
from src.models.tool_type import ToolType from src.models import ToolType
from src.services.manifest_compiler import ( from src.services.manifest_compiler import (
compile_compose, compile_compose,
compile_dockerfile, compile_dockerfile,
+30 -56
View File
@@ -18,7 +18,6 @@ from fastapi import (
Response, Response,
status, status,
) )
from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,12 +29,12 @@ from src.auth.dependencies import (
) )
from src.services.event_bus import InstanceEventBus from src.services.event_bus import InstanceEventBus
from src.services.lifecycle_hooks import publish_lifecycle_event from src.services.lifecycle_hooks import publish_lifecycle_event
from src.models.config_profile import ConfigProfile from src.models import ConfigProfile
from src.models.git_repository import GitRepository from src.models import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models import SSHKey
from src.models.tool_instance import ToolInstance from src.models import ToolInstance
from src.models.tool_type import ToolType from src.models import ToolType
from src.services.clone import check_dirty_state, clone_repository from src.services.clone import check_dirty_state, clone_repository
from src.services.config_profile_resolver import ( from src.services.config_profile_resolver import (
ConfigProfileCycleError, ConfigProfileCycleError,
@@ -82,6 +81,7 @@ from src.services.manifest_compiler import (
from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions
from src.services.readiness_probe import execute_probe from src.services.readiness_probe import execute_probe
from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_event_bus = InstanceEventBus() _event_bus = InstanceEventBus()
@@ -418,48 +418,6 @@ def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
router = APIRouter(prefix="/projects", tags=["tool-instances"]) router = APIRouter(prefix="/projects", tags=["tool-instances"])
class CreateInstanceRequest(BaseModel):
"""Request body for creating a tool instance."""
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(
default=None, description="Optional display name for the instance"
)
workspace_id: str | None = Field(
default=None, description="UUID of workspace to mount (replaces clone_mode)"
)
clone_mode: str = Field(
default="mount", description="Repository access mode: 'mount' or 'clone'"
)
branch: str | None = Field(
default="main", description="Branch to clone (when clone_mode='clone')"
)
new_branch: str | None = Field(
default=None, description="Create a new local branch after cloning"
)
config_profile_id: str | None = Field(
default=None, description="Optional config profile ID for launch"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
class StartInstanceRequest(BaseModel):
"""Request body for starting a tool instance."""
model_config = {"extra": "ignore"}
config_profile_id: str | None = Field(
default=None, description="Config profile ID to apply, or null for none"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
async def _validate_config_profile( async def _validate_config_profile(
session: AsyncSession, session: AsyncSession,
profile_id: str | None, profile_id: str | None,
@@ -856,7 +814,7 @@ async def create_instance(
workspace = None workspace = None
workspace_id = None workspace_id = None
if data.workspace_id: if data.workspace_id:
from src.models.workspace import Workspace as WorkspaceModel from src.models import Workspace as WorkspaceModel
try: try:
workspace_id = uuid.UUID(data.workspace_id) workspace_id = uuid.UUID(data.workspace_id)
@@ -893,9 +851,25 @@ async def create_instance(
# Generate unique name # Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
instance_display = (
data.display_name or f"{tool_type.display_name} - {repo.name}" # Auto-generate display name with numbering when duplicates exist
if data.display_name:
instance_display = data.display_name
else:
auto_name = f"{_project.name} / {repo.name} / {tool_type.display_name}"
result = await session.execute(
select(ToolInstance).where(
ToolInstance.project_id == project_id,
ToolInstance.repository_id == repo_id,
ToolInstance.tool_type_id == tool_type_id,
ToolInstance.owner_id == user_id,
) )
)
existing_count = len(result.scalars().all())
if existing_count > 0:
instance_display = f"{auto_name} #{existing_count + 1}"
else:
instance_display = auto_name
# Create instance directory # Create instance directory
instance_dir = ensure_instance_directory(instance_name) instance_dir = ensure_instance_directory(instance_name)
@@ -1040,7 +1014,7 @@ services:
elif tool_type.definition_type == "manifest": elif tool_type.definition_type == "manifest":
# Manifest-based: generate compose only; image built lazily on start # Manifest-based: generate compose only; image built lazily on start
from src.models.tool_definition_manifest import ToolDefinitionManifest from src.models import ToolDefinitionManifest
manifest_def = await session.get( manifest_def = await session.get(
ToolDefinitionManifest, tool_type.manifest_id ToolDefinitionManifest, tool_type.manifest_id
@@ -1325,7 +1299,7 @@ async def _prepare_manifest_instance(
Returns: Returns:
Tuple of (image_tag, compose_content, resolved_manifest, home_dir) Tuple of (image_tag, compose_content, resolved_manifest, home_dir)
""" """
from src.models.tool_definition_manifest import ToolDefinitionManifest from src.models import ToolDefinitionManifest
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id) manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
@@ -1521,7 +1495,7 @@ async def start_instance(
container_uid = 0 container_uid = 0
container_gid = 0 container_gid = 0
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
from src.models.tool_definition_manifest import ToolDefinitionManifest from src.models import ToolDefinitionManifest
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id) manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
if manifest_def: if manifest_def:
@@ -1708,7 +1682,7 @@ async def start_instance(
# Determine repo path (workspace takes precedence) # Determine repo path (workspace takes precedence)
repo_path = "" repo_path = ""
if instance.workspace_id: if instance.workspace_id:
from src.models.workspace import Workspace as WorkspaceModel from src.models import Workspace as WorkspaceModel
workspace = await session.get(WorkspaceModel, instance.workspace_id) workspace = await session.get(WorkspaceModel, instance.workspace_id)
if workspace: if workspace:
@@ -2751,7 +2725,7 @@ async def get_instance_events(
List of event dictionaries. List of event dictionaries.
""" """
from sqlalchemy import select from sqlalchemy import select
from src.models.instance_event import InstanceEvent from src.models import InstanceEvent
_user = await _get_user(session, user_id) _user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session) _project = await _get_owned_project(project_id, user_id, session)
+7 -240
View File
@@ -1,8 +1,6 @@
import uuid import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,8 +10,14 @@ from src.api.tool_types_validation import (
validate_required_variables, validate_required_variables,
) )
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.tool_type import ToolType from src.models import ToolType
from src.models.user import User from src.models.user import User
from src.schemas.tool import (
ToolTypeCreate,
ToolTypeResponse,
ToolTypeUpdate,
ToolTypeValidateRequest,
)
router = APIRouter(prefix="/tool-types", tags=["tool-types"]) router = APIRouter(prefix="/tool-types", tags=["tool-types"])
@@ -29,237 +33,6 @@ async def _require_admin(user: User) -> None:
pass pass
class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
default_port: int = 0
definition_type: str = "compose"
manifest_id: uuid.UUID | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] = []
category: str = "other"
interface_type: str = "web"
requires_port: bool = True
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None or not v.strip():
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None or not v.strip():
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
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, 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
@field_validator("required_variables")
@classmethod
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
for var in v:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise ValueError(
f"Required variable '{var}' not found in compose template"
)
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "manifest":
if self.manifest_id is None:
raise ValueError(
"manifest_id is required when definition_type is 'manifest'"
)
return self
if self.definition_type == "dockerfile" and (
self.dockerfile_template is None or not self.dockerfile_template.strip()
):
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if self.definition_type == "compose" and (
self.compose_template is None or not self.compose_template.strip()
):
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
# 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 = validate_compose_yaml(self.compose_template)
except ValueError:
return self
if not check_port_exposed(parsed, self.default_port):
raise ValueError(
f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section."
)
return self
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
description: str | None = None
default_port: int | None = None
definition_type: str | None = None
manifest_id: uuid.UUID | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] | None = None
category: str | None = None
interface_type: str | None = None
requires_port: bool | None = None
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
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:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "compose":
return v
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "dockerfile":
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
display_name: str
description: str | None
category: str
interface_type: str
requires_port: bool
default_port: int
definition_type: str
manifest_id: uuid.UUID | None
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
startup_command: str | None
required_variables: list[str]
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@router.post( @router.post(
"", "",
response_model=ToolTypeResponse, response_model=ToolTypeResponse,
@@ -461,12 +234,6 @@ async def update_tool_type(
return tool_type return tool_type
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
@router.post( @router.post(
"/validate", "/validate",
summary="Validate tool type template", summary="Validate tool type template",
+1
View File
@@ -0,0 +1 @@
"""User module."""
+2 -24
View File
@@ -2,12 +2,12 @@ import logging
import uuid import uuid
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user_config import UserConfig from src.models import UserConfig
from src.schemas.user import UserConfigResponse, UserConfigUpdate
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,28 +38,6 @@ async def _get_or_create_config(
return config return config
class UserConfigResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
default_editor: str | None = None
theme: str = "system"
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
class UserConfigUpdate(BaseModel):
default_editor: str | None = None
theme: str | None = None
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
@router.get( @router.get(
"/config", "/config",
response_model=UserConfigResponse, response_model=UserConfigResponse,
+7 -17
View File
@@ -2,11 +2,11 @@ import uuid
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user import User from src.models.user import User
from src.schemas.user import UserProfileResponse, UserProfileUpdate
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -16,20 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
@router.get( @router.get(
"/me", "/me",
response_model=UserProfileResponse, response_model=UserProfileResponse,
@@ -77,12 +63,16 @@ async def update_profile(
if data.name is not None: if data.name is not None:
if len(data.name.strip()) == 0: if len(data.name.strip()) == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty") raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty"
)
user.name = data.name.strip() user.name = data.name.strip()
if data.email is not None: if data.email is not None:
if "@" not in data.email: if "@" not in data.email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email") raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email"
)
user.email = data.email.strip() user.email = data.email.strip()
await session.commit() await session.commit()
+1
View File
@@ -0,0 +1 @@
"""Workspace module."""
+1 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.workspace import Workspace from src.models import Workspace
from src.services.file_service import FileService from src.services.file_service import FileService
router = APIRouter(prefix="/workspaces/{workspace_id}/files") router = APIRouter(prefix="/workspaces/{workspace_id}/files")
+1 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.workspace import Workspace from src.models import Workspace
from src.services.git_operations import GitOperations from src.services.git_operations import GitOperations
router = APIRouter(prefix="/workspaces/{workspace_id}/git") router = APIRouter(prefix="/workspaces/{workspace_id}/git")
+2 -2
View File
@@ -7,8 +7,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_instance import ToolInstance from src.models import ToolInstance
from src.models.workspace import Workspace from src.models import Workspace
router = APIRouter(prefix="/workspaces/{workspace_id}/instances") router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
+3 -3
View File
@@ -9,9 +9,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.git_repository import GitRepository from src.models import GitRepository
from src.models.tool_instance import ToolInstance from src.models import ToolInstance
from src.models.workspace import Workspace from src.models import Workspace
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+7 -2
View File
@@ -29,14 +29,15 @@ from src.api.workspace_git import router as workspace_git_router
from src.api.workspace_instances import router as workspace_instances_router from src.api.workspace_instances import router as workspace_instances_router
from src.api.workspaces import all_workspaces_router, router as workspaces_router from src.api.workspaces import all_workspaces_router, router as workspaces_router
from src.config import Settings from src.config import Settings
from src.models.notification import Notification # noqa: F401 Alembic model discovery from src.models import Notification # noqa: F401 Alembic model discovery
from src.models.terminal_session import TerminalSessionModel # noqa: F401 Alembic model discovery from src.models import TerminalSessionModel # noqa: F401 Alembic model discovery
from src.database import init_database from src.database import init_database
from src.logging_config import ( from src.logging_config import (
ExceptionLoggingMiddleware, ExceptionLoggingMiddleware,
RequestLoggingMiddleware, RequestLoggingMiddleware,
configure_logging, configure_logging,
) )
from src.seeds.builtin_tool_types import seed_builtin_tool_types
from src.services.correlation import CorrelationIdMiddleware from src.services.correlation import CorrelationIdMiddleware
from src.services.event_bus import InstanceEventBus from src.services.event_bus import InstanceEventBus
from src.services.health_monitor import HealthMonitor from src.services.health_monitor import HealthMonitor
@@ -135,6 +136,10 @@ async def on_startup():
_health_monitor.start() _health_monitor.start()
logger.info("Health monitor started") logger.info("Health monitor started")
# Seed built-in tool types
await seed_builtin_tool_types()
logger.info("Built-in tool types seeded")
logger.info("Startup complete.") logger.info("Startup complete.")
+14 -14
View File
@@ -1,18 +1,18 @@
from src.models.base import Base from src.models.base import Base
from src.models.config_profile import ConfigProfile, ConfigProfileInclude from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository from src.models.project.git_repository import GitRepository
from src.models.health_check import HealthCheck from src.models.project.project import Project
from src.models.instance_event import InstanceEvent from src.models.project.workspace import Workspace
from src.models.notification import Notification from src.models.system.health_check import HealthCheck
from src.models.project import Project from src.models.system.instance_event import InstanceEvent
from src.models.ssh_key import SSHKey from src.models.system.notification import Notification
from src.models.terminal_session import TerminalSessionModel from src.models.system.terminal_session import TerminalSessionModel
from src.models.tool_definition_manifest import ToolDefinitionManifest from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool_instance import ToolInstance from src.models.tool.tool_instance import ToolInstance
from src.models.tool_type import ToolType from src.models.tool.tool_type import ToolType
from src.models.user import User from src.models.user.ssh_key import SSHKey
from src.models.user_config import UserConfig from src.models.user.user import User
from src.models.workspace import Workspace from src.models.user.user_config import UserConfig
__all__ = [ __all__ = [
"Base", "Base",
+5
View File
@@ -0,0 +1,5 @@
"""Config models module."""
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
__all__ = ["ConfigProfile", "ConfigProfileInclude"]
@@ -1,7 +1,15 @@
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean from sqlalchemy import (
Boolean,
ForeignKey,
JSON,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy import Uuid as UUID from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -9,12 +17,15 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.project import Project from src.models.project import Project
from src.models.tool_type import ToolType from src.models import ToolType
from src.models.user import User from src.models.user import User
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base): class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profiles" __tablename__ = "config_profiles"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
user_id: Mapped[uuid.UUID] = mapped_column( user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
+7
View File
@@ -0,0 +1,7 @@
"""Project models module."""
from src.models.project.git_repository import GitRepository
from src.models.project.project import Project
from src.models.project.workspace import Workspace
__all__ = ["GitRepository", "Project", "Workspace"]
@@ -10,7 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models import SSHKey
from src.models.user import User from src.models.user import User
@@ -8,8 +8,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.git_repository import GitRepository from src.models import GitRepository
from src.models.ssh_key import SSHKey from src.models import SSHKey
from src.models.user import User from src.models.user import User
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin from src.models.base import Base, TimestampMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.git_repository import GitRepository from src.models import GitRepository
from src.models.user import User from src.models.user import User
+8
View File
@@ -0,0 +1,8 @@
"""System models module."""
from src.models.system.health_check import HealthCheck
from src.models.system.instance_event import InstanceEvent
from src.models.system.notification import Notification
from src.models.system.terminal_session import TerminalSessionModel
__all__ = ["HealthCheck", "InstanceEvent", "Notification", "TerminalSessionModel"]
+7
View File
@@ -0,0 +1,7 @@
"""Tool models module."""
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool.tool_instance import ToolInstance
from src.models.tool.tool_type import ToolType
__all__ = ["ToolDefinitionManifest", "ToolInstance", "ToolType"]
@@ -9,12 +9,12 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.config_profile import ConfigProfile from src.models import ConfigProfile
from src.models.git_repository import GitRepository from src.models import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.tool_type import ToolType from src.models import ToolType
from src.models.user import User from src.models.user import User
from src.models.workspace import Workspace from src.models import Workspace
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
@@ -6,8 +6,7 @@ from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool_definition_manifest import ToolDefinitionManifest
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.user import User from src.models.user import User
+7
View File
@@ -0,0 +1,7 @@
"""User models module."""
from src.models.user.ssh_key import SSHKey
from src.models.user.user import User
from src.models.user.user_config import UserConfig
__all__ = ["SSHKey", "User", "UserConfig"]
@@ -7,8 +7,8 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models import SSHKey
from src.models.user_config import UserConfig from src.models import UserConfig
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+51
View File
@@ -0,0 +1,51 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, JSON
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 UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_configs"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id"), nullable=False, unique=True
)
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:
"""Return the legacy global default profile ID from config JSON."""
profile_id = self.config.get("default_profile_id")
if isinstance(profile_id, str):
return uuid.UUID(profile_id)
return 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 per-tool-type default profile IDs from config JSON."""
value = self.config.get("default_profiles", {})
if isinstance(value, dict):
return {str(k): str(v) for k, v in value.items()}
return {}
@default_profiles.setter
def default_profiles(self, value: dict[str, str]) -> None:
self.config["default_profiles"] = value
-20
View File
@@ -1,20 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy import JSON, 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 UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_configs"
user_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False, unique=True)
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
user: Mapped["User"] = relationship(back_populates="user_config")
+27
View File
@@ -0,0 +1,27 @@
"""Config schemas module."""
from src.schemas.config.config_profile import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
ConfigProfileResponse,
ConfigProfileUpdate,
DefaultProfilesUpdate,
GitMountItem,
GitMountMapping,
MountItem,
ValidateGitUrlRequest,
ValidateGitUrlResponse,
)
__all__ = [
"ConfigProfileCreate",
"ConfigProfileIncludeUpdate",
"ConfigProfileResponse",
"ConfigProfileUpdate",
"DefaultProfilesUpdate",
"GitMountItem",
"GitMountMapping",
"MountItem",
"ValidateGitUrlRequest",
"ValidateGitUrlResponse",
]
@@ -0,0 +1,280 @@
"""Config profile request/response schemas."""
import uuid
from pydantic import BaseModel, Field, field_validator, model_validator
from src.api.shared_validators import validate_env_vars as _validate_env_vars
def _validate_uuid(v: str | None) -> str | None:
if v is None:
return v
try:
uuid.UUID(v)
except ValueError as exc:
raise ValueError(f"Invalid UUID: {v}") from exc
return v
class GitMountMapping(BaseModel):
source_path: str = Field(
description="Path within repository (supports glob patterns)"
)
target_path: str = Field(description="Absolute path inside container")
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str) -> str:
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
source_path: str | None = Field(
default=None, description="Path within repository (legacy single mapping)"
)
target_path: str | None = Field(
default=None,
description="Absolute path inside container (legacy single mapping)",
)
branch: str | None = Field(default=None, description="Optional branch or tag name")
mappings: list[GitMountMapping] | None = Field(
default=None, description="Multiple source/target mappings from the same repo"
)
@field_validator("remote_url")
@classmethod
def validate_remote_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://", "git@", "ssh://")):
raise ValueError(
"remote_url must be a valid git URL (https://, git@, or ssh://)"
)
return v
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str | None) -> str | None:
if v is None:
return v
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
@model_validator(mode="after")
def check_mappings_or_legacy(self):
has_legacy = self.source_path is not None and self.target_path is not None
has_mappings = self.mappings is not None and len(self.mappings) > 0
if not has_legacy and not has_mappings:
raise ValueError(
"Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'"
)
return self
class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
@field_validator("target")
@classmethod
def validate_target(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount target must be absolute (start with /)")
return v
@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
if v not in ("ro", "rw"):
raise ValueError("Mount mode must be 'ro' or 'rw'")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v:
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"Mount file paths must be relative (got: {path}). "
f"The mount target defines the absolute container path."
)
return v
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict = Field(default_factory=dict, description="Environment variables")
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
mounts: list[MountItem] = Field(
default_factory=list, description="Mount definitions"
)
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] = Field(
default_factory=list, description="Git repository mounts"
)
is_default: bool = Field(
default=False, description="Whether this is the default profile for its scope"
)
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v:
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"File paths must be relative (got: {path}). "
f"Use Mounts for absolute container paths."
)
return v
@field_validator("env_vars")
@classmethod
def validate_env_vars(cls, v: dict) -> dict:
result = _validate_env_vars(v)
if result is None:
raise ValueError("env_vars must be a JSON object")
return result
@field_validator("runtime_hints")
@classmethod
def validate_runtime_hints(cls, v: dict) -> dict:
if not isinstance(v, dict):
raise ValueError("runtime_hints must be a JSON object")
return v
@field_validator("mounts")
@classmethod
def validate_mounts(cls, v: list) -> list:
if not isinstance(v, list):
raise ValueError("mounts must be a JSON array")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict | None = Field(default=None, description="Environment variables")
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
mounts: list[MountItem] | None = Field(
default=None, description="Mount definitions"
)
files: dict | None = Field(
default=None, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] | None = Field(
default=None, description="Git repository mounts"
)
is_default: bool | None = Field(
default=None, description="Whether this is the default profile"
)
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
if v is None:
return v
for path in v:
if ".." in path or path.startswith("/") or not path:
raise ValueError(f"Invalid file path: {path}")
return v
class ConfigProfileIncludeUpdate(BaseModel):
includes: list[str] = Field(description="Ordered list of included profile IDs")
@field_validator("includes")
@classmethod
def validate_includes(cls, v: list) -> list:
for item in v:
try:
uuid.UUID(item)
except ValueError as exc:
raise ValueError(f"Invalid UUID in includes: {item}") from exc
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
project_id: str | None
tool_type_id: str | None
env_vars: dict
runtime_hints: dict
mounts: list
files: dict
git_mounts: list
is_default: bool
includes: list[dict]
created_at: str
updated_at: str
class DefaultProfilesUpdate(BaseModel):
default_profiles: dict[str, str] = Field(
description="Mapping of tool_type_id -> profile_id for default profiles"
)
class ValidateGitUrlRequest(BaseModel):
url: str = Field(description="Git remote URL to validate")
ssh_key_id: str | None = Field(
default=None, description="Optional SSH key ID for private repos"
)
class ValidateGitUrlResponse(BaseModel):
valid: bool
suggested_url: str | None = None
branches: list[str] | None = None
default_branch: str | None = None
error: str | None = None
error_code: str | None = None
+41
View File
@@ -0,0 +1,41 @@
"""Project schemas module."""
from src.schemas.project.git_repository import (
GitRepositoryCreate,
GitRepositoryResponse,
UpdateSSHKeyRequest,
URLParseRequest,
URLParseResponse,
)
from src.schemas.project.project import (
ProjectCreate,
ProjectResponse,
ProjectUpdate,
SetDefaultSSHKeyRequest,
)
from src.schemas.project.ssh_key import (
SSHKeyCreate,
SSHKeyResponse,
SignPayloadRequest,
SignatureResponse,
VerifySignatureRequest,
VerifySignatureResponse,
)
__all__ = [
"GitRepositoryCreate",
"GitRepositoryResponse",
"ProjectCreate",
"ProjectResponse",
"ProjectUpdate",
"SSHKeyCreate",
"SSHKeyResponse",
"SetDefaultSSHKeyRequest",
"SignPayloadRequest",
"SignatureResponse",
"URLParseRequest",
"URLParseResponse",
"UpdateSSHKeyRequest",
"VerifySignatureRequest",
"VerifySignatureResponse",
]
@@ -0,0 +1,47 @@
"""Git repository request/response schemas."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class GitRepositoryCreate(BaseModel):
name: str
remote_url: str | None = None
force_original_url: bool = False
ssh_key_id: str | None = None
class URLParseRequest(BaseModel):
url: str
class URLParseResponse(BaseModel):
original_url: str
base_url: str | None
is_valid_clone_url: bool
needs_parsing: bool
host: str | None
message: str
error_code: str | None
class GitRepositoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID | None
owner_id: uuid.UUID
is_mirror: bool
remote_url: str | None
last_push: datetime | None
ssh_key_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
class UpdateSSHKeyRequest(BaseModel):
ssh_key_id: str | None = None
+29
View File
@@ -0,0 +1,29 @@
"""Project request/response schemas."""
import uuid
from pydantic import BaseModel, ConfigDict
class ProjectCreate(BaseModel):
name: str
description: str | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
default_ssh_key_id: uuid.UUID | None
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
+36
View File
@@ -0,0 +1,36 @@
"""SSH key request/response schemas."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class SSHKeyCreate(BaseModel):
name: str
class SSHKeyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
public_key: str
created_at: datetime
class SignPayloadRequest(BaseModel):
payload: str
class SignatureResponse(BaseModel):
signature: str
class VerifySignatureRequest(BaseModel):
payload: str
signature: str
class VerifySignatureResponse(BaseModel):
valid: bool
+17
View File
@@ -0,0 +1,17 @@
"""System schemas module."""
from src.schemas.system.health import (
DatabaseHealth,
DatabaseHealthResponse,
DiskHealth,
HealthChecks,
HealthResponse,
)
__all__ = [
"DatabaseHealth",
"DatabaseHealthResponse",
"DiskHealth",
"HealthChecks",
"HealthResponse",
]
+50
View File
@@ -0,0 +1,50 @@
"""Health check response schemas."""
from pydantic import BaseModel, Field
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(
description="Query response time in milliseconds", examples=[5.2]
)
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(
description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"]
)
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(
description="Server uptime in seconds", examples=[3600.0]
)
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(
description="Query response time in milliseconds", examples=[5.2]
)
+18
View File
@@ -0,0 +1,18 @@
"""Tool schemas module."""
from src.schemas.tool.tool_instance import CreateInstanceRequest, StartInstanceRequest
from src.schemas.tool.tool_type import (
ToolTypeCreate,
ToolTypeResponse,
ToolTypeUpdate,
ToolTypeValidateRequest,
)
__all__ = [
"CreateInstanceRequest",
"StartInstanceRequest",
"ToolTypeCreate",
"ToolTypeResponse",
"ToolTypeUpdate",
"ToolTypeValidateRequest",
]
@@ -0,0 +1,45 @@
"""Tool instance request/response schemas."""
from pydantic import BaseModel, Field
class CreateInstanceRequest(BaseModel):
"""Request body for creating a tool instance."""
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(
default=None, description="Optional display name for the instance"
)
workspace_id: str | None = Field(
default=None, description="UUID of workspace to mount (replaces clone_mode)"
)
clone_mode: str = Field(
default="mount", description="Repository access mode: 'mount' or 'clone'"
)
branch: str | None = Field(
default="main", description="Branch to clone (when clone_mode='clone')"
)
new_branch: str | None = Field(
default=None, description="Create a new local branch after cloning"
)
config_profile_id: str | None = Field(
default=None, description="Optional config profile ID for launch"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
class StartInstanceRequest(BaseModel):
"""Request body for starting a tool instance."""
model_config = {"extra": "ignore"}
config_profile_id: str | None = Field(
default=None, description="Config profile ID to apply, or null for none"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
+230
View File
@@ -0,0 +1,230 @@
"""Tool type request/response schemas."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from src.api.tool_types_validation import (
check_port_exposed,
validate_compose_yaml,
validate_required_variables,
)
class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
default_port: int = 0
definition_type: str = "compose"
manifest_id: uuid.UUID | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] = []
category: str = "other"
interface_type: str = "web"
requires_port: bool = True
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None or not v.strip():
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None or not v.strip():
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
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, 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
@field_validator("required_variables")
@classmethod
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
validate_required_variables(template, v)
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "manifest":
if self.manifest_id is None:
raise ValueError(
"manifest_id is required when definition_type is 'manifest'"
)
return self
if self.definition_type == "dockerfile" and (
self.dockerfile_template is None or not self.dockerfile_template.strip()
):
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if self.definition_type == "compose" and (
self.compose_template is None or not self.compose_template.strip()
):
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
if (
self.requires_port
and self.definition_type == "compose"
and self.compose_template
):
try:
parsed = validate_compose_yaml(self.compose_template)
except ValueError:
return self
if not check_port_exposed(parsed, self.default_port):
raise ValueError(
f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section."
)
return self
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
description: str | None = None
default_port: int | None = None
definition_type: str | None = None
manifest_id: uuid.UUID | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] | None = None
category: str | None = None
interface_type: str | None = None
requires_port: bool | None = None
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
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:
if v is None:
return v
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
display_name: str
description: str | None
category: str
interface_type: str
requires_port: bool
default_port: int
definition_type: str
manifest_id: uuid.UUID | None
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
startup_command: str | None
required_variables: list[str]
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
+11
View File
@@ -0,0 +1,11 @@
"""User schemas module."""
from src.schemas.user.user import UserProfileResponse, UserProfileUpdate
from src.schemas.user.user_config import UserConfigResponse, UserConfigUpdate
__all__ = [
"UserConfigResponse",
"UserConfigUpdate",
"UserProfileResponse",
"UserProfileUpdate",
]
+19
View File
@@ -0,0 +1,19 @@
"""User response schemas."""
import uuid
from pydantic import BaseModel, ConfigDict
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
+25
View File
@@ -0,0 +1,25 @@
"""User config response schemas."""
from pydantic import BaseModel, ConfigDict
class UserConfigResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
default_editor: str | None = None
theme: str = "system"
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
class UserConfigUpdate(BaseModel):
default_editor: str | None = None
theme: str | None = None
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
+1
View File
@@ -0,0 +1 @@
"""Database seeding utilities."""
+168
View File
@@ -0,0 +1,168 @@
"""Seed built-in tool types into the database."""
import logging
from sqlalchemy import select, text
from src.database import SessionLocal
from src.models import ToolType
logger = logging.getLogger(__name__)
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
try:
result = await session.execute(
text(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""
),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
async def seed_builtin_tool_types():
"""Create or update built-in tool types in the database.
Built-in tool types have no creator (created_by_id=None) and provide
out-of-the-box tools for users without requiring manual tool creation.
"""
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
builtin_types = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interface_type": "web",
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: unless-stopped""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interface_type": "web",
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: unless-stopped""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interface_type": "terminal",
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /workspace
environment:
- HOME=/tmp
volumes:
- {{REPO_PATH}}:/workspace
- opencode_home:/tmp
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
echo 'cd /workspace' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
cd /workspace &&
exec tail -f /dev/null"
stdin_open: true
tty: true
restart: unless-stopped
volumes:
opencode_home:""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
for tool_data in builtin_types:
existing = await session.scalar(
select(ToolType).where(ToolType.name == tool_data["name"])
)
if not existing:
tool_type = ToolType(
name=tool_data["name"],
display_name=tool_data["display_name"],
description=tool_data["description"],
category=tool_data["category"],
interface_type=tool_data["interface_type"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
default_port=tool_data.get("default_port", 0),
created_by_id=None,
)
session.add(tool_type)
logger.info("Created built-in tool type: %s", tool_data["name"])
else:
# Update existing built-in tool types to reflect code changes
existing.display_name = tool_data["display_name"]
existing.description = tool_data["description"]
existing.category = tool_data["category"]
existing.interface_type = tool_data["interface_type"]
existing.definition_type = "compose"
existing.compose_template = tool_data["compose_template"]
existing.required_variables = tool_data["required_variables"]
existing.default_port = tool_data.get("default_port", 0)
logger.info("Updated built-in tool type: %s", tool_data["name"])
await session.commit()
logger.info("Built-in tool types seeded successfully.")
+1
View File
@@ -0,0 +1 @@
"""Config module."""
@@ -13,7 +13,7 @@ from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models.config_profile import ConfigProfile, ConfigProfileInclude from src.models import ConfigProfile, ConfigProfileInclude
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+55
View File
@@ -0,0 +1,55 @@
"""Docker services package for container and compose operations."""
from src.services.docker.compose import (
execute_compose_command,
render_compose_template,
sort_volumes_by_specificity,
write_compose_file,
)
from src.services.docker.config_staging import (
ensure_instance_directory,
write_config_files,
write_env_file,
)
from src.services.docker.container import (
connect_container_to_network,
find_free_port,
get_backend_network_name,
get_container_id,
get_container_ip_on_network,
get_container_logs,
get_container_name,
get_container_status,
is_container_on_network,
wait_for_container_running,
)
from src.services.docker.tunnel import (
check_tunnel_health,
recreate_tunnel,
start_tunnel,
stop_tunnel,
)
__all__ = [
"check_tunnel_health",
"connect_container_to_network",
"ensure_instance_directory",
"execute_compose_command",
"find_free_port",
"get_backend_network_name",
"get_container_id",
"get_container_ip_on_network",
"get_container_logs",
"get_container_name",
"get_container_status",
"is_container_on_network",
"recreate_tunnel",
"render_compose_template",
"sort_volumes_by_specificity",
"start_tunnel",
"stop_tunnel",
"wait_for_container_running",
"write_compose_file",
"write_config_files",
"write_env_file",
]
+120
View File
@@ -0,0 +1,120 @@
"""Docker Compose file generation and manipulation."""
import logging
import subprocess
from collections import Counter
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
"""Sort volume strings so parent paths come before child paths.
Docker Compose mounts volumes in array order. A later mount at a parent
path hides earlier mounts at child paths. By sorting shallow paths first
and deep paths last, deeper (more specific) mounts overlay correctly.
Volume format: source:target or source:target:type
Args:
volumes: List of Docker volume mount strings.
Returns:
Sorted list with parent paths before child paths.
"""
def _target_depth(vol: str) -> int:
parts = vol.split(":")
if len(parts) < 2:
return 0
target = parts[1].rstrip("/")
if not target or target == "/":
return 0
return target.count("/")
# Detect duplicate targets and warn
targets = []
for vol in volumes:
parts = vol.split(":")
targets.append(parts[1] if len(parts) > 1 else "")
dupes = [t for t, c in Counter(targets).items() if c > 1]
if dupes:
logger.warning("Duplicate mount targets detected: %s", dupes)
# Stable sort: parent paths first, child paths last
return sorted(volumes, key=_target_depth)
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
"""Render a Docker Compose template with variable substitution.
Args:
template: The compose template string
variables: Dictionary of variable names to values
Returns:
Rendered compose file content
"""
result = template
for key, value in variables.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
return result
def write_compose_file(instance_dir: str, content: str) -> str:
"""Write the rendered compose file to the instance directory.
Args:
instance_dir: Path to instance directory
content: Rendered compose content
Returns:
Path to the compose file
"""
compose_path = Path(instance_dir) / "docker-compose.yml"
compose_path.write_text(content)
return str(compose_path)
def execute_compose_command(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
"""Execute a docker compose command.
Args:
compose_path: Path to docker-compose.yml
action: The compose action (up, down, start, stop, restart)
timeout: Command timeout in seconds
env_file: Optional path to .env file for environment variables
Returns:
Tuple of (returncode, stdout, stderr)
"""
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d", "--force-recreate"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
cmd.append(action)
else:
raise ValueError(f"Unknown compose action: {action}")
result = subprocess.run(
cmd,
cwd=str(instance_dir),
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
@@ -0,0 +1,61 @@
"""Staging configuration files into instance directories."""
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
"""Create and return the instance directory path.
Args:
instance_id: Unique instance identifier
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
Returns:
Absolute path to instance directory
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
return str(instance_dir.absolute())
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
"""Write environment variables to a .env file.
Args:
instance_dir: Path to instance directory
env_vars: Dictionary of env var names to values
Returns:
Path to the env file
"""
env_path = Path(instance_dir) / ".env"
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
env_path.write_text("\n".join(lines) + "\n")
return str(env_path)
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
"""Write config files to the instance directory.
Args:
instance_dir: Path to instance directory
files: Dictionary of file paths (relative to instance dir) to content
"""
instance_path = Path(instance_dir)
for file_path, content in files.items():
# Ensure the path is within the instance directory (security)
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
@@ -1,181 +1,13 @@
"""Docker service for managing tool instances.""" """Docker container runtime queries and network management."""
import logging import logging
import subprocess import subprocess
import time import time
from collections import Counter
from pathlib import Path
from typing import Any from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
"""Sort volume strings so parent paths come before child paths.
Docker Compose mounts volumes in array order. A later mount at a parent
path hides earlier mounts at child paths. By sorting shallow paths first
and deep paths last, deeper (more specific) mounts overlay correctly.
Volume format: source:target or source:target:type
Args:
volumes: List of Docker volume mount strings.
Returns:
Sorted list with parent paths before child paths.
"""
def _target_depth(vol: str) -> int:
parts = vol.split(":")
if len(parts) < 2:
return 0
target = parts[1].rstrip("/")
if not target or target == "/":
return 0
return target.count("/")
# Detect duplicate targets and warn
targets = []
for vol in volumes:
parts = vol.split(":")
targets.append(parts[1] if len(parts) > 1 else "")
dupes = [t for t, c in Counter(targets).items() if c > 1]
if dupes:
logger.warning("Duplicate mount targets detected: %s", dupes)
# Stable sort: parent paths first, child paths last
return sorted(volumes, key=_target_depth)
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
"""Render a Docker Compose template with variable substitution.
Args:
template: The compose template string
variables: Dictionary of variable names to values
Returns:
Rendered compose file content
"""
result = template
for key, value in variables.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
return result
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
"""Create and return the instance directory path.
Args:
instance_id: Unique instance identifier
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
Returns:
Absolute path to instance directory
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
return str(instance_dir.absolute())
def write_compose_file(instance_dir: str, content: str) -> str:
"""Write the rendered compose file to the instance directory.
Args:
instance_dir: Path to instance directory
content: Rendered compose content
Returns:
Path to the compose file
"""
compose_path = Path(instance_dir) / "docker-compose.yml"
compose_path.write_text(content)
return str(compose_path)
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
"""Write environment variables to a .env file.
Args:
instance_dir: Path to instance directory
env_vars: Dictionary of env var names to values
Returns:
Path to the env file
"""
env_path = Path(instance_dir) / ".env"
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
env_path.write_text("\n".join(lines) + "\n")
return str(env_path)
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
"""Write config files to the instance directory.
Args:
instance_dir: Path to instance directory
files: Dictionary of file paths (relative to instance dir) to content
"""
instance_path = Path(instance_dir)
for file_path, content in files.items():
# Ensure the path is within the instance directory (security)
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
def execute_compose_command(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
"""Execute a docker compose command.
Args:
compose_path: Path to docker-compose.yml
action: The compose action (up, down, start, stop, restart)
timeout: Command timeout in seconds
env_file: Optional path to .env file for environment variables
Returns:
Tuple of (returncode, stdout, stderr)
"""
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d", "--force-recreate"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
cmd.append(action)
else:
raise ValueError(f"Unknown compose action: {action}")
result = subprocess.run(
cmd,
cwd=str(instance_dir),
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
def get_container_id(instance_name: str) -> str | None: def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service. """Get the container ID for a compose service.
+281
View File
@@ -0,0 +1,281 @@
"""Cloudflare tunnel management using cloudflared Docker containers.
Each tunnel runs as a Docker container on the same 'backend' network as the API.
cloudflared connects to the tool container by its Docker Compose service name
(e.g. http://code-server-headquarter-34837cd3:8443).
"""
import logging
import re
import subprocess
from typing import Any
from src.services.docker.container import get_backend_network_name
logger = logging.getLogger(__name__)
TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
def _tunnel_container_name(instance_name: str) -> str:
return f"tunnel-{instance_name.lower()}"
def _ensure_image() -> None:
"""Pull cloudflared image if not already present."""
result = subprocess.run(
["docker", "images", "-q", TUNNEL_IMAGE],
capture_output=True,
text=True,
)
if not result.stdout.strip():
logger.info("Pulling %s ...", TUNNEL_IMAGE)
pull = subprocess.run(
["docker", "pull", TUNNEL_IMAGE],
capture_output=True,
text=True,
)
if pull.returncode != 0:
logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr)
def _cleanup_stale_tunnel(tunnel_name: str) -> None:
"""Remove any existing tunnel container with this name."""
subprocess.run(
["docker", "stop", "-t", "3", tunnel_name],
capture_output=True,
text=True,
)
subprocess.run(
["docker", "rm", "-f", tunnel_name],
capture_output=True,
text=True,
)
def _get_tunnel_logs(tunnel_name: str) -> tuple[str, str]:
"""Get stdout and stderr logs from a container."""
result = subprocess.run(
["docker", "logs", tunnel_name],
capture_output=True,
text=True,
)
return result.stdout, result.stderr
def _get_tunnel_exit_code(tunnel_name: str) -> int | None:
"""Get exit code of a container if it has exited."""
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
capture_output=True,
text=True,
)
if result.returncode == 0:
try:
return int(result.stdout.strip())
except ValueError:
pass
return None
def start_tunnel(
instance_name: str,
container_port: int,
timeout: int = 30,
target_url: str | None = None,
) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for an instance.
Args:
instance_name: The tool instance name (used for tunnel naming).
container_port: The port the tool container listens on internally.
timeout: Seconds to wait for the tunnel URL.
target_url: Optional explicit URL to proxy to. If omitted, derives
http://{instance_name.lower()}:{container_port}.
Returns:
Dict with 'url' and 'container_name'.
"""
_ensure_image()
tunnel_name = _tunnel_container_name(instance_name)
_cleanup_stale_tunnel(tunnel_name)
# Target the tool container by name on the backend network
if target_url is None:
target_url = f"http://{instance_name.lower()}:{container_port}"
cmd = [
"docker",
"run",
"-d",
"--network",
get_backend_network_name(),
"--name",
tunnel_name,
TUNNEL_IMAGE,
"tunnel",
"--no-autoupdate",
"--url",
target_url,
]
logger.debug("Running: %s", " ".join(cmd))
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(
f"Failed to start tunnel container {tunnel_name}: {proc.stderr}"
)
container_id = proc.stdout.strip()
logger.debug("Tunnel container started: %s", container_id)
# Wait for URL to appear in logs
# Exclude api.trycloudflare.com which is the Cloudflare API endpoint,
# not a tunnel URL. Real tunnel URLs have random subdomains (10+ chars).
url_pattern = re.compile(r"https://(?!api\.)[a-z0-9-]{10,}\.trycloudflare\.com")
start_time = __import__("time").time()
url: str | None = None
combined_logs = ""
while __import__("time").time() - start_time < timeout:
stdout, stderr = _get_tunnel_logs(tunnel_name)
combined_logs = stdout + "\n" + stderr
match = url_pattern.search(combined_logs)
if match:
url = match.group(0)
break
# Check if container exited early
exit_code = _get_tunnel_exit_code(tunnel_name)
if exit_code is not None and exit_code != 0:
_cleanup_stale_tunnel(tunnel_name)
raise RuntimeError(
f"Tunnel container {tunnel_name} exited with code {exit_code}. "
f"Logs:\n{combined_logs[-3000:]}"
)
__import__("time").sleep(0.5)
if not url:
stdout, stderr = _get_tunnel_logs(tunnel_name)
combined_logs = stdout + "\n" + stderr
exit_code = _get_tunnel_exit_code(tunnel_name)
_cleanup_stale_tunnel(tunnel_name)
raise RuntimeError(
f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. "
f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}"
)
# Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain
__import__("time").sleep(2)
logger.info(
"Tunnel %s started for %s%s (%s)",
tunnel_name,
instance_name,
target_url,
url,
)
return {"url": url, "container_name": tunnel_name}
def stop_tunnel(instance_name: str) -> None:
"""Stop and remove the tunnel container for an instance."""
tunnel_name = _tunnel_container_name(instance_name)
_cleanup_stale_tunnel(tunnel_name)
logger.debug("Stopped and removed tunnel container %s", tunnel_name)
def recreate_tunnel(
instance_name: str, container_port: int, target_url: str | None = None
) -> dict[str, str]:
"""Recreate a tunnel for an instance.
Args:
instance_name: The tool instance name.
container_port: The port the tool container listens on internally.
target_url: Optional explicit origin URL. If omitted, derives
http://{instance_name.lower()}:{container_port}.
"""
stop_tunnel(instance_name)
return start_tunnel(instance_name, container_port, target_url=target_url)
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy.
Returns:
Dict with 'tunnel_status', 'status_code', 'healthy', 'error'.
"""
try:
result = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
str(timeout),
url,
],
capture_output=True,
text=True,
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,
}
if status_code in (502, 503, 504):
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"Application returned HTTP {status_code}",
}
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",
}
except (ValueError, Exception) as exc:
error_str = str(exc).lower()
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: {exc}",
}
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": str(exc),
}
+1 -1
View File
@@ -4,7 +4,7 @@ import logging
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from src.models.workspace import Workspace from src.models import Workspace
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+1
View File
@@ -0,0 +1 @@
"""Git module."""
+1 -1
View File
@@ -4,7 +4,7 @@ import asyncio
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from src.models.workspace import Workspace from src.models import Workspace
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+2 -2
View File
@@ -10,8 +10,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.database import SessionLocal from src.database import SessionLocal
from src.models.health_check import HealthCheck from src.models import HealthCheck
from src.models.tool_instance import ToolInstance from src.models import ToolInstance
from src.services.correlation import get_correlation_id from src.services.correlation import get_correlation_id
from src.services.docker import get_container_status from src.services.docker import get_container_status
from src.services.tunnel import check_tunnel_health from src.services.tunnel import check_tunnel_health
@@ -0,0 +1 @@
"""Instance module."""
+2 -2
View File
@@ -6,8 +6,8 @@ from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models.instance_event import InstanceEvent from src.models import InstanceEvent
from src.models.tool_instance import ToolInstance from src.models import ToolInstance
from src.services.correlation import get_correlation_id from src.services.correlation import get_correlation_id
from src.services.event_bus import InstanceEventBus, InstanceEventPayload from src.services.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.notification_service import notification_service from src.services.notification_service import notification_service
@@ -8,7 +8,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.engine import CursorResult from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models.notification import Notification from src.models import Notification
class NotificationService: class NotificationService:
+1
View File
@@ -0,0 +1 @@
"""Shared module."""
@@ -0,0 +1 @@
"""Terminal module."""
+1 -1
View File
@@ -9,7 +9,7 @@ from fastapi import WebSocket
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from src.database import SessionLocal from src.database import SessionLocal
from src.models.terminal_session import TerminalSessionModel from src.models import TerminalSessionModel
from src.services.terminal_session import TerminalSession from src.services.terminal_session import TerminalSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+7 -280
View File
@@ -1,281 +1,8 @@
"""Clean tunnel service using cloudflared containers on the backend network. """Tunnel service — re-exported from docker.tunnel for backward compatibility."""
Design: from src.services.docker.tunnel import ( # noqa: F401
- Each tunnel runs as a Docker container on the same 'backend' network as the API. check_tunnel_health,
- cloudflared connects to the tool container by its Docker Compose service name recreate_tunnel,
(e.g. http://code-server-headquarter-34837cd3:8443). start_tunnel,
- This avoids host port conflicts and DNS resolution issues. stop_tunnel,
""" )
import logging
import re
import subprocess
from typing import Any
from src.services.docker import get_backend_network_name
logger = logging.getLogger(__name__)
TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
def _tunnel_container_name(instance_name: str) -> str:
return f"tunnel-{instance_name.lower()}"
def _ensure_image() -> None:
"""Pull cloudflared image if not already present."""
result = subprocess.run(
["docker", "images", "-q", TUNNEL_IMAGE],
capture_output=True,
text=True,
)
if not result.stdout.strip():
logger.info("Pulling %s ...", TUNNEL_IMAGE)
pull = subprocess.run(
["docker", "pull", TUNNEL_IMAGE],
capture_output=True,
text=True,
)
if pull.returncode != 0:
logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr)
def _cleanup_stale_tunnel(tunnel_name: str) -> None:
"""Remove any existing tunnel container with this name."""
subprocess.run(
["docker", "stop", "-t", "3", tunnel_name],
capture_output=True,
text=True,
)
subprocess.run(
["docker", "rm", "-f", tunnel_name],
capture_output=True,
text=True,
)
def _get_container_logs(tunnel_name: str) -> tuple[str, str]:
"""Get stdout and stderr logs from a container."""
result = subprocess.run(
["docker", "logs", tunnel_name],
capture_output=True,
text=True,
)
return result.stdout, result.stderr
def _get_container_exit_code(tunnel_name: str) -> int | None:
"""Get exit code of a container if it has exited."""
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
capture_output=True,
text=True,
)
if result.returncode == 0:
try:
return int(result.stdout.strip())
except ValueError:
pass
return None
def start_tunnel(
instance_name: str,
container_port: int,
timeout: int = 30,
target_url: str | None = None,
) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for an instance.
Args:
instance_name: The tool instance name (used for tunnel naming).
container_port: The port the tool container listens on internally.
timeout: Seconds to wait for the tunnel URL.
target_url: Optional explicit URL to proxy to. If omitted, derives
http://{instance_name.lower()}:{container_port}.
Returns:
Dict with 'url' and 'container_name'.
"""
_ensure_image()
tunnel_name = _tunnel_container_name(instance_name)
_cleanup_stale_tunnel(tunnel_name)
# Target the tool container by name on the backend network
if target_url is None:
target_url = f"http://{instance_name.lower()}:{container_port}"
cmd = [
"docker",
"run",
"-d",
"--network",
get_backend_network_name(),
"--name",
tunnel_name,
TUNNEL_IMAGE,
"tunnel",
"--no-autoupdate",
"--url",
target_url,
]
logger.debug("Running: %s", " ".join(cmd))
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(
f"Failed to start tunnel container {tunnel_name}: {proc.stderr}"
)
container_id = proc.stdout.strip()
logger.debug("Tunnel container started: %s", container_id)
# Wait for URL to appear in logs
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
start_time = __import__("time").time()
url: str | None = None
combined_logs = ""
while __import__("time").time() - start_time < timeout:
stdout, stderr = _get_container_logs(tunnel_name)
combined_logs = stdout + "\n" + stderr
match = url_pattern.search(combined_logs)
if match:
url = match.group(0)
break
# Check if container exited early
exit_code = _get_container_exit_code(tunnel_name)
if exit_code is not None and exit_code != 0:
_cleanup_stale_tunnel(tunnel_name)
raise RuntimeError(
f"Tunnel container {tunnel_name} exited with code {exit_code}. "
f"Logs:\n{combined_logs[-3000:]}"
)
__import__("time").sleep(0.5)
if not url:
stdout, stderr = _get_container_logs(tunnel_name)
combined_logs = stdout + "\n" + stderr
exit_code = _get_container_exit_code(tunnel_name)
_cleanup_stale_tunnel(tunnel_name)
raise RuntimeError(
f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. "
f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}"
)
# Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain
__import__("time").sleep(2)
logger.info(
"Tunnel %s started for %s%s (%s)",
tunnel_name,
instance_name,
target_url,
url,
)
return {"url": url, "container_name": tunnel_name}
def stop_tunnel(instance_name: str) -> None:
"""Stop and remove the tunnel container for an instance."""
tunnel_name = _tunnel_container_name(instance_name)
_cleanup_stale_tunnel(tunnel_name)
logger.debug("Stopped and removed tunnel container %s", tunnel_name)
def recreate_tunnel(
instance_name: str, container_port: int, target_url: str | None = None
) -> dict[str, str]:
"""Recreate a tunnel for an instance.
Args:
instance_name: The tool instance name.
container_port: The port the tool container listens on internally.
target_url: Optional explicit origin URL. If omitted, derives
http://{instance_name.lower()}:{container_port}.
"""
stop_tunnel(instance_name)
return start_tunnel(instance_name, container_port, target_url=target_url)
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy.
Returns:
Dict with 'tunnel_status', 'status_code', 'healthy', 'error'.
"""
try:
result = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
str(timeout),
url,
],
capture_output=True,
text=True,
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,
}
if status_code in (502, 503, 504):
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"Application returned HTTP {status_code}",
}
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",
}
except (ValueError, Exception) as exc:
error_str = str(exc).lower()
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: {exc}",
}
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": str(exc),
}
+7 -7
View File
@@ -14,15 +14,15 @@ from typing import TYPE_CHECKING
from sqlalchemy import select from sqlalchemy import select
from src.models.workspace import Workspace from src.models import Workspace
from src.services.git_service import GitService from src.services.git_service import GitService
from src.services.ssh_keys import _get_fernet from src.services.ssh_keys import _get_fernet
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models.git_repository import GitRepository from src.models import GitRepository
from src.models.tool_instance import ToolInstance from src.models import ToolInstance
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -96,7 +96,7 @@ class WorkspaceManager:
# Load SSH key if repo has one # Load SSH key if repo has one
ssh_key = None ssh_key = None
if getattr(repo, "ssh_key_id", None) and session is not None: if getattr(repo, "ssh_key_id", None) and session is not None:
from src.models.ssh_key import SSHKey from src.models import SSHKey
result = await session.execute( result = await session.execute(
select(SSHKey).where(SSHKey.id == repo.ssh_key_id) select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
@@ -181,8 +181,8 @@ class WorkspaceManager:
# Load SSH key if repo has one # Load SSH key if repo has one
ssh_key = None ssh_key = None
if session is not None: if session is not None:
from src.models.git_repository import GitRepository from src.models import GitRepository
from src.models.ssh_key import SSHKey from src.models import SSHKey
repo = await session.get(GitRepository, workspace.repo_id) repo = await session.get(GitRepository, workspace.repo_id)
if repo and getattr(repo, "ssh_key_id", None): if repo and getattr(repo, "ssh_key_id", None):
@@ -241,7 +241,7 @@ class WorkspaceManager:
session: AsyncSession, session: AsyncSession,
) -> list[ToolInstance]: ) -> list[ToolInstance]:
"""Get all tool instances associated with this workspace.""" """Get all tool instances associated with this workspace."""
from src.models.tool_instance import ToolInstance from src.models import ToolInstance
result = await session.execute( result = await session.execute(
select(ToolInstance).where(ToolInstance.workspace_id == workspace.id) select(ToolInstance).where(ToolInstance.workspace_id == workspace.id)
+1
View File
@@ -64,6 +64,7 @@ services:
- /data/repos:/data/repos - /data/repos:/data/repos
- /data/working-copies:/data/working-copies - /data/working-copies:/data/working-copies
- /data/instances:/data/instances - /data/instances:/data/instances
- ./apps/api/src:/app/src:ro
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-03
@@ -0,0 +1,319 @@
## Context
Current `dev` has all behavioral features from the overwritten `main` merge, but the code structure is pre-refactor:
- Monolithic `api/tool_instances.py` (~3000 lines)
- Monolithic `api/config_profiles.py` (~1000 lines)
- Monolithic `services/docker.py`
- No `schemas/` directory
- Flat frontend component structure with inconsistent naming
The `b6f89f9` merge from `main` had a clean refactoring that we need to redo, but adapted to our current reality.
## Goals / Non-Goals
**Goals:**
- Extract Pydantic schemas from API routers into `src/schemas/`
- Split `services/docker.py` into `services/docker/` package
- Extract instance lifecycle logic from `api/tool_instances.py` into `services/instance_lifecycle.py`
- Extract config profile business logic from `api/config_profiles.py` into `services/config_profiles.py`
- Add `get_current_user` auth dependency and migrate routers that need the full user object
- Reorganize frontend components into `features/` directories
- Standardize frontend API file naming to kebab-case
- Standardize frontend page naming to `*Page.tsx`
**Non-Goals:**
- Changing any API request/response shapes
- Changing any database schemas
- Adding new features
- Modifying frontend component behavior or styling
- Converting `ConfigProfile` mounts from JSON to relation tables (out of scope — would require migration)
## Decisions
### 0. Submodule Rule: Max 510 Files Per Directory
**Decision:** Every directory that functions as a Python module must contain at most 510 `.py` files. When a module grows beyond this, split it into a package with submodules.
**Rationale:** Prevents monolithic directories, makes navigation predictable, and keeps cognitive load bounded.
### 1. Schema Extraction: Domain Subpackages
**Decision:** Extract Pydantic models into `schemas/` subpackages by domain:
- `schemas/tool/` — tool_type.py, tool_instance.py
- `schemas/config/` — config_profile.py
- `schemas/user/` — user.py, user_config.py
- `schemas/project/` — project.py, git_repository.py, ssh_key.py
- `schemas/system/` — health.py
**Rationale:** Keeps schemas close to their domain. Each subpackage has ≤5 files.
### 2. API Router Subpackages
**Decision:** Split `api/` into domain subpackages:
- `api/tool/` — tool_instances.py, tool_types.py, tool_definitions.py, tool_types_validation.py, sessions.py
- `api/config/` — config_profiles.py, user_config.py
- `api/workspace/` — workspaces.py, workspace_files.py, workspace_git.py, workspace_instances.py
- `api/user/` — users.py, auth.py, ssh_keys.py
- `api/project/` — projects.py, git_repositories.py
- `api/system/` — health.py, events.py, notifications.py, dashboard.py, terminal.py, instance_proxy.py
**Rationale:** `api/` currently has ~22 files. Splitting into 6 subpackages keeps each at 26 files.
### 3. Service Subpackages
**Decision:** Split `services/` into subpackages:
- `services/docker/` — compose.py, container.py, config_staging.py, tunnel.py, __init__.py
- `services/instance/` — instance_lifecycle.py, lifecycle_hooks.py, health_monitor.py, event_bus.py
- `services/config/` — config_profile_resolver.py, config_profiles.py
- `services/git/` — clone.py, git_operations.py, git_service.py
- `services/build/` — docker_build.py, manifest_compiler.py
- `services/terminal/` — terminal_manager.py, terminal_session.py
- `services/shared/` — tunnel.py, notification_service.py, file_service.py, permission_fixer.py, readiness_probe.py, ssh_keys.py, workspace_manager.py, correlation.py
**Rationale:** `services/` currently has ~20 files. Subpackages keep each at ≤8 files.
### 4. Model Subpackages
**Decision:** Split `models/` into subpackages:
- `models/tool/` — tool_type.py, tool_instance.py, tool_definition_manifest.py
- `models/config/` — config_profile.py, config_include.py, config_mount.py
- `models/user/` — user.py, user_config.py, ssh_key.py
- `models/project/` — project.py, git_repository.py, workspace.py
- `models/system/` — health_check.py, notification.py, instance_event.py, terminal_session.py
- `models/base.py` stays at root
**Rationale:** `models/` currently has ~15 files. Subpackages keep each at ≤4 files.
### 5. Docker Service Split: Functional Boundaries
**Decision:** Split by responsibility:
- `compose.py` — compose file generation, modification, port injection, network injection
- `container.py` — container status, IP lookup, network connect, logs
- `config_staging.py` — staging config files into instance directories
- `tunnel.py` — extracting tunnel URLs from cloudflared output
**Rationale:** Each module has a single reason to change. `docker.py` mixed compose logic with container runtime queries.
### 6. Instance Lifecycle: Service Receives Raw Params, Not Request Objects
**Decision:** Service functions receive model instances and primitive parameters, not FastAPI request objects.
**Example:** `create_instance(session, user, project, repo, tool_type, data: CreateInstanceRequest)` → service extracts fields.
**Rationale:** Keeps service layer independent of HTTP framework. Easier to test.
### 7. Auth Pattern: Gradual Migration, Not Big Bang
**Decision:** Add `get_current_user` alongside existing `get_current_user_id`. Migrate routers incrementally.
**Rationale:** Reduces risk. Endpoints that only need the ID can keep the old pattern.
### 8. Frontend Naming: Align with `b6f89f9` Conventions
**Decision:** Use kebab-case for API files, PascalCase for page files with `Page` suffix, `features/` for component directories.
**Rationale:** Matches the `b6f89f9` structure that was already reviewed and accepted.
## Module Map
### Backend — Before
```
api/ (~22 .py files)
tool_instances.py (~3000 lines) — HTTP + Docker + Git + Lifecycle
config_profiles.py (~1000 lines) — HTTP + Validation + Defaults
tool_types.py (~500 lines) — HTTP + Schemas
health.py (~150 lines) — HTTP + Schemas
users.py (~100 lines) — HTTP + Schemas
...
services/ (~20 .py files)
docker.py (~600 lines) — Compose + Container + Tunnel
models/ (~15 .py files)
config_profile.py
tool_instance.py
...
```
### Backend — After
```
schemas/ (5 subpackages, ≤5 files each)
tool/
__init__.py
tool_type.py
tool_instance.py
config/
__init__.py
config_profile.py
user/
__init__.py
user.py
user_config.py
project/
__init__.py
project.py
git_repository.py
ssh_key.py
system/
__init__.py
health.py
api/ (6 subpackages, 26 files each)
tool/
__init__.py
tool_instances.py (~300 lines) — HTTP routing only
tool_types.py (~250 lines) — HTTP + validation
tool_definitions.py
tool_types_validation.py
sessions.py
config/
__init__.py
config_profiles.py (~200 lines) — HTTP routing only
user_config.py
workspace/
__init__.py
workspaces.py
workspace_files.py
workspace_git.py
workspace_instances.py
user/
__init__.py
users.py (~60 lines) — HTTP only
auth.py
ssh_keys.py
project/
__init__.py
projects.py
git_repositories.py
system/
__init__.py
health.py (~80 lines) — HTTP only
events.py
notifications.py
dashboard.py
terminal.py
instance_proxy.py
services/ (7 subpackages, ≤8 files each)
docker/
__init__.py (~40 lines) — Re-exports
compose.py (~240 lines) — Compose generation
container.py (~120 lines) — Container queries
config_staging.py (~80 lines) — File staging
tunnel.py (~150 lines) — Tunnel URL extraction
instance/
__init__.py
instance_lifecycle.py (~420 lines) — Create/Start/Stop/Restart/Delete
lifecycle_hooks.py
health_monitor.py
event_bus.py
config/
__init__.py
config_profile_resolver.py
config_profiles.py (~300 lines) — CRUD + Defaults
git/
__init__.py
clone.py
git_operations.py
git_service.py
build/
__init__.py
docker_build.py
manifest_compiler.py
terminal/
__init__.py
terminal_manager.py
terminal_session.py
shared/
__init__.py
tunnel.py
notification_service.py
file_service.py
permission_fixer.py
readiness_probe.py
ssh_keys.py
workspace_manager.py
correlation.py
models/ (5 subpackages + base.py)
tool/
__init__.py
tool_type.py
tool_instance.py
tool_definition_manifest.py
config/
__init__.py
config_profile.py
config_include.py
config_mount.py
user/
__init__.py
user.py
user_config.py
ssh_key.py
project/
__init__.py
project.py
git_repository.py
workspace.py
system/
__init__.py
health_check.py
notification.py
instance_event.py
terminal_session.py
base.py (stays at root)
```
### Frontend — Before
```
src/
api/
tool_types.ts
ssh_keys.ts
git_repositories.ts
sessions.ts
components/
git-toolbar.tsx
file-editor.tsx
commit-dialog.tsx
...
pages/
dashboard.tsx
projects.tsx
sessions.tsx
...
```
### Frontend — After
```
src/
api/
tool-types.ts
ssh-keys.ts
git-repositories.ts
sessions.ts
components/
features/
git/
GitToolbar.tsx
FileBrowser.tsx
FileEditor.tsx
CommitDialog.tsx
MergeDialog.tsx
WorkspaceSidebar.tsx
dashboard/
DashboardSummary.tsx
ActiveSessionsList.tsx
ProjectsSection.tsx
QuickCreateForm.tsx
RecentSessionsSection.tsx
project/
RepositoriesSettingsTab.tsx
tool-workshop/
ToolTypesTab.tsx
ProtectedRoute.tsx
AppShell.tsx
pages/
DashboardPage.tsx
ProjectsPage.tsx
SessionsPage.tsx
...
```
## Risks / Trade-offs
**[Risk] Import cycles during extraction** → **Mitigation:** Extract schemas first (no service dependencies), then services, then thin routers last. Use TYPE_CHECKING guards.
**[Risk] Merge conflicts with in-flight features** → **Mitigation:** Coordinate timing. This refactor should be the only large change on `dev` while it's in progress. Freeze other backend work.
**[Risk] Frontend renaming breaks imports** → **Mitigation:** Use `git mv` for renames so git tracks history. Update all imports in a single commit.
**[Risk] Missing re-export in docker/__init__.py breaks consumers** → **Mitigation:** After splitting, run a full import test across all backend files. Add any missing re-exports.
## Migration Plan
1. **Phase 1: Schemas** — Extract all Pydantic models into `schemas/`. Update imports in API routers. No logic changes.
2. **Phase 2: Services** — Split `docker.py`, extract `instance_lifecycle.py`, extract `config_profiles.py`. Update imports.
3. **Phase 3: Auth** — Add `get_current_user`, migrate routers that need the full user object.
4. **Phase 4: Frontend** — Rename files, move components, update imports.
5. **Phase 5: Verification** — Run full test suite, typecheck, build.
@@ -0,0 +1,52 @@
## Why
The `main` branch was previously merged into `dev` (commit `b6f89f9`) bringing a large structural refactoring: schema extraction, service splitting, auth dependency pattern changes, and frontend reorganization. This merge was later overwritten when `dev` was reset to a pre-merge clean state (`51a399c`).
We have since forward-ported all behavioral features (built-in tool type seeding, config profile defaults, unique constraints, SSH key mounting, terminal backend, etc.) onto the clean `dev` base. **The codebase now works functionally but lacks the structural cleanliness of the refactoring.**
Monolithic files make the backend harder to navigate, test, and maintain:
- `api/tool_instances.py` is ~3000 lines (mixing HTTP handling with Docker orchestration)
- `api/config_profiles.py` is ~1000 lines (mixing HTTP handling with business logic)
- `services/docker.py` is a monolith of ~600 lines covering compose, container, and tunnel logic
- Frontend API files use inconsistent naming (`tool_types.ts` vs `tool-types.ts`)
- Frontend components are flat in `components/` instead of organized by feature domain
## What Changes
Redo the structural refactoring from `b6f89f9`, **adapted to current dev reality**:
1. **Backend schema extraction** — Extract Pydantic request/response schemas from API routers into `apps/api/src/schemas/`
2. **Backend service splits** — Split `services/docker.py` into `services/docker/` package; extract `services/instance_lifecycle.py` and `services/config_profiles.py`
3. **Auth dependency refactor** — Change from `get_current_user_id` + manual `_get_user` calls to `get_current_user` dependency returning `User` directly
4. **Frontend reorganization** — Move components into `features/` directories; rename API files to kebab-case
**No behavioral changes.** This is a pure structural refactor. All existing endpoints, models, and UI flows remain identical.
## Capabilities
### New Capabilities
- None (pure refactor)
### Modified Capabilities
- `backend-structure`: Cleaner module boundaries, smaller files, separated concerns
- `frontend-structure`: Feature-organized components, consistent file naming
## Impact
- **Backend**: `apps/api/src/schemas/*` (new), `apps/api/src/services/docker/*` (new package), `apps/api/src/services/instance_lifecycle.py` (new), `apps/api/src/services/config_profiles.py` (new)
- **Backend**: `apps/api/src/api/*.py` (reduced in size, imports change)
- **Backend**: `apps/api/src/auth/dependencies.py` (new `get_current_user`)
- **Frontend**: `apps/web/src/components/features/*` (new directories), `apps/web/src/api/*` (renamed to kebab-case)
- **Frontend**: `apps/web/src/pages/*` (renamed to `*Page.tsx`)
## Exclusions (Already Done)
The following behavioral features from `b6f89f9` are **already present** in current `dev` and out of scope for this refactor:
- Built-in tool type seeding (`src/seeds/builtin_tool_types.py`)
- Config profile default management (endpoints + `UserConfig` properties)
- Config profile unique constraint (`uq_config_profiles_user_name`)
- SSH key mounting in instance lifecycle
- Terminal backend (WebSocket, session management)
- Tunnel URL regex fix
- Session auto-numbering
- Config profile resolver (`services/config_profile_resolver.py`)
@@ -0,0 +1,75 @@
## Scope
This change is a **pure structural refactoring** of the backend and frontend codebase. No API contracts, database schemas, or user-facing behaviors change.
### In Scope
1. **Schema extraction** (`apps/api/src/schemas/`)
- Extract Pydantic models from API routers into dedicated schema modules
- Schemas to extract: `tool_type`, `tool_instance`, `config_profile`, `user`, `user_config`, `project`, `ssh_key`, `git_repository`, `health`
- Each API router imports schemas from `src.schemas.*` instead of defining inline
2. **Docker service package** (`apps/api/src/services/docker/`)
- Split `services/docker.py` monolith into focused modules:
- `docker/compose.py` — compose file generation, modification, validation
- `docker/container.py` — container lifecycle (status, IP, network, logs)
- `docker/config_staging.py` — config file staging for instances
- `docker/tunnel.py` — tunnel URL extraction (moved from `services/tunnel.py`)
- `docker/__init__.py` — re-exports for backward compatibility
- Update all imports across the backend
3. **Instance lifecycle extraction** (`apps/api/src/services/instance_lifecycle.py`)
- Extract instance creation, start, stop, restart, and deletion logic from `api/tool_instances.py`
- The API router becomes thin: validates auth, calls service, returns response
- Service functions are async and receive `AsyncSession`, models, and raw parameters
4. **Config profile service extraction** (`apps/api/src/services/config_profiles.py`)
- Extract business logic from `api/config_profiles.py`: CRUD helpers, validation, default profile management
- API router delegates to service functions
5. **Auth dependency refactor** (`apps/api/src/auth/dependencies.py`)
- Add `get_current_user` dependency that returns a `User` model directly
- Update API routers to use `user: User = Depends(get_current_user)` where the full user object is needed
- Keep `get_current_user_id` for endpoints that only need the ID
6. **Frontend reorganization**
- Move components into `features/` directories by domain:
- `features/git/` — CommitDialog, FileBrowser, FileEditor, GitToolbar, MergeDialog, WorkspaceSidebar
- `features/dashboard/` — ActiveSessionsList, DashboardSummary, ProjectsSection, QuickCreateForm, RecentSessionsSection
- `features/project/` — RepositoriesSettingsTab
- `features/tool-workshop/` — ToolTypesTab (already exists)
- Rename API files from snake_case to kebab-case:
- `tool_types.ts``tool-types.ts`
- `ssh_keys.ts``ssh-keys.ts`
- `git_repositories.ts``git-repositories.ts`
- Rename page files to `*Page.tsx`:
- `dashboard.tsx``DashboardPage.tsx`
- `projects.tsx``ProjectsPage.tsx`
- etc.
### Out of Scope
- Any new features or behavioral changes
- Database schema changes (no migrations)
- API contract changes (same endpoints, same request/response shapes)
- Frontend UI behavior changes (same components, same interactions)
- Removing or modifying the `ConfigProfileInclude` model (already exists as a relation)
- Extracting `ConfigMount` into a separate model (current dev uses JSON arrays; this is a schema decision, not a refactor)
- Changes to `tool_definition_manifest` or `workspace` models
## Acceptance Criteria
1. All existing tests pass without modification (behavior unchanged)
2. All existing API endpoints return identical responses for identical requests
3. All frontend pages render identically
4. `docker compose up` starts successfully
5. Backend `ruff check` passes
6. Frontend `npm run typecheck` passes
7. Frontend `npm run build` passes
8. File sizes are reduced: no API router > 500 lines, no service > 400 lines
## Preconditions
- Current `dev` branch is stable and all behavioral forward-ports are complete
- All legacy `ToolConfig`/`ConfigFolder` code has been removed
- Database migrations are at a single head
@@ -0,0 +1,158 @@
## Phase 0: Submodule Infrastructure (Create Directories + __init__.py Files)
- [ ] 0.1 Create `apps/api/src/schemas/tool/__init__.py`
- [ ] 0.2 Create `apps/api/src/schemas/config/__init__.py`
- [ ] 0.3 Create `apps/api/src/schemas/user/__init__.py`
- [ ] 0.4 Create `apps/api/src/schemas/project/__init__.py`
- [ ] 0.5 Create `apps/api/src/schemas/system/__init__.py`
- [ ] 0.6 Create `apps/api/src/api/tool/__init__.py`
- [ ] 0.7 Create `apps/api/src/api/config/__init__.py`
- [ ] 0.8 Create `apps/api/src/api/workspace/__init__.py`
- [ ] 0.9 Create `apps/api/src/api/user/__init__.py`
- [ ] 0.10 Create `apps/api/src/api/project/__init__.py`
- [ ] 0.11 Create `apps/api/src/api/system/__init__.py`
- [ ] 0.12 Create `apps/api/src/services/instance/__init__.py`
- [ ] 0.13 Create `apps/api/src/services/config/__init__.py`
- [ ] 0.14 Create `apps/api/src/services/git/__init__.py`
- [ ] 0.15 Create `apps/api/src/services/build/__init__.py`
- [ ] 0.16 Create `apps/api/src/services/terminal/__init__.py`
- [ ] 0.17 Create `apps/api/src/services/shared/__init__.py`
- [ ] 0.18 Create `apps/api/src/models/tool/__init__.py`
- [ ] 0.19 Create `apps/api/src/models/config/__init__.py`
- [ ] 0.20 Create `apps/api/src/models/user/__init__.py`
- [ ] 0.21 Create `apps/api/src/models/project/__init__.py`
- [ ] 0.22 Create `apps/api/src/models/system/__init__.py`
## Phase 1: Model Subpackages
- [ ] 1.1 Move `models/tool_type.py``models/tool/tool_type.py`
- [ ] 1.2 Move `models/tool_instance.py``models/tool/tool_instance.py`
- [ ] 1.3 Move `models/tool_definition_manifest.py``models/tool/tool_definition_manifest.py`
- [ ] 1.4 Move `models/config_profile.py``models/config/config_profile.py`
- [ ] 1.5 Move `models/config_include.py` (if exists) → `models/config/config_include.py`
- [ ] 1.6 Move `models/config_mount.py` (if exists) → `models/config/config_mount.py`
- [ ] 1.7 Move `models/user.py``models/user/user.py`
- [ ] 1.8 Move `models/user_config.py``models/user/user_config.py`
- [ ] 1.9 Move `models/ssh_key.py``models/user/ssh_key.py`
- [ ] 1.10 Move `models/project.py``models/project/project.py`
- [ ] 1.11 Move `models/git_repository.py``models/project/git_repository.py`
- [ ] 1.12 Move `models/workspace.py``models/project/workspace.py`
- [ ] 1.13 Move `models/health_check.py``models/system/health_check.py`
- [ ] 1.14 Move `models/notification.py``models/system/notification.py`
- [ ] 1.15 Move `models/instance_event.py``models/system/instance_event.py`
- [ ] 1.16 Move `models/terminal_session.py``models/system/terminal_session.py`
- [ ] 1.17 Update `models/__init__.py` to import from subpackages
- [ ] 1.18 Update all backend imports to use `models.tool.tool_type` etc.
- [ ] 1.19 Verify `py_compile` and `ruff` pass
## Phase 2: Schema Extraction + Subpackages
- [ ] 2.1 Extract `schemas/tool/tool_type.py` from `api/tool_types.py`
- [ ] 2.2 Extract `schemas/tool/tool_instance.py` from `api/tool_instances.py`
- [ ] 2.3 Extract `schemas/config/config_profile.py` from `api/config_profiles.py`
- [ ] 2.4 Extract `schemas/system/health.py` from `api/health.py`
- [ ] 2.5 Extract `schemas/user/user.py` from `api/users.py`
- [ ] 2.6 Extract `schemas/user/user_config.py` from `api/user_config.py`
- [ ] 2.7 Extract `schemas/project/project.py` from `api/projects.py`
- [ ] 2.8 Extract `schemas/project/ssh_key.py` from `api/ssh_keys.py`
- [ ] 2.9 Extract `schemas/project/git_repository.py` from `api/git_repositories.py`
- [ ] 2.10 Update all API routers to import schemas from `src.schemas.*`
- [ ] 2.11 Verify `py_compile` and `ruff` pass
## Phase 3: Docker Service Package Split
- [ ] 3.1 Create `services/docker/__init__.py` with re-exports
- [ ] 3.2 Create `services/docker/compose.py` from `services/docker.py`
- [ ] 3.3 Create `services/docker/container.py` from `services/docker.py`
- [ ] 3.4 Create `services/docker/config_staging.py` from `services/docker.py`
- [ ] 3.5 Create `services/docker/tunnel.py` from `services/tunnel.py`
- [ ] 3.6 Remove `services/docker.py` after verifying imports
- [ ] 3.7 Update `services/tunnel.py` or remove if subsumed
- [ ] 3.8 Update all consumers to import from `services.docker`
- [ ] 3.9 Verify `py_compile` and `ruff` pass
## Phase 4: Service Subpackages
- [ ] 4.1 Move `services/lifecycle_hooks.py``services/instance/lifecycle_hooks.py`
- [ ] 4.2 Move `services/health_monitor.py``services/instance/health_monitor.py`
- [ ] 4.3 Move `services/event_bus.py``services/instance/event_bus.py`
- [ ] 4.4 Move `services/config_profile_resolver.py``services/config/config_profile_resolver.py`
- [ ] 4.5 Move `services/clone.py``services/git/clone.py`
- [ ] 4.6 Move `services/git_operations.py``services/git/git_operations.py`
- [ ] 4.7 Move `services/git_service.py``services/git/git_service.py`
- [ ] 4.8 Move `services/docker_build.py``services/build/docker_build.py`
- [ ] 4.9 Move `services/manifest_compiler.py``services/build/manifest_compiler.py`
- [ ] 4.10 Move `services/terminal_manager.py``services/terminal/terminal_manager.py`
- [ ] 4.11 Move `services/terminal_session.py``services/terminal/terminal_session.py`
- [ ] 4.12 Move `services/tunnel.py``services/shared/tunnel.py`
- [ ] 4.13 Move `services/notification_service.py``services/shared/notification_service.py`
- [ ] 4.14 Move `services/file_service.py``services/shared/file_service.py`
- [ ] 4.15 Move `services/permission_fixer.py``services/shared/permission_fixer.py`
- [ ] 4.16 Move `services/readiness_probe.py``services/shared/readiness_probe.py`
- [ ] 4.17 Move `services/ssh_keys.py``services/shared/ssh_keys.py`
- [ ] 4.18 Move `services/workspace_manager.py``services/shared/workspace_manager.py`
- [ ] 4.19 Move `services/correlation.py``services/shared/correlation.py`
- [ ] 4.20 Extract `services/instance/instance_lifecycle.py` from `api/tool_instances.py`
- [ ] 4.21 Extract `services/config/config_profiles.py` from `api/config_profiles.py`
- [ ] 4.22 Update all imports across the backend
- [ ] 4.23 Verify `py_compile` and `ruff` pass
## Phase 5: API Router Subpackages
- [ ] 5.1 Move `api/tool_instances.py``api/tool/tool_instances.py`
- [ ] 5.2 Move `api/tool_types.py``api/tool/tool_types.py`
- [ ] 5.3 Move `api/tool_definitions.py``api/tool/tool_definitions.py`
- [ ] 5.4 Move `api/tool_types_validation.py``api/tool/tool_types_validation.py`
- [ ] 5.5 Move sessions_router from `api/tool_instances.py``api/tool/sessions.py`
- [ ] 5.6 Move `api/config_profiles.py``api/config/config_profiles.py`
- [ ] 5.7 Move `api/user_config.py``api/config/user_config.py`
- [ ] 5.8 Move `api/workspaces.py``api/workspace/workspaces.py`
- [ ] 5.9 Move `api/workspace_files.py``api/workspace/workspace_files.py`
- [ ] 5.10 Move `api/workspace_git.py``api/workspace/workspace_git.py`
- [ ] 5.11 Move `api/workspace_instances.py``api/workspace/workspace_instances.py`
- [ ] 5.12 Move `api/users.py``api/user/users.py`
- [ ] 5.13 Move `api/auth.py``api/user/auth.py`
- [ ] 5.14 Move `api/ssh_keys.py``api/user/ssh_keys.py`
- [ ] 5.15 Move `api/projects.py``api/project/projects.py`
- [ ] 5.16 Move `api/git_repositories.py``api/project/git_repositories.py`
- [ ] 5.17 Move `api/health.py``api/system/health.py`
- [ ] 5.18 Move `api/events.py``api/system/events.py`
- [ ] 5.19 Move `api/notifications.py``api/system/notifications.py`
- [ ] 5.20 Move `api/dashboard.py``api/system/dashboard.py`
- [ ] 5.21 Move `api/terminal.py``api/system/terminal.py`
- [ ] 5.22 Move `api/instance_proxy.py``api/system/instance_proxy.py`
- [ ] 5.23 Update `main.py` to import from subpackages
- [ ] 5.24 Update all cross-router imports
- [ ] 5.25 Verify `py_compile` and `ruff` pass
## Phase 6: Auth Dependency Refactor
- [ ] 6.1 Add `get_current_user` to `auth/dependencies.py`
- [ ] 6.2 Migrate `api/user/users.py` to use `get_current_user`
- [ ] 6.3 Migrate `api/tool/sessions.py` to use `get_current_user`
- [ ] 6.4 Migrate other routers incrementally
- [ ] 6.5 Verify `py_compile` and `ruff` pass
## Phase 7: Frontend Reorganization
- [ ] 7.1 Rename API files to kebab-case
- [ ] 7.2 Rename page files to `*Page.tsx`
- [ ] 7.3 Move components into `features/` directories
- [ ] 7.4 Update `router.tsx`
- [ ] 7.5 Verify `npm run typecheck` passes
- [ ] 7.6 Verify `npm run build` passes
## Phase 8: Integration and Verification
- [ ] 8.1 Run backend tests: `docker exec hq-api pytest`
- [ ] 8.2 Run backend lint: `ruff check`
- [ ] 8.3 Run frontend typecheck: `npm run typecheck`
- [ ] 8.4 Run frontend build: `npm run build`
- [ ] 8.5 Run `docker compose up --build` and verify API starts
- [ ] 8.6 Verify key user flows manually
- [ ] 8.7 Verify no 404s or import errors in browser console
## Phase 9: Documentation
- [ ] 9.1 Update `AGENTS.md` with new module structure
- [ ] 9.2 Document `get_current_user` vs `get_current_user_id` pattern
+30
View File
@@ -0,0 +1,30 @@
# Progress
## Status
In Progress — Backend structural refactoring
## Tasks
- [x] Phase 0: Create submodule directory structure (__init__.py files)
- [x] Phase 1: Model subpackages (tool, config, user, project, system)
- [x] Phase 2: Docker service package split (compose, container, config_staging, tunnel)
- [ ] Phase 3: Service subpackages (instance, config, git, build, terminal, shared)
- [ ] Phase 4: Schema extraction (tool, config, user, project, system)
- [ ] Phase 5: API router subpackages (tool, config, workspace, user, project, system)
- [ ] Phase 6: Auth dependency refactor
- [ ] Phase 7: Frontend reorganization
- [ ] Phase 8: Integration and verification
## Files Changed
- `apps/api/src/services/docker.py` → deleted (split into package)
- `apps/api/src/services/docker/__init__.py` — re-exports all public functions
- `apps/api/src/services/docker/compose.py` — compose generation & commands
- `apps/api/src/services/docker/container.py` — container runtime queries
- `apps/api/src/services/docker/config_staging.py` — file staging into instances
- `apps/api/src/services/docker/tunnel.py` — cloudflared tunnel lifecycle
- `apps/api/src/services/tunnel.py` — thin backward-compat re-export wrapper
## Notes
- All backward-compatible imports preserved via __init__.py re-exports
- `from src.services.docker import X` continues to work for all previously exported symbols
- `from src.services.tunnel import X` continues to work via re-export wrapper
- No behavior changes, pure structural refactor