From fc72c5f6e9e69ebedbcd24fdb0e3e0bb2c04c8f4 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 3 Jun 2026 14:24:22 +0200 Subject: [PATCH 1/2] fix: resolve mapper configuration for ToolDefinitionManifest and Workspace Move ToolDefinitionManifest import out of TYPE_CHECKING in tool_type.py so SQLAlchemy can resolve the string-annotated relationship during mapper configuration. Add Workspace to models/__init__.py (before ToolInstance) so the ToolInstance-Workspace relationship can be resolved. Quality gates: py_compile passed, ruff passed, all mappers configure OK. --- apps/api/src/models/__init__.py | 2 ++ apps/api/src/models/tool_type.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index 0ea612d..49a69a4 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -12,6 +12,7 @@ from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType from src.models.user import User from src.models.user_config import UserConfig +from src.models.workspace import Workspace __all__ = [ "Base", @@ -29,4 +30,5 @@ __all__ = [ "ToolType", "User", "UserConfig", + "Workspace", ] diff --git a/apps/api/src/models/tool_type.py b/apps/api/src/models/tool_type.py index 24b93dc..1d7b6a3 100644 --- a/apps/api/src/models/tool_type.py +++ b/apps/api/src/models/tool_type.py @@ -7,8 +7,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin +from src.models.tool_definition_manifest import ToolDefinitionManifest + if TYPE_CHECKING: - from src.models.tool_definition_manifest import ToolDefinitionManifest from src.models.user import User From 0c74997cfe0dbb8230abf22d6d9984f7013edda6 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 3 Jun 2026 22:23:19 +0200 Subject: [PATCH 2/2] 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. --- .../versions/0013_add_config_profiles.py | 204 ++++++++++++++++++ .../0014_add_profile_resolver_fields.py | 180 ++++++++++++++++ ..._merge_profile_resolver_and_workspaces_.py | 20 ++ 3 files changed, 404 insertions(+) create mode 100644 apps/api/alembic/versions/0013_add_config_profiles.py create mode 100644 apps/api/alembic/versions/0014_add_profile_resolver_fields.py create mode 100644 apps/api/alembic/versions/86cec91fdb00_merge_profile_resolver_and_workspaces_.py diff --git a/apps/api/alembic/versions/0013_add_config_profiles.py b/apps/api/alembic/versions/0013_add_config_profiles.py new file mode 100644 index 0000000..bf0262d --- /dev/null +++ b/apps/api/alembic/versions/0013_add_config_profiles.py @@ -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") diff --git a/apps/api/alembic/versions/0014_add_profile_resolver_fields.py b/apps/api/alembic/versions/0014_add_profile_resolver_fields.py new file mode 100644 index 0000000..5302c5e --- /dev/null +++ b/apps/api/alembic/versions/0014_add_profile_resolver_fields.py @@ -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") diff --git a/apps/api/alembic/versions/86cec91fdb00_merge_profile_resolver_and_workspaces_.py b/apps/api/alembic/versions/86cec91fdb00_merge_profile_resolver_and_workspaces_.py new file mode 100644 index 0000000..88163e6 --- /dev/null +++ b/apps/api/alembic/versions/86cec91fdb00_merge_profile_resolver_and_workspaces_.py @@ -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