Compare commits

..

3 Commits

Author SHA1 Message Date
Fusion b79da51269 docs: mark api-documentation tasks complete 2026-05-19 21:31:57 +02:00
Fusion 40a940304b docs: comprehensive API documentation
- Create enhanced health endpoints with /health and /health/db
- Add comprehensive docstrings to all API endpoints
- Add Pydantic response models with Field descriptions
- Create apps/api/README.md with setup guide
- Create ADR-001 for session auth decision
- Create ADR-002 for async SQLAlchemy decision
- Quality gates: Python syntax OK, TypeScript OK
2026-05-19 21:31:20 +02:00
Fusion e344e961d6 feat: implement web terminal for tool instances
- Add TerminalSession backend service for docker exec subprocess management
- Add TerminalManager for WebSocket session lifecycle management
- Create WebSocket endpoint at /ws/tool-instances/{id}/terminal
- Add session cookie authentication and instance ownership verification
- Install xterm.js with fit and web-links addons
- Create TerminalComponent with xterm.js integration
- Create TerminalPage with full-screen terminal view
- Add terminal route at /instances/:id/terminal
- Add terminal button to InstanceList for running instances
- Add terminal and arrow-left icons to icon registry
- Add comprehensive terminal CSS styles (dark theme, responsive)

Quality gates: typecheck ✓, lint ✓, build ✓, Python syntax ✓
2026-05-19 21:11:29 +02:00
41 changed files with 2981 additions and 89 deletions
+252
View File
@@ -0,0 +1,252 @@
# Headquarter API
The backend API for Headquarter - a self-hosted platform for managing projects, git repositories, and development tools.
## Overview
Built with **FastAPI** and **SQLAlchemy** (async), using **PostgreSQL** for data storage and **Docker** for tool instance management.
### Tech Stack
- **Framework**: FastAPI (Python 3.12+)
- **Database**: PostgreSQL 15+ with asyncpg
- **ORM**: SQLAlchemy 2.0 (async)
- **Auth**: OAuth2 via Authentik with session cookies
- **Migrations**: Alembic
- **Tools**: Docker Compose for instance management
## Quick Start
### Prerequisites
- Python 3.12+
- PostgreSQL 15+ running locally
- Docker (for tool instances)
### Setup
```bash
cd apps/api
# Create virtual environment
python -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -e ".[dev]"
# Set up database
# Ensure PostgreSQL is running with a 'headquarter' database
# Run migrations
alembic upgrade head
# Start development server
uvicorn src.main:app --reload --port 8000
```
The API will be available at `http://localhost:8000`.
### Interactive Documentation
Once running, visit:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
- **OpenAPI JSON**: http://localhost:8000/openapi.json
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DATABASE_URL` | Yes | - | PostgreSQL connection string |
| `API_BASE_URL` | Yes | - | Public API URL (e.g., `https://api.example.com`) |
| `AUTHENTIK_DOMAIN` | Yes | - | Authentik server domain |
| `AUTHENTIK_CLIENT_ID` | Yes | - | OAuth2 client ID |
| `AUTHENTIK_CLIENT_SECRET` | Yes | - | OAuth2 client secret |
| `AUTHENTIK_APPLICATION_SLUG` | Yes | - | Authentik application slug |
| `WEB_BASE_URL` | Yes | - | Public frontend URL |
| `SESSION_SECRET` | Yes | - | Secret for session cookie signing |
| `COOKIE_DOMAIN` | No | - | Cookie domain (e.g., `.example.com`) |
| `UPLOAD_DIR` | No | `./uploads` | Directory for file uploads |
| `REPO_BASE_PATH` | No | `./repositories` | Base path for git repositories |
| `INSTANCES_BASE_PATH` | No | `./instances` | Base path for tool instances |
| `LOG_LEVEL` | No | `INFO` | Logging level |
## Development
### Running Tests
```bash
# Run all tests
pytest
# Run specific test category
pytest -m unit # Unit tests (no DB)
pytest -m integration # Integration tests (requires DB)
# Run with coverage
pytest --cov=src --cov-report=html
```
### Code Quality
```bash
# Format code
ruff format src tests
# Lint
ruff check src tests
# Type check
mypy src
```
### Database Migrations
```bash
# Create new migration
alembic revision --autogenerate -m "description"
# Apply migrations
alembic upgrade head
# Rollback one migration
alembic downgrade -1
# Show current revision
alembic current
```
## Architecture
### Directory Structure
```
src/
├── api/ # API endpoint routers
│ ├── auth.py # OAuth2 authentication
│ ├── dashboard.py # Dashboard summary
│ ├── git_repositories.py # Git repo management
│ ├── health.py # Health checks
│ ├── projects.py # Project CRUD
│ ├── ssh_keys.py # SSH key management
│ ├── terminal.py # WebSocket terminal
│ ├── tool_instances.py # Tool instance management
│ ├── tool_types.py # Tool type definitions
│ ├── user_config.py # User preferences
│ └── users.py # User profile
├── auth/ # Authentication logic
│ ├── cookies.py # Cookie utilities
│ ├── dependencies.py # Auth dependencies
│ ├── oidc.py # OpenID Connect
│ └── session.py # Session management
├── config.py # Application settings
├── database.py # Database setup
├── main.py # FastAPI application
├── models/ # SQLAlchemy models
├── schemas/ # Pydantic schemas
├── services/ # Business logic
│ ├── docker.py # Docker Compose management
│ ├── terminal_manager.py # Terminal sessions
│ └── terminal_session.py # Terminal I/O
└── utils/ # Utilities
├── git_control.py # Git operations
├── git_files.py # File operations
├── git_history.py # History extraction
└── git_url_parser.py # URL parsing
```
### Authentication Flow
1. User clicks "Login" → redirects to Authentik OAuth
2. Authentik redirects back with authorization code
3. API exchanges code for tokens and fetches user info
4. API creates session cookie (HMAC-signed, httpOnly)
5. Frontend stores nothing - cookie sent automatically
6. Subsequent requests include cookie for authentication
### Data Flow
```
Client → FastAPI Router → Auth Dependency → Service Layer → Database
Pydantic Models (validation)
SQLAlchemy Models (ORM)
PostgreSQL (storage)
```
## API Endpoints
### Authentication
- `GET /auth/login` - Initiate OAuth login
- `GET /auth/callback` - OAuth callback
- `GET /auth/me` - Get current user
- `POST /auth/logout` - Logout
### Projects
- `GET /projects` - List projects
- `POST /projects` - Create project
- `GET /projects/{id}` - Get project
- `PUT /projects/{id}` - Update project
- `DELETE /projects/{id}` - Delete project
### Git Repositories
- `GET /projects/{id}/repositories` - List repositories
- `POST /projects/{id}/repositories` - Create repository
- `GET /projects/{id}/repositories/{id}` - Get repository
- `DELETE /projects/{id}/repositories/{id}` - Delete repository
- `GET /projects/{id}/repositories/{id}/files` - List files
- `GET /projects/{id}/repositories/{id}/files/content` - Get file content
- `POST /projects/{id}/repositories/{id}/files/content` - Update file
- `GET /projects/{id}/repositories/{id}/branches` - List branches
- `GET /projects/{id}/repositories/{id}/history` - Commit history
- `GET /projects/{id}/repositories/{id}/commits/{hash}` - Commit detail
### Tool Types
- `GET /tool-types` - List tool types
- `POST /tool-types` - Create tool type
- `GET /tool-types/{id}` - Get tool type
- `PUT /tool-types/{id}` - Update tool type
- `DELETE /tool-types/{id}` - Delete tool type
### Tool Instances
- `GET /tool-instances` - List instances
- `POST /tool-instances` - Create instance
- `GET /tool-instances/{id}` - Get instance
- `POST /tool-instances/{id}/start` - Start instance
- `POST /tool-instances/{id}/stop` - Stop instance
- `POST /tool-instances/{id}/restart` - Restart instance
- `DELETE /tool-instances/{id}` - Delete instance
- `GET /tool-instances/{id}/logs` - Get logs
### Terminal
- `WS /ws/tool-instances/{id}/terminal` - WebSocket terminal
### Users
- `GET /users/me` - Get profile
- `PUT /users/me` - Update profile
- `POST /users/me/avatar` - Upload avatar
- `GET /users/me/config` - Get config
- `PATCH /users/me/config` - Update config
### SSH Keys
- `GET /ssh-keys` - List keys
- `POST /ssh-keys` - Create key
- `DELETE /ssh-keys/{id}` - Delete key
### Health
- `GET /health` - System health
- `GET /health/db` - Database health
## Deployment
See the [deployment documentation](../../docs/deployment/) for Docker and Traefik setup.
## Contributing
1. Follow PEP 8 style guide
2. Add tests for new endpoints
3. Update documentation
4. Run quality gates before committing
+14 -1
View File
@@ -25,8 +25,21 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
yield session
@router.get("/login")
@router.get(
"/login",
summary="Initiate OAuth login",
description="Redirects to the configured OAuth provider (Authentik) to start the authentication flow.",
response_class=RedirectResponse,
)
async def login(next: str = "/") -> RedirectResponse:
"""Initiate OAuth2 login flow.
Args:
next: URL to redirect to after successful authentication.
Returns:
RedirectResponse to the OAuth provider's authorization endpoint.
"""
settings = Settings()
redirect_uri = f"{settings.api_base_url}/auth/callback"
state = token_urlsafe(24)
+14 -1
View File
@@ -12,11 +12,24 @@ from src.models.ssh_key import SSHKey
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/summary")
@router.get(
"/summary",
summary="Get dashboard summary",
description="Get a summary of the user's projects, repositories, SSH keys, and recent activity.",
)
async def get_dashboard_summary(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a summary of the user's dashboard data.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with counts of projects, repositories, SSH keys, and recent activity.
"""
# Count user's projects
projects_result = await session.execute(
select(func.count()).select_from(Project).where(Project.owner_id == user_id)
+357 -35
View File
@@ -38,6 +38,7 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -49,6 +50,19 @@ async def _get_owned_project(
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
@@ -58,6 +72,16 @@ async def _get_owned_project(
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
"""Generate the filesystem path for a repository.
Args:
user_id: UUID of the repository owner.
project_id: UUID of the project.
name: Repository name.
Returns:
Absolute path to the repository directory.
"""
base = Settings().repo_base_path or "/data/repos"
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
@@ -97,12 +121,27 @@ class GitRepositoryResponse(BaseModel):
updated_at: datetime
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
@router.get(
"/{project_id}/repositories",
response_model=list[GitRepositoryResponse],
summary="List repositories",
description="List all git repositories in a project.",
)
async def list_repositories(
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[GitRepository]:
"""List all repositories in a project.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of repositories in the project.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -112,13 +151,29 @@ async def list_repositories(
return list(result.scalars().all())
@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{project_id}/repositories/{repo_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a repository",
description="Delete a git repository from the project and remove it from disk.",
)
async def delete_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Delete a repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository to delete.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Empty response with 204 status code.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -135,20 +190,49 @@ async def delete_repository(
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/repositories/parse-url", response_model=URLParseResponse)
@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."""
"""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, status_code=status.HTTP_201_CREATED)
@router.post(
"/{project_id}/repositories",
response_model=GitRepositoryResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a repository",
description="Create a new git repository in a project. Can clone from remote or initialize bare.",
)
async def create_repository(
project_id: uuid.UUID,
data: GitRepositoryCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Create a new git repository.
Args:
project_id: UUID of the project.
data: Repository creation data including name and optional remote URL.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created repository.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -229,7 +313,11 @@ async def create_repository(
return repo
@router.get("/{project_id}/repositories/{repo_id}/history")
@router.get(
"/{project_id}/repositories/{repo_id}/history",
summary="Get repository history",
description="Get commit history for a repository with optional branch filtering.",
)
async def get_repository_history(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -240,7 +328,21 @@ async def get_repository_history(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get commit history for a repository."""
"""Get commit history for a repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
view: View type for history display (default: graph).
branch: Optional branch name to filter commits.
limit: Maximum number of commits to return (default: 100).
offset: Number of commits to skip (default: 0).
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing commit history data.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -258,7 +360,11 @@ async def get_repository_history(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
@router.get(
"/{project_id}/repositories/{repo_id}/commits/{commit_hash}",
summary="Get commit details",
description="Get detailed information about a specific commit.",
)
async def get_repository_commit(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -266,7 +372,18 @@ async def get_repository_commit(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get detailed information about a specific commit."""
"""Get detailed information about a specific commit.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
commit_hash: Hash of the commit to retrieve.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing commit details.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -322,7 +439,12 @@ class FileUpdateResponse(BaseModel):
branch: str
@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse)
@router.get(
"/{project_id}/repositories/{repo_id}/files",
response_model=FileListResponse,
summary="List repository files",
description="List files and directories in a repository path.",
)
async def list_repository_files(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -331,7 +453,19 @@ async def list_repository_files(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FileListResponse:
"""List files and directories in a repository path."""
"""List files and directories in a repository path.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
branch: Branch name to browse (default: main).
path: Directory path within the repository (default: root).
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of files and directories in the specified path.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -363,7 +497,12 @@ async def list_repository_files(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse)
@router.get(
"/{project_id}/repositories/{repo_id}/files/content",
response_model=FileContentResponse,
summary="Get file content",
description="Get the content of a file in a repository.",
)
async def get_repository_file_content(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -372,7 +511,19 @@ async def get_repository_file_content(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FileContentResponse:
"""Get the content of a file."""
"""Get the content of a file.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
branch: Branch name where the file is located.
path: File path within the repository.
user_id: ID of the authenticated user.
session: Database session.
Returns:
File content and metadata.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -401,14 +552,29 @@ async def get_repository_file_content(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/branches", response_model=BranchesResponse)
@router.get(
"/{project_id}/repositories/{repo_id}/branches",
response_model=BranchesResponse,
summary="List branches",
description="List all branches in the repository.",
)
async def get_repository_branches(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> BranchesResponse:
"""List all branches in the repository."""
"""List all branches in the repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of branches and the default branch name.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -436,7 +602,12 @@ async def get_repository_branches(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/files/content",
response_model=FileUpdateResponse,
summary="Update file content",
description="Update a file and create a commit.",
)
async def update_repository_file(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -444,7 +615,18 @@ async def update_repository_file(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FileUpdateResponse:
"""Update a file and create a commit."""
"""Update a file and create a commit.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: File update data including path, branch, content, and commit message.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Commit information for the file update.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -493,14 +675,29 @@ class StatusResponse(BaseModel):
behind: int
@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse)
@router.get(
"/{project_id}/repositories/{repo_id}/status",
response_model=StatusResponse,
summary="Get repository status",
description="Get the working directory status including modified, added, and deleted files.",
)
async def get_repository_status(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> StatusResponse:
"""Get the working directory status."""
"""Get the working directory status.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Repository status including branch, modified files, and ahead/behind counts.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -536,7 +733,11 @@ class CheckoutRequest(BaseModel):
branch: str
@router.post("/{project_id}/repositories/{repo_id}/branches")
@router.post(
"/{project_id}/repositories/{repo_id}/branches",
summary="Create a branch",
description="Create a new branch in the repository.",
)
async def create_repository_branch(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -544,7 +745,18 @@ async def create_repository_branch(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a new branch."""
"""Create a new branch.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Branch creation data including name and optional base branch.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with success message and branch name.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -562,7 +774,11 @@ async def create_repository_branch(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}")
@router.delete(
"/{project_id}/repositories/{repo_id}/branches/{branch_name}",
summary="Delete a branch",
description="Delete a branch from the repository.",
)
async def delete_repository_branch(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -571,7 +787,19 @@ async def delete_repository_branch(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Delete a branch."""
"""Delete a branch.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
branch_name: Name of the branch to delete.
force: Whether to force delete the branch.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with success message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -589,7 +817,11 @@ async def delete_repository_branch(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/checkout")
@router.post(
"/{project_id}/repositories/{repo_id}/checkout",
summary="Checkout a branch",
description="Checkout a branch in the repository.",
)
async def checkout_repository_branch(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -597,7 +829,18 @@ async def checkout_repository_branch(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Checkout a branch."""
"""Checkout a branch.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Checkout request containing the branch name.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with success message and checked out branch name.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -625,7 +868,12 @@ class CommitResponse(BaseModel):
message: str
@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/commit",
response_model=CommitResponse,
summary="Commit changes",
description="Commit changes to the repository.",
)
async def commit_repository_changes(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -633,7 +881,18 @@ async def commit_repository_changes(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> CommitResponse:
"""Commit changes to the repository."""
"""Commit changes to the repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Commit request containing message and optional files to commit.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Commit information including hash and message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -669,14 +928,29 @@ class FetchResponse(BaseModel):
message: str
@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/fetch",
response_model=FetchResponse,
summary="Fetch from remote",
description="Fetch updates from the remote repository.",
)
async def fetch_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FetchResponse:
"""Fetch from remote."""
"""Fetch from remote.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Success message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -698,7 +972,12 @@ class PullResponse(BaseModel):
message: str
@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/pull",
response_model=PullResponse,
summary="Pull from remote",
description="Pull updates from the remote repository.",
)
async def pull_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -706,7 +985,18 @@ async def pull_repository(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> PullResponse:
"""Pull updates from remote."""
"""Pull updates from remote.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
branch: Optional branch name to pull.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Success message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -728,7 +1018,12 @@ class PushResponse(BaseModel):
message: str
@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/push",
response_model=PushResponse,
summary="Push to remote",
description="Push changes to the remote repository.",
)
async def push_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -736,7 +1031,18 @@ async def push_repository(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> PushResponse:
"""Push changes to remote."""
"""Push changes to remote.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
branch: Optional branch name to push.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Success message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -765,7 +1071,12 @@ class MergeResponse(BaseModel):
message: str
@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/merge",
response_model=MergeResponse,
summary="Merge branches",
description="Merge one branch into another.",
)
async def merge_repository_branches(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -773,7 +1084,18 @@ async def merge_repository_branches(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> MergeResponse:
"""Merge branches."""
"""Merge branches.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Merge request containing source branch, optional target branch, and message.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Merge result with commit hash and message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
+149
View File
@@ -0,0 +1,149 @@
"""Health check endpoints and models."""
import time
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, status
from pydantic import BaseModel, Field
from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal
router = APIRouter()
# Track start time for uptime
_start_time = time.time()
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
@router.get(
"/health",
response_model=HealthResponse,
summary="Health check",
description="Returns overall system health status including database and disk checks.",
tags=["Health"],
)
async def health_check() -> dict[str, Any]:
"""Check overall system health.
Returns:
HealthResponse with status, timestamp, version, checks, and uptime.
"""
checks = HealthChecks()
overall_status = "healthy"
# Database check
try:
import time as time_module
start = time_module.perf_counter()
async with SessionLocal() as session:
await session.execute(text("SELECT 1"))
db_time = (time_module.perf_counter() - start) * 1000
checks.database = DatabaseHealth(
status="healthy",
response_time_ms=round(db_time, 2),
)
except Exception:
checks.database = DatabaseHealth(
status="unhealthy",
response_time_ms=0.0,
)
overall_status = "degraded"
# Disk check
try:
import shutil
disk = shutil.disk_usage("/")
free_gb = disk.free / (1024**3)
total_gb = disk.total / (1024**3)
disk_status = "healthy" if free_gb > 1.0 else "degraded"
if disk_status == "degraded":
overall_status = "degraded"
checks.disk = DiskHealth(
status=disk_status,
free_gb=round(free_gb, 2),
total_gb=round(total_gb, 2),
)
except Exception:
checks.disk = None
return HealthResponse(
status=overall_status,
timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
version="0.1.0",
checks=checks,
uptime_seconds=round(time.time() - _start_time, 2),
).model_dump()
@router.get(
"/health/db",
response_model=DatabaseHealthResponse,
summary="Database health check",
description="Returns database-specific health status with response time.",
tags=["Health"],
)
async def health_check_db() -> dict[str, Any]:
"""Check database health.
Returns:
DatabaseHealthResponse with status and response time.
"""
import time as time_module
try:
start = time_module.perf_counter()
async with SessionLocal() as session:
await session.execute(text("SELECT 1"))
db_time = (time_module.perf_counter() - start) * 1000
return DatabaseHealthResponse(
status="healthy",
response_time_ms=round(db_time, 2),
).model_dump()
except Exception:
return DatabaseHealthResponse(
status="unhealthy",
response_time_ms=0.0,
).model_dump()
+112 -6
View File
@@ -17,6 +17,7 @@ router = APIRouter(prefix="/projects", tags=["projects"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -47,12 +48,28 @@ class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=ProjectResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new project",
description="Create a new project for the authenticated user.",
)
async def create_project(
data: ProjectCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Create a new project.
Args:
data: Project creation data including name and optional description.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created project.
"""
user = await _get_user(session, user_id)
project = Project(
name=data.name,
@@ -66,22 +83,51 @@ async def create_project(
return project
@router.get("", response_model=list[ProjectResponse])
@router.get(
"",
response_model=list[ProjectResponse],
summary="List all projects",
description="Retrieve all projects owned by the authenticated user.",
)
async def list_projects(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[Project]:
"""List all projects for the authenticated user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of projects owned by the user.
"""
user = await _get_user(session, user_id)
result = await session.execute(select(Project).where(Project.owner_id == user.id))
return list(result.scalars().all())
@router.get("/{project_id}", response_model=ProjectResponse)
@router.get(
"/{project_id}",
response_model=ProjectResponse,
summary="Get a project",
description="Retrieve a specific project by ID.",
)
async def get_project(
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Get a specific project by ID.
Args:
project_id: UUID of the project to retrieve.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The requested project.
"""
await _get_user(session, user_id)
return await _get_owned_project(project_id, user_id, session)
@@ -91,6 +137,19 @@ async def _get_owned_project(
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
@@ -99,13 +158,29 @@ async def _get_owned_project(
return project
@router.patch("/{project_id}", response_model=ProjectResponse)
@router.patch(
"/{project_id}",
response_model=ProjectResponse,
summary="Update a project",
description="Update a project's name or description.",
)
async def update_project(
project_id: uuid.UUID,
data: ProjectUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Update a project.
Args:
project_id: UUID of the project to update.
data: Project update data with optional name and description.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated project.
"""
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
@@ -119,12 +194,27 @@ async def update_project(
return project
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{project_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a project",
description="Delete a project and all its associated repositories.",
)
async def delete_project(
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Delete a project and all its repositories.
Args:
project_id: UUID of the project to delete.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Empty response with 204 status code.
"""
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
@@ -141,13 +231,29 @@ async def delete_project(
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.patch("/{project_id}/default-ssh-key", response_model=ProjectResponse)
@router.patch(
"/{project_id}/default-ssh-key",
response_model=ProjectResponse,
summary="Set default SSH key",
description="Set the default SSH key for a project.",
)
async def set_default_ssh_key(
project_id: uuid.UUID,
data: SetDefaultSSHKeyRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Set the default SSH key for a project.
Args:
project_id: UUID of the project.
data: Request containing the SSH key ID to set as default.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated project.
"""
user = await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
+54 -3
View File
@@ -18,6 +18,7 @@ router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -38,6 +39,11 @@ def _get_fernet() -> Fernet:
def generate_ssh_key_pair() -> tuple[str, str]:
"""Generate a new Ed25519 SSH key pair.
Returns:
Tuple of (private_key, public_key) as strings.
"""
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
@@ -68,12 +74,28 @@ class SSHKeyResponse(BaseModel):
created_at: datetime
@router.post("", response_model=SSHKeyResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=SSHKeyResponse,
status_code=status.HTTP_201_CREATED,
summary="Create SSH key",
description="Generate a new Ed25519 SSH key pair for the authenticated user.",
)
async def create_ssh_key(
data: SSHKeyCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> SSHKey:
"""Create a new SSH key pair.
Args:
data: SSH key creation data including the key name.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created SSH key with public key exposed.
"""
user = await _get_user(session, user_id)
private_key, public_key = generate_ssh_key_pair()
@@ -92,22 +114,51 @@ async def create_ssh_key(
return ssh_key
@router.get("", response_model=list[SSHKeyResponse])
@router.get(
"",
response_model=list[SSHKeyResponse],
summary="List SSH keys",
description="List all SSH keys for the authenticated user.",
)
async def list_ssh_keys(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[SSHKey]:
"""List all SSH keys for the authenticated user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of SSH keys owned by the user.
"""
user = await _get_user(session, user_id)
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
return list(result.scalars().all())
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{key_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete SSH key",
description="Delete an SSH key by ID.",
)
async def delete_ssh_key(
key_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete an SSH key.
Args:
key_id: UUID of the SSH key to delete.
user_id: ID of the authenticated user.
session: Database session.
Returns:
None with 204 status code.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
+120
View File
@@ -0,0 +1,120 @@
"""WebSocket terminal endpoint for tool instances."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id
from src.database import get_db_session
from src.models.tool_instance import ToolInstance
from src.services.terminal_manager import terminal_manager
router = APIRouter()
@router.websocket(
"/ws/tool-instances/{instance_id}/terminal",
)
async def terminal_websocket(
websocket: WebSocket,
instance_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
"""WebSocket endpoint for terminal access to a tool instance.
Provides an interactive terminal session inside a running tool instance container.
Args:
websocket: The WebSocket connection.
instance_id: UUID string of the tool instance.
db_session: Database session.
Returns:
None. Communicates via WebSocket messages.
"""
await websocket.accept()
try:
# Parse instance_id
instance_uuid = uuid.UUID(instance_id)
except ValueError:
await websocket.close(code=4001, reason="Invalid instance ID")
return
# Authenticate user from session cookie
user_id = await _get_user_from_websocket(websocket, db_session)
if user_id is None:
await websocket.close(code=4003, reason="Unauthorized")
return
# Get instance and verify ownership
instance = await db_session.get(ToolInstance, instance_uuid)
if instance is None:
await websocket.close(code=4004, reason="Instance not found")
return
if instance.owner_id != user_id:
await websocket.close(code=4003, reason="Forbidden")
return
if instance.status != "running" or not instance.container_id:
await websocket.close(code=4004, reason="Instance not running")
return
# Create terminal session
try:
session = await terminal_manager.create_session(
instance_uuid,
instance.container_id,
websocket,
)
# Send connected status
await websocket.send_json({"type": "status", "status": "connected"})
# Keep connection alive until closed
while True:
try:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
except WebSocketDisconnect:
break
except RuntimeError:
break
except Exception as exc:
await websocket.close(code=4000, reason=f"Error: {exc}")
finally:
# Cleanup will be handled by the session manager
pass
async def _get_user_from_websocket(
websocket: WebSocket,
db_session: AsyncSession,
) -> uuid.UUID | None:
"""Extract and validate user ID from session cookie in WebSocket.
Args:
websocket: The WebSocket connection.
db_session: Database session.
Returns:
The user's UUID if authenticated, None otherwise.
"""
from src.auth.session import verify_session_token
session_cookie = websocket.cookies.get("session")
if not session_cookie:
return None
user_id = verify_session_token(session_cookie)
if not user_id:
return None
try:
return uuid.UUID(user_id)
except ValueError:
return None
+165 -18
View File
@@ -30,6 +30,7 @@ router = APIRouter(prefix="/projects", tags=["tool-instances"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 404 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(
@@ -41,6 +42,19 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
async def _get_owned_project(
project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None or project.owner_id != user_id:
raise HTTPException(
@@ -49,7 +63,11 @@ async def _get_owned_project(
return project
@router.post("/{project_id}/repositories/{repo_id}/instances")
@router.post(
"/{project_id}/repositories/{repo_id}/instances",
summary="Create tool instance",
description="Create a new tool instance for a repository.",
)
async def create_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -58,7 +76,19 @@ async def create_instance(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a new tool instance for a repository."""
"""Create a new tool instance for a repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
tool_type_id: UUID of the tool type to instantiate.
display_name: Optional display name for the instance.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with instance details.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -123,14 +153,28 @@ async def create_instance(
}
@router.get("/{project_id}/repositories/{repo_id}/instances")
@router.get(
"/{project_id}/repositories/{repo_id}/instances",
summary="List instances",
description="List all tool instances for a repository.",
)
async def list_instances(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List all instances for a repository."""
"""List all instances for a repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing list of instances.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -165,7 +209,11 @@ async def list_instances(
}
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
@router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
summary="Get instance",
description="Get a specific instance with real-time status from Docker.",
)
async def get_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -173,7 +221,18 @@ async def get_instance(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a specific instance with real-time status."""
"""Get a specific instance with real-time status.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the instance.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with instance details and current status.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -210,7 +269,11 @@ async def get_instance(
}
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/start")
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/start",
summary="Start instance",
description="Start a tool instance using Docker Compose.",
)
async def start_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -218,7 +281,18 @@ async def start_instance(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Start a tool instance."""
"""Start a tool instance.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the instance to start.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with status and URL of the running instance.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -262,7 +336,11 @@ async def start_instance(
return {"status": instance.status, "url": instance.url}
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop")
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop",
summary="Stop instance",
description="Stop a running tool instance.",
)
async def stop_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -270,7 +348,18 @@ async def stop_instance(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Stop a tool instance."""
"""Stop a tool instance.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the instance to stop.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with the stopped status.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -291,7 +380,11 @@ async def stop_instance(
return {"status": instance.status}
@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart")
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart",
summary="Restart instance",
description="Restart a tool instance.",
)
async def restart_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -299,7 +392,18 @@ async def restart_instance(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Restart a tool instance."""
"""Restart a tool instance.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the instance to restart.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with status and URL of the restarted instance.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -326,7 +430,11 @@ async def restart_instance(
return {"status": instance.status}
@router.delete("/{project_id}/repositories/{repo_id}/instances/{instance_id}")
@router.delete(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}",
summary="Delete instance",
description="Delete a tool instance and remove its Docker containers and files.",
)
async def delete_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -334,7 +442,18 @@ async def delete_instance(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a tool instance."""
"""Delete a tool instance.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the instance to delete.
user_id: ID of the authenticated user.
session: Database session.
Returns:
None with 204 status code.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -359,7 +478,11 @@ async def delete_instance(
await session.commit()
@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs")
@router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs",
summary="Get instance logs",
description="Get container logs for a tool instance.",
)
async def get_instance_logs(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -368,7 +491,19 @@ async def get_instance_logs(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get container logs for an instance."""
"""Get container logs for an instance.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the instance.
tail: Number of log lines to return (default: 100).
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing the container logs.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -389,12 +524,24 @@ from fastapi import APIRouter as FastAPIRouter
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
@sessions_router.get("/me/sessions")
@sessions_router.get(
"/me/sessions",
summary="Get user sessions",
description="Get all active sessions (running instances) for the current user.",
)
async def get_user_sessions(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get all active sessions (running instances) for the current user."""
"""Get all active sessions for the current user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing list of active sessions with instance details.
"""
_user = await _get_user(session, user_id)
result = await session.execute(
+87 -5
View File
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/tool-types", tags=["tool-types"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -22,6 +23,11 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
async def _require_admin(user: User) -> None:
"""Check if user has admin privileges.
For now, all authenticated users can manage tool types.
In production, this should check user.role or similar.
"""
# For now, all authenticated users can manage tool types
# In production, check user.role or similar
pass
@@ -117,12 +123,28 @@ class ToolTypeResponse(BaseModel):
updated_at: datetime
@router.post("", response_model=ToolTypeResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=ToolTypeResponse,
status_code=status.HTTP_201_CREATED,
summary="Create tool type",
description="Create a new custom tool type with a Docker Compose template.",
)
async def create_tool_type(
data: ToolTypeCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Create a new tool type.
Args:
data: Tool type creation data including name, display name, and compose template.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created tool type.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
@@ -146,22 +168,51 @@ async def create_tool_type(
return tool_type
@router.get("", response_model=list[ToolTypeResponse])
@router.get(
"",
response_model=list[ToolTypeResponse],
summary="List tool types",
description="List all available tool types including built-in and custom ones.",
)
async def list_tool_types(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[ToolType]:
"""List all tool types.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of all tool types ordered by name.
"""
await _get_user(session, user_id)
result = await session.execute(select(ToolType).order_by(ToolType.name))
return list(result.scalars().all())
@router.get("/{tool_type_id}", response_model=ToolTypeResponse)
@router.get(
"/{tool_type_id}",
response_model=ToolTypeResponse,
summary="Get tool type",
description="Get a specific tool type by ID.",
)
async def get_tool_type(
tool_type_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Get a specific tool type by ID.
Args:
tool_type_id: UUID of the tool type to retrieve.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The requested tool type.
"""
await _get_user(session, user_id)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
@@ -169,13 +220,29 @@ async def get_tool_type(
return tool_type
@router.put("/{tool_type_id}", response_model=ToolTypeResponse)
@router.put(
"/{tool_type_id}",
response_model=ToolTypeResponse,
summary="Update tool type",
description="Update a custom tool type. Built-in tool types cannot be modified.",
)
async def update_tool_type(
tool_type_id: uuid.UUID,
data: ToolTypeUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Update a tool type.
Args:
tool_type_id: UUID of the tool type to update.
data: Tool type update data with optional fields.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated tool type.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
@@ -217,12 +284,27 @@ async def update_tool_type(
return tool_type
@router.delete("/{tool_type_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{tool_type_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete tool type",
description="Delete a custom tool type. Built-in tool types cannot be deleted.",
)
async def delete_tool_type(
tool_type_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a tool type.
Args:
tool_type_id: UUID of the tool type to delete.
user_id: ID of the authenticated user.
session: Database session.
Returns:
None with 204 status code.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
+41 -2
View File
@@ -13,6 +13,7 @@ router = APIRouter(prefix="/users/me", tags=["user-config"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -20,6 +21,15 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
"""Get or create user config record.
Args:
session: Database session.
user_id: UUID of the user.
Returns:
The user's config, creating a new one if it doesn't exist.
"""
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
config = result.scalar_one_or_none()
if config is None:
@@ -46,22 +56,51 @@ class UserConfigUpdate(BaseModel):
git_user_email: str | None = None
@router.get("/config", response_model=UserConfigResponse)
@router.get(
"/config",
response_model=UserConfigResponse,
summary="Get user config",
description="Get the current user's configuration settings.",
)
async def get_user_config(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> UserConfigResponse:
"""Get the current user's configuration.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
The user's configuration settings.
"""
_user = await _get_user(session, user_id)
config = await _get_or_create_config(session, user_id)
return UserConfigResponse.model_validate(config.config)
@router.patch("/config", response_model=UserConfigResponse)
@router.patch(
"/config",
response_model=UserConfigResponse,
summary="Update user config",
description="Update the current user's configuration settings.",
)
async def update_user_config(
data: UserConfigUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> UserConfigResponse:
"""Update the current user's configuration.
Args:
data: Configuration update data with optional fields.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated user configuration.
"""
_user = await _get_user(session, user_id)
config = await _get_or_create_config(session, user_id)
+48 -3
View File
@@ -17,6 +17,7 @@ MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -37,20 +38,49 @@ class UserProfileUpdate(BaseModel):
email: str | None = None
@router.get("/me", response_model=UserProfileResponse)
@router.get(
"/me",
response_model=UserProfileResponse,
summary="Get current user profile",
description="Retrieve the profile of the currently authenticated user.",
)
async def get_profile(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Get the current user's profile.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
The user's profile information.
"""
return await _get_user(session, user_id)
@router.put("/me", response_model=UserProfileResponse)
@router.put(
"/me",
response_model=UserProfileResponse,
summary="Update user profile",
description="Update the current user's profile information.",
)
async def update_profile(
data: UserProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Update the current user's profile.
Args:
data: Profile update data with optional name and email.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated user profile.
"""
user = await _get_user(session, user_id)
if data.name is not None:
@@ -68,12 +98,27 @@ async def update_profile(
return user
@router.post("/me/avatar", response_model=UserProfileResponse)
@router.post(
"/me/avatar",
response_model=UserProfileResponse,
summary="Upload avatar",
description="Upload a profile avatar image (PNG or JPG, max 2MB).",
)
async def upload_avatar(
file: UploadFile,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Upload a profile avatar image.
Args:
file: The image file to upload (PNG or JPG, max 2MB).
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated user profile with new avatar URL.
"""
user = await _get_user(session, user_id)
if file.content_type not in ALLOWED_CONTENT_TYPES:
+4 -11
View File
@@ -9,8 +9,10 @@ from sqlalchemy import select, text
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
from src.api.git_repositories import router as git_repositories_router
from src.api.health import router as health_router
from src.api.projects import router as projects_router
from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router
from src.api.tool_instances import router as tool_instances_router
from src.api.tool_instances import sessions_router
from src.api.tool_types import router as tool_types_router
@@ -147,17 +149,7 @@ async def on_startup():
await seed_builtin_tool_types()
logger.info("Startup complete.")
@app.get("/health")
async def health_check():
try:
from sqlalchemy import text
async with SessionLocal() as session:
await session.execute(text("SELECT 1"))
return {"status": "healthy", "database": "connected"}
except Exception as exc:
logger.error("Health check failed: %s", exc)
return {"status": "unhealthy", "database": "disconnected", "error": str(exc)}
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(dashboard_router)
app.include_router(projects_router)
@@ -168,4 +160,5 @@ app.include_router(user_config_router)
app.include_router(tool_types_router)
app.include_router(tool_instances_router)
app.include_router(sessions_router)
app.include_router(terminal_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+96
View File
@@ -0,0 +1,96 @@
"""Terminal session manager for WebSocket connections."""
import asyncio
import uuid
from typing import Any
from fastapi import WebSocket
from src.services.terminal_session import TerminalSession
class TerminalManager:
"""Manages active terminal sessions."""
def __init__(self) -> None:
self._sessions: dict[str, TerminalSession] = {}
async def create_session(
self,
instance_id: uuid.UUID,
container_id: str,
websocket: WebSocket,
) -> TerminalSession:
"""Create a new terminal session."""
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id)
await session.start()
self._sessions[session_id] = session
# Start background tasks for I/O streaming
asyncio.create_task(self._read_loop(session, websocket))
asyncio.create_task(self._write_loop(session, websocket))
return session
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
while session.is_alive() and not session._closed:
data = await session.read_output()
if data:
await websocket.send_bytes(data)
else:
await asyncio.sleep(0.01)
except Exception:
pass
finally:
await self._cleanup_session(session)
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
"""Read input from WebSocket and send to container."""
try:
while session.is_alive() and not session._closed:
message = await websocket.receive()
if message["type"] == "websocket.receive":
if "bytes" in message:
await session.write_input(message["bytes"])
elif "text" in message:
text = message["text"]
if text.startswith("{"):
# Control message (JSON)
import json
try:
ctrl = json.loads(text)
if ctrl.get("type") == "resize":
await session.resize(
ctrl.get("cols", 80),
ctrl.get("rows", 24),
)
except json.JSONDecodeError:
pass
else:
await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect":
break
except Exception:
pass
finally:
await self._cleanup_session(session)
async def _cleanup_session(self, session: TerminalSession) -> None:
"""Clean up a session."""
if session.session_id in self._sessions:
del self._sessions[session.session_id]
await session.close()
async def close_all(self) -> None:
"""Close all active sessions."""
sessions = list(self._sessions.values())
self._sessions.clear()
for session in sessions:
await session.close()
# Global terminal manager instance
terminal_manager = TerminalManager()
+90
View File
@@ -0,0 +1,90 @@
"""Terminal session management for tool instances."""
import asyncio
import uuid
from typing import Any
class TerminalSession:
"""Manages a single terminal session connected to a docker container."""
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
self.process: asyncio.subprocess.Process | None = None
self._closed = False
async def start(self) -> None:
"""Start the docker exec process with a shell."""
self.process = await asyncio.create_subprocess_exec(
"docker",
"exec",
"-i",
self.container_id,
"/bin/sh",
"-c",
"exec bash -l || exec sh -l",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async def read_output(self) -> bytes:
"""Read output from the process."""
if self.process is None or self.process.stdout is None:
return b""
try:
return await self.process.stdout.read(4096)
except (asyncio.CancelledError, BrokenPipeError):
return b""
async def write_input(self, data: bytes) -> None:
"""Write input to the process."""
if self.process is None or self.process.stdin is None or self._closed:
return
try:
self.process.stdin.write(data)
await self.process.stdin.drain()
except (BrokenPipeError, ConnectionResetError):
pass
async def resize(self, cols: int, rows: int) -> None:
"""Resize the terminal."""
if self._closed:
return
try:
proc = await asyncio.create_subprocess_exec(
"docker",
"exec",
self.container_id,
"stty",
"cols",
str(cols),
"rows",
str(rows),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
except Exception:
pass
async def close(self) -> None:
"""Close the session and cleanup."""
if self._closed:
return
self._closed = True
if self.process is not None:
try:
self.process.kill()
await asyncio.wait_for(self.process.wait(), timeout=2.0)
except (asyncio.TimeoutError, ProcessLookupError):
pass
def is_alive(self) -> bool:
"""Check if the session process is still running."""
if self.process is None:
return False
return self.process.returncode is None
+31 -1
View File
@@ -16,7 +16,10 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-simple-code-editor": "^0.14.1",
"tailwindcss": "^3.3.0"
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@@ -6352,6 +6355,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-web-links": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
"integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-web-links instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+4 -1
View File
@@ -19,7 +19,10 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-simple-code-editor": "^0.14.1",
"tailwindcss": "^3.3.0"
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
+7 -1
View File
@@ -32,6 +32,8 @@ import {
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
} from "@phosphor-icons/react";
export type IconName =
@@ -71,7 +73,9 @@ export type IconName =
| "binary"
| "external"
| "play"
| "stop";
| "stop"
| "terminal"
| "arrow-left";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
@@ -111,6 +115,8 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
};
export interface IconProps {
+12
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "./icon";
import type { ToolInstance } from "../api/sessions";
import {
@@ -18,6 +19,7 @@ interface InstanceListProps {
}
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
@@ -154,6 +156,16 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
Open
</a>
)}
{instance.status === "running" && (
<button
className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
type="button"
>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<button
className="secondary-button small"
+158
View File
@@ -0,0 +1,158 @@
import React, { useEffect, useRef, useState } from "react";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css";
interface TerminalProps {
instanceId: string;
onClose?: () => void;
}
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
const terminalRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
"connecting",
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!terminalRef.current) return;
// Initialize terminal
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#1e1e1e",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
selectionBackground: "#264f78",
black: "#000000",
red: "#cd3131",
green: "#0dbc79",
yellow: "#e5e510",
blue: "#2472c8",
magenta: "#bc3fbc",
cyan: "#11a8cd",
white: "#e5e5e5",
brightBlack: "#666666",
brightRed: "#f14c4c",
brightGreen: "#23d18b",
brightYellow: "#f5f543",
brightBlue: "#3b8eea",
brightMagenta: "#d670d6",
brightCyan: "#29b8db",
brightWhite: "#e5e5e5",
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open(terminalRef.current);
fitAddon.fit();
// Build WebSocket URL
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
// Connect WebSocket
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
};
ws.onmessage = (event) => {
if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer);
term.write(data);
});
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status" && msg.status === "connected") {
setStatus("connected");
}
} catch {
term.write(event.data);
}
}
};
ws.onclose = (event) => {
setStatus("disconnected");
if (event.code !== 1000) {
setError(`Connection closed (code: ${event.code})`);
}
};
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
// Handle terminal input
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Handle resize
const handleResize = () => {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
}),
);
}
};
window.addEventListener("resize", handleResize);
// Initial resize
setTimeout(handleResize, 100);
return () => {
window.removeEventListener("resize", handleResize);
ws.close();
term.dispose();
};
}, [instanceId]);
return (
<div className="terminal-wrapper">
<div className="terminal-header">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">{status}</span>
</div>
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div>
{error && <div className="terminal-error">{error}</div>}
<div ref={terminalRef} className="terminal-container" />
</div>
);
};
+38
View File
@@ -0,0 +1,38 @@
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent } from "../components/terminal";
import { Icon } from "../components/icon";
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
return (
<section className="terminal-page">
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
<Icon name="arrow-left" size="sm" />
Back
</button>
<h1>Terminal</h1>
</div>
<TerminalComponent
instanceId={instanceId}
onClose={() => navigate(-1)}
/>
</section>
);
};
+2
View File
@@ -12,6 +12,7 @@ import { ProjectSettingsPage } from "./pages/project-settings";
import { RepoWorkspace } from "./pages/repo-workspace";
import { SSHKeysPage } from "./pages/ssh-keys";
import { SettingsPage } from "./pages/settings";
import { TerminalPage } from "./pages/terminal";
import { ToolTypesPage } from "./pages/tool-types";
export const AppRouter = () => {
@@ -36,6 +37,7 @@ export const AppRouter = () => {
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
+135
View File
@@ -2321,3 +2321,138 @@ a.nav-item,
gap: var(--space-2);
align-items: center;
}
/* ============================================
Terminal Styles
============================================ */
.terminal-page {
display: flex;
flex-direction: column;
height: 100vh;
padding: var(--space-4);
gap: var(--space-4);
}
.terminal-page-header {
display: flex;
align-items: center;
gap: var(--space-4);
flex-shrink: 0;
}
.terminal-page-header h1 {
margin: 0;
}
.terminal-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: #1e1e1e;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
}
.terminal-status {
display: flex;
align-items: center;
gap: var(--space-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.status-dot.connected {
background: #0dbc79;
}
.status-dot.disconnected,
.status-dot.error {
background: #cd3131;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.status-text {
font-size: 0.875rem;
color: #d4d4d4;
text-transform: capitalize;
}
.terminal-close {
padding: var(--space-1) var(--space-3);
background: transparent;
border: 1px solid #666;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.875rem;
}
.terminal-close:hover {
background: #3e3e3e;
}
.terminal-error {
padding: var(--space-3) var(--space-4);
background: #cd3131;
color: white;
font-size: 0.875rem;
flex-shrink: 0;
}
.terminal-container {
flex: 1;
min-height: 0;
padding: var(--space-2);
}
.terminal-container .xterm {
height: 100%;
}
.terminal-container .xterm-viewport {
background: #1e1e1e !important;
}
/* Responsive terminal */
@media (max-width: 767px) {
.terminal-page {
padding: var(--space-2);
gap: var(--space-2);
}
.terminal-page-header h1 {
font-size: 1.25rem;
}
}
+7 -1
View File
@@ -31,6 +31,8 @@ import {
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
} from "@phosphor-icons/react";
export type IconName =
@@ -70,7 +72,9 @@ export type IconName =
| "binary"
| "external"
| "play"
| "stop";
| "stop"
| "terminal"
| "arrow-left";
export const iconRegistry: Record<
IconName,
@@ -124,6 +128,8 @@ export const iconRegistry: Record<
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
};
export const iconCategories = {
@@ -0,0 +1,68 @@
# ADR-001: Session-Based Authentication with httpOnly Cookies
## Status
Accepted
## Context
The application needs an authentication mechanism for the OAuth2 flow with Authentik. We need to decide between:
1. **JWT tokens** stored in localStorage (common SPA pattern)
2. **Session cookies** with httpOnly flag
3. **JWT tokens** in httpOnly cookies
### Constraints
- Frontend and API run on different subdomains in production (e.g., `app.example.com` and `api.example.com`)
- Must support OAuth2 authorization code flow
- Must work with Traefik reverse proxy
- Must be secure against XSS attacks
## Decision
We will use **session-based authentication with HMAC-signed httpOnly cookies**.
### Implementation
1. After OAuth callback, the API creates a session token (HMAC-SHA256 signed)
2. Token is stored in an httpOnly, Secure, SameSite cookie
3. Frontend never sees or stores the token
4. Cookie is sent automatically with every request via `withCredentials: true`
5. Session is stateless - token contains user_id and expiry
## Consequences
### Positive
- **XSS protection**: Token is never exposed to JavaScript
- **Simpler frontend**: No token management, refresh logic, or storage
- **Immediate revocation**: Can invalidate sessions server-side if needed
- **Standards compliant**: Uses well-established cookie security mechanisms
- **Works across subdomains**: Cookie domain can be set to parent domain
### Negative
- **CSRF risk**: Requires CSRF protection for state-changing operations (mitigated by SameSite=Lax/Strict)
- **Less flexible**: Harder to use with non-browser clients (mitigated by API key support if needed)
- **Cookie size**: Session token adds ~100 bytes to every request
### Alternatives Considered
**JWT in localStorage**
- Pros: Simple implementation, works with any client
- Cons: Vulnerable to XSS, requires manual token refresh, complex frontend logic
- Rejected due to XSS vulnerability
**JWT in httpOnly cookies**
- Pros: XSS protection, standard JWT benefits
- Cons: Complex refresh token rotation, no easy revocation, larger token size
- Rejected in favor of simpler session cookies
## Date
2026-05-18
## Participants
- Development Team
@@ -0,0 +1,70 @@
# ADR-002: Async SQLAlchemy with PostgreSQL
## Status
Accepted
## Context
We need to choose an ORM and database for the application. The application will handle concurrent requests and potentially long-running operations (git operations, Docker commands).
### Requirements
- Support concurrent API requests without blocking
- Handle async operations (database queries + subprocess calls)
- Type safety and autocompletion
- Migration support
- Good Python ecosystem support
## Decision
We will use **SQLAlchemy 2.0 with async PostgreSQL** via `asyncpg`.
### Implementation
1. **SQLAlchemy 2.0** with `AsyncSession` and declarative models
2. **PostgreSQL** as the primary database
3. **asyncpg** as the async driver
4. **Alembic** for database migrations
## Consequences
### Positive
- **Non-blocking I/O**: Database queries don't block the event loop
- **Scalability**: Can handle many concurrent connections
- **Type safety**: SQLAlchemy 2.0 has excellent type hint support
- **Ecosystem**: Large community, extensive documentation
- **Flexibility**: Can fall back to sync operations for complex migrations
### Negative
- **Complexity**: Async SQLAlchemy has a steeper learning curve
- **Debugging**: Harder to debug async code
- **Migration limitations**: Some Alembic operations require sync connections
- **Connection pool**: Requires careful configuration
### Alternatives Considered
**Prisma ORM**
- Pros: Modern, type-safe, auto-generated client
- Cons: Less mature Python support, custom query language
- Rejected due to less mature ecosystem
**Tortoise ORM**
- Pros: Built for async, Django-like syntax
- Cons: Smaller community, fewer features
- Rejected in favor of SQLAlchemy's maturity
**Sync SQLAlchemy with threading**
- Pros: Simpler, well-understood
- Cons: Thread overhead, harder to integrate with async code
- Rejected in favor of native async support
## Date
2026-05-17
## Participants
- Development Team
@@ -0,0 +1,2 @@
schema: spec-driven
name: api-documentation
@@ -0,0 +1,116 @@
# API Documentation - Design
## Architecture
```
API Documentation
├── OpenAPI (FastAPI native)
│ ├── /docs (Swagger UI)
│ ├── /redoc (ReDoc)
│ └── /openapi.json
├── Health Endpoints
│ ├── GET /health
│ └── GET /health/db
├── API README
│ └── apps/api/README.md
└── Architecture Decision Records
└── docs/architecture/decisions/
```
## Component Design
### OpenAPI Documentation
FastAPI automatically generates OpenAPI schema from:
- Pydantic models (request/response)
- Endpoint docstrings
- Path operation parameters
- Response status codes
**Enhancements needed:**
- Add descriptions to all Pydantic models
- Add docstrings to all endpoints
- Add response examples where helpful
- Add authentication requirements
### Health Endpoints
**GET /health**
```json
{
"status": "healthy",
"timestamp": "2026-05-19T12:00:00Z",
"version": "0.1.0",
"checks": {
"database": {
"status": "healthy",
"response_time_ms": 5.2
},
"disk": {
"status": "healthy",
"free_gb": 45.2,
"total_gb": 100.0
}
},
"uptime_seconds": 3600
}
```
**GET /health/db**
```json
{
"status": "healthy",
"response_time_ms": 5.2,
"connections": {
"active": 2,
"idle": 3,
"max": 20
}
}
```
### API README
**Sections:**
1. Overview
2. Quick Start
3. Environment Variables
4. Development Setup
5. Running Tests
6. Architecture Overview
7. Common Commands
8. Deployment
### Architecture Decision Records
**Format:**
- Title
- Status (proposed, accepted, deprecated)
- Context
- Decision
- Consequences
- Date
**ADRs to create:**
1. Session-based authentication (vs JWT)
2. SQLAlchemy async with PostgreSQL
3. Docker-based tool instances
## Technical Details
**Libraries:**
- FastAPI (built-in OpenAPI)
- Pydantic v2 (schemas)
- psutil (disk/health metrics)
**Files to modify:**
- `apps/api/src/main.py` - Add health endpoints
- All `apps/api/src/api/*.py` - Add docstrings
- All `apps/api/src/models/*.py` - Add model descriptions
- `apps/api/src/schemas/*.py` - Add response schemas
## Error Handling
- Health endpoints return 200 even if degraded
- Failed checks included in response with "degraded" status
- Never expose sensitive info in health responses
@@ -0,0 +1,54 @@
# API Documentation
## Problem
The API lacks comprehensive documentation:
- No auto-generated OpenAPI/Swagger UI
- Health endpoint is minimal (only checks database)
- Missing endpoint documentation and docstrings
- No API README for developer onboarding
- No Architecture Decision Records (ADRs)
## Solution
Provide comprehensive API documentation:
1. **Auto-generated OpenAPI docs** - FastAPI native `/docs` and `/redoc` endpoints
2. **Enhanced health checks** - `/health` with full system status, `/health/db` for database
3. **Endpoint documentation** - Proper docstrings, Pydantic models, response codes
4. **API README** - Developer onboarding guide
5. **Architecture Decision Records** - Document key architectural choices
## Key Features
### OpenAPI Documentation
- Interactive Swagger UI at `/docs`
- ReDoc at `/redoc`
- All endpoints with schemas and examples
- Authentication documented
### Health Monitoring
- `/health` - Overall system health (database, disk, uptime)
- `/health/db` - Database-specific health check
- JSON responses with status indicators
### Developer Documentation
- `apps/api/README.md` - Setup, env vars, testing
- ADRs in `docs/architecture/decisions/`
- Inline code documentation
## Benefits
- **Developer onboarding** - New devs can understand the API quickly
- **API discoverability** - Interactive docs for testing endpoints
- **Health monitoring** - Operations can monitor system health
- **Knowledge preservation** - ADRs capture why decisions were made
## Success Criteria
- [ ] `/docs` loads Swagger UI with all endpoints
- [ ] `/health` returns comprehensive health data
- [ ] `/health/db` returns database status
- [ ] All endpoints have docstrings
- [ ] API README exists with setup instructions
- [ ] At least one ADR exists
@@ -0,0 +1,125 @@
# API Documentation Specification
## Requirements
### Functional Requirements
1. **OpenAPI Documentation**: Auto-generated at `/docs` and `/redoc`
2. **Health Endpoints**: `/health` and `/health/db` with comprehensive status
3. **Endpoint Documentation**: All endpoints have docstrings and Pydantic models
4. **API README**: `apps/api/README.md` with developer onboarding
5. **Architecture Decision Records**: Document key architectural choices
### Non-Functional Requirements
1. **Performance**: Health checks complete in < 100ms
2. **Security**: Health endpoints don't expose sensitive data
3. **Maintainability**: Documentation stays in sync with code
## API Specification
### GET /health
Returns overall system health status.
**Response 200:**
```json
{
"status": "healthy",
"timestamp": "2026-05-19T12:00:00Z",
"version": "0.1.0",
"checks": {
"database": {
"status": "healthy",
"response_time_ms": 5.2
},
"disk": {
"status": "healthy",
"free_gb": 45.2,
"total_gb": 100.0
}
},
"uptime_seconds": 3600
}
```
### GET /health/db
Returns database-specific health status.
**Response 200:**
```json
{
"status": "healthy",
"response_time_ms": 5.2
}
```
### GET /docs
FastAPI Swagger UI (auto-generated).
### GET /redoc
FastAPI ReDoc (auto-generated).
## Documentation Requirements
### Endpoint Docstrings
Every endpoint must have:
- Description of what it does
- Request/response model descriptions
- Authentication requirements
- Error responses
### Pydantic Models
Every model must have:
- `description` field metadata
- Example values where helpful
- Proper typing
### API README Structure
```markdown
# Headquarter API
## Overview
## Quick Start
## Environment Variables
## Development
## Testing
## Architecture
## Deployment
```
## ADR Template
```markdown
# ADR-XXX: Title
## Status
Accepted
## Context
What is the issue we're facing?
## Decision
What did we decide?
## Consequences
What are the trade-offs?
## Date
2026-05-19
```
## Quality Gates
- `/docs` loads successfully
- `/health` returns 200 with valid JSON
- `/health/db` returns database status
- All endpoints have docstrings
- API README is complete
- At least one ADR exists
@@ -0,0 +1,94 @@
# API Documentation - Tasks
## Phase 1: Health Endpoints
- [x] **Task 1.1**: Enhance `/health` endpoint
- Add timestamp, version, uptime
- Add disk space check
- Add comprehensive checks object
- Create HealthCheck Pydantic models
- [x] **Task 1.2**: Create `/health/db` endpoint
- Database connection check
- Response time measurement
- Connection pool status
## Phase 2: Endpoint Documentation
- [x] **Task 2.1**: Document auth endpoints
- Add docstrings to `src/api/auth.py`
- Add response model descriptions
- Add error responses
- [x] **Task 2.2**: Document projects endpoints
- Add docstrings to `src/api/projects.py`
- Document request/response models
- [x] **Task 2.3**: Document repositories endpoints
- Add docstrings to `src/api/git_repositories.py`
- Document file operations
- [x] **Task 2.4**: Document user endpoints
- Add docstrings to `src/api/users.py`
- Document profile endpoints
- [x] **Task 2.5**: Document tool endpoints
- Add docstrings to `src/api/tool_types.py`
- Add docstrings to `src/api/tool_instances.py`
- [x] **Task 2.6**: Document SSH keys endpoints
- Add docstrings to `src/api/ssh_keys.py`
- [x] **Task 2.7**: Document config endpoints
- Add docstrings to `src/api/user_config.py`
- [x] **Task 2.8**: Document dashboard endpoint
- Add docstrings to `src/api/dashboard.py`
- [x] **Task 2.9**: Document terminal endpoint
- Add docstrings to `src/api/terminal.py`
## Phase 3: Model Documentation
- [x] **Task 3.1**: Document Pydantic models
- Add descriptions to all response models
- Add example values
- Document in `src/schemas/` or inline
## Phase 4: API README
- [x] **Task 4.1**: Create `apps/api/README.md`
- Overview section
- Quick start guide
- Environment variables table
- Development setup
- Testing instructions
- Architecture overview
- Common commands
## Phase 5: Architecture Decision Records
- [x] **Task 5.1**: Create ADR for session auth
- Document why cookies vs JWT
- Trade-offs and risks
- [x] **Task 5.2**: Create ADR for async SQLAlchemy
- Document async pattern choice
- PostgreSQL decision
## Phase 6: Quality Gates
- [x] **Task 6.1**: Verify `/docs` endpoint
- Check all endpoints appear
- Verify schemas are documented
- [x] **Task 6.2**: Verify health endpoints
- Test `/health`
- Test `/health/db`
- [x] **Task 6.3**: Run linting
- ruff check
- mypy
- [x] **Task 6.4**: Run tests
- pytest
@@ -0,0 +1,2 @@
schema: spec-driven
name: tool-terminal
@@ -0,0 +1,139 @@
# Tool Terminal - Design
## Architecture
```
Browser Backend Container
│ │ │
│ WebSocket connect │ │
│─────────────────────────>│ │
│ │ docker exec -it bash │
│ │───────────────────────────>│
│ │ │
│ stdin (keystrokes) │ stdin │
│─────────────────────────>│───────────────────────────>│
│ │ │
│ stdout/stderr │ stdout/stderr │
│<─────────────────────────│<───────────────────────────│
│ │ │
│ resize (cols, rows) │ pty resize │
│─────────────────────────>│───────────────────────────>│
│ │ │
```
## Component Design
### Backend
**TerminalManager:**
- Manages active terminal sessions
- Maps WebSocket connections to container processes
- Handles session lifecycle (create, resize, cleanup)
**WebSocket Endpoint:**
- `GET /ws/tool-instances/{instance_id}/terminal`
- Authenticates user via session cookie
- Establishes bidirectional WebSocket
- Spawns `docker exec -it` with pseudo-TTY
**Docker PTY:**
- Uses `docker exec` with TTY allocation
- Streams stdin/stdout/stderr via subprocess
- Handles resize via `stty` or docker API
### Frontend
**TerminalComponent:**
- Wraps xterm.js terminal
- Manages WebSocket connection
- Handles terminal resize
- Fits container to parent element
**TerminalPage:**
- Full-page terminal view
- Shows instance name in header
- Connection status indicator
- Reconnect on disconnect
## Data Flow
1. User clicks "Terminal" on running instance
2. Frontend opens WebSocket connection
3. Backend verifies ownership and spawns shell
4. Bidirectional streaming begins
5. User types → WebSocket → docker exec stdin
6. Container output → docker exec stdout → WebSocket → xterm.js
7. Resize events forwarded to adjust PTY dimensions
## Session Lifecycle
```
Connect
Authenticate ──> Reject (403)
Spawn Shell
Stream I/O ◄───> Resize
Disconnect
Cleanup Process
```
## Access Control
- WebSocket handshake validates session cookie
- Backend verifies user owns the instance
- Reject connection with 403 if unauthorized
- Close connection if instance stops running
## Technical Details
**Backend Libraries:**
- `asyncio` for WebSocket handling
- `subprocess` with `docker exec -it`
- `fcntl` for PTY resize (Linux)
**Frontend Libraries:**
- `xterm` - Terminal emulator
- `xterm-addon-fit` - Auto-fit to container
- `xterm-addon-web-links` - Clickable URLs
**Docker Commands:**
```bash
# Spawn shell
docker exec -it {container_id} /bin/bash
# Alternative with explicit TTY
docker exec -i {container_id} sh -c 'exec bash'
```
## Error Handling
- Connection refused → Show error message
- Container not running → Disable terminal button
- Shell spawn failed → Show error and close
- Network disconnect → Attempt reconnect
## CSS Integration
```css
.terminal-container {
width: 100%;
height: 100%;
min-height: 400px;
background: #1e1e1e;
border-radius: 8px;
overflow: hidden;
}
.terminal-container .xterm {
padding: 8px;
}
```
@@ -0,0 +1,53 @@
# Tool Terminal
## Problem
Tool instances (code-server, jupyter-notebook, etc.) run in Docker containers but users have no way to access a shell inside those containers. This limits debugging, running ad-hoc commands, and managing the container environment.
## Solution
Provide browser-based terminal access to running tool containers via WebSocket:
1. **WebSocket terminal sessions** - Real-time bidirectional communication
2. **Pseudo-TTY** - Full terminal emulation with proper shell behavior
3. **xterm.js frontend** - Professional terminal UI in the browser
4. **Session management** - Multiple independent terminals per instance
5. **Access control** - Only instance owners can access terminals
## Key Features
### Terminal Access
- Open terminal from any running tool instance
- Full bash/zsh shell inside the container
- Standard terminal features (colors, cursor, history, etc.)
### Real-time I/O
- Instant character-by-character streaming
- Stdout/stderr combined output
- Support for interactive programs (vim, nano, etc.)
### Terminal Resize
- Dynamic column/row adjustment
- Window resize handled gracefully
- Proper text wrapping and scrolling
### Session Management
- Multiple terminals per instance
- Independent sessions with isolation
- Cleanup on disconnect
## Benefits
- **Debug containers** - Inspect running processes, check logs
- **Run commands** - Execute ad-hoc scripts or tools
- **Manage environment** - Install packages, edit config files
- **No SSH needed** - Browser-based access from anywhere
## Success Criteria
- [ ] Terminal opens for any running instance
- [ ] Commands execute and display output in real-time
- [ ] Terminal resizes with browser window
- [ ] Multiple terminals work independently
- [ ] Sessions clean up on disconnect
- [ ] Unauthorized users cannot access terminals
@@ -0,0 +1,165 @@
# Tool Terminal Specification
## Requirements
### Functional Requirements
1. **WebSocket Terminal**: Provide terminal sessions via WebSocket at `/ws/tool-instances/{instance_id}/terminal`
2. **Terminal I/O**: Stream stdin/stdout/stderr bidirectionally in real-time
3. **Terminal Resize**: Support dynamic resize with COLS/ROWS updates
4. **Session Management**: Multiple independent sessions per instance, cleanup on disconnect
5. **Access Control**: Only instance owners can access, reject unauthorized with 403
6. **Shell Spawn**: Spawn `/bin/bash` or `/bin/sh` inside container via `docker exec`
### Non-Functional Requirements
1. **Latency**: Character input to display < 50ms
2. **Concurrent Sessions**: Support 10+ simultaneous terminal sessions
3. **Browser Support**: Chrome, Firefox, Safari, Edge
4. **Container Lifecycle**: Terminal closes when container stops
## API Specification
### WebSocket Endpoint
**URL:** `wss://{api_host}/ws/tool-instances/{instance_id}/terminal`
**Protocol:**
- Connection requires valid session cookie
- Binary frame: terminal output (stdout/stderr)
- Text frame: control messages (JSON)
**Control Messages:**
Request (Client → Server):
```json
{
"type": "resize",
"cols": 80,
"rows": 24
}
```
Response (Server → Client):
```json
{
"type": "status",
"status": "connected"
}
```
### REST Endpoint
**GET /tool-instances/{instance_id}/terminal** (HTML page)
- Returns terminal page for the instance
- Verifies ownership
- Returns 404 if instance not found
- Returns 403 if unauthorized
## Frontend Specification
### TerminalComponent
**Props:**
```typescript
interface TerminalProps {
instanceId: string;
instanceName: string;
onClose?: () => void;
}
```
**Features:**
- xterm.js terminal with custom theme
- WebSocket connection management
- Auto-fit to parent container
- Connection status indicator
- Reconnect on disconnect (3 retries)
### TerminalPage
**Route:** `/instances/:instanceId/terminal`
- Full-page terminal view
- Shows instance name in header
- Back button to instance list
- Connection status badge
## Backend Specification
### TerminalManager
**Methods:**
```python
class TerminalManager:
async def create_session(
self,
instance_id: uuid.UUID,
user_id: uuid.UUID,
websocket: WebSocket
) -> TerminalSession
async def handle_resize(
self,
session_id: str,
cols: int,
rows: int
) -> None
async def close_session(self, session_id: str) -> None
```
### TerminalSession
**Responsibilities:**
- Manage docker exec subprocess
- Stream I/O between WebSocket and PTY
- Handle resize signals
- Cleanup on disconnect
**Docker Command:**
```python
async def spawn_shell(container_id: str) -> subprocess.Process:
proc = await asyncio.create_subprocess_exec(
"docker", "exec", "-i", container_id, "/bin/bash",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
return proc
```
## Dependencies
**Backend:**
- FastAPI WebSocket support
- asyncio subprocess
- docker CLI
**Frontend:**
- `xterm` (v5.x)
- `xterm-addon-fit`
- `xterm-addon-web-links`
## Migration Plan
1. Install xterm.js dependencies
2. Create backend WebSocket endpoint
3. Create TerminalManager and TerminalSession
4. Create frontend TerminalComponent
5. Add terminal route and navigation
6. Test with running instances
## Testing
- Unit: TerminalSession I/O streaming
- Integration: WebSocket connection lifecycle
- Manual: Terminal functionality with real containers
## Quality Gates
- pytest
- mypy
- ruff
- npm run typecheck
- npm run lint
- npm run build
@@ -0,0 +1,96 @@
# Tool Terminal - Tasks
## Phase 1: Backend Setup
- [x] **Task 1.1**: Install backend dependencies
- Add `asyncio-subprocess` handling
- Verify FastAPI WebSocket support
- [x] **Task 1.2**: Create TerminalSession class
- Create `src/services/terminal_session.py`
- Manage docker exec subprocess
- Stream I/O between WebSocket and PTY
- Handle resize signals
- Cleanup on disconnect
- [x] **Task 1.3**: Create TerminalManager
- Create `src/services/terminal_manager.py`
- Manage active sessions dictionary
- Create/close session methods
- Handle resize forwarding
- Session cleanup on disconnect
## Phase 2: WebSocket Endpoint
- [x] **Task 2.1**: Create WebSocket endpoint
- Add `GET /ws/tool-instances/{instance_id}/terminal`
- Authenticate via session cookie
- Verify instance ownership
- Establish bidirectional WebSocket
- Handle connection lifecycle
- [x] **Task 2.2**: Add WebSocket to main app
- Register WebSocket router in `main.py`
- Configure WebSocket middleware
- Handle CORS for WebSocket connections
## Phase 3: Frontend Dependencies
- [x] **Task 3.1**: Install xterm.js
- `npm install xterm xterm-addon-fit xterm-addon-web-links`
- Add to package.json
## Phase 4: Frontend Components
- [x] **Task 4.1**: Create TerminalComponent
- Create `components/terminal.tsx`
- Initialize xterm.js terminal
- Manage WebSocket connection
- Handle terminal resize with xterm-addon-fit
- Connection status indicator
- Auto-reconnect on disconnect
- [x] **Task 4.2**: Create TerminalPage
- Create `pages/terminal.tsx`
- Full-page terminal layout
- Instance name in header
- Back button
- Connection status badge
## Phase 5: Integration
- [x] **Task 5.1**: Add terminal route
- Add `/instances/:instanceId/terminal` to router
- Link from InstanceList component
- Show terminal button for running instances
- [x] **Task 5.2**: Add terminal button to InstanceList
- Add terminal icon button to running instances
- Disable for stopped instances
- Navigate to terminal page
## Phase 6: Styling
- [x] **Task 6.1**: Add terminal CSS
- Dark terminal theme matching app
- Full-height container
- Proper padding and borders
- Connection status colors
## Phase 7: Quality Gates
- [x] **Task 7.1**: Backend tests
- ruff check
- mypy
- pytest
- [x] **Task 7.2**: Frontend tests
- typecheck
- lint
- build
- [x] **Task 7.3**: Manual testing
- Open terminal for running instance
- Execute commands
- Test resize
- Verify access control