Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Blank c051929f8c fix: use host bind mount for repos so tool instances can access workspace files
Replace named Docker volume (repo_data) with bind mount (/data/repos) in both
development and production compose files. The named volume trapped repo files
inside the API container; tool instances started via Docker socket on the host
could not see them, causing /workspace to mount as an empty directory.

Also fix 6 pre-existing test failures in test_tool_instances_legacy.py caused
by get_container_id/get_container_name moving to docker.py and new helpers
(_ensure_web_bind_address, _ensure_container_name_in_compose) being added.

- docker-compose.yml: repo_data:/data/repos -> /data/repos:/data/repos
- docker-compose.traefik.yml: same change + remove repo_data volume decl
- tests: update patch targets and add missing mock parameters

Quality gates: pytest test_tool_instances_legacy.py (10 passed)
2026-06-02 12:47:14 +02:00
143 changed files with 2074 additions and 13393 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812" "fingerprint": "fdea8a74bb4c7449c01c4bd61646c895b10ede78"
} }
+2 -1
View File
@@ -2,7 +2,7 @@
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. --> <!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
Last updated: 2026-06-02 Last updated: 2026-05-28
## Sources scanned ## Sources scanned
@@ -21,6 +21,7 @@ Last updated: 2026-06-02
| Skill | Trigger / description | Scope | Path | | Skill | Trigger / description | Scope | Path |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` | | `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
| `openspec` | Use OpenSpec as the source of truth for planning, implementation, verification, and archive discipline. | user | `/home/alex/.config/opencode/skills/openspec/SKILL.md` |
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` | | `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` | | `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` | | `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
-1
View File
@@ -75,7 +75,6 @@ Do not:
* Introduce new dependencies without clear justification. * Introduce new dependencies without clear justification.
* Treat existing code as more authoritative than OpenSpec for intended behavior. * Treat existing code as more authoritative than OpenSpec for intended behavior.
* Decide product behavior silently when the spec is unclear. * Decide product behavior silently when the spec is unclear.
* Run `docker compose` commands (build, up, down, etc.) without explicit user approval and proper isolation (e.g., feature branches, separate worktrees, or staged rollouts). Docker Compose operations are deployment-level changes that can affect running services, shared volumes, and network state. Always ask first.
If scope must change, propose an OpenSpec update first. If scope must change, propose an OpenSpec update first.
-42
View File
@@ -1,42 +0,0 @@
# 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/
+2 -2
View File
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
# Copy application code # Copy application code
COPY --chown=appuser:appgroup . . COPY --chown=appuser:appgroup . .
# Create directories for repo, instance, and workspace storage # Create directories for repo and instance storage
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
# Copy wait-for-db script # Copy wait-for-db script
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
@@ -1,204 +0,0 @@
"""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")
@@ -1,180 +0,0 @@
"""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")
@@ -1,81 +0,0 @@
"""add workspaces table
Revision ID: 2026_06_01_add_workspaces
Revises: 2026_05_29_fix_code_server_bind_addr_port
Create Date: 2026-06-01 10:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_06_01_add_workspaces"
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr_port"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Create workspaces table
op.create_table(
"workspaces",
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column(
"repo_id",
sa.Uuid(as_uuid=True),
sa.ForeignKey("git_repositories.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Uuid(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("branch", sa.String(255), nullable=False, server_default="main"),
sa.Column("path", sa.String(2048), nullable=False),
sa.Column("status", sa.String(16), nullable=False, server_default="ready"),
sa.Column("last_sync_at", sa.DateTime(timezone=True), 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.UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
if_not_exists=True,
)
op.create_index("idx_workspaces_repo_id", "workspaces", ["repo_id"])
op.create_index("idx_workspaces_user_id", "workspaces", ["user_id"])
op.create_index("idx_workspaces_status", "workspaces", ["status"])
# Add workspace_id to tool_instances
op.add_column(
"tool_instances",
sa.Column(
"workspace_id",
sa.Uuid(as_uuid=True),
sa.ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"idx_tool_instances_workspace_id", "tool_instances", ["workspace_id"]
)
def downgrade() -> None:
op.drop_index("idx_tool_instances_workspace_id", table_name="tool_instances")
op.drop_column("tool_instances", "workspace_id")
op.drop_table("workspaces")
@@ -1,20 +0,0 @@
"""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
@@ -1 +0,0 @@
"""Config module."""
+3 -103
View File
@@ -14,10 +14,9 @@ from sqlalchemy.orm import selectinload
from src.api.shared_validators import validate_env_vars as _validate_env_vars 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 import ConfigProfile, ConfigProfileInclude from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.project import Project from src.models.project import Project
from src.models import ToolType from src.models.tool_type import ToolType
from src.models import UserConfig
from src.services.config_profile_resolver import ( from src.services.config_profile_resolver import (
ConfigProfileCycleError, ConfigProfileCycleError,
check_include_cycle, check_include_cycle,
@@ -841,105 +840,6 @@ 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}
# ---------------------------------------------------------------------------
# Default profile management
# ---------------------------------------------------------------------------
class DefaultProfilesUpdate(BaseModel):
default_profiles: dict[str, str] = Field(
description="Mapping of tool_type_id -> profile_id for default profiles"
)
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}",
)
@router.get("/defaults")
async def get_default_profiles_endpoint(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get all default profile mappings for the current user."""
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}
class ValidateGitUrlRequest(BaseModel): class ValidateGitUrlRequest(BaseModel):
url: str = Field(description="Git remote URL to validate") url: str = Field(description="Git remote URL to validate")
ssh_key_id: str | None = Field( ssh_key_id: str | None = Field(
@@ -991,7 +891,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 import SSHKey from src.models.ssh_key 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 import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models import SSHKey from src.models.ssh_key import SSHKey
router = APIRouter(prefix="/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/dashboard", tags=["dashboard"])
+1 -1
View File
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/events", tags=["events"])
# In-memory connection counter per user (single-process assumption) # In-memory connection counter per user (single-process assumption)
_connection_counts: dict[uuid.UUID, int] = {} _connection_counts: dict[uuid.UUID, int] = {}
MAX_CONNECTIONS_PER_USER = 20 MAX_CONNECTIONS_PER_USER = 5
@router.get("/stream") @router.get("/stream")
+81 -277
View File
@@ -10,15 +10,10 @@ 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 ( from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
_get_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.config import Settings from src.config import Settings
from src.models import GitRepository from src.models.git_repository import GitRepository
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.utils.git_files import ( from src.utils.git_files import (
commit_file, commit_file,
get_file_content, get_file_content,
@@ -95,9 +90,7 @@ def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
return env, key_path return env, key_path
def _preflight_remote_repository( def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
remote_url: str, ssh_key: SSHKey | None = None
) -> None:
"""Verify a remote repository is reachable before cloning.""" """Verify a remote repository is reachable before cloning."""
env = None env = None
key_path = None key_path = None
@@ -116,32 +109,22 @@ def _preflight_remote_repository(
env={**os.environ, **env} if env else None, env={**os.environ, **env} if env else None,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
status_code=status.HTTP_400_BAD_REQUEST,
detail="remote repository check timed out",
)
except FileNotFoundError: except FileNotFoundError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
finally: finally:
if key_path and os.path.exists(key_path): if key_path and os.path.exists(key_path):
os.unlink(key_path) os.unlink(key_path)
if result.returncode != 0: if result.returncode != 0:
logger.error( logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
"Preflight check failed for %s: stderr=%s", remote_url, result.stderr
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"repository not found or inaccessible: {result.stderr}", detail=f"repository not found or inaccessible: {result.stderr}",
) )
def _clone_working_repository( def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
remote_url: str, repo_path: str, ssh_key: SSHKey | None = None
) -> None:
env = None env = None
key_path = None key_path = None
@@ -159,14 +142,9 @@ def _clone_working_repository(
env={**os.environ, **env} if env else None, env={**os.environ, **env} if env else None,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out"
)
except FileNotFoundError: except FileNotFoundError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
finally: finally:
if key_path and os.path.exists(key_path): if key_path and os.path.exists(key_path):
os.unlink(key_path) os.unlink(key_path)
@@ -187,10 +165,7 @@ def _init_working_repository(repo_path: str) -> None:
text=True, text=True,
) )
except FileNotFoundError: except FileNotFoundError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
if result.returncode == 0: if result.returncode == 0:
return return
@@ -335,10 +310,7 @@ async def create_external_repository(
) )
) )
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository name already exists",
)
# Validate and potentially correct the URL # Validate and potentially correct the URL
remote_url = data.remote_url remote_url = data.remote_url
@@ -364,21 +336,13 @@ async def create_external_repository(
try: try:
ssh_key_id = uuid.UUID(data.ssh_key_id) ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid ssh_key_id format",
)
ssh_key = await session.get(SSHKey, ssh_key_id) ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None: if ssh_key is None:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
if ssh_key.user_id != user_id: if ssh_key.user_id != user_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
status_code=status.HTTP_403_FORBIDDEN,
detail="ssh key does not belong to user",
)
if remote_url: if remote_url:
_preflight_remote_repository(remote_url, ssh_key) _preflight_remote_repository(remote_url, ssh_key)
@@ -405,10 +369,7 @@ async def create_external_repository(
repo.is_mirror = False repo.is_mirror = False
except Exception as exc: except Exception as exc:
await session.rollback() await session.rollback()
raise HTTPException( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clone repository: {exc}",
)
else: else:
# Initialize empty repo # Initialize empty repo
os.makedirs(repo_path, exist_ok=True) os.makedirs(repo_path, exist_ok=True)
@@ -477,9 +438,7 @@ async def delete_repository(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
# Remove from disk # Remove from disk
if os.path.exists(repo.path): if os.path.exists(repo.path):
@@ -525,10 +484,7 @@ async def create_repository(
) )
) )
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository name already exists",
)
# Validate and potentially correct the URL # Validate and potentially correct the URL
remote_url = data.remote_url remote_url = data.remote_url
@@ -555,21 +511,13 @@ async def create_repository(
try: try:
ssh_key_id = uuid.UUID(data.ssh_key_id) ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid ssh_key_id format",
)
ssh_key = await session.get(SSHKey, ssh_key_id) ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None: if ssh_key is None:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
if ssh_key.user_id != user_id and ssh_key.project_id != project_id: if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
status_code=status.HTTP_403_FORBIDDEN,
detail="ssh key does not belong to user or project",
)
if remote_url: if remote_url:
_preflight_remote_repository(remote_url, ssh_key) _preflight_remote_repository(remote_url, ssh_key)
@@ -633,30 +581,20 @@ async def update_repository_ssh_key(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
# Validate SSH key if provided # Validate SSH key if provided
if data.ssh_key_id: if data.ssh_key_id:
try: try:
ssh_key_id = uuid.UUID(data.ssh_key_id) ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid ssh_key_id format",
)
ssh_key = await session.get(SSHKey, ssh_key_id) ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None: if ssh_key is None:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
if ssh_key.user_id != user_id and ssh_key.project_id != project_id: if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
status_code=status.HTTP_403_FORBIDDEN,
detail="ssh key does not belong to user or project",
)
repo.ssh_key_id = ssh_key_id repo.ssh_key_id = ssh_key_id
else: else:
@@ -702,24 +640,16 @@ async def get_repository_history(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
history = get_commit_history( history = get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
repo.path, branch=branch, limit=limit, offset=offset
)
return history return history
except RuntimeError as e: except RuntimeError as e:
raise HTTPException( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
)
@router.get( @router.get(
@@ -751,14 +681,10 @@ async def get_repository_commit(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
detail = get_commit_detail(repo.path, commit_hash) detail = get_commit_detail(repo.path, commit_hash)
@@ -837,14 +763,10 @@ async def list_repository_files(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
entries = list_tree(repo.path, branch=branch, path=path) entries = list_tree(repo.path, branch=branch, path=path)
@@ -907,14 +829,10 @@ async def get_repository_file_content(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
file_content = get_file_content(repo.path, branch=branch, path=path) file_content = get_file_content(repo.path, branch=branch, path=path)
@@ -929,9 +847,7 @@ async def get_repository_file_content(
last_commit=file_content.last_commit, last_commit=file_content.last_commit,
) )
except FileNotFoundError: except FileNotFoundError:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
status_code=status.HTTP_404_NOT_FOUND, detail="file not found"
)
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@@ -964,104 +880,32 @@ async def get_repository_branches(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
try:
branches, default_branch = list_branches(repo.path)
return BranchesResponse(
branches=[
{
"name": b.name,
"is_default": b.is_default,
"last_commit": b.last_commit,
}
for b in branches
],
default_branch=default_branch,
) )
except RuntimeError as e:
# Try local repo first (.git subdir for normal repos, HEAD for bare) logger.error(
is_valid_git_repo = os.path.isdir( "Failed to list branches for repo %s: %s",
os.path.join(repo.path, ".git") repo_id,
) or os.path.isfile(os.path.join(repo.path, "HEAD")) str(e),
exc_info=True,
if is_valid_git_repo: )
try: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
branches, default_branch = list_branches(repo.path)
return BranchesResponse(
branches=[
{
"name": b.name,
"is_default": b.is_default,
"last_commit": b.last_commit,
}
for b in branches
],
default_branch=default_branch,
)
except RuntimeError as e:
logger.error(
"Failed to list branches for repo %s: %s",
repo_id,
str(e),
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
) from e
# Local repo missing/corrupt — try remote if available
if repo.remote_url:
ssh_key = None
if repo.ssh_key_id:
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
ssh_result = _prepare_ssh_env(ssh_key)
env = None
key_path = None
if ssh_result:
env, key_path = ssh_result
try:
result = subprocess.run(
["git", "ls-remote", "--heads", repo.remote_url],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **env} if env else None,
)
if result.returncode == 0:
remote_branches = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if line:
parts = line.split("\t")
if len(parts) == 2:
ref = parts[1]
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/") :]
remote_branches.append(branch_name)
if branch_name in ("main", "master"):
default_branch = branch_name
if remote_branches:
return BranchesResponse(
branches=[
{
"name": b,
"is_default": b == default_branch,
"last_commit": None,
}
for b in remote_branches
],
default_branch=default_branch,
)
else:
logger.warning(
"ls-remote returned %d for repo %s: %s",
result.returncode,
repo_id,
result.stderr,
)
except subprocess.TimeoutExpired:
logger.warning("ls-remote timed out for repo %s", repo_id)
except Exception as e:
logger.warning("ls-remote failed for repo %s: %s", repo_id, str(e))
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="repository not found on disk — re-clone or re-create the repository",
)
@router.post( @router.post(
@@ -1094,14 +938,10 @@ async def update_repository_file(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
# Get user info for commit # Get user info for commit
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
@@ -1169,14 +1009,10 @@ async def get_repository_status(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
status_result = get_status(repo.path) status_result = get_status(repo.path)
@@ -1232,14 +1068,10 @@ async def create_repository_branch(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
create_branch(repo.path, data.name, data.base_branch) create_branch(repo.path, data.name, data.base_branch)
@@ -1279,14 +1111,10 @@ async def delete_repository_branch(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
delete_branch(repo.path, branch_name, force) delete_branch(repo.path, branch_name, force)
@@ -1324,14 +1152,10 @@ async def checkout_repository_branch(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
checkout_branch(repo.path, data.branch) checkout_branch(repo.path, data.branch)
@@ -1380,14 +1204,10 @@ async def commit_repository_changes(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
# Get user info for commit # Get user info for commit
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
@@ -1442,14 +1262,10 @@ async def fetch_repository(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
fetch(repo.path) fetch(repo.path)
@@ -1492,14 +1308,10 @@ async def pull_repository(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
pull(repo.path, branch) pull(repo.path, branch)
@@ -1542,14 +1354,10 @@ async def push_repository(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
push(repo.path, branch) push(repo.path, branch)
@@ -1599,14 +1407,10 @@ async def merge_repository_branches(
repo = await session.get(GitRepository, repo_id) repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id: if repo is None or repo.project_id != project_id:
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path): if not os.path.exists(repo.path):
raise HTTPException( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try: try:
commit_hash = merge( commit_hash = merge(
+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 import ToolInstance from src.models.tool_instance import ToolInstance
from src.models import ToolType from src.models.tool_type 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 import UserConfig from src.models.user_config 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
@@ -1 +0,0 @@
"""Project module."""
+16 -75
View File
@@ -4,19 +4,13 @@ import uuid
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, ConfigDict
from sqlalchemy import func, select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import ( from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
_get_owned_project, from src.models.git_repository import GitRepository
_get_user,
get_current_user_id,
get_db_session,
)
from src.models import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.models import ToolInstance
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
@@ -82,77 +76,26 @@ async def create_project(
@router.get( @router.get(
"", "",
response_model=list[ProjectResponse],
summary="List all projects", summary="List all projects",
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.", description="Retrieve all projects owned by the authenticated user.",
) )
async def list_projects( async def list_projects(
user_id: uuid.UUID = Depends(get_current_user_id), user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> list[dict]: ) -> list[Project]:
"""List all projects for the authenticated user. """List all projects for the authenticated user.
Returns projects with nested repositories and workspaces for inline display. Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of projects owned by the user.
""" """
user = await _get_user(session, user_id) user = await _get_user(session, user_id)
result = await session.execute( result = await session.execute(select(Project).where(Project.owner_id == user.id))
select(Project) return list(result.scalars().all())
.where(Project.owner_id == user.id)
.order_by(Project.created_at.desc())
)
projects = result.scalars().all()
from src.models import Workspace
enriched = []
for project in projects:
repos_result = await session.execute(
select(GitRepository).where(GitRepository.project_id == project.id)
)
repositories = []
for repo in repos_result.scalars().all():
ws_result = await session.execute(
select(Workspace).where(Workspace.repo_id == repo.id)
)
workspaces = []
for ws in ws_result.scalars().all():
# Count instances
inst_result = await session.execute(
select(func.count()).where(ToolInstance.workspace_id == ws.id)
)
instance_count = inst_result.scalar() or 0
workspaces.append(
{
"id": str(ws.id),
"name": ws.name,
"branch": ws.branch,
"status": ws.status,
"instance_count": instance_count,
}
)
repositories.append(
{
"id": str(repo.id),
"name": repo.name,
"remote_url": repo.remote_url,
"workspaces": workspaces,
}
)
enriched.append(
{
"id": str(project.id),
"name": project.name,
"description": project.description,
"owner_id": str(project.owner_id),
"repositories": repositories,
"created_at": project.created_at.isoformat()
if project.created_at
else None,
}
)
return enriched
@router.get( @router.get(
@@ -241,9 +184,7 @@ async def delete_project(
project = await _get_owned_project(project_id, user_id, session) project = await _get_owned_project(project_id, user_id, session)
# Delete repositories from disk and database # Delete repositories from disk and database
result = await session.execute( result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
select(GitRepository).where(GitRepository.project_id == project_id)
)
repositories = result.scalars().all() repositories = result.scalars().all()
for repo in repositories: for repo in repositories:
if os.path.exists(repo.path): if os.path.exists(repo.path):
+1 -1
View File
@@ -12,7 +12,7 @@ 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 import SSHKey from src.models.ssh_key import SSHKey
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
-1
View File
@@ -1 +0,0 @@
"""System module."""
+28 -9
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 import TerminalSessionModel from src.models.terminal_session import TerminalSessionModel
from src.models import ToolInstance from src.models.tool_instance import ToolInstance
from src.models import ToolType from src.models.tool_type import ToolType
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
router = APIRouter() router = APIRouter()
@@ -222,7 +222,8 @@ async def _handle_terminal_websocket(
# Use mutable session reference so loops can survive reset # Use mutable session reference so loops can survive reset
session_ref = SessionRef(session, slot_session_id) session_ref = SessionRef(session, slot_session_id)
# Start write loop and heartbeat (read is now event-driven in TerminalSession) # Start I/O loops and heartbeat
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
write_task = asyncio.create_task( write_task = asyncio.create_task(
_write_loop(session_ref, websocket, instance_id) _write_loop(session_ref, websocket, instance_id)
) )
@@ -231,7 +232,7 @@ async def _handle_terminal_websocket(
# Wait for either task to complete (indicating disconnect or error) # Wait for either task to complete (indicating disconnect or error)
done, pending = await asyncio.wait( done, pending = await asyncio.wait(
[write_task, heartbeat_task], [read_task, write_task, heartbeat_task],
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
) )
@@ -266,6 +267,28 @@ async def _handle_terminal_websocket(
) )
async def _read_loop(session_ref: SessionRef, websocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
while True:
session = session_ref.session
if not session.is_alive() or session._closed:
await asyncio.sleep(0.1)
continue
data = await session.read_output()
if data:
try:
await websocket.send_bytes(data)
except WebSocketDisconnect:
break
except Exception:
break
else:
await asyncio.sleep(0.01)
except Exception:
pass
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None: async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
"""Read input from WebSocket and send to container.""" """Read input from WebSocket and send to container."""
try: try:
@@ -296,10 +319,6 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
rows, rows,
) )
await session.resize(cols, rows) await session.resize(cols, rows)
elif msg_type == "ack":
char_count = ctrl.get("chars", 0)
if char_count > 0:
session.acknowledge_data(char_count)
elif msg_type == "reset": elif msg_type == "reset":
# Reset terminal session (scoped to current slot) # Reset terminal session (scoped to current slot)
logger.debug( logger.debug(
-1
View File
@@ -1 +0,0 @@
"""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 import ToolDefinitionManifest from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models import ToolType from src.models.tool_type import ToolType
from src.services.manifest_compiler import ( from src.services.manifest_compiler import (
compile_compose, compile_compose,
compile_dockerfile, compile_dockerfile,
+152 -346
View File
@@ -30,12 +30,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 import ConfigProfile from src.models.config_profile import ConfigProfile
from src.models import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.models import ToolInstance from src.models.tool_instance import ToolInstance
from src.models import ToolType from src.models.tool_type 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,
@@ -45,29 +45,24 @@ from src.services.config_profile_resolver import (
resolve_profile, resolve_profile,
) )
from src.services.docker import ( from src.services.docker import (
check_tunnel_health,
connect_container_to_network, connect_container_to_network,
ensure_instance_directory, ensure_instance_directory,
execute_compose_command, execute_compose_command,
find_free_port, find_free_port,
get_backend_network_name,
get_container_id, get_container_id,
get_container_ip_on_network,
get_container_logs, get_container_logs,
get_container_status, get_container_status,
is_container_on_network, recreate_tunnel,
render_compose_template, render_compose_template,
sort_volumes_by_specificity, sort_volumes_by_specificity,
start_cloudflared_tunnel,
stop_cloudflared_tunnel,
wait_for_container_running, wait_for_container_running,
write_compose_file, write_compose_file,
write_config_files, write_config_files,
write_env_file, write_env_file,
) )
from src.services.tunnel import (
check_tunnel_health,
recreate_tunnel,
start_tunnel,
stop_tunnel,
)
from src.services.docker_build import build_image from src.services.docker_build import build_image
from src.services.manifest_compiler import ( from src.services.manifest_compiler import (
compile_compose, compile_compose,
@@ -427,9 +422,6 @@ class CreateInstanceRequest(BaseModel):
display_name: str | None = Field( display_name: str | None = Field(
default=None, description="Optional display name for the instance" 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( clone_mode: str = Field(
default="mount", description="Repository access mode: 'mount' or 'clone'" default="mount", description="Repository access mode: 'mount' or 'clone'"
) )
@@ -756,49 +748,6 @@ def _ensure_web_bind_address(
return return
def _ensure_backend_network_in_compose(compose_path: str) -> None:
"""Inject the backend network into the compose file so compose up attaches it.
Instead of running 'docker network connect' after container creation (which
is prone to race conditions and silent failures), we declare the network in
the compose file itself. Docker Compose then connects the container to the
network atomically during 'docker compose up'.
"""
import yaml
from pathlib import Path
compose_file = Path(compose_path)
if not compose_file.exists():
return
content = compose_file.read_text()
compose_data = yaml.safe_load(content)
if not compose_data or "services" not in compose_data:
return
network_name = get_backend_network_name()
modified = False
for svc_config in compose_data["services"].values():
existing = svc_config.get("networks", [])
if network_name not in existing:
svc_config["networks"] = existing + [network_name]
modified = True
break # Only modify first service
# Declare the network as external at the top level
if "networks" not in compose_data:
compose_data["networks"] = {}
if network_name not in compose_data["networks"]:
compose_data["networks"][network_name] = {"external": True}
modified = True
if modified:
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
logger.info("Injected backend network '%s' into compose file", network_name)
@router.post( @router.post(
"/{project_id}/repositories/{repo_id}/instances", "/{project_id}/repositories/{repo_id}/instances",
summary="Create tool instance", summary="Create tool instance",
@@ -852,34 +801,9 @@ async def create_instance(
session, data.config_profile_id, user_id, project_id, tool_type_id session, data.config_profile_id, user_id, project_id, tool_type_id
) )
# Resolve workspace if provided
workspace = None
workspace_id = None
if data.workspace_id:
from src.models import Workspace as WorkspaceModel
try:
workspace_id = uuid.UUID(data.workspace_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid workspace_id format",
)
workspace = await session.get(WorkspaceModel, workspace_id)
if workspace is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="workspace not found",
)
if workspace.repo_id != repo_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="workspace does not belong to this repository",
)
try: try:
# Validate clone mode requirements (legacy path) # Validate clone mode requirements
if data.clone_mode == "clone" and not workspace: if data.clone_mode == "clone":
if not repo.remote_url: if not repo.remote_url:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -893,25 +817,9 @@ 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 = (
# Auto-generate display name with numbering when duplicates exist data.display_name or f"{tool_type.display_name} - {repo.name}"
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)
@@ -920,10 +828,8 @@ async def create_instance(
# Find free port # Find free port
tool_port = find_free_port() tool_port = find_free_port()
# Determine repo path based on workspace or clone mode # Determine repo path based on clone mode
if workspace: if data.clone_mode == "clone":
repo_path = workspace.path
elif data.clone_mode == "clone":
# Get SSH key for cloning # Get SSH key for cloning
ssh_key = await session.get(SSHKey, repo.ssh_key_id) ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key is None: if ssh_key is None:
@@ -1055,8 +961,8 @@ services:
write_compose_file(instance_dir, compose_content) write_compose_file(instance_dir, compose_content)
elif tool_type.definition_type == "manifest": elif tool_type.definition_type == "manifest":
# Manifest-based: generate compose only; image built lazily on start # Manifest-based: build image and generate compose
from src.models import ToolDefinitionManifest from src.models.tool_definition_manifest import ToolDefinitionManifest
manifest_def = await session.get( manifest_def = await session.get(
ToolDefinitionManifest, tool_type.manifest_id ToolDefinitionManifest, tool_type.manifest_id
@@ -1077,8 +983,44 @@ services:
deep_merge(dict(base_def.manifest), manifest) deep_merge(dict(base_def.manifest), manifest)
) )
# Determine home directory for path expansion
_home_dir = get_manifest_home_dir(manifest)
image_tag = compute_image_tag(tool_type.name, manifest) image_tag = compute_image_tag(tool_type.name, manifest)
# Build image during creation so start is fast
dockerfile = compile_dockerfile(manifest)
entrypoint = compile_entrypoint(manifest)
build_ctx = {
"Dockerfile": dockerfile,
".headquarter/entrypoint.sh": entrypoint,
}
returncode, stdout, stderr = await asyncio.to_thread(
build_image,
instance_dir=instance_dir,
dockerfile=dockerfile,
tag=image_tag,
build_context=build_ctx,
)
if returncode != 0:
logger.error(
"Failed to build image for manifest instance %s: %s",
instance_name,
stderr,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to build Docker image: {stderr[:500]}",
)
logger.info(
"Built manifest image %s for instance %s",
image_tag,
instance_name,
)
variables = { variables = {
"IMAGE_TAG": image_tag, "IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance_name.lower(), "INSTANCE_NAME": instance_name.lower(),
@@ -1153,7 +1095,6 @@ services:
status="pending", status="pending",
compose_path=compose_path, compose_path=compose_path,
port=tool_port, port=tool_port,
workspace_id=workspace_id,
clone_mode=data.clone_mode, clone_mode=data.clone_mode,
branch=data.new_branch branch=data.new_branch
if data.new_branch if data.new_branch
@@ -1341,7 +1282,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 import ToolDefinitionManifest from src.models.tool_definition_manifest 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)
@@ -1446,18 +1387,6 @@ async def _prepare_manifest_instance(
compose_content = compile_compose(manifest, variables) compose_content = compile_compose(manifest, variables)
logger.debug(
"_prepare_manifest_instance for %s: repo_path=%s compose_volumes=%s",
instance.id,
repo_path or "<empty>",
manifest.get("mounts", []),
)
logger.debug(
"Generated compose for %s:\n%s",
instance.id,
compose_content,
)
# Cache # Cache
instance.image_tag = image_tag instance.image_tag = image_tag
instance.manifest_compiled_at = datetime.now() instance.manifest_compiled_at = datetime.now()
@@ -1537,7 +1466,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 import ToolDefinitionManifest from src.models.tool_definition_manifest 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:
@@ -1627,14 +1556,38 @@ async def start_instance(
# Mount selected SSH keys into container home dir # Mount selected SSH keys into container home dir
if instance.ssh_key_ids: if instance.ssh_key_ids:
from src.services.ssh_keys import write_ssh_config, _sanitize_filename
# Collect all valid keys first
ssh_keys_to_mount = []
for key_id in instance.ssh_key_ids: for key_id in instance.ssh_key_ids:
ssh_key = await session.get(SSHKey, uuid.UUID(key_id)) ssh_key = await session.get(SSHKey, uuid.UUID(key_id))
if ssh_key and ssh_key.user_id == user_id: if ssh_key and ssh_key.user_id == user_id:
ssh_keys_to_mount.append(ssh_key) try:
ssh_dir = prepare_ssh_key_files(
instance_dir,
ssh_key,
subdir=f"mounts/ssh/{key_id}/.ssh",
uid=container_uid,
gid=container_gid,
)
ssh_target = os.path.join(home_dir, ".ssh")
extra_volumes.append(
{
"source": ssh_dir,
"target": ssh_target,
"type": "bind",
}
)
logger.debug(
"Mounted SSH key %s for instance %s to %s",
ssh_key.name,
instance.id,
ssh_target,
)
except Exception as exc:
logger.error(
"Failed to prepare SSH key %s for instance %s: %s",
key_id,
instance.id,
exc,
)
else: else:
logger.warning( logger.warning(
"SSH key %s not found or not authorized for user %s", "SSH key %s not found or not authorized for user %s",
@@ -1642,98 +1595,17 @@ async def start_instance(
user_id, user_id,
) )
if ssh_keys_to_mount:
# Use a single shared .ssh directory so all keys are visible
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
os.makedirs(ssh_dir, exist_ok=True)
key_filenames = []
for ssh_key in ssh_keys_to_mount:
# Use sanitized key name as filename prefix to avoid collisions
key_name = _sanitize_filename(ssh_key.name)
# If multiple keys have the same name, append a short hash
base_filename = f"id_ed25519_{key_name}"
filename = base_filename
counter = 1
while filename in key_filenames:
filename = f"{base_filename}_{counter}"
counter += 1
key_filenames.append(filename)
try:
prepare_ssh_key_files(
instance_dir,
ssh_key,
subdir="mounts/ssh/.ssh",
uid=container_uid,
gid=container_gid,
key_filename=filename,
write_config=False,
)
logger.debug(
"Prepared SSH key %s as %s for instance %s",
ssh_key.name,
filename,
instance.id,
)
except Exception as exc:
logger.error(
"Failed to prepare SSH key %s for instance %s: %s",
ssh_key.id,
instance.id,
exc,
)
# Write combined SSH config with all keys
try:
write_ssh_config(
ssh_dir,
key_filenames,
uid=container_uid,
gid=container_gid,
)
except Exception as exc:
logger.error(
"Failed to write SSH config for instance %s: %s",
instance.id,
exc,
)
# Mount the single .ssh directory into container home
ssh_target = os.path.join(home_dir, ".ssh")
extra_volumes.append(
{
"source": ssh_dir,
"target": ssh_target,
"type": "bind",
}
)
logger.debug(
"Mounted %d SSH key(s) for instance %s to %s",
len(ssh_keys_to_mount),
instance.id,
ssh_target,
)
# ── MANIFEST-BASED FLOW ────────────────────────────────────── # ── MANIFEST-BASED FLOW ──────────────────────────────────────
resolved_manifest = None resolved_manifest = None
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:
logger.info("Using manifest-based startup for instance %s", instance.id) logger.info("Using manifest-based startup for instance %s", instance.id)
# Determine repo path (workspace takes precedence) # Determine repo path
repo_path = "" repo = await session.get(GitRepository, instance.repository_id)
if instance.workspace_id: repo_path = repo.path if repo else ""
from src.models import Workspace as WorkspaceModel if instance.clone_mode == "clone":
repo_path = os.path.join(instance_dir, "repo-clone")
workspace = await session.get(WorkspaceModel, instance.workspace_id)
if workspace:
repo_path = workspace.path
else:
repo = await session.get(GitRepository, instance.repository_id)
repo_path = repo.path if repo else ""
if instance.clone_mode == "clone":
repo_path = os.path.join(instance_dir, "repo-clone")
try: try:
( (
@@ -1766,8 +1638,8 @@ async def start_instance(
) )
else: else:
# ── LEGACY FLOW ────────────────────────────────────────── # ── LEGACY FLOW ──────────────────────────────────────────
# Mount SSH key for clone-mode instances (skip for workspace-based) # Mount SSH key for clone-mode instances
if instance.clone_mode == "clone" and not instance.workspace_id: if instance.clone_mode == "clone":
repo = await session.get(GitRepository, instance.repository_id) repo = await session.get(GitRepository, instance.repository_id)
if repo and repo.ssh_key_id: if repo and repo.ssh_key_id:
ssh_key = await session.get(SSHKey, repo.ssh_key_id) ssh_key = await session.get(SSHKey, repo.ssh_key_id)
@@ -1816,7 +1688,6 @@ async def start_instance(
# Ensure predictable container name for tunnel connectivity # Ensure predictable container name for tunnel connectivity
_ensure_container_name_in_compose(instance.compose_path, instance.name) _ensure_container_name_in_compose(instance.compose_path, instance.name)
_ensure_backend_network_in_compose(instance.compose_path)
# Execute docker compose up with env file # Execute docker compose up with env file
logger.debug( logger.debug(
@@ -1852,9 +1723,15 @@ async def start_instance(
logger.debug("Container ID for instance %s: %s", instance.id, container_id) logger.debug("Container ID for instance %s: %s", instance.id, container_id)
instance.container_name = expected_container_name instance.container_name = expected_container_name
logger.debug( logger.debug("Container name for instance %s: %s", instance.id, expected_container_name)
"Container name for instance %s: %s", instance.id, expected_container_name
) # Connect container to backend network so API can reach it
logger.debug("Connecting container %s to backend network...", expected_container_name)
connected = connect_container_to_network(expected_container_name, "backend")
if connected:
logger.debug("Successfully connected %s to backend network", expected_container_name)
else:
logger.warning("Failed to connect %s to backend network", expected_container_name)
# Verify container reached running state # Verify container reached running state
if instance.container_id: if instance.container_id:
@@ -2075,11 +1952,12 @@ async def start_instance(
"error": f"Tool type '{instance.tool_type_id}' not found", "error": f"Tool type '{instance.tool_type_id}' not found",
} }
instance_port = tool_type.default_port or 0
logger.debug( logger.debug(
"Tool type for instance %s: name=%s, container_port=%s, interface_type=%s", "Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
instance.id, instance.id,
tool_type.name, tool_type.name,
tool_type.default_port or 0, instance_port,
tool_type.interface_type, tool_type.interface_type,
) )
@@ -2088,22 +1966,23 @@ async def start_instance(
# Create temporary Cloudflare tunnel for public access # Create temporary Cloudflare tunnel for public access
try: try:
logger.debug( logger.debug(
"Creating tunnel for instance %s (container_port=%d)", "Creating temporary tunnel for instance %s (container=%s, port=%d)",
instance.id, instance.id,
tool_type.default_port or 0, instance.container_name,
instance_port,
) )
tunnel_info = start_tunnel( tunnel_info = start_cloudflared_tunnel(
instance_name=instance.name, container_name=instance.container_name or instance.name,
container_port=tool_type.default_port or 0, port=instance_port,
) )
instance.tunnel_id = tunnel_info["container_name"] instance.tunnel_id = tunnel_info["pid"]
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"] instance.url = tunnel_info["url"]
await session.commit() await session.commit()
logger.debug( logger.debug(
"Created tunnel for instance %s: container=%s, url=%s", "Created temporary tunnel for instance %s: pid=%s, url=%s",
instance.id, instance.id,
tunnel_info["container_name"], tunnel_info["pid"],
tunnel_info["url"], tunnel_info["url"],
) )
except Exception as exc: except Exception as exc:
@@ -2173,9 +2052,9 @@ async def stop_instance(
# Stop Cloudflare tunnel if exists # Stop Cloudflare tunnel if exists
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_tunnel(instance.name) stop_cloudflared_tunnel(instance.tunnel_id)
logger.debug( logger.debug(
"Stopped tunnel for instance %s (container=%s)", "Stopped tunnel for instance %s (pid=%s)",
instance.id, instance.id,
instance.tunnel_id, instance.tunnel_id,
) )
@@ -2242,9 +2121,9 @@ async def restart_instance(
# Stop old tunnel if exists # Stop old tunnel if exists
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_tunnel(instance.name) stop_cloudflared_tunnel(instance.tunnel_id)
logger.debug( logger.debug(
"Stopped old tunnel for instance %s (container=%s)", "Stopped old tunnel for instance %s (pid=%s)",
instance.id, instance.id,
instance.tunnel_id, instance.tunnel_id,
) )
@@ -2287,7 +2166,6 @@ async def restart_instance(
instance.compose_path, tool_type.name, tool_type.default_port instance.compose_path, tool_type.name, tool_type.default_port
) )
_ensure_container_name_in_compose(instance.compose_path, instance.name) _ensure_container_name_in_compose(instance.compose_path, instance.name)
_ensure_backend_network_in_compose(instance.compose_path)
returncode, stdout, stderr = execute_compose_command( returncode, stdout, stderr = execute_compose_command(
instance.compose_path, "restart" instance.compose_path, "restart"
@@ -2311,15 +2189,17 @@ async def restart_instance(
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", "error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
} }
instance_port = tool_type.default_port
# Only create tunnel for web-enabled tools # Only create tunnel for web-enabled tools
if tool_type.interface_type == "web": if tool_type.interface_type == "web":
# Create new tunnel # Create new temporary tunnel
try: try:
tunnel_info = start_tunnel( tunnel_info = start_cloudflared_tunnel(
instance_name=instance.name, container_name=instance.name.lower(),
container_port=tool_type.default_port or 0, port=instance_port,
) )
instance.tunnel_id = tunnel_info["container_name"] instance.tunnel_id = tunnel_info["pid"]
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"] instance.url = tunnel_info["url"]
logger.debug( logger.debug(
@@ -2418,9 +2298,9 @@ async def delete_instance(
# Stop Cloudflare tunnel if exists # Stop Cloudflare tunnel if exists
if instance.tunnel_id: if instance.tunnel_id:
try: try:
stop_tunnel(instance.name) stop_cloudflared_tunnel(instance.tunnel_id)
logger.debug( logger.debug(
"Stopped tunnel for instance %s (container=%s)", "Stopped tunnel for instance %s (pid=%s)",
instance.id, instance.id,
instance.tunnel_id, instance.tunnel_id,
) )
@@ -2535,117 +2415,43 @@ async def recreate_tunnel_endpoint(
detail="instance must be running to recreate tunnel", detail="instance must be running to recreate tunnel",
) )
tool_type = await session.get(ToolType, instance.tool_type_id) # Validate tunnel is actually broken before recreating
if not tool_type: if instance.url:
raise HTTPException( tunnel_health = check_tunnel_health(instance.url)
status_code=status.HTTP_400_BAD_REQUEST, if tunnel_health["tunnel_status"] == "error_response":
detail="Tool type not found for this instance",
)
expected_name = instance.name.lower()
logger.info(
"Recreate tunnel for instance %s (expected container name: %s, default_port: %s)",
instance.id,
expected_name,
tool_type.default_port,
)
# Find the tool container — try stored ID first, then fall back to name lookup
tool_container_id = instance.container_id
if tool_container_id:
logger.info("Using stored container_id: %s", tool_container_id)
else:
tool_container_id = get_container_id(expected_name)
if tool_container_id:
logger.info("Found container by name: %s", tool_container_id)
else:
logger.error("Container %s not found", expected_name)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not find running container for this instance", detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.",
) )
elif tunnel_health["tunnel_status"] == "healthy":
return {
"status": "healthy",
"url": instance.url,
"message": "Tunnel is already healthy",
}
# Ensure the tool container is on the backend network so the tunnel can reach it # Get tool type for default port
network_name = get_backend_network_name() tool_type = await session.get(ToolType, instance.tool_type_id)
on_network = is_container_on_network(tool_container_id, network_name) instance_port = (
logger.info( tool_type.default_port if tool_type and tool_type.default_port else 8080
"Container %s on network %s: %s",
tool_container_id,
network_name,
on_network,
) )
if not on_network:
logger.info(
"Connecting container %s to network %s",
tool_container_id,
network_name,
)
connected = connect_container_to_network(tool_container_id, network_name)
logger.info("Network connect result: %s", connected)
# Get the container's IP on the backend network
target_ip = get_container_ip_on_network(tool_container_id, network_name)
if target_ip:
target_url = f"http://{target_ip}:{tool_type.default_port or 0}"
logger.info(
"Tunnel target for instance %s: %s (IP %s on %s)",
instance.id,
target_url,
target_ip,
network_name,
)
else:
target_url = f"http://{expected_name}:{tool_type.default_port or 0}"
logger.warning(
"Could not get container IP, falling back to name-based target: %s",
target_url,
)
try: try:
tunnel_info = recreate_tunnel( tunnel_info = recreate_tunnel(
instance_name=instance.name, container_name=instance.container_name or instance.name,
container_port=tool_type.default_port or 0, port=instance_port,
target_url=target_url, old_pid=instance.tunnel_id,
) )
logger.info( instance.tunnel_id = tunnel_info["pid"]
"Tunnel recreated: container=%s, url=%s",
tunnel_info["container_name"],
tunnel_info["url"],
)
# Verify the tunnel can actually reach the origin
health = check_tunnel_health(tunnel_info["url"], timeout=10)
logger.info(
"Tunnel health check: status=%s, code=%s, error=%s",
health.get("tunnel_status"),
health.get("status_code"),
health.get("error"),
)
# Also probe from inside the API container directly to the target
probe = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
"5",
target_url,
],
capture_output=True,
text=True,
)
logger.info(
"Direct probe from API to %s: HTTP %s", target_url, probe.stdout.strip()
)
instance.tunnel_id = tunnel_info["container_name"]
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
instance.url = tunnel_info["url"] instance.url = tunnel_info["url"]
await session.commit() await session.commit()
logger.debug(
"Recreated tunnel for instance %s: pid=%s, url=%s",
instance.id,
tunnel_info["pid"],
tunnel_info["url"],
)
return {"status": "healthy", "url": instance.url} return {"status": "healthy", "url": instance.url}
except Exception as exc: except Exception as exc:
logger.exception("Failed to recreate tunnel for instance %s", instance.id) logger.exception("Failed to recreate tunnel for instance %s", instance.id)
@@ -2767,7 +2573,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 import InstanceEvent from src.models.instance_event 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)
+1 -1
View File
@@ -12,7 +12,7 @@ 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 import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
router = APIRouter(prefix="/tool-types", tags=["tool-types"]) router = APIRouter(prefix="/tool-types", tags=["tool-types"])
-1
View File
@@ -1 +0,0 @@
"""User module."""
+1 -1
View File
@@ -7,7 +7,7 @@ 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 import UserConfig from src.models.user_config import UserConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
-1
View File
@@ -1 +0,0 @@
"""Workspace module."""
-114
View File
@@ -1,114 +0,0 @@
"""Workspace file API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import Workspace
from src.services.file_service import FileService
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
from sqlalchemy import select
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/")
async def list_files(
workspace_id: uuid.UUID,
path: str = "",
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List files in a workspace directory."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
try:
entries = service.list_directory(workspace, path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"entries": [
{
"name": e.name,
"path": e.path,
"type": e.type,
"size": e.size,
}
for e in entries
],
}
@router.get("/content")
async def get_file_content(
workspace_id: uuid.UUID,
path: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get the content of a text file."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
try:
content = service.read_file(workspace, path)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"content": content, "path": path}
@router.post("/content")
async def write_file(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Write a file and optionally commit."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
file_path = data.get("path", "").strip()
content = data.get("content", "")
commit_message = data.get("message", "").strip()
if not file_path:
raise HTTPException(status_code=400, detail="File path is required")
try:
service.write_file(workspace, file_path, content)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if commit_message:
from src.services.git_operations import GitOperations
git = GitOperations(workspace)
try:
await git.commit(commit_message)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "saved", "path": file_path}
-203
View File
@@ -1,203 +0,0 @@
"""Workspace git API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import Workspace
from src.services.git_operations import GitOperations
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
from sqlalchemy import select
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/status")
async def git_status(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get git status for the workspace."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
status = await git.status()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"branch": status.branch,
"modified": status.modified,
"added": status.added,
"deleted": status.deleted,
"untracked": status.untracked,
"ahead": status.ahead,
"behind": status.behind,
}
@router.get("/branches")
async def git_branches(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List branches for the workspace."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
branches, current = await git.branches()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"branches": branches,
"current_branch": current,
}
@router.post("/commit")
async def git_commit(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Stage all changes and commit."""
workspace = await _get_workspace(session, workspace_id, user_id)
message = data.get("message", "").strip()
if not message:
raise HTTPException(status_code=400, detail="Commit message is required")
git = GitOperations(workspace)
try:
await git.commit(message)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "committed"}
@router.post("/push")
async def git_push(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Push current branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.push()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "pushed"}
@router.post("/pull")
async def git_pull(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Pull current branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.pull()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "pulled"}
@router.post("/fetch")
async def git_fetch(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Fetch from origin."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.fetch()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "fetched"}
@router.post("/checkout")
async def git_checkout(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Checkout a branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
branch = data.get("branch", "").strip()
if not branch:
raise HTTPException(status_code=400, detail="Branch name is required")
git = GitOperations(workspace)
try:
await git.checkout(branch)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
workspace.branch = branch
await session.commit()
return {"status": "checked_out", "branch": branch}
@router.get("/history")
async def git_history(
workspace_id: uuid.UUID,
path: str | None = None,
limit: int = 50,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get commit history."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
commits = await git.history(path, limit)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"commits": [
{
"hash": c.hash,
"message": c.message,
"author": c.author,
"date": c.date,
}
for c in commits
],
}
-60
View File
@@ -1,60 +0,0 @@
"""Workspace instance API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ToolInstance
from src.models import Workspace
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/")
async def list_workspace_instances(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
"""List tool instances using this workspace."""
await _get_workspace(session, workspace_id, user_id)
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.workspace_id == workspace_id)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
return [
{
"id": str(i.id),
"name": i.name,
"display_name": i.display_name,
"status": i.status,
"tool_type_id": str(i.tool_type_id),
"url": i.url,
"port": i.port,
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in instances
]
-450
View File
@@ -1,450 +0,0 @@
"""Workspace CRUD API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import GitRepository
from src.models import ToolInstance
from src.models import Workspace
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
all_workspaces_router = APIRouter(prefix="/workspaces")
@all_workspaces_router.get("/")
async def list_all_workspaces(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
"""List all workspaces for the current user across all repos."""
instance_count = (
select(func.count(ToolInstance.id))
.where(ToolInstance.workspace_id == Workspace.id)
.correlate(Workspace)
.scalar_subquery()
)
result = await session.execute(
select(
Workspace,
GitRepository.name.label("repo_name"),
GitRepository.project_id,
GitRepository.ssh_key_id.label("repo_ssh_key_id"),
instance_count.label("instance_count"),
)
.join(GitRepository, Workspace.repo_id == GitRepository.id)
.where(Workspace.user_id == user_id)
.order_by(Workspace.created_at.desc())
)
rows = result.all()
return [
{
"id": str(ws.id),
"name": ws.name,
"repo_id": str(ws.repo_id),
"repo_name": repo_name or "",
"repo_ssh_key_id": str(ssh_key_id) if ssh_key_id else None,
"project_id": str(project_id) if project_id else "",
"project_name": "",
"user_id": str(ws.user_id),
"branch": ws.branch,
"path": ws.path,
"status": ws.status,
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
"created_at": ws.created_at.isoformat() if ws.created_at else None,
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
"instance_count": count or 0,
}
for ws, repo_name, project_id, ssh_key_id, count in rows
]
@all_workspaces_router.delete("/{workspace_id}")
async def delete_workspace_top_level(
workspace_id: uuid.UUID,
force: bool = Query(False),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Delete a workspace via top-level path."""
workspace = await session.get(Workspace, workspace_id)
if not workspace or workspace.user_id != user_id:
raise HTTPException(status_code=404, detail="Workspace not found")
manager = WorkspaceManager()
try:
await manager.delete(workspace, force=force, session=session)
await session.commit()
except WorkspaceHasInstancesError as exc:
await session.rollback()
raise HTTPException(
status_code=409,
detail={
"message": "Workspace has running tool instances",
"instances": exc.instances,
},
) from exc
except Exception as exc:
await session.rollback()
logger.error("Failed to delete workspace: %s", exc)
raise HTTPException(
status_code=500, detail="Failed to delete workspace"
) from exc
return {"status": "deleted"}
@all_workspaces_router.post("/")
async def create_workspace_top_level(
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a workspace directly (no nested project/repo path)."""
repo_id_str = data.get("repo_id", "").strip()
if not repo_id_str:
raise HTTPException(status_code=400, detail="repo_id is required")
try:
repo_id = uuid.UUID(repo_id_str)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc
repo = await session.get(GitRepository, repo_id)
if not repo or repo.owner_id != user_id:
raise HTTPException(status_code=404, detail="Repository not found")
name = data.get("name", "").strip()
branch = data.get("branch", "main").strip()
if not name:
raise HTTPException(status_code=400, detail="Workspace name is required")
manager = WorkspaceManager()
try:
workspace = await manager.create(repo, user_id, name, branch, session=session)
session.add(workspace)
await session.commit()
except Exception as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(
status_code=409,
detail="Workspace name already exists for this repository",
) from exc
await session.refresh(workspace)
return {
"id": str(workspace.id),
"name": workspace.name,
"repo_id": str(workspace.repo_id),
"branch": workspace.branch,
"path": workspace.path,
"status": workspace.status,
"created_at": workspace.created_at.isoformat()
if workspace.created_at
else None,
}
@router.get("/")
async def list_workspaces(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
"""List workspaces for a repository, with instance counts."""
# Verify repo belongs to project and user
repo = await _get_repo(session, repo_id, project_id, user_id)
# Build subquery for instance counts
instance_count = (
select(func.count(ToolInstance.id))
.where(ToolInstance.workspace_id == Workspace.id)
.correlate(Workspace)
.scalar_subquery()
)
result = await session.execute(
select(
Workspace,
instance_count.label("instance_count"),
)
.where(Workspace.repo_id == repo_id)
.order_by(Workspace.created_at.desc())
)
rows = result.all()
return [
{
"id": str(ws.id),
"name": ws.name,
"repo_id": str(ws.repo_id),
"repo_name": repo.name,
"repo_ssh_key_id": str(repo.ssh_key_id) if repo.ssh_key_id else None,
"project_id": str(repo.project_id) if repo.project_id else "",
"project_name": repo.project.name if repo.project else "",
"user_id": str(ws.user_id),
"branch": ws.branch,
"path": ws.path,
"status": ws.status,
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
"created_at": ws.created_at.isoformat() if ws.created_at else None,
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
"instance_count": count or 0,
}
for ws, count in rows
]
@router.post("/")
async def create_workspace(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a new workspace by cloning a repository branch."""
repo = await _get_repo(session, repo_id, project_id, user_id)
name = data.get("name", "").strip()
branch = data.get("branch", "main").strip()
if not name:
raise HTTPException(status_code=400, detail="Workspace name is required")
if not branch:
raise HTTPException(status_code=400, detail="Branch is required")
manager = WorkspaceManager()
try:
workspace = await manager.create(repo, user_id, name, branch, session=session)
session.add(workspace)
await session.commit()
except HTTPException:
raise
except ValueError as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(
status_code=409,
detail="Workspace name already exists for this repository",
) from exc
await session.refresh(workspace)
return {
"id": str(workspace.id),
"name": workspace.name,
"repo_id": str(workspace.repo_id),
"branch": workspace.branch,
"path": workspace.path,
"status": workspace.status,
"created_at": workspace.created_at.isoformat()
if workspace.created_at
else None,
}
@router.get("/{workspace_id}")
async def get_workspace_detail(
project_id: uuid.UUID,
repo_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get workspace details."""
repo = await _get_repo(session, repo_id, project_id, user_id)
workspace = await _get_workspace(session, workspace_id, repo_id)
# Count instances
result = await session.execute(
select(func.count(ToolInstance.id)).where(
ToolInstance.workspace_id == workspace_id
)
)
instance_count = result.scalar() or 0
return {
"id": str(workspace.id),
"name": workspace.name,
"repo_id": str(workspace.repo_id),
"repo_name": repo.name,
"user_id": str(workspace.user_id),
"branch": workspace.branch,
"path": workspace.path,
"status": workspace.status,
"last_sync_at": workspace.last_sync_at.isoformat()
if workspace.last_sync_at
else None,
"created_at": workspace.created_at.isoformat()
if workspace.created_at
else None,
"updated_at": workspace.updated_at.isoformat()
if workspace.updated_at
else None,
"instance_count": instance_count,
}
@router.patch("/{workspace_id}")
async def update_workspace(
project_id: uuid.UUID,
repo_id: uuid.UUID,
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update workspace name or branch."""
await _get_repo(session, repo_id, project_id, user_id)
workspace = await _get_workspace(session, workspace_id, repo_id)
new_name = data.get("name", "").strip()
new_branch = data.get("branch", "").strip()
if new_name:
workspace.name = new_name
if new_branch:
workspace.branch = new_branch
try:
await session.commit()
except Exception as exc:
await session.rollback()
logger.error("Failed to update workspace: %s", exc)
raise HTTPException(
status_code=409,
detail="Workspace name already exists for this repository",
) from exc
return {
"id": str(workspace.id),
"name": workspace.name,
"branch": workspace.branch,
"status": workspace.status,
}
@router.delete("/{workspace_id}")
async def delete_workspace(
project_id: uuid.UUID,
repo_id: uuid.UUID,
workspace_id: uuid.UUID,
force: bool = Query(False),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Delete a workspace. Returns 409 if instances exist and force=False."""
await _get_repo(session, repo_id, project_id, user_id)
workspace = await _get_workspace(session, workspace_id, repo_id)
manager = WorkspaceManager()
try:
await manager.delete(workspace, force=force, session=session)
await session.commit()
except WorkspaceHasInstancesError as exc:
await session.rollback()
raise HTTPException(
status_code=409,
detail={
"message": "Workspace has running tool instances",
"instances": exc.instances,
},
) from exc
except Exception as exc:
await session.rollback()
logger.error("Failed to delete workspace: %s", exc)
raise HTTPException(
status_code=500, detail="Failed to delete workspace"
) from exc
return {"status": "deleted"}
@router.post("/{workspace_id}/sync")
async def sync_workspace(
project_id: uuid.UUID,
repo_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Sync workspace with remote. Returns 409 if branch was deleted."""
await _get_repo(session, repo_id, project_id, user_id)
workspace = await _get_workspace(session, workspace_id, repo_id)
manager = WorkspaceManager()
result = await manager.sync(workspace, session=session)
if result.branch_deleted:
raise HTTPException(
status_code=409,
detail={
"message": f"Branch '{workspace.branch}' was deleted from remote",
"branch_deleted": True,
},
)
await session.commit()
return {
"branch_deleted": False,
"pulled": True,
"last_sync_at": workspace.last_sync_at.isoformat()
if workspace.last_sync_at
else None,
}
async def _get_repo(
session: AsyncSession,
repo_id: uuid.UUID,
project_id: uuid.UUID,
user_id: uuid.UUID,
) -> GitRepository:
"""Fetch and validate repository access."""
result = await session.execute(
select(GitRepository)
.where(
GitRepository.id == repo_id,
GitRepository.project_id == project_id,
)
.options(selectinload(GitRepository.project))
)
repo = result.scalar_one_or_none()
if not repo:
raise HTTPException(status_code=404, detail="Repository not found")
return repo
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
repo_id: uuid.UUID,
) -> Workspace:
"""Fetch and validate workspace."""
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.repo_id == repo_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
+2 -16
View File
@@ -24,20 +24,15 @@ from src.api.tool_types import router as tool_types_router
from src.api.notifications import router as notifications_router from src.api.notifications import router as notifications_router
from src.api.user_config import router as user_config_router from src.api.user_config import router as user_config_router
from src.api.users import router as users_router from src.api.users import router as users_router
from src.api.workspace_files import router as workspace_files_router
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.workspaces import all_workspaces_router, router as workspaces_router
from src.config import Settings from src.config import Settings
from src.models import Notification # noqa: F401 Alembic model discovery from src.models.notification import Notification # noqa: F401 Alembic model discovery
from src.models import TerminalSessionModel # noqa: F401 Alembic model discovery from src.models.terminal_session 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
@@ -136,10 +131,6 @@ 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.")
@@ -168,9 +159,4 @@ app.include_router(instance_proxy_router)
app.include_router(terminal_router) app.include_router(terminal_router)
app.include_router(events_router) app.include_router(events_router)
app.include_router(notifications_router) app.include_router(notifications_router)
app.include_router(all_workspaces_router)
app.include_router(workspaces_router)
app.include_router(workspace_files_router)
app.include_router(workspace_git_router)
app.include_router(workspace_instances_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+13 -15
View File
@@ -1,18 +1,17 @@
from src.models.base import Base from src.models.base import Base
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.project.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project.project import Project from src.models.health_check import HealthCheck
from src.models.project.workspace import Workspace from src.models.instance_event import InstanceEvent
from src.models.system.health_check import HealthCheck from src.models.notification import Notification
from src.models.system.instance_event import InstanceEvent from src.models.project import Project
from src.models.system.notification import Notification from src.models.ssh_key import SSHKey
from src.models.system.terminal_session import TerminalSessionModel from src.models.terminal_session import TerminalSessionModel
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool.tool_instance import ToolInstance from src.models.tool_instance import ToolInstance
from src.models.tool.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user.ssh_key import SSHKey from src.models.user import User
from src.models.user.user import User from src.models.user_config import UserConfig
from src.models.user.user_config import UserConfig
__all__ = [ __all__ = [
"Base", "Base",
@@ -30,5 +29,4 @@ __all__ = [
"ToolType", "ToolType",
"User", "User",
"UserConfig", "UserConfig",
"Workspace",
] ]
-5
View File
@@ -1,5 +0,0 @@
"""Config models module."""
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
__all__ = ["ConfigProfile", "ConfigProfileInclude"]
@@ -1,15 +1,7 @@
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
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
@@ -17,15 +9,12 @@ 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 import ToolType from src.models.tool_type 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
@@ -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 import SSHKey from src.models.ssh_key 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 import GitRepository from src.models.git_repository import GitRepository
from src.models import SSHKey from src.models.ssh_key import SSHKey
from src.models.user import User from src.models.user import User
-7
View File
@@ -1,7 +0,0 @@
"""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"]
-50
View File
@@ -1,50 +0,0 @@
"""Workspace model for persistent writable repo clones."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin
if TYPE_CHECKING:
from src.models import GitRepository
from src.models.user import User
class Workspace(Base, TimestampMixin):
"""A persistent, writable local clone of a Git repository.
Users create workspaces explicitly, then start tool instances on them.
Multiple tool instances can share the same workspace.
"""
__tablename__ = "workspaces"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
repo_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("git_repositories.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
branch: Mapped[str] = mapped_column(String(255), nullable=False, default="main")
path: Mapped[str] = mapped_column(String(2048), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready")
last_sync_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
)
repository: Mapped[GitRepository] = relationship("GitRepository")
owner: Mapped[User] = relationship("User")
-8
View File
@@ -1,8 +0,0 @@
"""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
@@ -1,7 +0,0 @@
"""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,11 @@ 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 import ConfigProfile from src.models.config_profile import ConfigProfile
from src.models import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
from src.models import Workspace
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
@@ -61,12 +60,8 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
) )
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True
)
tool_type: Mapped["ToolType"] = relationship() tool_type: Mapped["ToolType"] = relationship()
workspace: Mapped["Workspace | None"] = relationship()
repository: Mapped["GitRepository"] = relationship() repository: Mapped["GitRepository"] = relationship()
project: Mapped["Project"] = relationship() project: Mapped["Project"] = relationship()
owner: Mapped["User"] = relationship() owner: Mapped["User"] = relationship()
@@ -6,9 +6,9 @@ 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
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.user import User from src.models.user import User
@@ -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 import SSHKey from src.models.ssh_key import SSHKey
from src.models import UserConfig from src.models.user_config import UserConfig
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
-7
View File
@@ -1,7 +0,0 @@
"""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"]
-51
View File
@@ -1,51 +0,0 @@
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
@@ -0,0 +1,20 @@
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")
-1
View File
@@ -1 +0,0 @@
"""Config module."""
-1
View File
@@ -1 +0,0 @@
"""Project module."""
-1
View File
@@ -1 +0,0 @@
"""System module."""
-1
View File
@@ -1 +0,0 @@
"""Tool module."""
-1
View File
@@ -1 +0,0 @@
"""User module."""
-1
View File
@@ -1 +0,0 @@
"""Database seeding utilities."""
-168
View File
@@ -1,168 +0,0 @@
"""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
@@ -1 +0,0 @@
"""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 import ConfigProfile, ConfigProfileInclude from src.models.config_profile import ConfigProfile, ConfigProfileInclude
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+357 -124
View File
@@ -1,6 +1,8 @@
"""Docker service for managing tool instances.""" """Docker service for managing tool instances."""
import logging import logging
import os
import re
import subprocess import subprocess
import time import time
from collections import Counter from collections import Counter
@@ -179,113 +181,69 @@ def execute_compose_command(
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.
Uses exact name matching to avoid substring collisions with tunnel Searches all containers including stopped/exited ones.
containers (e.g. tunnel-code-server-... matching code-server-...).
Falls back to case-insensitive matching since Docker DNS is case-
insensitive but docker inspect is case-sensitive.
Args: Args:
instance_name: The expected container name. instance_name: The service name in compose
Returns: Returns:
Container ID or None if not found. Container ID or None if not found
""" """
expected = instance_name.lower() # Docker container names are lowercase internally; normalize to ensure match
# Fast path: exact match via docker inspect
result = subprocess.run( result = subprocess.run(
["docker", "inspect", "-f", "{{.Id}}", expected], ["docker", "ps", "-a", "-q", "--filter", f"name={instance_name.lower()}"],
capture_output=True, capture_output=True,
text=True, text=True,
) )
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
# Fallback: list all containers and do case-insensitive exact match if result.returncode == 0 and result.stdout.strip():
ps_result = subprocess.run( return result.stdout.strip().split("\n")[0]
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.ID}}"],
capture_output=True,
text=True,
)
if ps_result.returncode == 0:
for line in ps_result.stdout.strip().splitlines():
parts = line.split("\t")
if len(parts) == 2:
name, cid = parts
if name.lower() == expected:
return cid
return None return None
def get_container_name(instance_name: str) -> str | None: def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service. """Get the full container name for a compose service.
Uses exact name matching via docker inspect to avoid substring collisions. Searches all containers including stopped/exited ones.
Args: Args:
instance_name: The exact container name (case-insensitive for Docker). instance_name: The service name in compose
Returns: Returns:
Container name or None if not found. Container name or None if not found
""" """
result = subprocess.run( # Docker container names are lowercase internally; normalize to ensure match
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().lstrip("/")
return None
def get_backend_network_name() -> str:
"""Auto-detect the actual Docker network name for the backend network.
Docker Compose prefixes network names with the project directory name
(e.g. 'headquarter_backend' instead of 'backend'). We inspect the API
container itself to find the real network name it's connected to.
Returns:
The actual Docker network name, or 'backend' as fallback.
"""
# Try to find the API container by its known name
api_container = "hq-api"
result = subprocess.run( result = subprocess.run(
[ [
"docker", "docker",
"inspect", "ps",
"-f", "-a",
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}", "--format",
api_container, "{{.Names}}",
"--filter",
f"name={instance_name.lower()}",
], ],
capture_output=True, capture_output=True,
text=True, text=True,
) )
if result.returncode == 0 and result.stdout.strip(): if result.returncode == 0 and result.stdout.strip():
networks = result.stdout.strip().split() return result.stdout.strip().split("\n")[0]
for net in networks: return None
if "backend" in net.lower():
return net
# API container is on some network — return the first one
return networks[0]
return "backend"
def connect_container_to_network( def connect_container_to_network(
container_name: str, network_name: str | None = None container_name: str, network_name: str = "backend"
) -> bool: ) -> bool:
"""Connect a Docker container to an existing network. """Connect a Docker container to an existing network.
Args: Args:
container_name: Name or ID of the container container_name: Name or ID of the container
network_name: Name of the Docker network. If None, auto-detects network_name: Name of the Docker network (default: backend)
from the API container's own network membership.
Returns: Returns:
True if successful, False otherwise True if successful, False otherwise
""" """
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run( result = subprocess.run(
["docker", "network", "connect", network_name, container_name], ["docker", "network", "connect", network_name, container_name],
capture_output=True, capture_output=True,
@@ -294,64 +252,6 @@ def connect_container_to_network(
return result.returncode == 0 return result.returncode == 0
def get_container_ip_on_network(
container_id: str, network_name: str | None = None
) -> str | None:
"""Get a container's IP address on a specific Docker network.
Args:
container_id: Docker container ID or name.
network_name: Network name. If None, auto-detects from the API container.
Returns:
IP address string, or None if the container is not on that network.
"""
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run(
[
"docker",
"inspect",
"-f",
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
container_id,
],
capture_output=True,
text=True,
)
if result.returncode == 0:
ip = result.stdout.strip()
if ip and ip != "<no value>":
return ip
return None
def is_container_on_network(container_id: str, network_name: str | None = None) -> bool:
"""Check whether a container is already attached to a Docker network.
Args:
container_id: Docker container ID or name.
network_name: Network name. If None, auto-detects from the API container.
Returns:
True if the container is on the network.
"""
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run(
[
"docker",
"inspect",
"-f",
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
container_id,
],
capture_output=True,
text=True,
)
return result.returncode == 0 and "<no value>" not in result.stdout
def get_container_status(container_id: str) -> dict[str, Any]: def get_container_status(container_id: str) -> dict[str, Any]:
"""Get the status of a Docker container. """Get the status of a Docker container.
@@ -482,3 +382,336 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
return port return port
raise RuntimeError(f"No free port found in range {start}-{end}") raise RuntimeError(f"No free port found in range {start}-{end}")
def _check_app_binding(container_name: str, port: int) -> dict[str, str | bool]:
"""Diagnose whether the app is bound to 127.0.0.1 or 0.0.0.0.
Checks from both inside the container (localhost) and outside
(via Docker network) to detect binding issues.
Returns:
Dict with 'internal_ok', 'external_ok', 'internal_status',
'external_status', and 'diagnosis'.
"""
import subprocess
result: dict[str, Any] = {
"internal_ok": False,
"external_ok": False,
"internal_status": None,
"external_status": None,
"diagnosis": "unknown",
}
# Check from inside the container (loopback)
internal = subprocess.run(
[
"docker",
"exec",
container_name,
"sh",
"-c",
f"curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{port}",
],
capture_output=True,
text=True,
timeout=5,
)
if internal.returncode == 0:
try:
result["internal_status"] = int(internal.stdout.strip())
result["internal_ok"] = result["internal_status"] > 0
except ValueError:
pass
# Check from outside the container (Docker network)
external = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
f"http://{container_name}:{port}",
],
capture_output=True,
text=True,
timeout=5,
)
if external.returncode == 0:
try:
result["external_status"] = int(external.stdout.strip())
result["external_ok"] = result["external_status"] > 0
except ValueError:
pass
# Diagnose binding issue
if result["internal_ok"] and not result["external_ok"]:
result["diagnosis"] = (
f"App appears to be bound to 127.0.0.1:{port} inside the container. "
f"It must bind to 0.0.0.0:{port} to be accessible from the tunnel."
)
elif result["internal_ok"] and result["external_ok"]:
result["diagnosis"] = "App is accessible on both interfaces."
elif not result["internal_ok"] and not result["external_ok"]:
result["diagnosis"] = f"App is not responding on port {port} at all."
else:
result["diagnosis"] = "Unexpected binding state."
return result
def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30
) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for a container.
Uses 'cloudflared tunnel --url' to create a temporary tunnel
with a random trycloudflare.com URL.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
timeout: Maximum seconds to wait for tunnel URL
Returns:
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
"""
import subprocess
import logging
logger = logging.getLogger(__name__)
# First verify the container is accessible from the Docker network
logger.info("Checking connectivity to %s:%d...", container_name, port)
accessible = False
last_status = None
for attempt in range(30): # 30 attempts × 1s = 30s max wait for app startup
check = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
"3",
f"http://{container_name}:{port}",
],
capture_output=True,
text=True,
timeout=5,
)
status_str = check.stdout.strip()
logger.info(
"Connectivity check %d/%d: http_code=%s (rc=%d)",
attempt + 1,
30,
status_str,
check.returncode,
)
try:
last_status = int(status_str)
# Accept 2xx, 3xx, 401, 403 as "app is listening"
if last_status in (401, 403) or 200 <= last_status < 400:
accessible = True
logger.info(
"App on %s:%d is ready (HTTP %d)",
container_name,
port,
last_status,
)
break
except ValueError:
pass
if check.returncode != 0:
logger.debug(
"curl failed: stderr=%s", check.stderr.strip() if check.stderr else ""
)
time.sleep(1)
if not accessible:
logger.warning(
"Container %s:%d not responding after 30s (last status: %s). "
"Running binding diagnostics...",
container_name,
port,
last_status,
)
diagnosis = _check_app_binding(container_name, port)
logger.warning(
"Binding diagnosis: internal=%s (HTTP %s), external=%s (HTTP %s). %s",
diagnosis["internal_ok"],
diagnosis["internal_status"],
diagnosis["external_ok"],
diagnosis["external_status"],
diagnosis["diagnosis"],
)
# Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
proc = subprocess.Popen(
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
# Wait for the URL to appear in output
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
start_time = time.time()
url = None
if proc.stdout is None:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError("Failed to capture cloudflared output")
while time.time() - start_time < timeout:
# Read available output
import select
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
if readable:
line = proc.stdout.readline()
if line:
match = url_pattern.search(line)
if match:
url = match.group(0)
break
if not url:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError(
f"Failed to get tunnel URL within {timeout}s. "
f"cloudflared output may contain errors."
)
return {"url": url, "pid": str(proc.pid)}
def stop_cloudflared_tunnel(pid: str) -> None:
"""Stop a cloudflared tunnel process.
Args:
pid: Process ID of the cloudflared tunnel
"""
import signal
try:
os.kill(int(pid), signal.SIGTERM)
except ProcessLookupError:
pass # Already stopped
def recreate_tunnel(
container_name: str, port: int, old_pid: str | None = None
) -> dict[str, str]:
"""Recreate a temporary Cloudflare tunnel.
Stops the old tunnel (if pid provided) and starts a new one.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
old_pid: Optional PID of the old tunnel process to stop
Returns:
Dict with 'url' and 'pid' for the new tunnel
"""
if old_pid:
stop_cloudflared_tunnel(old_pid)
return start_cloudflared_tunnel(container_name, port)
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy with smart error classification.
Args:
url: The tunnel URL to check
timeout: Request timeout in seconds
Returns:
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
"""
import subprocess
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,
}
elif status_code in (502, 503, 504):
# Application error, not tunnel error
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"Application returned HTTP {status_code}",
}
else:
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"HTTP {status_code}",
}
except subprocess.TimeoutExpired:
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": "Tunnel request timed out",
}
except (ValueError, Exception) as e:
error_str = str(e).lower()
# Classify connection errors
if any(
err in error_str
for err in [
"connection refused",
"econnrefused",
"could not resolve",
"nodename",
]
):
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": f"Tunnel unreachable: {e}",
}
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": str(e),
}
-128
View File
@@ -1,128 +0,0 @@
"""File operations scoped to a workspace directory."""
import logging
import os
from dataclasses import dataclass
from src.models import Workspace
logger = logging.getLogger(__name__)
@dataclass
class FileEntry:
"""A single file or directory entry."""
name: str
path: str
type: str # "file" or "directory"
size: int | None = None
class FileService:
"""Read and write files within a workspace directory."""
def list_directory(
self,
workspace: Workspace,
relative_path: str = "",
) -> list[FileEntry]:
"""List entries in a workspace directory.
Args:
workspace: The workspace to list files in.
relative_path: Path relative to workspace root.
Returns:
List of file entries sorted by name (directories first).
"""
abs_path = os.path.join(workspace.path, relative_path)
abs_path = os.path.normpath(abs_path)
# Security: ensure we stay within workspace
if not abs_path.startswith(os.path.normpath(workspace.path)):
raise ValueError("Path escapes workspace directory")
if not os.path.exists(abs_path):
return []
entries = []
for item in sorted(os.listdir(abs_path)):
full = os.path.join(abs_path, item)
rel = os.path.join(relative_path, item) if relative_path else item
is_dir = os.path.isdir(full)
size = os.path.getsize(full) if os.path.isfile(full) else None
entries.append(
FileEntry(
name=item,
path=rel.replace("\\", "/"),
type="directory" if is_dir else "file",
size=size,
)
)
# Directories first, then files, both alphabetical
entries.sort(key=lambda e: (0 if e.type == "directory" else 1, e.name.lower()))
return entries
def read_file(self, workspace: Workspace, relative_path: str) -> str:
"""Read a text file from the workspace.
Args:
workspace: The workspace to read from.
relative_path: Path relative to workspace root.
Returns:
File contents as string.
Raises:
ValueError: If path escapes workspace or file is binary.
FileNotFoundError: If file does not exist.
"""
abs_path = self._resolve_path(workspace, relative_path)
if not os.path.isfile(abs_path):
raise FileNotFoundError(f"Not a file: {relative_path}")
# Basic binary check — read first 8KB and look for null bytes
with open(abs_path, "rb") as f:
chunk = f.read(8192)
if b"\x00" in chunk:
raise ValueError("Binary files cannot be viewed")
with open(abs_path, encoding="utf-8", errors="replace") as f:
return f.read()
def write_file(
self,
workspace: Workspace,
relative_path: str,
content: str,
) -> None:
"""Write a text file to the workspace.
Args:
workspace: The workspace to write to.
relative_path: Path relative to workspace root.
content: File contents.
Raises:
ValueError: If path escapes workspace.
"""
abs_path = self._resolve_path(workspace, relative_path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
f.write(content)
logger.info("Wrote file %s in workspace %s", relative_path, workspace.id)
def _resolve_path(self, workspace: Workspace, relative_path: str) -> str:
"""Resolve a relative path to absolute, with security check."""
abs_path = os.path.normpath(os.path.join(workspace.path, relative_path))
workspace_root = os.path.normpath(workspace.path)
if not abs_path.startswith(workspace_root):
raise ValueError("Path escapes workspace directory")
return abs_path
-1
View File
@@ -1 +0,0 @@
"""Git module."""
-223
View File
@@ -1,223 +0,0 @@
"""Git commands scoped to a workspace directory."""
import asyncio
import logging
from dataclasses import dataclass
from src.models import Workspace
logger = logging.getLogger(__name__)
@dataclass
class GitStatus:
"""Parsed git status output."""
branch: str
modified: list[str]
added: list[str]
deleted: list[str]
untracked: list[str]
ahead: int = 0
behind: int = 0
@dataclass
class Commit:
"""A single git commit."""
hash: str
message: str
author: str
date: str
class GitOperations:
"""Run git commands within a workspace directory."""
def __init__(self, workspace: Workspace) -> None:
self.cwd = workspace.path
self.branch = workspace.branch
async def _run(self, *cmd: str) -> tuple[int, str, str]:
"""Run a git command and return (returncode, stdout, stderr)."""
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode or 0, stdout.decode(), stderr.decode()
async def status(self) -> GitStatus:
"""Get git status for the workspace."""
returncode, stdout, _ = await self._run(
"git", "-C", self.cwd, "status", "--porcelain", "-b"
)
modified: list[str] = []
added: list[str] = []
deleted: list[str] = []
untracked: list[str] = []
branch = self.branch
ahead = 0
behind = 0
for line in stdout.splitlines():
if line.startswith("##"):
# Branch info line
branch_info = line[3:].strip()
if "..." in branch_info:
branch = branch_info.split("...")[0]
if "[ahead " in branch_info:
ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
ahead = int(ahead_str.split(",")[0])
if "[behind " in branch_info:
behind_str = branch_info.split("[behind ")[1].split("]")[0]
behind = int(behind_str.split(",")[0])
else:
branch = branch_info
continue
if len(line) < 3:
continue
status_code = line[:2]
file_path = line[3:]
# XY format: X = index status, Y = working tree status
if status_code == "??":
untracked.append(file_path)
elif status_code[1] == "D" or status_code[0] == "D":
deleted.append(file_path)
elif status_code[0] == "A" or status_code[1] == "A":
added.append(file_path)
else:
modified.append(file_path)
return GitStatus(
branch=branch,
modified=modified,
added=added,
deleted=deleted,
untracked=untracked,
ahead=ahead,
behind=behind,
)
async def commit(self, message: str) -> None:
"""Stage all changes and commit."""
rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A")
if rc != 0:
raise RuntimeError(f"Git add failed: {err}")
rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
if rc != 0:
raise RuntimeError(f"Git commit failed: {err}")
logger.info("Committed in workspace: %s", self.cwd)
async def push(self) -> None:
"""Push current branch to origin."""
rc, _, err = await self._run(
"git", "-C", self.cwd, "push", "origin", self.branch
)
if rc != 0:
raise RuntimeError(f"Git push failed: {err}")
logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd)
async def pull(self) -> None:
"""Pull current branch from origin."""
rc, _, err = await self._run(
"git", "-C", self.cwd, "pull", "origin", self.branch
)
if rc != 0:
raise RuntimeError(f"Git pull failed: {err}")
logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd)
async def fetch(self) -> None:
"""Fetch from origin."""
rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin")
if rc != 0:
raise RuntimeError(f"Git fetch failed: {err}")
logger.info("Fetched origin for workspace: %s", self.cwd)
async def checkout(self, branch: str) -> None:
"""Checkout a branch."""
rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch)
if rc != 0:
raise RuntimeError(f"Git checkout failed: {err}")
self.branch = branch
logger.info("Checked out branch %s in workspace: %s", branch, self.cwd)
async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
"""Get commit history.
Args:
path: Optional file path to filter history.
limit: Maximum number of commits.
Returns:
List of commits.
"""
cmd = [
"git",
"-C",
self.cwd,
"log",
f"--max-count={limit}",
"--pretty=format:%H|%s|%an|%ad",
"--date=iso",
]
if path:
cmd.extend(["--", path])
rc, stdout, err = await self._run(*cmd)
if rc != 0:
raise RuntimeError(f"Git log failed: {err}")
commits = []
for line in stdout.strip().splitlines():
parts = line.split("|", 3)
if len(parts) >= 4:
commits.append(
Commit(
hash=parts[0],
message=parts[1],
author=parts[2],
date=parts[3],
)
)
return commits
async def branches(self) -> tuple[list[str], str]:
"""List all branches and current branch.
Returns:
Tuple of (all_branches, current_branch).
"""
rc, stdout, err = await self._run(
"git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)"
)
if rc != 0:
raise RuntimeError(f"Git branch failed: {err}")
branches = []
current = self.branch
for line in stdout.strip().splitlines():
line = line.strip()
if line.startswith("HEAD") or line.endswith("/HEAD"):
continue
if line.startswith("remotes/origin/"):
branch_name = line.replace("remotes/origin/", "")
if branch_name not in branches:
branches.append(branch_name)
elif line and line not in branches:
branches.append(line)
return branches, current
-176
View File
@@ -1,176 +0,0 @@
"""Git operations for workspace management."""
import asyncio
import logging
import os
import subprocess
import tempfile
logger = logging.getLogger(__name__)
class GitService:
"""Low-level git operations for creating and syncing workspaces."""
@staticmethod
def _prepare_ssh_env(
ssh_key: str | None,
) -> tuple[dict[str, str] | None, str | None]:
"""Prepare environment for git commands with SSH authentication.
Returns a tuple of (env_dict, temp_key_path). Caller must clean up key_path.
"""
if not ssh_key:
return None, None
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, ssh_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
return env, key_path
@staticmethod
async def clone(
remote_url: str, branch: str, path: str, ssh_key: str | None = None
) -> None:
"""Clone a repository to the given path.
Args:
remote_url: The git remote URL.
branch: The branch to clone.
path: The destination path for the clone.
ssh_key: Optional decrypted SSH private key for authentication.
Raises:
RuntimeError: If the clone fails.
"""
cmd = [
"git",
"clone",
"--branch",
branch,
"--single-branch",
remote_url,
path,
]
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **env} if env else None,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git clone failed: %s", error_msg)
raise RuntimeError(f"Git clone failed: {error_msg}")
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@staticmethod
async def fetch(path: str, ssh_key: str | None = None) -> None:
"""Fetch from origin.
Args:
path: The path to the local git repository.
ssh_key: Optional decrypted SSH private key for authentication.
Raises:
RuntimeError: If fetch fails.
"""
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
path,
"fetch",
"origin",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **env} if env else None,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git fetch failed: %s", error_msg)
raise RuntimeError(f"Git fetch failed: {error_msg}")
logger.debug("Fetched origin for %s", path)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@staticmethod
async def pull(path: str, branch: str, ssh_key: str | None = None) -> None:
"""Pull latest changes from origin.
Args:
path: The path to the local git repository.
branch: The branch to pull.
ssh_key: Optional decrypted SSH private key for authentication.
Raises:
RuntimeError: If pull fails.
"""
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
path,
"pull",
"origin",
branch,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **env} if env else None,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git pull failed: %s", error_msg)
raise RuntimeError(f"Git pull failed: {error_msg}")
logger.debug("Pulled origin/%s for %s", branch, path)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@staticmethod
def branch_exists_remotely(
path: str, branch: str, ssh_key: str | None = None
) -> bool:
"""Check if a branch exists on the remote.
Args:
path: The path to the local git repository.
branch: The branch name to check.
ssh_key: Optional decrypted SSH private key for authentication.
Returns:
True if the branch exists on origin, False otherwise.
"""
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
result = subprocess.run(
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
capture_output=True,
text=True,
env={**os.environ, **env} if env else None,
)
exists = result.returncode == 0 and result.stdout.strip() != ""
logger.debug("Branch %s exists on remote: %s", branch, exists)
return exists
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
+3 -4
View File
@@ -10,11 +10,10 @@ 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 import HealthCheck from src.models.health_check import HealthCheck
from src.models import ToolInstance from src.models.tool_instance 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 check_tunnel_health, get_container_status
from src.services.tunnel import check_tunnel_health
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
@@ -1 +0,0 @@
"""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 import InstanceEvent from src.models.instance_event import InstanceEvent
from src.models import ToolInstance from src.models.tool_instance 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
+2 -37
View File
@@ -26,7 +26,7 @@ def resolve_base(manifest: dict) -> dict:
result = deepcopy(manifest) result = deepcopy(manifest)
base_definition_id = result.pop("base_definition_id", None) base_definition_id = result.pop("base_definition_id", None)
result.pop("base_version", None) base_version = result.pop("base_version", "latest")
if base_definition_id: if base_definition_id:
# This will be provided by the caller (they have the DB session) # This will be provided by the caller (they have the DB session)
@@ -118,11 +118,6 @@ def compile_dockerfile(manifest: dict) -> str:
# System packages (apt) # System packages (apt)
apt_packages = manifest.get("packages", {}).get("apt", []) apt_packages = manifest.get("packages", {}).get("apt", [])
if manifest.get("user"):
# Ensure sudo is available for permission-fixing startup scripts
apt_packages = list(apt_packages)
if "sudo" not in apt_packages:
apt_packages.append("sudo")
if apt_packages: if apt_packages:
lines.append("RUN apt-get update && apt-get install -y \\") lines.append("RUN apt-get update && apt-get install -y \\")
for pkg in apt_packages[:-1]: for pkg in apt_packages[:-1]:
@@ -172,17 +167,6 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f"ENV HOME={home}") lines.append(f"ENV HOME={home}")
lines.append(f"ENV USER={name}") lines.append(f"ENV USER={name}")
lines.append("") lines.append("")
# Ensure home directory exists and is writable by the user
lines.append(
f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}"
)
lines.append("")
# Configure passwordless sudo so startup scripts can fix permissions
lines.append(
f'RUN echo "{name} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/{name} && chmod 0440 /etc/sudoers.d/{name}'
)
lines.append("")
# Build scripts # Build scripts
build_scripts = manifest.get("scripts", {}).get("build", []) build_scripts = manifest.get("scripts", {}).get("build", [])
@@ -197,11 +181,6 @@ def compile_dockerfile(manifest: dict) -> str:
if build_scripts: if build_scripts:
lines.append("") lines.append("")
# After build scripts, ensure everything in home is owned by the user
if user and build_scripts:
lines.append(f"RUN chown -R {name}:{name} {home}")
lines.append("")
# Create mount target directories # Create mount target directories
mounts = manifest.get("mounts", []) mounts = manifest.get("mounts", [])
if mounts: if mounts:
@@ -329,21 +308,7 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
service["volumes"] = sort_volumes_by_specificity(volumes) service["volumes"] = sort_volumes_by_specificity(volumes)
compose = {"services": {"app": service}} compose = {"services": {"app": service}}
result = yaml.dump(compose, default_flow_style=False) return yaml.dump(compose, default_flow_style=False)
# Debug: log mount resolution so we can diagnose missing mounts
import logging
logger = logging.getLogger(__name__)
logger.debug(
"compile_compose: REPO_PATH=%s SSH_PATH=%s EXTRA_VOLUMES=%s mounts=%s volumes=%s",
variables.get("REPO_PATH", "<empty>"),
variables.get("SSH_PATH", "<empty>"),
variables.get("EXTRA_VOLUMES", []),
manifest.get("mounts", []),
volumes,
)
return result
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str: def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
@@ -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 import Notification from src.models.notification import Notification
class NotificationService: class NotificationService:
-1
View File
@@ -1 +0,0 @@
"""Shared module."""
+26 -99
View File
@@ -2,7 +2,6 @@
import logging import logging
import os import os
import re
from pathlib import Path from pathlib import Path
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
@@ -23,28 +22,12 @@ def _get_fernet() -> Fernet:
return Fernet(key) return Fernet(key)
def _sanitize_filename(name: str) -> str:
"""Sanitize a string for use as a filename.
Replaces non-alphanumeric characters with underscores and strips
leading/trailing underscores.
"""
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
sanitized = sanitized.strip("_")
# Ensure it's not empty
if not sanitized:
sanitized = "key"
return sanitized
def prepare_ssh_key_files( def prepare_ssh_key_files(
instance_dir: str, instance_dir: str,
ssh_key, ssh_key,
subdir: str = ".ssh", subdir: str = ".ssh",
uid: int | None = None, uid: int | None = None,
gid: int | None = None, gid: int | None = None,
key_filename: str = "id_ed25519",
write_config: bool = True,
) -> str: ) -> str:
"""Decrypt and write SSH key files to instance directory for container mounting. """Decrypt and write SSH key files to instance directory for container mounting.
@@ -54,12 +37,6 @@ def prepare_ssh_key_files(
subdir: Subdirectory within instance_dir to write to (default: ".ssh") subdir: Subdirectory within instance_dir to write to (default: ".ssh")
uid: Optional UID to own the files (for bind-mount into non-root container) uid: Optional UID to own the files (for bind-mount into non-root container)
gid: Optional GID to own the files gid: Optional GID to own the files
key_filename: Base filename for the key pair (default: "id_ed25519").
The private key will be named "{key_filename}" and the public key
"{key_filename}.pub".
write_config: Whether to write an SSH config file (default: True).
Set to False when combining multiple keys into one directory,
then call write_ssh_config() separately.
Returns: Returns:
Path to the .ssh directory Path to the .ssh directory
@@ -72,101 +49,51 @@ def prepare_ssh_key_files(
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
# Write private key with restricted permissions # Write private key with restricted permissions
private_key_path = ssh_dir / key_filename private_key_path = ssh_dir / "id_ed25519"
private_key_path.write_text(private_key) private_key_path.write_text(private_key)
os.chmod(private_key_path, 0o600) os.chmod(private_key_path, 0o600)
# Write public key # Write public key
public_key_path = ssh_dir / f"{key_filename}.pub" public_key_path = ssh_dir / "id_ed25519.pub"
public_key_path.write_text(ssh_key.public_key) public_key_path.write_text(ssh_key.public_key)
os.chmod(public_key_path, 0o644) os.chmod(public_key_path, 0o644)
# Write SSH config (only if requested) # Write SSH config
if write_config: config_path = ssh_dir / "config"
config_path = ssh_dir / "config" config_content = """Host *
config_content = f"""Host *
StrictHostKeyChecking no StrictHostKeyChecking no
UserKnownHostsFile /dev/null UserKnownHostsFile /dev/null
IdentityFile ~/.ssh/{key_filename} IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes IdentitiesOnly yes
""" """
config_path.write_text(config_content)
os.chmod(config_path, 0o644)
# Set ownership to target container user if requested
if uid is not None or gid is not None:
effective_uid = uid if uid is not None else -1
effective_gid = gid if gid is not None else -1
try:
os.chown(ssh_dir, effective_uid, effective_gid)
os.chown(private_key_path, effective_uid, effective_gid)
os.chown(public_key_path, effective_uid, effective_gid)
os.chown(config_path, effective_uid, effective_gid)
logger.debug(
"Set SSH key ownership to uid=%s gid=%s for %s",
effective_uid,
effective_gid,
ssh_dir,
)
except PermissionError as exc:
logger.warning(
"Cannot chown SSH keys to uid=%s gid=%s (running as uid=%s): %s",
effective_uid,
effective_gid,
os.getuid(),
exc,
)
else:
# Still chown the key files even if we didn't write config
if uid is not None or gid is not None:
effective_uid = uid if uid is not None else -1
effective_gid = gid if gid is not None else -1
try:
os.chown(private_key_path, effective_uid, effective_gid)
os.chown(public_key_path, effective_uid, effective_gid)
except PermissionError:
pass
return str(ssh_dir)
def write_ssh_config(
ssh_dir: str,
key_filenames: list[str],
uid: int | None = None,
gid: int | None = None,
) -> None:
"""Write an SSH config file that includes multiple IdentityFile entries.
Args:
ssh_dir: Path to the .ssh directory
key_filenames: List of key filenames (without .pub extension)
uid: Optional UID to own the config file
gid: Optional GID to own the config file
"""
ssh_dir_path = Path(ssh_dir)
ssh_dir_path.mkdir(parents=True, exist_ok=True)
config_path = ssh_dir_path / "config"
lines = ["Host *"]
lines.append(" StrictHostKeyChecking no")
lines.append(" UserKnownHostsFile /dev/null")
lines.append(" IdentitiesOnly yes")
for filename in key_filenames:
lines.append(f" IdentityFile ~/.ssh/{filename}")
lines.append("")
config_content = "\n".join(lines)
config_path.write_text(config_content) config_path.write_text(config_content)
os.chmod(config_path, 0o644) os.chmod(config_path, 0o644)
# Set ownership to target container user if requested
if uid is not None or gid is not None: if uid is not None or gid is not None:
effective_uid = uid if uid is not None else -1 effective_uid = uid if uid is not None else -1
effective_gid = gid if gid is not None else -1 effective_gid = gid if gid is not None else -1
try: try:
os.chown(ssh_dir, effective_uid, effective_gid)
os.chown(private_key_path, effective_uid, effective_gid)
os.chown(public_key_path, effective_uid, effective_gid)
os.chown(config_path, effective_uid, effective_gid) os.chown(config_path, effective_uid, effective_gid)
except PermissionError: logger.debug(
pass "Set SSH key ownership to uid=%s gid=%s for %s",
effective_uid,
effective_gid,
ssh_dir,
)
except PermissionError as exc:
logger.warning(
"Cannot chown SSH keys to uid=%s gid=%s (running as uid=%s): %s",
effective_uid,
effective_gid,
os.getuid(),
exc,
)
return str(ssh_dir)
def cleanup_ssh_key_files(instance_dir: str) -> None: def cleanup_ssh_key_files(instance_dir: str) -> None:
@@ -1 +0,0 @@
"""Terminal module."""
+10 -19
View File
@@ -6,10 +6,9 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import WebSocket from fastapi import WebSocket
from sqlalchemy.dialects.postgresql import insert as pg_insert
from src.database import SessionLocal from src.database import SessionLocal
from src.models import TerminalSessionModel from src.models.terminal_session import TerminalSessionModel
from src.services.terminal_session import TerminalSession from src.services.terminal_session import TerminalSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -84,26 +83,18 @@ class TerminalManager:
instance_id: uuid.UUID, instance_id: uuid.UUID,
name: str, name: str,
) -> None: ) -> None:
"""Insert a TerminalSessionModel row into the database. """Insert a TerminalSessionModel row into the database."""
Uses ON CONFLICT DO NOTHING to handle races when a session is
restored from DB and then re-inserted.
"""
try: try:
async with SessionLocal() as db_session: async with SessionLocal() as db_session:
stmt = ( db_row = TerminalSessionModel(
pg_insert(TerminalSessionModel) id=uuid.UUID(session_id),
.values( instance_id=instance_id,
id=uuid.UUID(session_id), name=name,
instance_id=instance_id, status="active",
name=name, created_at=datetime.now(timezone.utc),
status="active", last_activity_at=datetime.now(timezone.utc),
created_at=datetime.now(timezone.utc),
last_activity_at=datetime.now(timezone.utc),
)
.on_conflict_do_nothing(index_elements=["id"])
) )
await db_session.execute(stmt) db_session.add(db_row)
await db_session.commit() await db_session.commit()
logger.debug( logger.debug(
"Inserted terminal session row %s for instance %s", "Inserted terminal session row %s for instance %s",
+67 -256
View File
@@ -1,13 +1,10 @@
"""High-performance terminal session with asyncio-native I/O. """Terminal session management for tool instances."""
Replaces blocking select.select() with event-driven asyncio.add_reader()
for sub-frame latency. Includes output batching and flow control.
"""
import asyncio import asyncio
import logging import logging
import os import os
import pty import pty
import select
import signal import signal
import struct import struct
import fcntl import fcntl
@@ -20,31 +17,18 @@ logger = logging.getLogger(__name__)
class TerminalSession: class TerminalSession:
"""Manages a single terminal session with event-driven PTY I/O. """Manages a single terminal session connected to a docker container.
Uses asyncio.add_reader() instead of polling for near-zero read latency. Supports persistent sessions that survive WebSocket disconnections.
Output is batched (2ms window) and sent as binary WebSocket frames. Multiple WebSocket connections can attach/detach from the same session.
Flow control prevents memory bloat on fast output.
""" """
# Circular buffer for replay (10KB) # Circular buffer size (10KB)
BUFFER_SIZE = 10 * 1024 BUFFER_SIZE = 10 * 1024
# Idle timeout in seconds (30 minutes) # Idle timeout in seconds (30 minutes)
IDLE_TIMEOUT = 30 * 60 IDLE_TIMEOUT = 30 * 60
# Output batching window in seconds
BATCH_WINDOW_S = 0.002 # 2ms
# Flow control: pause PTY reads when unacknowledged bytes exceed this
FLOW_CONTROL_PAUSE = 64 * 1024
# Flow control: resume PTY reads when unacknowledged bytes drop below this
FLOW_CONTROL_RESUME = 32 * 1024
# Max WebSocket frame size
MAX_FRAME_SIZE = 64 * 1024
# Session number counter per instance_id for auto-naming # Session number counter per instance_id for auto-naming
_instance_counters: dict[str, int] = {} _instance_counters: dict[str, int] = {}
@@ -63,6 +47,7 @@ class TerminalSession:
self.process: asyncio.subprocess.Process | None = None self.process: asyncio.subprocess.Process | None = None
self._closed = False self._closed = False
self._master_fd: int | None = None self._master_fd: int | None = None
self._slave_fd: int | None = None
# Circular buffer for output replay # Circular buffer for output replay
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE) self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
@@ -82,20 +67,6 @@ class TerminalSession:
self.name = name or self._generate_name(str(instance_id)) self.name = name or self._generate_name(str(instance_id))
self.status: str = "active" self.status: str = "active"
# Output batching
self._batch_buffer = bytearray()
self._batch_timer: asyncio.TimerHandle | None = None
self._batch_lock = asyncio.Lock()
# Flow control
self._unacknowledged_bytes = 0
self._paused = False
self._read_handler_set = False
self._flow_control_lock = asyncio.Lock()
# Ack timeout fallback
self._ack_timeout_handle: asyncio.TimerHandle | None = None
@classmethod @classmethod
def _generate_name(cls, instance_id: str) -> str: def _generate_name(cls, instance_id: str) -> str:
"""Generate an auto-incremented session name for the instance.""" """Generate an auto-incremented session name for the instance."""
@@ -106,225 +77,91 @@ class TerminalSession:
async def start(self, startup_command: str | None = None) -> None: async def start(self, startup_command: str | None = None) -> None:
"""Start the docker exec process with a shell using a PTY.""" """Start the docker exec process with a shell using a PTY."""
# Create a pseudo-terminal on the host # Create a pseudo-terminal on the host
self._master_fd, slave_fd = pty.openpty() self._master_fd, self._slave_fd = pty.openpty()
# Set the terminal size initially # Set the terminal size initially
self._set_terminal_size(self._cols, self._rows) self._set_terminal_size(self._cols, self._rows)
logger.debug( logger.debug(
"Starting terminal session %s for container %s with initial size %sx%s", f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}"
self.session_id,
self.container_id,
self._cols,
self._rows,
) )
# Build the shell command # Build the shell command
cmd = startup_command or self.startup_command if startup_command:
if cmd: shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
shell_cmd = f'bash -c "{cmd}" || true; exec bash -il'
logger.debug( logger.debug(
"Using startup command for session %s: %s", f"Using startup command for session {self.session_id}: {startup_command}"
self.session_id,
cmd,
) )
else: else:
shell_cmd = "bash -il" shell_cmd = "bash -il"
# Start docker exec with the slave fd as stdin/stdout/stderr # Start docker exec with the slave fd as stdin/stdout/stderr
# Using -it because the slave fd IS a TTY
self.process = await asyncio.create_subprocess_exec( self.process = await asyncio.create_subprocess_exec(
"docker", "docker",
"exec", "exec",
"-it", "-it",
"-e", "-e",
"TERM=xterm-256color", "TERM=xterm",
self.container_id, self.container_id,
"bash", "bash",
"-c", "-c",
shell_cmd, shell_cmd,
stdin=slave_fd, stdin=self._slave_fd,
stdout=slave_fd, stdout=self._slave_fd,
stderr=slave_fd, stderr=self._slave_fd,
) )
# Close slave fd in parent process # Close slave fd in parent process
os.close(slave_fd) os.close(self._slave_fd)
self._slave_fd = None
self.last_activity = time.time() self.last_activity = time.time()
# Start event-driven reading def _set_terminal_size(self, cols: int, rows: int) -> None:
self._start_reading() """Set the terminal size using TIOCSWINSZ."""
if self._master_fd is None:
def _start_reading(self) -> None: logger.warning("Cannot resize: master_fd is None (session not started)")
"""Register PTY master fd with asyncio event loop for event-driven reads."""
if self._read_handler_set or self._master_fd is None or self._closed:
return return
# TIOCSWINSZ = 0x5414 on Linux
TIOCSWINSZ = 0x5414
size = struct.pack("HHHH", rows, cols, 0, 0)
try: try:
loop = asyncio.get_event_loop() fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
loop.add_reader(self._master_fd, self._on_fd_readable) logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
self._read_handler_set = True except (OSError, IOError) as e:
logger.debug("Started event-driven reading for session %s", self.session_id) logger.error(f"Failed to resize PTY: {e}")
except Exception as exc:
logger.error(
"Failed to start reading for session %s: %s", self.session_id, exc
)
def _stop_reading(self) -> None: async def read_output(self) -> bytes:
"""Unregister PTY master fd from asyncio event loop.""" """Read output from the PTY master and store in buffer."""
if not self._read_handler_set or self._master_fd is None:
return
try:
loop = asyncio.get_event_loop()
loop.remove_reader(self._master_fd)
self._read_handler_set = False
except Exception:
pass
def _on_fd_readable(self) -> None:
"""Callback when PTY master fd has data available (called by event loop)."""
if self._master_fd is None or self._closed: if self._master_fd is None or self._closed:
return return b""
try: try:
data = os.read(self._master_fd, 4096) # Use select to check if data is available
except (OSError, IOError) as exc: readable, _, _ = select.select([self._master_fd], [], [], 0.1)
logger.debug("PTY read error for session %s: %s", self.session_id, exc) if readable:
self._handle_eof() data = os.read(self._master_fd, 4096)
return if data:
self._add_to_buffer(data)
if not data: self.last_activity = time.time()
# EOF: docker exec process exited return data
logger.debug("PTY EOF for session %s", self.session_id) return b""
self._handle_eof() except (OSError, IOError, ValueError):
return return b""
self._add_to_buffer(data)
self.last_activity = time.time()
# Queue for batching + flow control
self._queue_output(data)
def _add_to_buffer(self, data: bytes) -> None: def _add_to_buffer(self, data: bytes) -> None:
"""Add data to circular buffer, maintaining size limit.""" """Add data to circular buffer, maintaining size limit."""
self._output_buffer.append(data) self._output_buffer.append(data)
self._buffer_size += len(data) self._buffer_size += len(data)
# Trim if exceeds max size
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer: while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
removed = self._output_buffer.popleft() removed = self._output_buffer.popleft()
self._buffer_size -= len(removed) self._buffer_size -= len(removed)
def _queue_output(self, data: bytes) -> None:
"""Add output to batch buffer and schedule flush."""
self._batch_buffer.extend(data)
self._unacknowledged_bytes += len(data)
# Check flow control
if self._unacknowledged_bytes > self.FLOW_CONTROL_PAUSE and not self._paused:
self._pause_output()
# Schedule batch flush if not already scheduled
if self._batch_timer is None:
loop = asyncio.get_event_loop()
self._batch_timer = loop.call_later(
self.BATCH_WINDOW_S,
self._flush_batch_sync,
)
def _flush_batch_sync(self) -> None:
"""Synchronous entry point for batch flush (called from event loop)."""
self._batch_timer = None
if not self._batch_buffer or not self._websockets:
self._batch_buffer.clear()
return
payload = bytes(self._batch_buffer)
self._batch_buffer.clear()
# Send to all websockets (asyncio.create_task for async send)
dead_sockets = set()
for ws in list(self._websockets):
try:
asyncio.create_task(self._send_bytes(ws, payload))
except Exception:
dead_sockets.add(ws)
if dead_sockets:
self._websockets -= dead_sockets
async def _send_bytes(self, ws: Any, payload: bytes) -> None:
"""Send bytes to a single websocket, catching errors."""
try:
await ws.send_bytes(payload)
except Exception:
self._websockets.discard(ws)
def acknowledge_data(self, char_count: int) -> None:
"""Client acknowledges processing char_count bytes.
Called from the WebSocket handler when the client sends an 'ack' message.
"""
self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count)
if self._paused and self._unacknowledged_bytes < self.FLOW_CONTROL_RESUME:
self._resume_output()
# Reset ack timeout
if self._ack_timeout_handle:
self._ack_timeout_handle.cancel()
loop = asyncio.get_event_loop()
self._ack_timeout_handle = loop.call_later(5.0, self._ack_timeout_fallback)
def _ack_timeout_fallback(self) -> None:
"""If no ack received for 5s, assume client is dead and resume."""
logger.warning(
"Flow control ack timeout for session %s, resuming output",
self.session_id,
)
self._unacknowledged_bytes = 0
if self._paused:
self._resume_output()
def _pause_output(self) -> None:
"""Pause reading from PTY due to flow control."""
self._paused = True
self._stop_reading()
logger.debug(
"Paused output for session %s (%d unacked)",
self.session_id,
self._unacknowledged_bytes,
)
def _resume_output(self) -> None:
"""Resume reading from PTY."""
self._paused = False
self._start_reading()
logger.debug("Resumed output for session %s", self.session_id)
def get_buffer(self) -> bytes: def get_buffer(self) -> bytes:
"""Get buffered output for replay.""" """Get buffered output for replay."""
return b"".join(self._output_buffer) return b"".join(self._output_buffer)
def _handle_eof(self) -> None:
"""Handle PTY EOF: process died, close websockets to force reconnect."""
self._stop_reading()
# Mark process as done so is_alive() returns False
if self.process is not None and self.process.returncode is None:
# Force returncode to a non-None value since the process is dead
# but asyncio.subprocess may not have set it yet
try:
self.process._transport.close() # type: ignore[attr-defined]
except Exception:
pass
# Close all websockets to force frontend reconnection
dead_sockets = set(self._websockets)
self._websockets.clear()
for ws in dead_sockets:
try:
asyncio.create_task(
ws.close(code=4001, reason="Session process exited")
)
except Exception:
pass
logger.info("Session %s EOF handled, websockets closed", self.session_id)
async def write_input(self, data: bytes) -> None: async def write_input(self, data: bytes) -> None:
"""Write input to the PTY master.""" """Write input to the PTY master."""
if self._master_fd is None or self._closed: if self._master_fd is None or self._closed:
@@ -332,22 +169,8 @@ class TerminalSession:
try: try:
os.write(self._master_fd, data) os.write(self._master_fd, data)
self.last_activity = time.time() self.last_activity = time.time()
except (OSError, IOError) as exc: except (OSError, IOError):
logger.debug("PTY write error for session %s: %s", self.session_id, exc) pass
self._handle_eof()
def _set_terminal_size(self, cols: int, rows: int) -> None:
"""Set the terminal size using TIOCSWINSZ."""
if self._master_fd is None:
logger.warning("Cannot resize: master_fd is None (session not started)")
return
TIOCSWINSZ = 0x5414
size = struct.pack("HHHH", rows, cols, 0, 0)
try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
logger.debug("Resized PTY to %sx%s (fd=%s)", cols, rows, self._master_fd)
except (OSError, IOError) as e:
logger.error("Failed to resize PTY: %s", e)
async def resize(self, cols: int, rows: int) -> None: async def resize(self, cols: int, rows: int) -> None:
"""Resize the terminal.""" """Resize the terminal."""
@@ -355,24 +178,32 @@ class TerminalSession:
logger.warning("Cannot resize: session is closed") logger.warning("Cannot resize: session is closed")
return return
# Only resize if dimensions actually changed
if cols == self._cols and rows == self._rows: if cols == self._cols and rows == self._rows:
return return
self._cols = cols self._cols = cols
self._rows = rows self._rows = rows
logger.debug( logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
"resize() called for session %s: %sx%s", self.session_id, cols, rows
)
self._set_terminal_size(cols, rows) self._set_terminal_size(cols, rows)
# Send SIGWINCH to docker exec process # Docker exec -it creates its own PTY inside the container,
# so host PTY resize doesn't propagate to the container shell.
# Send SIGWINCH to the docker exec process on the host.
# Docker exec forwards signals to the container process, which should
# cause the container's shell to re-read its terminal size.
if self.process and self.process.pid: if self.process and self.process.pid:
try: try:
os.kill(self.process.pid, signal.SIGWINCH) os.kill(self.process.pid, signal.SIGWINCH)
logger.debug(
f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}"
)
except ProcessLookupError: except ProcessLookupError:
logger.warning("docker exec process %s not found", self.process.pid) logger.warning(
f"docker exec process {self.process.pid} not found for session {self.session_id}"
)
except Exception as e: except Exception as e:
logger.warning("Failed to send SIGWINCH: %s", e) logger.warning(f"Failed to send SIGWINCH: {e}")
async def reset(self) -> None: async def reset(self) -> None:
"""Reset the session by killing the process and clearing state.""" """Reset the session by killing the process and clearing state."""
@@ -382,13 +213,9 @@ class TerminalSession:
self._output_buffer.clear() self._output_buffer.clear()
self._buffer_size = 0 self._buffer_size = 0
self._websockets.clear() self._websockets.clear()
self._batch_buffer.clear()
self._batch_timer = None
self._unacknowledged_bytes = 0
self._paused = False
self._read_handler_set = False
self.process = None self.process = None
self._master_fd = None self._master_fd = None
self._slave_fd = None
self.status = "active" self.status = "active"
async def close(self) -> None: async def close(self) -> None:
@@ -398,21 +225,11 @@ class TerminalSession:
self._closed = True self._closed = True
self.status = "closed" self.status = "closed"
self._stop_reading()
if self._batch_timer:
self._batch_timer.cancel()
self._batch_timer = None
if self._ack_timeout_handle:
self._ack_timeout_handle.cancel()
self._ack_timeout_handle = None
if self._master_fd is not None: if self._master_fd is not None:
try: try:
os.close(self._master_fd) os.close(self._master_fd)
except OSError: except OSError:
pass pass # noqa: S110
self._master_fd = None self._master_fd = None
if self.process is not None: if self.process is not None:
@@ -448,20 +265,14 @@ class TerminalSession:
return len(self._websockets) > 0 return len(self._websockets) > 0
async def send_to_all(self, data: bytes) -> None: async def send_to_all(self, data: bytes) -> None:
"""Send data to all attached WebSockets (used for control messages).""" """Send data to all attached WebSockets."""
dead_sockets = set() dead_sockets = set()
for ws in self._websockets: for ws in self._websockets:
try: try:
await ws.send_bytes(data) await ws.send_bytes(data)
except Exception: except Exception:
dead_sockets.add(ws) dead_sockets.add(ws)
# Clean up dead sockets
for ws in dead_sockets: for ws in dead_sockets:
self._websockets.discard(ws) self._websockets.discard(ws)
async def read_output(self) -> bytes:
"""Legacy method: read output synchronously.
With event-driven I/O, output is automatically sent to websockets.
This method returns any buffered data for callers that poll.
"""
return b""
-283
View File
@@ -1,283 +0,0 @@
"""Clean tunnel service using cloudflared containers on the backend network.
Design:
- 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).
- This avoids host port conflicts and DNS resolution issues.
"""
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
# 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_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),
}
-257
View File
@@ -1,257 +0,0 @@
"""Workspace lifecycle management service."""
from __future__ import annotations
import contextlib
import logging
import os
import shutil
import stat
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import select
from src.models import Workspace
from src.services.git_service import GitService
from src.services.ssh_keys import _get_fernet
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import GitRepository
from src.models import ToolInstance
logger = logging.getLogger(__name__)
@dataclass
class SyncResult:
"""Result of a workspace sync operation."""
branch_deleted: bool = False
class WorkspaceHasInstancesError(Exception):
"""Raised when attempting to delete a workspace with running instances."""
def __init__(self, instances: list[dict]) -> None:
self.instances = instances
super().__init__(f"Workspace has {len(instances)} running tool instance(s)")
class WorkspaceManager:
"""Manages workspace lifecycle: create, delete, sync, validate."""
BASE_PATH = "/data/working-copies"
def _workspace_path(self, repo_id: uuid.UUID, name: str) -> str:
"""Return the filesystem path for a workspace."""
return os.path.join(self.BASE_PATH, str(repo_id), name)
async def create(
self,
repo: GitRepository,
user_id: uuid.UUID,
name: str,
branch: str = "main",
session: AsyncSession | None = None,
) -> Workspace:
"""Clone repo to workspace path and create DB record.
Args:
repo: The git repository to clone.
user_id: The owner user ID.
name: The workspace name (unique per repo).
branch: The branch to clone (default: "main").
session: Database session for loading SSH keys.
Returns:
The created Workspace record.
Raises:
RuntimeError: If git clone fails.
"""
path = self._workspace_path(repo.id, name)
parent = os.path.dirname(path)
os.makedirs(parent, exist_ok=True)
# Ensure container users (various UIDs) can write to workspace dirs
with contextlib.suppress(OSError):
os.chmod(parent, 0o777)
logger.info(
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
)
if not repo.remote_url:
raise ValueError("Repository has no remote URL")
# Remove stale directory from previous failed/aborted clone
if os.path.exists(path):
logger.warning("Removing stale workspace directory: %s", path)
shutil.rmtree(path, ignore_errors=True)
# Load SSH key if repo has one
ssh_key = None
if getattr(repo, "ssh_key_id", None) and session is not None:
from src.models import SSHKey
result = await session.execute(
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
)
ssh_key_obj = result.scalar_one_or_none()
if ssh_key_obj:
fernet = _get_fernet()
ssh_key = fernet.decrypt(
ssh_key_obj.private_key_encrypted.encode()
).decode()
await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key)
self._make_world_writable(path)
workspace = Workspace(
name=name,
repo_id=repo.id,
user_id=user_id,
branch=branch,
path=path,
status="ready",
last_sync_at=datetime.now(),
)
logger.info("Workspace created: %s", workspace.id)
return workspace
async def delete(
self,
workspace: Workspace,
force: bool = False,
session: AsyncSession | None = None,
) -> None:
"""Delete a workspace and all associated tool instances.
Args:
workspace: The workspace to delete.
force: If True, delete even if instances exist.
session: The database session (required for checking instances).
Raises:
WorkspaceHasInstancesError: If instances exist and force=False.
"""
if session is None:
raise ValueError("session is required for delete")
instances = await self._get_instances(workspace, session)
if instances and not force:
raise WorkspaceHasInstancesError(
[{"id": str(i.id), "name": i.name} for i in instances]
)
# Stop and delete all instances
for instance in instances:
await self._stop_and_delete_instance(instance)
# Delete directory
if os.path.exists(workspace.path):
shutil.rmtree(workspace.path, ignore_errors=True)
logger.info("Deleted workspace directory: %s", workspace.path)
# Delete record
await session.delete(workspace)
logger.info("Deleted workspace record: %s", workspace.id)
async def sync(
self, workspace: Workspace, session: AsyncSession | None = None
) -> SyncResult:
"""Sync a workspace with its remote.
Args:
workspace: The workspace to sync.
session: Database session for loading SSH keys.
Returns:
SyncResult indicating whether the branch was deleted.
Raises:
RuntimeError: If git operations fail.
"""
logger.info("Syncing workspace: %s", workspace.id)
# Load SSH key if repo has one
ssh_key = None
if session is not None:
from src.models import GitRepository
from src.models import SSHKey
repo = await session.get(GitRepository, workspace.repo_id)
if repo and getattr(repo, "ssh_key_id", None):
result = await session.execute(
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
)
ssh_key_obj = result.scalar_one_or_none()
if ssh_key_obj:
fernet = _get_fernet()
ssh_key = fernet.decrypt(
ssh_key_obj.private_key_encrypted.encode()
).decode()
await GitService.fetch(workspace.path, ssh_key=ssh_key)
if not GitService.branch_exists_remotely(
workspace.path, workspace.branch, ssh_key=ssh_key
):
return SyncResult(branch_deleted=True)
await GitService.pull(workspace.path, workspace.branch, ssh_key=ssh_key)
self._make_world_writable(workspace.path)
workspace.last_sync_at = datetime.now()
logger.info("Workspace synced: %s", workspace.id)
return SyncResult(branch_deleted=False)
def _make_world_writable(self, path: str) -> None:
"""Recursively make path readable/writable/traversable by any UID.
Directories get 777 (traversable). Files get rw for all while
preserving any existing execute bits.
"""
with contextlib.suppress(OSError):
os.chmod(path, 0o777)
for root, dirs, files in os.walk(path):
for d in dirs:
dpath = os.path.join(root, d)
with contextlib.suppress(OSError):
os.chmod(dpath, 0o777)
for f in files:
fpath = os.path.join(root, f)
with contextlib.suppress(OSError):
mode = os.stat(fpath).st_mode
# Preserve execute bits, ensure read+write for all
new_mode = (mode & stat.S_IXUSR) | 0o666
if mode & stat.S_IXGRP:
new_mode |= stat.S_IXGRP
if mode & stat.S_IXOTH:
new_mode |= stat.S_IXOTH
os.chmod(fpath, new_mode)
async def _get_instances(
self,
workspace: Workspace,
session: AsyncSession,
) -> list[ToolInstance]:
"""Get all tool instances associated with this workspace."""
from src.models import ToolInstance
result = await session.execute(
select(ToolInstance).where(ToolInstance.workspace_id == workspace.id)
)
return list(result.scalars().all())
async def _stop_and_delete_instance(self, instance: ToolInstance) -> None:
"""Stop and delete a tool instance.
TODO(PR-2): Wire up to actual instance stop/delete logic.
For now, this is a placeholder.
"""
logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
@@ -1,361 +0,0 @@
"""Integration tests for workspace API endpoints."""
import asyncio
import uuid
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models.workspace import Workspace
from src.services.workspace_manager import WorkspaceManager
def _get_user_id_from_client(client: TestClient) -> uuid.UUID:
"""Extract user ID from authenticated client session cookie."""
from src.auth.session import decode_session_cookie
from src.config import Settings
settings = Settings()
session_cookie = client.cookies.get("session")
if session_cookie:
session_data = decode_session_cookie(
settings=settings, cookie_value=session_cookie
)
if session_data:
return uuid.UUID(session_data["user_id"])
raise RuntimeError("Could not get user ID from authenticated client")
@pytest.fixture
def test_repo(db_session: AsyncSession, authenticated_client: TestClient):
"""Create a test repository."""
user_id = _get_user_id_from_client(authenticated_client)
async def _create():
project = Project(name="Test Project", owner_id=user_id)
db_session.add(project)
await db_session.flush()
repo = GitRepository(
name="test-repo",
path="/tmp/test-repo",
remote_url="https://github.com/test/repo.git",
project_id=project.id,
owner_id=user_id,
)
db_session.add(repo)
await db_session.commit()
await db_session.refresh(repo)
return repo
return asyncio.run(_create())
class TestListWorkspaces:
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
def test_list_empty(
self, authenticated_client: TestClient, test_repo: GitRepository
):
"""Returns empty list when no workspaces exist."""
response = authenticated_client.get(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
)
assert response.status_code == 200
assert response.json() == []
def test_list_with_workspaces(
self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
):
"""Returns workspaces with instance counts."""
ws = Workspace(
name="dev",
repo_id=test_repo.id,
user_id=test_repo.owner_id,
branch="main",
path="/data/working-copies/test/dev",
)
db_session.add(ws)
async def _commit():
await db_session.commit()
asyncio.run(_commit())
response = authenticated_client.get(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "dev"
assert data[0]["instance_count"] == 0
class TestCreateWorkspace:
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
def test_create_success(
self, authenticated_client: TestClient, test_repo: GitRepository
):
"""Creates a workspace and clones the repo."""
mock_ws = Workspace(
id=uuid.uuid4(),
name="feature-branch",
repo_id=test_repo.id,
user_id=test_repo.owner_id,
branch="feature",
path="/data/working-copies/test/feature-branch",
)
with patch.object(
WorkspaceManager, "create", return_value=mock_ws
) as mock_create:
response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
json={"name": "feature-branch", "branch": "feature"},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "feature-branch"
assert data["branch"] == "feature"
mock_create.assert_called_once()
def test_create_missing_name(
self, authenticated_client: TestClient, test_repo: GitRepository
):
"""Returns 400 when name is missing."""
response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
json={"branch": "main"},
)
assert response.status_code == 400
assert "name" in response.json()["detail"]
def test_create_duplicate_name(
self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
):
"""Returns 409 when workspace name already exists."""
ws = Workspace(
name="dev",
repo_id=test_repo.id,
user_id=test_repo.owner_id,
branch="main",
path="/data/working-copies/test/dev",
)
db_session.add(ws)
async def _commit():
await db_session.commit()
asyncio.run(_commit())
with patch.object(
WorkspaceManager, "create", side_effect=Exception("duplicate")
):
response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
json={"name": "dev", "branch": "main"},
)
assert response.status_code == 409
class TestDeleteWorkspace:
"""Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}."""
def test_delete_without_instances(
self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
):
"""Deletes workspace when no instances exist."""
ws = Workspace(
name="dev",
repo_id=test_repo.id,
user_id=test_repo.owner_id,
branch="main",
path="/data/working-copies/test/dev",
)
db_session.add(ws)
async def _commit_refresh():
await db_session.commit()
await db_session.refresh(ws)
asyncio.run(_commit_refresh())
with patch.object(WorkspaceManager, "delete", return_value=None):
response = authenticated_client.delete(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}"
)
assert response.status_code == 200
assert response.json()["status"] == "deleted"
@pytest.mark.skip(
reason="Async fixture interaction with sync tests — endpoint logic verified manually"
)
def test_delete_with_instances_no_force(
self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
):
"""Returns 409 when workspace has instances and force=False."""
ws = Workspace(
name="dev",
repo_id=test_repo.id,
user_id=test_repo.owner_id,
branch="main",
path="/data/working-copies/test/dev",
)
db_session.add(ws)
tool_type = ToolType(
name="test-tool",
display_name="Test Tool",
default_port=8080,
category="dev",
)
db_session.add(tool_type)
async def _flush():
await db_session.flush()
asyncio.run(_flush())
instance = ToolInstance(
name="test-instance",
display_name="Test Instance",
tool_type_id=tool_type.id,
repository_id=test_repo.id,
project_id=test_repo.project_id,
owner_id=test_repo.owner_id,
workspace_id=ws.id,
status="running",
)
db_session.add(instance)
async def _commit_refresh():
await db_session.commit()
await db_session.refresh(ws)
asyncio.run(_commit_refresh())
response = authenticated_client.delete(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}"
)
assert response.status_code == 409
detail = response.json()["detail"]
assert detail["message"] == "Workspace has running tool instances"
assert len(detail["instances"]) == 1
def test_delete_with_instances_force(
self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
):
"""Deletes workspace when force=True even with instances."""
ws = Workspace(
name="dev",
repo_id=test_repo.id,
user_id=test_repo.owner_id,
branch="main",
path="/data/working-copies/test/dev",
)
db_session.add(ws)
async def _commit_refresh():
await db_session.commit()
await db_session.refresh(ws)
asyncio.run(_commit_refresh())
with patch.object(WorkspaceManager, "delete", return_value=None):
response = authenticated_client.delete(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}?force=true"
)
assert response.status_code == 200
class TestSyncWorkspace:
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync."""
def test_sync_success(
self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
):
"""Sync succeeds and updates last_sync_at."""
ws = Workspace(
name="dev",
repo_id=test_repo.id,
user_id=test_repo.owner_id,
branch="main",
path="/data/working-copies/test/dev",
)
db_session.add(ws)
async def _commit_refresh():
await db_session.commit()
await db_session.refresh(ws)
asyncio.run(_commit_refresh())
with patch.object(
WorkspaceManager, "sync", return_value=MagicMock(branch_deleted=False)
):
response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}/sync"
)
assert response.status_code == 200
data = response.json()
assert data["branch_deleted"] is False
assert data["pulled"] is True
def test_sync_branch_deleted(
self,
authenticated_client: TestClient,
db_session: AsyncSession,
test_repo: GitRepository,
):
"""Returns 409 when branch was deleted from remote."""
ws = Workspace(
name="dev",
repo_id=test_repo.id,
user_id=test_repo.owner_id,
branch="feature-gone",
path="/data/working-copies/test/dev",
)
db_session.add(ws)
async def _commit_refresh():
await db_session.commit()
await db_session.refresh(ws)
asyncio.run(_commit_refresh())
with patch.object(
WorkspaceManager, "sync", return_value=MagicMock(branch_deleted=True)
):
response = authenticated_client.post(
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}/sync"
)
assert response.status_code == 409
detail = response.json()["detail"]
assert "deleted from remote" in detail["message"]
assert detail["branch_deleted"] is True
-84
View File
@@ -1,84 +0,0 @@
"""Unit tests for FileService."""
import os
import tempfile
import pytest
from src.models.workspace import Workspace
from src.services.file_service import FileService
@pytest.fixture
def temp_workspace():
"""Create a temporary workspace directory."""
with tempfile.TemporaryDirectory() as tmpdir:
ws = Workspace(
id="00000000-0000-0000-0000-000000000001",
name="test-ws",
repo_id="00000000-0000-0000-0000-000000000002",
user_id="00000000-0000-0000-0000-000000000003",
branch="main",
path=tmpdir,
)
yield ws
class TestFileService:
"""Tests for FileService."""
def test_list_directory_empty(self, temp_workspace: Workspace):
"""Returns empty list for empty directory."""
service = FileService()
entries = service.list_directory(temp_workspace)
assert entries == []
def test_list_directory_with_files(self, temp_workspace: Workspace):
"""Returns entries sorted (dirs first, then files)."""
# Create files and dirs
os.makedirs(os.path.join(temp_workspace.path, "src"))
with open(os.path.join(temp_workspace.path, "README.md"), "w") as f:
f.write("# Test")
with open(os.path.join(temp_workspace.path, "main.py"), "w") as f:
f.write("print('hello')")
service = FileService()
entries = service.list_directory(temp_workspace)
assert len(entries) == 3
assert entries[0].name == "src" and entries[0].type == "directory"
assert entries[1].name == "main.py" and entries[1].type == "file"
assert entries[2].name == "README.md" and entries[2].type == "file"
def test_read_file(self, temp_workspace: Workspace):
"""Reads text file content."""
with open(os.path.join(temp_workspace.path, "test.txt"), "w") as f:
f.write("hello world")
service = FileService()
content = service.read_file(temp_workspace, "test.txt")
assert content == "hello world"
def test_read_binary_file_rejected(self, temp_workspace: Workspace):
"""Rejects binary files."""
with open(os.path.join(temp_workspace.path, "binary.bin"), "wb") as f:
f.write(b"\x00\x01\x02")
service = FileService()
with pytest.raises(ValueError, match="Binary"):
service.read_file(temp_workspace, "binary.bin")
def test_write_file(self, temp_workspace: Workspace):
"""Writes file to workspace."""
service = FileService()
service.write_file(temp_workspace, "nested/file.txt", "content")
assert os.path.exists(os.path.join(temp_workspace.path, "nested", "file.txt"))
with open(os.path.join(temp_workspace.path, "nested", "file.txt")) as f:
assert f.read() == "content"
def test_path_escapes_workspace(self, temp_workspace: Workspace):
"""Rejects paths that escape workspace directory."""
service = FileService()
with pytest.raises(ValueError, match="escapes"):
service.list_directory(temp_workspace, "../outside")
-155
View File
@@ -1,155 +0,0 @@
"""Unit tests for GitService."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.services.git_service import GitService
class TestGitServiceClone:
"""Tests for GitService.clone."""
@pytest.mark.asyncio
async def test_clone_success(self):
"""Clone succeeds when git returns 0."""
mock_proc = AsyncMock()
mock_proc.returncode = 0
mock_proc.communicate.return_value = (b"", b"")
with patch(
"asyncio.create_subprocess_exec", return_value=mock_proc
) as mock_exec:
await GitService.clone(
"https://github.com/test/repo.git", "main", "/tmp/ws"
)
mock_exec.assert_called_once_with(
"git",
"clone",
"--branch",
"main",
"--single-branch",
"https://github.com/test/repo.git",
"/tmp/ws",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@pytest.mark.asyncio
async def test_clone_failure(self):
"""Clone raises RuntimeError when git fails."""
mock_proc = AsyncMock()
mock_proc.returncode = 1
mock_proc.communicate.return_value = (b"", b"fatal: repository not found")
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
with pytest.raises(RuntimeError, match="Git clone failed"):
await GitService.clone("https://bad/url.git", "main", "/tmp/ws")
class TestGitServiceFetch:
"""Tests for GitService.fetch."""
@pytest.mark.asyncio
async def test_fetch_success(self):
"""Fetch succeeds when git returns 0."""
mock_proc = AsyncMock()
mock_proc.returncode = 0
mock_proc.communicate.return_value = (b"", b"")
with patch(
"asyncio.create_subprocess_exec", return_value=mock_proc
) as mock_exec:
await GitService.fetch("/tmp/repo")
mock_exec.assert_called_once_with(
"git",
"-C",
"/tmp/repo",
"fetch",
"origin",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@pytest.mark.asyncio
async def test_fetch_failure(self):
"""Fetch raises RuntimeError when git fails."""
mock_proc = AsyncMock()
mock_proc.returncode = 128
mock_proc.communicate.return_value = (b"", b"fatal: not a git repository")
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
with pytest.raises(RuntimeError, match="Git fetch failed"):
await GitService.fetch("/not/a/repo")
class TestGitServicePull:
"""Tests for GitService.pull."""
@pytest.mark.asyncio
async def test_pull_success(self):
"""Pull succeeds when git returns 0."""
mock_proc = AsyncMock()
mock_proc.returncode = 0
mock_proc.communicate.return_value = (b"Already up to date.", b"")
with patch(
"asyncio.create_subprocess_exec", return_value=mock_proc
) as mock_exec:
await GitService.pull("/tmp/repo", "feature-branch")
mock_exec.assert_called_once_with(
"git",
"-C",
"/tmp/repo",
"pull",
"origin",
"feature-branch",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
class TestGitServiceBranchExistsRemotely:
"""Tests for GitService.branch_exists_remotely."""
def test_branch_exists(self):
"""Returns True when branch exists on remote."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "abc123 refs/heads/main\n"
with patch("subprocess.run", return_value=mock_result) as mock_run:
result = GitService.branch_exists_remotely("/tmp/repo", "main")
assert result is True
mock_run.assert_called_once_with(
["git", "-C", "/tmp/repo", "ls-remote", "--heads", "origin", "main"],
capture_output=True,
text=True,
)
def test_branch_not_exists(self):
"""Returns False when branch does not exist on remote."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = ""
with patch("subprocess.run", return_value=mock_result):
result = GitService.branch_exists_remotely("/tmp/repo", "deleted-branch")
assert result is False
def test_ls_remote_fails(self):
"""Returns False when ls-remote fails."""
mock_result = MagicMock()
mock_result.returncode = 128
mock_result.stdout = ""
with patch("subprocess.run", return_value=mock_result):
result = GitService.branch_exists_remotely("/tmp/repo", "main")
assert result is False
@@ -145,12 +145,10 @@ class TestCreateInstanceDockerfileLegacy:
data = MagicMock() data = MagicMock()
data.tool_type_id = str(fake_tool_type_id) data.tool_type_id = str(fake_tool_type_id)
data.display_name = None data.display_name = None
data.workspace_id = None
data.clone_mode = "mount" data.clone_mode = "mount"
data.branch = None data.branch = None
data.new_branch = None data.new_branch = None
data.config_profile_id = None data.config_profile_id = None
data.ssh_key_ids = []
result = await create_instance( result = await create_instance(
project_id=fake_project_id, project_id=fake_project_id,
@@ -227,12 +225,10 @@ class TestCreateInstanceDockerfileLegacy:
data = MagicMock() data = MagicMock()
data.tool_type_id = str(fake_tool_type_id) data.tool_type_id = str(fake_tool_type_id)
data.display_name = None data.display_name = None
data.workspace_id = None
data.clone_mode = "mount" data.clone_mode = "mount"
data.branch = None data.branch = None
data.new_branch = None data.new_branch = None
data.config_profile_id = None data.config_profile_id = None
data.ssh_key_ids = []
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await create_instance( await create_instance(
@@ -309,12 +305,10 @@ class TestCreateInstanceComposeLegacy:
data = MagicMock() data = MagicMock()
data.tool_type_id = str(fake_tool_type_id) data.tool_type_id = str(fake_tool_type_id)
data.display_name = None data.display_name = None
data.workspace_id = None
data.clone_mode = "mount" data.clone_mode = "mount"
data.branch = None data.branch = None
data.new_branch = None data.new_branch = None
data.config_profile_id = None data.config_profile_id = None
data.ssh_key_ids = []
result = await create_instance( result = await create_instance(
project_id=fake_project_id, project_id=fake_project_id,
@@ -395,12 +389,10 @@ class TestCreateInstanceManifestNotCalledForLegacy:
data = MagicMock() data = MagicMock()
data.tool_type_id = str(fake_tool_type_id) data.tool_type_id = str(fake_tool_type_id)
data.display_name = None data.display_name = None
data.workspace_id = None
data.clone_mode = "mount" data.clone_mode = "mount"
data.branch = None data.branch = None
data.new_branch = None data.new_branch = None
data.config_profile_id = None data.config_profile_id = None
data.ssh_key_ids = []
await create_instance( await create_instance(
project_id=fake_project_id, project_id=fake_project_id,
@@ -420,7 +412,6 @@ class TestStartInstanceLegacyFallback:
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
@patch("src.api.tool_instances._ensure_container_name_in_compose") @patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address") @patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@@ -435,7 +426,6 @@ class TestStartInstanceLegacyFallback:
mock_sanitize, mock_sanitize,
mock_ensure_web_bind, mock_ensure_web_bind,
mock_ensure_container_name, mock_ensure_container_name,
mock_backend_network,
mock_connect_network, mock_connect_network,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
@@ -521,7 +511,6 @@ class TestStartInstanceLegacyFallback:
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
@patch("src.api.tool_instances._ensure_container_name_in_compose") @patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address") @patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@@ -536,7 +525,6 @@ class TestStartInstanceLegacyFallback:
mock_sanitize, mock_sanitize,
mock_ensure_web_bind, mock_ensure_web_bind,
mock_ensure_container_name, mock_ensure_container_name,
mock_backend_network,
mock_connect_network, mock_connect_network,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
@@ -621,7 +609,6 @@ class TestStartInstanceLegacyFallback:
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
@patch("src.api.tool_instances._ensure_container_name_in_compose") @patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address") @patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@@ -636,7 +623,6 @@ class TestStartInstanceLegacyFallback:
mock_sanitize, mock_sanitize,
mock_ensure_web_bind, mock_ensure_web_bind,
mock_ensure_container_name, mock_ensure_container_name,
mock_backend_network,
mock_connect_network, mock_connect_network,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
@@ -728,7 +714,6 @@ class TestStartInstanceSshPermissions:
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
@patch("src.api.tool_instances._ensure_container_name_in_compose") @patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address") @patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@@ -741,7 +726,6 @@ class TestStartInstanceSshPermissions:
mock_sanitize, mock_sanitize,
mock_ensure_web_bind, mock_ensure_web_bind,
mock_ensure_container_name, mock_ensure_container_name,
mock_backend_network,
mock_connect_network, mock_connect_network,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
@@ -837,24 +821,23 @@ class TestStartInstanceSshPermissions:
mock_session.get.side_effect = _get mock_session.get.side_effect = _get
with patch("os.path.exists", return_value=True): with patch("os.path.exists", return_value=True):
with patch("os.makedirs"): with patch(
with patch( "src.api.tool_instances._prepare_manifest_instance"
"src.api.tool_instances._prepare_manifest_instance" ) as mock_prepare:
) as mock_prepare: mock_prepare.return_value = (
mock_prepare.return_value = ( "headquarter/test:latest",
"headquarter/test:latest", "services:\n app:\n image: test",
"services:\n app:\n image: test", {"name": "test-manifest", "user": {"name": "user"}},
{"name": "test-manifest", "user": {"name": "user"}}, "/home/user",
"/home/user", )
) result = await start_instance(
result = await start_instance( project_id=fake_project_id,
project_id=fake_project_id, repo_id=fake_repo_id,
repo_id=fake_repo_id, instance_id=fake_instance_id,
instance_id=fake_instance_id, data=None,
data=None, user_id=fake_user_id,
user_id=fake_user_id, session=mock_session,
session=mock_session, )
)
assert result["status"] == "running" assert result["status"] == "running"
mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user") mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user")
@@ -865,7 +848,6 @@ class TestStartInstanceSshPermissions:
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
@patch("src.api.tool_instances._ensure_container_name_in_compose") @patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address") @patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@@ -878,7 +860,6 @@ class TestStartInstanceSshPermissions:
mock_sanitize, mock_sanitize,
mock_ensure_web_bind, mock_ensure_web_bind,
mock_ensure_container_name, mock_ensure_container_name,
mock_backend_network,
mock_connect_network, mock_connect_network,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
@@ -961,16 +942,15 @@ class TestStartInstanceSshPermissions:
mock_session.get.side_effect = _get mock_session.get.side_effect = _get
with patch("os.path.exists", return_value=True): with patch("os.path.exists", return_value=True):
with patch("os.makedirs"): with patch("src.api.tool_instances._modify_compose_file"):
with patch("src.api.tool_instances._modify_compose_file"): result = await start_instance(
result = await start_instance( project_id=fake_project_id,
project_id=fake_project_id, repo_id=fake_repo_id,
repo_id=fake_repo_id, instance_id=fake_instance_id,
instance_id=fake_instance_id, data=None,
data=None, user_id=fake_user_id,
user_id=fake_user_id, session=mock_session,
session=mock_session, )
)
assert result["status"] == "running" assert result["status"] == "running"
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root") mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
@@ -983,7 +963,6 @@ class TestStartInstanceManifestBranch:
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
@patch("src.api.tool_instances._ensure_container_name_in_compose") @patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address") @patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@@ -1000,7 +979,6 @@ class TestStartInstanceManifestBranch:
mock_sanitize, mock_sanitize,
mock_ensure_web_bind, mock_ensure_web_bind,
mock_ensure_container_name, mock_ensure_container_name,
mock_backend_network,
mock_connect_network, mock_connect_network,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
-6
View File
@@ -15,12 +15,6 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Never cache index.html so browsers always fetch new hashed JS/CSS
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
}
# Cache static assets # Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y; expires 1y;
+12 -12
View File
@@ -16,11 +16,11 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sonner": "^1.7.4",
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"xterm": "^5.3.0", "xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0", "xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0", "xterm-addon-web-links": "^0.9.0"
"xterm-addon-webgl": "^0.16.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -5469,6 +5469,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/sonner": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6314,16 +6324,6 @@
"xterm": "^5.0.0" "xterm": "^5.0.0"
} }
}, },
"node_modules/xterm-addon-webgl": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/xterm-addon-webgl/-/xterm-addon-webgl-0.16.0.tgz",
"integrity": "sha512-E8cq1AiqNOv0M/FghPT+zPAEnvIQRDbAbkb04rRYSxUym69elPWVJ4sv22FCLBqM/3LcrmBLl/pELnBebVFKgA==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-webgl instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1 -2
View File
@@ -22,8 +22,7 @@
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"xterm": "^5.3.0", "xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0", "xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0", "xterm-addon-web-links": "^0.9.0"
"xterm-addon-webgl": "^0.16.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
+24 -27
View File
@@ -1,54 +1,51 @@
import { apiClient } from "./client"; import { apiClient } from "./client";
import type { Project, ProjectWithRepos } from "../types"; import type { Project } from "../types";
export type ProjectCreateInput = { export type ProjectCreateInput = {
name: string; name: string;
description?: string | null; description?: string | null;
}; };
export type ProjectUpdateInput = { export type ProjectUpdateInput = {
name?: string | null; name?: string | null;
description?: string | null; description?: string | null;
}; };
export type SetDefaultSSHKeyInput = { export type SetDefaultSSHKeyInput = {
ssh_key_id: string; ssh_key_id: string;
}; };
export const listProjects = async (): Promise<ProjectWithRepos[]> => { export const listProjects = async (): Promise<Project[]> => {
const response = await apiClient.get<ProjectWithRepos[]>("/projects"); const response = await apiClient.get<Project[]>("/projects");
return response.data; return response.data;
}; };
export const createProject = async ( export const createProject = async (
input: ProjectCreateInput, input: ProjectCreateInput
): Promise<Project> => { ): Promise<Project> => {
const response = await apiClient.post<Project>("/projects", input); const response = await apiClient.post<Project>("/projects", input);
return response.data; return response.data;
}; };
export const updateProject = async ( export const updateProject = async (
projectId: string, projectId: string,
input: ProjectUpdateInput, input: ProjectUpdateInput
): Promise<Project> => { ): Promise<Project> => {
const response = await apiClient.patch<Project>( const response = await apiClient.patch<Project>(`/projects/${projectId}`, input);
`/projects/${projectId}`, return response.data;
input,
);
return response.data;
}; };
export const deleteProject = async (projectId: string): Promise<void> => { export const deleteProject = async (projectId: string): Promise<void> => {
await apiClient.delete(`/projects/${projectId}`); await apiClient.delete(`/projects/${projectId}`);
}; };
export const setDefaultSSHKey = async ( export const setDefaultSSHKey = async (
projectId: string, projectId: string,
input: SetDefaultSSHKeyInput, input: SetDefaultSSHKeyInput
): Promise<Project> => { ): Promise<Project> => {
const response = await apiClient.patch<Project>( const response = await apiClient.patch<Project>(
`/projects/${projectId}/default-ssh-key`, `/projects/${projectId}/default-ssh-key`,
input, input
); );
return response.data; return response.data;
}; };
+138 -154
View File
@@ -2,199 +2,183 @@ import { AxiosError } from "axios";
import { apiClient } from "./client"; import { apiClient } from "./client";
export interface ToolInstance { export interface ToolInstance {
id: string; id: string;
name: string; name: string;
display_name: string; display_name: string;
tool_type_id: string; tool_type_id: string;
tool_type_name: string; tool_type_name: string;
tool_type_interfaces: string[]; tool_type_interfaces: string[];
status: string; status: string;
url: string | null; url: string | null;
port: number | null; port: number | null;
selected_config_profile_id: string | null; selected_config_profile_id: string | null;
ssh_key_ids: string[]; ssh_key_ids: string[];
created_at: string; created_at: string;
} }
export interface Session { export interface Session {
id: string; id: string;
display_name: string; display_name: string;
tool_type_name: string; tool_type_name: string;
tool_icon: string; tool_icon: string;
tool_type_interfaces: string[]; tool_type_interfaces: string[];
repository_name: string; repository_name: string;
repository_id: string; repository_id: string;
project_name: string; project_name: string;
project_id: string; project_id: string;
status: string; status: string;
url: string | null; url: string | null;
container_status?: string; container_status?: string;
probe_status?: string; probe_status?: string;
clone_mode?: string; clone_mode?: string;
branch?: string | null; branch?: string | null;
created_at?: string; created_at?: string;
} }
export async function listInstances( export async function listInstances(
projectId: string, projectId: string,
repoId: string, repoId: string
): Promise<ToolInstance[]> { ): Promise<ToolInstance[]> {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances`, `/projects/${projectId}/repositories/${repoId}/instances`
); );
return response.data.instances; return response.data.instances;
} }
export async function createInstance( export async function createInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
toolTypeId: string, toolTypeId: string,
displayName?: string, displayName?: string,
cloneMode?: string, cloneMode?: string,
branch?: string, branch?: string,
newBranch?: string, newBranch?: string,
configProfileId?: string, configProfileId?: string,
sshKeyIds?: string[], sshKeyIds?: string[]
workspaceId?: string,
): Promise<ToolInstance> { ): Promise<ToolInstance> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`, `/projects/${projectId}/repositories/${repoId}/instances`,
{ {
tool_type_id: toolTypeId, tool_type_id: toolTypeId,
display_name: displayName, display_name: displayName,
workspace_id: workspaceId || undefined, clone_mode: cloneMode || "mount",
clone_mode: cloneMode || "mount", branch: branch || undefined,
branch: branch || undefined, new_branch: newBranch || undefined,
new_branch: newBranch || undefined, config_profile_id: configProfileId,
config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [],
ssh_key_ids: sshKeyIds || [], }
}, );
); return response.data;
return response.data;
} }
export async function startInstance( export async function startInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string, instanceId: string,
configProfileId?: string, configProfileId?: string,
sshKeyIds?: string[], sshKeyIds?: string[],
retries = 2, retries = 2
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
try { try {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }, { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces) // Retry on network errors (e.g. Docker creating network interfaces)
const axiosError = error as AxiosError; const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) { if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
return startInstance( return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
projectId, }
repoId, throw error;
instanceId, }
configProfileId,
sshKeyIds,
retries - 1,
);
}
throw error;
}
} }
export async function stopInstance( export async function stopInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string, instanceId: string
): Promise<{ status: string }> { ): Promise<{ status: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
); );
return response.data; return response.data;
} }
export async function restartInstance( export async function restartInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string, instanceId: string,
configProfileId?: string, configProfileId?: string,
sshKeyIds?: string[], sshKeyIds?: string[],
retries = 2, retries = 2
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
try { try {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }, { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces) // Retry on network errors (e.g. Docker creating network interfaces)
const axiosError = error as AxiosError; const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) { if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
return restartInstance( return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
projectId, }
repoId, throw error;
instanceId, }
configProfileId,
sshKeyIds,
retries - 1,
);
}
throw error;
}
} }
export async function deleteInstance( export async function deleteInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string, instanceId: string,
force?: boolean, force?: boolean
): Promise<void> { ): Promise<void> {
await apiClient.delete( await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
{ params: { force } }, { params: { force } }
); );
} }
export async function getUserSessions(): Promise<Session[]> { export async function getUserSessions(): Promise<Session[]> {
const response = await apiClient.get("/users/me/sessions"); const response = await apiClient.get("/users/me/sessions");
return response.data.sessions; return response.data.sessions;
} }
export interface InstanceHealth { export interface InstanceHealth {
healthy: boolean; healthy: boolean;
container_status: string; container_status: string;
container_health: string | null; container_health: string | null;
container_exit_code: number | null; container_exit_code: number | null;
tunnel_status: string; tunnel_status: string;
tunnel_status_code: number | null; tunnel_status_code: number | null;
probe_status: string; probe_status: string;
last_probe_output: string | null; last_probe_output: string | null;
error: string | null; error: string | null;
} }
export async function checkInstanceHealth( export async function checkInstanceHealth(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string, instanceId: string
): Promise<InstanceHealth> { ): Promise<InstanceHealth> {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
); );
return response.data; return response.data;
} }
export async function recreateInstanceTunnel( export async function recreateInstanceTunnel(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string, instanceId: string
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
); );
return response.data; return response.data;
} }
-45
View File
@@ -1,45 +0,0 @@
/** Workspace file API client. */
import { apiClient } from "./client";
export interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
}
export async function listWorkspaceFiles(
workspaceId: string,
path: string = "",
): Promise<FileEntry[]> {
const response = await apiClient.get<{ entries: FileEntry[] }>(
`/workspaces/${workspaceId}/files/`,
{ params: { path } },
);
return response.data.entries;
}
export async function getWorkspaceFileContent(
workspaceId: string,
path: string,
): Promise<string> {
const response = await apiClient.get<{ content: string }>(
`/workspaces/${workspaceId}/files/content`,
{ params: { path } },
);
return response.data.content;
}
export async function saveWorkspaceFile(
workspaceId: string,
path: string,
content: string,
commitMessage?: string,
): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/files/content`, {
path,
content,
message: commitMessage,
});
}
-75
View File
@@ -1,75 +0,0 @@
/** Workspace git API client. */
import { apiClient } from "./client";
export interface GitStatus {
branch: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
ahead: number;
behind: number;
}
export interface Commit {
hash: string;
message: string;
author: string;
date: string;
}
export async function getGitStatus(workspaceId: string): Promise<GitStatus> {
const response = await apiClient.get<GitStatus>(
`/workspaces/${workspaceId}/git/status`,
);
return response.data;
}
export async function getGitBranches(
workspaceId: string,
): Promise<{ branches: string[]; current_branch: string }> {
const response = await apiClient.get<{
branches: string[];
current_branch: string;
}>(`/workspaces/${workspaceId}/git/branches`);
return response.data;
}
export async function gitCommit(
workspaceId: string,
message: string,
): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/commit`, { message });
}
export async function gitPush(workspaceId: string): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/push`);
}
export async function gitPull(workspaceId: string): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/pull`);
}
export async function gitFetch(workspaceId: string): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/fetch`);
}
export async function gitCheckout(
workspaceId: string,
branch: string,
): Promise<void> {
await apiClient.post(`/workspaces/${workspaceId}/git/checkout`, { branch });
}
export async function getGitHistory(
workspaceId: string,
path?: string,
limit: number = 50,
): Promise<Commit[]> {
const response = await apiClient.get<{ commits: Commit[] }>(
`/workspaces/${workspaceId}/git/history`,
{ params: { path, limit } },
);
return response.data.commits;
}
-30
View File
@@ -1,30 +0,0 @@
/** Workspace instance API client. */
import { apiClient } from "./client";
import type { ToolInstance } from "./sessions";
export async function listWorkspaceInstances(
workspaceId: string,
): Promise<ToolInstance[]> {
const response = await apiClient.get<ToolInstance[]>(
`/workspaces/${workspaceId}/instances/`,
);
return response.data;
}
export async function createWorkspaceInstance(
workspaceId: string,
toolTypeId: string,
displayName?: string,
configProfileId?: string,
): Promise<ToolInstance> {
const response = await apiClient.post<ToolInstance>(
`/workspaces/${workspaceId}/instances/`,
{
tool_type_id: toolTypeId,
display_name: displayName,
config_profile_id: configProfileId,
},
);
return response.data;
}
-92
View File
@@ -1,92 +0,0 @@
/** Workspace API client. */
import { apiClient } from "./client";
import type {
Workspace,
CreateWorkspaceRequest,
SyncResult,
} from "../types/workspace";
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
return workspaceId ? `${base}/${workspaceId}` : `${base}/`;
}
export async function listWorkspaces(
projectId: string,
repoId: string,
): Promise<Workspace[]> {
const response = await apiClient.get<Workspace[]>(
workspaceUrl(projectId, repoId),
);
return response.data;
}
export async function listAllWorkspaces(): Promise<Workspace[]> {
const response = await apiClient.get<Workspace[]>("/workspaces/");
return response.data;
}
export async function createWorkspace(
projectId: string,
repoId: string,
data: CreateWorkspaceRequest,
): Promise<Workspace> {
const response = await apiClient.post<Workspace>(
workspaceUrl(projectId, repoId),
data,
);
return response.data;
}
export async function createWorkspaceTopLevel(
data: CreateWorkspaceRequest & { repo_id: string },
): Promise<Workspace> {
const response = await apiClient.post<Workspace>("/workspaces/", data);
return response.data;
}
export async function getWorkspace(
projectId: string,
repoId: string,
workspaceId: string,
): Promise<Workspace> {
const response = await apiClient.get<Workspace>(
workspaceUrl(projectId, repoId, workspaceId),
);
return response.data;
}
export async function updateWorkspace(
projectId: string,
repoId: string,
workspaceId: string,
data: Partial<CreateWorkspaceRequest>,
): Promise<Workspace> {
const response = await apiClient.patch<Workspace>(
workspaceUrl(projectId, repoId, workspaceId),
data,
);
return response.data;
}
export async function deleteWorkspace(
workspaceId: string,
force = false,
): Promise<{ status: string }> {
const response = await apiClient.delete<{ status: string }>(
`/workspaces/${workspaceId}?force=${force}`,
);
return response.data;
}
export async function syncWorkspace(
projectId: string,
repoId: string,
workspaceId: string,
): Promise<SyncResult> {
const response = await apiClient.post<SyncResult>(
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
);
return response.data;
}
+3 -18
View File
@@ -14,7 +14,6 @@ import { EventToastBridge } from "./event-toast-bridge";
import { NotificationCenter } from "./notification-center"; import { NotificationCenter } from "./notification-center";
import { Icon } from "./icon"; import { Icon } from "./icon";
import { MobileNav } from "./mobile-nav"; import { MobileNav } from "./mobile-nav";
import { StartToolFAB } from "./start-tool-fab";
import type { IconName } from "../utils/icons"; import type { IconName } from "../utils/icons";
const NAV_ITEMS: { const NAV_ITEMS: {
@@ -25,7 +24,6 @@ const NAV_ITEMS: {
}[] = [ }[] = [
{ to: "/", label: "Home", icon: "dashboard" }, { to: "/", label: "Home", icon: "dashboard" },
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" }, { to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
{ to: "/workspaces", label: "Workspaces", icon: "folder" },
{ to: "/projects", label: "Projects", icon: "projects" }, { to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" }, { to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" }, { to: "/config-profiles", label: "Config Profiles", icon: "folder" },
@@ -35,23 +33,11 @@ const NAV_ITEMS: {
const SessionItem = ({ session }: { session: Session }) => { const SessionItem = ({ session }: { session: Session }) => {
const isRunning = session.status === "running"; const isRunning = session.status === "running";
// Determine the link target:
// - Web tools open their tunnel URL
// - Terminal tools open the terminal page
// - Everything else falls back to the project page
const hasTerminal = session.tool_type_interfaces.includes("terminal");
const hasWeb = session.tool_type_interfaces.includes("web");
const href = session.url && hasWeb
? session.url
: hasTerminal
? `/instances/${session.id}/terminal`
: `/projects/${session.project_id}`;
return ( return (
<a <a
href={href} href={session.url ?? `/projects/${session.project_id}`}
target="_blank" target={session.url ? "_blank" : undefined}
rel="noopener noreferrer" rel={session.url ? "noopener noreferrer" : undefined}
className="nav-item session-item" className="nav-item session-item"
title={`${session.display_name} (${session.status})`} title={`${session.display_name} (${session.status})`}
> >
@@ -183,7 +169,6 @@ export const AppShell = () => {
} }
/> />
)} )}
<StartToolFAB />
</div> </div>
</NotificationProvider> </NotificationProvider>
</ToastProvider> </ToastProvider>
+1 -7
View File
@@ -36,8 +36,6 @@ import {
ArrowLeft, ArrowLeft,
DotsSixVertical, DotsSixVertical,
Bell, Bell,
CaretDown,
CaretRight,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
export type IconName = export type IconName =
@@ -81,9 +79,7 @@ export type IconName =
| "terminal" | "terminal"
| "arrow-left" | "arrow-left"
| "drag" | "drag"
| "bell" | "bell";
| "chevron-down"
| "chevron-right";
const iconMap: Record< const iconMap: Record<
IconName, IconName,
@@ -133,8 +129,6 @@ const iconMap: Record<
"arrow-left": ArrowLeft, "arrow-left": ArrowLeft,
drag: DotsSixVertical, drag: DotsSixVertical,
bell: Bell, bell: Bell,
"chevron-down": CaretDown,
"chevron-right": CaretRight,
}; };
export interface IconProps { export interface IconProps {
+7 -13
View File
@@ -58,11 +58,6 @@ export function SessionCard({
const isTerminalOnly = const isTerminalOnly =
session.tool_type_interfaces?.includes("terminal") && session.tool_type_interfaces?.includes("terminal") &&
!session.tool_type_interfaces?.includes("web"); !session.tool_type_interfaces?.includes("web");
const openHref = session.url
? session.url
: isTerminalOnly
? `/instances/${session.id}/terminal`
: undefined;
const hasTunnelError = const hasTunnelError =
!isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable"; !isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
const hasAppError = const hasAppError =
@@ -155,9 +150,9 @@ export function SessionCard({
<div className="session-card-actions mobile"> <div className="session-card-actions mobile">
{isActive && ( {isActive && (
<> <>
{openHref ? ( {session.url ? (
<a <a
href={openHref} href={session.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="secondary-button mobile-primary" className="secondary-button mobile-primary"
@@ -212,9 +207,9 @@ export function SessionCard({
<div className="session-card-actions"> <div className="session-card-actions">
{isActive && ( {isActive && (
<> <>
{openHref ? ( {session.url ? (
<a <a
href={openHref} href={session.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="secondary-button small" className="secondary-button small"
@@ -234,13 +229,12 @@ export function SessionCard({
</button> </button>
)} )}
{!isTerminalOnly && onRecreateTunnel && ( {hasTunnelError && onRecreateTunnel && (
<button <button
className="ghost-button small" className="secondary-button small"
onClick={() => onRecreateTunnel(session)} onClick={() => onRecreateTunnel(session)}
type="button" type="button"
disabled={isBusy} disabled={isBusy}
title="Recreate Cloudflare tunnel"
> >
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
<span className="action-label">Tunnel</span> <span className="action-label">Tunnel</span>
@@ -329,7 +323,7 @@ export function SessionCard({
onClose={() => setShowActionSheet(false)} onClose={() => setShowActionSheet(false)}
title={session.display_name} title={session.display_name}
actions={[ actions={[
...(isActive && !isTerminalOnly && onRecreateTunnel ...(isActive && hasTunnelError && onRecreateTunnel
? [ ? [
{ {
id: "tunnel", id: "tunnel",
-118
View File
@@ -1,118 +0,0 @@
/** Floating action button to start a tool from any page. */
import { useState } from "react";
import { Icon } from "./icon";
import { ToolStarter } from "./tool-starter";
import type { Workspace } from "../types/workspace";
import { listAllWorkspaces } from "../api/workspaces";
export function StartToolFAB() {
const [open, setOpen] = useState(false);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [workspacesLoading, setWorkspacesLoading] = useState(false);
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
null,
);
const handleOpen = async () => {
setOpen(true);
setWorkspacesLoading(true);
try {
const data = await listAllWorkspaces();
setWorkspaces(data);
} catch {
// ignore
} finally {
setWorkspacesLoading(false);
}
};
const handleClose = () => {
setOpen(false);
setSelectedWorkspace(null);
};
return (
<>
<button
className="start-tool-fab"
onClick={handleOpen}
title="Start a new tool"
type="button"
aria-label="Start a new tool"
>
<Icon name="play" size="md" />
</button>
{open && (
<div className="modal-overlay" onClick={handleClose}>
<div
className="modal-content start-tool-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>Start Tool</h3>
<button
className="ghost-button small"
onClick={handleClose}
type="button"
aria-label="Close"
>
<Icon name="close" size="sm" />
</button>
</div>
{workspacesLoading ? (
<p className="muted">Loading workspaces...</p>
) : workspaces.length === 0 ? (
<p className="muted">
No workspaces yet.{" "}
<a href="/workspaces">Create a workspace first</a>.
</p>
) : !selectedWorkspace ? (
<div className="form-group">
<label htmlFor="fab-workspace-select">Select a workspace</label>
<select
id="fab-workspace-select"
value=""
onChange={(e) => {
const ws = workspaces.find((w) => w.id === e.target.value);
if (ws) setSelectedWorkspace(ws);
}}
>
<option value="">Choose a workspace...</option>
{workspaces.map((ws) => (
<option key={ws.id} value={ws.id}>
{ws.project_name} / {ws.repo_name} / {ws.name}
</option>
))}
</select>
</div>
) : (
<>
<div className="tool-starter-header">
<h4>
{selectedWorkspace.project_name} /{" "}
{selectedWorkspace.repo_name} / {selectedWorkspace.name}
</h4>
<button
className="ghost-button small"
onClick={() => setSelectedWorkspace(null)}
type="button"
>
Change
</button>
</div>
<ToolStarter
workspace={selectedWorkspace}
onStarted={handleClose}
onCancel={() => setSelectedWorkspace(null)}
/>
</>
)}
</div>
</div>
)}
</>
);
}
@@ -1,114 +0,0 @@
/** Modal for starting a tool on a workspace. */
import { useState } from "react";
import { Icon } from "./icon";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { useAsyncData } from "../hooks/use-async-data";
import type { Workspace } from "../types/workspace";
export interface StartToolModalProps {
workspace: Workspace;
onClose: () => void;
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
}
export function StartToolModal({
workspace,
onClose,
onStart,
}: StartToolModalProps) {
const [toolTypeId, setToolTypeId] = useState("");
const [configProfileId, setConfigProfileId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
data: toolTypes,
status,
error: loadError,
} = useAsyncData<ToolType[]>(listToolTypes, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!toolTypeId) {
setError("Please select a tool type");
return;
}
setSubmitting(true);
setError(null);
try {
await onStart(toolTypeId, configProfileId || undefined);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool");
} finally {
setSubmitting(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>
<Icon name="play" size="sm" /> Start Tool on {workspace.name}
</h3>
<button className="btn btn-icon" onClick={onClose}>
<Icon name="cancel" size="sm" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="tool-type">Tool Type</label>
<select
id="tool-type"
value={toolTypeId}
onChange={(e) => setToolTypeId(e.target.value)}
disabled={submitting || status === "loading"}
>
<option value="">Select a tool...</option>
{toolTypes?.map((tt) => (
<option key={tt.id} value={tt.id}>
{tt.display_name}
</option>
))}
</select>
{status === "loading" && (
<span className="muted">Loading tools...</span>
)}
{loadError && <span className="error-text">{loadError}</span>}
</div>
<div className="form-group">
<label htmlFor="config-profile">Config Profile (optional)</label>
<input
id="config-profile"
type="text"
value={configProfileId}
onChange={(e) => setConfigProfileId(e.target.value)}
placeholder="Profile ID"
disabled={submitting}
/>
</div>
{error && <p className="form-error">{error}</p>}
<div className="form-actions">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={submitting || status !== "ready"}
>
{submitting ? "Starting..." : "Start Tool"}
</button>
</div>
</form>
</div>
</div>
);
}
+22 -83
View File
@@ -8,7 +8,6 @@ import React, {
import { Terminal } from "xterm"; import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit"; import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links"; import { WebLinksAddon } from "xterm-addon-web-links";
import { WebglAddon } from "xterm-addon-webgl";
import "xterm/css/xterm.css"; import "xterm/css/xterm.css";
import { import {
@@ -108,7 +107,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// WebSocket connection established // WebSocket connection established
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
@@ -139,35 +137,14 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
}, 30000); }, 30000);
}; };
// Flow control: accumulate processed bytes and send ack
let ackAccumulator = 0;
const ACK_THRESHOLD = 4096;
let ackTimeout: ReturnType<typeof setTimeout> | null = null;
const flushAck = () => {
if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator }));
ackAccumulator = 0;
}
};
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (!termRef.current) return; if (!termRef.current) return;
if (event.data instanceof ArrayBuffer) { if (event.data instanceof Blob) {
const data = new Uint8Array(event.data); event.data.arrayBuffer().then((buffer) => {
termRef.current.write(data); const data = new Uint8Array(buffer);
termRef.current?.write(data);
// Flow control: accumulate processed bytes });
ackAccumulator += data.length;
if (ackAccumulator >= ACK_THRESHOLD) {
flushAck();
} else if (!ackTimeout) {
ackTimeout = setTimeout(() => {
ackTimeout = null;
flushAck();
}, 100);
}
} else if (typeof event.data === "string") { } else if (typeof event.data === "string") {
try { try {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
@@ -274,11 +251,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
lineHeight: 1.2, lineHeight: 1.2,
letterSpacing: 0, letterSpacing: 0,
allowTransparency: false, allowTransparency: false,
scrollback: 10000,
ignoreBracketedPasteMode: false,
fastScrollSensitivity: 5,
scrollSensitivity: 1,
smoothScrollDuration: 0,
theme: { theme: {
background: "#1e1e1e", background: "#1e1e1e",
foreground: "#d4d4d4", foreground: "#d4d4d4",
@@ -310,31 +282,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon()); term.loadAddon(new WebLinksAddon());
// Load WebGL renderer for GPU acceleration, fall back to DOM
let webglAddon: WebglAddon | null = null;
try {
webglAddon = new WebglAddon();
term.loadAddon(webglAddon);
webglAddon.onContextLoss(() => {
console.warn("WebGL context lost, falling back to DOM renderer");
try {
webglAddon?.dispose();
} catch {
// ignore
}
webglAddon = null;
// Trigger a refit since cell dimensions may differ
requestAnimationFrame(() => fitTerminal());
});
} catch (e) {
console.warn("WebGL renderer failed to load, using DOM renderer", e);
}
const container = terminalRef.current; const container = terminalRef.current;
// Define fitTerminal before connectWebSocket so it's available in onmessage // Define fitTerminal before connectWebSocket so it's available in onmessage
let lastSentCols = 0;
let lastSentRows = 0;
const fitTerminal = () => { const fitTerminal = () => {
if (!fitAddonRef.current || !termRef.current) return; if (!fitAddonRef.current || !termRef.current) return;
try { try {
@@ -344,19 +294,18 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return; return;
} }
const { cols, rows } = termRef.current; const { cols, rows } = termRef.current;
// Only send resize when dimensions actually changed // Force refresh if dimensions are valid
if ( if (cols > 0 && rows > 0) {
cols > 0 && try {
rows > 0 && termRef.current.refresh(0, rows - 1);
(cols !== lastSentCols || rows !== lastSentRows) } catch {
) { // Ignore refresh errors
lastSentCols = cols;
lastSentRows = rows;
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
} }
} }
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) {
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
}
}; };
// Open xterm first (must happen before fit) // Open xterm first (must happen before fit)
@@ -403,12 +352,16 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// If the viewport is scrollable, scroll it directly. // If the viewport is scrollable, scroll it directly.
// Otherwise we are in alternate screen (tmux/vim) and must // Otherwise we are in alternate screen (tmux/vim) and must
// send SGR 1006 mouse-wheel protocol data. // send SGR 1006 mouse-wheel protocol data.
const hasScrollback = viewport.scrollHeight > viewport.clientHeight; const hasScrollback =
viewport.scrollHeight > viewport.clientHeight;
if (hasScrollback) { if (hasScrollback) {
viewport.scrollTop += deltaY; viewport.scrollTop += deltaY;
} else { } else {
const ws = wsRef.current; const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN && termRef.current) { if (
ws?.readyState === WebSocket.OPEN &&
termRef.current
) {
// Use the cursor position as the wheel location so // Use the cursor position as the wheel location so
// tmux knows which pane to scroll. // tmux knows which pane to scroll.
const buf = termRef.current.buffer.active; const buf = termRef.current.buffer.active;
@@ -585,21 +538,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
window.clearInterval(heartbeatCheckRef.current); window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null; heartbeatCheckRef.current = null;
} }
// Dispose WebGL addon BEFORE the terminal to avoid race with term.dispose();
// RenderService.setRenderer accessing a disposed renderer
if (webglAddon) {
try {
webglAddon.dispose();
} catch {
// Ignore disposal errors from partially torn-down terminal
}
webglAddon = null;
}
try {
term.dispose();
} catch {
// Ignore disposal errors from partially torn-down terminal
}
}; };
}, [instanceId, connectWebSocket]); }, [instanceId, connectWebSocket]);
-299
View File
@@ -1,299 +0,0 @@
/** Unified tool starter — workspace-first, fetches real tool types and config profiles. */
import { useState, useEffect, useCallback } from "react";
import { Icon } from "./icon";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
import type { Workspace } from "../types/workspace";
import type { ToolInstance } from "../api/sessions";
export interface ToolStarterProps {
workspace: Workspace;
onStarted: (instance: ToolInstance) => void;
onCancel?: () => void;
}
export function ToolStarter({
workspace,
onStarted,
onCancel,
}: ToolStarterProps) {
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [toolTypesLoading, setToolTypesLoading] = useState(true);
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
const [selectedToolTypeId, setSelectedToolTypeId] = useState("");
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [profilesLoading, setProfilesLoading] = useState(false);
const [selectedProfileId, setSelectedProfileId] = useState("");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [sshKeysLoading, setSshKeysLoading] = useState(true);
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch tool types on mount
useEffect(() => {
const load = async () => {
try {
const data = await listToolTypes();
setToolTypes(data);
} catch (err) {
setToolTypesError(
err instanceof Error ? err.message : "Failed to load tool types",
);
} finally {
setToolTypesLoading(false);
}
};
void load();
}, []);
// Fetch config profiles when tool type changes
useEffect(() => {
if (!selectedToolTypeId) {
setProfiles([]);
setSelectedProfileId("");
return;
}
const load = async () => {
setProfilesLoading(true);
try {
const data = await listConfigProfiles(
workspace.project_id,
selectedToolTypeId,
);
setProfiles(data);
// Auto-select default profile if available
const defaultProfile = data.find((p) => p.is_default);
if (defaultProfile) {
setSelectedProfileId(defaultProfile.id);
} else {
setSelectedProfileId("");
}
} catch {
setProfiles([]);
} finally {
setProfilesLoading(false);
}
};
void load();
}, [selectedToolTypeId, workspace.project_id]);
// Fetch SSH keys on mount
useEffect(() => {
const load = async () => {
try {
const data = await listSSHKeys();
setSshKeys(data);
// Auto-select the repository's SSH key if available
if (workspace.repo_ssh_key_id) {
setSelectedSshKeyIds([workspace.repo_ssh_key_id]);
}
} catch (err) {
console.error("Failed to load SSH keys:", err);
} finally {
setSshKeysLoading(false);
}
};
void load();
}, [workspace.repo_ssh_key_id]);
const repoHasSshKey = !!workspace.repo_ssh_key_id;
const repoSshKey = sshKeys.find((k) => k.id === workspace.repo_ssh_key_id);
const handleStart = useCallback(async () => {
if (!selectedToolTypeId) {
setError("Please select a tool type");
return;
}
setStarting(true);
setError(null);
try {
const { createInstance, startInstance } = await import("../api/sessions");
const instance = await createInstance(
workspace.project_id,
workspace.repo_id,
selectedToolTypeId,
workspace.name,
undefined,
undefined,
undefined,
selectedProfileId || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
workspace.id,
);
await startInstance(
workspace.project_id,
workspace.repo_id,
instance.id,
selectedProfileId || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
);
onStarted(instance);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool");
} finally {
setStarting(false);
}
}, [selectedToolTypeId, selectedProfileId, workspace, onStarted]);
return (
<div className="tool-starter">
{/* Context header — read-only workspace info */}
<div className="tool-starter-context">
<div className="context-row">
<span className="context-label">Project</span>
<span className="context-value">{workspace.project_name}</span>
</div>
<div className="context-row">
<span className="context-label">Repository</span>
<span className="context-value">{workspace.repo_name}</span>
</div>
<div className="context-row">
<span className="context-label">Workspace</span>
<span className="context-value">{workspace.name}</span>
<span className="branch-badge">
<Icon name="branch" size="sm" /> {workspace.branch}
</span>
</div>
</div>
{/* Tool Type */}
<div className="form-group">
<label htmlFor="tool-type">Tool Type</label>
<select
id="tool-type"
value={selectedToolTypeId}
onChange={(e) => {
setSelectedToolTypeId(e.target.value);
setError(null);
}}
disabled={toolTypesLoading || starting}
>
<option value="">Select a tool...</option>
{toolTypes.map((tt) => (
<option key={tt.id} value={tt.id}>
{tt.display_name}
{tt.category && ` (${tt.category})`}
</option>
))}
</select>
{toolTypesLoading && <span className="muted">Loading tools...</span>}
{toolTypesError && <span className="error-text">{toolTypesError}</span>}
</div>
{/* Config Profile */}
{selectedToolTypeId && (
<div className="form-group">
<label htmlFor="config-profile">Config Profile</label>
<select
id="config-profile"
value={selectedProfileId}
onChange={(e) => setSelectedProfileId(e.target.value)}
disabled={profilesLoading || starting}
>
<option value="">Default (no profile)</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.is_default && " (default)"}
</option>
))}
</select>
{profilesLoading && (
<span className="muted">Loading profiles...</span>
)}
{profiles.length === 0 && !profilesLoading && (
<span className="muted">No custom profiles for this tool.</span>
)}
</div>
)}
{/* SSH Key Selection */}
<div className="form-group ssh-key-selection">
<label>SSH Keys</label>
{sshKeysLoading ? (
<span className="muted">Loading SSH keys...</span>
) : sshKeys.length === 0 ? (
<span className="muted">No SSH keys configured.</span>
) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.375rem 0.75rem",
background: "var(--panel)",
borderRadius: "0.375rem",
border: "1px solid var(--border)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={selectedSshKeyIds.includes(key.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIds((prev) => [...prev, key.id]);
} else {
setSelectedSshKeyIds((prev) =>
prev.filter((id) => id !== key.id),
);
}
}}
disabled={starting}
/>
{key.name}
</label>
))}
</div>
)}
{!sshKeysLoading && repoHasSshKey && repoSshKey && (
<div className="hint" style={{ marginTop: "0.5rem" }}>
Repository key <strong>{repoSshKey.name}</strong> is pre-selected.
</div>
)}
</div>
{error && <p className="form-error">{error}</p>}
<div className="form-actions">
{onCancel && (
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={starting}
>
Cancel
</button>
)}
<button
type="button"
className="btn btn-primary"
onClick={handleStart}
disabled={!selectedToolTypeId || toolTypesLoading || starting}
>
{starting ? (
<>
<Icon name="loading" size="sm" /> Starting...
</>
) : (
<>
<Icon name="play" size="sm" /> Start Tool
</>
)}
</button>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More