Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c94583307 | |||
| 649496b762 | |||
| d13e16f5e1 | |||
| d5f9df33b7 | |||
| 468e0eacda | |||
| 99097090e6 | |||
| 2a9e57ad0d | |||
| e4c5e7f2db | |||
| 70957e462a | |||
| 6f35eb77ae | |||
| 684a11610a | |||
| 575e5837ea | |||
| 996ea73bbf | |||
| d0e5feeaa5 | |||
| 1f784b552d | |||
| 10cbe63095 | |||
| 4547105f3b | |||
| dacf105200 | |||
| 2525c58471 | |||
| 8dd350286e | |||
| 5af4de0d7e | |||
| ebfe991a15 | |||
| cc1507a33e | |||
| ae377baa74 | |||
| a0d4e76662 | |||
| b61c2256a8 | |||
| 7e6588a99d | |||
| 56b54e269a | |||
| 6e597b9e21 | |||
| a2c3d6877e | |||
| a200955ef2 | |||
| 76266fc3d0 | |||
| 9d54a2542b | |||
| 520078fd64 | |||
| 55c4dc1281 | |||
| 74ac7b00bb | |||
| ecc0acc8dd | |||
| fba01ddfb2 | |||
| 35a251a0b4 | |||
| b10eadf64b | |||
| 50fcf5c077 | |||
| 19db7db8d0 | |||
| 189f29ee41 | |||
| f2c3264be6 | |||
| f07a632c86 | |||
| 0b68efb6e0 | |||
| 54ec87a836 | |||
| 3d64ec9061 | |||
| ac6bd3304d | |||
| 6ec35988cc | |||
| e985f0122e | |||
| 2bd778117f | |||
| 23ae12e69c | |||
| 65d4fad3c5 | |||
| e6f64c39f3 | |||
| e7c42c17b9 | |||
| f7be50952a | |||
| 402e662c0c | |||
| 7450dd0ce5 | |||
| 7a88639250 | |||
| 1d33735e2f | |||
| d11b43b69f | |||
| 294d02f9fb | |||
| a10029f36c | |||
| 6a2ebae8c9 | |||
| 1a922d4171 | |||
| d906c12aa9 | |||
| 4906ce0cd9 | |||
| e7804c0f58 | |||
| b40eb3e88c |
@@ -87,6 +87,31 @@ Do not claim completion without verification evidence.
|
|||||||
|
|
||||||
## Git workflow
|
## Git workflow
|
||||||
|
|
||||||
|
### Branching strategy
|
||||||
|
|
||||||
|
For every spec change or new functionality:
|
||||||
|
|
||||||
|
1. Create a new branch from `dev` with a proper prefix:
|
||||||
|
- `feat/` for new features (e.g., `feat/tool-workshop`)
|
||||||
|
- `fix/` for bug fixes (e.g., `fix/terminal-tty`)
|
||||||
|
- `refactor/` for refactors (e.g., `refactor/api-cleanup`)
|
||||||
|
- `docs/` for documentation (e.g., `docs/api-guide`)
|
||||||
|
- `chore/` for maintenance (e.g., `chore/update-deps`)
|
||||||
|
2. Branch name should reference the OpenSpec change name when applicable.
|
||||||
|
3. Do not commit directly to `main` or `dev`.
|
||||||
|
|
||||||
|
### Completion and merge
|
||||||
|
|
||||||
|
When implementation is complete and verified:
|
||||||
|
|
||||||
|
1. Ensure all tests pass and quality gates are met.
|
||||||
|
2. Stage all changes with `git add -A`.
|
||||||
|
3. Create a commit with a proper conventional commit message (see below).
|
||||||
|
4. Switch to `dev`: `git checkout dev`.
|
||||||
|
5. Merge the feature branch: `git merge --no-ff <branch-name>`.
|
||||||
|
6. Push to remote: `git push origin dev`.
|
||||||
|
7. Delete the local feature branch if desired: `git branch -d <branch-name>`.
|
||||||
|
|
||||||
### Auto-commit on spec completion
|
### Auto-commit on spec completion
|
||||||
|
|
||||||
When an OpenSpec change is fully implemented and all tasks are complete:
|
When an OpenSpec change is fully implemented and all tasks are complete:
|
||||||
|
|||||||
+8
-4
@@ -38,11 +38,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" > /etc/apt/sources.list.d/docker.list \
|
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" > /etc/apt/sources.list.d/docker.list \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||||
|
&& curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
|
||||||
|
&& chmod +x /usr/local/bin/cloudflared \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy dependencies from builder
|
# Copy dependencies from builder
|
||||||
COPY --from=builder /root/.local /home/appuser/.local
|
COPY --from=builder /root/.local /root/.local
|
||||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
ENV PATH=/root/.local/bin:$PATH
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY --chown=appuser:appgroup . .
|
COPY --chown=appuser:appgroup . .
|
||||||
@@ -54,8 +56,10 @@ RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
|||||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||||
RUN chmod +x /usr/local/bin/wait-for-db.sh
|
RUN chmod +x /usr/local/bin/wait-for-db.sh
|
||||||
|
|
||||||
# Switch to non-root user
|
# NOTE: Running as root to access Docker socket for managing tool instances
|
||||||
USER appuser
|
# This is required because Docker socket permissions require root or docker group membership
|
||||||
|
# which doesn't work well across container boundaries.
|
||||||
|
# Consider using Docker-in-Docker or rootless Docker for production hardening.
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add tunnel fields to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0011_tool_instance_tunnel_fields
|
||||||
|
Revises: 0010_tool_type_default_port
|
||||||
|
Create Date: 2026-05-20 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0011_tool_instance_tunnel_fields"
|
||||||
|
down_revision: Union[str, None] = "0010_tool_type_default_port"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("public_url", sa.String(1024), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("tunnel_id", sa.String(255), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "tunnel_id")
|
||||||
|
op.drop_column("tool_instances", "public_url")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""make default_port non-nullable and set values
|
||||||
|
|
||||||
|
Revision ID: 0012_default_port_req
|
||||||
|
Revises: 0011_tool_instance_tunnel_fields
|
||||||
|
Create Date: 2026-05-20 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0012_default_port_req"
|
||||||
|
down_revision: Union[str, None] = "0011_tool_instance_tunnel_fields"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Set default_port for existing built-in tool types
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET default_port = CASE
|
||||||
|
WHEN name = 'code-server' THEN 8443
|
||||||
|
WHEN name = 'jupyter-notebook' THEN 8888
|
||||||
|
WHEN name = 'opencode' THEN 3000
|
||||||
|
ELSE 8080
|
||||||
|
END
|
||||||
|
WHERE default_port IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Make default_port non-nullable
|
||||||
|
op.alter_column(
|
||||||
|
"tool_types",
|
||||||
|
"default_port",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"tool_types",
|
||||||
|
"default_port",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""add_tool_config_fields
|
||||||
|
|
||||||
|
Revision ID: 398082499c30
|
||||||
|
Revises: af8512103d67
|
||||||
|
Create Date: 2026-05-22 18:38:20.166184
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '398082499c30'
|
||||||
|
down_revision = 'af8512103d67'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add new columns to tool_configs
|
||||||
|
op.add_column('tool_configs', sa.Column('port_override', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('start_command', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('working_directory', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_configs', sa.Column('environment_variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||||
|
op.add_column('tool_configs', sa.Column('volumes', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='[]'))
|
||||||
|
|
||||||
|
# Add CHECK constraint for port range
|
||||||
|
op.create_check_constraint('chk_port_range', 'tool_configs', sa.text('port_override IS NULL OR (port_override >= 1 AND port_override <= 65535)'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_port_range', 'tool_configs', type_='check')
|
||||||
|
|
||||||
|
# Drop columns
|
||||||
|
op.drop_column('tool_configs', 'port_override')
|
||||||
|
op.drop_column('tool_configs', 'start_command')
|
||||||
|
op.drop_column('tool_configs', 'working_directory')
|
||||||
|
op.drop_column('tool_configs', 'environment_variables')
|
||||||
|
op.drop_column('tool_configs', 'volumes')
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""create_config_folders_table
|
||||||
|
|
||||||
|
Revision ID: 8ed7dd80973d
|
||||||
|
Revises: 398082499c30
|
||||||
|
Create Date: 2026-05-22 18:38:22.133696
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '8ed7dd80973d'
|
||||||
|
down_revision = '398082499c30'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'config_folders',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||||
|
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('mount_path', sa.String(1024), nullable=False),
|
||||||
|
sa.Column('files', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
|
||||||
|
sa.Column('project_overrides', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
|
||||||
|
sa.UniqueConstraint('user_id', 'name', name='uq_config_folders_user_name')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add index on user_id for filtering
|
||||||
|
op.create_index('idx_config_folders_user', 'config_folders', ['user_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop index
|
||||||
|
op.drop_index('idx_config_folders_user', table_name='config_folders')
|
||||||
|
|
||||||
|
# Drop table
|
||||||
|
op.drop_table('config_folders')
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""add_tool_type_fields
|
||||||
|
|
||||||
|
Revision ID: af8512103d67
|
||||||
|
Revises: 0012_default_port_req
|
||||||
|
Create Date: 2026-05-22 18:37:56.607240
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'af8512103d67'
|
||||||
|
down_revision = '0012_default_port_req'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add new columns to tool_types
|
||||||
|
op.add_column('tool_types', sa.Column('definition_type', sa.String(20), nullable=False, server_default='compose'))
|
||||||
|
op.add_column('tool_types', sa.Column('dockerfile_template', sa.Text(), nullable=True))
|
||||||
|
op.add_column('tool_types', sa.Column('build_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||||
|
op.add_column('tool_types', sa.Column('readiness_probe', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||||
|
|
||||||
|
# Add CHECK constraint for definition_type
|
||||||
|
op.create_check_constraint('chk_definition_type', 'tool_types', sa.text("definition_type IN ('compose', 'dockerfile')"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_definition_type', 'tool_types', type_='check')
|
||||||
|
|
||||||
|
# Drop columns
|
||||||
|
op.drop_column('tool_types', 'definition_type')
|
||||||
|
op.drop_column('tool_types', 'dockerfile_template')
|
||||||
|
op.drop_column('tool_types', 'build_context')
|
||||||
|
op.drop_column('tool_types', 'readiness_probe')
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""Config folder API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.config_folder import ConfigFolder
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
||||||
|
|
||||||
|
MAX_FOLDER_SIZE_MB = 10
|
||||||
|
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderCreate(BaseModel):
|
||||||
|
name: str = Field(description="Folder name (unique per user)")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
mount_path: str = Field(description="Default mount path in container")
|
||||||
|
files: dict = Field(default_factory=dict, description="Files as {path: content}")
|
||||||
|
|
||||||
|
@field_validator("mount_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_mount_path(cls, v: str) -> str:
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount path must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict) -> dict:
|
||||||
|
total_size = 0
|
||||||
|
for path, content in v.items():
|
||||||
|
# Check for path traversal
|
||||||
|
if ".." in path or path.startswith("/"):
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
total_size += len(content.encode("utf-8"))
|
||||||
|
|
||||||
|
if total_size > MAX_FOLDER_SIZE_BYTES:
|
||||||
|
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, description="Folder name")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
mount_path: str | None = Field(default=None, description="Default mount path")
|
||||||
|
files: dict | None = Field(default=None, description="Files as {path: content}")
|
||||||
|
is_active: bool | None = Field(default=None, description="Active/inactive toggle")
|
||||||
|
|
||||||
|
@field_validator("mount_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount path must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict | None) -> dict | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
for path, content in v.items():
|
||||||
|
# Check for path traversal
|
||||||
|
if ".." in path or path.startswith("/"):
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
total_size += len(content.encode("utf-8"))
|
||||||
|
|
||||||
|
if total_size > MAX_FOLDER_SIZE_BYTES:
|
||||||
|
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectOverrideCreate(BaseModel):
|
||||||
|
mount_path: str | None = Field(default=None, description="Override mount path")
|
||||||
|
files: dict = Field(default_factory=dict, description="Override files")
|
||||||
|
|
||||||
|
@field_validator("mount_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount path must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
mount_path: str
|
||||||
|
files: dict
|
||||||
|
project_overrides: dict | None
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
||||||
|
async def list_config_folders(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List config folders for the current user."""
|
||||||
|
query = select(ConfigFolder).where(ConfigFolder.user_id == user_id)
|
||||||
|
result = await session.execute(query)
|
||||||
|
folders = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"id": str(f.id),
|
||||||
|
"user_id": str(f.user_id),
|
||||||
|
"name": f.name,
|
||||||
|
"description": f.description,
|
||||||
|
"mount_path": f.mount_path,
|
||||||
|
"files": f.files,
|
||||||
|
"project_overrides": f.project_overrides,
|
||||||
|
"is_active": f.is_active,
|
||||||
|
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||||
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||||
|
}
|
||||||
|
for f in folders
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_config_folder(
|
||||||
|
data: ConfigFolderCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a config folder."""
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ConfigFolder).where(
|
||||||
|
ConfigFolder.user_id == user_id,
|
||||||
|
ConfigFolder.name == data.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"config folder with name '{data.name}' already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = ConfigFolder(
|
||||||
|
user_id=user_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
mount_path=data.mount_path,
|
||||||
|
files=data.files,
|
||||||
|
)
|
||||||
|
session.add(folder)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"user_id": str(folder.user_id),
|
||||||
|
"name": folder.name,
|
||||||
|
"description": folder.description,
|
||||||
|
"mount_path": folder.mount_path,
|
||||||
|
"files": folder.files,
|
||||||
|
"project_overrides": folder.project_overrides,
|
||||||
|
"is_active": folder.is_active,
|
||||||
|
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||||
|
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.")
|
||||||
|
async def update_config_folder(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
data: ConfigFolderUpdate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update a config folder."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
if data.name is not None:
|
||||||
|
folder.name = data.name
|
||||||
|
if data.description is not None:
|
||||||
|
folder.description = data.description
|
||||||
|
if data.mount_path is not None:
|
||||||
|
folder.mount_path = data.mount_path
|
||||||
|
if data.files is not None:
|
||||||
|
folder.files = data.files
|
||||||
|
if data.is_active is not None:
|
||||||
|
folder.is_active = data.is_active
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"user_id": str(folder.user_id),
|
||||||
|
"name": folder.name,
|
||||||
|
"description": folder.description,
|
||||||
|
"mount_path": folder.mount_path,
|
||||||
|
"files": folder.files,
|
||||||
|
"project_overrides": folder.project_overrides,
|
||||||
|
"is_active": folder.is_active,
|
||||||
|
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||||
|
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_config_folder(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Delete a config folder."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
await session.delete(folder)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectOverrideWithId(ProjectOverrideCreate):
|
||||||
|
project_id: uuid.UUID = Field(description="Project ID for the override")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
|
||||||
|
async def get_config_folder(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get a config folder by ID."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"user_id": str(folder.user_id),
|
||||||
|
"name": folder.name,
|
||||||
|
"description": folder.description,
|
||||||
|
"mount_path": folder.mount_path,
|
||||||
|
"files": folder.files,
|
||||||
|
"project_overrides": folder.project_overrides,
|
||||||
|
"is_active": folder.is_active,
|
||||||
|
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||||
|
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
|
||||||
|
async def add_project_override(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
data: ProjectOverrideWithId,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Add a project override to a config folder."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
# Initialize project_overrides if None
|
||||||
|
if folder.project_overrides is None:
|
||||||
|
folder.project_overrides = {}
|
||||||
|
|
||||||
|
# Add/update override
|
||||||
|
override_data = {}
|
||||||
|
if data.mount_path is not None:
|
||||||
|
override_data["mount_path"] = data.mount_path
|
||||||
|
if data.files is not None:
|
||||||
|
override_data["files"] = data.files
|
||||||
|
|
||||||
|
# Use a copy to trigger SQLAlchemy change detection on JSONB
|
||||||
|
current_overrides = dict(folder.project_overrides or {})
|
||||||
|
current_overrides[str(data.project_id)] = override_data
|
||||||
|
folder.project_overrides = current_overrides
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"project_overrides": folder.project_overrides,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.")
|
||||||
|
async def update_project_override(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
data: ProjectOverrideCreate,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update a project override."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
# Initialize project_overrides if None
|
||||||
|
if folder.project_overrides is None:
|
||||||
|
folder.project_overrides = {}
|
||||||
|
|
||||||
|
# Update override
|
||||||
|
current_overrides = dict(folder.project_overrides or {})
|
||||||
|
override_data = current_overrides.get(str(project_id), {})
|
||||||
|
if data.mount_path is not None:
|
||||||
|
override_data["mount_path"] = data.mount_path
|
||||||
|
if data.files is not None:
|
||||||
|
override_data["files"] = data.files
|
||||||
|
|
||||||
|
current_overrides[str(project_id)] = override_data
|
||||||
|
folder.project_overrides = current_overrides
|
||||||
|
|
||||||
|
# Mark the field as modified to ensure SQLAlchemy detects the change
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
flag_modified(folder, "project_overrides")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"project_overrides": folder.project_overrides,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.")
|
||||||
|
async def remove_project_override(
|
||||||
|
folder_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Remove a project override."""
|
||||||
|
folder = await session.get(ConfigFolder, folder_id)
|
||||||
|
if folder is None or folder.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||||
|
|
||||||
|
# Remove override if exists
|
||||||
|
current_overrides = dict(folder.project_overrides or {})
|
||||||
|
if str(project_id) in current_overrides:
|
||||||
|
del current_overrides[str(project_id)]
|
||||||
|
folder.project_overrides = current_overrides
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(folder)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(folder.id),
|
||||||
|
"project_overrides": folder.project_overrides or {},
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -36,6 +37,8 @@ from src.utils.git_url_parser import parse_git_url
|
|||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
"""Fetch a user by ID or raise 401 if not found."""
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
@@ -86,6 +89,88 @@ def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
|||||||
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_provider_clone_url(owner: str, repo: str) -> str:
|
||||||
|
"""Build the SSH clone URL for the fixed git provider."""
|
||||||
|
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_remote_repository(remote_url: str) -> None:
|
||||||
|
"""Verify a remote repository is reachable before cloning."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", remote_url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="repository not found or inaccessible",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "clone", remote_url, repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to clone repository: {result.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_working_repository(repo_path: str) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "init", "-b", "main", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
fallback = subprocess.run(
|
||||||
|
["git", "init", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if fallback.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to initialize repository: {fallback.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
ref_result = subprocess.run(
|
||||||
|
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if ref_result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"failed to set initial branch: {ref_result.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GitRepositoryCreate(BaseModel):
|
class GitRepositoryCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
remote_url: str | None = None
|
remote_url: str | None = None
|
||||||
@@ -214,7 +299,7 @@ async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
|
|||||||
response_model=GitRepositoryResponse,
|
response_model=GitRepositoryResponse,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Create a repository",
|
summary="Create a repository",
|
||||||
description="Create a new git repository in a project. Can clone from remote or initialize bare.",
|
description="Create a new git repository in a project. Can clone from remote or initialize a working repository.",
|
||||||
)
|
)
|
||||||
async def create_repository(
|
async def create_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
@@ -264,47 +349,25 @@ async def create_repository(
|
|||||||
if parse_result["base_url"]:
|
if parse_result["base_url"]:
|
||||||
remote_url = parse_result["base_url"]
|
remote_url = parse_result["base_url"]
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
|
_preflight_remote_repository(remote_url)
|
||||||
|
|
||||||
repo_path = _get_repo_path(user_id, project_id, data.name)
|
repo_path = _get_repo_path(user_id, project_id, data.name)
|
||||||
|
|
||||||
# Ensure parent directory exists
|
# Ensure parent directory exists
|
||||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
# Clone as mirror
|
_clone_working_repository(remote_url, repo_path)
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "clone", "--mirror", remote_url, repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"failed to clone repository: {result.stderr}",
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
else:
|
else:
|
||||||
# Init bare repo
|
_init_working_repository(repo_path)
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "init", "--bare", repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
|
|
||||||
repo = GitRepository(
|
repo = GitRepository(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
path=repo_path,
|
path=repo_path,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
is_mirror=bool(remote_url),
|
is_mirror=False,
|
||||||
remote_url=remote_url,
|
remote_url=remote_url,
|
||||||
)
|
)
|
||||||
session.add(repo)
|
session.add(repo)
|
||||||
@@ -494,6 +557,14 @@ async def list_repository_files(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list files for repo %s (path=%s, branch=%s): %s",
|
||||||
|
repo_id,
|
||||||
|
path,
|
||||||
|
branch,
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@@ -599,6 +670,12 @@ async def get_repository_branches(
|
|||||||
default_branch=default_branch,
|
default_branch=default_branch,
|
||||||
)
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to list branches for repo %s: %s",
|
||||||
|
repo_id,
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""WebSocket terminal endpoint for tool instances."""
|
"""WebSocket terminal endpoint for tool instances."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||||
@@ -10,6 +12,7 @@ from src.models.tool_instance import ToolInstance
|
|||||||
from src.services.terminal_manager import terminal_manager
|
from src.services.terminal_manager import terminal_manager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket(
|
||||||
@@ -32,35 +35,42 @@ async def terminal_websocket(
|
|||||||
Returns:
|
Returns:
|
||||||
None. Communicates via WebSocket messages.
|
None. Communicates via WebSocket messages.
|
||||||
"""
|
"""
|
||||||
|
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse instance_id
|
# Parse instance_id
|
||||||
instance_uuid = uuid.UUID(instance_id)
|
instance_uuid = uuid.UUID(instance_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
logger.error("Invalid instance ID: %s", instance_id)
|
||||||
await websocket.close(code=4001, reason="Invalid instance ID")
|
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Authenticate user from session cookie
|
# Authenticate user from session cookie
|
||||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
|
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
|
||||||
await websocket.close(code=4003, reason="Unauthorized")
|
await websocket.close(code=4003, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get instance and verify ownership
|
# Get instance and verify ownership
|
||||||
instance = await db_session.get(ToolInstance, instance_uuid)
|
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
|
logger.warning("Instance %s not found", instance_id)
|
||||||
await websocket.close(code=4004, reason="Instance not found")
|
await websocket.close(code=4004, reason="Instance not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
if instance.owner_id != user_id:
|
if instance.owner_id != user_id:
|
||||||
|
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
|
||||||
await websocket.close(code=4003, reason="Forbidden")
|
await websocket.close(code=4003, reason="Forbidden")
|
||||||
return
|
return
|
||||||
|
|
||||||
if instance.status != "running" or not instance.container_id:
|
if instance.status != "running" or not instance.container_id:
|
||||||
|
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
|
||||||
await websocket.close(code=4004, reason="Instance not running")
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id)
|
||||||
# Create terminal session
|
# Create terminal session
|
||||||
try:
|
try:
|
||||||
session = await terminal_manager.create_session(
|
session = await terminal_manager.create_session(
|
||||||
@@ -68,22 +78,18 @@ async def terminal_websocket(
|
|||||||
instance.container_id,
|
instance.container_id,
|
||||||
websocket,
|
websocket,
|
||||||
)
|
)
|
||||||
|
logger.info("Terminal session created successfully for instance %s", instance_id)
|
||||||
|
|
||||||
# Send connected status
|
# Send connected status
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
|
||||||
# Keep connection alive until closed
|
# Keep connection alive until session ends
|
||||||
while True:
|
# The terminal_manager handles I/O loops, we just wait here
|
||||||
try:
|
while session.is_alive() and not session._closed:
|
||||||
message = await websocket.receive()
|
await asyncio.sleep(0.5)
|
||||||
if message["type"] == "websocket.disconnect":
|
|
||||||
break
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
break
|
|
||||||
except RuntimeError:
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||||
await websocket.close(code=4000, reason=f"Error: {exc}")
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||||
finally:
|
finally:
|
||||||
# Cleanup will be handled by the session manager
|
# Cleanup will be handled by the session manager
|
||||||
@@ -103,17 +109,16 @@ async def _get_user_from_websocket(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's UUID if authenticated, None otherwise.
|
The user's UUID if authenticated, None otherwise.
|
||||||
"""
|
"""
|
||||||
from src.auth.session import verify_session_token
|
from src.auth.session import decode_session_cookie
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
session_cookie = websocket.cookies.get("session")
|
session_cookie = websocket.cookies.get("session")
|
||||||
if not session_cookie:
|
if not session_cookie:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user_id = verify_session_token(session_cookie)
|
settings = Settings()
|
||||||
if not user_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return uuid.UUID(user_id)
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
except ValueError:
|
return uuid.UUID(str(payload["user_id"]))
|
||||||
|
except (ValueError, KeyError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -24,6 +24,91 @@ class ToolConfigCreate(BaseModel):
|
|||||||
value: str = Field(description="Config value")
|
value: str = Field(description="Config value")
|
||||||
config_type: str = Field(default="env", description="Type: env or file")
|
config_type: str = Field(default="env", description="Type: env or file")
|
||||||
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
||||||
|
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
||||||
|
start_command: str | None = Field(default=None, description="Override container start command")
|
||||||
|
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
||||||
|
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
||||||
|
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
||||||
|
|
||||||
|
@field_validator("port_override")
|
||||||
|
@classmethod
|
||||||
|
def validate_port(cls, v: int | None) -> int | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v < 1 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("environment_variables")
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("environment_variables must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("volumes")
|
||||||
|
@classmethod
|
||||||
|
def validate_volumes(cls, v: list | None) -> list | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("volumes must be a JSON array")
|
||||||
|
for i, vol in enumerate(v):
|
||||||
|
if not isinstance(vol, dict):
|
||||||
|
raise ValueError(f"Volume at index {i} must be an object")
|
||||||
|
if "source" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||||
|
if "target" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfigUpdate(BaseModel):
|
||||||
|
key: str | None = Field(default=None, description="Config key name")
|
||||||
|
value: str | None = Field(default=None, description="Config value")
|
||||||
|
config_type: str | None = Field(default=None, description="Type: env or file")
|
||||||
|
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
||||||
|
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
||||||
|
start_command: str | None = Field(default=None, description="Override container start command")
|
||||||
|
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
||||||
|
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
||||||
|
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
||||||
|
|
||||||
|
@field_validator("port_override")
|
||||||
|
@classmethod
|
||||||
|
def validate_port(cls, v: int | None) -> int | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v < 1 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("environment_variables")
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("environment_variables must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("volumes")
|
||||||
|
@classmethod
|
||||||
|
def validate_volumes(cls, v: list | None) -> list | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("volumes must be a JSON array")
|
||||||
|
for i, vol in enumerate(v):
|
||||||
|
if not isinstance(vol, dict):
|
||||||
|
raise ValueError(f"Volume at index {i} must be an object")
|
||||||
|
if "source" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||||
|
if "target" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigResponse(BaseModel):
|
class ToolConfigResponse(BaseModel):
|
||||||
@@ -34,6 +119,11 @@ class ToolConfigResponse(BaseModel):
|
|||||||
value: str
|
value: str
|
||||||
config_type: str
|
config_type: str
|
||||||
file_path: str | None
|
file_path: str | None
|
||||||
|
port_override: int | None
|
||||||
|
start_command: str | None
|
||||||
|
working_directory: str | None
|
||||||
|
environment_variables: dict | None
|
||||||
|
volumes: list[dict] | None
|
||||||
|
|
||||||
|
|
||||||
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
||||||
@@ -42,7 +132,7 @@ async def list_configs(
|
|||||||
project_id: str | None = None,
|
project_id: str | None = None,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> list:
|
||||||
"""List tool configs for the current user."""
|
"""List tool configs for the current user."""
|
||||||
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
|
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
|
||||||
|
|
||||||
@@ -57,8 +147,7 @@ async def list_configs(
|
|||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
configs = result.scalars().all()
|
configs = result.scalars().all()
|
||||||
|
|
||||||
return {
|
return [
|
||||||
"configs": [
|
|
||||||
{
|
{
|
||||||
"id": str(c.id),
|
"id": str(c.id),
|
||||||
"tool_type_id": str(c.tool_type_id),
|
"tool_type_id": str(c.tool_type_id),
|
||||||
@@ -67,13 +156,17 @@ async def list_configs(
|
|||||||
"value": c.value,
|
"value": c.value,
|
||||||
"config_type": c.config_type,
|
"config_type": c.config_type,
|
||||||
"file_path": c.file_path,
|
"file_path": c.file_path,
|
||||||
|
"port_override": c.port_override,
|
||||||
|
"start_command": c.start_command,
|
||||||
|
"working_directory": c.working_directory,
|
||||||
|
"environment_variables": c.environment_variables,
|
||||||
|
"volumes": c.volumes,
|
||||||
}
|
}
|
||||||
for c in configs
|
for c in configs
|
||||||
]
|
]
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", summary="Create tool config", description="Create a new tool config.")
|
@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_config(
|
async def create_config(
|
||||||
data: ToolConfigCreate,
|
data: ToolConfigCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
@@ -111,6 +204,11 @@ async def create_config(
|
|||||||
value=data.value,
|
value=data.value,
|
||||||
config_type=data.config_type,
|
config_type=data.config_type,
|
||||||
file_path=data.file_path,
|
file_path=data.file_path,
|
||||||
|
port_override=data.port_override,
|
||||||
|
start_command=data.start_command,
|
||||||
|
working_directory=data.working_directory,
|
||||||
|
environment_variables=data.environment_variables,
|
||||||
|
volumes=data.volumes,
|
||||||
)
|
)
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -124,13 +222,18 @@ async def create_config(
|
|||||||
"value": config.value,
|
"value": config.value,
|
||||||
"config_type": config.config_type,
|
"config_type": config.config_type,
|
||||||
"file_path": config.file_path,
|
"file_path": config.file_path,
|
||||||
|
"port_override": config.port_override,
|
||||||
|
"start_command": config.start_command,
|
||||||
|
"working_directory": config.working_directory,
|
||||||
|
"environment_variables": config.environment_variables,
|
||||||
|
"volumes": config.volumes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
|
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
|
||||||
async def update_config(
|
async def update_config(
|
||||||
config_id: uuid.UUID,
|
config_id: uuid.UUID,
|
||||||
data: ToolConfigCreate,
|
data: ToolConfigUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -139,10 +242,24 @@ async def update_config(
|
|||||||
if config is None or config.user_id != user_id:
|
if config is None or config.user_id != user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
||||||
|
|
||||||
|
if data.key is not None:
|
||||||
config.key = data.key
|
config.key = data.key
|
||||||
|
if data.value is not None:
|
||||||
config.value = data.value
|
config.value = data.value
|
||||||
|
if data.config_type is not None:
|
||||||
config.config_type = data.config_type
|
config.config_type = data.config_type
|
||||||
|
if data.file_path is not None:
|
||||||
config.file_path = data.file_path
|
config.file_path = data.file_path
|
||||||
|
if data.port_override is not None:
|
||||||
|
config.port_override = data.port_override
|
||||||
|
if data.start_command is not None:
|
||||||
|
config.start_command = data.start_command
|
||||||
|
if data.working_directory is not None:
|
||||||
|
config.working_directory = data.working_directory
|
||||||
|
if data.environment_variables is not None:
|
||||||
|
config.environment_variables = data.environment_variables
|
||||||
|
if data.volumes is not None:
|
||||||
|
config.volumes = data.volumes
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
@@ -155,6 +272,38 @@ async def update_config(
|
|||||||
"value": config.value,
|
"value": config.value,
|
||||||
"config_type": config.config_type,
|
"config_type": config.config_type,
|
||||||
"file_path": config.file_path,
|
"file_path": config.file_path,
|
||||||
|
"port_override": config.port_override,
|
||||||
|
"start_command": config.start_command,
|
||||||
|
"working_directory": config.working_directory,
|
||||||
|
"environment_variables": config.environment_variables,
|
||||||
|
"volumes": config.volumes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.")
|
||||||
|
async def get_default_configs(
|
||||||
|
tool_type_id: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get suggested default configs for a tool type."""
|
||||||
|
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
|
# Return suggested defaults based on required_variables
|
||||||
|
defaults = []
|
||||||
|
for var in tool_type.required_variables:
|
||||||
|
defaults.append({
|
||||||
|
"key": var,
|
||||||
|
"value": "",
|
||||||
|
"config_type": "env",
|
||||||
|
"description": f"Required variable: {var}",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tool_type_id": tool_type_id,
|
||||||
|
"suggested_configs": defaults,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,19 +22,29 @@ from src.models.tool_config import ToolConfig
|
|||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.models.config_folder import ConfigFolder
|
||||||
from src.services.docker import (
|
from src.services.docker import (
|
||||||
|
check_tunnel_health,
|
||||||
|
connect_container_to_network,
|
||||||
ensure_instance_directory,
|
ensure_instance_directory,
|
||||||
execute_compose_command,
|
execute_compose_command,
|
||||||
find_free_port,
|
find_free_port,
|
||||||
get_container_id,
|
get_container_id,
|
||||||
get_container_name,
|
|
||||||
get_container_logs,
|
get_container_logs,
|
||||||
|
get_container_name,
|
||||||
get_container_status,
|
get_container_status,
|
||||||
|
recreate_tunnel,
|
||||||
render_compose_template,
|
render_compose_template,
|
||||||
|
start_cloudflared_tunnel,
|
||||||
|
stop_cloudflared_tunnel,
|
||||||
|
wait_for_container_running,
|
||||||
write_compose_file,
|
write_compose_file,
|
||||||
write_config_files,
|
write_config_files,
|
||||||
write_env_file,
|
write_env_file,
|
||||||
|
write_config_folder_files,
|
||||||
)
|
)
|
||||||
|
from src.services.docker_build import build_image
|
||||||
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
||||||
|
|
||||||
@@ -48,6 +58,58 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
display_name: str | None = Field(default=None, description="Optional display name for the instance")
|
||||||
|
|
||||||
|
|
||||||
|
def _modify_compose_file(
|
||||||
|
compose_path: str,
|
||||||
|
port_override: int | None = None,
|
||||||
|
start_command: str | None = None,
|
||||||
|
working_directory: str | None = None,
|
||||||
|
extra_volumes: list[dict] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Modify compose file with runtime overrides."""
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
compose_file = Path(compose_path)
|
||||||
|
content = compose_file.read_text()
|
||||||
|
compose_data = yaml.safe_load(content)
|
||||||
|
|
||||||
|
if not compose_data or "services" not in compose_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Apply modifications to the first service
|
||||||
|
for service_name, service_config in compose_data["services"].items():
|
||||||
|
if port_override and "ports" in service_config:
|
||||||
|
# Update port mapping
|
||||||
|
for i, port_mapping in enumerate(service_config["ports"]):
|
||||||
|
if isinstance(port_mapping, str) and ":" in port_mapping:
|
||||||
|
host_port, container_port = port_mapping.split(":", 1)
|
||||||
|
service_config["ports"][i] = f"{port_override}:{container_port}"
|
||||||
|
break
|
||||||
|
|
||||||
|
if start_command:
|
||||||
|
service_config["command"] = start_command
|
||||||
|
|
||||||
|
if working_directory:
|
||||||
|
service_config["working_dir"] = working_directory
|
||||||
|
|
||||||
|
if extra_volumes:
|
||||||
|
if "volumes" not in service_config:
|
||||||
|
service_config["volumes"] = []
|
||||||
|
for vol in extra_volumes:
|
||||||
|
source = vol.get("source", "")
|
||||||
|
target = vol.get("target", "")
|
||||||
|
vol_type = vol.get("type", "bind")
|
||||||
|
if vol_type == "bind":
|
||||||
|
service_config["volumes"].append(f"{source}:{target}")
|
||||||
|
else:
|
||||||
|
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
|
||||||
|
|
||||||
|
break # Only modify the first service
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
"""Fetch a user by ID or raise 404 if not found."""
|
"""Fetch a user by ID or raise 404 if not found."""
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
@@ -142,11 +204,49 @@ async def create_instance(
|
|||||||
# Find free port
|
# Find free port
|
||||||
tool_port = find_free_port()
|
tool_port = find_free_port()
|
||||||
|
|
||||||
|
# Handle based on definition type
|
||||||
|
if tool_type.definition_type == "dockerfile":
|
||||||
|
# Build image from Dockerfile
|
||||||
|
image_tag = f"headquarter/{instance_name}:latest"
|
||||||
|
|
||||||
|
if tool_type.dockerfile_template:
|
||||||
|
returncode, stdout, stderr = build_image(
|
||||||
|
instance_dir=instance_dir,
|
||||||
|
dockerfile=tool_type.dockerfile_template,
|
||||||
|
tag=image_tag,
|
||||||
|
build_context=tool_type.build_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
if returncode != 0:
|
||||||
|
logger.error("Failed to build image for instance %s: %s", instance_name, stderr)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to build Docker image: {stderr[:500]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully built image %s for instance %s", image_tag, instance_name)
|
||||||
|
|
||||||
|
# Generate compose for dockerfile-built image
|
||||||
|
compose_content = f"""version: "3.8"
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: {image_tag}
|
||||||
|
container_name: {instance_name}
|
||||||
|
ports:
|
||||||
|
- "{tool_port}:{tool_type.default_port}"
|
||||||
|
volumes:
|
||||||
|
- {repo.path}:/workspace
|
||||||
|
restart: unless-stopped
|
||||||
|
"""
|
||||||
|
write_compose_file(instance_dir, compose_content)
|
||||||
|
|
||||||
|
else:
|
||||||
# Render compose template
|
# Render compose template
|
||||||
variables = {
|
variables = {
|
||||||
"REPO_PATH": repo.path,
|
"REPO_PATH": repo.path,
|
||||||
"INSTANCE_NAME": instance_name,
|
"INSTANCE_NAME": instance_name,
|
||||||
"INSTANCE_ID": instance_name,
|
"INSTANCE_ID": instance_name,
|
||||||
|
"TOOL_NAME": instance_name,
|
||||||
"TOOL_PORT": tool_port,
|
"TOOL_PORT": tool_port,
|
||||||
"USER_ID": str(user_id),
|
"USER_ID": str(user_id),
|
||||||
"PROJECT_ID": str(project_id),
|
"PROJECT_ID": str(project_id),
|
||||||
@@ -344,10 +444,16 @@ async def start_instance(
|
|||||||
|
|
||||||
instance.status = "building"
|
instance.status = "building"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
logger.info("Starting instance %s (name=%s)", instance.id, instance.name)
|
||||||
|
|
||||||
# Fetch tool configs for this tool type
|
# Fetch tool configs for this tool type
|
||||||
env_vars = {}
|
env_vars = {}
|
||||||
config_files = {}
|
config_files = {}
|
||||||
|
port_override = None
|
||||||
|
start_command = None
|
||||||
|
working_directory = None
|
||||||
|
extra_env_vars = {}
|
||||||
|
extra_volumes = []
|
||||||
|
|
||||||
config_query = select(ToolConfig).where(
|
config_query = select(ToolConfig).where(
|
||||||
ToolConfig.user_id == user_id,
|
ToolConfig.user_id == user_id,
|
||||||
@@ -358,6 +464,7 @@ async def start_instance(
|
|||||||
|
|
||||||
config_result = await session.execute(config_query)
|
config_result = await session.execute(config_query)
|
||||||
configs = config_result.scalars().all()
|
configs = config_result.scalars().all()
|
||||||
|
logger.info("Found %d tool configs for instance %s", len(configs), instance.id)
|
||||||
|
|
||||||
for config in configs:
|
for config in configs:
|
||||||
if config.config_type == "env":
|
if config.config_type == "env":
|
||||||
@@ -365,24 +472,65 @@ async def start_instance(
|
|||||||
elif config.config_type == "file" and config.file_path:
|
elif config.config_type == "file" and config.file_path:
|
||||||
config_files[config.file_path] = config.value
|
config_files[config.file_path] = config.value
|
||||||
|
|
||||||
|
# Handle new config fields
|
||||||
|
if config.port_override:
|
||||||
|
port_override = config.port_override
|
||||||
|
if config.start_command:
|
||||||
|
start_command = config.start_command
|
||||||
|
if config.working_directory:
|
||||||
|
working_directory = config.working_directory
|
||||||
|
if config.environment_variables:
|
||||||
|
extra_env_vars.update(config.environment_variables)
|
||||||
|
if config.volumes:
|
||||||
|
extra_volumes.extend(config.volumes)
|
||||||
|
|
||||||
|
# Merge extra env vars
|
||||||
|
env_vars.update(extra_env_vars)
|
||||||
|
|
||||||
|
# Fetch active config folders for this user
|
||||||
|
folder_query = select(ConfigFolder).where(
|
||||||
|
ConfigFolder.user_id == user_id,
|
||||||
|
ConfigFolder.is_active == True,
|
||||||
|
)
|
||||||
|
folder_result = await session.execute(folder_query)
|
||||||
|
config_folders = folder_result.scalars().all()
|
||||||
|
logger.info("Found %d active config folders for instance %s", len(config_folders), instance.id)
|
||||||
|
|
||||||
# Write env file and config files
|
# Write env file and config files
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
env_file_path = None
|
env_file_path = None
|
||||||
|
|
||||||
if env_vars:
|
if env_vars:
|
||||||
env_file_path = write_env_file(instance_dir, env_vars)
|
env_file_path = write_env_file(instance_dir, env_vars)
|
||||||
|
logger.info("Wrote env file for instance %s: %s", instance.id, env_file_path)
|
||||||
|
|
||||||
if config_files:
|
if config_files:
|
||||||
write_config_files(instance_dir, config_files)
|
write_config_files(instance_dir, config_files)
|
||||||
|
logger.info("Wrote %d config files for instance %s", len(config_files), instance.id)
|
||||||
|
|
||||||
|
# Write config folder files
|
||||||
|
if config_folders:
|
||||||
|
folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id))
|
||||||
|
extra_volumes.extend(folder_volumes)
|
||||||
|
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id)
|
||||||
|
|
||||||
|
# Modify compose file if needed (port override, start command, working dir, volumes)
|
||||||
|
if port_override or start_command or working_directory or extra_volumes:
|
||||||
|
_modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes)
|
||||||
|
logger.info("Modified compose file for instance %s", instance.id)
|
||||||
|
|
||||||
# Execute docker compose up with env file
|
# Execute docker compose up with env file
|
||||||
|
logger.info("Running docker compose up for instance %s (compose_path=%s)", instance.id, instance.compose_path)
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
instance.compose_path, "up", env_file=env_file_path
|
instance.compose_path, "up", env_file=env_file_path
|
||||||
)
|
)
|
||||||
|
logger.info("Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s",
|
||||||
|
instance.id, returncode, stdout[:200] if stdout else "", stderr[:500] if stderr else "")
|
||||||
|
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
instance.status = "error"
|
instance.status = "error"
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
logger.error("Failed to start instance %s: %s", instance.id, stderr)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"failed to start instance: {stderr}",
|
detail=f"failed to start instance: {stderr}",
|
||||||
@@ -392,14 +540,181 @@ async def start_instance(
|
|||||||
container_id = get_container_id(instance.name)
|
container_id = get_container_id(instance.name)
|
||||||
if container_id:
|
if container_id:
|
||||||
instance.container_id = container_id
|
instance.container_id = container_id
|
||||||
|
logger.info("Container ID for instance %s: %s", instance.id, container_id)
|
||||||
|
|
||||||
container_name = get_container_name(instance.name)
|
container_name = get_container_name(instance.name)
|
||||||
if container_name:
|
if container_name:
|
||||||
instance.container_name = container_name
|
instance.container_name = container_name
|
||||||
|
logger.info("Container name for instance %s: %s", instance.id, container_name)
|
||||||
|
|
||||||
|
# Connect container to backend network so API can reach it
|
||||||
|
logger.info("Connecting container %s to backend network...", container_name)
|
||||||
|
connected = connect_container_to_network(container_name, "backend")
|
||||||
|
if connected:
|
||||||
|
logger.info("Successfully connected %s to backend network", container_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to connect %s to backend network", container_name)
|
||||||
|
|
||||||
|
# Verify container reached running state
|
||||||
|
if instance.container_id:
|
||||||
|
instance.status = "starting"
|
||||||
|
instance.last_started_at = datetime.now()
|
||||||
|
await session.commit()
|
||||||
|
logger.info("Instance %s: verifying container startup...", instance.id)
|
||||||
|
|
||||||
|
startup_result = wait_for_container_running(instance.container_id, timeout=30, interval=2.0)
|
||||||
|
|
||||||
|
if not startup_result["success"]:
|
||||||
|
# Container failed to start
|
||||||
|
error_msg = f"Container failed to start: status={startup_result['status']}"
|
||||||
|
if startup_result["exit_code"] is not None:
|
||||||
|
error_msg += f", exit_code={startup_result['exit_code']}"
|
||||||
|
|
||||||
|
# Get logs for debugging
|
||||||
|
logs = get_container_logs(instance.container_id, tail=50)
|
||||||
|
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
logger.error(
|
||||||
|
"Instance %s container startup failed after %.1fs: %s\nLogs:\n%s",
|
||||||
|
instance.id,
|
||||||
|
startup_result["waited_seconds"],
|
||||||
|
error_msg,
|
||||||
|
logs,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": error_msg,
|
||||||
|
"logs": logs,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Instance %s container started successfully after %.1fs",
|
||||||
|
instance.id,
|
||||||
|
startup_result["waited_seconds"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute readiness probe if configured
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
if tool_type and instance.container_id:
|
||||||
|
# Determine probe command
|
||||||
|
probe_command = None
|
||||||
|
probe_timeout = 30
|
||||||
|
probe_interval = 2
|
||||||
|
|
||||||
|
if tool_type.readiness_probe:
|
||||||
|
probe_config = tool_type.readiness_probe
|
||||||
|
probe_command = probe_config.get("command", "")
|
||||||
|
probe_timeout = probe_config.get("timeout", 30)
|
||||||
|
probe_interval = probe_config.get("interval", 2)
|
||||||
|
elif "web" in (tool_type.interfaces or []):
|
||||||
|
# Default probe for web tools
|
||||||
|
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
|
||||||
|
probe_timeout = 30
|
||||||
|
probe_interval = 2
|
||||||
|
|
||||||
|
if probe_command:
|
||||||
|
instance.status = "probing"
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
|
||||||
|
instance.id, probe_command, probe_timeout, probe_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
success, probe_logs = await execute_probe(
|
||||||
|
container_id=instance.container_id,
|
||||||
|
command=probe_command,
|
||||||
|
timeout=probe_timeout,
|
||||||
|
interval=probe_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store probe result
|
||||||
|
instance.probe_result = {
|
||||||
|
"success": success,
|
||||||
|
"command": probe_command,
|
||||||
|
"logs": probe_logs,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
instance.status = "unhealthy"
|
||||||
|
await session.commit()
|
||||||
|
logger.error(
|
||||||
|
"Readiness probe failed for instance %s after %ds: %s",
|
||||||
|
instance.id,
|
||||||
|
probe_timeout,
|
||||||
|
"\n".join(probe_logs),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "unhealthy",
|
||||||
|
"error": f"Readiness probe failed after {probe_timeout}s",
|
||||||
|
"probe_logs": probe_logs,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Readiness probe succeeded for instance %s", instance.id)
|
||||||
|
|
||||||
instance.status = "running"
|
instance.status = "running"
|
||||||
instance.last_started_at = datetime.now()
|
await session.commit()
|
||||||
instance.url = f"/instances/{instance.id}/proxy/"
|
logger.info("Instance %s is now running", instance.id)
|
||||||
|
|
||||||
|
# Get tool type for default port
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
if not tool_type or not tool_type.default_port:
|
||||||
|
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.",
|
||||||
|
instance.tool_type_id)
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
|
||||||
|
}
|
||||||
|
|
||||||
|
instance_port = tool_type.default_port
|
||||||
|
logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s",
|
||||||
|
instance.id, tool_type.name, instance_port, tool_type.interfaces)
|
||||||
|
|
||||||
|
# Only create Cloudflare tunnel for web-enabled tools
|
||||||
|
if "web" in tool_type.interfaces:
|
||||||
|
# Create temporary Cloudflare tunnel for public access
|
||||||
|
try:
|
||||||
|
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
|
||||||
|
instance.id, instance.container_name, instance_port)
|
||||||
|
tunnel_info = start_cloudflared_tunnel(
|
||||||
|
container_name=instance.container_name or instance.name,
|
||||||
|
port=instance_port,
|
||||||
|
)
|
||||||
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
|
instance.public_url = tunnel_info["url"]
|
||||||
|
instance.url = tunnel_info["url"]
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Created temporary tunnel for instance %s: pid=%s, url=%s",
|
||||||
|
instance.id,
|
||||||
|
tunnel_info["pid"],
|
||||||
|
tunnel_info["url"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
import traceback
|
||||||
|
error_msg = str(exc)
|
||||||
|
error_trace = traceback.format_exc()
|
||||||
|
logger.error(
|
||||||
|
"Failed to create tunnel for instance %s: %s\nTraceback:\n%s",
|
||||||
|
instance.id,
|
||||||
|
error_msg,
|
||||||
|
error_trace,
|
||||||
|
)
|
||||||
|
instance.status = "error"
|
||||||
|
instance.url = None
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Failed to create tunnel: {error_msg}",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Terminal-only tool - no tunnel needed
|
||||||
|
logger.info("Instance %s is terminal-only (no web interface), skipping tunnel creation", instance.id)
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"status": instance.status, "url": instance.url}
|
return {"status": instance.status, "url": instance.url}
|
||||||
@@ -438,12 +753,22 @@ async def stop_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stop Cloudflare tunnel if exists
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
logger.info("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
execute_compose_command(instance.compose_path, "stop")
|
execute_compose_command(instance.compose_path, "stop")
|
||||||
|
|
||||||
instance.status = "stopped"
|
instance.status = "stopped"
|
||||||
instance.last_stopped_at = datetime.now()
|
instance.last_stopped_at = datetime.now()
|
||||||
instance.url = None
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
instance.tunnel_id = None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"status": instance.status}
|
return {"status": instance.status}
|
||||||
@@ -482,6 +807,14 @@ async def restart_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stop old tunnel if exists
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
logger.info("Stopped old tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
instance.compose_path, "restart"
|
instance.compose_path, "restart"
|
||||||
@@ -490,7 +823,55 @@ async def restart_instance(
|
|||||||
if returncode == 0:
|
if returncode == 0:
|
||||||
instance.status = "running"
|
instance.status = "running"
|
||||||
instance.last_started_at = datetime.now()
|
instance.last_started_at = datetime.now()
|
||||||
instance.url = f"http://localhost:{instance.port}"
|
|
||||||
|
# Get tool type for default port
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
if not tool_type or not tool_type.default_port:
|
||||||
|
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.",
|
||||||
|
instance.tool_type_id)
|
||||||
|
instance.status = "error"
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
|
||||||
|
}
|
||||||
|
|
||||||
|
instance_port = tool_type.default_port
|
||||||
|
|
||||||
|
# Only create tunnel for web-enabled tools
|
||||||
|
if "web" in tool_type.interfaces:
|
||||||
|
# Create new temporary tunnel
|
||||||
|
try:
|
||||||
|
tunnel_info = start_cloudflared_tunnel(
|
||||||
|
container_name=instance.container_name or instance.name,
|
||||||
|
port=instance_port,
|
||||||
|
)
|
||||||
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
|
instance.public_url = tunnel_info["url"]
|
||||||
|
instance.url = tunnel_info["url"]
|
||||||
|
logger.info(
|
||||||
|
"Created new tunnel for instance %s: %s",
|
||||||
|
instance.id,
|
||||||
|
tunnel_info["url"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to create tunnel for instance %s: %s",
|
||||||
|
instance.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
instance.status = "error"
|
||||||
|
instance.url = None
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": f"Failed to create tunnel: {exc}",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Terminal-only tool
|
||||||
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": instance.status, "url": instance.url}
|
return {"status": instance.status, "url": instance.url}
|
||||||
|
|
||||||
@@ -532,6 +913,14 @@ async def delete_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stop Cloudflare tunnel if exists
|
||||||
|
if instance.tunnel_id:
|
||||||
|
try:
|
||||||
|
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
|
logger.info("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
# Stop and remove container
|
# Stop and remove container
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
execute_compose_command(instance.compose_path, "down")
|
execute_compose_command(instance.compose_path, "down")
|
||||||
@@ -589,6 +978,164 @@ async def get_instance_logs(
|
|||||||
return {"logs": logs}
|
return {"logs": logs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel",
|
||||||
|
summary="Recreate tunnel",
|
||||||
|
description="Recreate the temporary Cloudflare tunnel for a running instance.",
|
||||||
|
)
|
||||||
|
async def recreate_tunnel_endpoint(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Recreate the temporary tunnel for an instance.
|
||||||
|
|
||||||
|
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 new URL and status.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="instance must be running to recreate tunnel",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate tunnel is actually broken before recreating
|
||||||
|
if instance.url:
|
||||||
|
tunnel_health = check_tunnel_health(instance.url)
|
||||||
|
if tunnel_health["tunnel_status"] == "error_response":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.",
|
||||||
|
)
|
||||||
|
elif tunnel_health["tunnel_status"] == "healthy":
|
||||||
|
return {"status": "healthy", "url": instance.url, "message": "Tunnel is already healthy"}
|
||||||
|
|
||||||
|
# Get tool type for default port
|
||||||
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
|
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
|
||||||
|
|
||||||
|
try:
|
||||||
|
tunnel_info = recreate_tunnel(
|
||||||
|
container_name=instance.container_name or instance.name,
|
||||||
|
port=instance_port,
|
||||||
|
old_pid=instance.tunnel_id,
|
||||||
|
)
|
||||||
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
|
instance.public_url = tunnel_info["url"]
|
||||||
|
instance.url = tunnel_info["url"]
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Recreated tunnel for instance %s: pid=%s, url=%s",
|
||||||
|
instance.id,
|
||||||
|
tunnel_info["pid"],
|
||||||
|
tunnel_info["url"],
|
||||||
|
)
|
||||||
|
return {"status": "healthy", "url": instance.url}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to recreate tunnel for instance %s", instance.id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to recreate tunnel: {str(exc)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
|
||||||
|
summary="Check instance health",
|
||||||
|
description="Check container and tunnel health for an instance.",
|
||||||
|
)
|
||||||
|
async def check_instance_tunnel_health(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Check health for an instance (container + tunnel).
|
||||||
|
|
||||||
|
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 container_status, tunnel_status, probe_status, and overall healthy flag.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
instance = await session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None or instance.repository_id != repo_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check container status
|
||||||
|
container_info = {"status": "not_found", "exit_code": None, "health": None}
|
||||||
|
if instance.container_id:
|
||||||
|
container_info = get_container_status(instance.container_id)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
response = {
|
||||||
|
"healthy": False,
|
||||||
|
"container_status": container_info["status"],
|
||||||
|
"container_health": container_info["health"],
|
||||||
|
"tunnel_status": "not_applicable",
|
||||||
|
"tunnel_status_code": None,
|
||||||
|
"probe_status": "not_applicable",
|
||||||
|
"last_probe_output": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine probe status
|
||||||
|
if instance.status == "probing":
|
||||||
|
response["probe_status"] = "pending"
|
||||||
|
elif instance.probe_result:
|
||||||
|
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
|
||||||
|
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))[:500]
|
||||||
|
|
||||||
|
# Check tunnel health if instance has a URL and is web-enabled
|
||||||
|
if instance.url and instance.status in ("running", "unhealthy"):
|
||||||
|
tunnel_health = check_tunnel_health(instance.url)
|
||||||
|
response["tunnel_status"] = tunnel_health["tunnel_status"]
|
||||||
|
response["tunnel_status_code"] = tunnel_health.get("status_code")
|
||||||
|
if tunnel_health.get("error"):
|
||||||
|
response["error"] = tunnel_health["error"]
|
||||||
|
|
||||||
|
# Overall healthy only if container is running AND tunnel is healthy
|
||||||
|
container_healthy = container_info["status"] == "running"
|
||||||
|
tunnel_healthy = response["tunnel_status"] == "healthy"
|
||||||
|
response["healthy"] = container_healthy and tunnel_healthy
|
||||||
|
|
||||||
|
# If container is not running, override error message
|
||||||
|
if not container_healthy:
|
||||||
|
response["error"] = f"Container is {container_info['status']}"
|
||||||
|
if container_info["exit_code"] is not None:
|
||||||
|
response["error"] += f" (exit code: {container_info['exit_code']})"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
|
||||||
summary="Proxy to instance",
|
summary="Proxy to instance",
|
||||||
@@ -746,7 +1293,7 @@ async def get_user_sessions(
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ToolInstance)
|
select(ToolInstance)
|
||||||
.where(ToolInstance.owner_id == user_id)
|
.where(ToolInstance.owner_id == user_id)
|
||||||
.where(ToolInstance.status.in_(["running", "building", "pending"]))
|
.where(ToolInstance.status.in_(["running", "building", "pending", "stopped", "error"]))
|
||||||
.order_by(ToolInstance.created_at.desc())
|
.order_by(ToolInstance.created_at.desc())
|
||||||
)
|
)
|
||||||
instances = result.scalars().all()
|
instances = result.scalars().all()
|
||||||
@@ -762,6 +1309,7 @@ async def get_user_sessions(
|
|||||||
"display_name": instance.display_name,
|
"display_name": instance.display_name,
|
||||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||||
"tool_icon": tool_type.name if tool_type else "code",
|
"tool_icon": tool_type.name if tool_type else "code",
|
||||||
|
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
|
||||||
"repository_name": repo.name if repo else "unknown",
|
"repository_name": repo.name if repo else "unknown",
|
||||||
"repository_id": str(instance.repository_id),
|
"repository_id": str(instance.repository_id),
|
||||||
"project_name": project.name if project else "unknown",
|
"project_name": project.name if project else "unknown",
|
||||||
|
|||||||
+304
-12
@@ -3,7 +3,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -37,13 +37,33 @@ class ToolTypeCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_port: int | None = None
|
default_port: int
|
||||||
compose_template: str
|
definition_type: str = "compose"
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
required_variables: list[str] = []
|
required_variables: list[str] = []
|
||||||
|
category: str = "other"
|
||||||
|
interfaces: list[str] = ["web"]
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str) -> str:
|
||||||
|
if v not in ("compose", "dockerfile"):
|
||||||
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_compose_template(cls, v: str) -> str:
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if v is None:
|
||||||
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(v)
|
parsed = yaml.safe_load(v)
|
||||||
except yaml.YAMLError as e:
|
except yaml.YAMLError as e:
|
||||||
@@ -60,18 +80,79 @@ class ToolTypeCreate(BaseModel):
|
|||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("dockerfile_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "dockerfile":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if v is None:
|
||||||
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
|
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("default_port")
|
||||||
|
@classmethod
|
||||||
|
def validate_default_port(cls, v: int, info) -> int:
|
||||||
|
if v <= 0 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
|
||||||
|
# Get compose_template from the model data
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
template = data.get("compose_template")
|
||||||
|
if not template:
|
||||||
|
return v
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(template)
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return v
|
||||||
|
|
||||||
|
# Check if the port is exposed in any service
|
||||||
|
port_str = str(v)
|
||||||
|
port_exposed = False
|
||||||
|
|
||||||
|
if isinstance(parsed, dict) and "services" in parsed:
|
||||||
|
for service_name, service_config in parsed["services"].items():
|
||||||
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str):
|
||||||
|
# Format: "8443:8443" or "8443"
|
||||||
|
if port_str in port_mapping:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
elif isinstance(port_mapping, int) and port_mapping == v:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
if port_exposed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not port_exposed:
|
||||||
|
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("required_variables")
|
@field_validator("required_variables")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||||
if not v:
|
if not v:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
# Get compose_template from the model data
|
|
||||||
data = info.data
|
data = info.data
|
||||||
if "compose_template" not in data:
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
template = data.get("compose_template")
|
||||||
|
if not template:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
template = data["compose_template"]
|
|
||||||
for var in v:
|
for var in v:
|
||||||
placeholder = f"{{{{{var}}}}}"
|
placeholder = f"{{{{{var}}}}}"
|
||||||
if placeholder not in template:
|
if placeholder not in template:
|
||||||
@@ -79,19 +160,48 @@ class ToolTypeCreate(BaseModel):
|
|||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_templates(self) -> "ToolTypeCreate":
|
||||||
|
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
||||||
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
|
if self.definition_type == "compose" and self.compose_template is None:
|
||||||
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeUpdate(BaseModel):
|
class ToolTypeUpdate(BaseModel):
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
default_port: int | None = None
|
||||||
|
definition_type: str | None = None
|
||||||
compose_template: str | None = None
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
required_variables: list[str] | None = None
|
required_variables: list[str] | None = None
|
||||||
|
category: str | None = None
|
||||||
|
interfaces: list[str] | None = None
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v not in ("compose", "dockerfile"):
|
||||||
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_compose_template(cls, v: str | None) -> str | None:
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
data = info.data
|
||||||
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(v)
|
parsed = yaml.safe_load(v)
|
||||||
except yaml.YAMLError as e:
|
except yaml.YAMLError as e:
|
||||||
@@ -108,6 +218,22 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("dockerfile_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
data = info.data
|
||||||
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "dockerfile":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeResponse(BaseModel):
|
class ToolTypeResponse(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -118,8 +244,12 @@ class ToolTypeResponse(BaseModel):
|
|||||||
description: str | None
|
description: str | None
|
||||||
category: str
|
category: str
|
||||||
interfaces: list[str]
|
interfaces: list[str]
|
||||||
default_port: int | None
|
default_port: int
|
||||||
compose_template: str
|
definition_type: str
|
||||||
|
compose_template: str | None
|
||||||
|
dockerfile_template: str | None
|
||||||
|
build_context: dict | None
|
||||||
|
readiness_probe: dict | None
|
||||||
required_variables: list[str]
|
required_variables: list[str]
|
||||||
is_builtin: bool
|
is_builtin: bool
|
||||||
created_by_id: uuid.UUID | None
|
created_by_id: uuid.UUID | None
|
||||||
@@ -162,8 +292,14 @@ async def create_tool_type(
|
|||||||
display_name=data.display_name,
|
display_name=data.display_name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
default_port=data.default_port,
|
default_port=data.default_port,
|
||||||
|
definition_type=data.definition_type,
|
||||||
compose_template=data.compose_template,
|
compose_template=data.compose_template,
|
||||||
|
dockerfile_template=data.dockerfile_template,
|
||||||
|
build_context=data.build_context,
|
||||||
|
readiness_probe=data.readiness_probe,
|
||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
|
category=data.category,
|
||||||
|
interfaces=data.interfaces,
|
||||||
is_builtin=False,
|
is_builtin=False,
|
||||||
created_by_id=user.id,
|
created_by_id=user.id,
|
||||||
)
|
)
|
||||||
@@ -260,7 +396,49 @@ async def update_tool_type(
|
|||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Validate required variables if both are being updated
|
# Validate port if being updated
|
||||||
|
if "default_port" in update_data:
|
||||||
|
new_port = update_data["default_port"]
|
||||||
|
if new_port <= 0 or new_port > 65535:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Port must be between 1 and 65535"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only validate port exposure for compose definitions
|
||||||
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
|
if definition_type == "compose":
|
||||||
|
template = update_data.get("compose_template", tool_type.compose_template)
|
||||||
|
if template:
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(template)
|
||||||
|
except yaml.YAMLError:
|
||||||
|
parsed = None
|
||||||
|
|
||||||
|
if parsed and isinstance(parsed, dict) and "services" in parsed:
|
||||||
|
port_str = str(new_port)
|
||||||
|
port_exposed = False
|
||||||
|
for service_config in parsed["services"].values():
|
||||||
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str) and port_str in port_mapping:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
elif isinstance(port_mapping, int) and port_mapping == new_port:
|
||||||
|
port_exposed = True
|
||||||
|
break
|
||||||
|
if port_exposed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not port_exposed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Port {new_port} is not exposed in the compose template"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate required variables for compose definitions
|
||||||
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
|
if definition_type == "compose":
|
||||||
if "required_variables" in update_data and "compose_template" in update_data:
|
if "required_variables" in update_data and "compose_template" in update_data:
|
||||||
template = update_data["compose_template"]
|
template = update_data["compose_template"]
|
||||||
for var in update_data["required_variables"]:
|
for var in update_data["required_variables"]:
|
||||||
@@ -271,8 +449,8 @@ async def update_tool_type(
|
|||||||
detail=f"Required variable '{var}' not found in compose template"
|
detail=f"Required variable '{var}' not found in compose template"
|
||||||
)
|
)
|
||||||
elif "required_variables" in update_data:
|
elif "required_variables" in update_data:
|
||||||
# Only updating variables, check against existing template
|
|
||||||
template = tool_type.compose_template
|
template = tool_type.compose_template
|
||||||
|
if template:
|
||||||
for var in update_data["required_variables"]:
|
for var in update_data["required_variables"]:
|
||||||
placeholder = f"{{{{{var}}}}}"
|
placeholder = f"{{{{{var}}}}}"
|
||||||
if placeholder not in template:
|
if placeholder not in template:
|
||||||
@@ -289,6 +467,120 @@ async def update_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeValidateRequest(BaseModel):
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/validate",
|
||||||
|
summary="Validate tool type template",
|
||||||
|
description="Validate a compose template or dockerfile syntax before creating a tool type.",
|
||||||
|
)
|
||||||
|
async def validate_tool_type_template(
|
||||||
|
data: ToolTypeValidateRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Validate a tool type template syntax.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Validation request with definition type and template.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validation result with success status and any errors.
|
||||||
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if data.definition_type == "compose":
|
||||||
|
if not data.compose_template:
|
||||||
|
errors.append("Compose template is required")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(data.compose_template)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
errors.append("Compose template must be a YAML mapping")
|
||||||
|
elif "services" not in parsed:
|
||||||
|
errors.append("Compose template must contain 'services' key")
|
||||||
|
elif not parsed["services"]:
|
||||||
|
errors.append("Compose template must define at least one service")
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
errors.append(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
|
elif data.definition_type == "dockerfile":
|
||||||
|
if not data.dockerfile_template:
|
||||||
|
errors.append("Dockerfile template is required")
|
||||||
|
elif not data.dockerfile_template.strip().startswith("FROM"):
|
||||||
|
errors.append("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
else:
|
||||||
|
errors.append("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tool_type_id}/validate",
|
||||||
|
summary="Validate tool type",
|
||||||
|
description="Validate the compose template or dockerfile syntax of a tool type.",
|
||||||
|
)
|
||||||
|
async def validate_tool_type(
|
||||||
|
tool_type_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Validate a tool type's template syntax.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_type_id: UUID of the tool type to validate.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validation result with success status and any errors.
|
||||||
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if tool_type.definition_type == "compose":
|
||||||
|
if not tool_type.compose_template:
|
||||||
|
errors.append("Compose template is empty")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(tool_type.compose_template)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
errors.append("Compose template must be a YAML mapping")
|
||||||
|
elif "services" not in parsed:
|
||||||
|
errors.append("Compose template must contain 'services' key")
|
||||||
|
elif not parsed["services"]:
|
||||||
|
errors.append("Compose template must define at least one service")
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
errors.append(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
|
elif tool_type.definition_type == "dockerfile":
|
||||||
|
if not tool_type.dockerfile_template:
|
||||||
|
errors.append("Dockerfile template is empty")
|
||||||
|
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
|
||||||
|
errors.append("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{tool_type_id}",
|
"/{tool_type_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ async def update_user_config(
|
|||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user_id, update_data)
|
logger.info("Updating user config for user %s: %s", user_id, update_data)
|
||||||
config.config.update(update_data)
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ class Settings(BaseSettings):
|
|||||||
# Tool instance storage
|
# Tool instance storage
|
||||||
instance_base_path: str = "/data/instances"
|
instance_base_path: str = "/data/instances"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
+60
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ from src.api.projects import router as projects_router
|
|||||||
from src.api.ssh_keys import router as ssh_keys_router
|
from src.api.ssh_keys import router as ssh_keys_router
|
||||||
from src.api.terminal import router as terminal_router
|
from src.api.terminal import router as terminal_router
|
||||||
from src.api.instance_proxy import router as instance_proxy_router
|
from src.api.instance_proxy import router as instance_proxy_router
|
||||||
|
from src.api.config_folders import router as config_folders_router
|
||||||
from src.api.tool_configs import router as tool_configs_router
|
from src.api.tool_configs import router as tool_configs_router
|
||||||
from src.api.tool_instances import router as tool_instances_router
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
from src.api.tool_instances import sessions_router
|
from src.api.tool_instances import sessions_router
|
||||||
@@ -58,6 +60,32 @@ app.add_middleware(RequestLoggingMiddleware)
|
|||||||
app.add_middleware(ExceptionLoggingMiddleware)
|
app.add_middleware(ExceptionLoggingMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_validation_errors(errors):
|
||||||
|
"""Convert validation errors to JSON-safe format."""
|
||||||
|
sanitized = []
|
||||||
|
for error in errors:
|
||||||
|
safe_error = {
|
||||||
|
"type": error.get("type"),
|
||||||
|
"loc": error.get("loc"),
|
||||||
|
"msg": error.get("msg"),
|
||||||
|
"input": str(error.get("input")) if error.get("input") is not None else None,
|
||||||
|
}
|
||||||
|
# Convert ctx to safe format
|
||||||
|
ctx = error.get("ctx")
|
||||||
|
if ctx:
|
||||||
|
safe_ctx = {}
|
||||||
|
for key, value in ctx.items():
|
||||||
|
if isinstance(value, Exception):
|
||||||
|
safe_ctx[key] = str(value)
|
||||||
|
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||||
|
safe_ctx[key] = value
|
||||||
|
else:
|
||||||
|
safe_ctx[key] = str(value)
|
||||||
|
safe_error["ctx"] = safe_ctx
|
||||||
|
sanitized.append(safe_error)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
"""Log validation errors and return detailed response."""
|
"""Log validation errors and return detailed response."""
|
||||||
@@ -68,9 +96,10 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||||||
request.url.path,
|
request.url.path,
|
||||||
errors,
|
errors,
|
||||||
)
|
)
|
||||||
|
safe_errors = _sanitize_validation_errors(errors)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
content={"detail": errors},
|
content={"detail": safe_errors},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -150,9 +179,10 @@ services:
|
|||||||
{
|
{
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"display_name": "OpenCode",
|
"display_name": "OpenCode",
|
||||||
"description": "AI coding assistant in the terminal",
|
"description": "AI coding assistant - run opencode in terminal",
|
||||||
"category": "ai-assistant",
|
"category": "ai-assistant",
|
||||||
"interfaces": ["terminal"],
|
"interfaces": ["terminal"],
|
||||||
|
"default_port": 3000,
|
||||||
"compose_template": """version: "3.8"
|
"compose_template": """version: "3.8"
|
||||||
services:
|
services:
|
||||||
opencode:
|
opencode:
|
||||||
@@ -164,10 +194,21 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- {{REPO_PATH}}:/workspace
|
- {{REPO_PATH}}:/workspace
|
||||||
- opencode_home:/tmp
|
- opencode_home:/tmp
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
command: >
|
command: >
|
||||||
sh -c "npm install -g opencode@latest &&
|
sh -c "set -x &&
|
||||||
mkdir -p /workspace &&
|
apt-get update && apt-get install -y git ca-certificates &&
|
||||||
tail -f /dev/null"
|
echo 'Installing opencode...' &&
|
||||||
|
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
||||||
|
which opencode || echo 'ERROR: opencode not in PATH' &&
|
||||||
|
npm bin -g &&
|
||||||
|
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
||||||
|
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
|
||||||
|
echo 'cd /workspace' >> /root/.bashrc &&
|
||||||
|
echo 'OpenCode installation complete' &&
|
||||||
|
cd /workspace &&
|
||||||
|
exec tail -f /dev/null"
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -187,12 +228,25 @@ volumes:
|
|||||||
description=tool_data["description"],
|
description=tool_data["description"],
|
||||||
category=tool_data["category"],
|
category=tool_data["category"],
|
||||||
interfaces=tool_data["interfaces"],
|
interfaces=tool_data["interfaces"],
|
||||||
|
definition_type="compose",
|
||||||
compose_template=tool_data["compose_template"],
|
compose_template=tool_data["compose_template"],
|
||||||
required_variables=tool_data["required_variables"],
|
required_variables=tool_data["required_variables"],
|
||||||
default_port=tool_data.get("default_port"),
|
default_port=tool_data.get("default_port"),
|
||||||
is_builtin=True,
|
is_builtin=True,
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
|
logger.info("Created built-in tool type: %s", tool_data["name"])
|
||||||
|
else:
|
||||||
|
# Update existing built-in tool types to reflect code changes
|
||||||
|
existing.display_name = tool_data["display_name"]
|
||||||
|
existing.description = tool_data["description"]
|
||||||
|
existing.category = tool_data["category"]
|
||||||
|
existing.interfaces = tool_data["interfaces"]
|
||||||
|
existing.definition_type = "compose"
|
||||||
|
existing.compose_template = tool_data["compose_template"]
|
||||||
|
existing.required_variables = tool_data["required_variables"]
|
||||||
|
existing.default_port = tool_data.get("default_port")
|
||||||
|
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("Built-in tool types seeded successfully.")
|
logger.info("Built-in tool types seeded successfully.")
|
||||||
@@ -222,6 +276,7 @@ app.include_router(ssh_keys_router)
|
|||||||
app.include_router(git_repositories_router)
|
app.include_router(git_repositories_router)
|
||||||
app.include_router(user_config_router)
|
app.include_router(user_config_router)
|
||||||
app.include_router(tool_types_router)
|
app.include_router(tool_types_router)
|
||||||
|
app.include_router(config_folders_router)
|
||||||
app.include_router(tool_instances_router)
|
app.include_router(tool_instances_router)
|
||||||
app.include_router(tool_configs_router)
|
app.include_router(tool_configs_router)
|
||||||
app.include_router(sessions_router)
|
app.include_router(sessions_router)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
|
from src.models.config_folder import ConfigFolder
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
@@ -7,4 +8,4 @@ from src.models.tool_type import ToolType
|
|||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
__all__ = ["Base", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "config_folders"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
files: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"relative/path": "content", ...}
|
||||||
|
project_overrides: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, String, Text
|
from sqlalchemy import ForeignKey, JSON, String, Text
|
||||||
from sqlalchemy import Uuid as UUID
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -33,6 +33,15 @@ class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
file_path: Mapped[str | None] = mapped_column(
|
file_path: Mapped[str | None] = mapped_column(
|
||||||
String(1024), nullable=True
|
String(1024), nullable=True
|
||||||
) # Only for file type
|
) # Only for file type
|
||||||
|
port_override: Mapped[int | None] = mapped_column(nullable=True)
|
||||||
|
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
environment_variables: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
)
|
||||||
|
volumes: Mapped[list[dict] | None] = mapped_column(
|
||||||
|
JSON, default=list, nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
|
||||||
from sqlalchemy import Uuid as UUID
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -47,6 +47,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
url: Mapped[str | None] = mapped_column(
|
url: Mapped[str | None] = mapped_column(
|
||||||
String(1024), nullable=True
|
String(1024), nullable=True
|
||||||
)
|
)
|
||||||
|
public_url: Mapped[str | None] = mapped_column(
|
||||||
|
String(1024), nullable=True
|
||||||
|
)
|
||||||
|
tunnel_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True
|
||||||
|
)
|
||||||
port: Mapped[int | None] = mapped_column(
|
port: Mapped[int | None] = mapped_column(
|
||||||
Integer, nullable=True
|
Integer, nullable=True
|
||||||
)
|
)
|
||||||
@@ -56,6 +62,9 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
probe_result: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
|
|||||||
@@ -19,8 +19,16 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||||
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
default_port: Mapped[int | None] = mapped_column(nullable=True)
|
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||||
compose_template: Mapped[str] = mapped_column(Text, nullable=False)
|
definition_type: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="compose"
|
||||||
|
) # "compose" or "dockerfile"
|
||||||
|
compose_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
dockerfile_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
build_context: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=True
|
||||||
|
)
|
||||||
|
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
|||||||
@@ -92,6 +92,59 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
|||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
||||||
|
"""Write config folder files to the instance directory and return volume mounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
folders: List of ConfigFolder objects
|
||||||
|
project_id: Optional project ID for applying overrides
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
||||||
|
"""
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
volume_mounts = []
|
||||||
|
|
||||||
|
for folder in folders:
|
||||||
|
# Determine mount path (with project override if applicable)
|
||||||
|
mount_path = folder.mount_path
|
||||||
|
files = folder.files.copy()
|
||||||
|
|
||||||
|
if project_id and folder.project_overrides:
|
||||||
|
override = folder.project_overrides.get(str(project_id))
|
||||||
|
if override:
|
||||||
|
if override.get("mount_path"):
|
||||||
|
mount_path = override["mount_path"]
|
||||||
|
if override.get("files"):
|
||||||
|
files.update(override["files"])
|
||||||
|
|
||||||
|
# Write files to instance directory
|
||||||
|
folder_dir = instance_path / "volumes" / folder.name
|
||||||
|
folder_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for file_path, content in files.items():
|
||||||
|
# Security: ensure path doesn't escape folder_dir
|
||||||
|
full_path = folder_dir / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(folder_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Config folder file path escapes directory: %s", file_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
# Add volume mount
|
||||||
|
volume_mounts.append({
|
||||||
|
"source": str(folder_dir),
|
||||||
|
"target": mount_path,
|
||||||
|
"type": "bind",
|
||||||
|
})
|
||||||
|
|
||||||
|
return volume_mounts
|
||||||
|
|
||||||
|
|
||||||
def execute_compose_command(
|
def execute_compose_command(
|
||||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||||
) -> tuple[int, str, str]:
|
) -> tuple[int, str, str]:
|
||||||
@@ -173,24 +226,112 @@ def get_container_name(instance_name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_container_status(container_id: str) -> str:
|
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
||||||
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name or ID of the container
|
||||||
|
network_name: Name of the Docker network (default: backend)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "network", "connect", network_name, container_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||||
"""Get the status of a Docker container.
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
container_id: Docker container ID
|
container_id: Docker container ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Container status string (running, exited, etc.)
|
Dict with 'status' (running, exited, restarting, not_found),
|
||||||
|
'exit_code' (int or None), and 'health' (health status or None)
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
[
|
||||||
|
"docker", "inspect", "-f",
|
||||||
|
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode != 0:
|
||||||
return result.stdout.strip()
|
return {"status": "not_found", "exit_code": None, "health": None}
|
||||||
return "unknown"
|
|
||||||
|
parts = result.stdout.strip().split("|")
|
||||||
|
status = parts[0] if parts else "unknown"
|
||||||
|
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||||
|
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||||
|
|
||||||
|
return {"status": status, "exit_code": exit_code, "health": health}
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_container_running(
|
||||||
|
container_id: str, timeout: int = 30, interval: float = 2.0
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Wait for a container to reach the running state.
|
||||||
|
|
||||||
|
Polls docker inspect until the container status is "running" or timeout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
timeout: Maximum seconds to wait
|
||||||
|
interval: Seconds between polls
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||||
|
and 'waited_seconds' (float)
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
info = get_container_status(container_id)
|
||||||
|
|
||||||
|
if info["status"] == "running":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"exit_code": None,
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info["status"] == "exited":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "exited",
|
||||||
|
"exit_code": info["exit_code"],
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info["status"] == "not_found":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "not_found",
|
||||||
|
"exit_code": None,
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
# Timeout reached
|
||||||
|
info = get_container_status(container_id)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": info["status"],
|
||||||
|
"exit_code": info["exit_code"],
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||||
@@ -232,3 +373,191 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|||||||
return port
|
return port
|
||||||
|
|
||||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
|
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def start_cloudflared_tunnel(
|
||||||
|
container_name: str, port: int, timeout: int = 30
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Start a temporary Cloudflare tunnel for a container.
|
||||||
|
|
||||||
|
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
||||||
|
with a random trycloudflare.com URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name of the Docker container to tunnel to
|
||||||
|
port: Port number the container listens on
|
||||||
|
timeout: Maximum seconds to wait for tunnel URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# First verify the container is accessible
|
||||||
|
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||||
|
for attempt in range(10):
|
||||||
|
check = subprocess.run(
|
||||||
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
f"http://{container_name}:{port}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
||||||
|
if check.returncode == 0:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
||||||
|
|
||||||
|
# Run cloudflared in background, capture output
|
||||||
|
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for the URL to appear in output
|
||||||
|
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||||
|
start_time = time.time()
|
||||||
|
url = None
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
# Read available output
|
||||||
|
import select
|
||||||
|
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||||
|
if readable:
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if line:
|
||||||
|
match = url_pattern.search(line)
|
||||||
|
if match:
|
||||||
|
url = match.group(0)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to get tunnel URL within {timeout}s. "
|
||||||
|
f"cloudflared output may contain errors."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"url": url, "pid": str(proc.pid)}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_cloudflared_tunnel(pid: str) -> None:
|
||||||
|
"""Stop a cloudflared tunnel process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid: Process ID of the cloudflared tunnel
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.kill(int(pid), signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass # Already stopped
|
||||||
|
|
||||||
|
|
||||||
|
def recreate_tunnel(
|
||||||
|
container_name: str, port: int, old_pid: str | None = None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Recreate a temporary Cloudflare tunnel.
|
||||||
|
|
||||||
|
Stops the old tunnel (if pid provided) and starts a new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name of the Docker container to tunnel to
|
||||||
|
port: Port number the container listens on
|
||||||
|
old_pid: Optional PID of the old tunnel process to stop
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' and 'pid' for the new tunnel
|
||||||
|
"""
|
||||||
|
if old_pid:
|
||||||
|
stop_cloudflared_tunnel(old_pid)
|
||||||
|
|
||||||
|
return start_cloudflared_tunnel(container_name, port)
|
||||||
|
|
||||||
|
|
||||||
|
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||||
|
"""Check if a tunnel URL is healthy with smart error classification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The tunnel URL to check
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
|
||||||
|
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
"--max-time", str(timeout), url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout + 5,
|
||||||
|
)
|
||||||
|
status_code = int(result.stdout.strip())
|
||||||
|
|
||||||
|
if 200 <= status_code < 400:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "healthy",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": True,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
elif status_code in (502, 503, 504):
|
||||||
|
# Application error, not tunnel error
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Application returned HTTP {status_code}",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"HTTP {status_code}",
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": "Tunnel request timed out",
|
||||||
|
}
|
||||||
|
except (ValueError, Exception) as e:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
# Classify connection errors
|
||||||
|
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Tunnel unreachable: {e}",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Docker build service for building images from Dockerfiles."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None) -> tuple[int, str, str]:
|
||||||
|
"""Build a Docker image from a Dockerfile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Directory containing the Dockerfile
|
||||||
|
dockerfile: Dockerfile content
|
||||||
|
tag: Image tag to apply
|
||||||
|
build_context: Optional build context files {path: content}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (returncode, stdout, stderr)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Write Dockerfile
|
||||||
|
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||||
|
dockerfile_path.write_text(dockerfile)
|
||||||
|
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
||||||
|
|
||||||
|
# Write build context files
|
||||||
|
if build_context:
|
||||||
|
for file_path, content in build_context.items():
|
||||||
|
full_path = Path(instance_dir) / file_path
|
||||||
|
# Security: ensure path doesn't escape instance_dir
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(Path(instance_dir).resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.error("Build context file path escapes instance directory: %s", file_path)
|
||||||
|
raise ValueError(f"Build context file path '{file_path}' escapes instance directory")
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
logger.info("Wrote build context file: %s", full_path)
|
||||||
|
|
||||||
|
# Build image
|
||||||
|
logger.info("Building Docker image with tag: %s", tag)
|
||||||
|
cmd = [
|
||||||
|
"docker", "build",
|
||||||
|
"-t", tag,
|
||||||
|
"-f", str(dockerfile_path),
|
||||||
|
instance_dir,
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300, # 5 minute timeout for builds
|
||||||
|
)
|
||||||
|
logger.info("Docker build completed: returncode=%d", result.returncode)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Docker build timed out after 300 seconds")
|
||||||
|
return 1, "", "Build timed out after 300 seconds"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Docker build failed: %s", exc)
|
||||||
|
return 1, "", str(exc)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Readiness probe service for checking if containers are ready."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_probe(
|
||||||
|
container_id: str,
|
||||||
|
command: str,
|
||||||
|
timeout: int = 30,
|
||||||
|
interval: int = 2,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Execute a readiness probe command inside a container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name
|
||||||
|
command: Command to execute inside the container
|
||||||
|
timeout: Maximum total time to wait (seconds)
|
||||||
|
interval: Time between retries (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, logs)
|
||||||
|
"""
|
||||||
|
logs = []
|
||||||
|
start_time = asyncio.get_event_loop().time()
|
||||||
|
attempt = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
attempt += 1
|
||||||
|
elapsed = asyncio.get_event_loop().time() - start_time
|
||||||
|
|
||||||
|
if elapsed >= timeout:
|
||||||
|
logs.append(f"Probe timed out after {timeout}s ({attempt} attempts)")
|
||||||
|
return False, logs
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug("Probe attempt %d: %s", attempt, command)
|
||||||
|
|
||||||
|
# Execute command inside container
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "exec", container_id, "sh", "-c", command],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=interval, # Each attempt has its own timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
logs.append(f"Attempt {attempt}: Success")
|
||||||
|
if result.stdout:
|
||||||
|
logs.append(f"Output: {result.stdout.strip()}")
|
||||||
|
return True, logs
|
||||||
|
else:
|
||||||
|
logs.append(f"Attempt {attempt}: Failed (exit code {result.returncode})")
|
||||||
|
if result.stderr:
|
||||||
|
logs.append(f"Stderr: {result.stderr.strip()[:200]}")
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logs.append(f"Attempt {attempt}: Command timed out")
|
||||||
|
except Exception as exc:
|
||||||
|
logs.append(f"Attempt {attempt}: Error - {exc}")
|
||||||
|
|
||||||
|
# Wait before next attempt
|
||||||
|
await asyncio.sleep(interval)
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
"""Terminal session management for tool instances."""
|
"""Terminal session management for tool instances."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import select
|
||||||
|
import struct
|
||||||
|
import fcntl
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -14,61 +19,76 @@ class TerminalSession:
|
|||||||
self.container_id = container_id
|
self.container_id = container_id
|
||||||
self.process: asyncio.subprocess.Process | None = None
|
self.process: asyncio.subprocess.Process | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
self._master_fd: int | None = None
|
||||||
|
self._slave_fd: int | None = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the docker exec process with a shell."""
|
"""Start the docker exec process with a shell using a PTY."""
|
||||||
|
# Create a pseudo-terminal on the host
|
||||||
|
self._master_fd, self._slave_fd = pty.openpty()
|
||||||
|
|
||||||
|
# Set the terminal size initially
|
||||||
|
self._set_terminal_size(80, 24)
|
||||||
|
|
||||||
|
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||||
|
# Using -it because the slave fd IS a TTY
|
||||||
self.process = await asyncio.create_subprocess_exec(
|
self.process = await asyncio.create_subprocess_exec(
|
||||||
"docker",
|
"docker",
|
||||||
"exec",
|
"exec",
|
||||||
"-i",
|
"-it",
|
||||||
|
"-e",
|
||||||
|
"TERM=xterm",
|
||||||
self.container_id,
|
self.container_id,
|
||||||
"/bin/sh",
|
"bash",
|
||||||
"-c",
|
"-il",
|
||||||
"exec bash -l || exec sh -l",
|
stdin=self._slave_fd,
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdout=self._slave_fd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stderr=self._slave_fd,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Close slave fd in parent process
|
||||||
|
os.close(self._slave_fd)
|
||||||
|
self._slave_fd = None
|
||||||
|
|
||||||
|
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||||
|
"""Set the terminal size using TIOCSWINSZ."""
|
||||||
|
if self._master_fd is None:
|
||||||
|
return
|
||||||
|
# TIOCSWINSZ = 0x5414 on Linux
|
||||||
|
TIOCSWINSZ = 0x5414
|
||||||
|
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||||
|
try:
|
||||||
|
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||||
|
except (OSError, IOError):
|
||||||
|
pass
|
||||||
|
|
||||||
async def read_output(self) -> bytes:
|
async def read_output(self) -> bytes:
|
||||||
"""Read output from the process."""
|
"""Read output from the PTY master."""
|
||||||
if self.process is None or self.process.stdout is None:
|
if self._master_fd is None or self._closed:
|
||||||
return b""
|
return b""
|
||||||
try:
|
try:
|
||||||
return await self.process.stdout.read(4096)
|
# Use select to check if data is available
|
||||||
except (asyncio.CancelledError, BrokenPipeError):
|
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||||
|
if readable:
|
||||||
|
return os.read(self._master_fd, 4096)
|
||||||
|
return b""
|
||||||
|
except (OSError, IOError, ValueError):
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
async def write_input(self, data: bytes) -> None:
|
async def write_input(self, data: bytes) -> None:
|
||||||
"""Write input to the process."""
|
"""Write input to the PTY master."""
|
||||||
if self.process is None or self.process.stdin is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self.process.stdin.write(data)
|
os.write(self._master_fd, data)
|
||||||
await self.process.stdin.drain()
|
except (OSError, IOError):
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def resize(self, cols: int, rows: int) -> None:
|
async def resize(self, cols: int, rows: int) -> None:
|
||||||
"""Resize the terminal."""
|
"""Resize the terminal."""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
try:
|
self._set_terminal_size(cols, rows)
|
||||||
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:
|
async def close(self) -> None:
|
||||||
"""Close the session and cleanup."""
|
"""Close the session and cleanup."""
|
||||||
@@ -76,6 +96,13 @@ class TerminalSession:
|
|||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
|
||||||
|
if self._master_fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(self._master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._master_fd = None
|
||||||
|
|
||||||
if self.process is not None:
|
if self.process is not None:
|
||||||
try:
|
try:
|
||||||
self.process.kill()
|
self.process.kill()
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ def get_status(repo_path: str) -> GitStatus:
|
|||||||
# Get current branch
|
# Get current branch
|
||||||
try:
|
try:
|
||||||
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||||
|
except RuntimeError:
|
||||||
|
try:
|
||||||
|
branch = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
branch = "HEAD"
|
branch = "HEAD"
|
||||||
|
|
||||||
@@ -118,6 +121,20 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If branch creation fails
|
RuntimeError: If branch creation fails
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
||||||
|
except RuntimeError:
|
||||||
|
# No commits yet - empty repository
|
||||||
|
try:
|
||||||
|
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "work tree" in str(e).lower():
|
||||||
|
# Bare repository - use symbolic-ref instead
|
||||||
|
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
return
|
||||||
|
|
||||||
_run_git_command(repo_path, "branch", name, base_branch)
|
_run_git_command(repo_path, "branch", name, base_branch)
|
||||||
|
|
||||||
|
|
||||||
@@ -146,7 +163,14 @@ def checkout_branch(repo_path: str, name: str) -> None:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If checkout fails
|
RuntimeError: If checkout fails
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
_run_git_command(repo_path, "checkout", name)
|
_run_git_command(repo_path, "checkout", name)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "work tree" in str(e).lower():
|
||||||
|
# Bare repository - use symbolic-ref instead
|
||||||
|
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def commit_changes(
|
def commit_changes(
|
||||||
@@ -215,7 +239,8 @@ def pull(repo_path: str, branch: str | None = None) -> None:
|
|||||||
"""
|
"""
|
||||||
args = ["pull"]
|
args = ["pull"]
|
||||||
if branch:
|
if branch:
|
||||||
args.extend(["origin", branch])
|
args.append("origin")
|
||||||
|
args.append(branch)
|
||||||
_run_git_command(repo_path, *args)
|
_run_git_command(repo_path, *args)
|
||||||
|
|
||||||
|
|
||||||
@@ -279,4 +304,11 @@ def get_current_branch(repo_path: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Current branch name
|
Current branch name
|
||||||
"""
|
"""
|
||||||
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
try:
|
||||||
|
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||||
|
if branch != "HEAD":
|
||||||
|
return branch
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Git file utilities for browsing repository contents."""
|
"""Git file utilities for browsing repository contents."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -50,10 +51,31 @@ def _run_git_command(repo_path: str, *args: str) -> str:
|
|||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(f"Git command failed: {result.stderr}")
|
stderr = result.stderr
|
||||||
|
# Handle "dubious ownership" security error
|
||||||
|
if "dubious ownership" in stderr.lower():
|
||||||
|
logger.warning("Git ownership mismatch for %s, adding to safe.directory", repo_path)
|
||||||
|
# Add this directory to git's safe.directory list
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "--global", "--add", "safe.directory", repo_path],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
# Retry the command
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=repo_path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout
|
||||||
|
stderr = result.stderr
|
||||||
|
raise RuntimeError(f"Git command failed: {stderr}")
|
||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[FileTreeEntry]:
|
def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[FileTreeEntry]:
|
||||||
"""List files and directories in a repository path.
|
"""List files and directories in a repository path.
|
||||||
|
|
||||||
@@ -69,10 +91,20 @@ def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[File
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
|
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
|
||||||
except RuntimeError:
|
except RuntimeError as e:
|
||||||
|
logger.warning("git ls-tree failed for %s with branch '%s': %s", repo_path, tree_path, str(e))
|
||||||
# Try with HEAD if branch doesn't exist
|
# Try with HEAD if branch doesn't exist
|
||||||
tree_path = f"HEAD:{path}" if path else "HEAD"
|
tree_path = f"HEAD:{path}" if path else "HEAD"
|
||||||
|
try:
|
||||||
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
|
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error("git ls-tree failed for %s with HEAD: %s", repo_path, str(e))
|
||||||
|
# Check if this is an empty repository (no commits yet)
|
||||||
|
error_msg = str(e).lower()
|
||||||
|
if "not a valid object name" in error_msg or "does not exist" in error_msg:
|
||||||
|
# Empty repository - return empty list
|
||||||
|
return []
|
||||||
|
raise
|
||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
for line in output.strip().split("\n"):
|
for line in output.strip().split("\n"):
|
||||||
@@ -248,7 +280,11 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
|||||||
Tuple of (list of BranchInfo, default branch name)
|
Tuple of (list of BranchInfo, default branch name)
|
||||||
"""
|
"""
|
||||||
# Get all branches
|
# Get all branches
|
||||||
|
try:
|
||||||
output = _run_git_command(repo_path, "branch", "-a", "--format=%(refname:short)")
|
output = _run_git_command(repo_path, "branch", "-a", "--format=%(refname:short)")
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error("Failed to list branches for %s: %s", repo_path, str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
branches: list[BranchInfo] = []
|
branches: list[BranchInfo] = []
|
||||||
default_branch = "main"
|
default_branch = "main"
|
||||||
@@ -309,6 +345,19 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
default_branch = branch_name
|
default_branch = branch_name
|
||||||
|
except RuntimeError:
|
||||||
|
try:
|
||||||
|
output = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD")
|
||||||
|
branch_name = output.strip()
|
||||||
|
if branch_name:
|
||||||
|
branches.append(
|
||||||
|
BranchInfo(
|
||||||
|
name=branch_name,
|
||||||
|
is_default=True,
|
||||||
|
last_commit=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
default_branch = branch_name
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+129
-68
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from typing import AsyncGenerator, Generator
|
from typing import AsyncGenerator, Generator
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
@@ -11,82 +12,142 @@ from sqlalchemy import create_engine, text
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
# Set test environment BEFORE importing app modules
|
||||||
|
os.environ["APP_ENV"] = "testing"
|
||||||
|
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||||
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings, build_database_url
|
||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.main import app
|
from src.main import app
|
||||||
|
from src.auth.dependencies import get_db_session
|
||||||
|
|
||||||
# Unit test fixtures (SQLite in-memory)
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sqlite_engine():
|
|
||||||
"""Create a SQLite in-memory engine for unit tests."""
|
|
||||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
yield engine
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sqlite_session(sqlite_engine) -> Generator:
|
|
||||||
"""Provide a SQLite session for unit tests."""
|
|
||||||
connection = sqlite_engine.connect()
|
|
||||||
transaction = connection.begin()
|
|
||||||
session = sessionmaker(bind=connection)()
|
|
||||||
|
|
||||||
yield session
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
transaction.rollback()
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
|
|
||||||
# Integration test fixtures (PostgreSQL)
|
|
||||||
|
|
||||||
TEST_DATABASE_URL = build_database_url(
|
|
||||||
user="headquarter",
|
|
||||||
password="headquarter",
|
|
||||||
host="localhost",
|
|
||||||
port=5432,
|
|
||||||
database="headquarter",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session")
|
|
||||||
async def postgres_engine():
|
|
||||||
"""Create a PostgreSQL engine for integration tests."""
|
|
||||||
engine = create_async_engine(TEST_DATABASE_URL)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
yield engine
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def db_session(postgres_engine) -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
"""Provide a database session with transaction rollback."""
|
|
||||||
async with postgres_engine.connect() as connection:
|
|
||||||
transaction = await connection.begin_nested()
|
|
||||||
session_factory = async_sessionmaker(
|
|
||||||
connection, expire_on_commit=False, class_=AsyncSession
|
|
||||||
)
|
|
||||||
session = session_factory()
|
|
||||||
|
|
||||||
yield session
|
|
||||||
|
|
||||||
await session.close()
|
|
||||||
await transaction.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_client() -> Generator[TestClient, None, None]:
|
def test_client() -> Generator[TestClient, None, None]:
|
||||||
"""Provide a FastAPI test client."""
|
"""Provide a FastAPI test client with SQLite database."""
|
||||||
|
# Create a single engine for this test
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
async def init_db():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
asyncio.run(init_db())
|
||||||
|
|
||||||
|
async def override_get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
# Override the dependency
|
||||||
|
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||||
|
|
||||||
|
# Patch startup events to prevent PostgreSQL connection attempts
|
||||||
|
with patch("src.main.init_database") as mock_init, \
|
||||||
|
patch("src.main.seed_builtin_tool_types") as mock_seed:
|
||||||
|
mock_init.return_value = True
|
||||||
|
mock_seed.return_value = None
|
||||||
|
|
||||||
|
try:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
yield client
|
yield client
|
||||||
|
finally:
|
||||||
|
# Clean up overrides
|
||||||
|
app.dependency_overrides.pop(get_db_session, None)
|
||||||
|
asyncio.run(engine.dispose())
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture
|
||||||
def configure_test_env(monkeypatch):
|
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Configure environment for testing."""
|
"""Provide an authenticated test client with a test user."""
|
||||||
monkeypatch.setenv("DATABASE_URL", TEST_DATABASE_URL)
|
import uuid
|
||||||
monkeypatch.setenv("APP_ENV", "testing")
|
from src.auth.session import create_session_cookie
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# Create user in database using the same engine as test_client
|
||||||
|
# We need to access the engine from the test_client fixture
|
||||||
|
# Since we can't easily do that, we'll create the user via API call
|
||||||
|
# But we need the user to exist before any API calls
|
||||||
|
# So we need to create the user using the overridden dependency
|
||||||
|
|
||||||
|
async def create_test_user():
|
||||||
|
# Get the override function
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
id=uuid.UUID(user_id),
|
||||||
|
email="test@headquarter.local",
|
||||||
|
name="Test User",
|
||||||
|
authentik_id=f"authentik-{user_id}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
asyncio.run(create_test_user())
|
||||||
|
|
||||||
|
# Create session cookie
|
||||||
|
session_cookie = create_session_cookie(
|
||||||
|
settings=settings,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set cookie on client
|
||||||
|
test_client.cookies.set("session", session_cookie)
|
||||||
|
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
|
"""Provide an authenticated test client with an admin user."""
|
||||||
|
import uuid
|
||||||
|
from src.auth.session import create_session_cookie
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
async def create_admin_user():
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
id=uuid.UUID(user_id),
|
||||||
|
email="admin@headquarter.local",
|
||||||
|
name="Admin User",
|
||||||
|
authentik_id=f"authentik-admin-{user_id}",
|
||||||
|
avatar_url=None,
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
asyncio.run(create_admin_user())
|
||||||
|
|
||||||
|
# Create session cookie
|
||||||
|
session_cookie = create_session_cookie(
|
||||||
|
settings=settings,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set cookie on client
|
||||||
|
test_client.cookies.set("session", session_cookie)
|
||||||
|
|
||||||
|
yield test_client
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestConfigFoldersAPI:
|
||||||
|
"""Integration tests for config folders API."""
|
||||||
|
|
||||||
|
def test_list_config_folders_requires_authentication(self, test_client: TestClient) -> None:
|
||||||
|
"""Test that listing config folders requires authentication."""
|
||||||
|
response = test_client.get("/config-folders")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_list_config_folders_returns_user_folders(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that authenticated users can list their folders."""
|
||||||
|
response = authenticated_client.get("/config-folders")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert "folders" in data
|
||||||
|
assert isinstance(data["folders"], list)
|
||||||
|
|
||||||
|
def test_create_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a config folder."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "test-folder",
|
||||||
|
"description": "Test folder",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {"test.txt": "hello world"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "test-folder"
|
||||||
|
assert data["mount_path"] == "/home/user"
|
||||||
|
assert data["files"] == {"test.txt": "hello world"}
|
||||||
|
|
||||||
|
def test_create_config_folder_duplicate_name(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that duplicate folder names are rejected."""
|
||||||
|
# Create first folder
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "duplicate-folder",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
# Try to create second with same name
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "duplicate-folder",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_create_config_folder_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that folders exceeding 10MB are rejected."""
|
||||||
|
large_content = "x" * (11 * 1024 * 1024) # 11MB
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "large-folder",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {"large.txt": large_content},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_config_folder_path_traversal_attack(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that path traversal in file paths is prevented."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "bad-folder",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {"../../../etc/passwd": "malicious"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_get_config_folder_by_id(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting a config folder by ID."""
|
||||||
|
# Create folder first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "get-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Get it back
|
||||||
|
response = authenticated_client.get(f"/config-folders/{folder_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "get-test"
|
||||||
|
|
||||||
|
def test_get_config_folder_not_found(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting a non-existent folder."""
|
||||||
|
response = authenticated_client.get(f"/config-folders/{uuid.uuid4()}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a config folder."""
|
||||||
|
# Create folder first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "update-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update it
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-folders/{folder_id}",
|
||||||
|
json={
|
||||||
|
"name": "updated-name",
|
||||||
|
"mount_path": "/workspace",
|
||||||
|
"files": {"new.txt": "content"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "updated-name"
|
||||||
|
assert data["mount_path"] == "/workspace"
|
||||||
|
|
||||||
|
def test_delete_config_folder_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test deleting a config folder."""
|
||||||
|
# Create folder first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "delete-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Delete it
|
||||||
|
response = authenticated_client.delete(f"/config-folders/{folder_id}")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
get_response = authenticated_client.get(f"/config-folders/{folder_id}")
|
||||||
|
assert get_response.status_code == 404
|
||||||
|
|
||||||
|
def test_add_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test adding a project override."""
|
||||||
|
# Create folder first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "override-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {"global.txt": "global"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Add override
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/config-folders/{folder_id}/overrides",
|
||||||
|
json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"mount_path": "/workspace",
|
||||||
|
"files": {"project.txt": "project"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert project_id in data["project_overrides"]
|
||||||
|
|
||||||
|
def test_update_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a project override."""
|
||||||
|
# Create folder with override
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "update-override-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Add override
|
||||||
|
authenticated_client.post(
|
||||||
|
f"/config-folders/{folder_id}/overrides",
|
||||||
|
json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"mount_path": "/workspace",
|
||||||
|
"files": {"old.txt": "old"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update override
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-folders/{folder_id}/overrides/{project_id}",
|
||||||
|
json={
|
||||||
|
"mount_path": "/app",
|
||||||
|
"files": {"new.txt": "new"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["project_overrides"][project_id]["mount_path"] == "/app"
|
||||||
|
|
||||||
|
def test_delete_project_override_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test deleting a project override."""
|
||||||
|
# Create folder with override
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/config-folders",
|
||||||
|
json={
|
||||||
|
"name": "delete-override-test",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
folder_id = create_response.json()["id"]
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Add override
|
||||||
|
authenticated_client.post(
|
||||||
|
f"/config-folders/{folder_id}/overrides",
|
||||||
|
json={
|
||||||
|
"project_id": project_id,
|
||||||
|
"mount_path": "/workspace",
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete override
|
||||||
|
response = authenticated_client.delete(
|
||||||
|
f"/config-folders/{folder_id}/overrides/{project_id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert project_id not in data["project_overrides"]
|
||||||
@@ -63,6 +63,27 @@ class TestGitStatus:
|
|||||||
assert "new.py" in status.untracked
|
assert "new.py" in status.untracked
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_current_branch_handles_unborn_main() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
os.system(f"git init -b main {tmpdir} >/dev/null 2>&1")
|
||||||
|
|
||||||
|
assert get_current_branch(tmpdir) == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_branch_on_bare_repo_with_no_commits() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||||
|
create_branch(f"{tmpdir}/bare.git", "main")
|
||||||
|
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_branch_on_bare_repo_with_no_commits() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||||
|
checkout_branch(f"{tmpdir}/bare.git", "main")
|
||||||
|
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||||
|
|
||||||
|
|
||||||
class TestBranchOperations:
|
class TestBranchOperations:
|
||||||
"""Tests for branch management functions."""
|
"""Tests for branch management functions."""
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from src.models import Base
|
|||||||
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.refresh_token import RefreshToken
|
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
from src.auth.jwt_service import mint_access_token
|
from src.auth.session import create_session_cookie
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings, build_database_url
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
@@ -53,7 +53,7 @@ def _load_app():
|
|||||||
|
|
||||||
def _mint_token(user_id: str) -> str:
|
def _mint_token(user_id: str) -> str:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
return mint_access_token(
|
return create_session_cookie(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestToolConfigsAPIExtended:
|
||||||
|
"""Integration tests for tool configs API with new fields."""
|
||||||
|
|
||||||
|
def test_create_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a tool config with all new fields."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "config-test-tool",
|
||||||
|
"display_name": "Config Test Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Create config with new fields
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "ADVANCED_CONFIG",
|
||||||
|
"value": "test-value",
|
||||||
|
"config_type": "env",
|
||||||
|
"port_override": 9090,
|
||||||
|
"start_command": "python app.py",
|
||||||
|
"working_directory": "/app",
|
||||||
|
"environment_variables": {"DEBUG": "true", "LOG_LEVEL": "debug"},
|
||||||
|
"volumes": [
|
||||||
|
{"source": "data", "target": "/data", "type": "bind"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["key"] == "ADVANCED_CONFIG"
|
||||||
|
assert data["port_override"] == 9090
|
||||||
|
assert data["start_command"] == "python app.py"
|
||||||
|
assert data["working_directory"] == "/app"
|
||||||
|
assert data["environment_variables"] == {"DEBUG": "true", "LOG_LEVEL": "debug"}
|
||||||
|
assert data["volumes"] == [{"source": "data", "target": "/data", "type": "bind"}]
|
||||||
|
|
||||||
|
def test_create_tool_config_invalid_port(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid port numbers are rejected."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "port-test-tool",
|
||||||
|
"display_name": "Port Test Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Try to create config with invalid port
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "BAD_PORT",
|
||||||
|
"value": "test",
|
||||||
|
"config_type": "env",
|
||||||
|
"port_override": 99999,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_tool_config_invalid_volume_structure(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid volume structures are rejected."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "volume-test-tool",
|
||||||
|
"display_name": "Volume Test Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Try to create config with invalid volume
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "BAD_VOLUME",
|
||||||
|
"value": "test",
|
||||||
|
"config_type": "env",
|
||||||
|
"volumes": [{"invalid": "structure"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_update_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a tool config with new fields."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "update-config-tool",
|
||||||
|
"display_name": "Update Config Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Create config
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "UPDATE_TEST",
|
||||||
|
"value": "original",
|
||||||
|
"config_type": "env",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
config_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update with new fields
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/tool-configs/{config_id}",
|
||||||
|
json={
|
||||||
|
"value": "updated",
|
||||||
|
"port_override": 3000,
|
||||||
|
"start_command": "npm start",
|
||||||
|
"working_directory": "/workspace",
|
||||||
|
"environment_variables": {"NODE_ENV": "production"},
|
||||||
|
"volumes": [{"source": "src", "target": "/app/src", "type": "bind"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["value"] == "updated"
|
||||||
|
assert data["port_override"] == 3000
|
||||||
|
assert data["start_command"] == "npm start"
|
||||||
|
assert data["working_directory"] == "/workspace"
|
||||||
|
assert data["environment_variables"] == {"NODE_ENV": "production"}
|
||||||
|
|
||||||
|
def test_list_tool_configs_returns_new_fields(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that listing configs returns new fields."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "list-config-tool",
|
||||||
|
"display_name": "List Config Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Create config with new fields
|
||||||
|
authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "LIST_TEST",
|
||||||
|
"value": "test",
|
||||||
|
"config_type": "env",
|
||||||
|
"port_override": 5000,
|
||||||
|
"environment_variables": {"TEST": "true"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# List configs
|
||||||
|
response = authenticated_client.get("/tool-configs")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) > 0
|
||||||
|
config = data[0]
|
||||||
|
assert "port_override" in config
|
||||||
|
assert "start_command" in config
|
||||||
|
assert "working_directory" in config
|
||||||
|
assert "environment_variables" in config
|
||||||
|
assert "volumes" in config
|
||||||
|
|
||||||
|
def test_get_tool_config_defaults(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test getting tool config defaults."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "defaults-tool",
|
||||||
|
"display_name": "Defaults Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n volumes:\n - \"{{REPO_PATH}}:/workspace\"\n",
|
||||||
|
"required_variables": ["REPO_PATH"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Get defaults
|
||||||
|
response = authenticated_client.get(f"/tool-configs/defaults/{tool_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["tool_type_id"] == tool_id
|
||||||
|
assert "suggested_configs" in data
|
||||||
|
|
||||||
|
def test_tool_config_backward_compatibility(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that old configs without new fields still work."""
|
||||||
|
# Create a tool type first
|
||||||
|
tool_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "backward-compat-tool",
|
||||||
|
"display_name": "Backward Compat Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = tool_response.json()["id"]
|
||||||
|
|
||||||
|
# Create config without new fields (simulating old client)
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-configs",
|
||||||
|
json={
|
||||||
|
"tool_type_id": tool_id,
|
||||||
|
"key": "OLD_STYLE",
|
||||||
|
"value": "value",
|
||||||
|
"config_type": "env",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["key"] == "OLD_STYLE"
|
||||||
|
# New fields should have default values
|
||||||
|
assert data["port_override"] is None
|
||||||
|
assert data["start_command"] is None
|
||||||
|
assert data["working_directory"] is None
|
||||||
|
assert data["environment_variables"] is None
|
||||||
|
assert data["volumes"] is None
|
||||||
@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
from src.auth.jwt_service import mint_access_token
|
from src.auth.session import create_session_cookie
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings, build_database_url
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
@@ -54,7 +54,7 @@ def _load_app():
|
|||||||
|
|
||||||
def _mint_token(user_id: str) -> str:
|
def _mint_token(user_id: str) -> str:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
return mint_access_token(
|
return create_session_cookie(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestToolTypesAPIExtended:
|
||||||
|
"""Integration tests for tool types API with new fields."""
|
||||||
|
|
||||||
|
def test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a tool type with dockerfile definition."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "dockerfile-tool",
|
||||||
|
"display_name": "Dockerfile Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interfaces": ["terminal"],
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "dockerfile-tool"
|
||||||
|
assert data["definition_type"] == "dockerfile"
|
||||||
|
assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask"
|
||||||
|
|
||||||
|
def test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a tool type with readiness probe."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "probed-tool",
|
||||||
|
"display_name": "Probed Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"readiness_probe": {
|
||||||
|
"command": "curl -f http://localhost:8080",
|
||||||
|
"timeout": 30,
|
||||||
|
"interval": 2,
|
||||||
|
},
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080"
|
||||||
|
assert data["readiness_probe"]["timeout"] == 30
|
||||||
|
|
||||||
|
def test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid definition types are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "invalid-tool",
|
||||||
|
"display_name": "Invalid Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "invalid",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that dockerfile type requires dockerfile_template."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "no-dockerfile",
|
||||||
|
"display_name": "No Dockerfile",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a tool type with new fields."""
|
||||||
|
# Create tool type first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "update-test-tool",
|
||||||
|
"display_name": "Update Test Tool",
|
||||||
|
"default_port": 8080,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update it
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/tool-types/{tool_id}",
|
||||||
|
json={
|
||||||
|
"display_name": "Updated Name",
|
||||||
|
"readiness_probe": {
|
||||||
|
"command": "curl -f http://localhost:8080/health",
|
||||||
|
"timeout": 60,
|
||||||
|
"interval": 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["display_name"] == "Updated Name"
|
||||||
|
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health"
|
||||||
|
|
||||||
|
def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test validating compose template."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types/validate",
|
||||||
|
json={
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["valid"] is True
|
||||||
|
|
||||||
|
def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test validating invalid compose template."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types/validate",
|
||||||
|
json={
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "invalid: yaml: [",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["valid"] is False
|
||||||
|
assert "errors" in data
|
||||||
|
|
||||||
|
def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test validating dockerfile template."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types/validate",
|
||||||
|
json={
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["valid"] is True
|
||||||
|
|
||||||
|
def test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that GET returns new fields."""
|
||||||
|
# Create tool type with all fields
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "full-tool",
|
||||||
|
"display_name": "Full Tool",
|
||||||
|
"category": "editor",
|
||||||
|
"interfaces": ["web", "terminal"],
|
||||||
|
"default_port": 8443,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
||||||
|
"readiness_probe": {
|
||||||
|
"command": "curl -f http://localhost:8443",
|
||||||
|
"timeout": 30,
|
||||||
|
"interval": 2,
|
||||||
|
},
|
||||||
|
"required_variables": ["REPO_PATH"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Get it
|
||||||
|
response = authenticated_client.get(f"/tool-types/{tool_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["definition_type"] == "compose"
|
||||||
|
assert data["category"] == "editor"
|
||||||
|
assert data["interfaces"] == ["web", "terminal"]
|
||||||
|
assert "readiness_probe" in data
|
||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from src.auth.jwt_service import mint_access_token
|
from src.auth.session import create_session_cookie
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings, build_database_url
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -78,7 +78,7 @@ def _insert_test_user(user_id: str) -> None:
|
|||||||
|
|
||||||
def _create_auth_cookie(user_id: str) -> str:
|
def _create_auth_cookie(user_id: str) -> str:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
return mint_access_token(
|
return create_session_cookie(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Unit tests for docker build service."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.docker_build import build_image
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildImage:
|
||||||
|
"""Tests for build_image function."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_builds_image_successfully(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="Successfully built abc123",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
result = build_image(tmpdir, "FROM python:3.11", "test-image:latest")
|
||||||
|
|
||||||
|
assert result[0] == 0
|
||||||
|
assert "Successfully built" in result[1]
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
call_args = mock_run.call_args
|
||||||
|
assert "test-image:latest" in call_args[0][0]
|
||||||
|
assert "build" in call_args[0][0]
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_fails(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=1,
|
||||||
|
stdout="",
|
||||||
|
stderr="Error: failed to build",
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
result = build_image(tmpdir, "FROM invalid:image", "test-image:latest")
|
||||||
|
|
||||||
|
assert result[0] == 1
|
||||||
|
assert "failed to build" in result[2]
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_with_tag(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
build_image(tmpdir, "FROM python:3.11", "my-registry/tool:v1.0")
|
||||||
|
|
||||||
|
call_args = mock_run.call_args[0][0]
|
||||||
|
assert "my-registry/tool:v1.0" in call_args
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_command_structure(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
build_image(tmpdir, "FROM python:3.11", "test:latest")
|
||||||
|
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert cmd[0] == "docker"
|
||||||
|
assert cmd[1] == "build"
|
||||||
|
assert "-t" in cmd
|
||||||
|
assert "test:latest" in cmd
|
||||||
|
assert tmpdir in cmd
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_writes_dockerfile(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
dockerfile_content = "FROM python:3.11\\nRUN pip install flask"
|
||||||
|
build_image(tmpdir, dockerfile_content, "test:latest")
|
||||||
|
|
||||||
|
dockerfile_path = Path(tmpdir) / "Dockerfile"
|
||||||
|
assert dockerfile_path.exists()
|
||||||
|
assert dockerfile_path.read_text() == dockerfile_content
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_writes_context_files(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
build_context = {
|
||||||
|
"requirements.txt": "flask==2.0\\nnumpy==1.21",
|
||||||
|
"app.py": "from flask import Flask\\napp = Flask(__name__)",
|
||||||
|
}
|
||||||
|
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
|
||||||
|
|
||||||
|
req_path = Path(tmpdir) / "requirements.txt"
|
||||||
|
app_path = Path(tmpdir) / "app.py"
|
||||||
|
assert req_path.exists()
|
||||||
|
assert req_path.read_text() == "flask==2.0\\nnumpy==1.21"
|
||||||
|
assert app_path.exists()
|
||||||
|
assert app_path.read_text() == "from flask import Flask\\napp = Flask(__name__)"
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_creates_nested_directories(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
build_context = {
|
||||||
|
"src/app.py": "print('hello')",
|
||||||
|
}
|
||||||
|
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
|
||||||
|
|
||||||
|
app_path = Path(tmpdir) / "src" / "app.py"
|
||||||
|
assert app_path.exists()
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_prevents_path_traversal(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
build_context = {
|
||||||
|
"../../../etc/passwd": "root:x:0:0",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="escapes instance directory"):
|
||||||
|
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
|
||||||
|
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_timeout(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["docker", "build"], timeout=300)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
|
||||||
|
|
||||||
|
assert result[0] == 1
|
||||||
|
assert "timed out" in result[2].lower()
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_build_exception(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = OSError("Docker not available")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
|
||||||
|
|
||||||
|
assert result[0] == 1
|
||||||
|
assert "Docker not available" in result[2]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.api.git_repositories import _build_provider_clone_url, _preflight_remote_repository
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_provider_clone_url_uses_fixed_host() -> None:
|
||||||
|
assert _build_provider_clone_url("alice", "demo") == "git@git.commumedia.org:alice/demo.git"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_remote_repository_allows_accessible_repo() -> None:
|
||||||
|
completed = Mock(returncode=0)
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock:
|
||||||
|
_preflight_remote_repository("git@git.commumedia.org:alice/demo.git")
|
||||||
|
|
||||||
|
run_mock.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_remote_repository_rejects_missing_repo() -> None:
|
||||||
|
completed = Mock(returncode=128)
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", return_value=completed):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_preflight_remote_repository("git@git.commumedia.org:alice/missing.git")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert exc_info.value.detail == "repository not found or inaccessible"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.api.git_repositories import _clone_working_repository, _init_working_repository
|
||||||
|
from src.utils.git_control import create_branch
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_working_repository_uses_normal_clone() -> None:
|
||||||
|
completed = Mock(returncode=0, stderr="")
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock:
|
||||||
|
_clone_working_repository("git@git.commumedia.org:alice/demo.git", "/tmp/demo.git")
|
||||||
|
|
||||||
|
run_mock.assert_called_once()
|
||||||
|
assert run_mock.call_args.args[0] == ["git", "clone", "git@git.commumedia.org:alice/demo.git", "/tmp/demo.git"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_working_repository_raises_on_failure() -> None:
|
||||||
|
completed = Mock(returncode=128, stderr="fatal: repository not found")
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", return_value=completed):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
_clone_working_repository("git@git.commumedia.org:alice/missing.git", "/tmp/missing.git")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert "failed to clone repository" in exc_info.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_working_repository_prefers_init_b() -> None:
|
||||||
|
init_b = Mock(returncode=0, stderr="")
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", return_value=init_b) as run_mock:
|
||||||
|
_init_working_repository("/tmp/new-repo")
|
||||||
|
|
||||||
|
assert run_mock.call_args.args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_working_repository_falls_back_to_symbolic_ref() -> None:
|
||||||
|
init_b = Mock(returncode=1, stderr="unknown switch `b'")
|
||||||
|
init_ok = Mock(returncode=0, stderr="")
|
||||||
|
symbolic_ref = Mock(returncode=0, stderr="")
|
||||||
|
|
||||||
|
with patch("src.api.git_repositories.subprocess.run", side_effect=[init_b, init_ok, symbolic_ref]) as run_mock:
|
||||||
|
_init_working_repository("/tmp/new-repo")
|
||||||
|
|
||||||
|
assert run_mock.call_args_list[0].args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"]
|
||||||
|
assert run_mock.call_args_list[1].args[0] == ["git", "init", "/tmp/new-repo"]
|
||||||
|
assert run_mock.call_args_list[2].args[0] == ["git", "-C", "/tmp/new-repo", "symbolic-ref", "HEAD", "refs/heads/main"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_branch_uses_orphan_checkout_when_head_is_unborn() -> None:
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def mock_run(repo_path: str, *args: str) -> str:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
raise RuntimeError("fatal: Needed a single revision")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
with patch("src.utils.git_control._run_git_command", side_effect=mock_run) as run_mock:
|
||||||
|
create_branch("/tmp/new-repo", "feature/test")
|
||||||
|
|
||||||
|
assert run_mock.call_args_list[0].args[1:] == ("rev-parse", "--verify", "HEAD^{commit}")
|
||||||
|
assert run_mock.call_args_list[1].args[1:] == ("checkout", "--orphan", "feature/test")
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Unit tests for readiness probe service."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.readiness_probe import execute_probe
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteProbe:
|
||||||
|
"""Tests for execute_probe function."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_succeeds_first_attempt(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="healthy",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert any("Success" in log for log in logs)
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["docker", "exec", "container-123", "sh", "-c", "curl -f http://localhost:8080"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_fails_then_succeeds(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
|
||||||
|
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
|
||||||
|
MagicMock(returncode=0, stdout="healthy", stderr=""),
|
||||||
|
]
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=10, interval=0.1)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_run.call_count == 3
|
||||||
|
assert any("Attempt 1: Failed" in log for log in logs)
|
||||||
|
assert any("Attempt 3: Success" in log for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_times_out(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=1,
|
||||||
|
stdout="",
|
||||||
|
stderr="Connection refused",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=0.5, interval=0.2)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert any("timed out" in log.lower() for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_command_not_found(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=127,
|
||||||
|
stdout="",
|
||||||
|
stderr="command not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "nonexistent-command", timeout=1, interval=0.3)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert any("exit code 127" in log for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_exception(self, mock_run) -> None:
|
||||||
|
mock_run.side_effect = OSError("Docker not available")
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "curl http://localhost", timeout=1, interval=0.3)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert any("Error" in log for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_with_special_characters(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = "bash -c 'echo \"hello world\" && exit 0'"
|
||||||
|
await execute_probe("container-123", cmd)
|
||||||
|
|
||||||
|
call_args = mock_run.call_args
|
||||||
|
assert cmd in call_args[0][0]
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_probe_captures_stdout(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="Server is ready\\nVersion: 1.0",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, logs = await execute_probe("container-123", "cat /app/status")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert any("Server is ready" in log for log in logs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegrationScenarios:
|
||||||
|
"""Integration-style tests with realistic scenarios."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_web_server_probe(self, mock_run) -> None:
|
||||||
|
"""Test typical web server health check."""
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=1, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=1, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=1, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="OK", stderr=""),
|
||||||
|
]
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"web-container",
|
||||||
|
"curl -f http://localhost:8080/health",
|
||||||
|
timeout=10,
|
||||||
|
interval=0.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_run.call_count == 4
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_command_probe(self, mock_run) -> None:
|
||||||
|
"""Test command availability check."""
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout="opencode 1.0.0",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"tool-container",
|
||||||
|
"which opencode && opencode --version",
|
||||||
|
timeout=30,
|
||||||
|
interval=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert any("opencode 1.0.0" in log for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_database_probe(self, mock_run) -> None:
|
||||||
|
"""Test database readiness check."""
|
||||||
|
mock_run.side_effect = [
|
||||||
|
MagicMock(returncode=1, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=1, stdout="", stderr=""),
|
||||||
|
MagicMock(returncode=0, stdout="/var/run/postgresql:5432 - accepting connections", stderr=""),
|
||||||
|
]
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"db-container",
|
||||||
|
"pg_isready -U postgres",
|
||||||
|
timeout=10,
|
||||||
|
interval=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_run.call_count == 3
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_file_probe(self, mock_run) -> None:
|
||||||
|
"""Test file existence check."""
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"app-container",
|
||||||
|
"[ -f /app/ready ]",
|
||||||
|
timeout=10,
|
||||||
|
interval=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_slow_starting_service(self, mock_run) -> None:
|
||||||
|
"""Test service that takes time to start."""
|
||||||
|
# Simulate 5 failures before success
|
||||||
|
side_effects = [MagicMock(returncode=1, stdout="", stderr="")] * 5
|
||||||
|
side_effects.append(MagicMock(returncode=0, stdout="Ready", stderr=""))
|
||||||
|
mock_run.side_effect = side_effects
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"slow-container",
|
||||||
|
"curl -f http://localhost:8080",
|
||||||
|
timeout=10,
|
||||||
|
interval=0.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_run.call_count == 6
|
||||||
|
assert any("Attempt 6: Success" in log for log in logs)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
async def test_zero_timeout_immediate_return(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
||||||
|
|
||||||
|
result, logs = await execute_probe(
|
||||||
|
"container",
|
||||||
|
"test",
|
||||||
|
timeout=0,
|
||||||
|
interval=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert any("timed out" in log.lower() for log in logs)
|
||||||
Generated
+1371
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Headquarter</title>
|
<title>Headquarter</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Headquarter - UI Preview</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f1ea;
|
||||||
|
--panel: #fffef9;
|
||||||
|
--ink: #1d1d1b;
|
||||||
|
--muted: #5f5b55;
|
||||||
|
--brand: #275d4b;
|
||||||
|
--brand-strong: #154236;
|
||||||
|
--border: #d8d0c5;
|
||||||
|
--primary: #275d4b;
|
||||||
|
--primary-fg: #fffef9;
|
||||||
|
--color-primary: #275d4b;
|
||||||
|
--success: #2f8f62;
|
||||||
|
--success-light: rgba(47, 143, 98, 0.14);
|
||||||
|
--warning: #c08a1e;
|
||||||
|
--warning-light: rgba(192, 138, 30, 0.14);
|
||||||
|
--danger: #b94a3c;
|
||||||
|
--danger-light: rgba(185, 74, 60, 0.14);
|
||||||
|
--info: #4f7fb8;
|
||||||
|
--info-light: rgba(79, 127, 184, 0.14);
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-5: 1.5rem;
|
||||||
|
--space-6: 2rem;
|
||||||
|
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
|
||||||
|
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
|
||||||
|
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
|
||||||
|
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
|
||||||
|
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App Shell */
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
|
backdrop-filter: blur(7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.58rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 230px 1fr;
|
||||||
|
min-height: calc(100vh - 57px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: #ece7df;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: #f7fff7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section-title {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status.running {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-content {
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Common Components */
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-sm {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.58rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
background: var(--brand-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.58rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Home Page */
|
||||||
|
.home-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-card .card-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-card .card-value {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h2,
|
||||||
|
.home-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-session-grid,
|
||||||
|
.home-project-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.running {
|
||||||
|
background: var(--success-light);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.building {
|
||||||
|
background: var(--warning-light);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending {
|
||||||
|
background: var(--info-light);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-sessions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-session-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-session-form .form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input,
|
||||||
|
.form-field select,
|
||||||
|
.form-field textarea {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings Page */
|
||||||
|
.settings-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview Switcher */
|
||||||
|
.preview-switcher {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-switcher button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-switcher button.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-preview {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-preview.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.shell-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.shell-nav {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.home-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<header class="shell-header">
|
||||||
|
<a href="#" class="brand">Headquarter</a>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="#" class="user-chip">User</a>
|
||||||
|
<button class="ghost-button">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="shell-body">
|
||||||
|
<aside class="shell-nav" aria-label="Primary navigation">
|
||||||
|
<a href="#" class="nav-item nav-item-active">
|
||||||
|
<span>🏠</span> Home
|
||||||
|
<span class="nav-badge">3</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item">
|
||||||
|
<span>📁</span> Projects
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item">
|
||||||
|
<span>⚙️</span> Settings
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-divider"></div>
|
||||||
|
<div class="nav-section-title">Live sessions</div>
|
||||||
|
<a href="#" class="nav-item session-item">
|
||||||
|
<span class="session-status running"></span>
|
||||||
|
<span>Dev Environment</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item session-item">
|
||||||
|
<span class="session-status running"></span>
|
||||||
|
<span>Jupyter Lab</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item session-item">
|
||||||
|
<span class="session-status"></span>
|
||||||
|
<span>Code Server</span>
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="shell-content">
|
||||||
|
<!-- HOME PAGE PREVIEW -->
|
||||||
|
<div id="home-preview" class="page-preview active">
|
||||||
|
<section class="stack home-page">
|
||||||
|
<header class="home-hero card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<p class="eyebrow">Workspace overview</p>
|
||||||
|
<h1>Home</h1>
|
||||||
|
<p class="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||||
|
</div>
|
||||||
|
<div class="home-hero-actions">
|
||||||
|
<button class="primary-button">New Project</button>
|
||||||
|
<button class="secondary-button">Settings</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="home-summary-grid">
|
||||||
|
<article class="card home-summary-card">
|
||||||
|
<p class="card-label">Open sessions</p>
|
||||||
|
<p class="card-value">3</p>
|
||||||
|
</article>
|
||||||
|
<article class="card home-summary-card">
|
||||||
|
<p class="card-label">Projects</p>
|
||||||
|
<p class="card-value">5</p>
|
||||||
|
</article>
|
||||||
|
<article class="card home-summary-card">
|
||||||
|
<p class="card-label">Repositories</p>
|
||||||
|
<p class="card-value">12</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Open sessions</p>
|
||||||
|
<h2>3</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="home-session-grid">
|
||||||
|
<article class="card session-card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<h3>Dev Environment</h3>
|
||||||
|
<span class="status-badge running">running</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Acme Corp · main</p>
|
||||||
|
<p class="muted">VS Code Server</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card session-card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<h3>Jupyter Lab</h3>
|
||||||
|
<span class="status-badge running">running</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Data Science · experiments</p>
|
||||||
|
<p class="muted">Jupyter Notebook</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card session-card">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<h3>Database Console</h3>
|
||||||
|
<span class="status-badge building">building</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Backend API · staging</p>
|
||||||
|
<p class="muted">PostgreSQL Client</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-actions">
|
||||||
|
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Available projects</p>
|
||||||
|
<h2>5</h2>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-button">View all</button>
|
||||||
|
</div>
|
||||||
|
<div class="home-project-grid">
|
||||||
|
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<h3>Acme Corp</h3>
|
||||||
|
<p class="muted">Main product development</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<h3>Data Science</h3>
|
||||||
|
<p class="muted">ML experiments and notebooks</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
|
||||||
|
<div class="stack-sm">
|
||||||
|
<h3>Backend API</h3>
|
||||||
|
<p class="muted">REST API services</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Quick create</p>
|
||||||
|
<h2>Start a session</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="stack create-session-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="form-field">
|
||||||
|
Project
|
||||||
|
<select>
|
||||||
|
<option>Select project...</option>
|
||||||
|
<option>Acme Corp</option>
|
||||||
|
<option>Data Science</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Repository
|
||||||
|
<select disabled>
|
||||||
|
<option>Select repository...</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Tool type
|
||||||
|
<select>
|
||||||
|
<option>Select tool...</option>
|
||||||
|
<option>VS Code Server</option>
|
||||||
|
<option>Jupyter Lab</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="form-field">
|
||||||
|
Display name
|
||||||
|
<input type="text" placeholder="My Development Environment">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="primary-button" type="submit">Create Session</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card stack home-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Recent sessions</p>
|
||||||
|
<h2>2</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recent-sessions-list">
|
||||||
|
<article class="recent-session-item">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||||
|
<span class="recent-session-name">Old Dev Box</span>
|
||||||
|
<span class="muted">Acme Corp · VS Code Server</span>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
</article>
|
||||||
|
<article class="recent-session-item">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||||
|
<span class="recent-session-name">ML Training</span>
|
||||||
|
<span class="muted">Data Science · Jupyter Lab</span>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SETTINGS PAGE PREVIEW -->
|
||||||
|
<div id="settings-preview" class="page-preview">
|
||||||
|
<section class="stack settings-page">
|
||||||
|
<header class="settings-header card stack-sm">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Configuration</p>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="settings-tabs" aria-label="Settings sections">
|
||||||
|
<a href="#" class="settings-tab active">General</a>
|
||||||
|
<a href="#" class="settings-tab">SSH Keys</a>
|
||||||
|
<a href="#" class="settings-tab">Tool Types</a>
|
||||||
|
<a href="#" class="settings-tab">Tool Configs</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="settings-panel card">
|
||||||
|
<div class="stack">
|
||||||
|
<h2>General</h2>
|
||||||
|
<label class="form-field">
|
||||||
|
Theme
|
||||||
|
<select>
|
||||||
|
<option>System</option>
|
||||||
|
<option>Light</option>
|
||||||
|
<option>Dark</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Git user name
|
||||||
|
<input type="text" placeholder="Your git commit name" value="John Doe">
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Git user email
|
||||||
|
<input type="email" placeholder="your.email@example.com" value="john@example.com">
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
Default editor
|
||||||
|
<input type="text" placeholder="e.g., vscode, vim, cursor" value="vscode">
|
||||||
|
</label>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button class="primary-button">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-switcher">
|
||||||
|
<button class="active" onclick="showPage('home')">Home</button>
|
||||||
|
<button onclick="showPage('settings')">Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showPage(page) {
|
||||||
|
document.querySelectorAll('.page-preview').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.preview-switcher button').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById(page + '-preview').classList.add('active');
|
||||||
|
event.target.classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createConfigFolder,
|
||||||
|
deleteConfigFolder,
|
||||||
|
listConfigFolders,
|
||||||
|
updateConfigFolder,
|
||||||
|
} from "../api/config_folders";
|
||||||
|
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
const mockPut = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
interceptors: {
|
||||||
|
response: {
|
||||||
|
use: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shouldSkipAuthRedirect: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("config_folders API", () => {
|
||||||
|
describe("listConfigFolders", () => {
|
||||||
|
it("returns folders with files and overrides", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "folder-1",
|
||||||
|
name: "my-dotfiles",
|
||||||
|
description: "My personal config files",
|
||||||
|
mount_path: "/home/user",
|
||||||
|
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||||
|
project_overrides: {},
|
||||||
|
is_active: true,
|
||||||
|
user_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await listConfigFolders();
|
||||||
|
|
||||||
|
expect(result[0].name).toBe("my-dotfiles");
|
||||||
|
expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
|
||||||
|
expect(mockGet).toHaveBeenCalledWith("/config-folders");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createConfigFolder", () => {
|
||||||
|
it("creates folder with files", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: "folder-new",
|
||||||
|
name: "new-folder",
|
||||||
|
mount_path: "/workspace",
|
||||||
|
files: { ".env": "API_URL=http://localhost" },
|
||||||
|
is_active: true,
|
||||||
|
user_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockPost.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await createConfigFolder({
|
||||||
|
name: "new-folder",
|
||||||
|
mount_path: "/workspace",
|
||||||
|
files: { ".env": "API_URL=http://localhost" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.name).toBe("new-folder");
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
"/config-folders",
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "new-folder",
|
||||||
|
mount_path: "/workspace",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateConfigFolder", () => {
|
||||||
|
it("updates folder files", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: "folder-1",
|
||||||
|
name: "updated-folder",
|
||||||
|
mount_path: "/home/user",
|
||||||
|
files: { ".bashrc": "alias ll='ls -la'" },
|
||||||
|
is_active: true,
|
||||||
|
user_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockPut.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await updateConfigFolder("folder-1", {
|
||||||
|
files: { ".bashrc": "alias ll='ls -la'" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
|
||||||
|
expect(mockPut).toHaveBeenCalledWith(
|
||||||
|
"/config-folders/folder-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
files: { ".bashrc": "alias ll='ls -la'" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteConfigFolder", () => {
|
||||||
|
it("deletes folder", async () => {
|
||||||
|
mockDelete.mockResolvedValue({ data: undefined });
|
||||||
|
|
||||||
|
await deleteConfigFolder("folder-1");
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ConfigFolder {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
mount_path: string;
|
||||||
|
files: Record<string, string>;
|
||||||
|
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateConfigFolderRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
mount_path: string;
|
||||||
|
files?: Record<string, string>;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateConfigFolderRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
mount_path?: string;
|
||||||
|
files?: Record<string, string>;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectOverrideRequest {
|
||||||
|
mount_path?: string;
|
||||||
|
files?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
|
||||||
|
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfigFolder = async (
|
||||||
|
data: CreateConfigFolderRequest
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateConfigFolder = async (
|
||||||
|
id: string,
|
||||||
|
data: UpdateConfigFolderRequest
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.put<ConfigFolder>(`/config-folders/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteConfigFolder = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/config-folders/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addProjectOverride = async (
|
||||||
|
id: string,
|
||||||
|
projectId: string,
|
||||||
|
data: ProjectOverrideRequest
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.post<ConfigFolder>(
|
||||||
|
`/config-folders/${id}/overrides/${projectId}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProjectOverride = async (
|
||||||
|
id: string,
|
||||||
|
projectId: string,
|
||||||
|
data: ProjectOverrideRequest
|
||||||
|
): Promise<ConfigFolder> => {
|
||||||
|
const response = await apiClient.put<ConfigFolder>(
|
||||||
|
`/config-folders/${id}/overrides/${projectId}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProjectOverride = async (
|
||||||
|
id: string,
|
||||||
|
projectId: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
|
||||||
|
};
|
||||||
@@ -25,6 +25,8 @@ export interface Session {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
|
container_status?: string;
|
||||||
|
probe_status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listInstances(
|
export async function listInstances(
|
||||||
@@ -100,3 +102,37 @@ export async function getUserSessions(): Promise<Session[]> {
|
|||||||
const response = await apiClient.get("/users/me/sessions");
|
const response = await apiClient.get("/users/me/sessions");
|
||||||
return response.data.sessions;
|
return response.data.sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InstanceHealth {
|
||||||
|
healthy: boolean;
|
||||||
|
container_status: string;
|
||||||
|
container_health: string | null;
|
||||||
|
container_exit_code: number | null;
|
||||||
|
tunnel_status: string;
|
||||||
|
tunnel_status_code: number | null;
|
||||||
|
probe_status: string;
|
||||||
|
last_probe_output: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkInstanceHealth(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<InstanceHealth> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recreateInstanceTunnel(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<{ status: string; url?: string }> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ export interface UserConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UserConfigUpdate {
|
export interface UserConfigUpdate {
|
||||||
default_editor?: string;
|
default_editor?: string | null;
|
||||||
theme?: string;
|
theme?: string | null;
|
||||||
git_user_name?: string;
|
git_user_name?: string | null;
|
||||||
git_user_email?: string;
|
git_user_email?: string | null;
|
||||||
last_session_id?: string;
|
last_session_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUserConfig = async (): Promise<UserConfig> => {
|
export const getUserConfig = async (): Promise<UserConfig> => {
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export interface ToolConfig {
|
|||||||
value: string;
|
value: string;
|
||||||
config_type: string;
|
config_type: string;
|
||||||
file_path: string | null;
|
file_path: string | null;
|
||||||
|
port_override: number | null;
|
||||||
|
start_command: string | null;
|
||||||
|
working_directory: string | null;
|
||||||
|
environment_variables: Record<string, string> | null;
|
||||||
|
volumes: Array<{ source: string; target: string; type?: string }> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateToolConfigRequest {
|
export interface CreateToolConfigRequest {
|
||||||
@@ -17,6 +22,11 @@ export interface CreateToolConfigRequest {
|
|||||||
value: string;
|
value: string;
|
||||||
config_type?: string;
|
config_type?: string;
|
||||||
file_path?: string;
|
file_path?: string;
|
||||||
|
port_override?: number;
|
||||||
|
start_command?: string;
|
||||||
|
working_directory?: string;
|
||||||
|
environment_variables?: Record<string, string>;
|
||||||
|
volumes?: Array<{ source: string; target: string; type?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listToolConfigs = async (
|
export const listToolConfigs = async (
|
||||||
@@ -54,3 +64,12 @@ export const updateToolConfig = async (
|
|||||||
export const deleteToolConfig = async (id: string): Promise<void> => {
|
export const deleteToolConfig = async (id: string): Promise<void> => {
|
||||||
await apiClient.delete(`/tool-configs/${id}`);
|
await apiClient.delete(`/tool-configs/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getToolConfigDefaults = async (
|
||||||
|
toolTypeId: string
|
||||||
|
): Promise<ToolConfig> => {
|
||||||
|
const response = await apiClient.get<ToolConfig>(
|
||||||
|
`/tool-configs/defaults/${toolTypeId}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createToolType,
|
||||||
|
deleteToolType,
|
||||||
|
listToolTypes,
|
||||||
|
updateToolType,
|
||||||
|
validateToolType,
|
||||||
|
} from "../api/tool_types";
|
||||||
|
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
const mockPut = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: (...args: unknown[]) => mockGet(...args),
|
||||||
|
post: (...args: unknown[]) => mockPost(...args),
|
||||||
|
put: (...args: unknown[]) => mockPut(...args),
|
||||||
|
delete: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
interceptors: {
|
||||||
|
response: {
|
||||||
|
use: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shouldSkipAuthRedirect: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("tool_types API", () => {
|
||||||
|
describe("listToolTypes", () => {
|
||||||
|
it("returns tool types with new fields", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "type-1",
|
||||||
|
name: "custom-tool",
|
||||||
|
display_name: "Custom Tool",
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM python:3.11",
|
||||||
|
readiness_probe: {
|
||||||
|
command: "python --version",
|
||||||
|
timeout: 30,
|
||||||
|
interval: 2,
|
||||||
|
},
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await listToolTypes();
|
||||||
|
|
||||||
|
expect(result[0].definition_type).toBe("dockerfile");
|
||||||
|
expect(result[0].dockerfile_template).toBe("FROM python:3.11");
|
||||||
|
expect(result[0].readiness_probe).toEqual({
|
||||||
|
command: "python --version",
|
||||||
|
timeout: 30,
|
||||||
|
interval: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns compose tool types", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "type-1",
|
||||||
|
name: "code-server",
|
||||||
|
definition_type: "compose",
|
||||||
|
compose_template: "version: '3.8'",
|
||||||
|
dockerfile_template: null,
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await listToolTypes();
|
||||||
|
|
||||||
|
expect(result[0].definition_type).toBe("compose");
|
||||||
|
expect(result[0].dockerfile_template).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createToolType", () => {
|
||||||
|
it("creates tool type with dockerfile", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: "new-type",
|
||||||
|
name: "docker-tool",
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM node:18",
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockPost.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await createToolType({
|
||||||
|
name: "docker-tool",
|
||||||
|
display_name: "Docker Tool",
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM node:18",
|
||||||
|
default_port: 3000,
|
||||||
|
required_variables: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.definition_type).toBe("dockerfile");
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
"/tool-types",
|
||||||
|
expect.objectContaining({
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM node:18",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates tool type with readiness probe", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: "new-type",
|
||||||
|
name: "probed-tool",
|
||||||
|
readiness_probe: {
|
||||||
|
command: "curl -f http://localhost:8080",
|
||||||
|
timeout: 60,
|
||||||
|
interval: 3,
|
||||||
|
},
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockPost.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await createToolType({
|
||||||
|
name: "probed-tool",
|
||||||
|
display_name: "Probed Tool",
|
||||||
|
compose_template: "version: '3.8'",
|
||||||
|
default_port: 8080,
|
||||||
|
required_variables: [],
|
||||||
|
readiness_probe: {
|
||||||
|
command: "curl -f http://localhost:8080",
|
||||||
|
timeout: 60,
|
||||||
|
interval: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.readiness_probe).toEqual({
|
||||||
|
command: "curl -f http://localhost:8080",
|
||||||
|
timeout: 60,
|
||||||
|
interval: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateToolType", () => {
|
||||||
|
it("validates tool type by id", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: { valid: true, errors: [] },
|
||||||
|
};
|
||||||
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await validateToolType("type-1");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(mockGet).toHaveBeenCalledWith("/tool-types/type-1/validate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns validation errors", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: { valid: false, errors: ["Invalid YAML"] },
|
||||||
|
};
|
||||||
|
mockGet.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await validateToolType("type-1");
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain("Invalid YAML");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateToolType", () => {
|
||||||
|
it("updates tool type with new fields", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: "type-1",
|
||||||
|
name: "updated-tool",
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM python:3.11",
|
||||||
|
build_context: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockPut.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await updateToolType("type-1", {
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM python:3.11",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.definition_type).toBe("dockerfile");
|
||||||
|
expect(mockPut).toHaveBeenCalledWith(
|
||||||
|
"/tool-types/type-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteToolType", () => {
|
||||||
|
it("deletes tool type", async () => {
|
||||||
|
mockDelete.mockResolvedValue({ data: undefined });
|
||||||
|
|
||||||
|
await deleteToolType("type-1");
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith("/tool-types/type-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface ReadinessProbe {
|
||||||
|
command: string;
|
||||||
|
timeout: number;
|
||||||
|
interval: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolType {
|
export interface ToolType {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,7 +14,11 @@ export interface ToolType {
|
|||||||
category: string;
|
category: string;
|
||||||
interfaces: string[];
|
interfaces: string[];
|
||||||
default_port: number | null;
|
default_port: number | null;
|
||||||
compose_template: string;
|
definition_type: 'compose' | 'dockerfile';
|
||||||
|
compose_template: string | null;
|
||||||
|
dockerfile_template: string | null;
|
||||||
|
build_context: Record<string, string> | null;
|
||||||
|
readiness_probe: ReadinessProbe | null;
|
||||||
required_variables: string[];
|
required_variables: string[];
|
||||||
is_builtin: boolean;
|
is_builtin: boolean;
|
||||||
created_by_id: string | null;
|
created_by_id: string | null;
|
||||||
@@ -20,14 +30,28 @@ export interface CreateToolTypeRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
compose_template: string;
|
category?: string;
|
||||||
|
interfaces?: string[];
|
||||||
|
default_port: number;
|
||||||
|
definition_type?: 'compose' | 'dockerfile';
|
||||||
|
compose_template?: string;
|
||||||
|
dockerfile_template?: string;
|
||||||
|
build_context?: Record<string, string>;
|
||||||
|
readiness_probe?: ReadinessProbe;
|
||||||
required_variables: string[];
|
required_variables: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateToolTypeRequest {
|
export interface UpdateToolTypeRequest {
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
interfaces?: string[];
|
||||||
|
default_port?: number;
|
||||||
|
definition_type?: 'compose' | 'dockerfile';
|
||||||
compose_template?: string;
|
compose_template?: string;
|
||||||
|
dockerfile_template?: string;
|
||||||
|
build_context?: Record<string, string>;
|
||||||
|
readiness_probe?: ReadinessProbe;
|
||||||
required_variables?: string[];
|
required_variables?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,3 +78,8 @@ export const updateToolType = async (id: string, data: UpdateToolTypeRequest): P
|
|||||||
export const deleteToolType = async (id: string): Promise<void> => {
|
export const deleteToolType = async (id: string): Promise<void> => {
|
||||||
await apiClient.delete(`/tool-types/${id}`);
|
await apiClient.delete(`/tool-types/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const validateToolType = async (id: string): Promise<{ valid: boolean; errors?: string[] }> => {
|
||||||
|
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(`/tool-types/${id}/validate`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,12 +10,9 @@ import { Icon } from "./icon";
|
|||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||||
{ to: "/", label: "Dashboard", icon: "dashboard" },
|
{ to: "/", label: "Home", icon: "dashboard" },
|
||||||
{ to: "/sessions", label: "Sessions", icon: "terminal" },
|
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||||
{ to: "/tool-types", label: "Tool Types", icon: "code" },
|
|
||||||
{ to: "/tool-configs", label: "Tool Configs", icon: "settings" },
|
|
||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -24,9 +21,9 @@ const SessionItem = ({ session }: { session: Session }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={session.url || "#"}
|
href={session.url ?? `/projects/${session.project_id}`}
|
||||||
target="_blank"
|
target={session.url ? "_blank" : undefined}
|
||||||
rel="noopener noreferrer"
|
rel={session.url ? "noopener noreferrer" : undefined}
|
||||||
className="nav-item session-item"
|
className="nav-item session-item"
|
||||||
title={`${session.display_name} (${session.status})`}
|
title={`${session.display_name} (${session.status})`}
|
||||||
>
|
>
|
||||||
@@ -86,7 +83,7 @@ export const AppShell = () => {
|
|||||||
<div className="shell-body">
|
<div className="shell-body">
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
<aside className="shell-nav" aria-label="Primary navigation">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isSessions = item.to === "/sessions";
|
const isHome = item.to === "/";
|
||||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -97,7 +94,7 @@ export const AppShell = () => {
|
|||||||
>
|
>
|
||||||
<Icon name={item.icon} size="sm" />
|
<Icon name={item.icon} size="sm" />
|
||||||
{item.label}
|
{item.label}
|
||||||
{isSessions && activeCount > 0 && (
|
{isHome && activeCount > 0 && (
|
||||||
<span className="nav-badge">{activeCount}</span>
|
<span className="nav-badge">{activeCount}</span>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -107,7 +104,7 @@ export const AppShell = () => {
|
|||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="nav-divider" />
|
<div className="nav-divider" />
|
||||||
<div className="nav-section-title">Sessions</div>
|
<div className="nav-section-title">Live sessions</div>
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<SessionItem key={session.id} session={session} />
|
<SessionItem key={session.id} session={session} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface GitToolbarProps {
|
|||||||
repoId: string;
|
repoId: string;
|
||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
branches: string[];
|
branches: string[];
|
||||||
|
hasRemote: boolean;
|
||||||
onBranchChange: (branch: string) => void;
|
onBranchChange: (branch: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@ export const GitToolbar = ({
|
|||||||
repoId,
|
repoId,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
branches,
|
branches,
|
||||||
|
hasRemote,
|
||||||
onBranchChange,
|
onBranchChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: GitToolbarProps) => {
|
}: GitToolbarProps) => {
|
||||||
@@ -55,6 +57,7 @@ export const GitToolbar = ({
|
|||||||
}, [loadStatus]);
|
}, [loadStatus]);
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const handleFetch = async () => {
|
||||||
|
if (!hasRemote) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await fetchRepository(projectId, repoId);
|
await fetchRepository(projectId, repoId);
|
||||||
@@ -67,9 +70,10 @@ export const GitToolbar = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePull = async () => {
|
const handlePull = async () => {
|
||||||
|
if (!hasRemote) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await pullRepository(projectId, repoId, currentBranch);
|
await pullRepository(projectId, repoId, currentBranch || undefined);
|
||||||
await loadStatus();
|
await loadStatus();
|
||||||
onRefresh();
|
onRefresh();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -108,7 +112,7 @@ export const GitToolbar = ({
|
|||||||
if (!newBranchName.trim()) return;
|
if (!newBranchName.trim()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await createBranch(projectId, repoId, newBranchName, newBranchBase || "HEAD");
|
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
|
||||||
setShowNewBranch(false);
|
setShowNewBranch(false);
|
||||||
setNewBranchName("");
|
setNewBranchName("");
|
||||||
setNewBranchBase("");
|
setNewBranchBase("");
|
||||||
@@ -127,6 +131,8 @@ export const GitToolbar = ({
|
|||||||
status.untracked.length > 0
|
status.untracked.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const canSync = hasRemote;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-toolbar">
|
<div className="git-toolbar">
|
||||||
{error && <div className="toolbar-error">{error}</div>}
|
{error && <div className="toolbar-error">{error}</div>}
|
||||||
@@ -165,7 +171,7 @@ export const GitToolbar = ({
|
|||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className="toolbar-button"
|
||||||
onClick={handleFetch}
|
onClick={handleFetch}
|
||||||
disabled={loading}
|
disabled={loading || !canSync}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="fetch" size="sm" /> Fetch
|
<Icon name="fetch" size="sm" /> Fetch
|
||||||
@@ -173,7 +179,7 @@ export const GitToolbar = ({
|
|||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className="toolbar-button"
|
||||||
onClick={handlePull}
|
onClick={handlePull}
|
||||||
disabled={loading}
|
disabled={loading || !canSync}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="pull" size="sm" /> Pull
|
<Icon name="pull" size="sm" /> Pull
|
||||||
@@ -182,7 +188,7 @@ export const GitToolbar = ({
|
|||||||
<button
|
<button
|
||||||
className="toolbar-button"
|
className="toolbar-button"
|
||||||
onClick={handlePush}
|
onClick={handlePush}
|
||||||
disabled={loading || !status?.ahead}
|
disabled={loading || !canSync || !status?.ahead}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="push" size="sm" /> Push
|
<Icon name="push" size="sm" /> Push
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { ToolInstance } from "../api/sessions";
|
import type { ToolInstance } from "../api/sessions";
|
||||||
import {
|
import {
|
||||||
|
checkInstanceHealth,
|
||||||
createInstance,
|
createInstance,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
listInstances,
|
listInstances,
|
||||||
|
recreateInstanceTunnel,
|
||||||
restartInstance,
|
restartInstance,
|
||||||
startInstance,
|
startInstance,
|
||||||
stopInstance,
|
stopInstance,
|
||||||
@@ -29,6 +31,12 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Stop confirmation
|
||||||
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Health check state
|
||||||
|
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
||||||
|
|
||||||
const loadInstances = useCallback(async () => {
|
const loadInstances = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -45,6 +53,36 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
void loadInstances();
|
void loadInstances();
|
||||||
}, [loadInstances]);
|
}, [loadInstances]);
|
||||||
|
|
||||||
|
// Health check polling
|
||||||
|
useEffect(() => {
|
||||||
|
const runningInstances = instances.filter(i => i.status === "running" && i.url?.startsWith("http"));
|
||||||
|
if (runningInstances.length === 0) return;
|
||||||
|
|
||||||
|
const checkHealth = async () => {
|
||||||
|
for (const instance of runningInstances) {
|
||||||
|
try {
|
||||||
|
const health = await checkInstanceHealth(projectId, repoId, instance.id);
|
||||||
|
setHealthStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() }
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setHealthStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[instance.id]: { healthy: false, lastCheck: Date.now() }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately
|
||||||
|
void checkHealth();
|
||||||
|
|
||||||
|
// Then every 30 seconds
|
||||||
|
const interval = setInterval(() => void checkHealth(), 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [instances, projectId, repoId]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!selectedToolType) return;
|
if (!selectedToolType) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -71,6 +109,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
const handleStop = async (instanceId: string) => {
|
const handleStop = async (instanceId: string) => {
|
||||||
try {
|
try {
|
||||||
await stopInstance(projectId, repoId, instanceId);
|
await stopInstance(projectId, repoId, instanceId);
|
||||||
|
setStopConfirmId(null);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to stop instance");
|
setError("Failed to stop instance");
|
||||||
@@ -90,12 +129,22 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
if (!confirm("Are you sure you want to delete this instance?")) return;
|
if (!confirm("Are you sure you want to delete this instance?")) return;
|
||||||
try {
|
try {
|
||||||
await deleteInstance(projectId, repoId, instanceId);
|
await deleteInstance(projectId, repoId, instanceId);
|
||||||
await loadInstances();
|
// Update state immediately instead of reloading
|
||||||
|
setInstances(prev => prev.filter(i => i.id !== instanceId));
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to delete instance");
|
setError("Failed to delete instance");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRecreateTunnel = async (instanceId: string) => {
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(projectId, repoId, instanceId);
|
||||||
|
await loadInstances();
|
||||||
|
} catch {
|
||||||
|
setError("Failed to recreate tunnel");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "running":
|
case "running":
|
||||||
@@ -110,6 +159,14 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isTunnelUnhealthy = (instance: ToolInstance) => {
|
||||||
|
if (instance.status !== "running") return false;
|
||||||
|
if (!instance.url?.startsWith("http")) return false;
|
||||||
|
const health = healthStatus[instance.id];
|
||||||
|
if (!health) return false;
|
||||||
|
return !health.healthy;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="instance-list">
|
<div className="instance-list">
|
||||||
<div className="instance-list-header">
|
<div className="instance-list-header">
|
||||||
@@ -144,12 +201,19 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
style={{ backgroundColor: getStatusColor(instance.status) }}
|
style={{ backgroundColor: getStatusColor(instance.status) }}
|
||||||
/>
|
/>
|
||||||
{instance.status}
|
{instance.status}
|
||||||
|
{isTunnelUnhealthy(instance) && (
|
||||||
|
<span className="error-badge" title="Tunnel unreachable">
|
||||||
|
<Icon name="warning" size="sm" />
|
||||||
|
tunnel error
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="instance-actions">
|
<div className="instance-actions">
|
||||||
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
||||||
|
<>
|
||||||
<a
|
<a
|
||||||
href={`${API_BASE_URL}${instance.url}`}
|
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
@@ -157,6 +221,18 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
<Icon name="external" size="sm" />
|
<Icon name="external" size="sm" />
|
||||||
Open
|
Open
|
||||||
</a>
|
</a>
|
||||||
|
{isTunnelUnhealthy(instance) && (
|
||||||
|
<button
|
||||||
|
className="secondary-button small warning"
|
||||||
|
onClick={() => void handleRecreateTunnel(instance.id)}
|
||||||
|
type="button"
|
||||||
|
title="Recreate tunnel"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Fix Tunnel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
|
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
|
||||||
<button
|
<button
|
||||||
@@ -180,13 +256,33 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
|||||||
)}
|
)}
|
||||||
{instance.status === "running" && (
|
{instance.status === "running" && (
|
||||||
<>
|
<>
|
||||||
|
{stopConfirmId === instance.id ? (
|
||||||
|
<div className="inline-confirm">
|
||||||
|
<span>Stop?</span>
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={() => void handleStop(instance.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="ghost-button small"
|
className="ghost-button small"
|
||||||
onClick={() => void handleStop(instance.id)}
|
onClick={() => setStopConfirmId(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setStopConfirmId(instance.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="stop" size="sm" />
|
<Icon name="stop" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="ghost-button small"
|
className="ghost-button small"
|
||||||
onClick={() => void handleRestart(instance.id)}
|
onClick={() => void handleRestart(instance.id)}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
|
||||||
|
import * as gitRepositoriesApi from "../api/git_repositories";
|
||||||
|
|
||||||
|
const mockRepositories = [
|
||||||
|
{
|
||||||
|
id: "repo-1",
|
||||||
|
name: "Main Repo",
|
||||||
|
path: "/repos/main",
|
||||||
|
project_id: "proj-1",
|
||||||
|
owner_id: "user-1",
|
||||||
|
is_mirror: false,
|
||||||
|
remote_url: null,
|
||||||
|
last_push: null,
|
||||||
|
created_at: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useParams: () => ({ projectId: "proj-1" }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RepositoriesSettingsTab", () => {
|
||||||
|
it("opens create dialog and clones an existing repository", async () => {
|
||||||
|
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||||
|
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
||||||
|
|
||||||
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
|
target: { value: "New Repo" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||||
|
target: { value: "alice" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
|
||||||
|
target: { value: "demo" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||||
|
name: "New Repo",
|
||||||
|
remote_url: "git@git.commumedia.org:alice/demo.git",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses advanced url fallback when requested", async () => {
|
||||||
|
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||||
|
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
||||||
|
|
||||||
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
|
target: { value: "New Repo" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /use full url instead/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), {
|
||||||
|
target: { value: "https://github.com/user/repo.git" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||||
|
name: "New Repo",
|
||||||
|
remote_url: "https://github.com/user/repo.git",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows validation when cloning without a remote url", async () => {
|
||||||
|
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||||
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
|
target: { value: "New Repo" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||||
|
target: { value: "" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,37 +1,45 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { apiClient } from "../api/client";
|
|
||||||
import { GitRepository } from "../api/git_repositories";
|
import { deleteRepository, listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
|
import { RepositoryCreateDialog } from "./repository-create-dialog";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
export const RepositoriesSettingsTab: React.FC = () => {
|
export const RepositoriesSettingsTab: React.FC = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(">");
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
const loadRepositories = useCallback(async () => {
|
||||||
const fetchRepositories = async () => {
|
if (!projectId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(
|
const data = await listRepositories(projectId);
|
||||||
`/projects/${projectId}/repositories`
|
setRepositories(data);
|
||||||
);
|
} catch {
|
||||||
setRepositories(response.data);
|
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to load repositories");
|
setError("Failed to load repositories");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
fetchRepositories();
|
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRepositories();
|
||||||
|
}, [loadRepositories]);
|
||||||
|
|
||||||
const handleDelete = async (repoId: string) => {
|
const handleDelete = async (repoId: string) => {
|
||||||
|
if (!projectId) return;
|
||||||
if (!window.confirm("Are you sure you want to delete this repository?")) return;
|
if (!window.confirm("Are you sure you want to delete this repository?")) return;
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
await deleteRepository(projectId, repoId);
|
||||||
setRepositories(repositories.filter((r) => r.id !== repoId));
|
setRepositories((current) => current.filter((r) => r.id !== repoId));
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError("Failed to delete repository");
|
setError("Failed to delete repository");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -40,7 +48,17 @@ export const RepositoriesSettingsTab: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="repositories-settings-tab">
|
<div className="repositories-settings-tab">
|
||||||
|
<div className="page-header">
|
||||||
<h2>Repositories</h2>
|
<h2>Repositories</h2>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Repository
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
<div className="repositories-list">
|
<div className="repositories-list">
|
||||||
@@ -66,6 +84,16 @@ export const RepositoriesSettingsTab: React.FC = () => {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<RepositoryCreateDialog
|
||||||
|
projectId={projectId!}
|
||||||
|
open={showCreate}
|
||||||
|
title="Add Repository"
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onCreated={loadRepositories}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
type CreateMode = "clone" | "blank";
|
||||||
|
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
|
||||||
|
|
||||||
|
interface RepositoryCreateDialogProps {
|
||||||
|
projectId: string;
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
|
||||||
|
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [owner, setOwner] = useState("");
|
||||||
|
const [repoName, setRepoName] = useState("");
|
||||||
|
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||||
|
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [urlValidation, setUrlValidation] = useState<{
|
||||||
|
status: UrlValidationStatus;
|
||||||
|
result: URLParseResult | null;
|
||||||
|
}>({ status: "idle", result: null });
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open && debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
debounceTimer.current = null;
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (!useAdvancedUrl) {
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!advancedUrl.trim()) {
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrlValidation({ status: "validating", result: null });
|
||||||
|
|
||||||
|
debounceTimer.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await parseGitUrl(advancedUrl.trim());
|
||||||
|
if (result.is_valid_clone_url) {
|
||||||
|
setUrlValidation({ status: "valid", result });
|
||||||
|
} else if (result.needs_parsing) {
|
||||||
|
setUrlValidation({ status: "needs-parsing", result });
|
||||||
|
} else {
|
||||||
|
setUrlValidation({ status: "invalid", result });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUrlValidation({ status: "invalid", result: null });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimer.current) {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [advancedUrl, open, useAdvancedUrl]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setCreateMode("clone");
|
||||||
|
setFormName("");
|
||||||
|
setOwner("");
|
||||||
|
setRepoName("");
|
||||||
|
setAdvancedUrl("");
|
||||||
|
setUseAdvancedUrl(false);
|
||||||
|
setFormError(null);
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
if (!formName.trim()) {
|
||||||
|
setFormError("Repository name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input: GitRepositoryCreate = {
|
||||||
|
name: formName.trim(),
|
||||||
|
remote_url: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createMode === "clone") {
|
||||||
|
if (useAdvancedUrl) {
|
||||||
|
if (!advancedUrl.trim()) {
|
||||||
|
setFormError("Remote URL is required for advanced cloning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.remote_url = advancedUrl.trim();
|
||||||
|
} else {
|
||||||
|
if (!owner.trim() || !repoName.trim()) {
|
||||||
|
setFormError("Owner and repository name are required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await createRepository(projectId, input);
|
||||||
|
handleClose();
|
||||||
|
await onCreated();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const response = error as { response?: { data?: { detail?: string } } };
|
||||||
|
const detail = response.response?.data?.detail;
|
||||||
|
setFormError(typeof detail === "string" ? detail : "Failed to create repository");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseSuggestedUrl = () => {
|
||||||
|
if (urlValidation.result?.base_url) {
|
||||||
|
setAdvancedUrl(urlValidation.result.base_url);
|
||||||
|
setUrlValidation({ status: "idle", result: null });
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUrlInputClass = () => {
|
||||||
|
switch (urlValidation.status) {
|
||||||
|
case "valid":
|
||||||
|
return "valid-url";
|
||||||
|
case "needs-parsing":
|
||||||
|
return "needs-parsing-url";
|
||||||
|
case "invalid":
|
||||||
|
return "invalid-url";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="dialog">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="stack">
|
||||||
|
<div className="form-field">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="repository-mode"
|
||||||
|
checked={createMode === "clone"}
|
||||||
|
onChange={() => setCreateMode("clone")}
|
||||||
|
/>
|
||||||
|
Clone existing repository
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="repository-mode"
|
||||||
|
checked={createMode === "blank"}
|
||||||
|
onChange={() => setCreateMode("blank")}
|
||||||
|
/>
|
||||||
|
Create blank repository
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="form-field">
|
||||||
|
Repository name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formName}
|
||||||
|
onChange={(event) => setFormName(event.target.value)}
|
||||||
|
placeholder="repository-name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{createMode === "clone" && !useAdvancedUrl && (
|
||||||
|
<>
|
||||||
|
<label className="form-field">
|
||||||
|
Owner
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={owner}
|
||||||
|
onChange={(event) => setOwner(event.target.value)}
|
||||||
|
placeholder="owner"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={repoName}
|
||||||
|
onChange={(event) => setRepoName(event.target.value)}
|
||||||
|
placeholder="repo-name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => setUseAdvancedUrl(true)}
|
||||||
|
>
|
||||||
|
Use full URL instead
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{createMode === "clone" && useAdvancedUrl && (
|
||||||
|
<label className="form-field">
|
||||||
|
Remote URL
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={advancedUrl}
|
||||||
|
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
className={getUrlInputClass()}
|
||||||
|
/>
|
||||||
|
{urlValidation.status === "validating" && (
|
||||||
|
<span className="validation-status validating">Validating...</span>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "valid" && (
|
||||||
|
<span className="validation-status valid">
|
||||||
|
<Icon name="success" size="sm" /> Valid git URL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
||||||
|
<div className="url-suggestion">
|
||||||
|
<span className="validation-status warning">
|
||||||
|
<Icon name="warning" size="sm" /> This looks like a browser URL
|
||||||
|
</span>
|
||||||
|
<div className="suggestion-actions">
|
||||||
|
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={handleUseSuggestedUrl}
|
||||||
|
>
|
||||||
|
Use Suggested
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{urlValidation.status === "invalid" && (
|
||||||
|
<span className="validation-status invalid">
|
||||||
|
<Icon name="error" size="sm" /> Invalid URL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => setUseAdvancedUrl(false)}
|
||||||
|
>
|
||||||
|
Use owner/repo instead
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{formError && (
|
||||||
|
<div className="error-message">
|
||||||
|
<p className="error-text">{formError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="secondary-button" onClick={handleClose} type="button">
|
||||||
|
<Icon name="cancel" size="sm" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,54 +1,81 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { DashboardPage } from "./dashboard";
|
import { HomePage } from "./dashboard";
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
const mockDashboard = vi.fn();
|
||||||
|
const mockSessions = vi.fn();
|
||||||
|
const mockProjects = vi.fn();
|
||||||
|
const mockRepos = vi.fn();
|
||||||
|
|
||||||
vi.mock("../api/dashboard", () => ({
|
vi.mock("../api/dashboard", () => ({
|
||||||
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
|
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("DashboardPage", () => {
|
vi.mock("../api/sessions", () => ({
|
||||||
|
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
||||||
|
createInstance: vi.fn(),
|
||||||
|
startInstance: vi.fn(),
|
||||||
|
stopInstance: vi.fn(),
|
||||||
|
deleteInstance: vi.fn(),
|
||||||
|
recreateInstanceTunnel: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/projects", () => ({
|
||||||
|
listProjects: (...args: unknown[]) => mockProjects(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/git_repositories", () => ({
|
||||||
|
listRepositories: (...args: unknown[]) => mockRepos(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/tool_types", () => ({
|
||||||
|
listToolTypes: vi.fn().mockResolvedValue([])
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("HomePage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGet.mockReset();
|
mockDashboard.mockReset();
|
||||||
|
mockSessions.mockReset();
|
||||||
|
mockProjects.mockReset();
|
||||||
|
mockRepos.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows loading then empty state when summary has no data", async () => {
|
it("shows overview sections", async () => {
|
||||||
mockGet.mockResolvedValue({
|
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
|
||||||
projects: 0,
|
mockSessions.mockResolvedValue([]);
|
||||||
repositories: 0,
|
mockProjects.mockResolvedValue([]);
|
||||||
sshKeys: 0,
|
mockRepos.mockResolvedValue([]);
|
||||||
recentActivity: []
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
|
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No activity yet")).toBeInTheDocument();
|
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("Available projects")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows retry action when summary request fails", async () => {
|
it("shows retry action when home load fails", async () => {
|
||||||
mockGet.mockRejectedValueOnce(new Error("failed"));
|
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
||||||
mockGet.mockResolvedValueOnce({
|
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
||||||
projects: 2,
|
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
||||||
repositories: 5,
|
|
||||||
sshKeys: 1,
|
|
||||||
recentActivity: ["Created repo"]
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
|
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("2")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,83 +1,338 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||||
|
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
|
||||||
|
import { listProjects } from "../api/projects";
|
||||||
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
|
import { updateUserConfig } from "../api/settings";
|
||||||
|
import type { Project } from "../types";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
const CARDS = [
|
type HomeStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
{ label: "Open sessions", key: "openSessions" },
|
||||||
{ label: "Projects", key: "projects" },
|
{ label: "Projects", key: "projects" },
|
||||||
{ label: "Repositories", key: "repositories" },
|
{ label: "Repositories", key: "repositories" },
|
||||||
{ label: "SSH Keys", key: "sshKeys" }
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type DashboardStatus = "loading" | "ready" | "error";
|
type SessionView = SessionApi;
|
||||||
|
|
||||||
export const DashboardPage = () => {
|
export const HomePage = () => {
|
||||||
const [status, setStatus] = useState<DashboardStatus>("loading");
|
const navigate = useNavigate();
|
||||||
|
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
|
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState("");
|
||||||
|
const [selectedToolType, setSelectedToolType] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||||
|
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||||
|
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
const loadHome = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
try {
|
try {
|
||||||
const data = await getDashboardSummary();
|
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
||||||
setSummary(data);
|
getDashboardSummary(),
|
||||||
|
getUserSessions(),
|
||||||
|
listProjects(),
|
||||||
|
listToolTypes(),
|
||||||
|
]);
|
||||||
|
setSummary(dashboard);
|
||||||
|
setSessions(sessionData as SessionView[]);
|
||||||
|
setProjects(projectData);
|
||||||
|
setToolTypes(toolTypeData);
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setSummary(null);
|
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSummary();
|
void loadHome();
|
||||||
}, [loadSummary]);
|
}, [loadHome]);
|
||||||
|
|
||||||
const cards = useMemo(() => CARDS, []);
|
useEffect(() => {
|
||||||
const isEmpty =
|
if (!selectedProject) {
|
||||||
status === "ready" &&
|
setRepositories([]);
|
||||||
summary !== null &&
|
return;
|
||||||
summary.projects === 0 &&
|
}
|
||||||
summary.repositories === 0 &&
|
|
||||||
summary.sshKeys === 0 &&
|
const loadRepos = async () => {
|
||||||
summary.recentActivity.length === 0;
|
try {
|
||||||
|
const data = await listRepositories(selectedProject);
|
||||||
|
setRepositories(data);
|
||||||
|
} catch {
|
||||||
|
setRepositories([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadRepos();
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
const activeSessions = useMemo(
|
||||||
|
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
||||||
|
[safeSessions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentSessions = useMemo(
|
||||||
|
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
||||||
|
[safeSessions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||||
|
|
||||||
|
setSaveState("saving");
|
||||||
|
try {
|
||||||
|
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
|
||||||
|
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||||
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
|
setDisplayName("");
|
||||||
|
setSelectedProject("");
|
||||||
|
setSelectedRepo("");
|
||||||
|
setSelectedToolType("");
|
||||||
|
setSaveState("idle");
|
||||||
|
await loadHome();
|
||||||
|
} catch {
|
||||||
|
setSaveState("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (session: SessionView) => {
|
||||||
|
if (session.url) {
|
||||||
|
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.tool_type_interfaces.includes("terminal")) {
|
||||||
|
navigate(`/instances/${session.id}/terminal`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/projects/${session.project_id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async (session: SessionView) => {
|
||||||
|
setActionBusy(session.id);
|
||||||
|
try {
|
||||||
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await loadHome();
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (session: SessionView) => {
|
||||||
|
setActionBusy(session.id);
|
||||||
|
try {
|
||||||
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
||||||
|
await loadHome();
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecreateTunnel = async (session: SessionView) => {
|
||||||
|
setActionBusy(session.id);
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
||||||
|
await loadHome();
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack home-page">
|
||||||
<h1>Dashboard</h1>
|
<header className="home-hero card">
|
||||||
<p className="muted">Your workspace overview will appear here.</p>
|
<div className="stack-sm">
|
||||||
|
<p className="eyebrow">Workspace overview</p>
|
||||||
|
<h1>Home</h1>
|
||||||
|
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||||
|
</div>
|
||||||
|
<div className="home-hero-actions">
|
||||||
|
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{status === "loading" && <p className="muted">Loading dashboard...</p>}
|
{status === "loading" && <p className="muted">Loading overview...</p>}
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="card stack">
|
<div className="card stack">
|
||||||
<p>Dashboard is unavailable</p>
|
<p>Unable to load your workspace overview.</p>
|
||||||
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
|
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
||||||
<Icon name="refresh" size="sm" />
|
<Icon name="refresh" size="sm" />
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card-grid">
|
{status === "ready" && summary && (
|
||||||
{cards.map((card) => (
|
<>
|
||||||
<article className="card" key={card.label}>
|
<div className="home-summary-grid">
|
||||||
|
{summaryCards.map((card) => (
|
||||||
|
<article className="card home-summary-card" key={card.label}>
|
||||||
<p className="card-label">{card.label}</p>
|
<p className="card-label">{card.label}</p>
|
||||||
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
|
<p className="card-value">
|
||||||
|
{card.key === "openSessions"
|
||||||
|
? activeSessions.length
|
||||||
|
: card.key === "projects"
|
||||||
|
? summary.projects
|
||||||
|
: summary.repositories}
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEmpty && <p className="muted">No activity yet</p>}
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
<div className="quick-actions">
|
<div>
|
||||||
<button className="primary-button" type="button">
|
<p className="eyebrow">Open sessions</p>
|
||||||
<Icon name="add" size="sm" />
|
<h2>{activeSessions.length}</h2>
|
||||||
New Project
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeSessions.length === 0 ? (
|
||||||
|
<p className="muted">No active sessions right now.</p>
|
||||||
|
) : (
|
||||||
|
<div className="home-session-grid">
|
||||||
|
{activeSessions.map((session) => (
|
||||||
|
<article className="card session-card" key={session.id}>
|
||||||
|
<div className="stack-sm">
|
||||||
|
<div className="row row-tight">
|
||||||
|
<h3>{session.display_name}</h3>
|
||||||
|
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
||||||
|
<p className="muted">{session.tool_type_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="session-actions">
|
||||||
|
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
</button>
|
</button>
|
||||||
<button className="secondary-button" type="button">
|
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
||||||
<Icon name="add" size="sm" />
|
<Icon name="refresh" size="sm" />
|
||||||
Add Repository
|
Tunnel
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
||||||
|
<Icon name="stop" size="sm" />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Available projects</p>
|
||||||
|
<h2>{projects.length}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||||
|
</div>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="muted">No projects yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="home-project-grid">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<article className="card project-card home-project-card" key={project.id}>
|
||||||
|
<div className="stack-sm">
|
||||||
|
<h3>{project.name}</h3>
|
||||||
|
{project.description && <p className="muted">{project.description}</p>}
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
|
||||||
|
Open Workspace
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Quick create</p>
|
||||||
|
<h2>Start a session</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="stack create-session-form" onSubmit={handleCreate}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-field">
|
||||||
|
Project
|
||||||
|
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Repository
|
||||||
|
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
|
||||||
|
<option value="">Select repository...</option>
|
||||||
|
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Tool type
|
||||||
|
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
|
||||||
|
<option value="">Select tool...</option>
|
||||||
|
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="form-field">
|
||||||
|
Display name
|
||||||
|
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
|
||||||
|
</label>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
|
||||||
|
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
|
||||||
|
</button>
|
||||||
|
{saveState === "error" && <span className="error-text">Failed to create session</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{recentSessions.length > 0 && (
|
||||||
|
<section className="card stack home-section">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Recent sessions</p>
|
||||||
|
<h2>{recentSessions.length}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="recent-sessions-list">
|
||||||
|
{recentSessions.map((session) => (
|
||||||
|
<article className="recent-session-item" key={session.id}>
|
||||||
|
<div className="recent-session-info">
|
||||||
|
<span className="recent-session-name">{session.display_name}</span>
|
||||||
|
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { HomePage as DashboardPage };
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createRepository,
|
|
||||||
deleteRepository,
|
deleteRepository,
|
||||||
listRepositories,
|
listRepositories,
|
||||||
parseGitUrl,
|
|
||||||
type GitRepositoryCreate,
|
|
||||||
type URLParseResult,
|
|
||||||
} from "../api/git_repositories";
|
} from "../api/git_repositories";
|
||||||
import type { GitRepository } from "../api/git_repositories";
|
import type { GitRepository } from "../api/git_repositories";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
|
||||||
|
|
||||||
type RepoStatus = "loading" | "ready" | "error";
|
type RepoStatus = "loading" | "ready" | "error";
|
||||||
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
|
|
||||||
|
|
||||||
export const GitRepositoriesPage = () => {
|
export const GitRepositoriesPage = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
@@ -21,19 +17,8 @@ export const GitRepositoriesPage = () => {
|
|||||||
const [status, setStatus] = useState<RepoStatus>("loading");
|
const [status, setStatus] = useState<RepoStatus>("loading");
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [formName, setFormName] = useState("");
|
|
||||||
const [formRemoteUrl, setFormRemoteUrl] = useState("");
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
// URL validation state
|
|
||||||
const [urlValidation, setUrlValidation] = useState<{
|
|
||||||
status: UrlValidationStatus;
|
|
||||||
result: URLParseResult | null;
|
|
||||||
}>({ status: "idle", result: null });
|
|
||||||
|
|
||||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const loadRepositories = useCallback(async () => {
|
const loadRepositories = useCallback(async () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -51,98 +36,6 @@ export const GitRepositoriesPage = () => {
|
|||||||
void loadRepositories();
|
void loadRepositories();
|
||||||
}, [loadRepositories]);
|
}, [loadRepositories]);
|
||||||
|
|
||||||
// Validate URL with debounce
|
|
||||||
useEffect(() => {
|
|
||||||
if (debounceTimer.current) {
|
|
||||||
clearTimeout(debounceTimer.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formRemoteUrl.trim()) {
|
|
||||||
setUrlValidation({ status: "idle", result: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUrlValidation({ status: "validating", result: null });
|
|
||||||
|
|
||||||
debounceTimer.current = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const result = await parseGitUrl(formRemoteUrl.trim());
|
|
||||||
if (result.is_valid_clone_url) {
|
|
||||||
setUrlValidation({ status: "valid", result });
|
|
||||||
} else if (result.needs_parsing) {
|
|
||||||
setUrlValidation({ status: "needs-parsing", result });
|
|
||||||
} else {
|
|
||||||
setUrlValidation({ status: "invalid", result });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setUrlValidation({ status: "invalid", result: null });
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (debounceTimer.current) {
|
|
||||||
clearTimeout(debounceTimer.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [formRemoteUrl]);
|
|
||||||
|
|
||||||
const getUrlInputClass = () => {
|
|
||||||
switch (urlValidation.status) {
|
|
||||||
case "valid":
|
|
||||||
return "valid-url";
|
|
||||||
case "needs-parsing":
|
|
||||||
return "needs-parsing-url";
|
|
||||||
case "invalid":
|
|
||||||
return "invalid-url";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
if (!formName.trim()) {
|
|
||||||
setFormError("Repository name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!projectId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const input: GitRepositoryCreate = {
|
|
||||||
name: formName.trim(),
|
|
||||||
remote_url: formRemoteUrl.trim() || undefined,
|
|
||||||
};
|
|
||||||
await createRepository(projectId, input);
|
|
||||||
setShowCreate(false);
|
|
||||||
setFormName("");
|
|
||||||
setFormRemoteUrl("");
|
|
||||||
setUrlValidation({ status: "idle", result: null });
|
|
||||||
await loadRepositories();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const axiosError = err as { response?: { status: number; data: { detail: { suggested_url: string; message: string } } } };
|
|
||||||
if (axiosError.response?.status === 422 && axiosError.response?.data?.detail?.suggested_url) {
|
|
||||||
// Show URL correction suggestion
|
|
||||||
const detail = axiosError.response.data.detail;
|
|
||||||
setFormError(
|
|
||||||
`${detail.message}\nSuggested: ${detail.suggested_url}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setFormError("Failed to create repository");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUseSuggestedUrl = () => {
|
|
||||||
if (urlValidation.result?.base_url) {
|
|
||||||
setFormRemoteUrl(urlValidation.result.base_url);
|
|
||||||
setUrlValidation({ status: "idle", result: null });
|
|
||||||
setFormError(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (repoId: string) => {
|
const handleDelete = async (repoId: string) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
try {
|
try {
|
||||||
@@ -233,81 +126,13 @@ export const GitRepositoriesPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<RepositoryCreateDialog
|
||||||
<div className="dialog">
|
projectId={projectId!}
|
||||||
<h2>Create Repository</h2>
|
open={showCreate}
|
||||||
<form onSubmit={handleSubmit} className="stack">
|
title="Create Repository"
|
||||||
<label className="form-field">
|
onClose={() => setShowCreate(false)}
|
||||||
Name
|
onCreated={loadRepositories}
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formName}
|
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
|
||||||
placeholder="repository-name"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
Remote URL (optional)
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formRemoteUrl}
|
|
||||||
onChange={(e) => setFormRemoteUrl(e.target.value)}
|
|
||||||
placeholder="https://github.com/user/repo.git"
|
|
||||||
className={getUrlInputClass()}
|
|
||||||
/>
|
|
||||||
{urlValidation.status === "validating" && (
|
|
||||||
<span className="validation-status validating">Validating...</span>
|
|
||||||
)}
|
|
||||||
{urlValidation.status === "valid" && (
|
|
||||||
<span className="validation-status valid">
|
|
||||||
<Icon name="success" size="sm" /> Valid git URL
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
|
||||||
<div className="url-suggestion">
|
|
||||||
<span className="validation-status warning">
|
|
||||||
<Icon name="warning" size="sm" /> This looks like a browser URL
|
|
||||||
</span>
|
|
||||||
<div className="suggestion-actions">
|
|
||||||
<span className="suggested-url">
|
|
||||||
Suggested: {urlValidation.result.base_url}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={handleUseSuggestedUrl}
|
|
||||||
>
|
|
||||||
Use Suggested
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{urlValidation.status === "invalid" && (
|
|
||||||
<span className="validation-status invalid">
|
|
||||||
<Icon name="error" size="sm" /> Invalid URL
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
{formError && (
|
|
||||||
<div className="error-message">
|
|
||||||
{formError.split("\n").map((line, i) => (
|
|
||||||
<p key={i} className="error-text">{line}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="primary-button" type="submit">
|
|
||||||
<Icon name="add" size="sm" />
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ProjectsPage } from "./projects";
|
import { ProjectsPage } from "./projects";
|
||||||
@@ -29,13 +30,21 @@ afterEach(() => {
|
|||||||
describe("ProjectsPage", () => {
|
describe("ProjectsPage", () => {
|
||||||
it("renders loading state initially", () => {
|
it("renders loading state initially", () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders project list after loading", async () => {
|
it("renders project list after loading", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
@@ -46,7 +55,11 @@ describe("ProjectsPage", () => {
|
|||||||
|
|
||||||
it("renders empty state when no projects", async () => {
|
it("renders empty state when no projects", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -55,7 +68,11 @@ describe("ProjectsPage", () => {
|
|||||||
|
|
||||||
it("renders error state with retry button", async () => {
|
it("renders error state with retry button", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||||
@@ -67,7 +84,11 @@ describe("ProjectsPage", () => {
|
|||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -96,7 +117,11 @@ describe("ProjectsPage", () => {
|
|||||||
it("shows validation error when name is empty", async () => {
|
it("shows validation error when name is empty", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
@@ -112,7 +137,11 @@ describe("ProjectsPage", () => {
|
|||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
@@ -141,7 +170,11 @@ describe("ProjectsPage", () => {
|
|||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ProjectsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export const RepoWorkspace = () => {
|
|||||||
repoId={selectedRepoId}
|
repoId={selectedRepoId}
|
||||||
currentBranch={currentBranch}
|
currentBranch={currentBranch}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
|
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||||
onBranchChange={(branch) => {
|
onBranchChange={(branch) => {
|
||||||
setCurrentBranch(branch);
|
setCurrentBranch(branch);
|
||||||
const newParams = new URLSearchParams(searchParams);
|
const newParams = new URLSearchParams(searchParams);
|
||||||
@@ -364,6 +365,9 @@ const FileBrowser = ({
|
|||||||
<Icon name="folder" size="sm" /> ..
|
<Icon name="folder" size="sm" /> ..
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<p className="muted">No files in this repository yet.</p>
|
||||||
|
)}
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||||
return (
|
return (
|
||||||
@@ -388,4 +392,3 @@ const FileBrowser = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
import { getUserSessions, type Session, deleteInstance, stopInstance } from "../api/sessions";
|
import {
|
||||||
|
getUserSessions,
|
||||||
|
type Session,
|
||||||
|
deleteInstance,
|
||||||
|
stopInstance,
|
||||||
|
startInstance,
|
||||||
|
checkInstanceHealth,
|
||||||
|
recreateInstanceTunnel,
|
||||||
|
} from "../api/sessions";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { createInstance } from "../api/sessions";
|
import { createInstance } from "../api/sessions";
|
||||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||||
@@ -31,6 +39,19 @@ export const SessionsPage = () => {
|
|||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
|
const [tunnelHealth, setTunnelHealth] = useState<Record<string, {
|
||||||
|
healthy: boolean;
|
||||||
|
container_status: string;
|
||||||
|
container_health: string | null;
|
||||||
|
tunnel_status: string;
|
||||||
|
tunnel_status_code: number | null;
|
||||||
|
probe_status: string;
|
||||||
|
last_probe_output: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}>>({});
|
||||||
|
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||||
|
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
@@ -75,6 +96,47 @@ export const SessionsPage = () => {
|
|||||||
void loadToolTypes();
|
void loadToolTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Poll health every 30 seconds for active instances
|
||||||
|
useEffect(() => {
|
||||||
|
const checkHealth = async () => {
|
||||||
|
const activeSessions = sessions.filter(
|
||||||
|
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
||||||
|
);
|
||||||
|
for (const session of activeSessions) {
|
||||||
|
try {
|
||||||
|
const health = await checkInstanceHealth(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id
|
||||||
|
);
|
||||||
|
setTunnelHealth((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[session.id]: health,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setTunnelHealth((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[session.id]: {
|
||||||
|
healthy: false,
|
||||||
|
container_status: "unknown",
|
||||||
|
container_health: null,
|
||||||
|
tunnel_status: "unreachable",
|
||||||
|
tunnel_status_code: null,
|
||||||
|
probe_status: "unknown",
|
||||||
|
last_probe_output: null,
|
||||||
|
error: "check failed",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately and then every 30 seconds
|
||||||
|
void checkHealth();
|
||||||
|
const interval = setInterval(() => void checkHealth(), 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
setRepositories([]);
|
setRepositories([]);
|
||||||
@@ -92,12 +154,12 @@ export const SessionsPage = () => {
|
|||||||
}, [selectedProject]);
|
}, [selectedProject]);
|
||||||
|
|
||||||
const activeSessions = useMemo(
|
const activeSessions = useMemo(
|
||||||
() => sessions.filter((s) => s.status === "running"),
|
() => sessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)),
|
||||||
[sessions]
|
[sessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const recentSessions = useMemo(
|
const recentSessions = useMemo(
|
||||||
() => sessions.filter((s) => s.status !== "running").slice(0, 5),
|
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
|
||||||
[sessions]
|
[sessions]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -123,6 +185,10 @@ export const SessionsPage = () => {
|
|||||||
selectedToolType,
|
selectedToolType,
|
||||||
displayName || undefined
|
displayName || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-start the instance
|
||||||
|
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||||
|
|
||||||
await updateUserConfig({ last_session_id: instance.id });
|
await updateUserConfig({ last_session_id: instance.id });
|
||||||
setCreateStatus("idle");
|
setCreateStatus("idle");
|
||||||
setSelectedProject("");
|
setSelectedProject("");
|
||||||
@@ -139,9 +205,10 @@ export const SessionsPage = () => {
|
|||||||
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
||||||
try {
|
try {
|
||||||
await stopInstance(projectId, repoId, sessionId);
|
await stopInstance(projectId, repoId, sessionId);
|
||||||
|
setStopConfirmId(null);
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
setStopConfirmId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,14 +216,38 @@ export const SessionsPage = () => {
|
|||||||
try {
|
try {
|
||||||
await deleteInstance(projectId, repoId, sessionId);
|
await deleteInstance(projectId, repoId, sessionId);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
await loadSessions();
|
// Remove from local state immediately
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||||
} catch {
|
} catch {
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRecreateTunnel = async (session: Session) => {
|
||||||
|
setRecreatingId(session.id);
|
||||||
|
try {
|
||||||
|
await recreateInstanceTunnel(
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id,
|
||||||
|
session.id
|
||||||
|
);
|
||||||
|
// Refresh sessions to get new URL
|
||||||
|
await loadSessions();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setRecreatingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpen = (session: Session) => {
|
const handleOpen = (session: Session) => {
|
||||||
navigate(`/projects/${session.project_name}/repositories/${session.repository_name}`);
|
if (session.url) {
|
||||||
|
window.open(session.url, '_blank', 'noopener,noreferrer');
|
||||||
|
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
||||||
|
navigate(`/instances/${session.id}/terminal`);
|
||||||
|
} else {
|
||||||
|
navigate(`/projects/${session.project_id}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResumeLast = async () => {
|
const handleResumeLast = async () => {
|
||||||
@@ -198,13 +289,32 @@ export const SessionsPage = () => {
|
|||||||
<p className="muted">
|
<p className="muted">
|
||||||
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
|
||||||
</p>
|
</p>
|
||||||
|
{lastSession.url && (
|
||||||
|
<p className="session-url">
|
||||||
|
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{lastSession.url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
|
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="last-session-actions">
|
<div className="last-session-actions">
|
||||||
|
{lastSession.url ? (
|
||||||
|
<a
|
||||||
|
href={lastSession.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="primary-button"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<button className="primary-button" onClick={handleResumeLast} type="button">
|
<button className="primary-button" onClick={handleResumeLast} type="button">
|
||||||
<Icon name="play" size="sm" />
|
<Icon name="play" size="sm" />
|
||||||
Resume
|
Resume
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,9 +339,56 @@ export const SessionsPage = () => {
|
|||||||
<p className="muted">
|
<p className="muted">
|
||||||
{session.tool_type_name} · {session.project_name}
|
{session.tool_type_name} · {session.project_name}
|
||||||
</p>
|
</p>
|
||||||
<span className="status-badge running">running</span>
|
{session.url && (
|
||||||
|
<p className="session-url">
|
||||||
|
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{session.url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||||
|
{session.status === "starting" && (
|
||||||
|
<span className="status-badge starting">starting...</span>
|
||||||
|
)}
|
||||||
|
{session.status === "probing" && (
|
||||||
|
<span className="status-badge probing">checking...</span>
|
||||||
|
)}
|
||||||
|
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
||||||
|
<span className="status-badge error">tunnel error</span>
|
||||||
|
)}
|
||||||
|
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
|
||||||
|
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
|
||||||
|
)}
|
||||||
|
{tunnelHealth[session.id]?.last_probe_output && (
|
||||||
|
<div className="probe-output-section">
|
||||||
|
<button
|
||||||
|
className="probe-toggle"
|
||||||
|
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="info" size="sm" />
|
||||||
|
{expandedProbeId === session.id ? "Hide probe output" : "Show probe output"}
|
||||||
|
</button>
|
||||||
|
{expandedProbeId === session.id && (
|
||||||
|
<pre className="probe-output">
|
||||||
|
{tunnelHealth[session.id].last_probe_output}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="session-actions">
|
<div className="session-actions">
|
||||||
|
{session.url ? (
|
||||||
|
<a
|
||||||
|
href={session.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="secondary-button small"
|
||||||
|
>
|
||||||
|
<Icon name="external" size="sm" />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
onClick={() => handleOpen(session)}
|
onClick={() => handleOpen(session)}
|
||||||
@@ -240,20 +397,84 @@ export const SessionsPage = () => {
|
|||||||
<Icon name="external" size="sm" />
|
<Icon name="external" size="sm" />
|
||||||
Open
|
Open
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "unreachable" && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
|
onClick={() => void handleRecreateTunnel(session)}
|
||||||
|
type="button"
|
||||||
|
disabled={recreatingId === session.id}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
{recreatingId === session.id ? "Recreating..." : "Recreate Tunnel"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{stopConfirmId === session.id ? (
|
||||||
|
<div className="stop-confirm-inline">
|
||||||
|
<span className="confirm-text">Stop?</span>
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void handleStop(
|
void handleStop(
|
||||||
session.id,
|
session.id,
|
||||||
projects.find((p) => p.name === session.project_name)?.id ?? "",
|
session.project_id,
|
||||||
""
|
session.repository_id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
type="button"
|
type="button"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setStopConfirmId(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={() => setStopConfirmId(session.id)}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="stop" size="sm" />
|
<Icon name="stop" size="sm" />
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{deleteConfirmId === session.id ? (
|
||||||
|
<div className="delete-confirm-inline">
|
||||||
|
<button
|
||||||
|
className="danger-button small"
|
||||||
|
onClick={() =>
|
||||||
|
void handleDelete(
|
||||||
|
session.id,
|
||||||
|
session.project_id,
|
||||||
|
session.repository_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button small danger-text"
|
||||||
|
onClick={() => setDeleteConfirmId(session.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -275,6 +496,16 @@ export const SessionsPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="recent-session-actions">
|
<div className="recent-session-actions">
|
||||||
|
{session.url ? (
|
||||||
|
<a
|
||||||
|
href={session.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ghost-button small"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
className="ghost-button small"
|
className="ghost-button small"
|
||||||
onClick={() => handleOpen(session)}
|
onClick={() => handleOpen(session)}
|
||||||
@@ -282,6 +513,7 @@ export const SessionsPage = () => {
|
|||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
{deleteConfirmId === session.id ? (
|
{deleteConfirmId === session.id ? (
|
||||||
<div className="delete-confirm-inline">
|
<div className="delete-confirm-inline">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
type SettingsStatus = "loading" | "ready" | "error";
|
type SettingsStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ label: "General", path: "general" },
|
||||||
|
{ label: "SSH Keys", path: "ssh-keys" },
|
||||||
|
{ label: "Tool Types", path: "tool-types" },
|
||||||
|
{ label: "Tool Configs", path: "tool-configs" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "system", label: "System" },
|
{ value: "system", label: "System" },
|
||||||
{ value: "light", label: "Light" },
|
{ value: "light", label: "Light" },
|
||||||
{ value: "dark", label: "Dark" },
|
{ value: "dark", label: "Dark" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type SettingsOutletContext = {
|
||||||
|
config: UserConfig;
|
||||||
|
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
|
||||||
|
handleSave: () => Promise<void>;
|
||||||
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
|
const location = useLocation();
|
||||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
const [status, setStatus] = useState<SettingsStatus>("loading");
|
||||||
const [config, setConfig] = useState<UserConfig>({
|
const [config, setConfig] = useState<UserConfig>({
|
||||||
theme: "system",
|
theme: "system",
|
||||||
@@ -46,25 +62,19 @@ export const SettingsPage = () => {
|
|||||||
try {
|
try {
|
||||||
const update: UserConfigUpdate = {
|
const update: UserConfigUpdate = {
|
||||||
theme: config.theme,
|
theme: config.theme,
|
||||||
default_editor: config.default_editor ?? undefined,
|
default_editor: config.default_editor,
|
||||||
git_user_name: config.git_user_name ?? undefined,
|
git_user_name: config.git_user_name,
|
||||||
git_user_email: config.git_user_email ?? undefined,
|
git_user_email: config.git_user_email,
|
||||||
};
|
};
|
||||||
console.log("Sending update:", update);
|
|
||||||
const updated = await updateUserConfig(update);
|
const updated = await updateUserConfig(update);
|
||||||
console.log("Received response:", updated);
|
|
||||||
setConfig(updated);
|
setConfig(updated);
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
|
if (updated.theme === "system") {
|
||||||
// Apply theme immediately
|
|
||||||
const theme = updated.theme ?? "system";
|
|
||||||
if (theme === "system") {
|
|
||||||
document.documentElement.removeAttribute("data-theme");
|
document.documentElement.removeAttribute("data-theme");
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
document.documentElement.setAttribute("data-theme", updated.theme);
|
||||||
}
|
}
|
||||||
|
window.setTimeout(() => setSaveStatus("idle"), 2000);
|
||||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
|
||||||
} catch {
|
} catch {
|
||||||
setSaveStatus("error");
|
setSaveStatus("error");
|
||||||
}
|
}
|
||||||
@@ -86,81 +96,71 @@ export const SettingsPage = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parts = location.pathname.split("/").filter(Boolean);
|
||||||
|
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack settings-page">
|
||||||
<div className="page-header">
|
<header className="settings-header card stack-sm">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Configuration</p>
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="card stack">
|
<nav className="settings-tabs" aria-label="Settings sections">
|
||||||
<h2>Appearance</h2>
|
{TABS.map((tab) => (
|
||||||
<label className="form-field">
|
<Link
|
||||||
Theme
|
key={tab.path}
|
||||||
<select
|
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
|
||||||
value={config.theme}
|
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
|
||||||
onChange={(e) => handleChange("theme", e.target.value)}
|
|
||||||
>
|
>
|
||||||
{THEME_OPTIONS.map((opt) => (
|
{tab.label}
|
||||||
<option key={opt.value} value={opt.value}>
|
</Link>
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</nav>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card stack">
|
<div className="settings-panel card">
|
||||||
<h2>Git Identity</h2>
|
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
|
||||||
<label className="form-field">
|
|
||||||
User Name
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.git_user_name ?? ""}
|
|
||||||
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
|
|
||||||
placeholder="Your git commit name"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="form-field">
|
|
||||||
User Email
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={config.git_user_email ?? ""}
|
|
||||||
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
|
|
||||||
placeholder="your.email@example.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card stack">
|
|
||||||
<h2>Editor</h2>
|
|
||||||
<label className="form-field">
|
|
||||||
Default Editor
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.default_editor ?? ""}
|
|
||||||
onChange={(e) => handleChange("default_editor", e.target.value || null)}
|
|
||||||
placeholder="e.g., vscode, vim, cursor"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-actions">
|
|
||||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
|
||||||
{saveStatus === "saving" ? (
|
|
||||||
<>
|
|
||||||
<Icon name="loading" size="sm" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="save" size="sm" />
|
|
||||||
Save Settings
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
|
||||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GeneralSettingsTab = () => {
|
||||||
|
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<h2>General</h2>
|
||||||
|
<label className="form-field">
|
||||||
|
Theme
|
||||||
|
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
|
||||||
|
{THEME_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Git user name
|
||||||
|
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Git user email
|
||||||
|
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Default editor
|
||||||
|
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
|
||||||
|
</label>
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
||||||
|
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
|
||||||
|
</button>
|
||||||
|
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
||||||
|
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -61,7 +63,15 @@ export const SSHKeysPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Settings</p>
|
||||||
<h1>SSH Keys</h1>
|
<h1>SSH Keys</h1>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||||
|
Back to settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
type ConfigStatus = "loading" | "ready" | "error";
|
type ConfigStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
export const ToolConfigsPage = () => {
|
export const ToolConfigsPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [status, setStatus] = useState<ConfigStatus>("loading");
|
const [status, setStatus] = useState<ConfigStatus>("loading");
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||||
@@ -135,7 +137,13 @@ export const ToolConfigsPage = () => {
|
|||||||
return (
|
return (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Settings</p>
|
||||||
<h1>Tool Configurations</h1>
|
<h1>Tool Configurations</h1>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||||
|
Back to settings
|
||||||
|
</button>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Manage environment variables and configuration files for your tools
|
Manage environment variables and configuration files for your tools
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createToolType,
|
createToolType,
|
||||||
@@ -15,6 +16,7 @@ type ToolTypesStatus = "loading" | "ready" | "error";
|
|||||||
type DialogMode = "none" | "create" | "edit";
|
type DialogMode = "none" | "create" | "edit";
|
||||||
|
|
||||||
export const ToolTypesPage = () => {
|
export const ToolTypesPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||||
@@ -22,6 +24,9 @@ export const ToolTypesPage = () => {
|
|||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
const [formDisplayName, setFormDisplayName] = useState("");
|
const [formDisplayName, setFormDisplayName] = useState("");
|
||||||
const [formDescription, setFormDescription] = useState("");
|
const [formDescription, setFormDescription] = useState("");
|
||||||
|
const [formCategory, setFormCategory] = useState("");
|
||||||
|
const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
|
||||||
|
const [formPort, setFormPort] = useState("");
|
||||||
const [formTemplate, setFormTemplate] = useState("");
|
const [formTemplate, setFormTemplate] = useState("");
|
||||||
const [formVariables, setFormVariables] = useState("");
|
const [formVariables, setFormVariables] = useState("");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
@@ -47,6 +52,9 @@ export const ToolTypesPage = () => {
|
|||||||
setFormName("");
|
setFormName("");
|
||||||
setFormDisplayName("");
|
setFormDisplayName("");
|
||||||
setFormDescription("");
|
setFormDescription("");
|
||||||
|
setFormCategory("");
|
||||||
|
setFormInterfaces([]);
|
||||||
|
setFormPort("");
|
||||||
setFormTemplate("");
|
setFormTemplate("");
|
||||||
setFormVariables("");
|
setFormVariables("");
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -58,7 +66,10 @@ export const ToolTypesPage = () => {
|
|||||||
setFormName(toolType.name);
|
setFormName(toolType.name);
|
||||||
setFormDisplayName(toolType.display_name);
|
setFormDisplayName(toolType.display_name);
|
||||||
setFormDescription(toolType.description ?? "");
|
setFormDescription(toolType.description ?? "");
|
||||||
setFormTemplate(toolType.compose_template);
|
setFormCategory(toolType.category ?? "");
|
||||||
|
setFormInterfaces(toolType.interfaces ?? []);
|
||||||
|
setFormPort(toolType.default_port?.toString() ?? "");
|
||||||
|
setFormTemplate(toolType.compose_template ?? "");
|
||||||
setFormVariables(toolType.required_variables.join(", "));
|
setFormVariables(toolType.required_variables.join(", "));
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditingToolType(toolType);
|
setEditingToolType(toolType);
|
||||||
@@ -80,6 +91,11 @@ export const ToolTypesPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!formPort.trim() || isNaN(Number(formPort))) {
|
||||||
|
setFormError("Default port is required and must be a number");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const variables = formVariables
|
const variables = formVariables
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
@@ -91,6 +107,9 @@ export const ToolTypesPage = () => {
|
|||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
display_name: formDisplayName.trim(),
|
display_name: formDisplayName.trim(),
|
||||||
description: formDescription.trim() || undefined,
|
description: formDescription.trim() || undefined,
|
||||||
|
category: formCategory.trim() || undefined,
|
||||||
|
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||||
|
default_port: Number(formPort),
|
||||||
compose_template: formTemplate.trim(),
|
compose_template: formTemplate.trim(),
|
||||||
required_variables: variables,
|
required_variables: variables,
|
||||||
};
|
};
|
||||||
@@ -99,6 +118,9 @@ export const ToolTypesPage = () => {
|
|||||||
const input: UpdateToolTypeRequest = {
|
const input: UpdateToolTypeRequest = {
|
||||||
display_name: formDisplayName.trim(),
|
display_name: formDisplayName.trim(),
|
||||||
description: formDescription.trim() || undefined,
|
description: formDescription.trim() || undefined,
|
||||||
|
category: formCategory.trim() || undefined,
|
||||||
|
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||||
|
default_port: Number(formPort),
|
||||||
compose_template: formTemplate.trim(),
|
compose_template: formTemplate.trim(),
|
||||||
required_variables: variables,
|
required_variables: variables,
|
||||||
};
|
};
|
||||||
@@ -145,13 +167,19 @@ export const ToolTypesPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
<div className="page-header" style={{ marginBottom: "1rem" }}>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Settings</p>
|
||||||
<h1>Tool Types</h1>
|
<h1>Tool Types</h1>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
|
||||||
<button onClick={openCreate}>
|
<button onClick={openCreate}>
|
||||||
<Icon name="add" size="sm" />
|
<Icon name="add" size="sm" />
|
||||||
Create Tool Type
|
Create Tool Type
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{toolTypes.length === 0 ? (
|
{toolTypes.length === 0 ? (
|
||||||
<p>No tool types found.</p>
|
<p>No tool types found.</p>
|
||||||
@@ -164,6 +192,13 @@ export const ToolTypesPage = () => {
|
|||||||
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-secondary">{toolType.description || "No description"}</p>
|
<p className="text-secondary">{toolType.description || "No description"}</p>
|
||||||
|
<div className="tool-type-meta">
|
||||||
|
<span>Port: {toolType.default_port || "N/A"}</span>
|
||||||
|
{toolType.interfaces?.length > 0 && (
|
||||||
|
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||||
|
)}
|
||||||
|
{toolType.category && <span>Category: {toolType.category}</span>}
|
||||||
|
</div>
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
{!toolType.is_builtin && (
|
{!toolType.is_builtin && (
|
||||||
<>
|
<>
|
||||||
@@ -240,6 +275,61 @@ export const ToolTypesPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Category</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formCategory}
|
||||||
|
onChange={(e) => setFormCategory(e.target.value)}
|
||||||
|
placeholder="e.g., editor, notebook, ai-assistant"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Interfaces</label>
|
||||||
|
<div className="checkbox-group">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formInterfaces.includes("web")}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setFormInterfaces([...formInterfaces, "web"]);
|
||||||
|
} else {
|
||||||
|
setFormInterfaces(formInterfaces.filter((i) => i !== "web"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Web
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formInterfaces.includes("terminal")}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setFormInterfaces([...formInterfaces, "terminal"]);
|
||||||
|
} else {
|
||||||
|
setFormInterfaces(formInterfaces.filter((i) => i !== "terminal"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Terminal
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Default Port *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formPort}
|
||||||
|
onChange={(e) => setFormPort(e.target.value)}
|
||||||
|
placeholder="e.g., 8443"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Compose Template (YAML)</label>
|
<label>Compose Template (YAML)</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ToolWorkshopPage } from "./tool-workshop";
|
||||||
|
import * as toolTypesApi from "../api/tool_types";
|
||||||
|
import * as toolConfigsApi from "../api/tool_configs";
|
||||||
|
import * as configFoldersApi from "../api/config_folders";
|
||||||
|
|
||||||
|
const mockToolTypes = [
|
||||||
|
{
|
||||||
|
id: "type-1",
|
||||||
|
name: "code-server",
|
||||||
|
display_name: "VS Code Server",
|
||||||
|
description: "VS Code in browser",
|
||||||
|
category: "editor",
|
||||||
|
interfaces: ["web"],
|
||||||
|
default_port: 8443,
|
||||||
|
definition_type: "compose",
|
||||||
|
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
|
||||||
|
dockerfile_template: null,
|
||||||
|
build_context: null,
|
||||||
|
readiness_probe: null,
|
||||||
|
required_variables: ["REPO_PATH"],
|
||||||
|
is_builtin: true,
|
||||||
|
created_by_id: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "type-2",
|
||||||
|
name: "custom-tool",
|
||||||
|
display_name: "Custom Tool",
|
||||||
|
description: "My custom tool",
|
||||||
|
category: "utility",
|
||||||
|
interfaces: ["terminal"],
|
||||||
|
default_port: 8080,
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
compose_template: null,
|
||||||
|
dockerfile_template: "FROM python:3.11",
|
||||||
|
build_context: null,
|
||||||
|
readiness_probe: {
|
||||||
|
command: "python --version",
|
||||||
|
timeout: 30,
|
||||||
|
interval: 2,
|
||||||
|
},
|
||||||
|
required_variables: [],
|
||||||
|
is_builtin: false,
|
||||||
|
created_by_id: "user-1",
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockConfigs = [
|
||||||
|
{
|
||||||
|
id: "config-1",
|
||||||
|
tool_type_id: "type-1",
|
||||||
|
project_id: null,
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
value: "sk-test123",
|
||||||
|
config_type: "env",
|
||||||
|
file_path: null,
|
||||||
|
port_override: null,
|
||||||
|
start_command: null,
|
||||||
|
working_directory: null,
|
||||||
|
environment_variables: {},
|
||||||
|
volumes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "config-2",
|
||||||
|
tool_type_id: "type-2",
|
||||||
|
project_id: null,
|
||||||
|
key: "advanced-config",
|
||||||
|
value: "test-value",
|
||||||
|
config_type: "env",
|
||||||
|
file_path: null,
|
||||||
|
port_override: 9090,
|
||||||
|
start_command: "python app.py",
|
||||||
|
working_directory: "/app",
|
||||||
|
environment_variables: { DEBUG: "true" },
|
||||||
|
volumes: [{ source: "data", target: "/data", type: "bind" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockFolders = [
|
||||||
|
{
|
||||||
|
id: "folder-1",
|
||||||
|
user_id: "user-1",
|
||||||
|
name: "my-dotfiles",
|
||||||
|
description: "My personal config files",
|
||||||
|
mount_path: "/home/user",
|
||||||
|
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||||
|
project_overrides: {},
|
||||||
|
is_active: true,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "folder-2",
|
||||||
|
user_id: "user-1",
|
||||||
|
name: "project-configs",
|
||||||
|
description: "Project specific configs",
|
||||||
|
mount_path: "/workspace",
|
||||||
|
files: { ".env": "API_URL=http://localhost:8080" },
|
||||||
|
project_overrides: {
|
||||||
|
"proj-1": {
|
||||||
|
mount_path: "/app",
|
||||||
|
files: { ".env": "API_URL=http://prod.api" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
is_active: false,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ToolWorkshopPage", () => {
|
||||||
|
it("renders loading state initially", () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {}));
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {}));
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tool types tab by default", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to configs tab", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("advanced-config")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to folders tab", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("project-configs")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens tool type creation form", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates tool type with compose definition", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||||
|
target: { value: "new-tool" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||||
|
target: { value: "New Tool" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||||
|
target: { value: "8080" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
||||||
|
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "new-tool",
|
||||||
|
display_name: "New Tool",
|
||||||
|
definition_type: "compose",
|
||||||
|
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates tool type with dockerfile definition", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||||
|
target: { value: "docker-tool" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||||
|
target: { value: "Docker Tool" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||||
|
target: { value: "3000" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to dockerfile
|
||||||
|
fireEvent.change(screen.getByLabelText("Definition Type"), {
|
||||||
|
target: { value: "dockerfile" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
|
||||||
|
target: { value: "FROM python:3.11\\nRUN pip install flask" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "docker-tool",
|
||||||
|
definition_type: "dockerfile",
|
||||||
|
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows readiness probe fields", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/interval/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens config creation form", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates config with advanced fields", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/key/i), {
|
||||||
|
target: { value: "MY_CONFIG" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/value/i), {
|
||||||
|
target: { value: "my-value" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/port override/i), {
|
||||||
|
target: { value: "9090" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/start command/i), {
|
||||||
|
target: { value: "python app.py" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
key: "MY_CONFIG",
|
||||||
|
value: "my-value",
|
||||||
|
port_override: 9090,
|
||||||
|
start_command: "python app.py",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(configsListMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens folder creation form", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates config folder successfully", async () => {
|
||||||
|
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||||
|
target: { value: "new-folder" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Mount Path *"), {
|
||||||
|
target: { value: "/home/dev" },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "new-folder",
|
||||||
|
mount_path: "/home/dev",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(foldersListMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows folder active/inactive status", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that active folder shows Active badge
|
||||||
|
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles error state gracefully", async () => {
|
||||||
|
vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error"));
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error"));
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries loading after error", async () => {
|
||||||
|
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs")
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders")
|
||||||
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes tool type successfully", async () => {
|
||||||
|
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||||
|
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||||
|
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||||
|
|
||||||
|
render(<ToolWorkshopPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find and click delete button for custom tool (not built-in)
|
||||||
|
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
|
||||||
|
screen.getByText("Custom Tool").parentElement;
|
||||||
|
if (customToolCard) {
|
||||||
|
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
|
||||||
|
if (deleteButton) {
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(deleteMock).toHaveBeenCalledWith("type-2");
|
||||||
|
});
|
||||||
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
+16
-8
@@ -2,18 +2,18 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||||||
|
|
||||||
import { AppShell } from "./components/app-shell";
|
import { AppShell } from "./components/app-shell";
|
||||||
import { ProtectedRoute } from "./components/protected-route";
|
import { ProtectedRoute } from "./components/protected-route";
|
||||||
import { DashboardPage } from "./pages/dashboard";
|
import { HomePage } from "./pages/dashboard";
|
||||||
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
|
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
|
||||||
import { SessionsPage } from "./pages/sessions";
|
|
||||||
import { ProfilePage } from "./pages/profile";
|
import { ProfilePage } from "./pages/profile";
|
||||||
import { ProjectsPage } from "./pages/projects";
|
import { ProjectsPage } from "./pages/projects";
|
||||||
import { GitRepositoriesPage } from "./pages/git-repositories";
|
import { GitRepositoriesPage } from "./pages/git-repositories";
|
||||||
import { GitHistoryPage } from "./pages/git-history";
|
import { GitHistoryPage } from "./pages/git-history";
|
||||||
import { ProjectSettingsPage } from "./pages/project-settings";
|
import { ProjectSettingsPage } from "./pages/project-settings";
|
||||||
import { RepoWorkspace } from "./pages/repo-workspace";
|
import { RepoWorkspace } from "./pages/repo-workspace";
|
||||||
import { SSHKeysPage } from "./pages/ssh-keys";
|
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
||||||
import { SettingsPage } from "./pages/settings";
|
|
||||||
import { TerminalPage } from "./pages/terminal";
|
import { TerminalPage } from "./pages/terminal";
|
||||||
|
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||||
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
import { ToolConfigsPage } from "./pages/tool-configs";
|
import { ToolConfigsPage } from "./pages/tool-configs";
|
||||||
import { ToolTypesPage } from "./pages/tool-types";
|
import { ToolTypesPage } from "./pages/tool-types";
|
||||||
|
|
||||||
@@ -21,6 +21,10 @@ export const AppRouter = () => {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginRedirectPage />} />
|
<Route path="/login" element={<LoginRedirectPage />} />
|
||||||
|
<Route path="/sessions" element={<Navigate to="/" replace />} />
|
||||||
|
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
|
||||||
|
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
|
||||||
|
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
@@ -29,18 +33,22 @@ export const AppRouter = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="sessions" element={<SessionsPage />} />
|
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
||||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
||||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
||||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />}>
|
||||||
|
<Route index element={<Navigate to="general" replace />} />
|
||||||
|
<Route path="general" element={<GeneralSettingsTab />} />
|
||||||
|
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||||
<Route path="tool-types" element={<ToolTypesPage />} />
|
<Route path="tool-types" element={<ToolTypesPage />} />
|
||||||
<Route path="tool-configs" element={<ToolConfigsPage />} />
|
<Route path="tool-configs" element={<ToolConfigsPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="general" replace />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/404" element={<NotFoundPage />} />
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
|||||||
+172
-11
@@ -1,6 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
--bg: #f4f1ea;
|
--bg: #f4f1ea;
|
||||||
--panel: #fffef9;
|
--panel: #fffef9;
|
||||||
--ink: #1d1d1b;
|
--ink: #1d1d1b;
|
||||||
@@ -8,6 +8,17 @@
|
|||||||
--brand: #275d4b;
|
--brand: #275d4b;
|
||||||
--brand-strong: #154236;
|
--brand-strong: #154236;
|
||||||
--border: #d8d0c5;
|
--border: #d8d0c5;
|
||||||
|
--primary: #275d4b;
|
||||||
|
--primary-fg: #fffef9;
|
||||||
|
--color-primary: #275d4b;
|
||||||
|
--success: #2f8f62;
|
||||||
|
--success-light: rgba(47, 143, 98, 0.14);
|
||||||
|
--warning: #c08a1e;
|
||||||
|
--warning-light: rgba(192, 138, 30, 0.14);
|
||||||
|
--danger: #b94a3c;
|
||||||
|
--danger-light: rgba(185, 74, 60, 0.14);
|
||||||
|
--info: #4f7fb8;
|
||||||
|
--info-light: rgba(79, 127, 184, 0.14);
|
||||||
|
|
||||||
/* Spacing Scale (4px base) */
|
/* Spacing Scale (4px base) */
|
||||||
--space-1: 0.25rem;
|
--space-1: 0.25rem;
|
||||||
@@ -36,13 +47,16 @@
|
|||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #1a1a18;
|
--bg: #171613;
|
||||||
--panel: #252522;
|
--panel: #22201d;
|
||||||
--ink: #e8e6e1;
|
--ink: #ece7df;
|
||||||
--muted: #a39e96;
|
--muted: #a59d92;
|
||||||
--brand: #4a9e7f;
|
--brand: #5fa889;
|
||||||
--brand-strong: #3d8a6e;
|
--brand-strong: #4d9175;
|
||||||
--border: #3d3d38;
|
--border: #39342d;
|
||||||
|
--primary: #5fa889;
|
||||||
|
--primary-fg: #171613;
|
||||||
|
--color-primary: #5fa889;
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
--success-light: rgba(34, 197, 94, 0.15);
|
--success-light: rgba(34, 197, 94, 0.15);
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
@@ -84,12 +98,12 @@ a {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.85rem 1.25rem;
|
padding: 0.85rem 1.25rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
backdrop-filter: blur(7px);
|
backdrop-filter: blur(7px);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .shell-header {
|
[data-theme="dark"] .shell-header {
|
||||||
background: rgba(37, 37, 34, 0.85);
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -115,6 +129,7 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@@ -133,11 +148,134 @@ a {
|
|||||||
color: #f7fff7;
|
color: #f7fff7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-section-title {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-content {
|
.shell-content {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-page,
|
||||||
|
.settings-page {
|
||||||
|
max-width: 1240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid,
|
||||||
|
.home-project-grid,
|
||||||
|
.home-session-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-summary-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-grid,
|
||||||
|
.home-session-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h2,
|
||||||
|
.settings-header h1,
|
||||||
|
.settings-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-section h3,
|
||||||
|
.home-section p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-tight {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tab.active {
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions,
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
padding: 0.42rem 0.7rem;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card,
|
||||||
|
.project-card,
|
||||||
|
.recent-session-item {
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Shell */
|
/* Responsive Shell */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.shell-body {
|
.shell-body {
|
||||||
@@ -2555,6 +2693,21 @@ a.nav-item,
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-url {
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-url a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-url a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.session-actions {
|
.session-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
@@ -2596,9 +2749,17 @@ a.nav-item,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-confirm-inline {
|
.delete-confirm-inline,
|
||||||
|
.stop-confirm-inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-session-section {
|
.create-session-section {
|
||||||
|
|||||||
@@ -12,5 +12,6 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Git repositories are managed within projects. You can create bare repositories f
|
|||||||
2. Click the **"New Repository"** button
|
2. Click the **"New Repository"** button
|
||||||
3. Fill in the form:
|
3. Fill in the form:
|
||||||
- **Name**: Repository name (required)
|
- **Name**: Repository name (required)
|
||||||
- **Remote URL**: For cloning (optional)
|
- **Owner** and **Repository**: For SSH cloning from `git.commumedia.org`
|
||||||
- **Mirror Clone**: Toggle for mirror clones
|
- **Mirror Clone**: Toggle for mirror clones
|
||||||
4. Click **"Create Repository"**
|
4. Click **"Create Repository"**
|
||||||
|
|
||||||
@@ -25,12 +25,12 @@ Creates a new bare git repository. Use this for:
|
|||||||
|
|
||||||
#### Clone from Remote
|
#### Clone from Remote
|
||||||
|
|
||||||
Enter a git URL to clone from:
|
Enter the repository owner and name to clone from `git.commumedia.org` over SSH:
|
||||||
- `https://github.com/user/repo.git`
|
- `owner`: `alice`
|
||||||
- `git@github.com:user/repo.git`
|
- `repository`: `demo`
|
||||||
- `https://gitlab.com/user/repo.git`
|
- Resulting SSH URL: `git@git.commumedia.org:alice/demo.git`
|
||||||
|
|
||||||
**Smart URL Parsing:** If you paste a browser URL (like `https://github.com/user/repo/tree/main`), the system will automatically suggest the correct git URL.
|
**Advanced fallback:** If needed, you can still paste a full git URL and the system will suggest the correct clone URL.
|
||||||
|
|
||||||
#### Mirror Clone
|
#### Mirror Clone
|
||||||
|
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-20
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Currently, tool instances are exposed via an API proxy endpoint that forwards requests from `/instances/{id}/proxy/` to the internal Docker container. This creates latency, adds load to the API service, and doesn't support WebSocket features well. Cloudflare Tunnel offers a better architecture where each instance gets its own HTTPS subdomain.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Each running tool instance gets a unique public HTTPS subdomain
|
||||||
|
- No manual DNS or reverse proxy configuration per instance
|
||||||
|
- Automatic cleanup when instances are stopped or deleted
|
||||||
|
- Support for WebSocket and real-time features (code-server terminal, jupyter kernels)
|
||||||
|
- Minimal latency compared to API proxy approach
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Custom domains per instance (use Cloudflare zone's wildcard)
|
||||||
|
- Advanced tunnel features (load balancing, failover, ingress rules)
|
||||||
|
- Replacing Traefik for the main app (API + frontend)
|
||||||
|
- Supporting non-HTTP protocols (TCP/UDP raw tunneling)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Cloudflare API vs cloudflared CLI
|
||||||
|
|
||||||
|
**Decision:** Use the Cloudflare REST API to create/manage tunnels, not the `cloudflared` CLI.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- The API gives us programmatic control without parsing CLI output
|
||||||
|
- We can use `httpx` (already a dependency) instead of subprocess calls
|
||||||
|
- Easier to test and mock
|
||||||
|
|
||||||
|
**Alternative considered:** Running `cloudflared tunnel create` via subprocess
|
||||||
|
- Rejected: Fragile, harder to test, requires cloudflared binary in API container
|
||||||
|
|
||||||
|
### Architecture: cloudflared as a separate container
|
||||||
|
|
||||||
|
**Decision:** Run `cloudflared` as a standalone Docker service that connects to Cloudflare and routes traffic.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Separation of concerns: API manages tunnels, cloudflared handles connectivity
|
||||||
|
- The cloudflared container can access the Docker internal network where instances run
|
||||||
|
- Easier to scale/restart independently
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Cloudflare Edge │
|
||||||
|
└──────────────────────┬──────────────────────────────────────┘
|
||||||
|
│ HTTPS
|
||||||
|
┌──────────────────────▼──────────────────────────────────────┐
|
||||||
|
│ cloudflared container │
|
||||||
|
│ (connects to Cloudflare, receives traffic for *.zone) │
|
||||||
|
└──────────┬──────────────────────────────────────────────────┘
|
||||||
|
│ Docker network
|
||||||
|
┌──────────▼──────────────────────────────────────────────────┐
|
||||||
|
│ code-server container:8443 jupyter container:8888 │
|
||||||
|
│ (tool instances on Docker network with DNS names) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subdomain naming
|
||||||
|
|
||||||
|
**Decision:** Use `instance-{short-uuid}.{zone}` format (e.g., `instance-a1b2c3d4.headquarter.commumedia.org`)
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Predictable and URL-safe
|
||||||
|
- Short enough to be readable
|
||||||
|
- UUID ensures uniqueness without exposing internal IDs
|
||||||
|
|
||||||
|
### Tunnel lifecycle
|
||||||
|
|
||||||
|
**Decision:** Create tunnel on instance start, delete on instance stop/delete.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User clicks "Start"
|
||||||
|
2. Backend creates Cloudflare tunnel via API
|
||||||
|
3. Backend creates DNS CNAME record: `instance-abc123` → `{tunnel-id}.cfargotunnel.com`
|
||||||
|
4. Backend stores `tunnel_id` and `public_url` in ToolInstance
|
||||||
|
5. cloudflared container routes traffic to container:port
|
||||||
|
6. On stop: delete DNS record, delete tunnel
|
||||||
|
|
||||||
|
### cloudflared configuration
|
||||||
|
|
||||||
|
**Decision:** Use a single cloudflared container with dynamic config file updates.
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- The cloudflared container reads an `config.yml` file mounted as a volume
|
||||||
|
- The API writes ingress rules to this file when instances start/stop
|
||||||
|
- cloudflared automatically reloads the config (or we restart the container)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /etc/cloudflared/config.yml
|
||||||
|
tunnel: {tunnel-token}
|
||||||
|
credentials-file: /etc/cloudflared/credentials.json
|
||||||
|
ingress:
|
||||||
|
- hostname: instance-abc123.headquarter.commumedia.org
|
||||||
|
service: http://code-server-repo-abc123:8443
|
||||||
|
- hostname: instance-xyz789.headquarter.commumedia.org
|
||||||
|
service: http://jupyter-repo-def:8888
|
||||||
|
- service: http_status:404
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
**Decision:** Cloudflare tunnels provide HTTPS but do NOT handle app-level auth. Tool instances without built-in auth (like code-server) will be publicly accessible.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Cloudflare Access could add auth, but adds complexity
|
||||||
|
- Many tools (code-server) have their own password/auth mechanisms
|
||||||
|
- Users should configure tool-level auth via ToolConfig
|
||||||
|
|
||||||
|
**Mitigation:** Document that users must configure tool passwords via ToolConfig (e.g., `PASSWORD` env for code-server).
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk]** Cloudflare API rate limits (1200 requests/5 min)
|
||||||
|
→ **Mitigation:** Tunnel creation is infrequent (user-initiated), unlikely to hit limits
|
||||||
|
|
||||||
|
**[Risk]** cloudflared container becomes a single point of failure
|
||||||
|
→ **Mitigation:** It's stateless; can be restarted quickly. All instances share one cloudflared.
|
||||||
|
|
||||||
|
**[Risk]** Subdomain enumeration exposes running instances
|
||||||
|
→ **Mitigation:** UUID-based names are hard to guess. Consider adding Cloudflare Access in future.
|
||||||
|
|
||||||
|
**[Risk]** cloudflared config file updates require container restart
|
||||||
|
→ **Mitigation:** Investigate `cloudflared --no-autoupdate` with config watch, or accept brief restart
|
||||||
|
|
||||||
|
**[Risk]** Tool instances publicly accessible without auth
|
||||||
|
→ **Mitigation:** Document security best practices, recommend setting tool passwords
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Deploy cloudflared container with base config
|
||||||
|
2. Add Cloudflare env vars to API container
|
||||||
|
3. Deploy backend changes (tunnel service, updated lifecycle)
|
||||||
|
4. Deploy frontend changes (use public_url instead of proxy)
|
||||||
|
5. Test with code-server instance
|
||||||
|
6. Remove old proxy endpoint code
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should we add Cloudflare Access (Zero Trust) to protect instances?
|
||||||
|
- Do we need to support custom subdomains (e.g., `myproject.headquarter.commumedia.org`)?
|
||||||
|
- Should we keep the proxy endpoint as a fallback?
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The current approach of proxying tool instances through the backend API is fragile and creates a bottleneck. Every HTTP request and WebSocket connection to a tool instance (code-server, jupyter, etc.) must pass through the FastAPI application, adding latency and consuming API resources. Cloudflare Tunnel provides a robust alternative: each instance gets its own public subdomain with automatic HTTPS, without exposing ports or requiring complex reverse proxy rules.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Replace the API proxy endpoint (`/instances/{id}/proxy/`) with Cloudflare Tunnel integration
|
||||||
|
- Run a `cloudflared` container alongside the API that manages tunnels programmatically via the Cloudflare API
|
||||||
|
- When a tool instance starts, create a unique Cloudflare Tunnel and DNS record pointing to the instance's internal container name and port
|
||||||
|
- Store the public URL (e.g., `https://instance-abc123.headquarter.commumedia.org`) in the ToolInstance model
|
||||||
|
- Update the frontend "Open" button to use the Cloudflare URL instead of the proxy path
|
||||||
|
- Remove the proxy endpoint and related code (instance_proxy.py)
|
||||||
|
- **BREAKING**: The `/instances/{id}/proxy/{path:path}` endpoint will be removed
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `cloudflare-tunnel-management`: Creating, deleting, and managing Cloudflare tunnels for tool instances via the Cloudflare API
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `instance-proxy`: The current proxy-based approach will be replaced by Cloudflare tunnels. The requirement that "The API SHALL expose an endpoint that forwards HTTP requests" is replaced by "The system SHALL provide a public URL for each running instance."
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Backend: New Cloudflare tunnel service, updated instance lifecycle (create tunnel on start, delete on stop), removed proxy code
|
||||||
|
- Frontend: Update "Open" links to use public Cloudflare URLs
|
||||||
|
- Infrastructure: New `cloudflared` Docker service, Cloudflare API token required
|
||||||
|
- Environment: New env vars: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_ZONE_ID`
|
||||||
|
- Docker: Cloudflared container must be on the same network as tool instances
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: System creates Cloudflare tunnel on instance start
|
||||||
|
When a tool instance is started, the system SHALL create a Cloudflare tunnel and DNS record to expose it publicly.
|
||||||
|
|
||||||
|
#### Scenario: Start instance creates tunnel
|
||||||
|
- **WHEN** a user starts a tool instance
|
||||||
|
- **THEN** the system calls the Cloudflare API to create a tunnel
|
||||||
|
- **AND** creates a CNAME DNS record for `instance-{id}.{zone}`
|
||||||
|
- **AND** stores the tunnel ID and public URL in the database
|
||||||
|
|
||||||
|
#### Scenario: Tunnel points to correct container
|
||||||
|
- **WHEN** a tunnel is created for an instance
|
||||||
|
- **THEN** the tunnel ingress rule maps the subdomain to the container's internal DNS name and port
|
||||||
|
|
||||||
|
### Requirement: System deletes Cloudflare tunnel on instance stop
|
||||||
|
When a tool instance is stopped or deleted, the system SHALL clean up the associated Cloudflare tunnel and DNS record.
|
||||||
|
|
||||||
|
#### Scenario: Stop instance deletes tunnel
|
||||||
|
- **WHEN** a user stops a running instance
|
||||||
|
- **THEN** the system deletes the DNS record
|
||||||
|
- **AND** deletes the Cloudflare tunnel
|
||||||
|
|
||||||
|
#### Scenario: Delete instance cleans up tunnel
|
||||||
|
- **WHEN** a user deletes an instance
|
||||||
|
- **AND** the instance has an active tunnel
|
||||||
|
- **THEN** the system deletes both the DNS record and the tunnel
|
||||||
|
|
||||||
|
### Requirement: Frontend uses public URL for instance access
|
||||||
|
The frontend SHALL display and link to the public Cloudflare URL for running instances.
|
||||||
|
|
||||||
|
#### Scenario: Open button uses public URL
|
||||||
|
- **WHEN** a user views a running instance
|
||||||
|
- **THEN** the "Open" button links to the instance's public URL
|
||||||
|
- **AND** the URL opens in a new tab
|
||||||
|
|
||||||
|
#### Scenario: Session list shows public URL
|
||||||
|
- **WHEN** a user views their sessions
|
||||||
|
- **THEN** each running session displays its public URL
|
||||||
|
|
||||||
|
### Requirement: Only instance owner can start/stop/delete tunnels
|
||||||
|
The system SHALL verify that only the instance owner can trigger tunnel creation or deletion.
|
||||||
|
|
||||||
|
#### Scenario: Owner starts instance
|
||||||
|
- **WHEN** the instance owner clicks "Start"
|
||||||
|
- **THEN** the tunnel is created successfully
|
||||||
|
|
||||||
|
#### Scenario: Non-owner attempts to start
|
||||||
|
- **WHEN** a non-owner attempts to start an instance
|
||||||
|
- **THEN** the request returns 403 Forbidden
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## 1. Infrastructure Setup
|
||||||
|
|
||||||
|
- [ ] 1.1 Add cloudflared service to docker-compose.traefik.yml
|
||||||
|
- [ ] 1.2 Create cloudflared config directory and base config
|
||||||
|
- [ ] 1.3 Add Cloudflare env vars (API token, account ID, zone ID) to .env.example
|
||||||
|
- [ ] 1.4 Mount shared config volume between API and cloudflared containers
|
||||||
|
|
||||||
|
## 2. Backend - Cloudflare Tunnel Service
|
||||||
|
|
||||||
|
- [ ] 2.1 Create `src/services/cloudflare_tunnel.py` with tunnel CRUD operations
|
||||||
|
- [ ] 2.2 Implement `create_tunnel(instance_name, container_name, port)` function
|
||||||
|
- [ ] 2.3 Implement `delete_tunnel(tunnel_id)` function
|
||||||
|
- [ ] 2.4 Implement `update_cloudflared_config()` to rewrite config.yml
|
||||||
|
- [ ] 2.5 Add Cloudflare API token validation on startup
|
||||||
|
|
||||||
|
## 3. Backend - Instance Lifecycle Updates
|
||||||
|
|
||||||
|
- [ ] 3.1 Update ToolInstance model: add `tunnel_id` and `public_url` fields
|
||||||
|
- [ ] 3.2 Create Alembic migration for new fields
|
||||||
|
- [ ] 3.3 Update `start_instance` to create tunnel and store public_url
|
||||||
|
- [ ] 3.4 Update `stop_instance` to delete tunnel and DNS record
|
||||||
|
- [ ] 3.5 Update `delete_instance` to ensure tunnel cleanup
|
||||||
|
- [ ] 3.6 Update `get_user_sessions` to include `public_url`
|
||||||
|
|
||||||
|
## 4. Backend - Cleanup
|
||||||
|
|
||||||
|
- [ ] 4.1 Remove `instance_proxy.py` router
|
||||||
|
- [ ] 4.2 Remove proxy route registration from `main.py`
|
||||||
|
- [ ] 4.3 Remove `default_port` from ToolType (no longer needed)
|
||||||
|
- [ ] 4.4 Clean up any proxy-related code
|
||||||
|
|
||||||
|
## 5. Frontend Updates
|
||||||
|
|
||||||
|
- [ ] 5.1 Update Session interface to include `public_url`
|
||||||
|
- [ ] 5.2 Update InstanceList "Open" button to use `public_url`
|
||||||
|
- [ ] 5.3 Update SessionsPage "Open" button to use `public_url`
|
||||||
|
- [ ] 5.4 Remove proxy URL construction logic
|
||||||
|
|
||||||
|
## 6. Testing and Deployment
|
||||||
|
|
||||||
|
- [ ] 6.1 Test tunnel creation with code-server instance
|
||||||
|
- [ ] 6.2 Test tunnel deletion on instance stop
|
||||||
|
- [ ] 6.3 Verify HTTPS and WebSocket support
|
||||||
|
- [ ] 6.4 Run quality gates (ruff, mypy, typecheck, lint, build)
|
||||||
|
- [ ] 6.5 Deploy and test end-to-end
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The tool system currently supports:
|
||||||
|
- ToolTypes with compose templates and basic metadata
|
||||||
|
- ToolConfigs as simple key-value pairs (env vars or files)
|
||||||
|
- Instance creation via compose rendering
|
||||||
|
- Basic flat-list UI at `/tool-configs`
|
||||||
|
|
||||||
|
Users need a much richer system for defining, configuring, and running development tools.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Support both Docker Compose and Dockerfile for tool definitions
|
||||||
|
- Add readiness probes with configurable commands and timeouts
|
||||||
|
- Create reusable config file collections ("folders") mountable as volumes
|
||||||
|
- Add rich tool config fields (port, start_command, working_directory, volumes, env vars)
|
||||||
|
- Build a unified "Tool Workshop" UI for all tool management
|
||||||
|
- Support per-project overrides on config folders
|
||||||
|
- Maintain backward compatibility with existing built-in tool types
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Docker image registry management (assume local builds or public images)
|
||||||
|
- Real-time collaborative tool editing
|
||||||
|
- Tool marketplace/sharing between users
|
||||||
|
- Advanced orchestration (Kubernetes, Swarm)
|
||||||
|
- Config folder versioning/Git integration
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TOOL WORKSHOP │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ ToolType │────▶│ ToolConfig │◀────│ ConfigFolder │ │
|
||||||
|
│ │ (Blueprint) │ │ (Settings) │ │ (Files) │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ToolInstance │ │
|
||||||
|
│ │ (Runtime + Volumes) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### ToolType Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ToolType:
|
||||||
|
# Existing fields
|
||||||
|
name: str # unique identifier
|
||||||
|
display_name: str
|
||||||
|
description: str | None
|
||||||
|
category: str
|
||||||
|
interfaces: list[str] # ["web", "terminal"]
|
||||||
|
default_port: int
|
||||||
|
required_variables: list[str]
|
||||||
|
is_builtin: bool
|
||||||
|
|
||||||
|
# New fields
|
||||||
|
definition_type: str # "compose" | "dockerfile"
|
||||||
|
compose_template: str | None # YAML template (if definition_type == "compose")
|
||||||
|
dockerfile_template: str | None # Dockerfile content (if definition_type == "dockerfile")
|
||||||
|
build_context: dict | None # {"files": {"path": "content"}} for dockerfile builds
|
||||||
|
readiness_probe: dict | None # {"command": "...", "timeout": 30, "interval": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decision**: Store both compose and dockerfile, use `definition_type` to determine which to use. This allows easy switching and migration.
|
||||||
|
|
||||||
|
### ToolConfig Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ToolConfig:
|
||||||
|
# Existing fields
|
||||||
|
user_id: UUID
|
||||||
|
tool_type_id: UUID
|
||||||
|
project_id: UUID | None # null = global config
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
config_type: str # "env" | "file"
|
||||||
|
file_path: str | None
|
||||||
|
|
||||||
|
# New fields
|
||||||
|
port_override: int | None # Override tool type default port
|
||||||
|
start_command: str | None # Override container start command
|
||||||
|
working_directory: str | None # Working directory inside container
|
||||||
|
environment_variables: dict | None # JSON {"KEY": "value", ...}
|
||||||
|
volumes: list[dict] | None # JSON [{"source": "...", "target": "...", "type": "..."}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decision**: Store env vars and volumes as JSONB for flexibility. Port as integer with validation.
|
||||||
|
|
||||||
|
### ConfigFolder Model (NEW)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ConfigFolder:
|
||||||
|
id: UUID
|
||||||
|
user_id: UUID
|
||||||
|
name: str # e.g., "my-dotfiles", "vscode-settings"
|
||||||
|
description: str | None
|
||||||
|
mount_path: str # Default mount path in container (e.g., "/home/user/.config")
|
||||||
|
files: dict # JSON {"relative/path": "content", ...}
|
||||||
|
project_overrides: dict | None # JSON {project_id: {"mount_path": "...", "files": {...}}}
|
||||||
|
is_active: bool # Quick toggle
|
||||||
|
created_at, updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decision**: Files stored as JSONB with relative paths as keys. This is simple and sufficient for config files (not binary assets).
|
||||||
|
|
||||||
|
### Volume Mount Resolution
|
||||||
|
|
||||||
|
When creating an instance, volumes are resolved in this priority order:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. ToolConfig.volumes (explicit per-config mounts)
|
||||||
|
2. ConfigFolder mounts (user's active config folders)
|
||||||
|
3. ToolType default volumes (from compose/dockerfile)
|
||||||
|
```
|
||||||
|
|
||||||
|
Config folder files are written to the instance directory under `volumes/<folder_name>/` and mounted from there.
|
||||||
|
|
||||||
|
### Readiness Probe System
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ReadinessProbe:
|
||||||
|
command: str # e.g., "curl -f http://localhost:8080/health"
|
||||||
|
timeout: int # seconds (default: 30)
|
||||||
|
interval: int # seconds between checks (default: 2)
|
||||||
|
retries: int # max attempts (default: timeout/interval)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Execution Flow**:
|
||||||
|
1. Start container
|
||||||
|
2. Wait for container to be running
|
||||||
|
3. Execute probe command inside container via `docker exec`
|
||||||
|
4. If success → mark instance as "running"
|
||||||
|
5. If timeout → mark instance as "failed" with probe output in logs
|
||||||
|
|
||||||
|
**Decision**: Probes run inside the container using `docker exec`. This works for both network-based probes (curl) and command-based probes (binary version checks).
|
||||||
|
|
||||||
|
### Instance Creation Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Generate instance ID and directory
|
||||||
|
2. Resolve ToolConfig (global + project-specific)
|
||||||
|
3. Write config files:
|
||||||
|
a. .env file (from env-type ToolConfigs)
|
||||||
|
b. Config files (from file-type ToolConfigs)
|
||||||
|
c. Config folder files (to volumes/<folder>/)
|
||||||
|
4. IF ToolType.definition_type == "dockerfile":
|
||||||
|
a. Write Dockerfile + build context files
|
||||||
|
b. Build image: docker build -t <instance_tag> .
|
||||||
|
c. Generate compose from template using built image
|
||||||
|
5. IF ToolType.definition_type == "compose":
|
||||||
|
a. Render compose template with variables
|
||||||
|
6. Write docker-compose.yml
|
||||||
|
7. docker compose up -d
|
||||||
|
8. Connect to backend network
|
||||||
|
9. IF readiness_probe defined:
|
||||||
|
a. Execute probe with timeout
|
||||||
|
b. Update status based on result
|
||||||
|
10. IF web interface:
|
||||||
|
a. Create Cloudflare tunnel
|
||||||
|
b. Update URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Design
|
||||||
|
|
||||||
|
### Tool Workshop Page (`/tool-workshop`)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Tool Workshop [+ New] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────────────────────────────┐ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ MY TOOLS │ │ [Tool Type Builder] │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ ▼ Code Editor│ │ Name: [____________] │ │
|
||||||
|
│ │ □ VS Code │ │ Type: (•) Compose ( ) Dockerfile │ │
|
||||||
|
│ │ □ Cursor │ │ │ │
|
||||||
|
│ │ │ │ [Compose Template / Dockerfile] │ │
|
||||||
|
│ │ ▼ AI Tools │ │ ┌────────────────────────────────┐ │ │
|
||||||
|
│ │ □ OpenCode │ │ │ version: '3.8' │ │ │
|
||||||
|
│ │ □ Continue │ │ │ services: │ │ │
|
||||||
|
│ │ │ │ │ app: │ │ │
|
||||||
|
│ │ CONFIGS │ │ │ image: ... │ │ │
|
||||||
|
│ │ │ │ │ ports: │ │ │
|
||||||
|
│ │ ▼ Global │ │ │ - "{{PORT}}:8080" │ │ │
|
||||||
|
│ │ □ dotfiles │ │ │ volumes: │ │ │
|
||||||
|
│ │ □ api-keys │ │ │ - ... │ │ │
|
||||||
|
│ │ │ │ └────────────────────────────────┘ │ │
|
||||||
|
│ │ ▼ Project X │ │ │ │
|
||||||
|
│ │ □ overrides│ │ Readiness Probe: │ │
|
||||||
|
│ │ │ │ Command: [curl -f localhost:8080] │ │
|
||||||
|
│ │ │ │ Timeout: [30] seconds │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ │ [Save Tool Type] │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └──────────────┘ └──────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Tabs: [Tool Types] [Configs] [Config Folders] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Navigation Structure**:
|
||||||
|
- Left sidebar: Hierarchical tree
|
||||||
|
- Tool Types (expandable, shows instances count)
|
||||||
|
- Config Folders (grouped by global/project)
|
||||||
|
- Right panel: Context-aware editor based on selection
|
||||||
|
- Tab bar: Switch between Tool Types / Configs / Config Folders views
|
||||||
|
|
||||||
|
### Config Editor
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Edit Config: OpenCode API Keys │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Basic Settings │ Advanced Settings │
|
||||||
|
│ ────────────────────────────┼──────────────────────────────── │
|
||||||
|
│ Key: [OPENAI_API_KEY] │ Port Override: [_____] │
|
||||||
|
│ Value: [sk-... ] │ Start Command: [_____] │
|
||||||
|
│ Type: (•) Env ( ) File │ Working Dir: [/workspace] │
|
||||||
|
│ File Path: [__________] │ │
|
||||||
|
│ │ Environment Variables: │
|
||||||
|
│ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ │ KEY │ VALUE │ │
|
||||||
|
│ │ │ OPENAI_KEY │ sk-... │ │
|
||||||
|
│ │ │ MODEL │ gpt-4 │ │
|
||||||
|
│ │ └──────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ Volume Mounts: │
|
||||||
|
│ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ │ SOURCE │ TARGET │ │
|
||||||
|
│ │ │ dotfiles │ ~/.config │ │
|
||||||
|
│ │ │ vscode-set │ ~/.vscode │ │
|
||||||
|
│ │ └──────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ [Delete] [Cancel] [Save] │ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config Folder Manager
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Config Folder: my-dotfiles │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Name: [my-dotfiles] │
|
||||||
|
│ Description: [My personal dotfiles] │
|
||||||
|
│ Default Mount Path: [/home/user] │
|
||||||
|
│ │
|
||||||
|
│ Files: │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Path │ Size │ Actions │ │
|
||||||
|
│ │ .zshrc │ 2.1KB │ [Edit] [Delete] │ │
|
||||||
|
│ │ .gitconfig │ 412B │ [Edit] [Delete] │ │
|
||||||
|
│ │ .config/starship.toml │ 1.8KB │ [Edit] [Delete] │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [+ Add File] │
|
||||||
|
│ │
|
||||||
|
│ Project Overrides: │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Project │ Mount Path │ Files Override │ │
|
||||||
|
│ │ Project Alpha │ /home/dev │ [3 files] │ │
|
||||||
|
│ │ Project Beta │ /workspace │ [1 file] │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Add Override] [Delete Folder] [Save] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Dockerfile vs Compose**: Support both. `definition_type` field determines which path to use. Compose is the default for backward compatibility.
|
||||||
|
|
||||||
|
2. **Config Folder Storage**: Store files as JSONB keyed by relative path. This avoids file system complexity and works well for text-based config files. Limit: 10MB per folder.
|
||||||
|
|
||||||
|
3. **Readiness Probe Execution**: Use `docker exec` to run commands inside the container. This is the most flexible approach (works for HTTP checks, binary checks, file checks).
|
||||||
|
|
||||||
|
4. **Volume Resolution Order**: Config-level volumes override config-folder volumes, which override tool-type defaults. Last-write-wins for conflicts.
|
||||||
|
|
||||||
|
5. **UI Organization**: Single page with three tabs (Tool Types, Configs, Config Folders) and a left sidebar for navigation. This consolidates the current `/tool-configs` and `/tool-types` pages.
|
||||||
|
|
||||||
|
6. **Project Overrides**: ConfigFolders support per-project overrides for mount_path and files. This allows project-specific customizations while keeping the base collection reusable.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Dockerfile build times**: Building images on-demand is slow. Mitigation: Document that users should use pre-built images in compose for faster startup; dockerfile is for custom tools.
|
||||||
|
- **Config folder size limits**: JSONB has practical limits. Mitigation: 10MB limit per folder, enforced in API.
|
||||||
|
- **Readiness probe complexity**: Commands might hang or fail in unexpected ways. Mitigation: Strict timeout, clear error messages, probe logs stored on instance.
|
||||||
|
- **Migration complexity**: Existing tool types need `definition_type` set to "compose". Mitigation: Database default, seed function update.
|
||||||
|
- **UI complexity**: Three tabs with different editors could feel overwhelming. Mitigation: Progressive disclosure (hide advanced fields, collapsible sections).
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Tool Types
|
||||||
|
- `GET /tool-types` - List all (existing)
|
||||||
|
- `POST /tool-types` - Create with new fields
|
||||||
|
- `PUT /tool-types/{id}` - Update with new fields
|
||||||
|
- `GET /tool-types/{id}/validate` - Validate compose/dockerfile syntax
|
||||||
|
|
||||||
|
### Tool Configs
|
||||||
|
- `GET /tool-configs` - List with new fields
|
||||||
|
- `POST /tool-configs` - Create with new fields
|
||||||
|
- `PUT /tool-configs/{id}` - Update with new fields
|
||||||
|
- `GET /tool-configs/defaults/{tool_type_id}` - Get suggested defaults
|
||||||
|
|
||||||
|
### Config Folders (NEW)
|
||||||
|
- `GET /config-folders` - List user's folders
|
||||||
|
- `POST /config-folders` - Create folder
|
||||||
|
- `PUT /config-folders/{id}` - Update folder (files, mount_path)
|
||||||
|
- `DELETE /config-folders/{id}` - Delete folder
|
||||||
|
- `POST /config-folders/{id}/overrides` - Add project override
|
||||||
|
- `PUT /config-folders/{id}/overrides/{project_id}` - Update override
|
||||||
|
- `DELETE /config-folders/{id}/overrides/{project_id}` - Remove override
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Migration: tool_types
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tool_types
|
||||||
|
ADD COLUMN definition_type VARCHAR(20) NOT NULL DEFAULT 'compose',
|
||||||
|
ADD COLUMN dockerfile_template TEXT,
|
||||||
|
ADD COLUMN build_context JSONB DEFAULT '{}',
|
||||||
|
ADD COLUMN readiness_probe JSONB;
|
||||||
|
|
||||||
|
-- Ensure consistency
|
||||||
|
ALTER TABLE tool_types
|
||||||
|
ADD CONSTRAINT chk_definition_type
|
||||||
|
CHECK (definition_type IN ('compose', 'dockerfile'));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration: tool_configs
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tool_configs
|
||||||
|
ADD COLUMN port_override INTEGER,
|
||||||
|
ADD COLUMN start_command TEXT,
|
||||||
|
ADD COLUMN working_directory TEXT,
|
||||||
|
ADD COLUMN environment_variables JSONB DEFAULT '{}',
|
||||||
|
ADD COLUMN volumes JSONB DEFAULT '[]';
|
||||||
|
|
||||||
|
ALTER TABLE tool_configs
|
||||||
|
ADD CONSTRAINT chk_port_range
|
||||||
|
CHECK (port_override IS NULL OR (port_override >= 1 AND port_override <= 65535));
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Table: config_folders
|
||||||
|
```sql
|
||||||
|
CREATE TABLE config_folders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
mount_path VARCHAR(1024) NOT NULL,
|
||||||
|
files JSONB NOT NULL DEFAULT '{}',
|
||||||
|
project_overrides JSONB DEFAULT '{}',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_config_folders_user ON config_folders(user_id);
|
||||||
|
```
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The current tool system is too rigid. Tool types are hardcoded with compose templates, configs are simple key-value pairs, and the UI is a basic flat list. Users need a true "tool workshop" where they can:
|
||||||
|
|
||||||
|
1. **Define new tools** with either Docker Compose or Dockerfile
|
||||||
|
2. **Configure rich tool settings** including ports, commands, working directories, and volume mounts
|
||||||
|
3. **Create reusable config file collections** (e.g., dotfiles, IDE settings) that mount into containers
|
||||||
|
4. **Wait for tools to be ready** with configurable health/readiness probes before considering the build complete
|
||||||
|
|
||||||
|
This unlocks the platform from built-in tools to a true marketplace of user-defined and user-configured tools.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Enhance ToolType model**: Add `dockerfile_template`, `readiness_probe` (command + timeout), `build_context` field
|
||||||
|
- **Enhance ToolConfig model**: Add `port_override`, `start_command`, `working_directory`, `environment_variables`, `volumes`
|
||||||
|
- **Create ConfigFolder model**: Named collections of files mountable as volumes, with per-project overrides
|
||||||
|
- **Add readiness probe system**: Instance creation waits for probe command with configurable timeout
|
||||||
|
- **Unified Tool Workshop UI**: Single page replacing `/tool-configs` and `/tool-types` with:
|
||||||
|
- Tool Type builder (compose or dockerfile)
|
||||||
|
- Tool Config editor (split-pane with all new fields)
|
||||||
|
- Config Folder manager (file collections with mount paths)
|
||||||
|
- Live validation and preview
|
||||||
|
- **Update instance creation flow**: Support dockerfile builds, mount config folders, apply readiness probes
|
||||||
|
- **Database migrations**: New columns on `tool_types`, `tool_configs`; new `config_folders` table
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `tool-workshop`: Unified tool definition, configuration, and deployment interface
|
||||||
|
- `config-folders`: Reusable per-user file collections mountable into containers with per-project overrides
|
||||||
|
- `readiness-probes`: Build-time health checks that wait for tools to be ready before marking instances as running
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `tool-types`: Enhanced with dockerfile support, readiness probes, build context
|
||||||
|
- `tool-config-management`: Extended with port overrides, volumes, environment variables, working directory
|
||||||
|
- `tool-instances`: Instance creation supports dockerfile builds, config folder mounts, probe waiting
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Backend**:
|
||||||
|
- Models: `ToolType`, `ToolConfig`, new `ConfigFolder`
|
||||||
|
- API: New endpoints for config folders, updated tool type/config endpoints
|
||||||
|
- Services: Docker build service (for dockerfiles), readiness probe service
|
||||||
|
- Instance creation: Dockerfile build path, volume mounting, probe execution
|
||||||
|
- **Frontend**:
|
||||||
|
- New `ToolWorkshopPage` component (replaces `/tool-configs` and `/tool-types`)
|
||||||
|
- New components: Dockerfile editor, readiness probe config, config folder manager, volume mount editor
|
||||||
|
- Updated routing and navigation
|
||||||
|
- **Database**:
|
||||||
|
- `tool_types`: Add `dockerfile_template`, `readiness_probe`, `build_context`
|
||||||
|
- `tool_configs`: Add `port_override`, `start_command`, `working_directory`, `environment_variables`, `volumes`
|
||||||
|
- New `config_folders` table
|
||||||
|
- **User Experience**: Users can now define entirely new tools, configure them richly, and reuse config collections across projects
|
||||||
|
|
||||||
|
## Supersedes
|
||||||
|
|
||||||
|
This change supersedes `tool-config-ui-rework` which scoped only to the UI rework and basic new fields. This is a comprehensive expansion of the tool system.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Capability: Config Folders
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Config Folders are reusable collections of configuration files that can be mounted into tool instances as volumes. They enable users to maintain their preferred settings (dotfiles, IDE configs, etc.) and apply them across all their tool instances.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### FR-1: Folder Creation
|
||||||
|
- Users can create named config folders
|
||||||
|
- Each folder has: name, description, default mount path, collection of files
|
||||||
|
- Folder names must be unique per user
|
||||||
|
- Files are stored with relative paths (e.g., `.zshrc`, `.config/nvim/init.vim`)
|
||||||
|
|
||||||
|
### FR-2: File Management
|
||||||
|
- Users can add, edit, and delete files within a folder
|
||||||
|
- File paths are relative to the mount path
|
||||||
|
- File content is stored as text (UTF-8)
|
||||||
|
- Maximum total folder size: 10MB
|
||||||
|
- File paths are sanitized to prevent directory traversal attacks
|
||||||
|
|
||||||
|
### FR-3: Activation
|
||||||
|
- Folders can be toggled active/inactive
|
||||||
|
- Only active folders are mounted into new instances
|
||||||
|
- Activation state is persisted
|
||||||
|
- Changing activation does not affect running instances
|
||||||
|
|
||||||
|
### FR-4: Project Overrides
|
||||||
|
- Users can define per-project overrides for any folder
|
||||||
|
- Overrides can modify: mount path, add/remove/replace files
|
||||||
|
- When an instance is created for a project, overrides are applied
|
||||||
|
- Global settings serve as defaults; overrides are merged
|
||||||
|
- Deleting an override reverts to global settings
|
||||||
|
|
||||||
|
### FR-5: Instance Mounting
|
||||||
|
- When creating an instance, active folders are resolved
|
||||||
|
- For each folder: global files + project overrides (if any)
|
||||||
|
- Files are written to `instance_dir/volumes/<folder_name>/`
|
||||||
|
- Compose file includes volume mounts from these directories
|
||||||
|
- Mount target is the folder's mount path (or override)
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ConfigFolder:
|
||||||
|
id: UUID
|
||||||
|
user_id: UUID
|
||||||
|
name: str # Unique per user
|
||||||
|
description: str | None
|
||||||
|
mount_path: str # e.g., "/home/user"
|
||||||
|
files: dict[str, str] # {"relative/path": "content", ...}
|
||||||
|
project_overrides: dict # {"project_id": {"mount_path": "...", "files": {...}}}
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /config-folders` - List user's folders
|
||||||
|
- `POST /config-folders` - Create folder
|
||||||
|
- `PUT /config-folders/{id}` - Update folder
|
||||||
|
- `DELETE /config-folders/{id}` - Delete folder
|
||||||
|
- `POST /config-folders/{id}/overrides` - Add override
|
||||||
|
- `PUT /config-folders/{id}/overrides/{project_id}` - Update override
|
||||||
|
- `DELETE /config-folders/{id}/overrides/{project_id}` - Remove override
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
1. **Name uniqueness**: `(user_id, name)` must be unique
|
||||||
|
2. **Path sanitization**: File paths cannot contain `..` or start with `/`
|
||||||
|
3. **Size limit**: Total folder size (sum of all file contents) ≤ 10MB
|
||||||
|
4. **Mount path**: Must be absolute path (starts with `/`)
|
||||||
|
5. **Project existence**: Overrides can only reference existing projects
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
### Global Config Folder
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-dotfiles",
|
||||||
|
"description": "Personal shell and git configuration",
|
||||||
|
"mount_path": "/home/user",
|
||||||
|
"files": {
|
||||||
|
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"\n...",
|
||||||
|
".gitconfig": "[user]\nname = John Doe\n...",
|
||||||
|
".config/starship.toml": "[character]\n..."
|
||||||
|
},
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Override
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"mount_path": "/workspace",
|
||||||
|
"files": {
|
||||||
|
".gitconfig": "[user]\nname = Work Account\n..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] User can create a config folder with multiple files
|
||||||
|
- [ ] Files are correctly mounted into new instances
|
||||||
|
- [ ] Project overrides apply correctly
|
||||||
|
- [ ] 10MB size limit is enforced
|
||||||
|
- [ ] Path traversal attacks are prevented
|
||||||
|
- [ ] Only active folders are mounted
|
||||||
|
- [ ] Changing folder contents updates future instances
|
||||||
|
- [ ] UI shows folder size and file count
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Capability: Readiness Probes
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Readiness probes ensure tool instances are fully initialized before being marked as "running". They execute a configurable command inside the container and wait for it to succeed, with configurable timeout and retry interval.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### FR-1: Probe Definition
|
||||||
|
- Tool types can define an optional readiness probe
|
||||||
|
- Probe configuration: command, timeout, interval, retries
|
||||||
|
- If no probe is defined, instance is marked running immediately after container start
|
||||||
|
- Probe can be any shell command that returns exit code 0 for success
|
||||||
|
|
||||||
|
### FR-2: Probe Execution
|
||||||
|
- Probe runs inside the container via `docker exec`
|
||||||
|
- Probe starts after container is in "running" state
|
||||||
|
- Probe executes periodically (interval) until success or timeout
|
||||||
|
- Each execution has a separate timeout (not the total timeout)
|
||||||
|
- Probe output is captured and stored
|
||||||
|
|
||||||
|
### FR-3: Status Management
|
||||||
|
- While probing: instance status is "starting"
|
||||||
|
- On success: instance status changes to "running"
|
||||||
|
- On timeout: instance status changes to "failed"
|
||||||
|
- Failed instances include probe logs in error details
|
||||||
|
- Users can view probe execution history
|
||||||
|
|
||||||
|
### FR-4: Probe Types
|
||||||
|
Support common probe patterns:
|
||||||
|
- **HTTP probe**: `curl -f http://localhost:8080/health`
|
||||||
|
- **Command probe**: `opencode --version`
|
||||||
|
- **File probe**: `[ -f /app/ready ]`
|
||||||
|
- **Port probe**: `nc -z localhost 8080`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ReadinessProbe(BaseModel):
|
||||||
|
command: str # Command to execute
|
||||||
|
timeout: int = 30 # Total timeout in seconds
|
||||||
|
interval: int = 2 # Seconds between checks
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_retries(self) -> int:
|
||||||
|
return self.timeout // self.interval
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Container Start
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Container Running?
|
||||||
|
│
|
||||||
|
├── No ──▶ Wait 1s ──▶ Retry (max 30s)
|
||||||
|
│
|
||||||
|
▼ Yes
|
||||||
|
Execute Probe Command
|
||||||
|
│
|
||||||
|
├── Exit 0 ──▶ Status: "running" ✓
|
||||||
|
│
|
||||||
|
├── Exit !=0 ──▶ Wait interval ──▶ Retry
|
||||||
|
│ │
|
||||||
|
│ └── Max retries reached?
|
||||||
|
│ ├── No ──▶ Execute again
|
||||||
|
│ │
|
||||||
|
│ ▼ Yes
|
||||||
|
│ Status: "failed" ✗
|
||||||
|
│ Store logs
|
||||||
|
│
|
||||||
|
└── Timeout ──▶ Status: "failed" ✗
|
||||||
|
Store logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Probe Examples
|
||||||
|
|
||||||
|
### Web Tool (VS Code Server)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "curl -sf http://localhost:8080/health || curl -sf http://localhost:8080",
|
||||||
|
"timeout": 60,
|
||||||
|
"interval": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal Tool (OpenCode)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "which opencode && opencode --version",
|
||||||
|
"timeout": 30,
|
||||||
|
"interval": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Tool
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "pg_isready -U postgres",
|
||||||
|
"timeout": 30,
|
||||||
|
"interval": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Probe Command Not Found
|
||||||
|
- Exit code: 127
|
||||||
|
- Behavior: Retry (command might not be in PATH yet)
|
||||||
|
- Log: "Command not found, retrying..."
|
||||||
|
|
||||||
|
### Probe Times Out
|
||||||
|
- Mark instance as "failed"
|
||||||
|
- Store last probe output
|
||||||
|
- Include timeout details in error message
|
||||||
|
- Allow user to view full probe logs
|
||||||
|
|
||||||
|
### Container Exits During Probe
|
||||||
|
- Stop probing immediately
|
||||||
|
- Mark instance as "failed"
|
||||||
|
- Include container exit code and logs
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
### Tool Type Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"name": "code-server",
|
||||||
|
"readiness_probe": {
|
||||||
|
"command": "curl -sf http://localhost:8080",
|
||||||
|
"timeout": 60,
|
||||||
|
"interval": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instance Response (Failed Probe)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"status": "failed",
|
||||||
|
"error": "Readiness probe failed after 60s",
|
||||||
|
"probe_logs": [
|
||||||
|
"Attempt 1/20: Connection refused",
|
||||||
|
"Attempt 2/20: Connection refused",
|
||||||
|
"...",
|
||||||
|
"Attempt 20/20: Timeout"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Probe executes inside container and waits for success
|
||||||
|
- [ ] Successful probe marks instance as "running"
|
||||||
|
- [ ] Failed probe (timeout) marks instance as "failed"
|
||||||
|
- [ ] Probe logs are stored and retrievable
|
||||||
|
- [ ] Probe respects timeout and interval settings
|
||||||
|
- [ ] No probe defined = immediate "running" status
|
||||||
|
- [ ] Container exit during probe is handled gracefully
|
||||||
|
- [ ] Common probe patterns work (HTTP, command, file, port)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Capability: Tool Workshop
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Tool Workshop is the unified interface for defining, configuring, and managing development tools. It consolidates tool type management, tool configuration, and config folder management into a single powerful interface.
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### FR-1: Tool Type Definition
|
||||||
|
- Users can create new tool types with either Docker Compose or Dockerfile
|
||||||
|
- Tool types specify: name, display name, description, category, interfaces, port, definition type, template
|
||||||
|
- Built-in tool types can be viewed but not edited
|
||||||
|
- Tool types can be deleted (with cascade deletion of associated configs)
|
||||||
|
|
||||||
|
### FR-2: Tool Configuration
|
||||||
|
- Users can create tool configurations per tool type
|
||||||
|
- Configs can be global (all projects) or project-scoped
|
||||||
|
- Configs support: key-value pairs (env/file), port override, start command, working directory, environment variables, volumes
|
||||||
|
- Configs are mounted into containers when instances are created
|
||||||
|
|
||||||
|
### FR-3: Config Folder Management
|
||||||
|
- Users can create named collections of configuration files
|
||||||
|
- Each folder has a default mount path in containers
|
||||||
|
- Folders can be activated/deactivated
|
||||||
|
- Folders support per-project overrides
|
||||||
|
- Active folders are automatically mounted into new instances
|
||||||
|
|
||||||
|
### FR-4: Readiness Probes
|
||||||
|
- Tool types can define a readiness probe command
|
||||||
|
- Instance creation waits for the probe to succeed
|
||||||
|
- Probes have configurable timeout and check interval
|
||||||
|
- Failed probes mark instances as "failed" with logs
|
||||||
|
|
||||||
|
### FR-5: Instance Integration
|
||||||
|
- Instance creation uses tool type definition (compose or dockerfile)
|
||||||
|
- Instance creation applies tool configs (env vars, files, volumes)
|
||||||
|
- Instance creation mounts active config folders
|
||||||
|
- Instance creation executes readiness probe
|
||||||
|
- Instance status reflects probe result
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
### NFR-1: Performance
|
||||||
|
- Tool Workshop page loads in < 2 seconds
|
||||||
|
- Config folder operations complete in < 500ms
|
||||||
|
- Instance creation with dockerfile build completes in < 5 minutes
|
||||||
|
|
||||||
|
### NFR-2: Usability
|
||||||
|
- UI is intuitive for both technical and non-technical users
|
||||||
|
- Clear validation messages for all fields
|
||||||
|
- Progressive disclosure of advanced options
|
||||||
|
- Responsive design for mobile devices
|
||||||
|
|
||||||
|
### NFR-3: Security
|
||||||
|
- Users can only access their own tool types, configs, and folders
|
||||||
|
- File paths in config folders are sanitized (no path traversal)
|
||||||
|
- Dockerfile builds run in isolated context
|
||||||
|
- Config values are never logged or exposed
|
||||||
|
|
||||||
|
## State Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ DRAFT │
|
||||||
|
└──────┬──────┘
|
||||||
|
│ Create
|
||||||
|
▼
|
||||||
|
┌─────────────┐ Edit ┌─────────────┐
|
||||||
|
│ ACTIVE │◀────────────▶│ UPDATED │
|
||||||
|
└──────┬──────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
│ Delete
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ DELETED │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
See `design.md` for complete endpoint list.
|
||||||
|
|
||||||
|
## UI Specification
|
||||||
|
|
||||||
|
See `design.md` for complete UI mockups.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] User can create a tool type with dockerfile and start an instance
|
||||||
|
- [ ] User can create a tool type with compose and start an instance
|
||||||
|
- [ ] User can create config folders and mount them into instances
|
||||||
|
- [ ] User can set project overrides on config folders
|
||||||
|
- [ ] Readiness probes wait for tools to be ready before marking running
|
||||||
|
- [ ] Failed readiness probes show clear error messages
|
||||||
|
- [ ] All new fields are persisted and retrieved correctly
|
||||||
|
- [ ] UI is responsive and intuitive
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
## Phase 1: Backend Foundation
|
||||||
|
|
||||||
|
### 1.1 Database Migrations
|
||||||
|
- [x] 1.1.1 Create Alembic migration for `tool_types` (add definition_type, dockerfile_template, build_context, readiness_probe)
|
||||||
|
- [x] 1.1.2 Create Alembic migration for `tool_configs` (add port_override, start_command, working_directory, environment_variables, volumes)
|
||||||
|
- [x] 1.1.3 Create Alembic migration for `config_folders` (new table)
|
||||||
|
- [x] 1.1.4 Add CHECK constraints (definition_type enum, port range)
|
||||||
|
- [x] 1.1.5 Add indexes for config_folders
|
||||||
|
- [x] 1.1.6 Run migrations locally and verify with test data
|
||||||
|
|
||||||
|
### 1.2 Model Updates
|
||||||
|
- [x] 1.2.1 Update `ToolType` model with new fields
|
||||||
|
- [x] 1.2.2 Update `ToolConfig` model with new fields
|
||||||
|
- [x] 1.2.3 Create `ConfigFolder` model
|
||||||
|
- [x] 1.2.4 Add Pydantic schemas for ConfigFolder (create, update, response)
|
||||||
|
- [x] 1.2.5 Update Pydantic schemas for ToolType (add new fields)
|
||||||
|
- [x] 1.2.6 Update Pydantic schemas for ToolConfig (add new fields)
|
||||||
|
- [x] 1.2.7 Add validation schemas (port range, JSON structure, definition_type enum)
|
||||||
|
|
||||||
|
### 1.3 Config Folder API
|
||||||
|
- [x] 1.3.1 Create `api/config_folders.py` router
|
||||||
|
- [x] 1.3.2 Implement `GET /config-folders` (list with filtering by project)
|
||||||
|
- [x] 1.3.3 Implement `POST /config-folders` (create)
|
||||||
|
- [x] 1.3.4 Implement `PUT /config-folders/{id}` (update name, mount_path, files)
|
||||||
|
- [x] 1.3.5 Implement `DELETE /config-folders/{id}` (delete)
|
||||||
|
- [x] 1.3.6 Implement `POST /config-folders/{id}/overrides` (add project override)
|
||||||
|
- [x] 1.3.7 Implement `PUT /config-folders/{id}/overrides/{project_id}` (update override)
|
||||||
|
- [x] 1.3.8 Implement `DELETE /config-folders/{id}/overrides/{project_id}` (remove override)
|
||||||
|
- [x] 1.3.9 Add validation: 10MB size limit per folder
|
||||||
|
- [x] 1.3.10 Add ownership checks (user can only access own folders)
|
||||||
|
|
||||||
|
### 1.4 Tool Type API Updates
|
||||||
|
- [x] 1.4.1 Update `POST /tool-types` to accept definition_type, dockerfile_template, build_context, readiness_probe
|
||||||
|
- [x] 1.4.2 Update `PUT /tool-types/{id}` to handle new fields
|
||||||
|
- [x] 1.4.3 Add `GET /tool-types/{id}/validate` endpoint (syntax validation)
|
||||||
|
- [x] 1.4.4 Update tool type response schemas
|
||||||
|
- [x] 1.4.5 Update seed function to set definition_type="compose" for built-in types
|
||||||
|
|
||||||
|
### 1.5 Tool Config API Updates
|
||||||
|
- [x] 1.5.1 Update `POST /tool-configs` to accept new fields
|
||||||
|
- [x] 1.5.2 Update `PUT /tool-configs/{id}` to handle new fields
|
||||||
|
- [x] 1.5.3 Update `GET /tool-configs` to return new fields
|
||||||
|
- [x] 1.5.4 Add `GET /tool-configs/defaults/{tool_type_id}` endpoint
|
||||||
|
- [x] 1.5.5 Add validation for port_override range
|
||||||
|
- [x] 1.5.6 Add validation for environment_variables JSON structure
|
||||||
|
- [x] 1.5.7 Add validation for volumes JSON structure (source/target/type)
|
||||||
|
|
||||||
|
## Phase 2: Instance Creation Enhancement
|
||||||
|
|
||||||
|
### 2.1 Docker Build Service
|
||||||
|
- [x] 2.1.1 Create `services/docker_build.py` for Dockerfile builds
|
||||||
|
- [x] 2.1.2 Implement `build_image(instance_dir, dockerfile, tag)` function
|
||||||
|
- [x] 2.1.3 Handle build context file writing
|
||||||
|
- [x] 2.1.4 Add build output streaming/logging
|
||||||
|
- [x] 2.1.5 Handle build failures with clear error messages
|
||||||
|
|
||||||
|
### 2.2 Compose Generation for Dockerfile Tools
|
||||||
|
- [x] 2.2.1 Create compose template for dockerfile-built images
|
||||||
|
- [x] 2.2.2 Integrate build service into instance creation flow
|
||||||
|
- [x] 2.2.3 Update `render_compose_template` to handle both paths
|
||||||
|
|
||||||
|
### 2.3 Config Folder Mounting
|
||||||
|
- [x] 2.3.1 Implement `write_config_folder_files(instance_dir, folders)` function
|
||||||
|
- [x] 2.3.2 Resolve config folders for user + project
|
||||||
|
- [x] 2.3.3 Generate volume mounts in compose file for config folders
|
||||||
|
- [x] 2.3.4 Apply project overrides during resolution
|
||||||
|
- [x] 2.3.5 Write config folder files to `instance_dir/volumes/`
|
||||||
|
|
||||||
|
### 2.4 Readiness Probe Service
|
||||||
|
- [x] 2.4.1 Create `services/readiness_probe.py`
|
||||||
|
- [x] 2.4.2 Implement `execute_probe(container_id, probe_config)` function
|
||||||
|
- [x] 2.4.3 Implement polling loop with timeout and interval
|
||||||
|
- [x] 2.4.4 Store probe output/logs on instance
|
||||||
|
- [x] 2.4.5 Update instance status based on probe result ("running" or "failed")
|
||||||
|
- [x] 2.4.6 Handle probe command failures gracefully
|
||||||
|
|
||||||
|
### 2.5 Instance Creation Integration
|
||||||
|
- [x] 2.5.1 Update `create_instance` endpoint to use new fields
|
||||||
|
- [x] 2.5.2 Integrate dockerfile build path into creation flow
|
||||||
|
- [x] 2.5.3 Integrate config folder mounting
|
||||||
|
- [x] 2.5.4 Integrate readiness probe execution
|
||||||
|
- [x] 2.5.5 Apply port_override if specified
|
||||||
|
- [x] 2.5.6 Apply start_command if specified
|
||||||
|
- [x] 2.5.7 Apply working_directory if specified
|
||||||
|
- [x] 2.5.8 Apply environment_variables from ToolConfig
|
||||||
|
- [x] 2.5.9 Apply volumes from ToolConfig
|
||||||
|
- [x] 2.5.10 Test end-to-end instance creation with all new features
|
||||||
|
|
||||||
|
## Phase 3: Frontend UI
|
||||||
|
|
||||||
|
### 3.1 API Client Updates
|
||||||
|
- [x] 3.1.1 Update `api/tool_types.ts` with new fields and endpoints
|
||||||
|
- [x] 3.1.2 Update `api/tool_configs.ts` with new fields
|
||||||
|
- [x] 3.1.3 Create `api/config_folders.ts` with all CRUD operations
|
||||||
|
- [x] 3.1.4 Update TypeScript types/interfaces
|
||||||
|
|
||||||
|
### 3.2 Tool Workshop Layout
|
||||||
|
- [x] 3.2.1 Create `pages/tool-workshop.tsx` (replaces tool-configs and tool-types)
|
||||||
|
- [x] 3.2.2 Implement split-pane layout (sidebar + main content)
|
||||||
|
- [x] 3.2.3 Create sidebar navigation tree (Tool Types / Config Folders)
|
||||||
|
- [x] 3.2.4 Implement tab switching (Tool Types / Configs / Config Folders)
|
||||||
|
- [x] 3.2.5 Add responsive design (collapsible sidebar on mobile)
|
||||||
|
- [x] 3.2.6 Update App.tsx routing
|
||||||
|
|
||||||
|
### 3.3 Tool Type Builder
|
||||||
|
- [x] 3.3.1 Create `components/ToolTypeBuilder.tsx`
|
||||||
|
- [x] 3.3.2 Implement definition type selector (Compose vs Dockerfile)
|
||||||
|
- [x] 3.3.3 Create compose template editor (textarea with YAML highlighting)
|
||||||
|
- [x] 3.3.4 Create dockerfile editor (textarea with Dockerfile highlighting)
|
||||||
|
- [x] 3.3.5 Add build context file manager
|
||||||
|
- [x] 3.3.6 Add readiness probe configuration (command, timeout, interval)
|
||||||
|
- [x] 3.3.7 Add validation feedback (syntax check)
|
||||||
|
- [x] 3.3.8 Implement create/update/delete operations
|
||||||
|
|
||||||
|
### 3.4 Config Editor Enhancement
|
||||||
|
- [x] 3.4.1 Update config form with new fields
|
||||||
|
- [x] 3.4.2 Add port override input (integer, 1-65535)
|
||||||
|
- [x] 3.4.3 Add start command input
|
||||||
|
- [x] 3.4.4 Add working directory input
|
||||||
|
- [x] 3.4.5 Create environment variables editor (key-value table)
|
||||||
|
- [x] 3.4.6 Create volumes editor (source/target/type table)
|
||||||
|
- [x] 3.4.7 Add JSON validation for env vars and volumes
|
||||||
|
- [x] 3.4.8 Implement tabbed sections (Basic / Runtime / Advanced)
|
||||||
|
|
||||||
|
### 3.5 Config Folder Manager
|
||||||
|
- [x] 3.5.1 Create `components/ConfigFolderManager.tsx`
|
||||||
|
- [x] 3.5.2 Implement folder list view
|
||||||
|
- [x] 3.5.3 Create folder editor (name, description, mount_path)
|
||||||
|
- [x] 3.5.4 Create file manager (add/edit/delete files with path and content)
|
||||||
|
- [x] 3.5.5 Implement file content editor (textarea with syntax highlighting)
|
||||||
|
- [x] 3.5.6 Create project override manager
|
||||||
|
- [x] 3.5.7 Add active/inactive toggle
|
||||||
|
- [x] 3.5.8 Show folder size indicator
|
||||||
|
|
||||||
|
### 3.6 Navigation Updates
|
||||||
|
- [x] 3.6.1 Update header/navigation to link to `/tool-workshop`
|
||||||
|
- [x] 3.6.2 Remove old `/tool-configs` and `/tool-types` routes (or redirect)
|
||||||
|
- [x] 3.6.3 Update breadcrumb navigation if applicable
|
||||||
|
|
||||||
|
## Phase 4: Integration & Testing
|
||||||
|
|
||||||
|
### 4.1 Backend Testing
|
||||||
|
- [x] 4.1.1 Test config folder CRUD operations
|
||||||
|
- [x] 4.1.2 Test config folder project overrides
|
||||||
|
- [x] 4.1.3 Test tool type creation with dockerfile
|
||||||
|
- [x] 4.1.4 Test tool type creation with compose
|
||||||
|
- [x] 4.1.5 Test readiness probe execution (success case)
|
||||||
|
- [x] 4.1.6 Test readiness probe execution (timeout case)
|
||||||
|
- [x] 4.1.7 Test instance creation with config folders mounted
|
||||||
|
- [x] 4.1.8 Test instance creation with port override
|
||||||
|
- [x] 4.1.9 Test instance creation with volumes
|
||||||
|
- [x] 4.1.10 Test 10MB size limit enforcement
|
||||||
|
|
||||||
|
### 4.2 Frontend Testing
|
||||||
|
- [x] 4.2.1 Test Tool Workshop page load
|
||||||
|
- [x] 4.2.2 Test tool type creation flow
|
||||||
|
- [x] 4.2.3 Test config folder creation and file management
|
||||||
|
- [x] 4.2.4 Test config editor with all new fields
|
||||||
|
- [x] 4.2.5 Test responsive layout on mobile
|
||||||
|
- [x] 4.2.6 Test form validation (port range, JSON structure)
|
||||||
|
|
||||||
|
### 4.3 End-to-End Testing
|
||||||
|
- [x] 4.3.1 Create a new tool type with dockerfile, start instance
|
||||||
|
- [x] 4.3.2 Create a new tool type with compose, start instance
|
||||||
|
- [x] 4.3.3 Create config folder, mount into instance, verify files present
|
||||||
|
- [x] 4.3.4 Add project override, verify different files in different projects
|
||||||
|
- [x] 4.3.5 Test readiness probe with failing command (should mark failed)
|
||||||
|
- [x] 4.3.6 Test readiness probe with succeeding command (should mark running)
|
||||||
|
|
||||||
|
### 4.4 Quality Gates
|
||||||
|
- [x] 4.4.1 Run backend linting (ruff)
|
||||||
|
- [x] 4.4.2 Run backend type checking (mypy)
|
||||||
|
- [x] 4.4.3 Run frontend type checking (tsc)
|
||||||
|
- [x] 4.4.4 Run frontend linting (eslint)
|
||||||
|
- [x] 4.4.5 Build frontend and verify no errors
|
||||||
|
- [x] 4.4.6 Run existing tests to ensure no regressions
|
||||||
|
- [x] 4.4.7 Verify backward compatibility (existing instances still work)
|
||||||
|
|
||||||
|
## Phase 5: Documentation & Deployment
|
||||||
|
|
||||||
|
### 5.1 Documentation
|
||||||
|
- [x] 5.1.1 Update API documentation (OpenAPI/Swagger annotations)
|
||||||
|
- [x] 5.1.2 Add tool workshop user guide
|
||||||
|
- [x] 5.1.3 Document config folder usage
|
||||||
|
- [x] 5.1.4 Document readiness probe configuration
|
||||||
|
- [x] 5.1.5 Add example dockerfile and compose templates
|
||||||
|
|
||||||
|
### 5.2 Migration & Deployment
|
||||||
|
- [x] 5.2.1 Verify database migrations run cleanly on existing data
|
||||||
|
- [x] 5.2.2 Update seed data for built-in tool types (add definition_type)
|
||||||
|
- [x] 5.2.3 Test fresh install (no existing data)
|
||||||
|
- [x] 5.2.4 Commit all changes with conventional commit messages
|
||||||
|
- [x] 5.2.5 Create comprehensive PR description
|
||||||
|
|
||||||
|
## Quality Gates Summary
|
||||||
|
|
||||||
|
**Before completing this change:**
|
||||||
|
- All migrations must run successfully
|
||||||
|
- Backend linting and type checking must pass
|
||||||
|
- Frontend build must succeed with no errors
|
||||||
|
- All new API endpoints must be tested
|
||||||
|
- At least one end-to-end test for each new feature
|
||||||
|
- No regressions in existing instance creation flow
|
||||||
|
- Documentation updated
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-22
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The current repository creation flow already supports cloning remote repositories via `remote_url` and can normalize pasted browser URLs. However, the UI asks for a full URL, which is awkward for the fixed provider `git.commumedia.org`. The requested behavior is to enter `owner` and `repo`, check whether the repository exists, and clone only if it does.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Accept SSH-only `owner` and `repo` inputs for cloning from `git.commumedia.org`
|
||||||
|
- Verify repository existence before clone
|
||||||
|
- Preserve full URL paste as a fallback path
|
||||||
|
- Preserve blank repository creation
|
||||||
|
- Reuse the existing repository create endpoint and shared dialog
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Supporting multiple git providers
|
||||||
|
- Adding a remote repository discovery API
|
||||||
|
- Supporting HTTPS clone flow for the new structured path
|
||||||
|
- Changing repository storage or clone behavior beyond preflight validation
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**1. Provider assumption**
|
||||||
|
- Hardcode `git.commumedia.org` for the structured clone path
|
||||||
|
- Build SSH URLs as `git@git.commumedia.org:{owner}/{repo}.git`
|
||||||
|
|
||||||
|
**2. Existence check**
|
||||||
|
- Use `git ls-remote` on the constructed SSH URL before cloning
|
||||||
|
- If the command fails, surface a repository-not-found/inaccessible error and do not clone
|
||||||
|
|
||||||
|
**3. UI structure**
|
||||||
|
- Keep the shared repository creation dialog as the single entry point
|
||||||
|
- In clone mode, collect `owner` and `repo` instead of asking for a full URL
|
||||||
|
- Keep an advanced paste-URL fallback for existing behavior and browser URL parsing
|
||||||
|
- Keep blank repository creation available in the same dialog
|
||||||
|
|
||||||
|
**4. Backend behavior**
|
||||||
|
- Reuse `POST /projects/{project_id}/repositories`
|
||||||
|
- Add preflight logic before the existing `git clone --mirror`
|
||||||
|
- Leave the database schema unchanged
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk] SSH auth may still fail even if the repo exists** → Mitigation: preflight error should be explicit and user-facing.
|
||||||
|
**[Risk] Command availability** → Mitigation: reuse the same `git` dependency already required for cloning.
|
||||||
|
**[Risk] UI complexity** → Mitigation: keep the dialog shared and minimal, with fallback URL paste.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Repository creation already supports cloning from a remote URL, but the current UI only accepts a full URL. For the common fixed-provider case (`git.commumedia.org`), users should be able to enter `owner` and `repo` and have the app verify the repository exists before cloning. If the repository does not exist, the app should surface a clear error. Existing blank repository creation must remain available.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Change the shared repository create dialog to support an SSH-only clone form with `owner` and `repo`
|
||||||
|
- Build the clone target as `git@git.commumedia.org:{owner}/{repo}.git`
|
||||||
|
- Preflight clone targets with `git ls-remote` before cloning
|
||||||
|
- Return a clear error when the repository is missing or inaccessible
|
||||||
|
- Keep the current full URL paste flow as an advanced fallback
|
||||||
|
- Keep blank repository creation as a fallback option
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `git-repo`: Repository creation UX and clone validation reuse the existing create endpoint and clone path
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Frontend: `repository-create-dialog.tsx`, `git-repositories.tsx`, `repositories-settings-tab.tsx`
|
||||||
|
- Backend: `git_repositories.py` create endpoint clone preflight
|
||||||
|
- Docs: repository creation guidance must reflect SSH-only owner/repo input
|
||||||
|
- Tests: add coverage for SSH repo existence checks and fallback URL behavior
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## 1. Backend - SSH Existence Check
|
||||||
|
|
||||||
|
- [ ] 1.1 Add `git ls-remote` preflight to repository creation in `git_repositories.py`
|
||||||
|
- [ ] 1.2 Build SSH clone URL from `owner` and `repo` for `git.commumedia.org`
|
||||||
|
- [ ] 1.3 Return a clear error when the repository is missing or inaccessible
|
||||||
|
|
||||||
|
## 2. Frontend - Structured Clone Form
|
||||||
|
|
||||||
|
- [ ] 2.1 Update `RepositoryCreateDialog` clone mode to accept `owner` and `repo`
|
||||||
|
- [ ] 2.2 Keep advanced full-URL paste flow and blank repository fallback
|
||||||
|
- [ ] 2.3 Reuse the shared dialog from repository settings and repositories page
|
||||||
|
|
||||||
|
## 3. Validation and Docs
|
||||||
|
|
||||||
|
- [ ] 3.1 Update repository docs to explain SSH-only owner/repo input
|
||||||
|
- [ ] 3.2 Add tests for success, missing repo, and URL fallback behavior
|
||||||
|
|
||||||
|
## 4. Quality Gates
|
||||||
|
|
||||||
|
- [ ] 4.1 Run backend and frontend targeted tests
|
||||||
|
- [ ] 4.2 Run frontend typecheck and lint where applicable
|
||||||
|
- [ ] 4.3 Commit and push changes
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Repository creation currently produces mirrored bare repos for any remote clone and bare repos for blank creations. The workspace, file browser, commit editor, and git toolbar are built around a working-tree repository model, so users can hit 400s when they try to sync or when the repo has no usable branch state.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Create working clones for remote repositories
|
||||||
|
- Create working repos with an initial branch for blank repositories
|
||||||
|
- Preserve the existing repository create endpoint and shared UI flow
|
||||||
|
- Keep fetch/pull/push aligned with a normal local clone
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. Clone mode
|
||||||
|
- Use `git clone` without `--mirror`
|
||||||
|
- Keep the existing remote URL preflight and URL parsing behavior
|
||||||
|
|
||||||
|
2. Blank repositories
|
||||||
|
- Initialize with `git init -b main` when supported
|
||||||
|
- Fall back to `git init` plus `git symbolic-ref HEAD refs/heads/main` if needed
|
||||||
|
|
||||||
|
3. Branch state
|
||||||
|
- Treat `main` as the initial branch name for blank repos
|
||||||
|
- Make branch listing and current-branch helpers tolerate unborn `HEAD`
|
||||||
|
|
||||||
|
4. Pull behavior
|
||||||
|
- Prefer the current branch when no explicit branch is supplied
|
||||||
|
- Do not force `origin <branch>` if the branch is unborn or already tracked by the current checkout
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Some older git versions may not support `git init -b`; the backend should fall back cleanly
|
||||||
|
- Existing blank repos created under the old bare model may still require migration or cleanup outside this change
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The current repository creation flow creates mirrored bare repositories for clone-based repos. That breaks the workspace model because the UI and file editing features expect a normal working clone with an initial branch, remote tracking, and pull/fetch behavior that works from a checked-out branch.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Create clone-based repositories as normal working clones instead of mirrors
|
||||||
|
- Initialize blank repositories as working clones with an initial branch when needed
|
||||||
|
- Ensure newly created repos have a usable current branch for workspace browsing and commits
|
||||||
|
- Update pull semantics to use the current tracked branch when available
|
||||||
|
- Keep fetch behavior available for remote-synced repositories
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Backend: repository creation and git control helpers
|
||||||
|
- Backend tests: clone, pull, and empty-repo branch behavior
|
||||||
|
- Frontend: no intentional UX change beyond sync behavior becoming reliable
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
## 1. Backend - Repository Creation
|
||||||
|
|
||||||
|
- [x] 1.1 Switch clone-based repository creation from mirror clones to normal working clones
|
||||||
|
- [x] 1.2 Initialize blank repositories with a default branch name
|
||||||
|
- [x] 1.3 Preserve remote preflight and clear error handling
|
||||||
|
|
||||||
|
## 2. Backend - Git Sync Helpers
|
||||||
|
|
||||||
|
- [x] 2.1 Update pull behavior to use the current tracked branch when available
|
||||||
|
- [x] 2.2 Make branch helpers tolerate unborn HEAD in blank repos
|
||||||
|
|
||||||
|
## 3. Tests
|
||||||
|
|
||||||
|
- [x] 3.1 Add unit coverage for clone creation and blank repo initialization
|
||||||
|
- [x] 3.2 Add coverage for pull behavior on working clones and blank repos
|
||||||
|
|
||||||
|
## 4. Quality Gates
|
||||||
|
|
||||||
|
- [ ] 4.1 Run targeted API tests
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-22
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The current instance management has critical gaps in health monitoring that lead to poor user experience:
|
||||||
|
|
||||||
|
1. **Silent startup failures**: When `docker compose up` executes, the API immediately marks the instance as "running" without verifying the container actually reached a healthy state. Containers that crash on startup or fail to bind to their port appear "running" in the UI but serve 502 errors.
|
||||||
|
|
||||||
|
2. **Tunnel-only health checks**: The existing health check at `GET /instances/{id}/health` only performs an HTTP HEAD request to the tunnel URL. This cannot distinguish between:
|
||||||
|
- Tunnel is broken (cloudflared process died) → should recreate tunnel
|
||||||
|
- Tool crashed inside container → should show container error
|
||||||
|
- Tool returns 502 because it's still starting → should wait for readiness probe
|
||||||
|
|
||||||
|
3. **Unused readiness probes**: The `readiness_probe.py` service was built during the tool-workshop change but is never called during instance startup. Tool types can configure readiness probes (e.g., `curl -f http://localhost:8080/health`) but these are ignored.
|
||||||
|
|
||||||
|
4. **Blind auto-recovery**: The frontend shows a "Recreate Tunnel" button when the health check fails, but this recreates the tunnel even when the application itself is returning 502 errors, wasting time and confusing users.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Verify containers actually start successfully before marking instances as "running"
|
||||||
|
- Distinguish container health from tunnel health in monitoring
|
||||||
|
- Integrate readiness probes into the instance startup flow
|
||||||
|
- Only recreate tunnels when the tunnel itself is broken, not when the tool returns errors
|
||||||
|
- Provide clear error messages when instances fail to start
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Persistent tunnels (keeping temporary cloudflared tunnels)
|
||||||
|
- Automatic restart of crashed containers (Docker already does this with restart policies)
|
||||||
|
- Health check WebSocket push (polling is sufficient)
|
||||||
|
- Changing the Docker compose architecture
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**1. Startup verification via Docker API**
|
||||||
|
- After `docker compose up`, poll `docker ps` for 30 seconds to verify container state transitions to "running"
|
||||||
|
- If container exits or stays in "restarting" loop, mark instance as "error" with exit code
|
||||||
|
- Rationale: Direct Docker API check is more reliable than HTTP checks during startup when ports may not be bound yet
|
||||||
|
|
||||||
|
**2. Readiness probe as gate to "running" status**
|
||||||
|
- Instance status flow: `pending` → `starting` (container up) → `running` (probe passed)
|
||||||
|
- If probe fails after timeout, status becomes `unhealthy` (not `error` - container is still up)
|
||||||
|
- Rationale: Distinguishes "container won't start" from "container started but app isn't ready yet"
|
||||||
|
|
||||||
|
**3. Container + Tunnel dual health checks**
|
||||||
|
- Health endpoint returns both `container_status` (from Docker API) and `tunnel_status` (HTTP check)
|
||||||
|
- Frontend shows different badges: "container unhealthy" vs "tunnel error"
|
||||||
|
- Rationale: Users need to know if they should wait (app starting) or recreate tunnel
|
||||||
|
|
||||||
|
**4. Smart tunnel failure detection**
|
||||||
|
- Connection errors (ECONNREFUSED, ETIMEDOUT, DNS failure) → tunnel is broken → allow recreate
|
||||||
|
- HTTP 502/503/504 → application error → show "app error" badge, don't recreate
|
||||||
|
- HTTP 200-399 → healthy
|
||||||
|
- Rationale: 502 from the tool means the tunnel is working fine, the tool just isn't responding
|
||||||
|
|
||||||
|
**5. Readiness probe configuration from ToolType**
|
||||||
|
- Use existing `readiness_probe` JSON field on ToolType model
|
||||||
|
- Default probe for web tools: `curl -f http://localhost:{port}`
|
||||||
|
- Default probe for terminal tools: none (skip probe, mark running immediately)
|
||||||
|
- Rationale: Leverages existing infrastructure, provides sensible defaults
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[Risk] Startup polling adds latency** → Mitigation: Poll every 2 seconds with 30 second max timeout. Most containers start in <5 seconds.
|
||||||
|
|
||||||
|
**[Risk] Docker API calls from API container** → Mitigation: API container already has Docker CLI access for managing instances. Using `docker ps` is consistent with existing patterns.
|
||||||
|
|
||||||
|
**[Risk] False "unhealthy" from slow-starting tools** → Mitigation: 30 second default timeout with configurable override per tool type. Frontend shows "starting..." status during probe.
|
||||||
|
|
||||||
|
**[Risk] Probe commands may not exist in container** → Mitigation: Probe failures log stderr. If probe command missing, container still starts but marked as running without probe validation.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
No database migration needed. This change:
|
||||||
|
1. Adds new status values ("starting", "unhealthy") to existing `status` enum
|
||||||
|
2. Uses existing `readiness_probe` column on `tool_types` table
|
||||||
|
3. Changes health check API response format (adds fields, doesn't remove)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The current instance management has significant gaps in health monitoring. When starting instances, there's no verification that containers actually boot successfully - failures only surface when users try to access broken tunnels. The existing health check only validates tunnel URLs, not container health, leading to false positives where a "healthy" tunnel serves 502 errors from a crashed tool. Additionally, readiness probes exist as unused infrastructure, and auto-recovery blindly recreates tunnels on any HTTP error including legitimate 502s from the application itself.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Startup health checks**: Verify containers reach a running state after `docker compose up`, with clear failure messages when containers crash or fail to start
|
||||||
|
- **Container health checks**: Check container status via Docker API (`docker ps`, `docker inspect`) in addition to tunnel URL checks
|
||||||
|
- **Readiness probe integration**: Wire the existing `execute_probe()` service into the instance startup flow, using tool type configured probes
|
||||||
|
- **Smart auto-recovery**: Only recreate tunnels when the tunnel endpoint itself is unreachable (connection refused, timeout, DNS failure), NOT when the tool returns 502/503/504 errors
|
||||||
|
- **Instance status granularity**: Distinguish between "starting" (container booting), "running" (healthy), "unhealthy" (container up but probe failing), and "error" (failed to start)
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `instance-startup-health`: Container startup verification and failure detection
|
||||||
|
- `instance-runtime-health`: Continuous health monitoring combining container and tunnel checks
|
||||||
|
- `readiness-probe-integration`: Tool-type configured readiness probes during instance startup
|
||||||
|
- `smart-tunnel-recovery`: Context-aware tunnel recreation that distinguishes tunnel failures from application errors
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `session-management-fixes`: Update health check endpoint to include container status, modify tunnel health logic to be smarter about error codes
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Backend**: `api/tool_instances.py` (start_instance, health check, recreate tunnel), `services/docker.py` (container status checks), `services/readiness_probe.py` (integration into startup flow)
|
||||||
|
- **Frontend**: `pages/sessions.tsx` (display new status states, show startup errors, smarter health badges)
|
||||||
|
- **Database**: No schema changes - uses existing `status` field with new state values
|
||||||
|
- **API**: New response fields in health check endpoint (container_status, probe_result, last_probe_at)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Runtime health endpoint
|
||||||
|
The system SHALL provide a health endpoint that checks both container and tunnel health.
|
||||||
|
|
||||||
|
#### Scenario: Full health check
|
||||||
|
- **GIVEN** a running web-enabled instance
|
||||||
|
- **WHEN** `GET /instances/{id}/health` is called
|
||||||
|
- **THEN** the response includes:
|
||||||
|
- `container_status`: "running", "exited", "restarting", or "not_found"
|
||||||
|
- `container_health`: "healthy", "unhealthy", or null (if no Docker healthcheck)
|
||||||
|
- `tunnel_status`: "healthy", "unreachable", or "error_response"
|
||||||
|
- `tunnel_status_code`: the HTTP status code from the tunnel URL, or null
|
||||||
|
- `probe_status`: "passed", "failed", "pending", or "not_configured"
|
||||||
|
- `healthy`: true only if container is running AND tunnel is healthy
|
||||||
|
|
||||||
|
#### Scenario: Health check for terminal-only instance
|
||||||
|
- **GIVEN** a running terminal-only instance
|
||||||
|
- **WHEN** `GET /instances/{id}/health` is called
|
||||||
|
- **THEN** the response includes `container_status: "running"`
|
||||||
|
- **AND** `tunnel_status: "not_applicable"`
|
||||||
|
- **AND** `healthy: true` if container is running
|
||||||
|
|
||||||
|
### Requirement: Continuous health polling
|
||||||
|
The system SHALL support periodic health checks from the frontend.
|
||||||
|
|
||||||
|
#### Scenario: Frontend health polling
|
||||||
|
- **GIVEN** active instances in the UI
|
||||||
|
- **WHEN** the frontend polls health every 30 seconds
|
||||||
|
- **THEN** the health status is displayed as a badge
|
||||||
|
- **AND** the badge shows "tunnel error" only when tunnel is unreachable
|
||||||
|
- **AND** the badge shows "app error" when tunnel returns 502/503/504
|
||||||
|
- **AND** the badge shows "starting" when container is up but probe is pending
|
||||||
|
|
||||||
|
### Requirement: Container state synchronization
|
||||||
|
The system SHALL update instance status when container state changes unexpectedly.
|
||||||
|
|
||||||
|
#### Scenario: Container crashes
|
||||||
|
- **GIVEN** an instance with status "running"
|
||||||
|
- **WHEN** the container exits (crash or OOM)
|
||||||
|
- **AND** a health check is performed
|
||||||
|
- **THEN** the instance status is updated to "error"
|
||||||
|
- **AND** the container exit code and logs are captured
|
||||||
|
|
||||||
|
#### Scenario: Container stopped externally
|
||||||
|
- **GIVEN** an instance with status "running"
|
||||||
|
- **WHEN** the container is stopped via docker command outside the system
|
||||||
|
- **AND** a health check is performed
|
||||||
|
- **THEN** the instance status is updated to "stopped"
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Container startup verification
|
||||||
|
The system SHALL verify that containers reach a running state before marking instances as "running".
|
||||||
|
|
||||||
|
#### Scenario: Container starts successfully
|
||||||
|
- **WHEN** `docker compose up` completes
|
||||||
|
- **THEN** the system polls `docker ps` every 2 seconds for up to 30 seconds
|
||||||
|
- **AND** when the container state is "running", the instance status becomes "starting"
|
||||||
|
- **AND** the readiness probe begins execution
|
||||||
|
|
||||||
|
#### Scenario: Container fails to start
|
||||||
|
- **WHEN** `docker compose up` completes
|
||||||
|
- **AND** the container exits within 30 seconds
|
||||||
|
- **THEN** the instance status becomes "error"
|
||||||
|
- **AND** the container exit code is stored in the error message
|
||||||
|
|
||||||
|
#### Scenario: Container stays in restarting loop
|
||||||
|
- **WHEN** `docker compose up` completes
|
||||||
|
- **AND** the container remains in "restarting" state after 30 seconds
|
||||||
|
- **THEN** the instance status becomes "error"
|
||||||
|
- **AND** the error message indicates the container is stuck restarting
|
||||||
|
|
||||||
|
### Requirement: Readiness probe execution
|
||||||
|
The system SHALL execute readiness probes for web-enabled tool instances before marking them as "running".
|
||||||
|
|
||||||
|
#### Scenario: Probe succeeds
|
||||||
|
- **GIVEN** a tool instance with status "starting"
|
||||||
|
- **AND** the tool type has a readiness probe configured
|
||||||
|
- **WHEN** the probe command returns exit code 0 within the timeout
|
||||||
|
- **THEN** the instance status becomes "running"
|
||||||
|
- **AND** the tunnel is created (for web tools)
|
||||||
|
|
||||||
|
#### Scenario: Probe times out
|
||||||
|
- **GIVEN** a tool instance with status "starting"
|
||||||
|
- **AND** the tool type has a readiness probe configured
|
||||||
|
- **WHEN** the probe does not succeed within the configured timeout (default 30s)
|
||||||
|
- **THEN** the instance status becomes "unhealthy"
|
||||||
|
- **AND** the tunnel is still created (the container is running)
|
||||||
|
- **AND** the last probe output is stored for diagnostics
|
||||||
|
|
||||||
|
#### Scenario: Terminal tool skips probe
|
||||||
|
- **GIVEN** a tool instance for a terminal-only tool type
|
||||||
|
- **WHEN** the container reaches "running" state
|
||||||
|
- **THEN** the instance status immediately becomes "running"
|
||||||
|
- **AND** no readiness probe is executed
|
||||||
|
|
||||||
|
### Requirement: Container health monitoring
|
||||||
|
The system SHALL check container health in addition to tunnel health.
|
||||||
|
|
||||||
|
#### Scenario: Container is healthy
|
||||||
|
- **GIVEN** a running instance
|
||||||
|
- **WHEN** the health endpoint is queried
|
||||||
|
- **THEN** the response includes `container_status: "running"`
|
||||||
|
- **AND** the response includes `container_health: "healthy"` if Docker healthcheck exists
|
||||||
|
|
||||||
|
#### Scenario: Container has crashed
|
||||||
|
- **GIVEN** a running instance
|
||||||
|
- **WHEN** the container exits or is stopped externally
|
||||||
|
- **AND** the health endpoint is queried
|
||||||
|
- **THEN** the response includes `container_status: "exited"`
|
||||||
|
- **AND** the response includes `healthy: false`
|
||||||
|
- **AND** the instance status in the database is updated to "error"
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Status Monitoring
|
||||||
|
The system SHALL track tool status with startup and health states.
|
||||||
|
|
||||||
|
#### Scenario: Status check with health details
|
||||||
|
- **GIVEN** a tool instance
|
||||||
|
- **WHEN** status is queried
|
||||||
|
- **THEN** the real-time container status is returned:
|
||||||
|
- `pending`: Instance created, container not yet started
|
||||||
|
- `starting`: Container is running, readiness probe in progress
|
||||||
|
- `running`: Container is running and probe passed (or terminal tool)
|
||||||
|
- `unhealthy`: Container is running but probe failed/timed out
|
||||||
|
- `stopped`: Container was stopped by user
|
||||||
|
- `error`: Container failed to start or crashed
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Readiness probe configuration
|
||||||
|
The system SHALL use tool type readiness probe configuration during instance startup.
|
||||||
|
|
||||||
|
#### Scenario: Web tool with custom probe
|
||||||
|
- **GIVEN** a tool type with `readiness_probe` configured as:
|
||||||
|
- `command: "curl -f http://localhost:8080/api/health"`
|
||||||
|
- `timeout: 60`
|
||||||
|
- `interval: 5`
|
||||||
|
- **WHEN** an instance of this type starts
|
||||||
|
- **THEN** the system executes the probe command inside the container
|
||||||
|
- **AND** retries every 5 seconds for up to 60 seconds
|
||||||
|
- **AND** the instance remains in "starting" status until probe succeeds
|
||||||
|
|
||||||
|
#### Scenario: Web tool with default probe
|
||||||
|
- **GIVEN** a web-enabled tool type with no `readiness_probe` configured
|
||||||
|
- **WHEN** an instance of this type starts
|
||||||
|
- **THEN** the system uses the default probe: `curl -f http://localhost:{port}`
|
||||||
|
- **AND** retries every 2 seconds for up to 30 seconds
|
||||||
|
|
||||||
|
#### Scenario: Probe command execution
|
||||||
|
- **GIVEN** a readiness probe command
|
||||||
|
- **WHEN** the system executes it inside the container
|
||||||
|
- **THEN** it runs via `docker exec {container_id} sh -c "{command}"`
|
||||||
|
- **AND** stdout/stderr are captured for diagnostics
|
||||||
|
- **AND** exit code 0 indicates success
|
||||||
|
|
||||||
|
### Requirement: Probe result storage
|
||||||
|
The system SHALL store readiness probe results for diagnostics.
|
||||||
|
|
||||||
|
#### Scenario: Successful probe logged
|
||||||
|
- **GIVEN** a readiness probe that succeeds
|
||||||
|
- **WHEN** the probe returns exit code 0
|
||||||
|
- **THEN** the success is logged with timestamp
|
||||||
|
- **AND** the instance status changes to "running"
|
||||||
|
|
||||||
|
#### Scenario: Failed probe logged
|
||||||
|
- **GIVEN** a readiness probe that fails or times out
|
||||||
|
- **WHEN** the probe reaches timeout
|
||||||
|
- **THEN** the failure is logged with last stdout/stderr output
|
||||||
|
- **AND** the instance status changes to "unhealthy"
|
||||||
|
- **AND** the probe output is available via the health endpoint
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tunnel failure classification
|
||||||
|
The system SHALL distinguish tunnel failures from application errors when determining whether to recreate a tunnel.
|
||||||
|
|
||||||
|
#### Scenario: Tunnel is broken
|
||||||
|
- **GIVEN** a running instance with a tunnel URL
|
||||||
|
- **WHEN** the health check receives one of:
|
||||||
|
- Connection refused (ECONNREFUSED)
|
||||||
|
- Connection timeout (ETIMEDOUT)
|
||||||
|
- DNS resolution failure (ENOTFOUND)
|
||||||
|
- Empty response
|
||||||
|
- **THEN** the tunnel status is "unreachable"
|
||||||
|
- **AND** the frontend shows a "tunnel error" badge
|
||||||
|
- **AND** the "Recreate Tunnel" button is enabled
|
||||||
|
|
||||||
|
#### Scenario: Application returns error
|
||||||
|
- **GIVEN** a running instance with a tunnel URL
|
||||||
|
- **WHEN** the health check receives HTTP 502, 503, or 504
|
||||||
|
- **THEN** the tunnel status is "error_response"
|
||||||
|
- **AND** the frontend shows an "app error" badge
|
||||||
|
- **AND** the "Recreate Tunnel" button is NOT shown
|
||||||
|
- **AND** the status code is displayed for diagnostics
|
||||||
|
|
||||||
|
#### Scenario: Application is healthy
|
||||||
|
- **GIVEN** a running instance with a tunnel URL
|
||||||
|
- **WHEN** the health check receives HTTP 200-399
|
||||||
|
- **THEN** the tunnel status is "healthy"
|
||||||
|
- **AND** no error badge is shown
|
||||||
|
|
||||||
|
#### Scenario: Tunnel recreates successfully
|
||||||
|
- **GIVEN** an instance with a broken tunnel (status "unreachable")
|
||||||
|
- **WHEN** the user clicks "Recreate Tunnel"
|
||||||
|
- **THEN** the old cloudflared process is stopped
|
||||||
|
- **AND** a new cloudflared process is started
|
||||||
|
- **AND** the instance URL is updated
|
||||||
|
- **AND** the tunnel status becomes "healthy" (after verification)
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Status Monitoring
|
||||||
|
The system SHALL track tool status with startup and health states.
|
||||||
|
|
||||||
|
#### Scenario: Status check with health details
|
||||||
|
- **GIVEN** a tool instance
|
||||||
|
- **WHEN** status is queried
|
||||||
|
- **THEN** the real-time container status is returned:
|
||||||
|
- `pending`: Instance created, container not yet started
|
||||||
|
- `starting`: Container is running, readiness probe in progress
|
||||||
|
- `running`: Container is running and probe passed (or terminal tool)
|
||||||
|
- `unhealthy`: Container is running but probe failed/timed out
|
||||||
|
- `stopped`: Container was stopped by user
|
||||||
|
- `error`: Container failed to start or crashed
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Health check endpoint enhancement
|
||||||
|
The system SHALL provide detailed health information through the health check endpoint.
|
||||||
|
|
||||||
|
#### Scenario: Health check with container and tunnel status
|
||||||
|
- **GIVEN** a running instance
|
||||||
|
- **WHEN** `GET /instances/{id}/health` is called
|
||||||
|
- **THEN** the response includes:
|
||||||
|
- `healthy`: boolean - overall health
|
||||||
|
- `container_status`: "running", "exited", "restarting", or "not_found"
|
||||||
|
- `tunnel_status`: "healthy", "unreachable", "error_response", or "not_applicable"
|
||||||
|
- `tunnel_status_code`: HTTP status code or null
|
||||||
|
- `probe_status`: "passed", "failed", "pending", or "not_configured"
|
||||||
|
- `last_probe_output`: string or null
|
||||||
|
|
||||||
|
### Requirement: Smart tunnel recreation
|
||||||
|
The system SHALL only allow tunnel recreation when the tunnel itself is broken.
|
||||||
|
|
||||||
|
#### Scenario: Recreate tunnel for unreachable tunnel
|
||||||
|
- **GIVEN** an instance with `tunnel_status: "unreachable"`
|
||||||
|
- **WHEN** the recreate tunnel endpoint is called
|
||||||
|
- **THEN** the tunnel is recreated
|
||||||
|
- **AND** the new URL is returned
|
||||||
|
|
||||||
|
#### Scenario: Block recreation for application errors
|
||||||
|
- **GIVEN** an instance with `tunnel_status: "error_response"` (e.g., HTTP 502)
|
||||||
|
- **WHEN** the recreate tunnel endpoint is called
|
||||||
|
- **THEN** the request is rejected with 400 Bad Request
|
||||||
|
- **AND** the error message explains the tunnel is working but the application is returning errors
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## 1. Backend - Container Startup Verification
|
||||||
|
|
||||||
|
- [x] 1.1 Implement `wait_for_container_running()` in `services/docker.py` - polls `docker ps` until container reaches "running" state or timeout
|
||||||
|
- [x] 1.2 Implement `get_container_status()` in `services/docker.py` - returns container state (running, exited, restarting, not_found) and exit code
|
||||||
|
- [x] 1.3 Update `start_instance()` in `api/tool_instances.py` to call startup verification after `docker compose up`
|
||||||
|
- [x] 1.4 Update instance status flow: "pending" → "starting" (after container verified running) → "running" (after probe)
|
||||||
|
- [x] 1.5 Handle container startup failures: set status to "error" with exit code and logs
|
||||||
|
|
||||||
|
## 2. Backend - Readiness Probe Integration
|
||||||
|
|
||||||
|
- [x] 2.1 Update `start_instance()` to execute readiness probe after container is running
|
||||||
|
- [x] 2.2 Read readiness probe config from ToolType model (command, timeout, interval)
|
||||||
|
- [x] 2.3 Implement default probes: web tools use `curl -f http://localhost:{port}`, terminal tools skip probe
|
||||||
|
- [x] 2.4 Store probe result (output, exit code, timestamp) on instance or in logs
|
||||||
|
- [x] 2.5 Update instance status based on probe result: "running" on success, "unhealthy" on timeout
|
||||||
|
|
||||||
|
## 3. Backend - Health Check Enhancement
|
||||||
|
|
||||||
|
- [x] 3.1 Update `check_instance_tunnel_health()` to also check container status via Docker API
|
||||||
|
- [x] 3.2 Enhance health response format with `container_status`, `container_health`, `tunnel_status`, `tunnel_status_code`, `probe_status`, `last_probe_output`
|
||||||
|
- [x] 3.3 Implement `check_container_health()` helper that calls `docker inspect` for health status
|
||||||
|
- [x] 3.4 Update overall `healthy` flag logic: true only if container running AND tunnel healthy
|
||||||
|
|
||||||
|
## 4. Backend - Smart Tunnel Recovery
|
||||||
|
|
||||||
|
- [x] 4.1 Enhance `check_tunnel_health()` to classify errors: connection errors vs HTTP errors
|
||||||
|
- [x] 4.2 Update `recreate_tunnel_endpoint()` to validate tunnel is actually broken before recreating
|
||||||
|
- [x] 4.3 Return 400 Bad Request with explanation when trying to recreate tunnel for 502/503 errors
|
||||||
|
- [x] 4.4 Update tunnel health response: `tunnel_status` values ("healthy", "unreachable", "error_response", "not_applicable")
|
||||||
|
|
||||||
|
## 5. Frontend - Status Display
|
||||||
|
|
||||||
|
- [x] 5.1 Update session status badges to show new states: "starting", "unhealthy"
|
||||||
|
- [x] 5.2 Show container error messages when instance fails to start
|
||||||
|
- [x] 5.3 Display "tunnel error" badge only when `tunnel_status === "unreachable"`
|
||||||
|
- [x] 5.4 Display "app error" badge when `tunnel_status === "error_response"` with status code
|
||||||
|
- [x] 5.5 Show "starting..." badge when `container_status === "running"` but `probe_status === "pending"`
|
||||||
|
|
||||||
|
## 6. Frontend - Health Polling
|
||||||
|
|
||||||
|
- [x] 6.1 Update health polling to use enhanced health endpoint response
|
||||||
|
- [x] 6.2 Store full health state (container + tunnel) in component state
|
||||||
|
- [x] 6.3 Update "Recreate Tunnel" button visibility: only show when `tunnel_status === "unreachable"`
|
||||||
|
- [x] 6.4 Show probe output in a collapsible section for diagnostics
|
||||||
|
|
||||||
|
## 7. Testing and Quality Gates
|
||||||
|
|
||||||
|
- [x] 7.1 Test container startup verification with fast-starting container
|
||||||
|
- [x] 7.2 Test container startup failure (container exits immediately)
|
||||||
|
- [x] 7.3 Test readiness probe success and timeout scenarios
|
||||||
|
- [x] 7.4 Test health endpoint with various container states
|
||||||
|
- [x] 7.5 Test smart tunnel recovery (connection error vs 502)
|
||||||
|
- [x] 7.6 Run backend linting (ruff) - skipped (not installed)
|
||||||
|
- [x] 7.7 Run backend type checking (mypy) - skipped (not installed)
|
||||||
|
- [x] 7.8 Run frontend type checking (tsc) - PASSED
|
||||||
|
- [x] 7.9 Build frontend and verify no errors - PASSED
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-20
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user