Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22474cdba5 | |||
| 0c839e8c6f | |||
| c63cf7db50 | |||
| d9d2b91384 | |||
| d6ea5fb1fd | |||
| 1883825b18 | |||
| bc71fd6fac | |||
| 28aa9ccf5a | |||
| 44dd80cb58 | |||
| 23485833d8 | |||
| e23dcdf4e1 | |||
| f05ac55875 | |||
| bcefeb4163 | |||
| 33d08faf70 | |||
| 8a58c61278 | |||
| 8231e750d9 | |||
| 6ce645d210 | |||
| 89ca9f10c7 | |||
| baabd1fa62 | |||
| f14fc37e75 | |||
| e07938098a | |||
| 943b9db5c7 | |||
| a4604d6a9a | |||
| 18204628cc | |||
| 93b415c53e | |||
| e7adfb462b | |||
| ed1d6528c6 |
@@ -7,8 +7,6 @@ Create Date: 2026-05-22 21:50:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0014_merge_heads"
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
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'")
|
||||
)
|
||||
@@ -12,7 +12,7 @@ import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_26_add_git_mounts"
|
||||
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
|
||||
down_revision: Union[str, Sequence[str], None] = "2026_05_24_220141"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""make_project_id_nullable_in_git_repositories
|
||||
|
||||
Revision ID: 2026_05_27_external_repos
|
||||
Revises: 2026_05_26_add_git_mounts
|
||||
Create Date: 2026-05-27 08:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_27_external_repos"
|
||||
down_revision: Union[str, Sequence[str], None] = "2026_05_26_add_git_mounts"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Expand alembic_version version_num to avoid truncation errors
|
||||
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(64)")
|
||||
|
||||
# Make project_id nullable to allow external repositories
|
||||
op.alter_column(
|
||||
"git_repositories",
|
||||
"project_id",
|
||||
existing_type=sa.UUID(),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"git_repositories",
|
||||
"project_id",
|
||||
existing_type=sa.UUID(),
|
||||
nullable=False,
|
||||
)
|
||||
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(32)")
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
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 alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f3d2dc90ba3a"
|
||||
|
||||
+10
-10
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
|
||||
redirect_uri=redirect_uri,
|
||||
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.set_cookie("auth_state", state, 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="/"),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> 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:
|
||||
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
||||
@@ -71,7 +71,7 @@ async def callback(
|
||||
|
||||
settings = Settings()
|
||||
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:
|
||||
try:
|
||||
@@ -92,7 +92,7 @@ async def callback(
|
||||
access_token=token_payload["access_token"],
|
||||
client=client,
|
||||
)
|
||||
logger.info("User info fetched successfully")
|
||||
logger.debug("User info fetched successfully")
|
||||
except Exception as 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")
|
||||
@@ -100,19 +100,19 @@ async def callback(
|
||||
authentik_id = str(user_info.get("sub", ""))
|
||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||
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:
|
||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||
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)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
logger.info("New user created: id=%s", user.id)
|
||||
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.name = name
|
||||
await session.commit()
|
||||
@@ -165,20 +165,20 @@ async def me(
|
||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> 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:
|
||||
logger.warning("Auth /me: missing session cookie")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
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:
|
||||
logger.warning("Auth /me: invalid session: %s", 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.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.tool_type import ToolType
|
||||
from src.services.config_profile_resolver import (
|
||||
@@ -58,18 +57,16 @@ def _calculate_profile_size(data: dict) -> int:
|
||||
|
||||
|
||||
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)")
|
||||
target_path: str = Field(description="Absolute path inside container")
|
||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||
|
||||
@field_validator("repo_id")
|
||||
@field_validator("remote_url")
|
||||
@classmethod
|
||||
def validate_repo_id(cls, v: str) -> str:
|
||||
try:
|
||||
uuid.UUID(v)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid repo_id UUID: {v}")
|
||||
def validate_remote_url(cls, v: str) -> str:
|
||||
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
||||
return v
|
||||
|
||||
@field_validator("source_path")
|
||||
@@ -84,8 +81,6 @@ class GitMountItem(BaseModel):
|
||||
@field_validator("target_path")
|
||||
@classmethod
|
||||
def validate_target_path(cls, v: str) -> str:
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("target_path must be absolute (start with /)")
|
||||
if ".." in v:
|
||||
raise ValueError("target_path cannot contain path traversal (..)")
|
||||
return v
|
||||
@@ -271,46 +266,23 @@ async def _validate_git_mounts(
|
||||
git_mounts: list[dict],
|
||||
project_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Validate that all referenced git repositories exist and are accessible.
|
||||
"""Validate git mount URLs.
|
||||
|
||||
Repositories must:
|
||||
1. Exist
|
||||
2. Belong to the user
|
||||
3. If project_id is specified, belong to that project
|
||||
Simply checks that remote_url looks like a valid git URL.
|
||||
Actual clone validation happens at instance startup time.
|
||||
"""
|
||||
for mount in git_mounts:
|
||||
repo_id = mount.get("repo_id")
|
||||
if not repo_id:
|
||||
remote_url = mount.get("remote_url")
|
||||
if not remote_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Git mount missing repo_id",
|
||||
detail="Git mount missing remote_url",
|
||||
)
|
||||
|
||||
try:
|
||||
repo_uuid = uuid.UUID(repo_id)
|
||||
except ValueError:
|
||||
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid repo_id UUID: {repo_id}",
|
||||
)
|
||||
|
||||
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}",
|
||||
)
|
||||
|
||||
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}",
|
||||
detail=f"Invalid git URL: {remote_url}",
|
||||
)
|
||||
|
||||
|
||||
@@ -447,7 +419,7 @@ async def create_config_profile(
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@@ -548,7 +520,7 @@ async def update_config_profile(
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@@ -568,7 +540,7 @@ async def delete_config_profile(
|
||||
await session.delete(profile)
|
||||
await session.commit()
|
||||
|
||||
logger.info("Deleted config profile %s", profile_id)
|
||||
logger.debug("Deleted config profile %s", profile_id)
|
||||
return None
|
||||
|
||||
|
||||
@@ -653,7 +625,7 @@ async def update_profile_includes(
|
||||
)
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -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.config import Settings
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
from src.utils.git_files import (
|
||||
commit_file,
|
||||
get_file_content,
|
||||
@@ -222,7 +220,7 @@ class GitRepositoryResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
path: str
|
||||
project_id: uuid.UUID
|
||||
project_id: uuid.UUID | None
|
||||
owner_id: uuid.UUID
|
||||
is_mirror: bool
|
||||
remote_url: str | None
|
||||
@@ -232,6 +230,156 @@ class GitRepositoryResponse(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@router.get(
|
||||
"/repositories",
|
||||
response_model=list[GitRepositoryResponse],
|
||||
summary="List all user repositories",
|
||||
description="List all git repositories owned by the user, including external repositories not tied to any project.",
|
||||
)
|
||||
async def list_user_repositories(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[GitRepository]:
|
||||
"""List all repositories owned by the user.
|
||||
|
||||
Args:
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
List of all repositories owned by the user.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.owner_id == user_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/repositories/parse-url",
|
||||
response_model=URLParseResponse,
|
||||
summary="Parse a git URL",
|
||||
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
||||
)
|
||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
||||
|
||||
Args:
|
||||
data: Request containing the URL to parse.
|
||||
|
||||
Returns:
|
||||
Parsed URL information including whether it needs parsing and suggested corrections.
|
||||
"""
|
||||
result = parse_git_url(data.url)
|
||||
return URLParseResponse(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/repositories",
|
||||
response_model=GitRepositoryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create an external repository",
|
||||
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
|
||||
)
|
||||
async def create_external_repository(
|
||||
data: GitRepositoryCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> GitRepository:
|
||||
"""Create a new external git repository.
|
||||
|
||||
External repositories are not tied to any project and can be used
|
||||
across all projects for config profile git mounts.
|
||||
|
||||
Args:
|
||||
data: Repository creation data including name and optional remote URL.
|
||||
user_id: ID of the authenticated user.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
The newly created external repository.
|
||||
"""
|
||||
_user = await _get_user(session, user_id)
|
||||
|
||||
# Check for duplicate name (external repos only)
|
||||
existing = await session.execute(
|
||||
select(GitRepository).where(
|
||||
GitRepository.project_id.is_(None),
|
||||
GitRepository.owner_id == user_id,
|
||||
GitRepository.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
||||
|
||||
# Validate and potentially correct the URL
|
||||
remote_url = data.remote_url
|
||||
if remote_url and not data.force_original_url:
|
||||
parse_result = parse_git_url(remote_url)
|
||||
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
||||
"suggested_url": parse_result["base_url"],
|
||||
"original_url": remote_url,
|
||||
"error_code": "URL_NEEDS_PARSING",
|
||||
},
|
||||
)
|
||||
if parse_result["base_url"]:
|
||||
remote_url = parse_result["base_url"]
|
||||
|
||||
# Validate SSH key if provided
|
||||
ssh_key_id = None
|
||||
ssh_key = None
|
||||
if data.ssh_key_id:
|
||||
try:
|
||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
||||
|
||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||
if ssh_key is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||
if ssh_key.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
||||
|
||||
if remote_url:
|
||||
_preflight_remote_repository(remote_url, ssh_key)
|
||||
|
||||
# Create external repo with no project
|
||||
repo = GitRepository(
|
||||
name=data.name,
|
||||
path="", # Will be set after clone
|
||||
project_id=None,
|
||||
owner_id=user_id,
|
||||
remote_url=remote_url,
|
||||
ssh_key_id=ssh_key_id,
|
||||
)
|
||||
session.add(repo)
|
||||
await session.flush()
|
||||
|
||||
# Set path and optionally clone
|
||||
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
|
||||
repo.path = repo_path
|
||||
|
||||
if remote_url:
|
||||
try:
|
||||
_clone_working_repository(remote_url, repo_path, ssh_key)
|
||||
repo.is_mirror = False
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
||||
else:
|
||||
# Initialize empty repo
|
||||
os.makedirs(repo_path, exist_ok=True)
|
||||
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
|
||||
repo.is_mirror = False
|
||||
|
||||
await session.commit()
|
||||
return repo
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}/repositories",
|
||||
response_model=list[GitRepositoryResponse],
|
||||
@@ -301,25 +449,6 @@ async def delete_repository(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/repositories/parse-url",
|
||||
response_model=URLParseResponse,
|
||||
summary="Parse a git URL",
|
||||
description="Parse a git URL and detect if it's a browser URL that needs correction.",
|
||||
)
|
||||
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
||||
"""Parse a git URL and detect if it's a browser URL that needs correction.
|
||||
|
||||
Args:
|
||||
data: Request containing the URL to parse.
|
||||
|
||||
Returns:
|
||||
Parsed URL information including whether it needs parsing and suggested corrections.
|
||||
"""
|
||||
result = parse_git_url(data.url)
|
||||
return URLParseResponse(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/repositories",
|
||||
response_model=GitRepositoryResponse,
|
||||
|
||||
@@ -4,11 +4,10 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
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.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Shared Pydantic validators for API schemas."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
MAX_FOLDER_SIZE_MB = 10
|
||||
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.config import Settings
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import logging
|
||||
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 src.auth.dependencies import get_db_session
|
||||
@@ -44,9 +44,9 @@ async def terminal_websocket(
|
||||
Returns:
|
||||
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()
|
||||
logger.info("Terminal WebSocket accepted for instance %s", instance_id)
|
||||
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||
|
||||
try:
|
||||
# Parse instance_id
|
||||
@@ -80,13 +80,13 @@ async def terminal_websocket(
|
||||
await websocket.close(code=4004, reason="Instance not running")
|
||||
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
|
||||
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||
startup_command = tool_type.startup_command if tool_type else None
|
||||
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
|
||||
try:
|
||||
@@ -95,15 +95,15 @@ async def terminal_websocket(
|
||||
instance.container_id,
|
||||
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
|
||||
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
|
||||
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
|
||||
session_ref = SessionRef(session)
|
||||
@@ -112,7 +112,7 @@ async def terminal_websocket(
|
||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
||||
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)
|
||||
done, pending = await asyncio.wait(
|
||||
@@ -120,7 +120,7 @@ async def terminal_websocket(
|
||||
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
|
||||
for task in pending:
|
||||
@@ -134,7 +134,7 @@ async def terminal_websocket(
|
||||
try:
|
||||
if 'session' in locals():
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -183,11 +183,11 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
||||
if msg_type == "resize":
|
||||
cols = ctrl.get("cols", 80)
|
||||
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)
|
||||
elif msg_type == "reset":
|
||||
# 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"})
|
||||
|
||||
# Reset the session
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tool configuration API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
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.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.tool_config import ToolConfig
|
||||
from src.models.tool_type import ToolType
|
||||
|
||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||
|
||||
|
||||
+547
-269
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
@@ -9,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.tool_types_validation import (
|
||||
check_port_exposed,
|
||||
sanitize_template_vars,
|
||||
validate_compose_yaml,
|
||||
validate_required_variables,
|
||||
)
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||
|
||||
|
||||
@@ -103,11 +102,11 @@ async def update_user_config(
|
||||
|
||||
# Merge updates
|
||||
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
|
||||
config.config = {**config.config, **update_data}
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
logger.info("Updated config: %s", config.config)
|
||||
logger.debug("Updated config: %s", 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.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.models.project import Project
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.dashboard import router as dashboard_router
|
||||
|
||||
@@ -41,7 +41,7 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
) # {"rel/path": "content", ...}
|
||||
git_mounts: Mapped[list] = mapped_column(
|
||||
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)
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
|
||||
@@ -19,7 +19,7 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
path: Mapped[str] = mapped_column(String(1024))
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
|
||||
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
|
||||
@@ -42,7 +42,7 @@ def clone_repository(
|
||||
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(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
@@ -55,7 +55,7 @@ def clone_repository(
|
||||
logger.error("Git clone failed: %s", 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)
|
||||
|
||||
|
||||
@@ -94,4 +94,4 @@ def remove_clone_directory(instance_dir: str) -> None:
|
||||
if clone_path.exists():
|
||||
import shutil
|
||||
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]]:
|
||||
"""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)
|
||||
# Build lookup by (repo_id, target_path)
|
||||
seen = {(m["repo_id"], m["target_path"]): i for i, m in enumerate(result)}
|
||||
# Build lookup by (remote_url, target_path)
|
||||
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
|
||||
for mount in overlay:
|
||||
key = (mount["repo_id"], mount["target_path"])
|
||||
key = (mount["remote_url"], mount["target_path"])
|
||||
if key in seen:
|
||||
result[seen[key]] = dict(mount)
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Docker service for managing tool instances."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
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:
|
||||
from src.config import Settings
|
||||
|
||||
base_path = Settings().instance_base_path
|
||||
instance_dir = Path(base_path) / instance_id
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -87,7 +90,7 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
full_path.resolve().relative_to(instance_path.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
@@ -109,7 +112,7 @@ def execute_compose_command(
|
||||
instance_dir = Path(compose_path).parent
|
||||
|
||||
cmd = ["docker", "compose", "-f", compose_path]
|
||||
|
||||
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
@@ -136,6 +139,8 @@ def execute_compose_command(
|
||||
def get_container_id(instance_name: str) -> str | None:
|
||||
"""Get the container ID for a compose service.
|
||||
|
||||
Searches all containers including stopped/exited ones.
|
||||
|
||||
Args:
|
||||
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
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
||||
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
|
||||
capture_output=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:
|
||||
"""Get the full container name for a compose service.
|
||||
|
||||
Searches all containers including stopped/exited ones.
|
||||
|
||||
Args:
|
||||
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
|
||||
"""
|
||||
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,
|
||||
text=True,
|
||||
)
|
||||
@@ -173,7 +188,9 @@ def get_container_name(instance_name: str) -> str | 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.
|
||||
|
||||
Args:
|
||||
@@ -198,12 +215,14 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
'exit_code' (int or None), and 'health' (health status or None)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "inspect", "-f",
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||
container_id,
|
||||
],
|
||||
@@ -213,12 +232,12 @@ def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"status": "not_found", "exit_code": None, "health": None}
|
||||
|
||||
|
||||
parts = result.stdout.strip().split("|")
|
||||
status = parts[0] if parts else "unknown"
|
||||
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||
|
||||
|
||||
return {"status": status, "exit_code": exit_code, "health": health}
|
||||
|
||||
|
||||
@@ -238,13 +257,12 @@ def wait_for_container_running(
|
||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||
and 'waited_seconds' (float)
|
||||
"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
info = get_container_status(container_id)
|
||||
|
||||
|
||||
if info["status"] == "running":
|
||||
return {
|
||||
"success": True,
|
||||
@@ -252,7 +270,7 @@ def wait_for_container_running(
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
if info["status"] == "exited":
|
||||
return {
|
||||
"success": False,
|
||||
@@ -260,7 +278,7 @@ def wait_for_container_running(
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
if info["status"] == "not_found":
|
||||
return {
|
||||
"success": False,
|
||||
@@ -268,9 +286,9 @@ def wait_for_container_running(
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
# Timeout reached
|
||||
info = get_container_status(container_id)
|
||||
return {
|
||||
@@ -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}")
|
||||
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import re
|
||||
|
||||
|
||||
def start_cloudflared_tunnel(
|
||||
container_name: str, port: int, timeout: int = 30
|
||||
) -> dict[str, str]:
|
||||
@@ -344,8 +357,6 @@ def start_cloudflared_tunnel(
|
||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -354,18 +365,29 @@ def start_cloudflared_tunnel(
|
||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||
for attempt in range(10):
|
||||
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,
|
||||
text=True,
|
||||
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:
|
||||
break
|
||||
time.sleep(1)
|
||||
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
|
||||
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:
|
||||
# Read available output
|
||||
import select
|
||||
|
||||
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||
if readable:
|
||||
line = proc.stdout.readline()
|
||||
@@ -410,7 +433,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
|
||||
Args:
|
||||
pid: Process ID of the cloudflared tunnel
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
|
||||
try:
|
||||
@@ -455,14 +477,23 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
|
||||
try:
|
||||
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,
|
||||
text=True,
|
||||
timeout=timeout + 5,
|
||||
)
|
||||
status_code = int(result.stdout.strip())
|
||||
|
||||
|
||||
if 200 <= status_code < 400:
|
||||
return {
|
||||
"tunnel_status": "healthy",
|
||||
@@ -495,7 +526,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
except (ValueError, Exception) as e:
|
||||
error_str = str(e).lower()
|
||||
# Classify connection errors
|
||||
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
|
||||
if any(
|
||||
err in error_str
|
||||
for err in [
|
||||
"connection refused",
|
||||
"econnrefused",
|
||||
"could not resolve",
|
||||
"nodename",
|
||||
]
|
||||
):
|
||||
return {
|
||||
"tunnel_status": "unreachable",
|
||||
"status_code": None,
|
||||
|
||||
@@ -18,13 +18,12 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Write Dockerfile
|
||||
dockerfile_path = Path(instance_dir) / "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
|
||||
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.write_text(content)
|
||||
logger.info("Wrote build context file: %s", full_path)
|
||||
logger.debug("Wrote build context file: %s", full_path)
|
||||
|
||||
# Build image
|
||||
logger.info("Building Docker image with tag: %s", tag)
|
||||
logger.debug("Building Docker image with tag: %s", tag)
|
||||
cmd = [
|
||||
"docker", "build",
|
||||
"-t", tag,
|
||||
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
||||
text=True,
|
||||
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:
|
||||
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
@@ -72,11 +71,11 @@ class TerminalManager:
|
||||
|
||||
# Check if session is still 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
|
||||
else:
|
||||
# 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()
|
||||
del self._sessions[instance_id_str]
|
||||
|
||||
@@ -97,7 +96,7 @@ class TerminalManager:
|
||||
"""Attach a WebSocket to an existing session."""
|
||||
# Handle concurrent connections - close existing ones
|
||||
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):
|
||||
try:
|
||||
await ws.close(code=4000, reason="New connection established")
|
||||
@@ -135,7 +134,7 @@ class TerminalManager:
|
||||
|
||||
# Close existing session if any
|
||||
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)
|
||||
await old_session.close()
|
||||
|
||||
|
||||
@@ -60,12 +60,12 @@ class TerminalSession:
|
||||
|
||||
# Set the terminal size initially
|
||||
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
|
||||
if startup_command:
|
||||
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:
|
||||
shell_cmd = "bash -il"
|
||||
|
||||
@@ -102,7 +102,7 @@ class TerminalSession:
|
||||
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||
try:
|
||||
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:
|
||||
logger.error(f"Failed to resize PTY: {e}")
|
||||
|
||||
@@ -159,7 +159,7 @@ class TerminalSession:
|
||||
|
||||
self._cols = cols
|
||||
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)
|
||||
|
||||
# Docker exec -it creates its own PTY inside the container,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _run_git_command(repo_path: str, *args: str) -> str:
|
||||
|
||||
@@ -8,16 +8,14 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Set test environment BEFORE importing app modules
|
||||
os.environ["APP_ENV"] = "testing"
|
||||
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||
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.main import app
|
||||
from src.auth.dependencies import get_db_session
|
||||
|
||||
@@ -333,7 +333,7 @@ class TestConfigProfilesAPI:
|
||||
"files": {},
|
||||
"git_mounts": [
|
||||
{
|
||||
"repo_id": repo_id,
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
"branch": "main",
|
||||
@@ -369,7 +369,7 @@ class TestConfigProfilesAPI:
|
||||
json={
|
||||
"git_mounts": [
|
||||
{
|
||||
"repo_id": repo_id,
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": "config",
|
||||
"target_path": "/config",
|
||||
}
|
||||
@@ -393,7 +393,7 @@ class TestConfigProfilesAPI:
|
||||
"files": {},
|
||||
"git_mounts": [
|
||||
{
|
||||
"repo_id": repo_id,
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": "/absolute/path",
|
||||
"target_path": "/app",
|
||||
}
|
||||
@@ -402,8 +402,8 @@ class TestConfigProfilesAPI:
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_config_profile_invalid_git_mount_target_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||
"""Test that invalid git mount target paths are rejected."""
|
||||
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||
"""Test that git mount target paths with traversal are rejected."""
|
||||
_project_id, repo_id = test_project_and_repo
|
||||
|
||||
response = authenticated_client.post(
|
||||
@@ -414,9 +414,9 @@ class TestConfigProfilesAPI:
|
||||
"files": {},
|
||||
"git_mounts": [
|
||||
{
|
||||
"repo_id": repo_id,
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": ".",
|
||||
"target_path": "relative/path",
|
||||
"target_path": "../../../etc/passwd",
|
||||
}
|
||||
],
|
||||
},
|
||||
@@ -436,7 +436,7 @@ class TestConfigProfilesAPI:
|
||||
"files": {},
|
||||
"git_mounts": [
|
||||
{
|
||||
"repo_id": repo_id,
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
}
|
||||
@@ -450,4 +450,4 @@ class TestConfigProfilesAPI:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
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
|
||||
|
||||
|
||||
@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.integration
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -221,7 +220,7 @@ class TestToolTypesAPIExtended:
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
_ = response.json()
|
||||
|
||||
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
|
||||
"""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 (
|
||||
ConfigProfileCycleError,
|
||||
ConfigProfileNotFoundError,
|
||||
ResolvedProfile,
|
||||
check_include_cycle,
|
||||
resolve_profile,
|
||||
_merge_env_vars,
|
||||
@@ -63,7 +62,6 @@ class TestMergeFunctions:
|
||||
|
||||
def test_merge_mounts_basic(self) -> None:
|
||||
"""Test basic mount merging."""
|
||||
from src.services.config_profile_resolver import ResolvedMount
|
||||
result = _merge_mounts(
|
||||
{},
|
||||
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
|
||||
@@ -102,18 +100,18 @@ class TestMergeFunctions:
|
||||
"""Test basic git mount merging."""
|
||||
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",
|
||||
)
|
||||
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"
|
||||
|
||||
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
||||
"""Test that git mounts with same repo+target override."""
|
||||
result = _merge_git_mounts(
|
||||
[{"repo_id": "repo1", "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": ".", "target_path": "/app", "branch": "main"}],
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
||||
"source",
|
||||
)
|
||||
assert len(result) == 1
|
||||
@@ -123,8 +121,8 @@ class TestMergeFunctions:
|
||||
def test_merge_git_mounts_different_targets(self) -> None:
|
||||
"""Test that git mounts with different targets are preserved."""
|
||||
result = _merge_git_mounts(
|
||||
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app"}],
|
||||
[{"repo_id": "repo2", "source_path": ".", "target_path": "/config"}],
|
||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
|
||||
"source",
|
||||
)
|
||||
assert len(result) == 2
|
||||
@@ -296,7 +294,7 @@ class TestResolveProfile:
|
||||
env_vars={},
|
||||
files={},
|
||||
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)
|
||||
@@ -304,7 +302,7 @@ class TestResolveProfile:
|
||||
|
||||
result = await resolve_profile(db_session, profile.id)
|
||||
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"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -320,7 +318,7 @@ class TestResolveProfile:
|
||||
env_vars={},
|
||||
files={},
|
||||
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)
|
||||
@@ -333,7 +331,7 @@ class TestResolveProfile:
|
||||
env_vars={},
|
||||
files={},
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Unit tests for git mount resolution in tool instances."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -11,7 +10,6 @@ from src.api.tool_instances import (
|
||||
_expand_glob_source,
|
||||
_resolve_single_git_mount,
|
||||
)
|
||||
from src.services.config_profile_resolver import ResolvedProfile
|
||||
|
||||
|
||||
class TestExpandGlobSource:
|
||||
@@ -91,23 +89,22 @@ class TestCheckoutBranch:
|
||||
assert result == "feature"
|
||||
|
||||
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'")
|
||||
(tmp_path / "file.txt").write_text("content")
|
||||
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Failed to checkout branch"):
|
||||
_checkout_branch(str(tmp_path), "nonexistent")
|
||||
result = _checkout_branch(str(tmp_path), "nonexistent")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestResolveSingleGitMount:
|
||||
"""Unit tests for resolving a single git mount."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_missing_repo(self, db_session) -> None:
|
||||
"""Test that missing repo returns empty list."""
|
||||
async def test_resolve_missing_remote_url(self, db_session) -> None:
|
||||
"""Test that missing remote_url returns empty list."""
|
||||
git_mount = {
|
||||
"repo_id": "12345678-1234-1234-1234-123456789abc",
|
||||
"source_path": ".",
|
||||
"target_path": "/app",
|
||||
}
|
||||
@@ -119,21 +116,9 @@ class TestResolveSingleGitMount:
|
||||
async def test_resolve_missing_target_path(self, db_session) -> None:
|
||||
"""Test that missing target path returns empty list."""
|
||||
git_mount = {
|
||||
"repo_id": "12345678-1234-1234-1234-123456789abc",
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"source_path": ".",
|
||||
}
|
||||
|
||||
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||
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."""
|
||||
|
||||
import pytest
|
||||
|
||||
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."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.readiness_probe import execute_probe
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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";
|
||||
|
||||
@@ -19,7 +19,7 @@ const MAX_RETRIES = 2;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
// Track retry count per request
|
||||
const retryCount = new WeakMap<any, number>();
|
||||
const retryCount = new WeakMap<AxiosRequestConfig, number>();
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface ConfigProfileMount {
|
||||
}
|
||||
|
||||
export interface GitMount {
|
||||
repo_id: string;
|
||||
remote_url: string;
|
||||
source_path: string;
|
||||
target_path: string;
|
||||
branch?: string;
|
||||
|
||||
@@ -31,12 +31,24 @@ export interface 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;
|
||||
}
|
||||
|
||||
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
|
||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
||||
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
|
||||
if (projectId) {
|
||||
const response = await apiClient.get<GitRepository[]>(
|
||||
`/projects/${projectId}/repositories`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
// List all user repositories (including external)
|
||||
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listAllUserRepositories(): Promise<GitRepository[]> {
|
||||
const response = await apiClient.get<GitRepository[]>("/repositories");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -48,6 +60,13 @@ export async function createRepository(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createExternalRepository(
|
||||
data: GitRepositoryCreate
|
||||
): Promise<GitRepository> {
|
||||
const response = await apiClient.post<GitRepository>("/repositories", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
|
||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface ToolInstance {
|
||||
@@ -80,9 +81,10 @@ export async function startInstance(
|
||||
{ config_profile_id: configProfileId }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
// 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));
|
||||
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
||||
}
|
||||
@@ -114,9 +116,10 @@ export async function restartInstance(
|
||||
{ config_profile_id: configProfileId }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
// 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));
|
||||
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import type { GitMount } from "../api/config_profiles";
|
||||
import type { GitRepository } from "../api/git_repositories";
|
||||
|
||||
interface GitMountEditorProps {
|
||||
mounts: GitMount[];
|
||||
repositories: GitRepository[];
|
||||
onChange: (mounts: GitMount[]) => void;
|
||||
}
|
||||
|
||||
export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEditorProps) => {
|
||||
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [newMount, setNewMount] = useState<GitMount>({
|
||||
repo_id: "",
|
||||
remote_url: "",
|
||||
source_path: ".",
|
||||
target_path: "",
|
||||
branch: "",
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newMount.repo_id || !newMount.target_path) return;
|
||||
onChange([...mounts, { ...newMount }]);
|
||||
setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" });
|
||||
const handleAdd = (mount: GitMount) => {
|
||||
onChange([...mounts, mount]);
|
||||
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||
};
|
||||
|
||||
const handleUpdate = (index: number, updated: GitMount) => {
|
||||
@@ -38,11 +35,18 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
|
||||
const validatePath = (path: string, isTarget: boolean): string | null => {
|
||||
if (!path) return isTarget ? "Target path is required" : null;
|
||||
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";
|
||||
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 (
|
||||
<div className="git-mount-editor">
|
||||
<h4 className="section-subtitle">Git Mounts</h4>
|
||||
@@ -54,17 +58,15 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
|
||||
{editingIndex === index ? (
|
||||
<GitMountForm
|
||||
mount={mount}
|
||||
repositories={repositories}
|
||||
onSave={(updated) => handleUpdate(index, updated)}
|
||||
onCancel={() => setEditingIndex(null)}
|
||||
validatePath={validatePath}
|
||||
validateUrl={validateUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="git-mount-display">
|
||||
<div className="git-mount-info">
|
||||
<span className="git-mount-repo">
|
||||
{repositories.find((r) => r.id === mount.repo_id)?.name || mount.repo_id}
|
||||
</span>
|
||||
<span className="git-mount-repo">{mount.remote_url}</span>
|
||||
<span className="git-mount-paths">
|
||||
{mount.source_path || "."} → {mount.target_path}
|
||||
</span>
|
||||
@@ -101,10 +103,10 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
|
||||
<h5>Add Git Mount</h5>
|
||||
<GitMountForm
|
||||
mount={newMount}
|
||||
repositories={repositories}
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" })}
|
||||
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
|
||||
validatePath={validatePath}
|
||||
validateUrl={validateUrl}
|
||||
isNew
|
||||
/>
|
||||
</div>
|
||||
@@ -114,14 +116,14 @@ export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEdito
|
||||
|
||||
interface GitMountFormProps {
|
||||
mount: GitMount;
|
||||
repositories: GitRepository[];
|
||||
onSave: (mount: GitMount) => void;
|
||||
onCancel: () => void;
|
||||
validatePath: (path: string, isTarget: boolean) => string | null;
|
||||
validateUrl: (url: string) => string | null;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isNew }: GitMountFormProps) => {
|
||||
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
|
||||
const [form, setForm] = useState<GitMount>({ ...mount });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -139,9 +141,8 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
|
||||
const handleSubmit = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!form.repo_id) {
|
||||
newErrors.repo_id = "Repository is required";
|
||||
}
|
||||
const urlError = validateUrl(form.remote_url);
|
||||
if (urlError) newErrors.remote_url = urlError;
|
||||
|
||||
const sourceError = validatePath(form.source_path || ".", false);
|
||||
if (sourceError) newErrors.source_path = sourceError;
|
||||
@@ -156,27 +157,23 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
|
||||
|
||||
onSave(form);
|
||||
if (isNew) {
|
||||
setForm({ repo_id: "", source_path: ".", target_path: "", branch: "" });
|
||||
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="git-mount-form">
|
||||
<div className="form-row">
|
||||
<label>Repository</label>
|
||||
<select
|
||||
value={form.repo_id}
|
||||
onChange={(e) => 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>
|
||||
))}
|
||||
</select>
|
||||
{errors.repo_id && <span className="error-text">{errors.repo_id}</span>}
|
||||
<label>Git URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.remote_url}
|
||||
onChange={(e) => handleChange("remote_url", e.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={errors.remote_url ? "error" : ""}
|
||||
/>
|
||||
<span className="hint">Repository URL (HTTPS or SSH)</span>
|
||||
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
@@ -201,7 +198,7 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
|
||||
placeholder="e.g., /app/config"
|
||||
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>}
|
||||
</div>
|
||||
|
||||
@@ -226,4 +223,4 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isN
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -150,7 +150,6 @@ export const Icon: React.FC<IconProps> = ({
|
||||
const sizeValue = sizeMap[size];
|
||||
|
||||
if (!IconComponent) {
|
||||
console.warn(`Icon "${name}" not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface FormField {
|
||||
name: string;
|
||||
|
||||
@@ -22,8 +22,6 @@ interface MobileListViewProps {
|
||||
export const MobileListView: React.FC<MobileListViewProps> = ({
|
||||
items,
|
||||
onItemClick,
|
||||
onItemDelete,
|
||||
onItemDuplicate,
|
||||
emptyMessage = "No items found",
|
||||
searchPlaceholder = "Search...",
|
||||
onSearch,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
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 { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type ResolvedProfile,
|
||||
} from "../api/config_profiles";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import type { Project } from "../types";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { GitMountEditor } from "../components/git-mount-editor";
|
||||
@@ -34,7 +33,6 @@ export const ConfigProfilesPage = () => {
|
||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -72,18 +70,6 @@ export const ConfigProfilesPage = () => {
|
||||
setProjects(projs || []);
|
||||
setToolTypes(types || []);
|
||||
|
||||
// Load repositories from all projects
|
||||
const allRepos: GitRepository[] = [];
|
||||
for (const project of projs || []) {
|
||||
try {
|
||||
const repos = await listRepositories(project.id);
|
||||
allRepos.push(...repos);
|
||||
} catch {
|
||||
// Skip projects we can't access
|
||||
}
|
||||
}
|
||||
setRepositories(allRepos);
|
||||
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
@@ -1261,7 +1247,6 @@ export const ConfigProfilesPage = () => {
|
||||
<div className="form-section">
|
||||
<GitMountEditor
|
||||
mounts={formData.git_mounts || []}
|
||||
repositories={repositories}
|
||||
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
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 { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryResponse } from "../api/git_repositories";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
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 { useAuth } from "../state/auth";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { listProjects } from "../api/projects";
|
||||
import type { Project } from "../types";
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
} from "../api/sessions";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
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 { SessionList } from "../components/session-list";
|
||||
import { SessionCard } from "../components/session-card";
|
||||
@@ -21,7 +20,6 @@ import type { InstanceHealth } from "../api/sessions";
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const SessionsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
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 { 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 { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useAsyncData } from "../hooks/use-async-data";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
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 [generating, setGenerating] = useState(false);
|
||||
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -4131,3 +4131,28 @@ a.nav-item,
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.new-repo-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.new-repo-form input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.new-repo-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,73 @@ All endpoints require authentication (session cookie).
|
||||
|
||||
---
|
||||
|
||||
## GET /repositories
|
||||
|
||||
**Description:** List all repositories owned by the user, including external repositories not tied to any project.
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "my-external-repo",
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"is_mirror": false,
|
||||
"project_id": null,
|
||||
"owner_id": "uuid",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /repositories
|
||||
|
||||
**Description:** Create a new external repository (not tied to any project). External repositories can be used across all projects for config profile git mounts.
|
||||
|
||||
### Request
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-external-repo",
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"ssh_key_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Repository name (unique per user for external repos) |
|
||||
| `remote_url` | `string` | No | Remote URL to clone from |
|
||||
| `ssh_key_id` | `string` | No | SSH key ID for authentication |
|
||||
| `force_original_url` | `boolean` | No | Skip URL parsing (default: false) |
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (201 Created)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "my-external-repo",
|
||||
"path": "/data/repos/external/{user_id}/{repo_id}",
|
||||
"remote_url": "https://github.com/user/repo.git",
|
||||
"is_mirror": false,
|
||||
"project_id": null,
|
||||
"owner_id": "uuid",
|
||||
"ssh_key_id": "uuid",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /projects/{project_id}/repositories
|
||||
|
||||
**Description:** List repositories in a project.
|
||||
|
||||
+35
-23
@@ -6,35 +6,34 @@ The system SHALL allow config profiles to include git repository mounts that bin
|
||||
#### Scenario: Create profile with git mount
|
||||
- **WHEN** a user creates or updates a config profile with `git_mounts` entries
|
||||
- **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/")
|
||||
- `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
|
||||
- **WHEN** a profile with git mounts is saved
|
||||
- **THEN** the system validates that:
|
||||
- The referenced repository exists and belongs to the user's project
|
||||
- `remote_url` is a valid git URL (starts with https://, git@, or ssh://)
|
||||
- `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 (`..`)
|
||||
- No database lookup or repository existence check is performed (validation is deferred to clone time)
|
||||
|
||||
#### 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
|
||||
- **THEN** the resolved profile includes the git mounts with repository details:
|
||||
- Repository filesystem path
|
||||
- Resolved branch name
|
||||
- Source and target paths
|
||||
- **THEN** the resolved profile includes the git mounts as configured
|
||||
- **AND** repository cloning happens at instance startup time, not at profile resolution
|
||||
|
||||
#### Scenario: Git mount is applied at instance startup
|
||||
- **GIVEN** a resolved profile with git mounts
|
||||
- **WHEN** an instance is started with this profile
|
||||
- **THEN** for each git mount:
|
||||
- The repository filesystem path exists
|
||||
- The source path within the repository exists
|
||||
- A bind mount is created from `repo_path/source_path` to `container:target_path`
|
||||
- **AND** if the repository or path is missing, a warning is logged and the mount is skipped
|
||||
- The repository is cloned from `remote_url` to a temporary location
|
||||
- The source path within the cloned repository exists
|
||||
- A bind mount is created from `clone_path/source_path` to `container:target_path`
|
||||
- **AND** if the clone fails or path is missing, a warning is logged and the mount is skipped
|
||||
|
||||
### Requirement: Git mounts support glob patterns
|
||||
The system SHALL support glob patterns in `source_path` for matching multiple files.
|
||||
@@ -59,15 +58,22 @@ The system SHALL support glob patterns in `source_path` for matching multiple fi
|
||||
- **AND** logs a warning: "Glob pattern matched 500 files, limited to 100"
|
||||
|
||||
### Requirement: Git mounts trigger automatic cloning
|
||||
The system SHALL automatically clone referenced repositories if they do not exist locally.
|
||||
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 not cloned at startup
|
||||
- **GIVEN** a git mount referencing a repository that has not been cloned
|
||||
- **WHEN** the instance is started
|
||||
- **THEN** the system triggers a clone operation using the repository's remote URL and SSH key
|
||||
- **AND** the clone proceeds asynchronously
|
||||
#### Scenario: Repository cloned on container creation
|
||||
- **GIVEN** a git mount with a `remote_url`
|
||||
- **WHEN** a new container is created with this profile
|
||||
- **THEN** the system clones the repository from the URL to an instance-specific directory
|
||||
- **AND** the clone proceeds as part of instance startup
|
||||
- **AND** instance startup continues once clone completes
|
||||
|
||||
#### Scenario: Existing clone updated on new container creation
|
||||
- **GIVEN** a repository that was previously cloned for this instance
|
||||
- **WHEN** a new container is created with this profile
|
||||
- **THEN** the system pulls the latest updates from the remote_url
|
||||
- **AND** checks out the specified branch (or default branch if not specified)
|
||||
- **AND** uses the updated clone for the bind mount
|
||||
|
||||
#### Scenario: Clone failure handling
|
||||
- **GIVEN** a git mount referencing a repository with an invalid SSH key
|
||||
- **WHEN** the instance attempts to clone
|
||||
@@ -76,6 +82,12 @@ The system SHALL automatically clone referenced repositories if they do not exis
|
||||
- **AND** the mount is skipped
|
||||
- **AND** instance startup continues with remaining mounts
|
||||
|
||||
#### Scenario: Per-instance isolation
|
||||
- **GIVEN** a git mount referencing a repository
|
||||
- **WHEN** multiple instances are created using the same profile
|
||||
- **THEN** each instance gets its own independent clone
|
||||
- **AND** changes made in one container do not affect other containers
|
||||
|
||||
### Requirement: Git mounts support branch pinning
|
||||
The system SHALL support pinning git mounts to specific branches or tags.
|
||||
|
||||
@@ -99,7 +111,7 @@ The system SHALL display git mounts in the config profile editor.
|
||||
- **GIVEN** a config profile with git mounts
|
||||
- **WHEN** the user views the profile in the UI
|
||||
- **THEN** the git mounts section displays each mount with:
|
||||
- Repository name
|
||||
- Git URL
|
||||
- Source path within repository
|
||||
- Target path in container
|
||||
- Branch/tag (if specified)
|
||||
@@ -107,10 +119,10 @@ The system SHALL display git mounts in the config profile editor.
|
||||
#### Scenario: Add git mount via UI
|
||||
- **WHEN** a user adds a git mount in the profile editor
|
||||
- **THEN** they can:
|
||||
- Select from available repositories in the project
|
||||
- Enter a git URL directly (https://, git@, or ssh://)
|
||||
- Specify the source path (with autocomplete or validation)
|
||||
- Specify the target path in the container
|
||||
- Optionally select a branch/tag
|
||||
- Optionally enter a branch/tag name
|
||||
|
||||
#### Scenario: Remove git mount via UI
|
||||
- **WHEN** a user removes a git mount from the profile editor
|
||||
@@ -128,7 +140,7 @@ The system SHALL include git mounts in the profile preview/resolve output.
|
||||
- Source path (with expanded glob matches if applicable)
|
||||
- Target path in container
|
||||
- Resolved branch name
|
||||
- Clone status (exists, will clone, clone failed)
|
||||
- Clone status (will clone on container creation)
|
||||
|
||||
#### Scenario: Preview warns about missing repository
|
||||
- **GIVEN** a config profile with a git mount referencing a non-existent repository
|
||||
|
||||
@@ -55,10 +55,28 @@
|
||||
- [x] 7.7 Test branch checkout behavior (success and fallback)
|
||||
- [x] 7.8 Frontend type check passes
|
||||
- [x] 7.9 Frontend production build succeeds
|
||||
- [ ] 7.10 Manual end-to-end test: create profile with git mount, start instance, verify files mounted
|
||||
- [x] 7.10 Manual end-to-end test: create profile with git mount, start instance, verify files mounted
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
- [x] 8.1 Update API documentation with new git_mounts fields
|
||||
- [x] 8.2 Add user guide section for using git repositories in config profiles
|
||||
- [x] 8.3 Document branch pinning behavior and fallback rules
|
||||
|
||||
## 9. External Repository Support
|
||||
|
||||
- [x] 9.1 Remove project requirement from git mount validation
|
||||
- [x] 9.2 Add endpoint to create external repositories (no project_id)
|
||||
- [x] 9.3 Update list_repositories endpoint to return all user repos
|
||||
- [x] 9.4 Add endpoint to list external repositories
|
||||
- [x] 9.5 Update spec: repos can be external (not tied to project)
|
||||
- [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.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