Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22474cdba5 | |||
| 0c839e8c6f | |||
| c63cf7db50 | |||
| d9d2b91384 | |||
| d6ea5fb1fd | |||
| 1883825b18 | |||
| bc71fd6fac | |||
| 28aa9ccf5a | |||
| 44dd80cb58 | |||
| 23485833d8 | |||
| e23dcdf4e1 | |||
| f05ac55875 | |||
| bcefeb4163 | |||
| 33d08faf70 | |||
| 8a58c61278 | |||
| 8231e750d9 | |||
| 6ce645d210 | |||
| 89ca9f10c7 |
@@ -7,8 +7,6 @@ Create Date: 2026-05-22 21:50:00.000000
|
|||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "0014_merge_heads"
|
revision: str = "0014_merge_heads"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
from sqlalchemy import inspect
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "0015_single_interface"
|
revision: str = "0015_single_interface"
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""add pi agent tool type
|
||||||
|
|
||||||
|
Revision ID: 20260527_160017_add_pi_agent
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-27T16:00:17
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "20260527_160017_add_pi_agent"
|
||||||
|
down_revision: Union[str, None] = "2026_05_27_external_repos"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
PI_AGENT_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check if pi-agent already exists
|
||||||
|
conn = op.get_bind()
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO tool_types (
|
||||||
|
id, name, display_name, description, category,
|
||||||
|
interface_type, requires_port, default_port,
|
||||||
|
definition_type, compose_template, dockerfile_template, required_variables,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:id, :name, :display_name, :description, :category,
|
||||||
|
:interface_type, :requires_port, :default_port,
|
||||||
|
:definition_type, :compose_template, :dockerfile_template, :required_variables,
|
||||||
|
now(), now()
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": PI_AGENT_ID,
|
||||||
|
"name": "pi-agent",
|
||||||
|
"display_name": "Pi Agent",
|
||||||
|
"description": "Pi coding agent terminal environment with nvim, ranger, and tmux",
|
||||||
|
"category": "development",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"compose_template": """services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
volumes:
|
||||||
|
- ${REPO_PATH}:/workspace
|
||||||
|
working_dir: /workspace
|
||||||
|
command: /bin/bash""",
|
||||||
|
"dockerfile_template": """# Pi Coding Agent - Terminal-based coding harness
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \\
|
||||||
|
curl \\
|
||||||
|
wget \\
|
||||||
|
git \\
|
||||||
|
neovim \\
|
||||||
|
ranger \\
|
||||||
|
tmux \\
|
||||||
|
htop \\
|
||||||
|
tree \\
|
||||||
|
jq \\
|
||||||
|
ca-certificates \\
|
||||||
|
python3 \\
|
||||||
|
python3-pip \\
|
||||||
|
build-essential \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js (required for Pi)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
|
||||||
|
&& apt-get install -y nodejs \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Pi Coding Agent globally
|
||||||
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main \\
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \\
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
# Create default tmux config
|
||||||
|
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
|
# Create default ranger config
|
||||||
|
RUN mkdir -p /home/user/.config/ranger \\
|
||||||
|
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
|
# Set up Pi config directory
|
||||||
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
# Default to bash (Pi is invoked manually via `pi` command)
|
||||||
|
CMD ["/bin/bash"]""",
|
||||||
|
"required_variables": json.dumps(["REPO_PATH"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(
|
||||||
|
sa.text("DELETE FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
)
|
||||||
@@ -5,8 +5,6 @@ Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
|
|||||||
Create Date: 2026-05-24 18:00:43.990361
|
Create Date: 2026-05-24 18:00:43.990361
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ Create Date: 2026-05-24 10:43:14.000000
|
|||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "f3d2dc90ba3a"
|
revision: str = "f3d2dc90ba3a"
|
||||||
|
|||||||
+10
-10
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
|
|||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||||
response = RedirectResponse(location)
|
response = RedirectResponse(location)
|
||||||
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
||||||
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
||||||
@@ -63,7 +63,7 @@ async def callback(
|
|||||||
auth_next: str | None = Cookie(default="/"),
|
auth_next: str | None = Cookie(default="/"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
||||||
|
|
||||||
if auth_state is None or auth_state != state:
|
if auth_state is None or auth_state != state:
|
||||||
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
||||||
@@ -71,7 +71,7 @@ async def callback(
|
|||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
@@ -92,7 +92,7 @@ async def callback(
|
|||||||
access_token=token_payload["access_token"],
|
access_token=token_payload["access_token"],
|
||||||
client=client,
|
client=client,
|
||||||
)
|
)
|
||||||
logger.info("User info fetched successfully")
|
logger.debug("User info fetched successfully")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("User info fetch failed: %s", exc)
|
logger.error("User info fetch failed: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
||||||
@@ -100,19 +100,19 @@ async def callback(
|
|||||||
authentik_id = str(user_info.get("sub", ""))
|
authentik_id = str(user_info.get("sub", ""))
|
||||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||||
name = str(user_info.get("name", email))
|
name = str(user_info.get("name", email))
|
||||||
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
logger.info("Creating new user: authentik_id=%s", authentik_id)
|
logger.debug("Creating new user: authentik_id=%s", authentik_id)
|
||||||
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
logger.info("New user created: id=%s", user.id)
|
logger.info("New user created: id=%s", user.id)
|
||||||
else:
|
else:
|
||||||
logger.info("Existing user found: id=%s, updating info", user.id)
|
logger.debug("Existing user found: id=%s, updating info", user.id)
|
||||||
user.email = email
|
user.email = email
|
||||||
user.name = name
|
user.name = name
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -165,20 +165,20 @@ async def me(
|
|||||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
|
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||||
|
|
||||||
if not session_cookie:
|
if not session_cookie:
|
||||||
logger.warning("Auth /me: missing session cookie")
|
logger.warning("Auth /me: missing session cookie")
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||||
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
user_id = payload["user_id"]
|
user_id = payload["user_id"]
|
||||||
logger.info("Auth /me: decoded session for user_id=%s", user_id)
|
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("Auth /me: invalid session: %s", exc)
|
logger.warning("Auth /me: invalid session: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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.config_profile import ConfigProfile, ConfigProfileInclude
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.services.config_profile_resolver import (
|
from src.services.config_profile_resolver import (
|
||||||
@@ -58,18 +57,16 @@ def _calculate_profile_size(data: dict) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class GitMountItem(BaseModel):
|
class GitMountItem(BaseModel):
|
||||||
repo_id: str = Field(description="UUID of the git repository")
|
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||||
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
||||||
target_path: str = Field(description="Absolute path inside container")
|
target_path: str = Field(description="Absolute path inside container")
|
||||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||||
|
|
||||||
@field_validator("repo_id")
|
@field_validator("remote_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_repo_id(cls, v: str) -> str:
|
def validate_remote_url(cls, v: str) -> str:
|
||||||
try:
|
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
uuid.UUID(v)
|
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
||||||
except ValueError:
|
|
||||||
raise ValueError(f"Invalid repo_id UUID: {v}")
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("source_path")
|
@field_validator("source_path")
|
||||||
@@ -84,8 +81,6 @@ class GitMountItem(BaseModel):
|
|||||||
@field_validator("target_path")
|
@field_validator("target_path")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_target_path(cls, v: str) -> str:
|
def validate_target_path(cls, v: str) -> str:
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("target_path must be absolute (start with /)")
|
|
||||||
if ".." in v:
|
if ".." in v:
|
||||||
raise ValueError("target_path cannot contain path traversal (..)")
|
raise ValueError("target_path cannot contain path traversal (..)")
|
||||||
return v
|
return v
|
||||||
@@ -271,53 +266,23 @@ async def _validate_git_mounts(
|
|||||||
git_mounts: list[dict],
|
git_mounts: list[dict],
|
||||||
project_id: uuid.UUID | None = None,
|
project_id: uuid.UUID | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate that all referenced git repositories exist and are accessible.
|
"""Validate git mount URLs.
|
||||||
|
|
||||||
Repositories must:
|
Simply checks that remote_url looks like a valid git URL.
|
||||||
1. Exist
|
Actual clone validation happens at instance startup time.
|
||||||
2. Belong to the user (external repos with no project are allowed)
|
|
||||||
3. If project_id is specified, repos can be either:
|
|
||||||
- External repos (project_id is null) belonging to the user
|
|
||||||
- Project repos belonging to that project
|
|
||||||
"""
|
"""
|
||||||
for mount in git_mounts:
|
for mount in git_mounts:
|
||||||
repo_id = mount.get("repo_id")
|
remote_url = mount.get("remote_url")
|
||||||
if not repo_id:
|
if not remote_url:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Git mount missing repo_id",
|
detail="Git mount missing remote_url",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
repo_uuid = uuid.UUID(repo_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Invalid repo_id UUID: {repo_id}",
|
detail=f"Invalid git URL: {remote_url}",
|
||||||
)
|
|
||||||
|
|
||||||
repo = await session.get(GitRepository, repo_uuid)
|
|
||||||
if repo is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Git repository not found: {repo_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if repo.owner_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"Not authorized to access repository: {repo_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# External repos (no project) are always allowed for git mounts
|
|
||||||
if repo.project_id is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Project repos are allowed if they belong to the profile's project
|
|
||||||
if project_id is not None and repo.project_id != project_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Repository {repo_id} does not belong to project {project_id}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -454,7 +419,7 @@ async def create_config_profile(
|
|||||||
)
|
)
|
||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
logger.info("Created config profile %s for user %s", profile.id, user_uuid)
|
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
||||||
return _profile_to_response(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -555,7 +520,7 @@ async def update_config_profile(
|
|||||||
)
|
)
|
||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
logger.info("Updated config profile %s", profile.id)
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
return _profile_to_response(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -575,7 +540,7 @@ async def delete_config_profile(
|
|||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info("Deleted config profile %s", profile_id)
|
logger.debug("Deleted config profile %s", profile_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -660,7 +625,7 @@ async def update_profile_includes(
|
|||||||
)
|
)
|
||||||
direct_includes = inc_result.scalars().all()
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
logger.info("Updated includes for config profile %s", profile.id)
|
logger.debug("Updated includes for config profile %s", profile.id)
|
||||||
return _profile_to_response(profile, list(direct_includes))
|
return _profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import _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.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
from src.utils.git_files import (
|
from src.utils.git_files import (
|
||||||
commit_file,
|
commit_file,
|
||||||
get_file_content,
|
get_file_content,
|
||||||
@@ -232,36 +230,6 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{project_id}/repositories",
|
|
||||||
response_model=list[GitRepositoryResponse],
|
|
||||||
summary="List repositories",
|
|
||||||
description="List all git repositories in a project.",
|
|
||||||
)
|
|
||||||
async def list_repositories(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[GitRepository]:
|
|
||||||
"""List all repositories in a project.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of repositories in the project.
|
|
||||||
"""
|
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/repositories",
|
"/repositories",
|
||||||
response_model=list[GitRepositoryResponse],
|
response_model=list[GitRepositoryResponse],
|
||||||
@@ -287,45 +255,6 @@ async def list_user_repositories(
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{project_id}/repositories/{repo_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
summary="Delete a repository",
|
|
||||||
description="Delete a git repository from the project and remove it from disk.",
|
|
||||||
)
|
|
||||||
async def delete_repository(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> Response:
|
|
||||||
"""Delete a repository.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project.
|
|
||||||
repo_id: UUID of the repository to delete.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Empty response with 204 status code.
|
|
||||||
"""
|
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
|
||||||
if repo is None or repo.project_id != project_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
|
||||||
|
|
||||||
# Remove from disk
|
|
||||||
if os.path.exists(repo.path):
|
|
||||||
shutil.rmtree(repo.path)
|
|
||||||
|
|
||||||
await session.delete(repo)
|
|
||||||
await session.commit()
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/repositories/parse-url",
|
"/repositories/parse-url",
|
||||||
response_model=URLParseResponse,
|
response_model=URLParseResponse,
|
||||||
@@ -451,6 +380,75 @@ async def create_external_repository(
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{project_id}/repositories",
|
||||||
|
response_model=list[GitRepositoryResponse],
|
||||||
|
summary="List repositories",
|
||||||
|
description="List all git repositories in a project.",
|
||||||
|
)
|
||||||
|
async def list_repositories(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[GitRepository]:
|
||||||
|
"""List all repositories in a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of repositories in the project.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{project_id}/repositories/{repo_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete a repository",
|
||||||
|
description="Delete a git repository from the project and remove it from disk.",
|
||||||
|
)
|
||||||
|
async def delete_repository(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Delete a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository to delete.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Empty response with 204 status code.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
repo = await session.get(GitRepository, repo_id)
|
||||||
|
if repo is None or repo.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||||
|
|
||||||
|
# Remove from disk
|
||||||
|
if os.path.exists(repo.path):
|
||||||
|
shutil.rmtree(repo.path)
|
||||||
|
|
||||||
|
await session.delete(repo)
|
||||||
|
await session.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{project_id}/repositories",
|
"/{project_id}/repositories",
|
||||||
response_model=GitRepositoryResponse,
|
response_model=GitRepositoryResponse,
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.config import Settings
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from src.auth.dependencies import _get_owned_project, _get_user, get_current_use
|
|||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Shared Pydantic validators for API schemas."""
|
"""Shared Pydantic validators for API schemas."""
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
MAX_FOLDER_SIZE_MB = 10
|
MAX_FOLDER_SIZE_MB = 10
|
||||||
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
@@ -44,9 +44,9 @@ async def terminal_websocket(
|
|||||||
Returns:
|
Returns:
|
||||||
None. Communicates via WebSocket messages.
|
None. Communicates via WebSocket messages.
|
||||||
"""
|
"""
|
||||||
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
|
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
logger.info("Terminal WebSocket accepted for instance %s", instance_id)
|
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse instance_id
|
# Parse instance_id
|
||||||
@@ -80,13 +80,13 @@ async def terminal_websocket(
|
|||||||
await websocket.close(code=4004, reason="Instance not running")
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||||
|
|
||||||
# Fetch tool type to get startup_command
|
# Fetch tool type to get startup_command
|
||||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
startup_command = tool_type.startup_command if tool_type else None
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
if startup_command:
|
if startup_command:
|
||||||
logger.info("Using startup command for instance %s: %s", instance_id, startup_command)
|
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
|
||||||
|
|
||||||
# Get or create terminal session
|
# Get or create terminal session
|
||||||
try:
|
try:
|
||||||
@@ -95,15 +95,15 @@ async def terminal_websocket(
|
|||||||
instance.container_id,
|
instance.container_id,
|
||||||
startup_command=startup_command,
|
startup_command=startup_command,
|
||||||
)
|
)
|
||||||
logger.info("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
||||||
|
|
||||||
# Attach WebSocket to session
|
# Attach WebSocket to session
|
||||||
await terminal_manager.attach_websocket(session, websocket)
|
await terminal_manager.attach_websocket(session, websocket)
|
||||||
logger.info("WebSocket attached to session for instance %s", instance_id)
|
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||||
|
|
||||||
# Send connected status
|
# Send connected status
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
logger.info("Sent connected status for instance %s", instance_id)
|
logger.debug("Sent connected status for instance %s", instance_id)
|
||||||
|
|
||||||
# Use mutable session reference so loops can survive reset
|
# Use mutable session reference so loops can survive reset
|
||||||
session_ref = SessionRef(session)
|
session_ref = SessionRef(session)
|
||||||
@@ -112,7 +112,7 @@ async def terminal_websocket(
|
|||||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||||
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
||||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||||
logger.info("Started terminal loops for instance %s", instance_id)
|
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||||
|
|
||||||
# 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(
|
||||||
@@ -120,7 +120,7 @@ async def terminal_websocket(
|
|||||||
return_when=asyncio.FIRST_COMPLETED,
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
||||||
|
|
||||||
# Cancel remaining tasks
|
# Cancel remaining tasks
|
||||||
for task in pending:
|
for task in pending:
|
||||||
@@ -134,7 +134,7 @@ async def terminal_websocket(
|
|||||||
try:
|
try:
|
||||||
if 'session' in locals():
|
if 'session' in locals():
|
||||||
await terminal_manager.detach_websocket(session, websocket)
|
await terminal_manager.detach_websocket(session, websocket)
|
||||||
logger.info("WebSocket detached from session for instance %s", instance_id)
|
logger.debug("WebSocket detached from session for instance %s", instance_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -183,11 +183,11 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
if msg_type == "resize":
|
if msg_type == "resize":
|
||||||
cols = ctrl.get("cols", 80)
|
cols = ctrl.get("cols", 80)
|
||||||
rows = ctrl.get("rows", 24)
|
rows = ctrl.get("rows", 24)
|
||||||
logger.info(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
||||||
await session.resize(cols, rows)
|
await session.resize(cols, rows)
|
||||||
elif msg_type == "reset":
|
elif msg_type == "reset":
|
||||||
# Reset terminal session
|
# Reset terminal session
|
||||||
logger.info("Resetting terminal session for instance %s", session.instance_id)
|
logger.debug("Resetting terminal session for instance %s", session.instance_id)
|
||||||
await websocket.send_json({"type": "status", "status": "resetting"})
|
await websocket.send_json({"type": "status", "status": "resetting"})
|
||||||
|
|
||||||
# Reset the session
|
# Reset the session
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tool configuration API endpoints."""
|
"""Tool configuration API endpoints."""
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
@@ -11,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
|
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.tool_config import ToolConfig
|
from src.models.tool_config import ToolConfig
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||||
|
|
||||||
|
|||||||
+412
-172
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -9,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from src.api.tool_types_validation import (
|
from src.api.tool_types_validation import (
|
||||||
check_port_exposed,
|
check_port_exposed,
|
||||||
sanitize_template_vars,
|
|
||||||
validate_compose_yaml,
|
validate_compose_yaml,
|
||||||
validate_required_variables,
|
validate_required_variables,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.user import User
|
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||||
|
|
||||||
|
|
||||||
@@ -103,11 +102,11 @@ async def update_user_config(
|
|||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user_id, update_data)
|
logger.debug("Updating user config for user %s: %s", user_id, update_data)
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
config.config = {**config.config, **update_data}
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
logger.info("Updated config: %s", config.config)
|
logger.debug("Updated config: %s", config.config)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.auth.session import decode_session_cookie
|
from src.auth.session import decode_session_cookie
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
|
from src.models.project import Project
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.dashboard import router as dashboard_router
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
) # {"rel/path": "content", ...}
|
) # {"rel/path": "content", ...}
|
||||||
git_mounts: Mapped[list] = mapped_column(
|
git_mounts: Mapped[list] = mapped_column(
|
||||||
JSON, default=list, nullable=False
|
JSON, default=list, nullable=False
|
||||||
) # [{"repo_id": "uuid", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def clone_repository(
|
|||||||
str(clone_path),
|
str(clone_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
logger.debug("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -55,7 +55,7 @@ def clone_repository(
|
|||||||
logger.error("Git clone failed: %s", result.stderr)
|
logger.error("Git clone failed: %s", result.stderr)
|
||||||
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
||||||
|
|
||||||
logger.info("Successfully cloned repository into %s", clone_path)
|
logger.debug("Successfully cloned repository into %s", clone_path)
|
||||||
return str(clone_path)
|
return str(clone_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -94,4 +94,4 @@ def remove_clone_directory(instance_dir: str) -> None:
|
|||||||
if clone_path.exists():
|
if clone_path.exists():
|
||||||
import shutil
|
import shutil
|
||||||
shutil.rmtree(clone_path)
|
shutil.rmtree(clone_path)
|
||||||
logger.info("Removed clone directory: %s", clone_path)
|
logger.debug("Removed clone directory: %s", clone_path)
|
||||||
|
|||||||
@@ -176,13 +176,13 @@ def _merge_git_mounts(
|
|||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Merge git mounts from included profiles.
|
"""Merge git mounts from included profiles.
|
||||||
|
|
||||||
Later mounts override earlier ones with the same repo_id + target_path combo.
|
Later mounts override earlier ones with the same remote_url + target_path combo.
|
||||||
"""
|
"""
|
||||||
result = list(base)
|
result = list(base)
|
||||||
# Build lookup by (repo_id, target_path)
|
# Build lookup by (remote_url, target_path)
|
||||||
seen = {(m["repo_id"], m["target_path"]): i for i, m in enumerate(result)}
|
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
|
||||||
for mount in overlay:
|
for mount in overlay:
|
||||||
key = (mount["repo_id"], mount["target_path"])
|
key = (mount["remote_url"], mount["target_path"])
|
||||||
if key in seen:
|
if key in seen:
|
||||||
result[seen[key]] = dict(mount)
|
result[seen[key]] = dict(mount)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Docker service for managing tool instances."""
|
"""Docker service for managing tool instances."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) ->
|
|||||||
"""
|
"""
|
||||||
if base_path is None:
|
if base_path is None:
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|
||||||
base_path = Settings().instance_base_path
|
base_path = Settings().instance_base_path
|
||||||
instance_dir = Path(base_path) / instance_id
|
instance_dir = Path(base_path) / instance_id
|
||||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -136,6 +139,8 @@ 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.
|
||||||
|
|
||||||
|
Searches all containers including stopped/exited ones.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
instance_name: The service name in compose
|
||||||
|
|
||||||
@@ -143,7 +148,7 @@ def get_container_id(instance_name: str) -> str | None:
|
|||||||
Container ID or None if not found
|
Container ID or None if not found
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
@@ -156,6 +161,8 @@ def get_container_id(instance_name: str) -> str | 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.
|
||||||
|
|
||||||
|
Searches all containers including stopped/exited ones.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
instance_name: The service name in compose
|
||||||
|
|
||||||
@@ -163,7 +170,15 @@ def get_container_name(instance_name: str) -> str | None:
|
|||||||
Container name or None if not found
|
Container name or None if not found
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
[
|
||||||
|
"docker",
|
||||||
|
"ps",
|
||||||
|
"-a",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
"--filter",
|
||||||
|
f"name={instance_name}",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
@@ -173,7 +188,9 @@ def get_container_name(instance_name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
def connect_container_to_network(
|
||||||
|
container_name: str, network_name: str = "backend"
|
||||||
|
) -> bool:
|
||||||
"""Connect a Docker container to an existing network.
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -203,7 +220,9 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"docker", "inspect", "-f",
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||||
container_id,
|
container_id,
|
||||||
],
|
],
|
||||||
@@ -238,7 +257,6 @@ def wait_for_container_running(
|
|||||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||||
and 'waited_seconds' (float)
|
and 'waited_seconds' (float)
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
@@ -322,11 +340,6 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|||||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def start_cloudflared_tunnel(
|
def start_cloudflared_tunnel(
|
||||||
container_name: str, port: int, timeout: int = 30
|
container_name: str, port: int, timeout: int = 30
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -344,8 +357,6 @@ def start_cloudflared_tunnel(
|
|||||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
import re
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -354,18 +365,29 @@ def start_cloudflared_tunnel(
|
|||||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||||
for attempt in range(10):
|
for attempt in range(10):
|
||||||
check = subprocess.run(
|
check = subprocess.run(
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
[
|
||||||
f"http://{container_name}:{port}"],
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
f"http://{container_name}:{port}",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
logger.info(
|
||||||
|
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
|
||||||
|
)
|
||||||
if check.returncode == 0:
|
if check.returncode == 0:
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
else:
|
else:
|
||||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
logger.warning(
|
||||||
|
"Container %s:%d not responding to curl checks", container_name, port
|
||||||
|
)
|
||||||
|
|
||||||
# Run cloudflared in background, capture output
|
# Run cloudflared in background, capture output
|
||||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||||
@@ -384,6 +406,7 @@ def start_cloudflared_tunnel(
|
|||||||
while time.time() - start_time < timeout:
|
while time.time() - start_time < timeout:
|
||||||
# Read available output
|
# Read available output
|
||||||
import select
|
import select
|
||||||
|
|
||||||
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||||
if readable:
|
if readable:
|
||||||
line = proc.stdout.readline()
|
line = proc.stdout.readline()
|
||||||
@@ -410,7 +433,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
|
|||||||
Args:
|
Args:
|
||||||
pid: Process ID of the cloudflared tunnel
|
pid: Process ID of the cloudflared tunnel
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -455,8 +477,17 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
[
|
||||||
"--max-time", str(timeout), url],
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
str(timeout),
|
||||||
|
url,
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout + 5,
|
timeout=timeout + 5,
|
||||||
@@ -495,7 +526,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|||||||
except (ValueError, Exception) as e:
|
except (ValueError, Exception) as e:
|
||||||
error_str = str(e).lower()
|
error_str = str(e).lower()
|
||||||
# Classify connection errors
|
# Classify connection errors
|
||||||
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
|
if any(
|
||||||
|
err in error_str
|
||||||
|
for err in [
|
||||||
|
"connection refused",
|
||||||
|
"econnrefused",
|
||||||
|
"could not resolve",
|
||||||
|
"nodename",
|
||||||
|
]
|
||||||
|
):
|
||||||
return {
|
return {
|
||||||
"tunnel_status": "unreachable",
|
"tunnel_status": "unreachable",
|
||||||
"status_code": None,
|
"status_code": None,
|
||||||
|
|||||||
@@ -18,13 +18,12 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (returncode, stdout, stderr)
|
Tuple of (returncode, stdout, stderr)
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Write Dockerfile
|
# Write Dockerfile
|
||||||
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||||
dockerfile_path.write_text(dockerfile)
|
dockerfile_path.write_text(dockerfile)
|
||||||
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
logger.debug("Wrote Dockerfile to %s", dockerfile_path)
|
||||||
|
|
||||||
# Write build context files
|
# Write build context files
|
||||||
if build_context:
|
if build_context:
|
||||||
@@ -39,10 +38,10 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
logger.info("Wrote build context file: %s", full_path)
|
logger.debug("Wrote build context file: %s", full_path)
|
||||||
|
|
||||||
# Build image
|
# Build image
|
||||||
logger.info("Building Docker image with tag: %s", tag)
|
logger.debug("Building Docker image with tag: %s", tag)
|
||||||
cmd = [
|
cmd = [
|
||||||
"docker", "build",
|
"docker", "build",
|
||||||
"-t", tag,
|
"-t", tag,
|
||||||
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=300, # 5 minute timeout for builds
|
timeout=300, # 5 minute timeout for builds
|
||||||
)
|
)
|
||||||
logger.info("Docker build completed: returncode=%d", result.returncode)
|
logger.debug("Docker build completed: returncode=%d", result.returncode)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("Docker build failed: %s", result.stderr[:1000])
|
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||||
return result.returncode, result.stdout, result.stderr
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
@@ -72,11 +71,11 @@ class TerminalManager:
|
|||||||
|
|
||||||
# Check if session is still alive
|
# Check if session is still alive
|
||||||
if session.is_alive():
|
if session.is_alive():
|
||||||
logger.info("Reattaching to existing terminal session for instance %s", instance_id)
|
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
|
||||||
return session
|
return session
|
||||||
else:
|
else:
|
||||||
# Session died, clean it up
|
# Session died, clean it up
|
||||||
logger.info("Existing session for instance %s is dead, cleaning up", instance_id)
|
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
|
||||||
await session.close()
|
await session.close()
|
||||||
del self._sessions[instance_id_str]
|
del self._sessions[instance_id_str]
|
||||||
|
|
||||||
@@ -97,7 +96,7 @@ class TerminalManager:
|
|||||||
"""Attach a WebSocket to an existing session."""
|
"""Attach a WebSocket to an existing session."""
|
||||||
# Handle concurrent connections - close existing ones
|
# Handle concurrent connections - close existing ones
|
||||||
if session.has_websockets():
|
if session.has_websockets():
|
||||||
logger.info("Closing existing WebSocket connections for instance %s", session.instance_id)
|
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
|
||||||
for ws in list(session._websockets):
|
for ws in list(session._websockets):
|
||||||
try:
|
try:
|
||||||
await ws.close(code=4000, reason="New connection established")
|
await ws.close(code=4000, reason="New connection established")
|
||||||
@@ -135,7 +134,7 @@ class TerminalManager:
|
|||||||
|
|
||||||
# Close existing session if any
|
# Close existing session if any
|
||||||
if instance_id_str in self._sessions:
|
if instance_id_str in self._sessions:
|
||||||
logger.info("Resetting terminal session for instance %s", instance_id)
|
logger.debug("Resetting terminal session for instance %s", instance_id)
|
||||||
old_session = self._sessions.pop(instance_id_str)
|
old_session = self._sessions.pop(instance_id_str)
|
||||||
await old_session.close()
|
await old_session.close()
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,12 @@ class TerminalSession:
|
|||||||
|
|
||||||
# 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.info(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
|
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
|
||||||
|
|
||||||
# Build the shell command
|
# Build the shell command
|
||||||
if startup_command:
|
if startup_command:
|
||||||
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
||||||
logger.info(f"Using startup command for session {self.session_id}: {startup_command}")
|
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
|
||||||
else:
|
else:
|
||||||
shell_cmd = "bash -il"
|
shell_cmd = "bash -il"
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ class TerminalSession:
|
|||||||
size = struct.pack('HHHH', rows, cols, 0, 0)
|
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||||
try:
|
try:
|
||||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||||
logger.info(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||||
except (OSError, IOError) as e:
|
except (OSError, IOError) as e:
|
||||||
logger.error(f"Failed to resize PTY: {e}")
|
logger.error(f"Failed to resize PTY: {e}")
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ class TerminalSession:
|
|||||||
|
|
||||||
self._cols = cols
|
self._cols = cols
|
||||||
self._rows = rows
|
self._rows = rows
|
||||||
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||||
self._set_terminal_size(cols, rows)
|
self._set_terminal_size(cols, rows)
|
||||||
|
|
||||||
# Docker exec -it creates its own PTY inside the container,
|
# Docker exec -it creates its own PTY inside the container,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def _run_git_command(repo_path: str, *args: str) -> str:
|
def _run_git_command(repo_path: str, *args: str) -> str:
|
||||||
|
|||||||
@@ -8,16 +8,14 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
# Set test environment BEFORE importing app modules
|
# Set test environment BEFORE importing app modules
|
||||||
os.environ["APP_ENV"] = "testing"
|
os.environ["APP_ENV"] = "testing"
|
||||||
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings
|
||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.main import app
|
from src.main import app
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
@@ -369,7 +369,7 @@ class TestConfigProfilesAPI:
|
|||||||
json={
|
json={
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": "config",
|
"source_path": "config",
|
||||||
"target_path": "/config",
|
"target_path": "/config",
|
||||||
}
|
}
|
||||||
@@ -393,7 +393,7 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": "/absolute/path",
|
"source_path": "/absolute/path",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
}
|
}
|
||||||
@@ -402,8 +402,8 @@ class TestConfigProfilesAPI:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_create_config_profile_invalid_git_mount_target_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
"""Test that invalid git mount target paths are rejected."""
|
"""Test that git mount target paths with traversal are rejected."""
|
||||||
_project_id, repo_id = test_project_and_repo
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
@@ -414,9 +414,9 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "relative/path",
|
"target_path": "../../../etc/passwd",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -436,7 +436,7 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
}
|
}
|
||||||
@@ -450,4 +450,4 @@ class TestConfigProfilesAPI:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert len(data["git_mounts"]) == 1
|
assert len(data["git_mounts"]) == 1
|
||||||
assert data["git_mounts"][0]["repo_id"] == repo_id
|
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
|
||||||
|
|||||||
@@ -82,28 +82,6 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
|
|||||||
assert UserConfig.user.property.mapper.class_ is User
|
assert UserConfig.user.property.mapper.class_ is User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
|
|
||||||
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
|
|
||||||
columns = RefreshToken.__table__.columns
|
|
||||||
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
|
|
||||||
|
|
||||||
assert set(columns.keys()) == {
|
|
||||||
"id",
|
|
||||||
"user_id",
|
|
||||||
"token_hash",
|
|
||||||
"expires_at",
|
|
||||||
"revoked_at",
|
|
||||||
"user_agent",
|
|
||||||
"ip_address",
|
|
||||||
"created_at",
|
|
||||||
}
|
|
||||||
assert columns["token_hash"].unique is True
|
|
||||||
assert columns["revoked_at"].nullable is True
|
|
||||||
assert user_fk.target_fullname == "users.id"
|
|
||||||
assert RefreshToken.user.property.mapper.class_ is User
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import uuid
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import uuid
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -221,7 +220,7 @@ class TestToolTypesAPIExtended:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
data = response.json()
|
_ = response.json()
|
||||||
|
|
||||||
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
|
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test creating a tool type with startup_command."""
|
"""Test creating a tool type with startup_command."""
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
|||||||
from src.services.config_profile_resolver import (
|
from src.services.config_profile_resolver import (
|
||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
ConfigProfileNotFoundError,
|
ConfigProfileNotFoundError,
|
||||||
ResolvedProfile,
|
|
||||||
check_include_cycle,
|
check_include_cycle,
|
||||||
resolve_profile,
|
resolve_profile,
|
||||||
_merge_env_vars,
|
_merge_env_vars,
|
||||||
@@ -63,7 +62,6 @@ class TestMergeFunctions:
|
|||||||
|
|
||||||
def test_merge_mounts_basic(self) -> None:
|
def test_merge_mounts_basic(self) -> None:
|
||||||
"""Test basic mount merging."""
|
"""Test basic mount merging."""
|
||||||
from src.services.config_profile_resolver import ResolvedMount
|
|
||||||
result = _merge_mounts(
|
result = _merge_mounts(
|
||||||
{},
|
{},
|
||||||
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
|
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
|
||||||
@@ -102,18 +100,18 @@ class TestMergeFunctions:
|
|||||||
"""Test basic git mount merging."""
|
"""Test basic git mount merging."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[],
|
[],
|
||||||
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["repo_id"] == "repo1"
|
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
assert result[0]["target_path"] == "/app"
|
assert result[0]["target_path"] == "/app"
|
||||||
|
|
||||||
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
||||||
"""Test that git mounts with same repo+target override."""
|
"""Test that git mounts with same repo+target override."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
||||||
[{"repo_id": "repo1", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -123,8 +121,8 @@ class TestMergeFunctions:
|
|||||||
def test_merge_git_mounts_different_targets(self) -> None:
|
def test_merge_git_mounts_different_targets(self) -> None:
|
||||||
"""Test that git mounts with different targets are preserved."""
|
"""Test that git mounts with different targets are preserved."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||||
[{"repo_id": "repo2", "source_path": ".", "target_path": "/config"}],
|
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
@@ -296,7 +294,7 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"repo_id": "repo1", "source_path": ".", "target_path": "/app"},
|
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(profile)
|
db_session.add(profile)
|
||||||
@@ -304,7 +302,7 @@ class TestResolveProfile:
|
|||||||
|
|
||||||
result = await resolve_profile(db_session, profile.id)
|
result = await resolve_profile(db_session, profile.id)
|
||||||
assert len(result.git_mounts) == 1
|
assert len(result.git_mounts) == 1
|
||||||
assert result.git_mounts[0]["repo_id"] == "repo1"
|
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
assert result.git_mounts[0]["target_path"] == "/app"
|
assert result.git_mounts[0]["target_path"] == "/app"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -320,7 +318,7 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"repo_id": "repo1", "source_path": ".", "target_path": "/app"},
|
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(base)
|
db_session.add(base)
|
||||||
@@ -333,7 +331,7 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"repo_id": "repo2", "source_path": "config", "target_path": "/config"},
|
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(child)
|
db_session.add(child)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Unit tests for git mount resolution in tool instances."""
|
"""Unit tests for git mount resolution in tool instances."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -11,7 +10,6 @@ from src.api.tool_instances import (
|
|||||||
_expand_glob_source,
|
_expand_glob_source,
|
||||||
_resolve_single_git_mount,
|
_resolve_single_git_mount,
|
||||||
)
|
)
|
||||||
from src.services.config_profile_resolver import ResolvedProfile
|
|
||||||
|
|
||||||
|
|
||||||
class TestExpandGlobSource:
|
class TestExpandGlobSource:
|
||||||
@@ -91,23 +89,22 @@ class TestCheckoutBranch:
|
|||||||
assert result == "feature"
|
assert result == "feature"
|
||||||
|
|
||||||
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
|
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
|
||||||
"""Test checking out a non-existent branch raises error."""
|
"""Test checking out a non-existent branch returns False."""
|
||||||
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||||
(tmp_path / "file.txt").write_text("content")
|
(tmp_path / "file.txt").write_text("content")
|
||||||
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="Failed to checkout branch"):
|
result = _checkout_branch(str(tmp_path), "nonexistent")
|
||||||
_checkout_branch(str(tmp_path), "nonexistent")
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
class TestResolveSingleGitMount:
|
class TestResolveSingleGitMount:
|
||||||
"""Unit tests for resolving a single git mount."""
|
"""Unit tests for resolving a single git mount."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_missing_repo(self, db_session) -> None:
|
async def test_resolve_missing_remote_url(self, db_session) -> None:
|
||||||
"""Test that missing repo returns empty list."""
|
"""Test that missing remote_url returns empty list."""
|
||||||
git_mount = {
|
git_mount = {
|
||||||
"repo_id": "12345678-1234-1234-1234-123456789abc",
|
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
}
|
}
|
||||||
@@ -119,21 +116,9 @@ class TestResolveSingleGitMount:
|
|||||||
async def test_resolve_missing_target_path(self, db_session) -> None:
|
async def test_resolve_missing_target_path(self, db_session) -> None:
|
||||||
"""Test that missing target path returns empty list."""
|
"""Test that missing target path returns empty list."""
|
||||||
git_mount = {
|
git_mount = {
|
||||||
"repo_id": "12345678-1234-1234-1234-123456789abc",
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await _resolve_single_git_mount(db_session, git_mount)
|
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resolve_invalid_repo_id(self, db_session) -> None:
|
|
||||||
"""Test that invalid repo_id returns empty list."""
|
|
||||||
git_mount = {
|
|
||||||
"repo_id": "not-a-uuid",
|
|
||||||
"source_path": ".",
|
|
||||||
"target_path": "/app",
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await _resolve_single_git_mount(db_session, git_mount)
|
|
||||||
assert result == []
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for git URL parsing utilities."""
|
"""Tests for git URL parsing utilities."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""Unit tests for readiness probe service."""
|
"""Unit tests for readiness probe service."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.services.readiness_probe import execute_probe
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from src.api.tool_instances import CreateInstanceRequest
|
from src.api.tool_instances import CreateInstanceRequest
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import axios from "axios";
|
import axios, { type AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const MAX_RETRIES = 2;
|
|||||||
const RETRY_DELAY_MS = 1000;
|
const RETRY_DELAY_MS = 1000;
|
||||||
|
|
||||||
// Track retry count per request
|
// Track retry count per request
|
||||||
const retryCount = new WeakMap<any, number>();
|
const retryCount = new WeakMap<AxiosRequestConfig, number>();
|
||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface ConfigProfileMount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GitMount {
|
export interface GitMount {
|
||||||
repo_id: string;
|
remote_url: string;
|
||||||
source_path: string;
|
source_path: string;
|
||||||
target_path: string;
|
target_path: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface URLParseResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||||
const response = await apiClient.post("/projects/repositories/parse-url", { url });
|
const response = await apiClient.post("/repositories/parse-url", { url });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export async function listRepositories(projectId?: string): Promise<GitRepositor
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllUserRepositories(): Promise<GitRepository[]> {
|
export async function listAllUserRepositories(): Promise<GitRepository[]> {
|
||||||
const response = await apiClient.get<GitRepository[]>("/projects/repositories");
|
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AxiosError } from "axios";
|
||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
export interface ToolInstance {
|
export interface ToolInstance {
|
||||||
@@ -80,9 +81,10 @@ export async function startInstance(
|
|||||||
{ config_profile_id: configProfileId }
|
{ config_profile_id: configProfileId }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||||
if (retries > 0 && !error.response) {
|
const axiosError = error as AxiosError;
|
||||||
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
||||||
}
|
}
|
||||||
@@ -114,9 +116,10 @@ export async function restartInstance(
|
|||||||
{ config_profile_id: configProfileId }
|
{ config_profile_id: configProfileId }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||||
if (retries > 0 && !error.response) {
|
const axiosError = error as AxiosError;
|
||||||
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { GitMount } from "../api/config_profiles";
|
import type { GitMount } from "../api/config_profiles";
|
||||||
import type { GitRepository } from "../api/git_repositories";
|
|
||||||
|
|
||||||
interface GitMountEditorProps {
|
interface GitMountEditorProps {
|
||||||
mounts: GitMount[];
|
mounts: GitMount[];
|
||||||
repositories: GitRepository[];
|
|
||||||
onChange: (mounts: GitMount[]) => void;
|
onChange: (mounts: GitMount[]) => void;
|
||||||
onCreateRepository?: (name: string, remoteUrl: string) => Promise<GitRepository>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitMountEditor = ({ mounts, repositories, onChange, onCreateRepository }: GitMountEditorProps) => {
|
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [newMount, setNewMount] = useState<GitMount>({
|
const [newMount, setNewMount] = useState<GitMount>({
|
||||||
repo_id: "",
|
remote_url: "",
|
||||||
source_path: ".",
|
source_path: ".",
|
||||||
target_path: "",
|
target_path: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = (mount: GitMount) => {
|
||||||
if (!newMount.repo_id || !newMount.target_path) return;
|
onChange([...mounts, mount]);
|
||||||
onChange([...mounts, { ...newMount }]);
|
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||||
setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = (index: number, updated: GitMount) => {
|
const handleUpdate = (index: number, updated: GitMount) => {
|
||||||
@@ -39,11 +35,18 @@ export const GitMountEditor = ({ mounts, repositories, onChange, onCreateReposit
|
|||||||
const validatePath = (path: string, isTarget: boolean): string | null => {
|
const validatePath = (path: string, isTarget: boolean): string | null => {
|
||||||
if (!path) return isTarget ? "Target path is required" : null;
|
if (!path) return isTarget ? "Target path is required" : null;
|
||||||
if (path.includes("..")) return "Path cannot contain ..";
|
if (path.includes("..")) return "Path cannot contain ..";
|
||||||
if (isTarget && !path.startsWith("/")) return "Target path must be absolute";
|
|
||||||
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
|
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateUrl = (url: string): string | null => {
|
||||||
|
if (!url) return "Git URL is required";
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
|
||||||
|
return "Must be a valid git URL (https://, git@, or ssh://)";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-mount-editor">
|
<div className="git-mount-editor">
|
||||||
<h4 className="section-subtitle">Git Mounts</h4>
|
<h4 className="section-subtitle">Git Mounts</h4>
|
||||||
@@ -55,18 +58,15 @@ export const GitMountEditor = ({ mounts, repositories, onChange, onCreateReposit
|
|||||||
{editingIndex === index ? (
|
{editingIndex === index ? (
|
||||||
<GitMountForm
|
<GitMountForm
|
||||||
mount={mount}
|
mount={mount}
|
||||||
repositories={repositories}
|
|
||||||
onSave={(updated) => handleUpdate(index, updated)}
|
onSave={(updated) => handleUpdate(index, updated)}
|
||||||
onCancel={() => setEditingIndex(null)}
|
onCancel={() => setEditingIndex(null)}
|
||||||
validatePath={validatePath}
|
validatePath={validatePath}
|
||||||
onCreateRepository={onCreateRepository}
|
validateUrl={validateUrl}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="git-mount-display">
|
<div className="git-mount-display">
|
||||||
<div className="git-mount-info">
|
<div className="git-mount-info">
|
||||||
<span className="git-mount-repo">
|
<span className="git-mount-repo">{mount.remote_url}</span>
|
||||||
{repositories.find((r) => r.id === mount.repo_id)?.name || mount.repo_id}
|
|
||||||
</span>
|
|
||||||
<span className="git-mount-paths">
|
<span className="git-mount-paths">
|
||||||
{mount.source_path || "."} → {mount.target_path}
|
{mount.source_path || "."} → {mount.target_path}
|
||||||
</span>
|
</span>
|
||||||
@@ -103,11 +103,10 @@ export const GitMountEditor = ({ mounts, repositories, onChange, onCreateReposit
|
|||||||
<h5>Add Git Mount</h5>
|
<h5>Add Git Mount</h5>
|
||||||
<GitMountForm
|
<GitMountForm
|
||||||
mount={newMount}
|
mount={newMount}
|
||||||
repositories={repositories}
|
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" })}
|
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
|
||||||
validatePath={validatePath}
|
validatePath={validatePath}
|
||||||
onCreateRepository={onCreateRepository}
|
validateUrl={validateUrl}
|
||||||
isNew
|
isNew
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,21 +116,16 @@ export const GitMountEditor = ({ mounts, repositories, onChange, onCreateReposit
|
|||||||
|
|
||||||
interface GitMountFormProps {
|
interface GitMountFormProps {
|
||||||
mount: GitMount;
|
mount: GitMount;
|
||||||
repositories: GitRepository[];
|
|
||||||
onSave: (mount: GitMount) => void;
|
onSave: (mount: GitMount) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
validatePath: (path: string, isTarget: boolean) => string | null;
|
validatePath: (path: string, isTarget: boolean) => string | null;
|
||||||
onCreateRepository?: (name: string, remoteUrl: string) => Promise<GitRepository>;
|
validateUrl: (url: string) => string | null;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, onCreateRepository, isNew }: GitMountFormProps) => {
|
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
|
||||||
const [form, setForm] = useState<GitMount>({ ...mount });
|
const [form, setForm] = useState<GitMount>({ ...mount });
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [isCreatingRepo, setIsCreatingRepo] = useState(false);
|
|
||||||
const [newRepoName, setNewRepoName] = useState("");
|
|
||||||
const [newRepoUrl, setNewRepoUrl] = useState("");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const handleChange = (field: keyof GitMount, value: string) => {
|
const handleChange = (field: keyof GitMount, value: string) => {
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
@@ -144,32 +138,11 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, onC
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateRepo = async () => {
|
|
||||||
if (!onCreateRepository || !newRepoName.trim() || !newRepoUrl.trim()) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const repo = await onCreateRepository(newRepoName.trim(), newRepoUrl.trim());
|
|
||||||
handleChange("repo_id", repo.id);
|
|
||||||
setIsCreatingRepo(false);
|
|
||||||
setNewRepoName("");
|
|
||||||
setNewRepoUrl("");
|
|
||||||
} catch (err) {
|
|
||||||
setErrors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
repo_id: err instanceof Error ? err.message : "Failed to create repository",
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
if (!form.repo_id) {
|
const urlError = validateUrl(form.remote_url);
|
||||||
newErrors.repo_id = "Repository is required";
|
if (urlError) newErrors.remote_url = urlError;
|
||||||
}
|
|
||||||
|
|
||||||
const sourceError = validatePath(form.source_path || ".", false);
|
const sourceError = validatePath(form.source_path || ".", false);
|
||||||
if (sourceError) newErrors.source_path = sourceError;
|
if (sourceError) newErrors.source_path = sourceError;
|
||||||
@@ -184,79 +157,23 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, onC
|
|||||||
|
|
||||||
onSave(form);
|
onSave(form);
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
setForm({ repo_id: "", source_path: ".", target_path: "", branch: "" });
|
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-mount-form">
|
<div className="git-mount-form">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label>Repository</label>
|
<label>Git URL</label>
|
||||||
{!isCreatingRepo ? (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
value={form.repo_id}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.value === "__new__") {
|
|
||||||
setIsCreatingRepo(true);
|
|
||||||
} else {
|
|
||||||
handleChange("repo_id", e.target.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={errors.repo_id ? "error" : ""}
|
|
||||||
>
|
|
||||||
<option value="">Select a repository...</option>
|
|
||||||
{repositories.map((repo) => (
|
|
||||||
<option key={repo.id} value={repo.id}>
|
|
||||||
{repo.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
{onCreateRepository && (
|
|
||||||
<option value="__new__">+ Add new repository...</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
{errors.repo_id && <span className="error-text">{errors.repo_id}</span>}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="new-repo-form">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newRepoName}
|
value={form.remote_url}
|
||||||
onChange={(e) => setNewRepoName(e.target.value)}
|
onChange={(e) => handleChange("remote_url", e.target.value)}
|
||||||
placeholder="Repository name"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newRepoUrl}
|
|
||||||
onChange={(e) => setNewRepoUrl(e.target.value)}
|
|
||||||
placeholder="https://github.com/user/repo.git"
|
placeholder="https://github.com/user/repo.git"
|
||||||
disabled={isSubmitting}
|
className={errors.remote_url ? "error" : ""}
|
||||||
/>
|
/>
|
||||||
<div className="new-repo-actions">
|
<span className="hint">Repository URL (HTTPS or SSH)</span>
|
||||||
<button
|
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
|
||||||
type="button"
|
|
||||||
className="primary-button small"
|
|
||||||
onClick={handleCreateRepo}
|
|
||||||
disabled={isSubmitting || !newRepoName.trim() || !newRepoUrl.trim()}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Creating..." : "Create Repository"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => {
|
|
||||||
setIsCreatingRepo(false);
|
|
||||||
setNewRepoName("");
|
|
||||||
setNewRepoUrl("");
|
|
||||||
}}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
@@ -281,7 +198,7 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, onC
|
|||||||
placeholder="e.g., /app/config"
|
placeholder="e.g., /app/config"
|
||||||
className={errors.target_path ? "error" : ""}
|
className={errors.target_path ? "error" : ""}
|
||||||
/>
|
/>
|
||||||
<span className="hint">Absolute path inside container</span>
|
<span className="hint">Use absolute path (e.g. /app/config). Relative paths need working_directory set in tool config.</span>
|
||||||
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
|
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ export const Icon: React.FC<IconProps> = ({
|
|||||||
const sizeValue = sizeMap[size];
|
const sizeValue = sizeMap[size];
|
||||||
|
|
||||||
if (!IconComponent) {
|
if (!IconComponent) {
|
||||||
console.warn(`Icon "${name}" not found`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "./icon";
|
|
||||||
|
|
||||||
interface FormField {
|
interface FormField {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ interface MobileListViewProps {
|
|||||||
export const MobileListView: React.FC<MobileListViewProps> = ({
|
export const MobileListView: React.FC<MobileListViewProps> = ({
|
||||||
items,
|
items,
|
||||||
onItemClick,
|
onItemClick,
|
||||||
onItemDelete,
|
|
||||||
onItemDuplicate,
|
|
||||||
emptyMessage = "No items found",
|
emptyMessage = "No items found",
|
||||||
searchPlaceholder = "Search...",
|
searchPlaceholder = "Search...",
|
||||||
onSearch,
|
onSearch,
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { FitAddon } from "xterm-addon-fit";
|
|||||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||||
import "xterm/css/xterm.css";
|
import "xterm/css/xterm.css";
|
||||||
|
|
||||||
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
|
import {
|
||||||
|
applyModifierToChar,
|
||||||
|
type ModifierKey,
|
||||||
|
} from "../hooks/use-special-keys";
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
@@ -14,9 +17,14 @@ interface TerminalProps {
|
|||||||
onModifierChange?: (modifier: ModifierKey | null) => void;
|
onModifierChange?: (modifier: ModifierKey | null) => void;
|
||||||
onTerminalReady?: (
|
onTerminalReady?: (
|
||||||
sendData: (data: string) => void,
|
sendData: (data: string) => void,
|
||||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting",
|
connectionStatus:
|
||||||
|
| "connecting"
|
||||||
|
| "connected"
|
||||||
|
| "disconnected"
|
||||||
|
| "error"
|
||||||
|
| "resetting",
|
||||||
focusInput: () => void,
|
focusInput: () => void,
|
||||||
changeFontSize: (delta: number) => void
|
changeFontSize: (delta: number) => void,
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +70,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
const lastPingRef = useRef<number>(0);
|
const lastPingRef = useRef<number>(0);
|
||||||
const heartbeatCheckRef = useRef<number | null>(null);
|
const heartbeatCheckRef = useRef<number | null>(null);
|
||||||
const isUnmountingRef = useRef(false);
|
const isUnmountingRef = useRef(false);
|
||||||
|
const permanentErrorRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const calculateFontSize = useCallback(() => {
|
const calculateFontSize = useCallback(() => {
|
||||||
return fontSize;
|
return fontSize;
|
||||||
@@ -73,12 +82,11 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||||
|
|
||||||
console.log(`[Terminal WS] Connecting to ${wsUrl} (attempt ${reconnectAttemptsRef.current + 1}/${RECONNECT_ATTEMPTS + 1})`);
|
// WebSocket connection established
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log(`[Terminal WS] Connected successfully`);
|
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
setError(null);
|
setError(null);
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
@@ -99,10 +107,8 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
}
|
}
|
||||||
heartbeatCheckRef.current = window.setInterval(() => {
|
heartbeatCheckRef.current = window.setInterval(() => {
|
||||||
const elapsed = Date.now() - lastPingRef.current;
|
const elapsed = Date.now() - lastPingRef.current;
|
||||||
console.log(`[Terminal WS] Heartbeat check: lastPing=${elapsed}ms ago`);
|
|
||||||
if (elapsed > 60000) {
|
if (elapsed > 60000) {
|
||||||
// No ping for 60 seconds, connection may be dead
|
// No ping for 60 seconds, connection may be dead
|
||||||
console.warn("[Terminal WS] Heartbeat timeout (>60s), closing connection");
|
|
||||||
ws.close(4000, "Heartbeat timeout");
|
ws.close(4000, "Heartbeat timeout");
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -132,7 +138,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
const { cols, rows } = termRef.current;
|
const { cols, rows } = termRef.current;
|
||||||
const currentWs = wsRef.current;
|
const currentWs = wsRef.current;
|
||||||
if (currentWs?.readyState === WebSocket.OPEN) {
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||||
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
currentWs.send(
|
||||||
|
JSON.stringify({ type: "resize", cols, rows }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -143,7 +151,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
} else if (msg.type === "ping") {
|
} else if (msg.type === "ping") {
|
||||||
// Respond with pong and update last ping time
|
// Respond with pong and update last ping time
|
||||||
lastPingRef.current = Date.now();
|
lastPingRef.current = Date.now();
|
||||||
console.log(`[Terminal WS] Received ping, sending pong`);
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: "pong" }));
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
}
|
}
|
||||||
@@ -155,46 +162,52 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
console.log(`[Terminal WS] Connection closed: code=${event.code}, reason="${event.reason}", wasClean=${event.wasClean}, attempts=${reconnectAttemptsRef.current}`);
|
|
||||||
setStatus("disconnected");
|
|
||||||
|
|
||||||
// Clean up heartbeat check
|
// Clean up heartbeat check
|
||||||
if (heartbeatCheckRef.current) {
|
if (heartbeatCheckRef.current) {
|
||||||
window.clearInterval(heartbeatCheckRef.current);
|
window.clearInterval(heartbeatCheckRef.current);
|
||||||
heartbeatCheckRef.current = null;
|
heartbeatCheckRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.code !== 1000 && event.code !== 4000) {
|
// Permanent errors: do not retry
|
||||||
|
if (event.code === 4001 || event.code === 4003 || event.code === 4004) {
|
||||||
|
const reason = event.reason || `Instance error (code: ${event.code})`;
|
||||||
|
setStatus("error");
|
||||||
|
setError(reason);
|
||||||
|
permanentErrorRef.current = reason;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.code === 1000) {
|
||||||
|
setStatus("disconnected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.code === 4000) {
|
||||||
|
// Server closed old connection for concurrent connection - don't reconnect
|
||||||
|
// The new connection is already established
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient errors: attempt reconnection
|
||||||
|
setStatus("disconnected");
|
||||||
setError(`Connection closed (code: ${event.code})`);
|
setError(`Connection closed (code: ${event.code})`);
|
||||||
|
|
||||||
// Attempt reconnection
|
|
||||||
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
||||||
reconnectAttemptsRef.current++;
|
reconnectAttemptsRef.current++;
|
||||||
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
const delay =
|
||||||
console.log(`[Terminal WS] Will retry in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})`);
|
RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isUnmountingRef.current) {
|
if (isUnmountingRef.current) {
|
||||||
console.log(`[Terminal WS] Component unmounting, skipping reconnect`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (document.visibilityState !== "hidden") {
|
if (document.visibilityState !== "hidden") {
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
} else {
|
|
||||||
console.log(`[Terminal WS] Tab hidden, skipping reconnect`);
|
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
} else {
|
|
||||||
console.log(`[Terminal WS] Max reconnection attempts (${RECONNECT_ATTEMPTS}) reached`);
|
|
||||||
}
|
|
||||||
} else if (event.code === 4000) {
|
|
||||||
// Server closed old connection for concurrent connection - don't reconnect
|
|
||||||
// The new connection is already established
|
|
||||||
console.log(`[Terminal WS] Server closed old connection (concurrent/heartbeat)`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = () => {
|
||||||
console.error(`[Terminal WS] Error event fired`, error);
|
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError("WebSocket error");
|
setError("WebSocket error");
|
||||||
};
|
};
|
||||||
@@ -250,8 +263,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
||||||
const fitTerminal = () => {
|
const fitTerminal = () => {
|
||||||
if (!fitAddonRef.current || !termRef.current) return;
|
if (!fitAddonRef.current || !termRef.current) return;
|
||||||
const oldCols = termRef.current.cols;
|
|
||||||
const oldRows = termRef.current.rows;
|
|
||||||
try {
|
try {
|
||||||
fitAddonRef.current.fit();
|
fitAddonRef.current.fit();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -259,7 +270,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { cols, rows } = termRef.current;
|
const { cols, rows } = termRef.current;
|
||||||
console.log(`[Terminal] fit() result: ${cols}x${rows} (was ${oldCols}x${oldRows})`);
|
|
||||||
// Force refresh if dimensions are valid
|
// Force refresh if dimensions are valid
|
||||||
if (cols > 0 && rows > 0) {
|
if (cols > 0 && rows > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -285,14 +295,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
fitAttempts++;
|
fitAttempts++;
|
||||||
// Ensure container has dimensions before fitting
|
// Ensure container has dimensions before fitting
|
||||||
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
||||||
console.log(`[Terminal] Container ready: ${container.clientWidth}x${container.clientHeight} (attempt ${fitAttempts})`);
|
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
} else if (fitAttempts < 50) {
|
} else if (fitAttempts < 50) {
|
||||||
// Container not ready yet, try again (max 50 attempts ~ 1s)
|
// Container not ready yet, try again (max 50 attempts ~ 1s)
|
||||||
console.log(`[Terminal] Container not ready: ${container.clientWidth}x${container.clientHeight} (attempt ${fitAttempts})`);
|
|
||||||
requestAnimationFrame(doInitialFit);
|
requestAnimationFrame(doInitialFit);
|
||||||
} else {
|
|
||||||
console.warn(`[Terminal] Container never got dimensions after ${fitAttempts} attempts`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
requestAnimationFrame(doInitialFit);
|
requestAnimationFrame(doInitialFit);
|
||||||
@@ -379,9 +385,14 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
|
|
||||||
// Visibility API for reconnection
|
// Visibility API for reconnection
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
console.log(`[Terminal WS] Visibility changed to: ${document.visibilityState}, wsState=${ws?.readyState}`);
|
if (
|
||||||
if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) {
|
document.visibilityState === "visible" &&
|
||||||
console.log(`[Terminal WS] Tab visible, resetting reconnect attempts and reconnecting`);
|
ws &&
|
||||||
|
ws.readyState !== WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
if (permanentErrorRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}
|
}
|
||||||
@@ -405,7 +416,6 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
}
|
}
|
||||||
term.dispose();
|
term.dispose();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [instanceId, connectWebSocket]);
|
}, [instanceId, connectWebSocket]);
|
||||||
|
|
||||||
// Update parent about status changes
|
// Update parent about status changes
|
||||||
@@ -427,7 +437,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
}, [status, onTerminalReady]);
|
}, [status, onTerminalReady]);
|
||||||
|
|
||||||
const handleFontSizeChange = (delta: number) => {
|
const handleFontSizeChange = (delta: number) => {
|
||||||
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
|
const newSize = Math.max(
|
||||||
|
MIN_FONT_SIZE,
|
||||||
|
Math.min(MAX_FONT_SIZE, fontSize + delta),
|
||||||
|
);
|
||||||
setFontSize(newSize);
|
setFontSize(newSize);
|
||||||
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
||||||
if (termRef.current && fitAddonRef.current) {
|
if (termRef.current && fitAddonRef.current) {
|
||||||
@@ -443,7 +456,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
type: "resize",
|
type: "resize",
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -564,7 +577,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
{showResetConfirm && (
|
{showResetConfirm && (
|
||||||
<div className="terminal-reset-confirm">
|
<div className="terminal-reset-confirm">
|
||||||
<div className="terminal-reset-confirm-content">
|
<div className="terminal-reset-confirm-content">
|
||||||
<p>Reset terminal? This will kill the current shell session and start fresh.</p>
|
<p>
|
||||||
|
Reset terminal? This will kill the current shell session and start
|
||||||
|
fresh.
|
||||||
|
</p>
|
||||||
<div className="terminal-reset-confirm-buttons">
|
<div className="terminal-reset-confirm-buttons">
|
||||||
<button
|
<button
|
||||||
className="terminal-reset-confirm-button cancel"
|
className="terminal-reset-confirm-button cancel"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
import { extractErrorMessage } from "../utils/errors";
|
import { extractErrorMessage } from "../utils/errors";
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
type ResolvedProfile,
|
type ResolvedProfile,
|
||||||
} from "../api/config_profiles";
|
} from "../api/config_profiles";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import { listAllUserRepositories, createExternalRepository, type GitRepository } from "../api/git_repositories";
|
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { GitMountEditor } from "../components/git-mount-editor";
|
import { GitMountEditor } from "../components/git-mount-editor";
|
||||||
@@ -34,7 +33,6 @@ export const ConfigProfilesPage = () => {
|
|||||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
|
||||||
|
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -72,14 +70,6 @@ export const ConfigProfilesPage = () => {
|
|||||||
setProjects(projs || []);
|
setProjects(projs || []);
|
||||||
setToolTypes(types || []);
|
setToolTypes(types || []);
|
||||||
|
|
||||||
// Load all user repositories (including external ones)
|
|
||||||
try {
|
|
||||||
const allRepos = await listAllUserRepositories();
|
|
||||||
setRepositories(allRepos);
|
|
||||||
} catch {
|
|
||||||
setRepositories([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
@@ -1257,16 +1247,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<GitMountEditor
|
<GitMountEditor
|
||||||
mounts={formData.git_mounts || []}
|
mounts={formData.git_mounts || []}
|
||||||
repositories={repositories}
|
|
||||||
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
||||||
onCreateRepository={async (name, remoteUrl) => {
|
|
||||||
const repo = await createExternalRepository({
|
|
||||||
name,
|
|
||||||
remote_url: remoteUrl,
|
|
||||||
});
|
|
||||||
setRepositories((prev) => [...prev, repo]);
|
|
||||||
return repo;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { listToolTypes, type ToolType } from "../api/tool_types";
|
|||||||
import { updateUserConfig } from "../api/settings";
|
import { updateUserConfig } from "../api/settings";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
|
||||||
import { CreateSessionForm } from "../components/create-session-form";
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
import { SessionList } from "../components/session-list";
|
import { SessionList } from "../components/session-list";
|
||||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories";
|
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryResponse } from "../api/git_repositories";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../state/auth";
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
@@ -11,7 +10,7 @@ import {
|
|||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { CreateSessionForm } from "../components/create-session-form";
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
import { SessionList } from "../components/session-list";
|
import { SessionList } from "../components/session-list";
|
||||||
import { SessionCard } from "../components/session-card";
|
import { SessionCard } from "../components/session-card";
|
||||||
@@ -21,7 +20,6 @@ import type { InstanceHealth } from "../api/sessions";
|
|||||||
type SessionsStatus = "loading" | "ready" | "error";
|
type SessionsStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
export const SessionsPage = () => {
|
export const SessionsPage = () => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useAsyncData } from "../hooks/use-async-data";
|
|||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: keys, status, error, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||||
|
|||||||
+20
-22
@@ -6,36 +6,34 @@ The system SHALL allow config profiles to include git repository mounts that bin
|
|||||||
#### Scenario: Create profile with git mount
|
#### Scenario: Create profile with git mount
|
||||||
- **WHEN** a user creates or updates a config profile with `git_mounts` entries
|
- **WHEN** a user creates or updates a config profile with `git_mounts` entries
|
||||||
- **THEN** the profile stores each git mount with:
|
- **THEN** the profile stores each git mount with:
|
||||||
- `repo_id`: UUID of the referenced git repository
|
- `remote_url`: Direct git URL (e.g., "https://github.com/user/repo.git", "git@github.com:user/repo.git")
|
||||||
- `source_path`: Path within the repository to mount (e.g., ".", "configs/")
|
- `source_path`: Path within the repository to mount (e.g., ".", "configs/")
|
||||||
- `target_path`: Absolute path inside the container (e.g., "/home/user")
|
- `target_path`: Absolute path inside the container (e.g., "/home/user")
|
||||||
- `branch`: Optional branch or tag name (defaults to repository default branch)
|
- `branch`: Optional branch or tag name (defaults to "main")
|
||||||
|
|
||||||
#### Scenario: Git mount validation
|
#### Scenario: Git mount validation
|
||||||
- **WHEN** a profile with git mounts is saved
|
- **WHEN** a profile with git mounts is saved
|
||||||
- **THEN** the system validates that:
|
- **THEN** the system validates that:
|
||||||
- The referenced repository exists and is owned by the user
|
- `remote_url` is a valid git URL (starts with https://, git@, or ssh://)
|
||||||
- Repositories can be external (not tied to any project) or project-based
|
|
||||||
- `source_path` is a relative path (no leading `/`)
|
- `source_path` is a relative path (no leading `/`)
|
||||||
- `target_path` is an absolute path (starts with `/`)
|
- `target_path` can be absolute (starts with `/`) or relative (resolved against working directory, defaulting to `/home/user`)
|
||||||
- `target_path` does not contain path traversal sequences (`..`)
|
- `target_path` does not contain path traversal sequences (`..`)
|
||||||
|
- No database lookup or repository existence check is performed (validation is deferred to clone time)
|
||||||
|
|
||||||
#### Scenario: Profile with git mounts is resolved
|
#### Scenario: Profile with git mounts is resolved
|
||||||
- **GIVEN** a config profile with git mounts referencing repository "dotfiles"
|
- **GIVEN** a config profile with git mounts
|
||||||
- **WHEN** the profile is resolved for instance startup
|
- **WHEN** the profile is resolved for instance startup
|
||||||
- **THEN** the resolved profile includes the git mounts with repository details:
|
- **THEN** the resolved profile includes the git mounts as configured
|
||||||
- Repository filesystem path
|
- **AND** repository cloning happens at instance startup time, not at profile resolution
|
||||||
- Resolved branch name
|
|
||||||
- Source and target paths
|
|
||||||
|
|
||||||
#### Scenario: Git mount is applied at instance startup
|
#### Scenario: Git mount is applied at instance startup
|
||||||
- **GIVEN** a resolved profile with git mounts
|
- **GIVEN** a resolved profile with git mounts
|
||||||
- **WHEN** an instance is started with this profile
|
- **WHEN** an instance is started with this profile
|
||||||
- **THEN** for each git mount:
|
- **THEN** for each git mount:
|
||||||
- The repository filesystem path exists
|
- The repository is cloned from `remote_url` to a temporary location
|
||||||
- The source path within the repository exists
|
- The source path within the cloned repository exists
|
||||||
- A bind mount is created from `repo_path/source_path` to `container:target_path`
|
- A bind mount is created from `clone_path/source_path` to `container:target_path`
|
||||||
- **AND** if the repository or path is missing, a warning is logged and the mount is skipped
|
- **AND** if the clone fails or path is missing, a warning is logged and the mount is skipped
|
||||||
|
|
||||||
### Requirement: Git mounts support glob patterns
|
### Requirement: Git mounts support glob patterns
|
||||||
The system SHALL support glob patterns in `source_path` for matching multiple files.
|
The system SHALL support glob patterns in `source_path` for matching multiple files.
|
||||||
@@ -63,16 +61,16 @@ The system SHALL support glob patterns in `source_path` for matching multiple fi
|
|||||||
The system SHALL automatically clone referenced repositories to a persistent storage location on every new container creation. Each instance gets its own fresh clone.
|
The system SHALL automatically clone referenced repositories to a persistent storage location on every new container creation. Each instance gets its own fresh clone.
|
||||||
|
|
||||||
#### Scenario: Repository cloned on container creation
|
#### Scenario: Repository cloned on container creation
|
||||||
- **GIVEN** a git mount referencing a repository
|
- **GIVEN** a git mount with a `remote_url`
|
||||||
- **WHEN** a new container is created with this profile
|
- **WHEN** a new container is created with this profile
|
||||||
- **THEN** the system clones the repository to a persistent location: `/data/repos/<user_id>/<repo_name>.git`
|
- **THEN** the system clones the repository from the URL to an instance-specific directory
|
||||||
- **AND** the clone proceeds asynchronously
|
- **AND** the clone proceeds as part of instance startup
|
||||||
- **AND** instance startup continues once clone completes
|
- **AND** instance startup continues once clone completes
|
||||||
|
|
||||||
#### Scenario: Existing clone updated on new container creation
|
#### Scenario: Existing clone updated on new container creation
|
||||||
- **GIVEN** a repository that was previously cloned to the persistent location
|
- **GIVEN** a repository that was previously cloned for this instance
|
||||||
- **WHEN** a new container is created with this profile
|
- **WHEN** a new container is created with this profile
|
||||||
- **THEN** the system pulls the latest updates from the remote
|
- **THEN** the system pulls the latest updates from the remote_url
|
||||||
- **AND** checks out the specified branch (or default branch if not specified)
|
- **AND** checks out the specified branch (or default branch if not specified)
|
||||||
- **AND** uses the updated clone for the bind mount
|
- **AND** uses the updated clone for the bind mount
|
||||||
|
|
||||||
@@ -113,7 +111,7 @@ The system SHALL display git mounts in the config profile editor.
|
|||||||
- **GIVEN** a config profile with git mounts
|
- **GIVEN** a config profile with git mounts
|
||||||
- **WHEN** the user views the profile in the UI
|
- **WHEN** the user views the profile in the UI
|
||||||
- **THEN** the git mounts section displays each mount with:
|
- **THEN** the git mounts section displays each mount with:
|
||||||
- Repository name
|
- Git URL
|
||||||
- Source path within repository
|
- Source path within repository
|
||||||
- Target path in container
|
- Target path in container
|
||||||
- Branch/tag (if specified)
|
- Branch/tag (if specified)
|
||||||
@@ -121,10 +119,10 @@ The system SHALL display git mounts in the config profile editor.
|
|||||||
#### Scenario: Add git mount via UI
|
#### Scenario: Add git mount via UI
|
||||||
- **WHEN** a user adds a git mount in the profile editor
|
- **WHEN** a user adds a git mount in the profile editor
|
||||||
- **THEN** they can:
|
- **THEN** they can:
|
||||||
- Select from all user-owned repositories (external repos not tied to any project are shown)
|
- Enter a git URL directly (https://, git@, or ssh://)
|
||||||
- Specify the source path (with autocomplete or validation)
|
- Specify the source path (with autocomplete or validation)
|
||||||
- Specify the target path in the container
|
- Specify the target path in the container
|
||||||
- Optionally select a branch/tag
|
- Optionally enter a branch/tag name
|
||||||
|
|
||||||
#### Scenario: Remove git mount via UI
|
#### Scenario: Remove git mount via UI
|
||||||
- **WHEN** a user removes a git mount from the profile editor
|
- **WHEN** a user removes a git mount from the profile editor
|
||||||
|
|||||||
@@ -73,3 +73,10 @@
|
|||||||
- [x] 9.6 Update spec: auto-clone to persistent location on every container creation
|
- [x] 9.6 Update spec: auto-clone to persistent location on every container creation
|
||||||
- [x] 9.7 Update spec: pull updates when creating new containers
|
- [x] 9.7 Update spec: pull updates when creating new containers
|
||||||
- [x] 9.8 Update spec: per-instance isolation (no shared clones)
|
- [x] 9.8 Update spec: per-instance isolation (no shared clones)
|
||||||
|
|
||||||
|
## 10. UI Improvements
|
||||||
|
|
||||||
|
- [x] 10.1 Add ability to create external repositories from git mount editor
|
||||||
|
- [x] 10.2 Show "+ Add new repository..." option in repo dropdown
|
||||||
|
- [x] 10.3 Add form fields for repo name and remote URL
|
||||||
|
- [x] 10.4 Auto-refresh repo list after creating new repository
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Tool Images
|
||||||
|
|
||||||
|
This directory contains Dockerfile templates for the base tool types used in Headquarter.
|
||||||
|
|
||||||
|
## Available Images
|
||||||
|
|
||||||
|
| File | Tool | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `base.dockerfile` | - | Common base with git, nvim, ranger, tmux, node |
|
||||||
|
| `code-server.dockerfile` | VS Code | Browser-based VS Code with extra tools |
|
||||||
|
| `jupyter.dockerfile` | Jupyter | Jupyter Lab/Notebook with extra tools |
|
||||||
|
| `opencode.dockerfile` | OpenCode | OpenCode agent server |
|
||||||
|
| `pi-agent.dockerfile` | Pi Agent | Pi coding agent with full terminal setup |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When creating a tool type in the Tool Workshop, you can reference these Dockerfiles:
|
||||||
|
|
||||||
|
1. Copy the contents of the desired `.dockerfile`
|
||||||
|
2. Paste into the "Dockerfile Template" field
|
||||||
|
3. Set `definition_type` to `dockerfile`
|
||||||
|
|
||||||
|
## Building Locally
|
||||||
|
|
||||||
|
To test an image locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tool-images
|
||||||
|
docker build -f pi-agent.dockerfile -t pi-agent:latest .
|
||||||
|
docker run -it pi-agent:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customizing
|
||||||
|
|
||||||
|
All images include:
|
||||||
|
- **git** - Version control
|
||||||
|
- **neovim** - Terminal editor
|
||||||
|
- **ranger** - Terminal file manager
|
||||||
|
- **tmux** - Terminal multiplexer
|
||||||
|
- **htop** - Process viewer
|
||||||
|
- **tree** - Directory tree
|
||||||
|
- **jq** - JSON processor
|
||||||
|
- **Node.js 20** - For npm-based tools
|
||||||
|
|
||||||
|
The `pi-agent` image additionally includes the [Pi Coding Agent](https://pi.dev/) for AI-assisted development.
|
||||||
|
|
||||||
|
## Adding New Images
|
||||||
|
|
||||||
|
1. Create a new `.dockerfile` in this directory
|
||||||
|
2. Use `base.dockerfile` as a starting point if applicable
|
||||||
|
3. Document it in the table above
|
||||||
|
4. Update the tool type in the database via the Tool Workshop
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Base development image with common tools
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
# Prevent interactive prompts during apt install
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base tools
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
neovim \
|
||||||
|
ranger \
|
||||||
|
tmux \
|
||||||
|
htop \
|
||||||
|
tree \
|
||||||
|
jq \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create a non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Install Node.js (needed for pi and many dev tools)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set up git configuration defaults
|
||||||
|
RUN git config --global init.defaultBranch main \
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
USER user
|
||||||
|
CMD ["/bin/bash"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# VS Code in browser
|
||||||
|
FROM lscr.io/linuxserver/code-server:latest
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Install additional tools
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git \
|
||||||
|
neovim \
|
||||||
|
ranger \
|
||||||
|
tmux \
|
||||||
|
htop \
|
||||||
|
tree \
|
||||||
|
jq \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
|
# Code-server runs as abc user by default
|
||||||
|
USER abc
|
||||||
|
|
||||||
|
EXPOSE 8443
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Jupyter Notebook/Lab
|
||||||
|
FROM jupyter/scipy-notebook:latest
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Install additional tools
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git \
|
||||||
|
neovim \
|
||||||
|
ranger \
|
||||||
|
tmux \
|
||||||
|
htop \
|
||||||
|
tree \
|
||||||
|
jq \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
|
# Switch back to jovyan user (default for scipy-notebook)
|
||||||
|
USER ${NB_UID}
|
||||||
|
|
||||||
|
EXPOSE 8888
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# OpenCode agent environment
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
neovim \
|
||||||
|
ranger \
|
||||||
|
tmux \
|
||||||
|
htop \
|
||||||
|
tree \
|
||||||
|
jq \
|
||||||
|
ca-certificates \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install OpenCode
|
||||||
|
RUN npm install -g opencode
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["opencode", "server"]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Pi Coding Agent - Terminal-based coding harness
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
neovim \
|
||||||
|
ranger \
|
||||||
|
tmux \
|
||||||
|
htop \
|
||||||
|
tree \
|
||||||
|
jq \
|
||||||
|
ca-certificates \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js (required for Pi)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Pi Coding Agent globally
|
||||||
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main \
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
# Create default tmux config
|
||||||
|
RUN echo 'set -g mouse on\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
|
# Create default ranger config
|
||||||
|
RUN mkdir -p /home/user/.config/ranger \
|
||||||
|
&& echo 'set preview_files true\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
|
# Set up Pi config directory
|
||||||
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
# Default to bash (Pi is invoked manually via `pi` command)
|
||||||
|
CMD ["/bin/bash"]
|
||||||
Reference in New Issue
Block a user