Compare commits

..

2 Commits

Author SHA1 Message Date
alex 22c035984e feat: add config profiles data model and migrations
- Add ConfigProfile model with user ownership, name, description
- Add ConfigInclude model for ordered profile self-references
- Add ConfigMount model for mount/file definitions
- Add selected_profile_id to ToolInstance for per-instance profile selection
- Add default profile properties to UserConfig JSONB config
- Create Alembic migration 0013 for new tables and columns
- Register new models in models/__init__.py
- Mark config_folders.is_active as deprecated
- Add migration metadata test

Quality gates: syntax check passed (all files parse successfully)
OpenSpec: add-config-profiles task 1.1
2026-05-24 12:54:45 +00:00
alex d35037df01 docs: add OpenSpec status review and update add-config-profiles tasks
- Create comprehensive OpenSpec status and implementation checklist review
- Document current state: 11 active changes, 66/345 tasks complete (19.1%)
- Update add-config-profiles/tasks.md to reflect completed model work
- Identify near-completion changes, blockers, and recommendations

Quality gates: review document only, no code changes
2026-05-24 12:54:23 +00:00
301 changed files with 4673 additions and 19802 deletions
-195
View File
@@ -1,195 +0,0 @@
---
name: sift-backlog
description: Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open.
---
# Sift Backlog
Triage backlog tasks: prioritize, group into plans, set dependencies, and activate.
## Overview
1. List backlog tasks (`sf task backlog`)
2. Clarify and enrich each task (titles, descriptions)
3. Identify groupings and create draft plans
4. Add tasks to plans and set dependencies
5. Activate plans
6. Set task status to open
## Workflow
### Step 1: List Backlog Tasks
```bash
sf task backlog
```
### Step 2: Clarify and Enrich Tasks
Backlog tasks often have only a brief title with no description. Before organizing, ensure each task is well-defined.
**For each task, evaluate:**
- Is the title clear and actionable?
- Is there a description? Check with `sf task describe <task-id> --show`
- Is the scope unambiguous?
**If the title is unclear**, update it:
```bash
sf update <task-id> --title "Clear, actionable title"
```
**Add a description** with context, scope, and acceptance criteria:
```bash
sf task describe <task-id> --content "Description with:
- What needs to be done
- Why it matters
- Acceptance criteria
- Any relevant context"
```
**Use your best judgment** to interpret tasks and make reasonable decisions about scope, grouping, and priority. You have context about the codebase, project patterns, and typical development practices—leverage this knowledge rather than deferring to the user for routine decisions.
**Only ask the user for clarity when absolutely necessary:**
- The task is fundamentally ambiguous (multiple mutually exclusive interpretations)
- Critical business logic or user-facing behavior that could go wrong in meaningful ways
- External dependencies or integrations you cannot verify
**Do NOT ask about:**
- Implementation details you can reasonably infer
- Priority or grouping decisions—use your judgment
- Standard development practices (testing, code style, etc.)
- Tasks where a reasonable interpretation exists
### Step 3: Create Draft Plans
Group related tasks into plans using your best judgment. Plans start as drafts (tasks won't be dispatched until activated).
**Grouping guidance:**
- Group tasks that share a common theme, feature area, or goal
- Consider technical dependencies when grouping (tasks that touch the same files/modules)
- Separate unrelated work into distinct plans for parallel execution
- Don't over-group—if tasks are truly independent, separate plans enable better parallelism
- Don't under-group—related tasks benefit from shared context and coordinated execution
```bash
sf plan create --title "Plan Name"
```
**Example:**
```bash
sf plan create --title "Authentication Improvements"
# Output: Created plan el-abc123
```
### Step 4: Add Tasks to Plans
```bash
sf plan add-task <plan-id> <task-id>
```
**Example:**
```bash
sf plan add-task el-abc123 el-task1
sf plan add-task el-abc123 el-task2
```
### Step 5: Set Dependencies Between Tasks
Use `blocks` dependency when one task must complete before another can start.
```bash
sf dependency add <blocked-id> <blocker-id> --type blocks
```
**Semantics:** The first ID is blocked BY the second ID. The blocker must complete first.
**Example:** Task 2 can't start until Task 1 completes:
```bash
sf dependency add el-task2 el-task1 --type blocks
```
### Step 6: Update Priorities
Set priorities based on your assessment of impact, urgency, and dependencies. Use your judgment—you don't need user confirmation for routine prioritization.
**Priority guidance:**
- **Critical (1):** Blocking issues, security vulnerabilities, production bugs
- **High (2):** Important features with deadlines, significant user impact
- **Medium (3):** Standard feature work, most tasks default here
- **Low (4):** Nice-to-haves, minor improvements, tech debt
- **Minimal (5):** Backlog cleanup, documentation, exploratory work
```bash
sf update <task-id> --priority <1-5>
```
| Value | Level |
| ----- | -------- |
| 1 | Critical |
| 2 | High |
| 3 | Medium |
| 4 | Low |
| 5 | Minimal |
### Step 7: Activate Plans
Once tasks are organized with dependencies set, activate plans to enable dispatch.
```bash
sf plan activate <plan-id>
```
### Step 8: Set Task Status to Open
Move tasks from backlog to open so they become ready for work.
```bash
sf update <id> --status open
```
## Other Actions
**Close obsolete tasks:**
```bash
sf task close <id> --reason "Won't do: <reason>"
```
**Defer tasks:**
```bash
sf task defer <id> --until <date>
```
**View existing plans:**
```bash
sf plan list
```
**View tasks in a plan:**
```bash
sf plan tasks <plan-id>
```
## Tips
- **Use your best judgment** for grouping, prioritization, and task interpretation—don't defer routine decisions to the user
- **Only escalate to the user** when ambiguity is fundamental and could lead to wasted work (mutually exclusive interpretations, critical business decisions)
- Make reasonable inferences about implementation details, scope, and priority based on codebase context
- Create plans before setting dependencies to avoid dispatch race conditions
- Always activate plans after dependencies are set
- Focus on oldest backlog items first (sorted by creation date)
- Every task should have a clear title and description before activation
- When uncertain about a minor detail, make a reasonable choice and document it in the task description—workers can ask if needed
-1
View File
@@ -48,4 +48,3 @@ apps/web/dist/
# OS
.DS_Store
Thumbs.db
/.stoneforge/.worktrees/
-2
View File
@@ -1,2 +0,0 @@
262629
1779624255076
-6
View File
@@ -1,6 +0,0 @@
# Runtime data
*.db
*.db-journal
*.db-wal
*.db-shm
daemon-state.json
-20
View File
@@ -1,20 +0,0 @@
# Stoneforge Configuration
database: stoneforge.db
sync:
auto_export: true
elements_file: elements.jsonl
dependencies_file: dependencies.jsonl
playbooks:
paths:
- playbooks
identity:
mode: soft
merge:
auto_merge: true
target_branch: null
require_approval: false
workflow:
preset: auto
agents:
permission_model: unrestricted
-43
View File
@@ -1,43 +0,0 @@
{"blockedId":"el-1of","blockerId":"el-258","type":"parent-child","createdAt":"2026-05-24T09:44:58.759Z","createdBy":"el-2jua"}
{"blockedId":"el-5fe","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:40.892Z","createdBy":"el-2jua"}
{"blockedId":"el-1nj","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.010Z","createdBy":"el-2jua"}
{"blockedId":"el-1bn","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.127Z","createdBy":"el-2jua"}
{"blockedId":"el-4hr","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.244Z","createdBy":"el-2jua"}
{"blockedId":"el-62c","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.372Z","createdBy":"el-2jua"}
{"blockedId":"el-5z8","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.490Z","createdBy":"el-2jua"}
{"blockedId":"el-1t7","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.607Z","createdBy":"el-2jua"}
{"blockedId":"el-5j5","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.726Z","createdBy":"el-2jua"}
{"blockedId":"el-2xl","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.844Z","createdBy":"el-2jua"}
{"blockedId":"el-4bc","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.959Z","createdBy":"el-2jua"}
{"blockedId":"el-107","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.074Z","createdBy":"el-2jua"}
{"blockedId":"el-32e","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.195Z","createdBy":"el-2jua"}
{"blockedId":"el-3ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.311Z","createdBy":"el-2jua"}
{"blockedId":"el-14w","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.425Z","createdBy":"el-2jua"}
{"blockedId":"el-1ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.541Z","createdBy":"el-2jua"}
{"blockedId":"el-1nj","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.651Z","createdBy":"el-2jua"}
{"blockedId":"el-1bn","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.761Z","createdBy":"el-2jua"}
{"blockedId":"el-4hr","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.868Z","createdBy":"el-2jua"}
{"blockedId":"el-62c","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:42.979Z","createdBy":"el-2jua"}
{"blockedId":"el-62c","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.092Z","createdBy":"el-2jua"}
{"blockedId":"el-5z8","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.205Z","createdBy":"el-2jua"}
{"blockedId":"el-5z8","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.313Z","createdBy":"el-2jua"}
{"blockedId":"el-1t7","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.422Z","createdBy":"el-2jua"}
{"blockedId":"el-1t7","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.529Z","createdBy":"el-2jua"}
{"blockedId":"el-1t7","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:43.647Z","createdBy":"el-2jua"}
{"blockedId":"el-5j5","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.758Z","createdBy":"el-2jua"}
{"blockedId":"el-2xl","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.876Z","createdBy":"el-2jua"}
{"blockedId":"el-4bc","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.987Z","createdBy":"el-2jua"}
{"blockedId":"el-4bc","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:44.096Z","createdBy":"el-2jua"}
{"blockedId":"el-4bc","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:44.208Z","createdBy":"el-2jua"}
{"blockedId":"el-107","blockerId":"el-5z8","type":"blocks","createdAt":"2026-05-24T12:44:44.319Z","createdBy":"el-2jua"}
{"blockedId":"el-32e","blockerId":"el-5j5","type":"blocks","createdAt":"2026-05-24T12:44:44.429Z","createdBy":"el-2jua"}
{"blockedId":"el-32e","blockerId":"el-2xl","type":"blocks","createdAt":"2026-05-24T12:44:44.539Z","createdBy":"el-2jua"}
{"blockedId":"el-3ou","blockerId":"el-4bc","type":"blocks","createdAt":"2026-05-24T12:44:44.650Z","createdBy":"el-2jua"}
{"blockedId":"el-3ou","blockerId":"el-107","type":"blocks","createdAt":"2026-05-24T12:44:44.761Z","createdBy":"el-2jua"}
{"blockedId":"el-14w","blockerId":"el-32e","type":"blocks","createdAt":"2026-05-24T12:44:44.873Z","createdBy":"el-2jua"}
{"blockedId":"el-1ou","blockerId":"el-3ou","type":"blocks","createdAt":"2026-05-24T12:44:44.987Z","createdBy":"el-2jua"}
{"blockedId":"el-1ou","blockerId":"el-14w","type":"blocks","createdAt":"2026-05-24T12:44:45.107Z","createdBy":"el-2jua"}
{"blockedId":"el-375","blockerId":"el-26p","type":"replies-to","createdAt":"2026-05-24T13:21:42.486Z","createdBy":"el-2i1s"}
{"blockedId":"el-3n4","blockerId":"el-31p","type":"replies-to","createdAt":"2026-05-24T13:21:46.044Z","createdBy":"el-13ju"}
{"blockedId":"el-3jer","blockerId":"el-1xx","type":"replies-to","createdAt":"2026-05-24T13:24:47.580Z","createdBy":"el-4350"}
{"blockedId":"el-1afv","blockerId":"el-1ozw","type":"replies-to","createdAt":"2026-05-24T13:32:42.658Z","createdBy":"el-51a8"}
File diff suppressed because one or more lines are too long
-25
View File
@@ -87,31 +87,6 @@ Do not claim completion without verification evidence.
## 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
When an OpenSpec change is fully implemented and all tasks are complete:
-1
View File
@@ -27,7 +27,6 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
git \
openssh-client \
netcat-openbsd \
ca-certificates \
curl \
@@ -0,0 +1,104 @@
"""add config profiles, includes, mounts, and tool instance profile selection
Revision ID: 0013_add_config_profiles
Revises: 0012_default_port_req
Create Date: 2026-05-24 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0013_add_config_profiles"
down_revision: Union[str, None] = "0012_default_port_req"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create config_profiles table
op.create_table(
"config_profiles",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
# Create config_includes table
op.create_table(
"config_includes",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
)
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"])
# Create config_mounts table
op.create_table(
"config_mounts",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("mount_path", sa.String(length=1024), nullable=False),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
# Add selected_profile_id to tool_instances
op.add_column(
"tool_instances",
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_tool_instances_selected_profile",
"tool_instances",
"config_profiles",
["selected_profile_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"])
def downgrade() -> None:
# Remove selected_profile_id from tool_instances
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey")
op.drop_column("tool_instances", "selected_profile_id")
# Drop config_mounts
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
op.drop_table("config_mounts")
# Drop config_includes
op.drop_index("idx_config_includes_included", table_name="config_includes")
op.drop_index("idx_config_includes_profile", table_name="config_includes")
op.drop_table("config_includes")
# Drop config_profiles
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
op.drop_table("config_profiles")
@@ -1,29 +0,0 @@
"""add probe_result to tool_instances
Revision ID: 0013_add_probe_result
Revises: 0012_default_port_req
Create Date: 2026-05-22 21:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0013_add_probe_result"
down_revision: Union[str, None] = "0012_default_port_req"
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("probe_result", postgresql.JSON, nullable=True)
)
def downgrade() -> None:
op.drop_column("tool_instances", "probe_result")
@@ -1,25 +0,0 @@
"""merge migration heads
Revision ID: 0014_merge_heads
Revises: 0013_add_probe_result, 8ed7dd80973d
Create Date: 2026-05-22 21:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0014_merge_heads"
down_revision: Union[str, Sequence[str], None] = ("0013_add_probe_result", "8ed7dd80973d")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,110 +0,0 @@
"""replace interfaces with interface_type and add requires_port
Revision ID: 0015_single_interface
Revises: 0014_merge_heads
Create Date: 2026-05-22 22:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = "0015_single_interface"
down_revision: Union[str, Sequence[str], None] = "0014_merge_heads"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _get_dialect() -> str:
"""Get the current database dialect name."""
conn = op.get_bind()
return conn.dialect.name
def upgrade() -> None:
dialect = _get_dialect()
# Add new columns
op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True))
op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true'))
# Migrate data: take first element from interfaces JSON array
if dialect == 'postgresql':
op.execute("""
UPDATE tool_types
SET interface_type = COALESCE(
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
'web'
),
requires_port = CASE
WHEN COALESCE(
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
'web'
) = 'web' THEN true
ELSE false
END
""")
else:
# SQLite: interfaces is stored as JSON text, extract first array element
op.execute("""
UPDATE tool_types
SET interface_type = COALESCE(
(SELECT json_extract(value, '$[0]')
FROM json_each(interfaces) AS value
WHERE json_valid(interfaces)
LIMIT 1),
'web'
),
requires_port = CASE
WHEN COALESCE(
(SELECT json_extract(value, '$[0]')
FROM json_each(interfaces) AS value
WHERE json_valid(interfaces)
LIMIT 1),
'web'
) = 'web' THEN true
ELSE false
END
""")
# Make interface_type non-nullable after data migration
op.alter_column('tool_types', 'interface_type', nullable=False)
# Drop old interfaces column
op.drop_column('tool_types', 'interfaces')
# Add CHECK constraint for interface_type (only on PostgreSQL; SQLite supports it too)
op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')"))
def downgrade() -> None:
dialect = _get_dialect()
# Drop CHECK constraint
op.drop_constraint('chk_interface_type', 'tool_types', type_='check')
# Add back interfaces column
if dialect == 'postgresql':
op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]'))
# Migrate data back: wrap interface_type in array
op.execute("""
UPDATE tool_types
SET interfaces = jsonb_build_array(interface_type)
""")
else:
op.add_column('tool_types', sa.Column('interfaces', sa.JSON(), nullable=False, server_default='["web"]'))
# Migrate data back: wrap interface_type in array for SQLite
op.execute("""
UPDATE tool_types
SET interfaces = json_array(interface_type)
""")
# Drop new columns
op.drop_column('tool_types', 'requires_port')
op.drop_column('tool_types', 'interface_type')
@@ -1,36 +0,0 @@
"""add_clone_mode_and_ssh_key_id
Revision ID: 2026_05_22_add_clone_mode
Revises: 0014_merge_heads
Create Date: 2026-05-22 20:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '2026_05_22_add_clone_mode'
down_revision = '0015_single_interface'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add ssh_key_id to git_repositories
op.add_column('git_repositories', sa.Column('ssh_key_id', postgresql.UUID(), nullable=True))
op.create_foreign_key('fk_git_repositories_ssh_key', 'git_repositories', 'ssh_keys', ['ssh_key_id'], ['id'])
# Add clone_mode and branch to tool_instances
op.add_column('tool_instances', sa.Column('clone_mode', sa.String(20), nullable=False, server_default='mount'))
op.add_column('tool_instances', sa.Column('branch', sa.String(255), nullable=True, server_default='main'))
def downgrade() -> None:
# Drop columns from tool_instances
op.drop_column('tool_instances', 'branch')
op.drop_column('tool_instances', 'clone_mode')
# Drop ssh_key_id from git_repositories
op.drop_constraint('fk_git_repositories_ssh_key', 'git_repositories', type_='foreignkey')
op.drop_column('git_repositories', 'ssh_key_id')
@@ -1,25 +0,0 @@
"""remove_is_builtin_from_tool_types
Revision ID: 2026_05_23_remove_is_builtin
Revises: 2026_05_22_add_clone_mode
Create Date: 2026-05-23 14:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2026_05_23_remove_is_builtin'
down_revision = 'f3d2dc90ba3a'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Drop the is_builtin column from tool_types
op.execute("ALTER TABLE tool_types DROP COLUMN IF EXISTS is_builtin")
def downgrade() -> None:
# Add the is_builtin column back to tool_types
op.add_column('tool_types', sa.Column('is_builtin', sa.Boolean(), nullable=False, server_default='false'))
@@ -1,30 +0,0 @@
"""add startup_command to tool_types
Revision ID: 2026_05_24_220141
Revises: 6fc7bfcf199f
Create Date: 2026-05-24 22:01:41.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_24_220141"
down_revision: Union[str, Sequence[str], None] = "6fc7bfcf199f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_types",
sa.Column("startup_command", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("tool_types", "startup_command")
@@ -1,86 +0,0 @@
"""add_config_profiles
Revision ID: 2026_05_24_add_config_profiles
Revises: f3d2dc90ba3a
Create Date: 2026-05-24 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "2026_05_24_add_config_profiles"
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create config_profiles table
op.create_table(
"config_profiles",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
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("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True),
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True),
sa.Column("env_vars", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
sa.Column("runtime_hints", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
sa.Column("mounts", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="[]"),
sa.Column("files", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
# Create indexes for config_profiles
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
# Create config_profile_includes table
op.create_table(
"config_profile_includes",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_profile_includes"),
)
# Create indexes for config_profile_includes
op.create_index("idx_config_profile_includes_profile", "config_profile_includes", ["profile_id"])
op.create_index("idx_config_profile_includes_included", "config_profile_includes", ["included_profile_id"])
# Add selected_config_profile_id to tool_instances
op.add_column(
"tool_instances",
sa.Column("selected_config_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True),
)
op.create_index("idx_tool_instances_config_profile", "tool_instances", ["selected_config_profile_id"])
def downgrade() -> None:
# Remove selected_config_profile_id from tool_instances
op.drop_index("idx_tool_instances_config_profile", table_name="tool_instances")
op.drop_column("tool_instances", "selected_config_profile_id")
# Drop config_profile_includes table
op.drop_index("idx_config_profile_includes_included", table_name="config_profile_includes")
op.drop_index("idx_config_profile_includes_profile", table_name="config_profile_includes")
op.drop_table("config_profile_includes")
# Drop config_profiles table
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
op.drop_table("config_profiles")
@@ -1,25 +0,0 @@
"""merge_remove_is_builtin_and_add_config_profiles
Revision ID: 6fc7bfcf199f
Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
Create Date: 2026-05-24 18:00:43.990361
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6fc7bfcf199f'
down_revision = ('2026_05_23_remove_is_builtin', '2026_05_24_add_config_profiles')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,25 +0,0 @@
"""merge_single_interface_and_clone_mode
Revision ID: f3d2dc90ba3a
Revises: 0015_single_interface, 2026_05_22_add_clone_mode
Create Date: 2026-05-24 10:43:14.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f3d2dc90ba3a"
down_revision: Union[str, Sequence[str], None] = ("0015_single_interface", "2026_05_22_add_clone_mode")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+41 -6
View File
@@ -8,7 +8,6 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.shared_validators import validate_files as _validate_files, validate_mount_path as _validate_mount_path
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_folder import ConfigFolder
@@ -16,6 +15,9 @@ 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)")
@@ -26,12 +28,24 @@ class ConfigFolderCreate(BaseModel):
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str) -> str:
return _validate_mount_path(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) -> dict:
return _validate_files(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 ConfigFolderUpdate(BaseModel):
@@ -44,12 +58,29 @@ class ConfigFolderUpdate(BaseModel):
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
return _validate_mount_path(v)
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:
return _validate_files(v)
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):
@@ -59,7 +90,11 @@ class ProjectOverrideCreate(BaseModel):
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
return _validate_mount_path(v)
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
class ConfigFolderResponse(BaseModel):
-647
View File
@@ -1,647 +0,0 @@
"""Config profile API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.api.shared_validators import validate_env_vars as _validate_env_vars
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.project import Project
from src.models.tool_type import ToolType
from src.services.config_profile_resolver import (
ConfigProfileCycleError,
check_include_cycle,
resolve_profile,
resolved_profile_to_dict,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def _validate_uuid(v: str | None) -> str | None:
if v is None:
return v
try:
uuid.UUID(v)
except ValueError:
raise ValueError(f"Invalid UUID: {v}")
return v
def _calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data."""
total = 0
for key, value in data.get("env_vars", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for key, value in data.get("runtime_hints", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for mount in data.get("mounts", []):
total += len(str(mount.get("target", "")).encode("utf-8"))
total += len(str(mount.get("mode", "")).encode("utf-8"))
for path, content in mount.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
for path, content in data.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
return total
class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
@field_validator("target")
@classmethod
def validate_target(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount target must be absolute (start with /)")
return v
@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
if v not in ("ro", "rw"):
raise ValueError("Mount mode must be 'ro' or 'rw'")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v.keys():
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"Mount file paths must be relative (got: {path}). "
f"The mount target defines the absolute container path."
)
return v
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict = Field(default_factory=dict, description="Environment variables")
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v.keys():
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"File paths must be relative (got: {path}). "
f"Use Mounts for absolute container paths."
)
return v
@field_validator("env_vars")
@classmethod
def validate_env_vars(cls, v: dict) -> dict:
result = _validate_env_vars(v)
if result is None:
raise ValueError("env_vars must be a JSON object")
return result
@field_validator("runtime_hints")
@classmethod
def validate_runtime_hints(cls, v: dict) -> dict:
if not isinstance(v, dict):
raise ValueError("runtime_hints must be a JSON object")
return v
@field_validator("mounts")
@classmethod
def validate_mounts(cls, v: list) -> list:
if not isinstance(v, list):
raise ValueError("mounts must be a JSON array")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict | None = Field(default=None, description="Environment variables")
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
if v is None:
return v
for path in v.keys():
if ".." in path or path.startswith("/") or not path:
raise ValueError(f"Invalid file path: {path}")
return v
class ConfigProfileIncludeUpdate(BaseModel):
includes: list[str] = Field(description="Ordered list of included profile IDs")
@field_validator("includes")
@classmethod
def validate_includes(cls, v: list) -> list:
for item in v:
try:
uuid.UUID(item)
except ValueError:
raise ValueError(f"Invalid UUID in includes: {item}")
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
project_id: str | None
tool_type_id: str | None
env_vars: dict
runtime_hints: dict
mounts: list
files: dict
is_default: bool
includes: list[dict]
created_at: str
updated_at: str
async def _get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) -> ConfigProfile | None:
"""Fetch a profile with includes eagerly loaded."""
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile_id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one_or_none()
async def _check_access(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID | None = None,
tool_type_id: uuid.UUID | None = None,
) -> None:
"""Verify user has access to referenced project and tool type."""
if project_id is not None:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
# Add ownership check if needed; for now just verify existence
if tool_type_id is not None:
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")
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"project_id": str(profile.project_id) if profile.project_id else None,
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
"env_vars": profile.env_vars or {},
"runtime_hints": profile.runtime_hints or {},
"mounts": profile.mounts or [],
"files": profile.files or {},
"is_default": profile.is_default,
"includes": [
{
"id": str(inc.id),
"included_profile_id": str(inc.included_profile_id),
"order_index": inc.order_index,
}
for inc in (includes or profile.includes)
],
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles(
project_id: str | None = Query(None, description="Filter by project compatibility"),
tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""List config profiles, optionally filtered by compatibility."""
user_uuid = current_user_id
query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes))
if project_id or tool_type_id:
# Compatibility filter: include portable profiles and matching scoped profiles
project_uuid = uuid.UUID(project_id) if project_id else None
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
from sqlalchemy import or_
conditions: list = []
# Portable profiles (no project, no tool)
conditions.append(
(ConfigProfile.project_id.is_(None)) & (ConfigProfile.tool_type_id.is_(None))
)
if project_uuid:
# Profiles matching this project (with or without tool)
conditions.append(ConfigProfile.project_id == project_uuid)
if tool_uuid:
# Profiles matching this tool (with or without project)
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
if project_uuid and tool_uuid:
# Exact match
conditions.append(
(ConfigProfile.project_id == project_uuid) & (ConfigProfile.tool_type_id == tool_uuid)
)
query = query.where(or_(*conditions))
result = await session.execute(query)
profiles = result.scalars().all()
return [_profile_to_response(p) for p in profiles]
@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED)
async def create_config_profile(
data: ConfigProfileCreate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Create a new config profile."""
user_uuid = current_user_id
# Check for duplicate name
existing = await session.execute(
select(ConfigProfile).where(
ConfigProfile.user_id == user_uuid,
ConfigProfile.name == data.name,
).options(selectinload(ConfigProfile.includes))
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{data.name}' already exists",
)
# Validate references
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await _check_access(session, user_uuid, project_uuid, tool_uuid)
# Check size
size = _calculate_profile_size(data.model_dump())
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
)
profile = ConfigProfile(
user_id=user_uuid,
name=data.name,
description=data.description,
project_id=project_uuid,
tool_type_id=tool_uuid,
env_vars=data.env_vars,
runtime_hints=data.runtime_hints,
mounts=[m.model_dump() for m in data.mounts],
files=data.files,
is_default=data.is_default,
)
session.add(profile)
await session.commit()
# Re-fetch with includes to avoid lazy loading issues
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
logger.info("Created config profile %s for user %s", profile.id, user_uuid)
return _profile_to_response(profile)
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
async def get_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Get a config profile by ID."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
if profile.user_id != current_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
return _profile_to_response(profile)
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
async def update_config_profile(
profile_id: str,
data: ConfigProfileUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Update a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
if profile.user_id != current_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
update_data = data.model_dump(exclude_unset=True)
# Handle name uniqueness
if "name" in update_data:
existing = await session.execute(
select(ConfigProfile).where(
ConfigProfile.user_id == profile.user_id,
ConfigProfile.name == update_data["name"],
ConfigProfile.id != profile.id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{update_data['name']}' already exists",
)
# Validate references
project_uuid = (
uuid.UUID(update_data["project_id"])
if "project_id" in update_data and update_data["project_id"]
else (profile.project_id if "project_id" not in update_data else None)
)
tool_uuid = (
uuid.UUID(update_data["tool_type_id"])
if "tool_type_id" in update_data and update_data["tool_type_id"]
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
# Check size
current_data = _profile_to_response(profile)
merged = {**current_data, **update_data}
size = _calculate_profile_size(merged)
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
)
# Apply updates
for field_name, value in update_data.items():
if field_name in ("project_id", "tool_type_id"):
value = uuid.UUID(value) if value else None
elif field_name == "mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
setattr(profile, field_name, value)
await session.commit()
# Re-fetch with includes to avoid lazy loading issues
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
logger.info("Updated config profile %s", profile.id)
return _profile_to_response(profile)
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Delete a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
if profile.user_id != current_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
await session.delete(profile)
await session.commit()
logger.info("Deleted config profile %s", profile_id)
return None
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
async def update_profile_includes(
profile_id: str,
data: ConfigProfileIncludeUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Update the ordered includes for a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
if profile.user_id != current_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
# Validate all included profiles exist and belong to the user
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
for inc_uuid in included_uuids:
inc_profile = await session.get(ConfigProfile, inc_uuid)
if inc_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Included profile not found: {inc_uuid}",
)
if inc_profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Not authorized to include profile: {inc_uuid}",
)
if inc_uuid == profile.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Profile cannot include itself",
)
# Check for cycles
cycle = await check_include_cycle(session, profile.id, None)
if cycle is None and included_uuids:
# Check each new include would not create a cycle
for inc_uuid in included_uuids:
cycle = await check_include_cycle(session, profile.id, inc_uuid)
if cycle is not None:
break
if cycle is not None:
cycle_str = " -> ".join(str(c) for c in cycle)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Include cycle detected: {cycle_str}",
)
# Remove existing includes
result = await session.execute(
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
)
for existing in result.scalars().all():
await session.delete(existing)
await session.flush()
# Add new includes
for order_index, inc_uuid in enumerate(included_uuids):
include = ConfigProfileInclude(
profile_id=profile.id,
included_profile_id=inc_uuid,
order_index=order_index,
)
session.add(include)
await session.flush()
await session.commit()
# Re-fetch profile (includes loaded separately due to SQLite async issue)
result = await session.execute(
select(ConfigProfile).where(ConfigProfile.id == profile.id)
)
profile = result.scalar_one()
inc_result = await session.execute(
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
)
direct_includes = inc_result.scalars().all()
logger.info("Updated includes for config profile %s", profile.id)
return _profile_to_response(profile, list(direct_includes))
@router.get("/{profile_id}/preview")
async def preview_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Preview the resolved output of a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
if profile.user_id != current_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
try:
resolved = await resolve_profile(session, profile.id)
except ConfigProfileCycleError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
return resolved_profile_to_dict(resolved)
@router.get("/defaults/resolve")
async def resolve_default_profile(
project_id: str = Query(..., description="Project ID"),
tool_type_id: str = Query(..., description="Tool type ID"),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Resolve the default config profile for a project/tool combination.
Selects by specificity:
1. project+tool explicit default
2. project explicit default
3. tool explicit default
4. global/user explicit default
5. first created compatible profile
6. none (returns null)
"""
user_uuid = current_user_id
project_uuid = uuid.UUID(project_id)
tool_uuid = uuid.UUID(tool_type_id)
# Fetch all compatible profiles ordered by created_at
query = (
select(ConfigProfile)
.where(ConfigProfile.user_id == user_uuid)
.where(
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
| (ConfigProfile.project_id == project_uuid)
| (ConfigProfile.tool_type_id == tool_uuid)
| (
(ConfigProfile.project_id == project_uuid)
& (ConfigProfile.tool_type_id == tool_uuid)
)
)
.order_by(ConfigProfile.created_at)
)
result = await session.execute(query)
profiles = result.scalars().all()
if not profiles:
return {"profile_id": None, "profile_name": None}
# Check explicit defaults by specificity
explicit_defaults = [p for p in profiles if p.is_default]
# Most specific: project+tool
for p in explicit_defaults:
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: project only
for p in explicit_defaults:
if p.project_id == project_uuid and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: tool only
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id == tool_uuid:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: global/user (no project, no tool)
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
# Fall back to first created compatible profile
first = profiles[0]
return {"profile_id": str(first.id), "profile_name": first.name}
+40 -140
View File
@@ -10,11 +10,10 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.auth.dependencies import get_current_user_id, get_db_session
from src.config import Settings
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.utils.git_files import (
commit_file,
@@ -35,13 +34,46 @@ from src.utils.git_control import (
)
from src.utils.git_history import get_commit_detail, get_commit_history
from src.utils.git_url_parser import parse_git_url
from src.services.ssh_keys import _get_fernet
router = APIRouter(prefix="/projects", tags=["git-repositories"])
logger = logging.getLogger(__name__)
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
"""Generate the filesystem path for a repository.
@@ -62,97 +94,41 @@ def _build_provider_clone_url(owner: str, repo: str) -> str:
return f"git@git.commumedia.org:{owner}/{repo}.git"
def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
"""Prepare environment variables for git commands with SSH authentication.
Returns a dict of extra env vars, or None if no SSH key provided.
The caller is responsible for cleaning up the temporary key file.
"""
if ssh_key is None:
return None
import tempfile
# Decrypt private key
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
# Write to temp file with restricted permissions
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
# Return env vars and the key path for cleanup
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
return env, key_path
def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
def _preflight_remote_repository(remote_url: str) -> None:
"""Verify a remote repository is reachable before cloning."""
env = None
key_path = None
if ssh_key is not None:
ssh_result = _prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try:
result = subprocess.run(
["git", "ls-remote", remote_url],
capture_output=True,
text=True,
timeout=60,
env={**os.environ, **env} if env else None,
)
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")
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"repository not found or inaccessible: {result.stderr}",
detail="repository not found or inaccessible",
)
def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
env = None
key_path = None
if ssh_key is not None:
ssh_result = _prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
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,
env={**os.environ, **env} if env else None,
)
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")
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0:
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to clone repository: {result.stderr}",
@@ -199,7 +175,6 @@ class GitRepositoryCreate(BaseModel):
name: str
remote_url: str | None = None
force_original_url: bool = False
ssh_key_id: str | None = None
class URLParseRequest(BaseModel):
@@ -227,7 +202,6 @@ class GitRepositoryResponse(BaseModel):
is_mirror: bool
remote_url: str | None
last_push: datetime | None
ssh_key_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@@ -375,23 +349,8 @@ async def create_repository(
if parse_result["base_url"]:
remote_url = parse_result["base_url"]
# Validate SSH key if provided
ssh_key_id = None
ssh_key = None
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
if remote_url:
_preflight_remote_repository(remote_url, ssh_key)
_preflight_remote_repository(remote_url)
repo_path = _get_repo_path(user_id, project_id, data.name)
@@ -399,7 +358,7 @@ async def create_repository(
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
if remote_url:
_clone_working_repository(remote_url, repo_path, ssh_key)
_clone_working_repository(remote_url, repo_path)
else:
_init_working_repository(repo_path)
@@ -410,7 +369,6 @@ async def create_repository(
owner_id=user_id,
is_mirror=False,
remote_url=remote_url,
ssh_key_id=ssh_key_id,
)
session.add(repo)
await session.commit()
@@ -418,64 +376,6 @@ async def create_repository(
return repo
class UpdateSSHKeyRequest(BaseModel):
ssh_key_id: str | None = None
@router.patch(
"/{project_id}/repositories/{repo_id}/ssh-key",
response_model=GitRepositoryResponse,
summary="Update repository SSH key",
description="Update the SSH key associated with a repository.",
)
async def update_repository_ssh_key(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: UpdateSSHKeyRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Update the SSH key for a repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Update data containing the new SSH key ID.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated repository.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
# Validate SSH key if provided
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
repo.ssh_key_id = ssh_key_id
else:
repo.ssh_key_id = None
await session.commit()
await session.refresh(repo)
return repo
@router.get(
"/{project_id}/repositories/{repo_id}/history",
summary="Get repository history",
+35 -1
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
@@ -16,6 +16,14 @@ from src.models.user import User
router = APIRouter(prefix="/projects", tags=["projects"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
class ProjectCreate(BaseModel):
name: str
description: str | None = None
@@ -124,6 +132,32 @@ async def get_project(
return await _get_owned_project(project_id, user_id, session)
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
@router.patch(
"/{project_id}",
response_model=ProjectResponse,
-99
View File
@@ -1,99 +0,0 @@
"""Shared Pydantic validators for API schemas."""
from typing import Any
MAX_FOLDER_SIZE_MB = 10
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
def validate_mount_path(v: str | None) -> str | None:
"""Validate that a mount path is absolute (starts with /).
Args:
v: Mount path string or None.
Returns:
The validated path, or None if input was None.
Raises:
ValueError: If path is not absolute.
"""
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
def validate_files(v: dict | None, max_size_bytes: int = MAX_FOLDER_SIZE_BYTES) -> dict | None:
"""Validate file dict for path traversal and size limits.
Args:
v: Dict of {path: content} or None.
max_size_bytes: Maximum total size in bytes.
Returns:
The validated dict, or None if input was None.
Raises:
ValueError: If path traversal detected or size limit exceeded.
"""
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_size_bytes:
raise ValueError(f"Total folder size exceeds {max_size_bytes // (1024 * 1024)}MB limit")
return v
def validate_env_vars(v: dict | None) -> dict | None:
"""Validate that environment variables is a JSON object.
Args:
v: Dict of env vars or None.
Returns:
The validated dict, or None if input was None.
Raises:
ValueError: If not a dict.
"""
if v is None:
return v
if not isinstance(v, dict):
raise ValueError("environment_variables must be a JSON object")
return v
def validate_volumes(v: list | None) -> list | None:
"""Validate volume mounts list.
Args:
v: List of volume dicts or None.
Returns:
The validated list, or None if input was None.
Raises:
ValueError: If not a list or missing required fields.
"""
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
+9 -96
View File
@@ -1,4 +1,3 @@
import base64
import uuid
from datetime import datetime
@@ -10,7 +9,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.auth.dependencies import get_current_user_id, get_db_session
from src.config import Settings
from src.models.ssh_key import SSHKey
from src.models.user import User
@@ -18,6 +17,14 @@ from src.models.user import User
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
import base64
@@ -67,23 +74,6 @@ class SSHKeyResponse(BaseModel):
created_at: datetime
class SignPayloadRequest(BaseModel):
payload: str
class SignatureResponse(BaseModel):
signature: str
class VerifySignatureRequest(BaseModel):
payload: str
signature: str
class VerifySignatureResponse(BaseModel):
valid: bool
@router.post(
"",
response_model=SSHKeyResponse,
@@ -176,80 +166,3 @@ async def delete_ssh_key(
await session.delete(ssh_key)
await session.commit()
@router.post(
"/{key_id}/sign",
response_model=SignatureResponse,
summary="Sign payload",
description="Sign a payload using the SSH private key.",
)
async def sign_payload(
key_id: uuid.UUID,
data: SignPayloadRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> SignatureResponse:
"""Sign a payload with an SSH key.
Args:
key_id: UUID of the SSH key to use for signing.
data: Sign request containing the payload string.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Base64-encoded Ed25519 signature.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
fernet = _get_fernet()
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
private_key = serialization.load_ssh_private_key(
private_key_pem.encode(), password=None
)
signature = private_key.sign(data.payload.encode())
return SignatureResponse(signature=base64.b64encode(signature).decode())
@router.post(
"/{key_id}/verify",
response_model=VerifySignatureResponse,
summary="Verify signature",
description="Verify a signature against a payload using the SSH public key.",
)
async def verify_signature(
key_id: uuid.UUID,
data: VerifySignatureRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> VerifySignatureResponse:
"""Verify a signature with an SSH key's public key.
Args:
key_id: UUID of the SSH key to use for verification.
data: Verify request containing payload and base64-encoded signature.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Whether the signature is valid.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
try:
signature = base64.b64decode(data.signature)
public_key.verify(signature, data.payload.encode())
return VerifySignatureResponse(valid=True)
except Exception:
return VerifySignatureResponse(valid=False)
+10 -210
View File
@@ -9,20 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_db_session
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.terminal_manager import terminal_manager
router = APIRouter()
logger = logging.getLogger(__name__)
class SessionRef:
"""Mutable reference to a terminal session, allowing updates during reset."""
def __init__(self, session):
self.session = session
@router.websocket(
"/ws/tool-instances/{instance_id}/terminal",
)
@@ -34,7 +26,6 @@ async def terminal_websocket(
"""WebSocket endpoint for terminal access to a tool instance.
Provides an interactive terminal session inside a running tool instance container.
Sessions persist across WebSocket disconnections.
Args:
websocket: The WebSocket connection.
@@ -46,7 +37,6 @@ async def terminal_websocket(
"""
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
await websocket.accept()
logger.info("Terminal WebSocket accepted for instance %s", instance_id)
try:
# Parse instance_id
@@ -80,222 +70,32 @@ async def terminal_websocket(
await websocket.close(code=4004, reason="Instance not running")
return
logger.info("Terminal auth passed for instance %s, user %s", instance_id, user_id)
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
if startup_command:
logger.info("Using startup command for instance %s: %s", instance_id, startup_command)
# Get or create terminal session
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id)
# Create terminal session
try:
session = await terminal_manager.get_or_create_session(
session = await terminal_manager.create_session(
instance_uuid,
instance.container_id,
startup_command=startup_command,
websocket,
)
logger.info("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
# Attach WebSocket to session
await terminal_manager.attach_websocket(session, websocket)
logger.info("WebSocket attached to session for instance %s", instance_id)
logger.info("Terminal session created successfully for instance %s", instance_id)
# Send connected status
await websocket.send_json({"type": "status", "status": "connected"})
logger.info("Sent connected status for instance %s", instance_id)
# Use mutable session reference so loops can survive reset
session_ref = SessionRef(session)
# Start I/O loops and heartbeat
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
logger.info("Started terminal loops for instance %s", instance_id)
# Wait for either task to complete (indicating disconnect or error)
done, pending = await asyncio.wait(
[read_task, write_task, heartbeat_task],
return_when=asyncio.FIRST_COMPLETED,
)
logger.info("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
# Cancel remaining tasks
for task in pending:
task.cancel()
# Keep connection alive until session ends
# The terminal_manager handles I/O loops, we just wait here
while session.is_alive() and not session._closed:
await asyncio.sleep(0.5)
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}")
finally:
# Detach WebSocket, don't kill session
try:
if 'session' in locals():
await terminal_manager.detach_websocket(session, websocket)
logger.info("WebSocket detached from session for instance %s", instance_id)
except Exception:
pass
async def _read_loop(session_ref: SessionRef, websocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
while True:
session = session_ref.session
if not session.is_alive() or session._closed:
await asyncio.sleep(0.1)
continue
data = await session.read_output()
if data:
try:
await websocket.send_bytes(data)
except Exception:
break
else:
await asyncio.sleep(0.01)
except Exception:
# Cleanup will be handled by the session manager
pass
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
"""Read input from WebSocket and send to container."""
try:
while True:
session = session_ref.session
if not session.is_alive() or session._closed:
await asyncio.sleep(0.1)
continue
message = await websocket.receive()
if message["type"] == "websocket.receive":
if "bytes" in message:
await session.write_input(message["bytes"])
elif "text" in message:
text = message["text"]
if text.startswith("{"):
# Control message (JSON)
import json
try:
ctrl = json.loads(text)
msg_type = ctrl.get("type")
if msg_type == "resize":
cols = ctrl.get("cols", 80)
rows = ctrl.get("rows", 24)
logger.info(f"Received resize message for instance {instance_id}: {cols}x{rows}")
await session.resize(cols, rows)
elif msg_type == "reset":
# Reset terminal session
logger.info("Resetting terminal session for instance %s", session.instance_id)
await websocket.send_json({"type": "status", "status": "resetting"})
# Reset the session
new_session = await terminal_manager.reset_session(
session.instance_id,
session.container_id,
startup_command=session.startup_command,
)
# Update the mutable session reference so read_loop uses the new session
session_ref.session = new_session
# Attach to new session
await terminal_manager.attach_websocket(new_session, websocket)
await websocket.send_json({"type": "status", "status": "connected"})
# Continue the loop with the new session
continue
except json.JSONDecodeError:
# Not a valid JSON control message, treat as regular input
await session.write_input(text.encode("utf-8"))
else:
await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect":
break
except Exception:
pass
async def _heartbeat_loop(websocket: WebSocket) -> None:
"""Send periodic ping messages to detect disconnections."""
try:
while True:
await asyncio.sleep(30) # Ping every 30 seconds
try:
await websocket.send_json({"type": "ping"})
except Exception:
# WebSocket is closed or broken
break
except Exception:
pass
@router.post(
"/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset",
summary="Reset terminal session",
description="Reset the terminal session for a tool instance, killing the current shell and starting fresh.",
)
async def reset_terminal_session(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Reset the terminal session for an instance.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the tool instance.
db_session: Database session.
Returns:
Dictionary with status message.
"""
# Get instance and verify it exists and is running
instance = await db_session.get(ToolInstance, instance_id)
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance not found"
)
if instance.status != "running" or not instance.container_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Instance is not running"
)
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
try:
# Reset the session
new_session = await terminal_manager.reset_session(
instance_id,
instance.container_id,
startup_command=startup_command,
)
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
return {
"status": "success",
"message": "Terminal session reset successfully",
"instance_id": str(instance_id),
"session_id": new_session.session_id,
}
except Exception as exc:
logger.error("Failed to reset terminal session for instance %s: %s", instance_id, str(exc), exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset terminal session: {exc}"
)
async def _get_user_from_websocket(
websocket: WebSocket,
db_session: AsyncSession,
+37 -5
View File
@@ -8,9 +8,11 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_config import ToolConfig
from src.models.tool_type import ToolType
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
@@ -40,12 +42,27 @@ class ToolConfigCreate(BaseModel):
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
return _validate_env_vars(v)
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:
return _validate_volumes(v)
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):
@@ -71,12 +88,27 @@ class ToolConfigUpdate(BaseModel):
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
return _validate_env_vars(v)
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:
return _validate_volumes(v)
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):
+90 -474
View File
@@ -2,7 +2,6 @@
import logging
import os
import subprocess
import uuid
from datetime import datetime
@@ -15,15 +14,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.auth.dependencies import get_current_user_id
from src.auth.dependencies import get_db_session
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.config_profile import ConfigProfile
from src.models.tool_config import ToolConfig
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models.user import User
from src.models.config_folder import ConfigFolder
from src.services.docker import (
check_tunnel_health,
connect_container_to_network,
@@ -31,27 +30,18 @@ from src.services.docker import (
execute_compose_command,
find_free_port,
get_container_id,
get_container_logs,
get_container_name,
get_container_status,
recreate_tunnel,
render_compose_template,
start_cloudflared_tunnel,
stop_cloudflared_tunnel,
wait_for_container_running,
write_compose_file,
write_config_files,
write_env_file,
write_config_folder_files,
)
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
from src.services.docker_build import build_image
from src.services.config_profile_resolver import (
ConfigProfileCycleError,
apply_resolved_profile,
resolve_profile,
)
from src.services.readiness_probe import execute_probe
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files
router = APIRouter(prefix="/projects", tags=["tool-instances"])
@@ -63,81 +53,6 @@ class CreateInstanceRequest(BaseModel):
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(default=None, description="Optional display name for the instance")
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
config_profile_id: str | None = Field(default=None, description="Optional config profile ID for launch")
class StartInstanceRequest(BaseModel):
"""Request body for starting a tool instance."""
model_config = {"extra": "ignore"}
config_profile_id: str | None = Field(default=None, description="Config profile ID to apply, or null for none")
async def _validate_config_profile(
session: AsyncSession,
profile_id: str | None,
user_id: uuid.UUID,
project_id: uuid.UUID,
tool_type_id: uuid.UUID,
) -> uuid.UUID | None:
"""Validate a config profile selection.
Args:
session: Database session.
profile_id: Profile ID string or None.
user_id: Authenticated user ID.
project_id: Project ID for compatibility check.
tool_type_id: Tool type ID for compatibility check.
Returns:
Validated UUID or None.
Raises:
HTTPException: If profile is not found, not owned, or incompatible.
"""
if profile_id is None:
return None
try:
profile_uuid = uuid.UUID(profile_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid config profile ID: {profile_id}",
)
profile = await session.get(ConfigProfile, profile_uuid)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Config profile not found: {profile_id}",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to use this config profile",
)
# Check compatibility: profile must be portable or match project/tool
is_compatible = (
(profile.project_id is None and profile.tool_type_id is None)
or (profile.project_id == project_id)
or (profile.tool_type_id == tool_type_id)
or (profile.project_id == project_id and profile.tool_type_id == tool_type_id)
)
if not is_compatible:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Selected config profile is not compatible with this project and tool type",
)
return profile_uuid
def _modify_compose_file(
@@ -192,6 +107,40 @@ def _modify_compose_file(
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 404 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="user not found"
)
return user
async def _get_owned_project(
project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None or project.owner_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="project not found"
)
return project
@router.post(
"/{project_id}/repositories/{repo_id}/instances",
summary="Create tool instance",
@@ -240,25 +189,7 @@ async def create_instance(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
)
# Validate config profile if provided
selected_profile_id = await _validate_config_profile(
session, data.config_profile_id, user_id, project_id, tool_type_id
)
try:
# Validate clone mode requirements
if data.clone_mode == "clone":
if not repo.remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository does not have a remote URL for cloning"
)
if not repo.ssh_key_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository must have an SSH key assigned for clone mode"
)
# Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}"
@@ -270,74 +201,6 @@ async def create_instance(
# Find free port
tool_port = find_free_port()
# Determine repo path based on clone mode
if data.clone_mode == "clone":
# Get SSH key for cloning
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="repository SSH key not found"
)
# Prepare SSH key for clone operation
ssh_key_path = None
try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
ssh_key_path = os.path.join(ssh_dir, "id_ed25519")
# Clone repository
clone_path = clone_repository(
remote_url=repo.remote_url,
ssh_key_path=ssh_key_path,
instance_dir=instance_dir,
branch=data.branch or "main",
)
repo_path = clone_path
except Exception as exc:
logger.exception("Failed to clone repository: %s", exc)
cleanup_ssh_key_files(instance_dir)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clone repository: {exc}"
)
else:
repo_path = repo.path
# Verify cloned repo has files
if data.clone_mode == "clone" and repo_path:
try:
repo_contents = os.listdir(repo_path)
if not repo_contents or (len(repo_contents) == 1 and repo_contents[0] == ".git"):
logger.error("Cloned repository at %s appears empty", repo_path)
raise RuntimeError("Cloned repository is empty")
logger.info("Verified cloned repo at %s has %d items", repo_path, len(repo_contents))
except Exception as exc:
logger.exception("Failed to verify cloned repository: %s", exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Cloned repository verification failed: {exc}"
)
# Create new local branch if requested
if data.clone_mode == "clone" and data.new_branch:
try:
result = subprocess.run(
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr)
raise RuntimeError(f"Failed to create branch: {result.stderr}")
logger.info("Created local branch %s in cloned repository", data.new_branch)
except Exception as exc:
logger.exception("Failed to create local branch: %s", exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create local branch: {exc}"
)
# Handle based on definition type
if tool_type.definition_type == "dockerfile":
# Build image from Dockerfile
@@ -369,7 +232,7 @@ services:
ports:
- "{tool_port}:{tool_type.default_port}"
volumes:
- {repo_path}:/workspace
- {repo.path}:/workspace
restart: unless-stopped
"""
write_compose_file(instance_dir, compose_content)
@@ -377,7 +240,7 @@ services:
else:
# Render compose template
variables = {
"REPO_PATH": repo_path,
"REPO_PATH": repo.path,
"INSTANCE_NAME": instance_name,
"INSTANCE_ID": instance_name,
"TOOL_NAME": instance_name,
@@ -386,37 +249,6 @@ services:
"PROJECT_ID": str(project_id),
}
compose_content = render_compose_template(tool_type.compose_template, variables)
# Safety check: for clone mode, ensure repo is mounted in compose file
if data.clone_mode == "clone" and repo_path:
import yaml
compose_data = yaml.safe_load(compose_content)
repo_mounted = False
if compose_data and "services" in compose_data:
for svc in compose_data["services"].values():
volumes = svc.get("volumes", [])
for vol in volumes:
vol_str = str(vol)
if repo_path in vol_str:
repo_mounted = True
break
if repo_mounted:
break
if not repo_mounted:
logger.warning(
"Compose template for tool type %s does not mount repo path; adding default mount",
tool_type.name,
)
# Add default mount to first service
if compose_data and "services" in compose_data:
for svc in compose_data["services"].values():
if "volumes" not in svc:
svc["volumes"] = []
svc["volumes"].append(f"{repo_path}:/workspace")
break
compose_content = yaml.dump(compose_data, default_flow_style=False)
write_compose_file(instance_dir, compose_content)
# Create database record
@@ -430,9 +262,6 @@ services:
status="pending",
compose_path=compose_path,
port=tool_port,
clone_mode=data.clone_mode,
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
selected_config_profile_id=selected_profile_id,
)
session.add(instance)
await session.commit()
@@ -444,9 +273,6 @@ services:
"display_name": instance.display_name,
"tool_type_id": str(instance.tool_type_id),
"status": instance.status,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
"created_at": instance.created_at.isoformat(),
}
except Exception as exc:
@@ -505,12 +331,10 @@ async def list_instances(
"display_name": i.display_name,
"tool_type_id": str(i.tool_type_id),
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
"status": i.status,
"url": i.url,
"port": i.port,
"clone_mode": i.clone_mode,
"branch": i.branch,
"created_at": i.created_at.isoformat(),
})
@@ -571,9 +395,6 @@ async def get_instance(
"compose_path": instance.compose_path,
"url": instance.url,
"port": instance.port,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
"created_at": instance.created_at.isoformat(),
@@ -589,7 +410,6 @@ async def start_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
data: StartInstanceRequest | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
@@ -599,7 +419,6 @@ async def start_instance(
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the instance to start.
data: Optional start configuration including config profile selection.
user_id: ID of the authenticated user.
session: Database session.
@@ -615,14 +434,6 @@ async def start_instance(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
# Validate and store config profile selection
if data and data.config_profile_id is not None:
selected_profile_id = await _validate_config_profile(
session, data.config_profile_id, user_id, project_id, instance.tool_type_id
)
instance.selected_config_profile_id = selected_profile_id
await session.commit()
if not instance.compose_path or not os.path.exists(instance.compose_path):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
@@ -673,45 +484,17 @@ async def start_instance(
# Merge extra env vars
env_vars.update(extra_env_vars)
# Apply selected config profile if any
instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None:
try:
resolved = await resolve_profile(session, instance.selected_config_profile_id)
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile(
instance_dir, resolved
)
# Profile env vars override tool config env vars
env_vars.update(profile_env)
# Profile files are written by apply_resolved_profile
config_files.update(profile_files)
# Profile mounts are added to extra volumes
extra_volumes.extend(profile_mounts)
# Profile runtime hints override tool config values
if profile_hints.get("start_command"):
start_command = profile_hints["start_command"]
if profile_hints.get("working_directory"):
working_directory = profile_hints["working_directory"]
if profile_hints.get("port_override"):
port_override = profile_hints["port_override"]
logger.info(
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d)",
resolved.profile_name,
instance.id,
len(profile_env),
len(profile_files),
len(profile_mounts),
)
except ConfigProfileCycleError as exc:
logger.error("Cycle detected in config profile for instance %s: %s", instance.id, exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Config profile cycle detected: {exc}",
)
else:
logger.info("No config profile selected for instance %s", instance.id)
# 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
instance_dir = os.path.dirname(instance.compose_path)
env_file_path = None
if env_vars:
@@ -722,22 +505,11 @@ async def start_instance(
write_config_files(instance_dir, config_files)
logger.info("Wrote %d config files for instance %s", len(config_files), instance.id)
# Mount SSH key for clone-mode instances
if instance.clone_mode == "clone":
repo = await session.get(GitRepository, instance.repository_id)
if repo and repo.ssh_key_id:
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key:
try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
extra_volumes.append({
"source": ssh_dir,
"target": "/root/.ssh",
"type": "ro",
})
logger.info("Mounted SSH key for clone-mode instance %s", instance.id)
except Exception as exc:
logger.error("Failed to prepare SSH key for instance %s: %s", instance.id, exc)
# 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:
@@ -780,67 +552,20 @@ async def start_instance(
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"],
)
instance.status = "starting"
instance.last_started_at = datetime.now()
await session.commit()
logger.info("Instance %s container is running, checking readiness", instance.id)
# 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 and 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)
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 tool_type.interface_type == "web":
# 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()
if probe_command and instance.container_id:
logger.info(
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
instance.id, probe_command, probe_timeout, probe_interval
@@ -853,25 +578,14 @@ async def start_instance(
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"
instance.status = "failed"
instance.url = None
instance.public_url = None
await session.commit()
logger.error(
"Readiness probe failed for instance %s after %ds: %s",
instance.id,
probe_timeout,
"\n".join(probe_logs),
)
logger.error("Readiness probe failed for instance %s: %s", instance.id, "\n".join(probe_logs))
return {
"status": "unhealthy",
"status": "failed",
"error": f"Readiness probe failed after {probe_timeout}s",
"probe_logs": probe_logs,
}
@@ -884,21 +598,22 @@ async def start_instance(
# Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id)
if not tool_type:
logger.error("Tool type %s not found", 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 '{instance.tool_type_id}' not found",
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
}
instance_port = tool_type.default_port or 0
logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
instance.id, tool_type.name, instance_port, tool_type.interface_type)
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 tool_type.interface_type == "web":
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)",
@@ -1039,30 +754,7 @@ async def restart_instance(
except Exception as exc:
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
# Re-apply stored config profile on restart
if instance.compose_path and os.path.exists(instance.compose_path):
instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None:
try:
resolved = await resolve_profile(session, instance.selected_config_profile_id)
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile(
instance_dir, resolved
)
# Write env file with resolved profile env vars
if profile_env:
write_env_file(instance_dir, profile_env)
logger.info(
"Re-applied config profile %s on restart for instance %s",
resolved.profile_name,
instance.id,
)
except ConfigProfileCycleError as exc:
logger.error(
"Cycle detected in stored config profile for instance %s: %s",
instance.id,
exc,
)
returncode, stdout, stderr = execute_compose_command(
instance.compose_path, "restart"
)
@@ -1086,7 +778,7 @@ async def restart_instance(
instance_port = tool_type.default_port
# Only create tunnel for web-enabled tools
if tool_type.interface_type == "web":
if "web" in tool_type.interfaces:
# Create new temporary tunnel
try:
tunnel_info = start_cloudflared_tunnel(
@@ -1136,7 +828,6 @@ async def delete_instance(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
force: bool = False,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
@@ -1161,23 +852,6 @@ async def delete_instance(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
# Check dirty state for clone-mode instances
if instance.clone_mode == "clone" and not force:
instance_dir = os.path.dirname(instance.compose_path) if instance.compose_path else None
if instance_dir:
clone_path = os.path.join(instance_dir, "repo-clone")
if os.path.exists(clone_path):
is_dirty, changed_files = check_dirty_state(clone_path)
if is_dirty:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Repository has uncommitted changes",
"changed_files": changed_files,
"force_required": True,
},
)
# Stop Cloudflare tunnel if exists
if instance.tunnel_id:
try:
@@ -1190,7 +864,7 @@ async def delete_instance(
if instance.compose_path and os.path.exists(instance.compose_path):
execute_compose_command(instance.compose_path, "down")
# Remove instance directory (includes clone and SSH keys)
# Remove instance directory
if instance.compose_path:
instance_dir = os.path.dirname(instance.compose_path)
if os.path.exists(instance_dir):
@@ -1282,17 +956,6 @@ async def recreate_tunnel_endpoint(
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
@@ -1324,8 +987,8 @@ async def recreate_tunnel_endpoint(
@router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
summary="Check instance health",
description="Check container and tunnel health for an instance.",
summary="Check tunnel health",
description="Check if the temporary Cloudflare tunnel for an instance is healthy.",
)
async def check_instance_tunnel_health(
project_id: uuid.UUID,
@@ -1334,7 +997,7 @@ async def check_instance_tunnel_health(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Check health for an instance (container + tunnel).
"""Check tunnel health for an instance.
Args:
project_id: UUID of the project.
@@ -1344,7 +1007,7 @@ async def check_instance_tunnel_health(
session: Database session.
Returns:
Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag.
Dictionary with health status.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -1355,54 +1018,11 @@ async def check_instance_tunnel_health(
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)
if not instance.url or instance.status != "running":
return {"healthy": False, "status_code": None, "error": "instance not running"}
# 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", []))
# 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: web tools need running container + healthy tunnel;
# terminal tools only need running container
container_healthy = container_info["status"] == "running"
if instance.url:
tunnel_healthy = response["tunnel_status"] == "healthy"
response["healthy"] = container_healthy and tunnel_healthy
else:
response["healthy"] = container_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
health = check_tunnel_health(instance.url)
return health
@router.get(
@@ -1578,17 +1198,13 @@ async def get_user_sessions(
"display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_icon": tool_type.name if tool_type else "code",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
"repository_name": repo.name if repo else "unknown",
"repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown",
"project_id": str(instance.project_id),
"status": instance.status,
"url": instance.url,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
"created_at": instance.created_at.isoformat() if instance.created_at else None,
})
return {"sessions": sessions}
+142 -77
View File
@@ -7,19 +7,21 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.tool_types_validation import (
check_port_exposed,
sanitize_template_vars,
validate_compose_yaml,
validate_required_variables,
)
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_type import ToolType
from src.models.user import User
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _require_admin(user: User) -> None:
"""Check if user has admin privileges.
@@ -35,17 +37,15 @@ class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
default_port: int = 0
default_port: int
definition_type: str = "compose"
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] = []
category: str = "other"
interface_type: str = "web"
requires_port: bool = True
interfaces: list[str] = ["web"]
@field_validator("definition_type")
@classmethod
@@ -64,7 +64,20 @@ class ToolTypeCreate(BaseModel):
if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
validate_compose_yaml(v)
try:
parsed = yaml.safe_load(v)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
return v
@field_validator("dockerfile_template")
@@ -82,22 +95,48 @@ class ToolTypeCreate(BaseModel):
return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str) -> str:
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("default_port")
@classmethod
def validate_default_port(cls, v: int, info) -> int:
data = info.data
requires_port = data.get("requires_port", True)
if not requires_port:
return v
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")
@@ -127,17 +166,6 @@ class ToolTypeCreate(BaseModel):
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'")
# Validate that default_port is exposed in compose template (only if requires_port)
if self.requires_port and self.definition_type == "compose" and self.compose_template:
try:
parsed = validate_compose_yaml(self.compose_template)
except ValueError:
return self
if not check_port_exposed(parsed, self.default_port):
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
return self
@@ -150,11 +178,9 @@ class ToolTypeUpdate(BaseModel):
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] | None = None
category: str | None = None
interface_type: str | None = None
requires_port: bool | None = None
interfaces: list[str] | None = None
@field_validator("definition_type")
@classmethod
@@ -165,27 +191,31 @@ class ToolTypeUpdate(BaseModel):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_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 != "compose":
return v
validate_compose_yaml(v)
try:
parsed = yaml.safe_load(v)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
return v
@field_validator("dockerfile_template")
@@ -213,16 +243,15 @@ class ToolTypeResponse(BaseModel):
display_name: str
description: str | None
category: str
interface_type: str
requires_port: bool
interfaces: list[str]
default_port: int
definition_type: str
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
startup_command: str | None
required_variables: list[str]
is_builtin: bool
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@@ -268,11 +297,10 @@ async def create_tool_type(
dockerfile_template=data.dockerfile_template,
build_context=data.build_context,
readiness_probe=data.readiness_probe,
startup_command=data.startup_command,
required_variables=data.required_variables,
category=data.category,
interface_type=data.interface_type,
requires_port=data.requires_port,
interfaces=data.interfaces,
is_builtin=False,
created_by_id=user.id,
)
session.add(tool_type)
@@ -363,13 +391,13 @@ async def update_tool_type(
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
# Built-in tool types can now be modified
if tool_type.is_builtin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
update_data = data.model_dump(exclude_unset=True)
# Validate port if being updated
requires_port = update_data.get("requires_port", tool_type.requires_port)
if "default_port" in update_data and requires_port:
if "default_port" in update_data:
new_port = update_data["default_port"]
if new_port <= 0 or new_port > 65535:
raise HTTPException(
@@ -383,29 +411,53 @@ async def update_tool_type(
template = update_data.get("compose_template", tool_type.compose_template)
if template:
try:
parsed = validate_compose_yaml(template)
if not check_port_exposed(parsed, new_port):
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"
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# 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:
validate_required_variables(
update_data["compose_template"], update_data["required_variables"]
)
template = update_data["compose_template"]
for var in update_data["required_variables"]:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
)
elif "required_variables" in update_data:
template = tool_type.compose_template
if template:
validate_required_variables(template, update_data["required_variables"])
for var in update_data["required_variables"]:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
)
for field, value in update_data.items():
setattr(tool_type, field, value)
@@ -450,9 +502,15 @@ async def validate_tool_type_template(
errors.append("Compose template is required")
else:
try:
validate_compose_yaml(data.compose_template)
except ValueError as e:
errors.append(str(e))
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:
@@ -501,9 +559,15 @@ async def validate_tool_type(
errors.append("Compose template is empty")
else:
try:
validate_compose_yaml(tool_type.compose_template)
except ValueError as e:
errors.append(str(e))
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:
@@ -545,7 +609,8 @@ async def delete_tool_type(
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
# Built-in tool types can now be deleted
if tool_type.is_builtin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
await session.delete(tool_type)
await session.commit()
-87
View File
@@ -1,87 +0,0 @@
"""Shared validation utilities for tool types."""
import re
import yaml
from fastapi import HTTPException, status
def sanitize_template_vars(template: str) -> str:
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
def validate_compose_yaml(template: str) -> dict:
"""Validate and parse a compose template.
Args:
template: Raw compose template string.
Returns:
Parsed YAML dict.
Raises:
ValueError: If YAML is invalid or missing required keys.
"""
sanitized = sanitize_template_vars(template)
try:
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
return parsed
def check_port_exposed(parsed: dict, port: int) -> bool:
"""Check if a port is exposed in a parsed compose template.
Args:
parsed: Parsed compose YAML dict.
port: Port number to check.
Returns:
True if port is exposed in any service.
"""
port_str = str(port)
if not isinstance(parsed, dict) or "services" not in parsed:
return 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:
return True
elif isinstance(port_mapping, int) and port_mapping == port:
return True
return False
def validate_required_variables(template: str, variables: list[str]) -> None:
"""Validate that all required variables exist in the template.
Args:
template: Compose template string.
variables: List of required variable names.
Raises:
HTTPException: If any variable is not found in the template.
"""
for var in variables:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template",
)
+9 -1
View File
@@ -8,13 +8,21 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.user import User
from src.models.user_config import UserConfig
router = APIRouter(prefix="/users/me", tags=["user-config"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
"""Get or create user config record.
+9 -1
View File
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.user import User
router = APIRouter(prefix="/users", tags=["users"])
@@ -16,6 +16,14 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
-36
View File
@@ -47,39 +47,3 @@ async def get_current_user(
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> "Project":
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: 404 if project not found, 403 if user is not the owner.
"""
from src.models.project import Project
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
+3 -3
View File
@@ -2,7 +2,7 @@ import hmac
import hashlib
import json
import base64
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import Any
from src.config import Settings
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
"""Create a signed session cookie value."""
payload = {
"user_id": user_id,
"exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
}
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
payload = json.loads(payload_bytes)
# Check expiry
if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
raise ValueError("session expired")
return payload
+154 -4
View File
@@ -7,7 +7,7 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy import select, text
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
@@ -18,7 +18,6 @@ from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_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.config_profiles import router as config_profiles_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 sessions_router
@@ -26,12 +25,13 @@ from src.api.tool_types import router as tool_types_router
from src.api.user_config import router as user_config_router
from src.api.users import router as users_router
from src.config import Settings
from src.database import init_database
from src.database import SessionLocal, init_database
from src.logging_config import (
ExceptionLoggingMiddleware,
RequestLoggingMiddleware,
configure_logging,
)
from src.models.tool_type import ToolType
# Configure logging early
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -103,6 +103,155 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
)
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
try:
result = await session.execute(
text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
async def seed_builtin_tool_types():
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
builtin_types = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interfaces": ["web"],
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: unless-stopped""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interfaces": ["web"],
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: unless-stopped""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interfaces": ["terminal"],
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /workspace
environment:
- HOME=/tmp
volumes:
- {{REPO_PATH}}:/workspace
- opencode_home:/tmp
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
echo 'cd /workspace' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
cd /workspace &&
exec tail -f /dev/null"
stdin_open: true
tty: true
restart: unless-stopped
volumes:
opencode_home:""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
for tool_data in builtin_types:
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
if not existing:
tool_type = ToolType(
name=tool_data["name"],
display_name=tool_data["display_name"],
description=tool_data["description"],
category=tool_data["category"],
interfaces=tool_data["interfaces"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
default_port=tool_data.get("default_port"),
is_builtin=True,
)
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()
logger.info("Built-in tool types seeded successfully.")
@app.on_event("startup")
async def on_startup():
logger.info("Starting up Headquarter API...")
@@ -114,6 +263,8 @@ async def on_startup():
import sys
sys.exit(1)
# Seed built-in data
await seed_builtin_tool_types()
logger.info("Startup complete.")
app.include_router(health_router)
@@ -126,7 +277,6 @@ app.include_router(git_repositories_router)
app.include_router(user_config_router)
app.include_router(tool_types_router)
app.include_router(config_folders_router)
app.include_router(config_profiles_router)
app.include_router(tool_instances_router)
app.include_router(tool_configs_router)
app.include_router(sessions_router)
+17 -2
View File
@@ -1,6 +1,8 @@
from src.models.base import Base
from src.models.config_folder import ConfigFolder
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
@@ -9,4 +11,17 @@ from src.models.tool_type import ToolType
from src.models.user import User
from src.models.user_config import UserConfig
__all__ = ["Base", "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
__all__ = [
"Base",
"ConfigFolder",
"ConfigInclude",
"ConfigMount",
"ConfigProfile",
"GitRepository",
"Project",
"SSHKey",
"ToolInstance",
"ToolType",
"User",
"UserConfig",
]
+2
View File
@@ -26,6 +26,8 @@ class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
project_overrides: Mapped[dict | None] = mapped_column(
JSON, default=dict, nullable=True
) # {"project_id": {"mount_path": "...", "files": {...}}}
# DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time
# auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
user: Mapped["User"] = relationship()
+36
View File
@@ -0,0 +1,36 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
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.config_profile import ConfigProfile
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_includes"
__table_args__ = (
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
)
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
included_profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="includes",
)
included_profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[included_profile_id],
)
+35
View File
@@ -0,0 +1,35 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, 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.config_profile import ConfigProfile
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_mounts"
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
content: Mapped[str | None] = mapped_column(Text, nullable=True)
source_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="mounts",
)
source_profile: Mapped["ConfigProfile | None"] = relationship(
"ConfigProfile",
foreign_keys=[source_profile_id],
)
+14 -49
View File
@@ -1,74 +1,39 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
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.project import Project
from src.models.tool_type import ToolType
from src.models.user import User
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profiles"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
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)
project_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
)
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
)
env_vars: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"VAR_NAME": "value", ...}
runtime_hints: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"start_command": "...", "working_dir": "...", ...}
mounts: Mapped[list] = mapped_column(
JSON, default=list, nullable=False
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
files: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"rel/path": "content", ...}
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship()
project: Mapped["Project | None"] = relationship()
tool_type: Mapped["ToolType | None"] = relationship()
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
"ConfigProfileInclude",
foreign_keys="ConfigProfileInclude.profile_id",
order_by="ConfigProfileInclude.order_index",
includes: Mapped[list["ConfigInclude"]] = relationship(
"ConfigInclude",
foreign_keys="ConfigInclude.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigInclude.order_index",
)
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profile_includes"
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
included_profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="includes",
)
included_profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[included_profile_id],
mounts: Mapped[list["ConfigMount"]] = relationship(
"ConfigMount",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigMount.order_index",
)
-5
View File
@@ -10,7 +10,6 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
@@ -24,10 +23,6 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("ssh_keys.id"), nullable=True
)
project: Mapped["Project"] = relationship(back_populates="repositories")
owner: Mapped["User"] = relationship()
ssh_key: Mapped["SSHKey | None"] = relationship()
+3 -12
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -63,16 +63,7 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
last_stopped_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
probe_result: Mapped[dict | None] = mapped_column(
JSON, nullable=True
)
clone_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="mount"
)
branch: Mapped[str | None] = mapped_column(
String(255), nullable=True, default="main"
)
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
)
@@ -80,4 +71,4 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
repository: Mapped["GitRepository"] = relationship()
project: Mapped["Project"] = relationship()
owner: Mapped["User"] = relationship()
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
selected_profile: Mapped["ConfigProfile | None"] = relationship()
+2 -3
View File
@@ -18,8 +18,7 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
default_port: Mapped[int] = mapped_column(nullable=False)
definition_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="compose"
@@ -30,8 +29,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
JSON, default=dict, nullable=True
)
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(),
ForeignKey("users.id"),
+20
View File
@@ -18,3 +18,23 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
user: Mapped["User"] = relationship(back_populates="user_config")
@property
def default_profile_id(self) -> uuid.UUID | None:
profile_id = self.config.get("default_profile_id")
return uuid.UUID(profile_id) if profile_id else None
@default_profile_id.setter
def default_profile_id(self, value: uuid.UUID | None) -> None:
if value is not None:
self.config["default_profile_id"] = str(value)
elif "default_profile_id" in self.config:
del self.config["default_profile_id"]
@property
def default_profiles(self) -> dict[str, str]:
return self.config.get("default_profiles", {})
@default_profiles.setter
def default_profiles(self, value: dict[str, str]) -> None:
self.config["default_profiles"] = value
-97
View File
@@ -1,97 +0,0 @@
"""Clone service for repository cloning and dirty state checking."""
import logging
import os
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
def clone_repository(
remote_url: str,
ssh_key_path: str | None,
instance_dir: str,
branch: str = "main",
) -> str:
"""Clone a git repository into the instance directory.
Args:
remote_url: Git remote URL (SSH or HTTPS)
ssh_key_path: Path to SSH private key for authentication (optional)
instance_dir: Path to instance directory
branch: Branch to clone (default: main)
Returns:
Path to the cloned repository
"""
clone_path = Path(instance_dir) / "repo-clone"
clone_path.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
if ssh_key_path:
# Use SSH key for cloning
env["GIT_SSH_COMMAND"] = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
cmd = [
"git",
"clone",
"--branch", branch,
"--single-branch",
remote_url,
str(clone_path),
]
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=300,
)
if result.returncode != 0:
logger.error("Git clone failed: %s", result.stderr)
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
logger.info("Successfully cloned repository into %s", clone_path)
return str(clone_path)
def check_dirty_state(clone_path: str) -> tuple[bool, list[str]]:
"""Check for uncommitted changes in a cloned repository.
Args:
clone_path: Path to the cloned repository
Returns:
Tuple of (is_dirty, list_of_changed_files)
"""
result = subprocess.run(
["git", "-C", clone_path, "status", "--short"],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.warning("Failed to check git status: %s", result.stderr)
return False, []
changed_files = [line.strip() for line in result.stdout.split("\n") if line.strip()]
is_dirty = len(changed_files) > 0
return is_dirty, changed_files
def remove_clone_directory(instance_dir: str) -> None:
"""Remove the cloned repository from the instance directory.
Args:
instance_dir: Path to instance directory
"""
clone_path = Path(instance_dir) / "repo-clone"
if clone_path.exists():
import shutil
shutil.rmtree(clone_path)
logger.info("Removed clone directory: %s", clone_path)
@@ -1,453 +0,0 @@
"""Config profile resolver service.
Provides recursive ordered include resolution with deterministic merge rules
and cycle protection.
"""
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
logger = logging.getLogger(__name__)
class ConfigProfileCycleError(Exception):
"""Raised when a cycle is detected in profile includes."""
pass
class ConfigProfileNotFoundError(Exception):
"""Raised when a referenced profile is not found."""
pass
@dataclass
class ResolvedMount:
"""A resolved mount with merged files and final mode."""
target: str
mode: str
files: dict[str, str] = field(default_factory=dict)
overridden_files: dict[str, str] = field(default_factory=dict)
@dataclass
class ResolvedProfile:
"""The fully resolved output of a config profile."""
profile_id: uuid.UUID
profile_name: str
env_vars: dict[str, str] = field(default_factory=dict)
runtime_hints: dict[str, Any] = field(default_factory=dict)
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
files: dict[str, str] = field(default_factory=dict)
env_overrides: dict[str, str] = field(default_factory=dict)
hint_overrides: dict[str, str] = field(default_factory=dict)
file_overrides: dict[str, str] = field(default_factory=dict)
mount_overrides: dict[str, str] = field(default_factory=dict)
included_profiles: list[dict[str, Any]] = field(default_factory=list)
def _detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) -> bool:
"""Detect if adding profile_id to path would create a cycle.
Args:
profile_id: The profile ID to check.
visited: Set of already-visited profile IDs in current resolution.
path: Current resolution path for error reporting.
Returns:
True if a cycle would be created.
"""
if profile_id in visited:
return True
return False
def _merge_env_vars(
base: dict[str, str],
overlay: dict[str, str],
overrides: dict[str, str],
source_name: str,
) -> dict[str, str]:
"""Merge env vars, tracking overrides.
Later values replace earlier values.
"""
result = dict(base)
for key, value in overlay.items():
if key in result and result[key] != value:
overrides[key] = source_name
result[key] = value
return result
def _merge_runtime_hints(
base: dict[str, Any],
overlay: dict[str, Any],
overrides: dict[str, str],
source_name: str,
) -> dict[str, Any]:
"""Merge runtime hints, tracking overrides.
Later values replace earlier values.
"""
result = dict(base)
for key, value in overlay.items():
if key in result and result[key] != value:
overrides[key] = source_name
result[key] = value
return result
def _merge_files(
base: dict[str, str],
overlay: dict[str, str],
overrides: dict[str, str],
source_name: str,
) -> dict[str, str]:
"""Merge file maps, tracking overrides.
Later relative file paths win.
"""
result = dict(base)
for path, content in overlay.items():
if path in result and result[path] != content:
overrides[path] = source_name
result[path] = content
return result
def _merge_mounts(
base: dict[str, ResolvedMount],
overlay: list[dict[str, Any]],
overrides: dict[str, str],
source_name: str,
) -> dict[str, ResolvedMount]:
"""Merge mounts, tracking overrides.
Mounts with the same target path have their file maps merged and later
relative file paths win. Mode conflicts: later layer wins.
"""
result = dict(base)
for mount_data in overlay:
target = mount_data["target"]
mode = mount_data.get("mode", "rw")
files = mount_data.get("files", {})
if target in result:
existing = result[target]
merged_files = dict(existing.files)
file_overrides = dict(existing.overridden_files)
for rel_path, content in files.items():
if rel_path in merged_files and merged_files[rel_path] != content:
file_overrides[rel_path] = source_name
merged_files[rel_path] = content
if existing.mode != mode:
overrides[target] = source_name
result[target] = ResolvedMount(
target=target,
mode=mode,
files=merged_files,
overridden_files=file_overrides,
)
else:
result[target] = ResolvedMount(
target=target,
mode=mode,
files=dict(files),
)
return result
async def _resolve_profile_recursive(
session: AsyncSession,
profile_id: uuid.UUID,
visited: set[uuid.UUID],
path: list[uuid.UUID],
) -> ResolvedProfile:
"""Recursively resolve a profile and its includes.
Args:
session: Database session.
profile_id: Profile ID to resolve.
visited: Set of already-visited profile IDs in current resolution chain.
path: Current resolution path for error reporting.
Returns:
ResolvedProfile with all includes merged.
Raises:
ConfigProfileCycleError: If a cycle is detected.
ConfigProfileNotFoundError: If the profile is not found.
"""
if _detect_cycle(profile_id, visited, path):
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
raise ConfigProfileCycleError(f"Cycle detected in profile includes: {cycle_path}")
profile = await session.get(ConfigProfile, profile_id)
if profile is None:
raise ConfigProfileNotFoundError(f"Config profile not found: {profile_id}")
new_visited = visited | {profile_id}
new_path = path + [profile_id]
result = ResolvedProfile(
profile_id=profile.id,
profile_name=profile.name,
)
# Resolve includes in order
include_query = (
select(ConfigProfileInclude)
.where(ConfigProfileInclude.profile_id == profile_id)
.order_by(ConfigProfileInclude.order_index)
)
include_result = await session.execute(include_query)
includes = include_result.scalars().all()
for include in includes:
included = await _resolve_profile_recursive(
session, include.included_profile_id, new_visited, new_path
)
result.included_profiles.append({
"id": str(included.profile_id),
"name": included.profile_name,
})
result.env_vars = _merge_env_vars(
result.env_vars, included.env_vars, result.env_overrides, included.profile_name
)
result.runtime_hints = _merge_runtime_hints(
result.runtime_hints,
included.runtime_hints,
result.hint_overrides,
included.profile_name,
)
result.files = _merge_files(
result.files, included.files, result.file_overrides, included.profile_name
)
result.mounts = _merge_mounts(
result.mounts,
[
{"target": m.target, "mode": m.mode, "files": m.files}
for m in included.mounts.values()
],
result.mount_overrides,
included.profile_name,
)
# Apply the profile's own settings (selected profile overrides includes)
result.env_vars = _merge_env_vars(
result.env_vars,
profile.env_vars or {},
result.env_overrides,
profile.name,
)
result.runtime_hints = _merge_runtime_hints(
result.runtime_hints,
profile.runtime_hints or {},
result.hint_overrides,
profile.name,
)
result.files = _merge_files(
result.files,
profile.files or {},
result.file_overrides,
profile.name,
)
result.mounts = _merge_mounts(
result.mounts,
profile.mounts or [],
result.mount_overrides,
profile.name,
)
return result
async def resolve_profile(
session: AsyncSession,
profile_id: uuid.UUID,
) -> ResolvedProfile:
"""Resolve a config profile with all includes.
Args:
session: Database session.
profile_id: Profile ID to resolve.
Returns:
ResolvedProfile with merged env vars, runtime hints, mounts, and files.
Raises:
ConfigProfileCycleError: If a cycle is detected in includes.
ConfigProfileNotFoundError: If the profile is not found.
"""
return await _resolve_profile_recursive(session, profile_id, set(), [])
async def check_include_cycle(
session: AsyncSession,
profile_id: uuid.UUID,
new_include_id: uuid.UUID | None = None,
) -> list[uuid.UUID] | None:
"""Check if adding an include would create a cycle.
Used at save time to validate include relationships before persisting.
Args:
session: Database session.
profile_id: The profile that would receive the new include.
new_include_id: Optional new profile to include. If None, checks existing includes.
Returns:
The cycle path as a list of UUIDs if a cycle exists, otherwise None.
"""
async def _check_from(
current_id: uuid.UUID,
target_id: uuid.UUID,
visited: set[uuid.UUID],
path: list[uuid.UUID],
) -> list[uuid.UUID] | None:
if current_id in visited:
if current_id == target_id:
return path + [current_id]
return None
if current_id == target_id and path:
return path + [current_id]
new_visited = visited | {current_id}
new_path = path + [current_id]
include_query = (
select(ConfigProfileInclude)
.where(ConfigProfileInclude.profile_id == current_id)
.order_by(ConfigProfileInclude.order_index)
)
include_result = await session.execute(include_query)
includes = include_result.scalars().all()
for include in includes:
cycle = await _check_from(
include.included_profile_id, target_id, new_visited, new_path
)
if cycle is not None:
return cycle
return None
# Check if new_include_id can reach profile_id (would create cycle)
if new_include_id is not None:
cycle = await _check_from(new_include_id, profile_id, set(), [])
if cycle is not None:
return cycle
# Also check existing includes for cycles
cycle = await _check_from(profile_id, profile_id, set(), [])
if cycle is not None and len(cycle) > 1:
return cycle
return None
def apply_resolved_profile(
instance_dir: str,
resolved: ResolvedProfile,
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
"""Apply a resolved profile to an instance directory.
Stages files, writes env vars, and prepares mount volumes.
Args:
instance_dir: Path to the instance directory.
resolved: The resolved profile.
Returns:
Tuple of (env_vars, files, volume_mounts, runtime_hints).
env_vars: Merged environment variables.
files: Relative file paths to content for the instance.
volume_mounts: List of Docker volume mount dicts.
runtime_hints: Extracted runtime hints.
"""
from pathlib import Path
instance_path = Path(instance_dir)
env_vars = dict(resolved.env_vars)
files = dict(resolved.files)
volume_mounts = []
# Write profile files to instance directory
for file_path, content in files.items():
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
logger.warning("Profile file path escapes instance directory: %s", file_path)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
# Stage mount files and prepare volume mounts
for mount in resolved.mounts.values():
mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_")
mount_dir.mkdir(parents=True, exist_ok=True)
for file_path, content in mount.files.items():
full_path = mount_dir / file_path
try:
full_path.resolve().relative_to(mount_dir.resolve())
except ValueError:
logger.warning("Mount file path escapes mount directory: %s", file_path)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
volume_mounts.append({
"source": str(mount_dir),
"target": mount.target,
"type": "bind",
})
return env_vars, files, volume_mounts, resolved.runtime_hints
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
"""Convert a ResolvedProfile to a plain dict for serialization.
Args:
resolved: The resolved profile.
Returns:
Dict with env_vars, runtime_hints, mounts, files, and metadata.
"""
return {
"profile_id": str(resolved.profile_id),
"profile_name": resolved.profile_name,
"env_vars": resolved.env_vars,
"runtime_hints": resolved.runtime_hints,
"mounts": [
{
"target": m.target,
"mode": m.mode,
"files": m.files,
"overridden_files": m.overridden_files,
}
for m in resolved.mounts.values()
],
"files": resolved.files,
"overrides": {
"env_vars": resolved.env_overrides,
"runtime_hints": resolved.hint_overrides,
"files": resolved.file_overrides,
"mounts": resolved.mount_overrides,
},
"included_profiles": resolved.included_profiles,
}
+65 -119
View File
@@ -92,6 +92,59 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
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(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
@@ -191,94 +244,24 @@ def connect_container_to_network(container_name: str, network_name: str = "backe
return result.returncode == 0
def get_container_status(container_id: str) -> dict[str, Any]:
def get_container_status(container_id: str) -> str:
"""Get the status of a Docker container.
Args:
container_id: Docker container ID
Returns:
Dict with 'status' (running, exited, restarting, not_found),
'exit_code' (int or None), and 'health' (health status or None)
Container status string (running, exited, etc.)
"""
result = subprocess.run(
[
"docker", "inspect", "-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id,
],
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
capture_output=True,
text=True,
)
if result.returncode != 0:
return {"status": "not_found", "exit_code": None, "health": None}
parts = result.stdout.strip().split("|")
status = parts[0] if parts else "unknown"
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
return {"status": status, "exit_code": exit_code, "health": health}
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,
}
if result.returncode == 0:
return result.stdout.strip()
return "unknown"
def get_container_logs(container_id: str, tail: int = 100) -> str:
@@ -441,15 +424,14 @@ def recreate_tunnel(
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy with smart error classification.
"""Check if a tunnel URL is healthy.
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)
Dict with 'healthy' (bool) and 'status_code' (int or None)
"""
import subprocess
@@ -462,49 +444,13 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
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",
"healthy": 200 <= status_code < 400,
"status_code": status_code,
}
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}",
}
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"status_code": None,
"error": str(e),
}
-73
View File
@@ -1,73 +0,0 @@
"""SSH key service utilities for preparing keys for container use."""
import os
from pathlib import Path
from cryptography.fernet import Fernet
from src.config import Settings
def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
import base64
import hashlib
settings = Settings()
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
key = base64.urlsafe_b64encode(key_bytes)
return Fernet(key)
def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str:
"""Decrypt and write SSH key files to instance directory for container mounting.
Args:
instance_dir: Path to instance directory
ssh_key: SSHKey model instance with encrypted private key
Returns:
Path to the .ssh directory
"""
ssh_dir = Path(instance_dir) / ".ssh"
ssh_dir.mkdir(parents=True, exist_ok=True)
# Decrypt private key
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
# Write private key with restricted permissions
private_key_path = ssh_dir / "id_ed25519"
private_key_path.write_text(private_key)
os.chmod(private_key_path, 0o600)
# Write public key
public_key_path = ssh_dir / "id_ed25519.pub"
public_key_path.write_text(ssh_key.public_key)
os.chmod(public_key_path, 0o644)
# Write SSH config
config_path = ssh_dir / "config"
config_content = """Host *
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
"""
config_path.write_text(config_content)
os.chmod(config_path, 0o644)
return str(ssh_dir)
def cleanup_ssh_key_files(instance_dir: str) -> None:
"""Remove temporary SSH key files from instance directory.
Args:
instance_dir: Path to instance directory
"""
ssh_dir = Path(instance_dir) / ".ssh"
if ssh_dir.exists():
for file_path in ssh_dir.iterdir():
file_path.unlink()
ssh_dir.rmdir()
+65 -131
View File
@@ -1,7 +1,6 @@
"""Terminal session manager for WebSocket connections."""
import asyncio
import logging
import uuid
from typing import Any
@@ -9,143 +8,81 @@ from fastapi import WebSocket
from src.services.terminal_session import TerminalSession
logger = logging.getLogger(__name__)
class TerminalManager:
"""Manages active terminal sessions with persistence support."""
"""Manages active terminal sessions."""
def __init__(self) -> None:
# Track sessions by instance_id for persistence
self._sessions: dict[str, TerminalSession] = {}
self._idle_check_task: asyncio.Task | None = None
self._start_idle_check()
def _start_idle_check(self) -> None:
"""Start the idle timeout background task."""
if self._idle_check_task is not None and not self._idle_check_task.done():
return
async def create_session(
self,
instance_id: uuid.UUID,
container_id: str,
websocket: WebSocket,
) -> TerminalSession:
"""Create a new terminal session."""
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id)
await session.start()
self._sessions[session_id] = session
# Start background tasks for I/O streaming
asyncio.create_task(self._read_loop(session, websocket))
asyncio.create_task(self._write_loop(session, websocket))
return session
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
loop = asyncio.get_running_loop()
self._idle_check_task = loop.create_task(self._idle_check_loop())
except RuntimeError:
# No event loop running yet, will be started lazily
while session.is_alive() and not session._closed:
data = await session.read_output()
if data:
await websocket.send_bytes(data)
else:
await asyncio.sleep(0.01)
except Exception:
pass
finally:
await self._cleanup_session(session)
async def _idle_check_loop(self) -> None:
"""Periodically check for idle sessions and clean them up."""
while True:
try:
await asyncio.sleep(60) # Check every minute
await self._cleanup_idle_sessions()
except Exception as exc:
logger.error("Error in idle check loop: %s", exc)
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
"""Read input from WebSocket and send to container."""
try:
while session.is_alive() and not session._closed:
message = await websocket.receive()
if message["type"] == "websocket.receive":
if "bytes" in message:
await session.write_input(message["bytes"])
elif "text" in message:
text = message["text"]
if text.startswith("{"):
# Control message (JSON)
import json
try:
ctrl = json.loads(text)
if ctrl.get("type") == "resize":
await session.resize(
ctrl.get("cols", 80),
ctrl.get("rows", 24),
)
except json.JSONDecodeError:
pass
else:
await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect":
break
except Exception:
pass
finally:
await self._cleanup_session(session)
async def _cleanup_idle_sessions(self) -> None:
"""Clean up sessions that have been idle for too long."""
idle_sessions = []
for instance_id, session in list(self._sessions.items()):
if session.is_idle():
idle_sessions.append(instance_id)
for instance_id in idle_sessions:
logger.info("Cleaning up idle terminal session for instance %s", instance_id)
session = self._sessions.pop(instance_id, None)
if session:
await session.close()
async def get_or_create_session(
self,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
) -> TerminalSession:
"""Get existing session or create a new one."""
# Ensure idle check is running (lazy start)
self._start_idle_check()
instance_id_str = str(instance_id)
# Check for existing session
if instance_id_str in self._sessions:
session = self._sessions[instance_id_str]
# Check if session is still alive
if session.is_alive():
logger.info("Reattaching to existing terminal session for instance %s", instance_id)
return session
else:
# Session died, clean it up
logger.info("Existing session for instance %s is dead, cleaning up", instance_id)
await session.close()
del self._sessions[instance_id_str]
# Create new session
logger.info("Creating new terminal session for instance %s", instance_id)
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session
return session
async def attach_websocket(
self,
session: TerminalSession,
websocket: WebSocket,
) -> None:
"""Attach a WebSocket to an existing session."""
# Handle concurrent connections - close existing ones
if session.has_websockets():
logger.info("Closing existing WebSocket connections for instance %s", session.instance_id)
for ws in list(session._websockets):
try:
await ws.close(code=4000, reason="New connection established")
except Exception:
pass
session._websockets.clear()
# Attach new WebSocket
session.attach_websocket(websocket)
# Replay buffer
buffer = session.get_buffer()
if buffer:
try:
await websocket.send_bytes(buffer)
except Exception:
pass
async def detach_websocket(
self,
session: TerminalSession,
websocket: WebSocket,
) -> None:
"""Detach a WebSocket from a session."""
session.detach_websocket(websocket)
async def reset_session(
self,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
) -> TerminalSession:
"""Reset a session by killing it and creating a new one."""
instance_id_str = str(instance_id)
# Close existing session if any
if instance_id_str in self._sessions:
logger.info("Resetting terminal session for instance %s", instance_id)
old_session = self._sessions.pop(instance_id_str)
await old_session.close()
# Create new session
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session
return session
async def _cleanup_session(self, session: TerminalSession) -> None:
"""Clean up a session."""
if session.session_id in self._sessions:
del self._sessions[session.session_id]
await session.close()
async def close_all(self) -> None:
"""Close all active sessions."""
@@ -153,9 +90,6 @@ class TerminalManager:
self._sessions.clear()
for session in sessions:
await session.close()
if self._idle_check_task and not self._idle_check_task.done():
self._idle_check_task.cancel()
# Global terminal manager instance
+9 -138
View File
@@ -1,73 +1,34 @@
"""Terminal session management for tool instances."""
import asyncio
import logging
import os
import pty
import select
import signal
import struct
import fcntl
import time
import uuid
from collections import deque
from typing import Any
logger = logging.getLogger(__name__)
class TerminalSession:
"""Manages a single terminal session connected to a docker container.
Supports persistent sessions that survive WebSocket disconnections.
Multiple WebSocket connections can attach/detach from the same session.
"""
"""Manages a single terminal session connected to a docker container."""
# Circular buffer size (10KB)
BUFFER_SIZE = 10 * 1024
# Idle timeout in seconds (30 minutes)
IDLE_TIMEOUT = 30 * 60
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command: str | None = None) -> None:
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
self.startup_command = startup_command
self.process: asyncio.subprocess.Process | None = None
self._closed = False
self._master_fd: int | None = None
self._slave_fd: int | None = None
# Circular buffer for output replay
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
self._buffer_size = 0
# WebSocket connections
self._websockets: set[Any] = set()
# Activity tracking
self.last_activity = time.time()
# Terminal size
self._cols = 80
self._rows = 24
async def start(self, startup_command: str | None = None) -> None:
async def start(self) -> None:
"""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(self._cols, self._rows)
logger.info(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
# Build the shell command
if startup_command:
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
logger.info(f"Using startup command for session {self.session_id}: {startup_command}")
else:
shell_cmd = "bash -il"
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
@@ -79,8 +40,7 @@ class TerminalSession:
"TERM=xterm",
self.container_id,
"bash",
"-c",
shell_cmd,
"-il",
stdin=self._slave_fd,
stdout=self._slave_fd,
stderr=self._slave_fd,
@@ -89,103 +49,46 @@ class TerminalSession:
# Close slave fd in parent process
os.close(self._slave_fd)
self._slave_fd = None
self.last_activity = time.time()
def _set_terminal_size(self, cols: int, rows: int) -> None:
"""Set the terminal size using TIOCSWINSZ."""
if self._master_fd is None:
logger.warning("Cannot resize: master_fd is None (session not started)")
return
# TIOCSWINSZ = 0x5414 on Linux
TIOCSWINSZ = 0x5414
size = struct.pack('HHHH', rows, cols, 0, 0)
try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
logger.info(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
except (OSError, IOError) as e:
logger.error(f"Failed to resize PTY: {e}")
except (OSError, IOError):
pass
async def read_output(self) -> bytes:
"""Read output from the PTY master and store in buffer."""
"""Read output from the PTY master."""
if self._master_fd is None or self._closed:
return b""
try:
# Use select to check if data is available
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
if readable:
data = os.read(self._master_fd, 4096)
if data:
self._add_to_buffer(data)
self.last_activity = time.time()
return data
return os.read(self._master_fd, 4096)
return b""
except (OSError, IOError, ValueError):
return b""
def _add_to_buffer(self, data: bytes) -> None:
"""Add data to circular buffer, maintaining size limit."""
self._output_buffer.append(data)
self._buffer_size += len(data)
# Trim if exceeds max size
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
removed = self._output_buffer.popleft()
self._buffer_size -= len(removed)
def get_buffer(self) -> bytes:
"""Get buffered output for replay."""
return b"".join(self._output_buffer)
async def write_input(self, data: bytes) -> None:
"""Write input to the PTY master."""
if self._master_fd is None or self._closed:
return
try:
os.write(self._master_fd, data)
self.last_activity = time.time()
except (OSError, IOError):
pass
async def resize(self, cols: int, rows: int) -> None:
"""Resize the terminal."""
if self._closed:
logger.warning("Cannot resize: session is closed")
return
# Only resize if dimensions actually changed
if cols == self._cols and rows == self._rows:
return
self._cols = cols
self._rows = rows
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
self._set_terminal_size(cols, rows)
# Docker exec -it creates its own PTY inside the container,
# so host PTY resize doesn't propagate to the container shell.
# Send SIGWINCH to the docker exec process on the host.
# Docker exec forwards signals to the container process, which should
# cause the container's shell to re-read its terminal size.
if self.process and self.process.pid:
try:
os.kill(self.process.pid, signal.SIGWINCH)
logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}")
except ProcessLookupError:
logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}")
except Exception as e:
logger.warning(f"Failed to send SIGWINCH: {e}")
async def reset(self) -> None:
"""Reset the session by killing the process and clearing state."""
await self.close()
self._closed = False
self._output_buffer.clear()
self._buffer_size = 0
self._websockets.clear()
self.process = None
self._master_fd = None
self._slave_fd = None
async def close(self) -> None:
"""Close the session and cleanup."""
@@ -212,35 +115,3 @@ class TerminalSession:
if self.process is None:
return False
return self.process.returncode is None
def is_idle(self) -> bool:
"""Check if the session has been idle for too long."""
if self._websockets:
return False
return time.time() - self.last_activity > self.IDLE_TIMEOUT
def attach_websocket(self, websocket: Any) -> None:
"""Attach a WebSocket to this session."""
self._websockets.add(websocket)
self.last_activity = time.time()
def detach_websocket(self, websocket: Any) -> None:
"""Detach a WebSocket from this session."""
self._websockets.discard(websocket)
def has_websockets(self) -> bool:
"""Check if any WebSockets are attached."""
return len(self._websockets) > 0
async def send_to_all(self, data: bytes) -> None:
"""Send data to all attached WebSockets."""
dead_sockets = set()
for ws in self._websockets:
try:
await ws.send_bytes(data)
except Exception:
dead_sockets.add(ws)
# Clean up dead sockets
for ws in dead_sockets:
self._websockets.discard(ws)
+4 -23
View File
@@ -124,15 +124,7 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
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
_run_git_command(repo_path, "checkout", "--orphan", name)
return
_run_git_command(repo_path, "branch", name, base_branch)
@@ -163,14 +155,7 @@ def checkout_branch(repo_path: str, name: str) -> None:
Raises:
RuntimeError: If checkout fails
"""
try:
_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
_run_git_command(repo_path, "checkout", name)
def commit_changes(
@@ -305,10 +290,6 @@ def get_current_branch(repo_path: str) -> str:
Current branch name
"""
try:
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
if branch != "HEAD":
return branch
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
except RuntimeError:
pass
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
+8 -26
View File
@@ -289,37 +289,19 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
branches: list[BranchInfo] = []
default_branch = "main"
# Get list of remote names to properly filter remote tracking branches
try:
remote_output = _run_git_command(repo_path, "remote")
remote_names = {r.strip() for r in remote_output.strip().split("\n") if r.strip()}
except RuntimeError:
remote_names = set()
for line in output.strip().split("\n"):
if not line:
continue
branch_name = line.strip()
# Skip detached HEAD pointer
if branch_name == "HEAD":
continue
# Skip remote tracking branches - they appear as "origin/branch-name"
# Check if first part is a remote name
if "/" in branch_name:
first_part = branch_name.split("/", 1)[0]
if first_part in remote_names:
# Extract just the branch name part (after "origin/")
branch_name = branch_name.split("/", 1)[1]
elif branch_name.startswith("remotes/"):
# Handle "remotes/origin/branch-name" format
parts = branch_name.split("/", 2)
if len(parts) >= 3:
branch_name = parts[2]
else:
continue
# Skip remote tracking branches (they start with remotes/)
if branch_name.startswith("remotes/"):
# Extract just the branch name part
parts = branch_name.split("/", 2)
if len(parts) >= 3:
branch_name = parts[2]
else:
continue
# Skip duplicates
if any(b.name == branch_name for b in branches):
+3 -26
View File
@@ -47,8 +47,10 @@ def test_client() -> Generator[TestClient, None, None]:
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:
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:
@@ -59,31 +61,6 @@ def test_client() -> Generator[TestClient, None, None]:
asyncio.run(engine.dispose())
@pytest_asyncio.fixture
async def db_session(test_client) -> AsyncGenerator[AsyncSession, None]:
"""Provide an async database session for unit tests."""
# Get the override function from the test_client fixture
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
yield session
finally:
await gen.aclose()
else:
# Fallback: create a new engine and session
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
yield session
await engine.dispose()
@pytest.fixture
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with a test user."""
@@ -1,322 +0,0 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestConfigProfilesAPI:
"""Integration tests for config profiles API."""
def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None:
"""Test that listing config profiles requires authentication."""
response = test_client.get("/config-profiles")
assert response.status_code == 401
def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None:
"""Test that authenticated users can list their profiles."""
response = authenticated_client.get("/config-profiles")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test creating a config profile."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "test-profile",
"description": "Test profile",
"env_vars": {"VAR": "value"},
"runtime_hints": {"start_command": "npm start"},
"mounts": [{"target": "/app", "mode": "rw", "files": {}}],
"files": {"test.txt": "hello"},
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "test-profile"
assert data["env_vars"] == {"VAR": "value"}
assert data["files"] == {"test.txt": "hello"}
assert data["mounts"][0]["target"] == "/app"
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
"""Test that duplicate profile names are rejected."""
# Create first profile
response = authenticated_client.post(
"/config-profiles",
json={
"name": "duplicate-profile",
"env_vars": {},
"files": {},
},
)
assert response.status_code == 201
# Try to create second with same name
response = authenticated_client.post(
"/config-profiles",
json={
"name": "duplicate-profile",
"env_vars": {},
"files": {},
},
)
assert response.status_code == 409
def test_create_config_profile_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
"""Test that profiles exceeding 10MB are rejected."""
large_content = "x" * (11 * 1024 * 1024) # 11MB
response = authenticated_client.post(
"/config-profiles",
json={
"name": "large-profile",
"env_vars": {},
"files": {"large.txt": large_content},
},
)
assert response.status_code == 413
def test_create_config_profile_invalid_file_path(self, authenticated_client: TestClient) -> None:
"""Test that invalid file paths are rejected."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-profile",
"env_vars": {},
"files": {"../../../etc/passwd": "malicious"},
},
)
assert response.status_code == 422
def test_create_config_profile_invalid_mount_target(self, authenticated_client: TestClient) -> None:
"""Test that invalid mount targets are rejected."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-mount-profile",
"env_vars": {},
"files": {},
"mounts": [{"target": "relative/path", "mode": "rw", "files": {}}],
},
)
assert response.status_code == 422
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
"""Test getting a config profile by ID."""
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "get-test",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Get it back
response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "get-test"
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
"""Test getting a non-existent profile."""
response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}")
assert response.status_code == 404
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a config profile."""
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "update-test",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Update it
response = authenticated_client.put(
f"/config-profiles/{profile_id}",
json={
"name": "updated-name",
"env_vars": {"NEW_VAR": "new_value"},
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated-name"
assert data["env_vars"] == {"NEW_VAR": "new_value"}
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a config profile."""
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "delete-test",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Delete it
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
assert response.status_code == 204
# Verify it's gone
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert get_response.status_code == 404
def test_update_profile_includes_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating profile includes."""
# Create base profile
base_response = authenticated_client.post(
"/config-profiles",
json={
"name": "base-profile",
"env_vars": {"BASE_VAR": "base_value"},
"files": {},
},
)
base_id = base_response.json()["id"]
# Create child profile
child_response = authenticated_client.post(
"/config-profiles",
json={
"name": "child-profile",
"env_vars": {},
"files": {},
},
)
child_id = child_response.json()["id"]
# Update includes
response = authenticated_client.put(
f"/config-profiles/{child_id}/includes",
json={"includes": [base_id]},
)
assert response.status_code == 200
data = response.json()
print(f"Response data: {data}")
print(f"Includes: {data.get('includes', 'NO INCLUDES KEY')}")
assert len(data["includes"]) == 1, f"Expected 1 include, got {len(data.get('includes', []))}: {data.get('includes', [])}"
assert data["includes"][0]["included_profile_id"] == base_id
def test_update_profile_includes_cycle_detection(self, authenticated_client: TestClient) -> None:
"""Test that include cycles are detected."""
# Create profile A
a_response = authenticated_client.post(
"/config-profiles",
json={
"name": "profile-a",
"env_vars": {},
"files": {},
},
)
a_id = a_response.json()["id"]
# Create profile B
b_response = authenticated_client.post(
"/config-profiles",
json={
"name": "profile-b",
"env_vars": {},
"files": {},
},
)
b_id = b_response.json()["id"]
# Make B include A
authenticated_client.put(
f"/config-profiles/{b_id}/includes",
json={"includes": [a_id]},
)
# Try to make A include B (would create cycle)
response = authenticated_client.put(
f"/config-profiles/{a_id}/includes",
json={"includes": [b_id]},
)
assert response.status_code == 400
def test_preview_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test previewing a resolved config profile."""
# Create base profile
base_response = authenticated_client.post(
"/config-profiles",
json={
"name": "preview-base",
"env_vars": {"BASE_VAR": "base"},
"files": {},
},
)
base_id = base_response.json()["id"]
# Create child profile
child_response = authenticated_client.post(
"/config-profiles",
json={
"name": "preview-child",
"env_vars": {"CHILD_VAR": "child"},
"files": {},
},
)
child_id = child_response.json()["id"]
# Make child include base
authenticated_client.put(
f"/config-profiles/{child_id}/includes",
json={"includes": [base_id]},
)
# Preview child
response = authenticated_client.get(f"/config-profiles/{child_id}/preview")
assert response.status_code == 200
data = response.json()
assert data["profile_name"] == "preview-child"
assert data["env_vars"]["BASE_VAR"] == "base"
assert data["env_vars"]["CHILD_VAR"] == "child"
assert len(data["included_profiles"]) == 1
def test_resolve_default_profile(self, authenticated_client: TestClient) -> None:
"""Test resolving default profile for project/tool."""
# Create a global default profile (no project/tool scoping)
authenticated_client.post(
"/config-profiles",
json={
"name": "default-profile",
"env_vars": {},
"files": {},
"is_default": True,
},
)
# Resolve default with random project/tool (should fall back to global)
project_id = str(uuid.uuid4())
tool_type_id = str(uuid.uuid4())
response = authenticated_client.get(
"/config-profiles/defaults/resolve",
params={"project_id": project_id, "tool_type_id": tool_type_id},
)
assert response.status_code == 200
data = response.json()
assert data["profile_name"] == "default-profile"
def test_resolve_default_profile_no_match(self, authenticated_client: TestClient) -> None:
"""Test resolving default profile when no profiles exist."""
project_id = str(uuid.uuid4())
tool_type_id = str(uuid.uuid4())
response = authenticated_client.get(
"/config-profiles/defaults/resolve",
params={"project_id": project_id, "tool_type_id": tool_type_id},
)
assert response.status_code == 200
data = response.json()
assert data["profile_id"] is None
@@ -70,20 +70,6 @@ def test_get_current_branch_handles_unborn_main() -> None:
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:
"""Tests for branch management functions."""
@@ -98,6 +98,7 @@ def _insert_tool_type(
name: str,
display_name: str,
compose_template: str,
is_builtin: bool = False,
created_by_id: str | None = None,
) -> None:
async def _run() -> None:
@@ -119,6 +120,7 @@ def _insert_tool_type(
description="A test tool type",
compose_template=compose_template,
required_variables=["REPO_PATH", "TOOL_NAME"],
is_builtin=is_builtin,
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
)
await session.merge(tool_type)
@@ -232,6 +234,7 @@ def test_create_tool_type_successfully() -> None:
assert data["name"] == "my-custom-tool"
assert data["display_name"] == "My Custom Tool"
assert data["description"] == "A custom development tool"
assert data["is_builtin"] == False
assert data["created_by_id"] == user_id
assert "id" in data
@@ -373,7 +376,28 @@ def test_update_tool_type_not_found() -> None:
assert response.status_code == 404
@pytest.mark.integration
def test_update_builtin_tool_type_fails() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(
tool_type_id,
"builtin-tool",
"Built-in Tool",
"version: '3.8'\nservices:\n app:\n image: builtin",
is_builtin=True,
)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user_id))
payload = {"display_name": "Updated"}
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
assert response.status_code == 403
@pytest.mark.integration
@@ -418,4 +442,53 @@ def test_delete_tool_type_not_found() -> None:
assert response.status_code == 404
@pytest.mark.integration
def test_delete_builtin_tool_type_fails() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(
tool_type_id,
"builtin-tool",
"Built-in Tool",
"version: '3.8'\nservices:\n app:\n image: builtin",
is_builtin=True,
)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user_id))
response = client.delete(f"/tool-types/{tool_type_id}")
assert response.status_code == 403
@pytest.mark.integration
def test_builtin_tool_types_seeded_on_startup() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
# Load app triggers startup event which seeds built-in types
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user_id))
response = client.get("/tool-types")
assert response.status_code == 200
data = response.json()
# Check that built-in types exist
builtin_names = [t["name"] for t in data if t["is_builtin"]]
assert "code-server" in builtin_names
assert "jupyter-notebook" in builtin_names
# Verify built-in types have correct attributes
code_server = next((t for t in data if t["name"] == "code-server"), None)
assert code_server is not None
assert code_server["display_name"] == "VS Code Server"
assert "services" in code_server["compose_template"]
assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"]
@@ -39,7 +39,7 @@ class TestToolTypesAPIExtended:
"interfaces": ["web"],
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"readiness_probe": {
"command": "curl -f http://localhost:8080",
"timeout": 30,
@@ -92,7 +92,7 @@ class TestToolTypesAPIExtended:
"display_name": "Update Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
@@ -167,7 +167,7 @@ class TestToolTypesAPIExtended:
"interfaces": ["web", "terminal"],
"default_port": 8443,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
"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,
@@ -186,114 +186,3 @@ class TestToolTypesAPIExtended:
assert data["category"] == "editor"
assert data["interfaces"] == ["web", "terminal"]
assert "readiness_probe" in data
def test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) -> None:
"""Test that creating a tool type without default_port fails validation."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "no-port-tool",
"display_name": "No Port Tool",
"category": "utility",
"interfaces": ["web"],
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"required_variables": [],
},
)
assert response.status_code == 422
data = response.json()
assert "default_port" in str(data)
def test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) -> None:
"""Test that port mismatch between default_port and compose template fails."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "port-mismatch-tool",
"display_name": "Port Mismatch Tool",
"category": "utility",
"interfaces": ["web"],
"default_port": 9999,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"required_variables": [],
},
)
assert response.status_code == 422
data = response.json()
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
"""Test creating a tool type with startup_command."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "startup-tool",
"display_name": "Startup Tool",
"category": "utility",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"startup_command": "cd /workspace && ls",
"required_variables": [],
},
)
assert response.status_code == 201
data = response.json()
assert data["startup_command"] == "cd /workspace && ls"
assert data["interface_type"] == "terminal"
def test_update_tool_type_startup_command(self, authenticated_client: TestClient) -> None:
"""Test updating a tool type's startup_command."""
# Create tool type first
create_response = authenticated_client.post(
"/tool-types",
json={
"name": "update-startup-tool",
"display_name": "Update Startup Tool",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"required_variables": [],
},
)
tool_id = create_response.json()["id"]
# Update with startup_command
response = authenticated_client.put(
f"/tool-types/{tool_id}",
json={
"startup_command": "source /etc/profile",
},
)
assert response.status_code == 200
data = response.json()
assert data["startup_command"] == "source /etc/profile"
def test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) -> None:
"""Test that GET returns startup_command."""
create_response = authenticated_client.post(
"/tool-types",
json={
"name": "get-startup-tool",
"display_name": "Get Startup Tool",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"startup_command": "echo hello",
"required_variables": [],
},
)
tool_id = create_response.json()["id"]
response = authenticated_client.get(f"/tool-types/{tool_id}")
assert response.status_code == 200
data = response.json()
assert data["startup_command"] == "echo hello"
assert "Port 9999 is not exposed" in str(data)
@@ -1,383 +0,0 @@
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.services.config_profile_resolver import (
ConfigProfileCycleError,
ConfigProfileNotFoundError,
ResolvedProfile,
check_include_cycle,
resolve_profile,
_merge_env_vars,
_merge_files,
_merge_mounts,
_merge_runtime_hints,
)
class TestMergeFunctions:
"""Unit tests for merge helper functions."""
def test_merge_env_vars_basic(self) -> None:
"""Test basic env var merging."""
result = _merge_env_vars(
{"A": "1", "B": "2"},
{"B": "3", "C": "4"},
{},
"source",
)
assert result == {"A": "1", "B": "3", "C": "4"}
def test_merge_env_vars_tracks_overrides(self) -> None:
"""Test that env var overrides are tracked."""
overrides = {}
_merge_env_vars(
{"A": "1"},
{"A": "2"},
overrides,
"source",
)
assert overrides == {"A": "source"}
def test_merge_runtime_hints_basic(self) -> None:
"""Test basic runtime hint merging."""
result = _merge_runtime_hints(
{"command": "old"},
{"command": "new", "port": 8080},
{},
"source",
)
assert result == {"command": "new", "port": 8080}
def test_merge_files_basic(self) -> None:
"""Test basic file merging."""
result = _merge_files(
{"a.txt": "old"},
{"a.txt": "new", "b.txt": "content"},
{},
"source",
)
assert result == {"a.txt": "new", "b.txt": "content"}
def test_merge_mounts_basic(self) -> None:
"""Test basic mount merging."""
from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts(
{},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
{},
"source",
)
assert "/app" in result
assert result["/app"].mode == "rw"
assert result["/app"].files == {"a.txt": "content"}
def test_merge_mounts_file_override(self) -> None:
"""Test mount file map merging with overrides."""
from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
{},
"source",
)
assert result["/app"].files == {"a.txt": "new"}
def test_merge_mounts_mode_conflict(self) -> None:
"""Test that mount mode conflicts are resolved (later wins)."""
from src.services.config_profile_resolver import ResolvedMount
overrides = {}
result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
[{"target": "/app", "mode": "ro", "files": {}}],
overrides,
"source",
)
assert result["/app"].mode == "ro"
assert overrides == {"/app": "source"}
class TestResolveProfile:
"""Unit tests for profile resolution."""
@pytest.mark.asyncio
async def test_resolve_simple_profile(self, db_session: AsyncSession) -> None:
"""Test resolving a profile with no includes."""
user_id = uuid.uuid4()
profile = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="simple",
env_vars={"VAR": "value"},
runtime_hints={"command": "run"},
files={"test.txt": "content"},
mounts=[{"target": "/app", "mode": "rw", "files": {}}],
)
db_session.add(profile)
await db_session.commit()
result = await resolve_profile(db_session, profile.id)
assert result.profile_name == "simple"
assert result.env_vars == {"VAR": "value"}
assert result.runtime_hints == {"command": "run"}
assert result.files == {"test.txt": "content"}
@pytest.mark.asyncio
async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None:
"""Test resolving a profile that includes another."""
user_id = uuid.uuid4()
# Create base profile
base = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="base",
env_vars={"BASE_VAR": "base_value"},
files={},
)
db_session.add(base)
# Create child profile
child = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="child",
env_vars={"CHILD_VAR": "child_value"},
files={},
)
db_session.add(child)
await db_session.commit()
# Create include relationship
include = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=child.id,
included_profile_id=base.id,
order_index=0,
)
db_session.add(include)
await db_session.commit()
result = await resolve_profile(db_session, child.id)
assert result.env_vars == {
"BASE_VAR": "base_value",
"CHILD_VAR": "child_value",
}
assert len(result.included_profiles) == 1
assert result.included_profiles[0]["name"] == "base"
@pytest.mark.asyncio
async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None:
"""Test that child profile values override parent values."""
user_id = uuid.uuid4()
base = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="base",
env_vars={"VAR": "base"},
files={},
)
db_session.add(base)
child = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="child",
env_vars={"VAR": "child"},
files={},
)
db_session.add(child)
await db_session.commit()
include = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=child.id,
included_profile_id=base.id,
order_index=0,
)
db_session.add(include)
await db_session.commit()
result = await resolve_profile(db_session, child.id)
assert result.env_vars == {"VAR": "child"}
assert result.env_overrides == {"VAR": "child"}
@pytest.mark.asyncio
async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None:
"""Test that cycles are detected during resolution."""
user_id = uuid.uuid4()
profile_a = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="a",
env_vars={},
files={},
)
db_session.add(profile_a)
profile_b = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="b",
env_vars={},
files={},
)
db_session.add(profile_b)
await db_session.commit()
# A includes B
include_ab = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_a.id,
included_profile_id=profile_b.id,
order_index=0,
)
db_session.add(include_ab)
# B includes A (creates cycle)
include_ba = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_b.id,
included_profile_id=profile_a.id,
order_index=0,
)
db_session.add(include_ba)
await db_session.commit()
with pytest.raises(ConfigProfileCycleError):
await resolve_profile(db_session, profile_a.id)
@pytest.mark.asyncio
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
"""Test resolving a non-existent profile."""
with pytest.raises(ConfigProfileNotFoundError):
await resolve_profile(db_session, uuid.uuid4())
class TestCheckIncludeCycle:
"""Unit tests for include cycle checking."""
@pytest.mark.asyncio
async def test_check_no_cycle(self, db_session: AsyncSession) -> None:
"""Test checking when no cycle exists."""
user_id = uuid.uuid4()
profile_a = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="a",
env_vars={},
files={},
)
db_session.add(profile_a)
profile_b = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="b",
env_vars={},
files={},
)
db_session.add(profile_b)
await db_session.commit()
# A includes B
include = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_a.id,
included_profile_id=profile_b.id,
order_index=0,
)
db_session.add(include)
await db_session.commit()
result = await check_include_cycle(db_session, profile_a.id)
assert result is None
@pytest.mark.asyncio
async def test_check_detects_cycle(self, db_session: AsyncSession) -> None:
"""Test detecting an existing cycle."""
user_id = uuid.uuid4()
profile_a = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="a",
env_vars={},
files={},
)
db_session.add(profile_a)
profile_b = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="b",
env_vars={},
files={},
)
db_session.add(profile_b)
await db_session.commit()
# A includes B
include_ab = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_a.id,
included_profile_id=profile_b.id,
order_index=0,
)
db_session.add(include_ab)
# B includes A
include_ba = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_b.id,
included_profile_id=profile_a.id,
order_index=0,
)
db_session.add(include_ba)
await db_session.commit()
result = await check_include_cycle(db_session, profile_a.id)
assert result is not None
assert len(result) > 1
@pytest.mark.asyncio
async def test_check_would_create_cycle(self, db_session: AsyncSession) -> None:
"""Test detecting a cycle that would be created."""
user_id = uuid.uuid4()
profile_a = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="a",
env_vars={},
files={},
)
db_session.add(profile_a)
profile_b = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="b",
env_vars={},
files={},
)
db_session.add(profile_b)
await db_session.commit()
# A includes B
include = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_a.id,
included_profile_id=profile_b.id,
order_index=0,
)
db_session.add(include)
await db_session.commit()
# Check if adding B includes A would create cycle
result = await check_include_cycle(db_session, profile_b.id, profile_a.id)
assert result is not None
@@ -39,3 +39,18 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
assert module.revision == "0002_refresh_tokens"
assert module.down_revision == "0001_initial_schema"
@pytest.mark.unit
def test_config_profiles_migration_has_expected_revision_chain() -> None:
migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py"
spec = spec_from_file_location("add_config_profiles", migration_path)
assert spec is not None
assert spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "0013_add_config_profiles"
assert module.down_revision == "0012_default_port_req"
@@ -1,182 +0,0 @@
"""Tests for session creation with branch selection and new branch creation."""
import os
import subprocess
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from src.api.tool_instances import CreateInstanceRequest
class TestCreateInstanceRequest:
"""Tests for CreateInstanceRequest model."""
def test_default_values(self):
"""Test default values for CreateInstanceRequest."""
request = CreateInstanceRequest(tool_type_id="123")
assert request.clone_mode == "mount"
assert request.branch == "main"
assert request.new_branch is None
assert request.display_name is None
def test_clone_mode_with_branch(self):
"""Test CreateInstanceRequest with clone mode and branch."""
request = CreateInstanceRequest(
tool_type_id="123",
clone_mode="clone",
branch="dev",
)
assert request.clone_mode == "clone"
assert request.branch == "dev"
def test_new_branch_field(self):
"""Test CreateInstanceRequest with new_branch field."""
request = CreateInstanceRequest(
tool_type_id="123",
clone_mode="clone",
branch="main",
new_branch="feature/test",
)
assert request.new_branch == "feature/test"
class TestBranchCreationInClone:
"""Tests for branch creation logic in clone process."""
def test_create_local_branch_success(self):
"""Test successful local branch creation."""
with tempfile.TemporaryDirectory() as tmpdir:
# Initialize repo
subprocess.run(
["git", "init", tmpdir],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.name", "Test User"],
capture_output=True,
check=True,
)
# Create initial commit
readme = os.path.join(tmpdir, "README.md")
with open(readme, "w") as f:
f.write("# Test\n")
subprocess.run(
["git", "-C", tmpdir, "add", "README.md"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
capture_output=True,
check=True,
)
# Create new branch
result = subprocess.run(
["git", "-C", tmpdir, "checkout", "-b", "feature/new-branch"],
capture_output=True,
text=True,
)
assert result.returncode == 0
# Verify branch exists
branches_result = subprocess.run(
["git", "-C", tmpdir, "branch", "--show-current"],
capture_output=True,
text=True,
)
assert branches_result.stdout.strip() == "feature/new-branch"
def test_create_local_branch_invalid_name(self):
"""Test local branch creation with invalid name fails."""
with tempfile.TemporaryDirectory() as tmpdir:
# Initialize repo
subprocess.run(
["git", "init", tmpdir],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.name", "Test User"],
capture_output=True,
check=True,
)
# Create initial commit
readme = os.path.join(tmpdir, "README.md")
with open(readme, "w") as f:
f.write("# Test\n")
subprocess.run(
["git", "-C", tmpdir, "add", "README.md"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
capture_output=True,
check=True,
)
# Try to create branch with invalid name (contains spaces)
result = subprocess.run(
["git", "-C", tmpdir, "checkout", "-b", "invalid branch name"],
capture_output=True,
text=True,
)
# Git accepts branch names with spaces but it's not recommended
# This test verifies the command structure
assert result.returncode == 0 or "fatal" in result.stderr
class TestCreateInstanceAPI:
"""Tests for create instance API endpoint with branch options."""
def test_create_instance_request_validation(self):
"""Test that CreateInstanceRequest validates correctly."""
# Valid request with new_branch
request = CreateInstanceRequest(
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
clone_mode="clone",
branch="main",
new_branch="feature/test",
)
assert request.new_branch == "feature/test"
# Valid request without new_branch
request2 = CreateInstanceRequest(
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
clone_mode="clone",
branch="dev",
)
assert request2.new_branch is None
def test_create_instance_with_new_branch_sets_instance_branch(self):
"""Test that instance branch is set to new_branch when provided."""
# This tests the logic: data.new_branch if data.new_branch else data.branch
new_branch = "feature/test"
base_branch = "main"
# Simulate the logic from create_instance
stored_branch = new_branch if new_branch else base_branch
assert stored_branch == "feature/test"
# Without new_branch
stored_branch2 = None if None else base_branch
assert stored_branch2 == "main"
+69 -512
View File
@@ -896,24 +896,6 @@
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -931,24 +913,6 @@
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -966,24 +930,6 @@
"node": ">=12"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -1444,6 +1390,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1461,6 +1410,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1478,6 +1430,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1495,6 +1450,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1512,6 +1470,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1529,6 +1490,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1707,6 +1671,9 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1721,6 +1688,9 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1735,6 +1705,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1749,6 +1722,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1763,6 +1739,9 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1777,6 +1756,9 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1791,6 +1773,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1805,6 +1790,9 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1819,6 +1807,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1833,6 +1824,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1847,6 +1841,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1861,6 +1858,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1875,6 +1875,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4357,6 +4360,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4378,6 +4384,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4399,6 +4408,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4420,6 +4432,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6107,420 +6122,6 @@
}
}
},
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@vitest/mocker": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
@@ -6548,50 +6149,6 @@
}
}
},
"node_modules/vitest/node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+1 -26
View File
@@ -14,38 +14,13 @@ export const shouldSkipAuthRedirect = (path: string): boolean => {
return path.startsWith("/login") || path.startsWith("/auth");
};
// Retry config for transient network errors
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1000;
// Track retry count per request
const retryCount = new WeakMap<any, number>();
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
(error) => {
const status = error?.response?.status;
if (status === 401 && !shouldSkipAuthRedirect(window.location.pathname)) {
window.location.assign(`${BASE_URL}/auth/login`);
return Promise.reject(error);
}
// Retry on transient network errors (ERR_NETWORK_CHANGED, etc.)
const isNetworkError = !error.response && error.message?.includes("Network");
const isRetryable = isNetworkError || status >= 502; // 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
if (isRetryable) {
const config = error.config;
const currentRetry = retryCount.get(config) || 0;
if (currentRetry < MAX_RETRIES) {
retryCount.set(config, currentRetry + 1);
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * (currentRetry + 1)));
return apiClient(config);
}
}
return Promise.reject(error);
}
);
-145
View File
@@ -1,145 +0,0 @@
import { apiClient } from "./client";
export interface ConfigProfile {
id: string;
user_id: string;
name: string;
description: string | null;
project_id: string | null;
tool_type_id: string | null;
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ConfigProfileMount[];
files: Record<string, string>;
is_default: boolean;
includes: ConfigProfileInclude[];
created_at: string;
updated_at: string;
}
export interface ConfigProfileMount {
target: string;
mode: "ro" | "rw";
files: Record<string, string>;
}
export interface ConfigProfileInclude {
id: string;
included_profile_id: string;
order_index: number;
}
export interface ResolvedProfile {
profile_id: string;
profile_name: string;
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ResolvedMount[];
files: Record<string, string>;
overrides: {
env_vars: Record<string, string>;
runtime_hints: Record<string, string>;
files: Record<string, string>;
mounts: Record<string, string>;
};
included_profiles: Array<{ id: string; name: string }>;
}
export interface ResolvedMount {
target: string;
mode: "ro" | "rw";
files: Record<string, string>;
overridden_files: Record<string, string>;
}
export interface CreateConfigProfileRequest {
name: string;
description?: string;
project_id?: string;
tool_type_id?: string;
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
files?: Record<string, string>;
is_default?: boolean;
}
export interface UpdateConfigProfileRequest {
name?: string;
description?: string;
project_id?: string;
tool_type_id?: string;
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
files?: Record<string, string>;
is_default?: boolean;
}
export interface UpdateIncludesRequest {
includes: string[];
}
export const listConfigProfiles = async (
projectId?: string,
toolTypeId?: string
): Promise<ConfigProfile[]> => {
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
params: { project_id: projectId, tool_type_id: toolTypeId },
});
return response.data;
};
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
return response.data;
};
export const createConfigProfile = async (
data: CreateConfigProfileRequest
): Promise<ConfigProfile> => {
const response = await apiClient.post<ConfigProfile>("/config-profiles", data);
return response.data;
};
export const updateConfigProfile = async (
id: string,
data: UpdateConfigProfileRequest
): Promise<ConfigProfile> => {
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data);
return response.data;
};
export const deleteConfigProfile = async (id: string): Promise<void> => {
await apiClient.delete(`/config-profiles/${id}`);
};
export const updateProfileIncludes = async (
id: string,
data: UpdateIncludesRequest
): Promise<ConfigProfile> => {
const response = await apiClient.put<ConfigProfile>(
`/config-profiles/${id}/includes`,
data
);
return response.data;
};
export const previewConfigProfile = async (
id: string
): Promise<ResolvedProfile> => {
const response = await apiClient.get<ResolvedProfile>(
`/config-profiles/${id}/preview`
);
return response.data;
};
export const resolveDefaultProfile = async (
projectId: string,
toolTypeId: string
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
const response = await apiClient.get("/config-profiles/defaults/resolve", {
params: { project_id: projectId, tool_type_id: toolTypeId },
});
return response.data;
};
-35
View File
@@ -8,7 +8,6 @@ export interface GitRepository {
owner_id: string;
is_mirror: boolean;
remote_url: string | null;
ssh_key_id: string | null;
last_push: string | null;
created_at: string | null;
}
@@ -17,7 +16,6 @@ export interface GitRepositoryCreate {
name: string;
remote_url?: string;
force_original_url?: boolean;
ssh_key_id?: string;
}
export interface URLParseResult {
@@ -52,39 +50,6 @@ export async function deleteRepository(projectId: string, repoId: string): Promi
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
}
export async function updateRepositorySshKey(
projectId: string,
repoId: string,
sshKeyId: string | null
): Promise<GitRepository> {
const response = await apiClient.patch(
`/projects/${projectId}/repositories/${repoId}/ssh-key`,
{ ssh_key_id: sshKeyId }
);
return response.data;
}
export interface Branch {
name: string;
is_default: boolean;
last_commit: string | null;
}
export interface BranchesResponse {
branches: Branch[];
default_branch: string;
}
export async function listRepositoryBranches(
projectId: string,
repoId: string
): Promise<BranchesResponse> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/branches`
);
return response.data;
}
export interface CommitHistoryEntry {
hash: string;
short_hash: string;
+14 -66
View File
@@ -10,7 +10,6 @@ export interface ToolInstance {
status: string;
url: string | null;
port: number | null;
selected_config_profile_id: string | null;
created_at: string;
}
@@ -26,11 +25,6 @@ export interface Session {
project_id: string;
status: string;
url: string | null;
container_status?: string;
probe_status?: string;
clone_mode?: string;
branch?: string | null;
created_at?: string;
}
export async function listInstances(
@@ -47,21 +41,13 @@ export async function createInstance(
projectId: string,
repoId: string,
toolTypeId: string,
displayName?: string,
cloneMode?: string,
branch?: string,
newBranch?: string,
configProfileId?: string
displayName?: string
): Promise<ToolInstance> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`,
{
tool_type_id: toolTypeId,
display_name: displayName,
clone_mode: cloneMode || "mount",
branch: branch || undefined,
new_branch: newBranch || undefined,
config_profile_id: configProfileId,
}
);
return response.data;
@@ -70,24 +56,12 @@ export async function createInstance(
export async function startInstance(
projectId: string,
repoId: string,
instanceId: string,
configProfileId?: string,
retries = 2
instanceId: string
): Promise<{ status: string; url?: string }> {
try {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
{ config_profile_id: configProfileId }
);
return response.data;
} catch (error: any) {
// Retry on network errors (e.g. Docker creating network interfaces)
if (retries > 0 && !error.response) {
await new Promise((r) => setTimeout(r, 1500));
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
throw error;
}
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
);
return response.data;
}
export async function stopInstance(
@@ -104,35 +78,21 @@ export async function stopInstance(
export async function restartInstance(
projectId: string,
repoId: string,
instanceId: string,
configProfileId?: string,
retries = 2
instanceId: string
): Promise<{ status: string; url?: string }> {
try {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
{ config_profile_id: configProfileId }
);
return response.data;
} catch (error: any) {
// Retry on network errors (e.g. Docker creating network interfaces)
if (retries > 0 && !error.response) {
await new Promise((r) => setTimeout(r, 1500));
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
throw error;
}
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`
);
return response.data;
}
export async function deleteInstance(
projectId: string,
repoId: string,
instanceId: string,
force?: boolean
instanceId: string
): Promise<void> {
await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
{ params: { force } }
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`
);
}
@@ -141,23 +101,11 @@ export async function getUserSessions(): Promise<Session[]> {
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> {
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
);
-27
View File
@@ -24,30 +24,3 @@ export async function createSSHKey(data: SSHKeyCreate): Promise<SSHKey> {
export async function deleteSSHKey(keyId: string): Promise<void> {
await apiClient.delete(`/ssh-keys/${keyId}`);
}
export interface SignPayloadRequest {
payload: string;
}
export interface SignatureResponse {
signature: string;
}
export interface VerifySignatureRequest {
payload: string;
signature: string;
}
export interface VerifySignatureResponse {
valid: boolean;
}
export async function signPayload(keyId: string, data: SignPayloadRequest): Promise<SignatureResponse> {
const response = await apiClient.post<SignatureResponse>(`/ssh-keys/${keyId}/sign`, data);
return response.data;
}
export async function verifySignature(keyId: string, data: VerifySignatureRequest): Promise<VerifySignatureResponse> {
const response = await apiClient.post<VerifySignatureResponse>(`/ssh-keys/${keyId}/verify`, data);
return response.data;
}
+4 -9
View File
@@ -12,16 +12,15 @@ export interface ToolType {
display_name: string;
description: string | null;
category: string;
interface_type: string;
requires_port: boolean;
interfaces: string[];
default_port: number | null;
definition_type: 'compose' | 'dockerfile';
compose_template: string | null;
dockerfile_template: string | null;
build_context: Record<string, string> | null;
readiness_probe: ReadinessProbe | null;
startup_command: string | null;
required_variables: string[];
is_builtin: boolean;
created_by_id: string | null;
created_at: string;
updated_at: string;
@@ -32,15 +31,13 @@ export interface CreateToolTypeRequest {
display_name: string;
description?: string;
category?: string;
interface_type?: string;
requires_port?: boolean;
interfaces?: string[];
default_port: number;
definition_type?: 'compose' | 'dockerfile';
compose_template?: string;
dockerfile_template?: string;
build_context?: Record<string, string>;
readiness_probe?: ReadinessProbe;
startup_command?: string;
required_variables: string[];
}
@@ -48,15 +45,13 @@ export interface UpdateToolTypeRequest {
display_name?: string;
description?: string;
category?: string;
interface_type?: string;
requires_port?: boolean;
interfaces?: string[];
default_port?: number;
definition_type?: 'compose' | 'dockerfile';
compose_template?: string;
dockerfile_template?: string;
build_context?: Record<string, string>;
readiness_probe?: ReadinessProbe;
startup_command?: string;
required_variables?: string[];
}
+35 -55
View File
@@ -1,22 +1,18 @@
import { useCallback, useEffect } from "react";
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { Link, NavLink, Outlet } from "react-router-dom";
import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions";
import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth";
import { useSessions } from "../state/sessions";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { Icon } from "./icon";
import { MobileNav } from "./mobile-nav";
import type { IconName } from "../utils/icons";
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
{ to: "/settings", label: "Settings", icon: "settings" }
];
@@ -42,9 +38,6 @@ export const AppShell = () => {
useTheme();
const { user, logout } = useAuth();
const { sessions, setAllSessions } = useSessions();
const location = useLocation();
const isMobile = useMobileViewport();
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
const loadSessions = useCallback(async () => {
try {
@@ -57,21 +50,13 @@ export const AppShell = () => {
useEffect(() => {
void loadSessions();
// Poll every 30 seconds (reduced from 10s to avoid ERR_NETWORK_CHANGED from Docker network changes)
// Poll every 10 seconds
const interval = setInterval(() => {
void loadSessions();
}, 30000);
}, 10000);
return () => clearInterval(interval);
}, [loadSessions]);
if (isMobileTerminal) {
return (
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
);
}
return (
<div className="shell">
<header className="shell-header">
@@ -96,46 +81,41 @@ export const AppShell = () => {
</header>
<div className="shell-body">
{!isMobile && (
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
)}
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{isHome && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
<main className="shell-content">
<Outlet />
</main>
</div>
{isMobile && (
<MobileNav sessionCount={sessions.filter((s) => s.status === "running").length} />
)}
</div>
);
};
@@ -1,521 +0,0 @@
import { useState, useEffect } from "react";
import { Icon } from "./icon";
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
import type { Project } from "../types";
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
import type { ToolType } from "../api/tool_types";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
interface CreateSessionFormProps {
projects: Project[];
repositories: GitRepository[];
toolTypes: ToolType[];
fixedProjectId?: string;
fixedRepoId?: string;
projectName?: string;
repoName?: string;
showCloneMode?: boolean;
showFixedFields?: boolean;
onProjectChange?: (projectId: string) => void;
onSuccess?: (instance: ToolInstance) => void;
onCancel?: () => void;
submitLabel?: string;
className?: string;
}
export const CreateSessionForm = ({
projects,
repositories,
toolTypes,
fixedProjectId,
fixedRepoId,
projectName,
repoName,
showCloneMode = true,
showFixedFields = true,
onProjectChange,
onSuccess,
onCancel,
submitLabel = "Create Session",
className = "",
}: CreateSessionFormProps) => {
const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
const [branch, setBranch] = useState("main");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
const [branches, setBranches] = useState<Branch[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [baseBranch, setBaseBranch] = useState("");
const [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
const [progress, setProgress] = useState("");
const [error, setError] = useState<string | null>(null);
// Load SSH keys when clone mode is shown
useEffect(() => {
if (!showCloneMode) return;
const loadKeys = async () => {
try {
const keys = await listSSHKeys();
setSshKeys(keys);
} catch {
// ignore
}
};
void loadKeys();
}, [showCloneMode]);
// Load config profiles when tool type is selected
useEffect(() => {
const projectId = fixedProjectId || selectedProject;
if (!selectedToolType || !projectId) {
setConfigProfiles([]);
setSelectedConfigProfile("");
return;
}
const loadProfiles = async () => {
try {
const profiles = await listConfigProfiles(projectId, selectedToolType);
setConfigProfiles(profiles);
// Auto-select default if available
const defaultProfile = profiles.find((p) => p.is_default);
if (defaultProfile) {
setSelectedConfigProfile(defaultProfile.id);
}
} catch {
// ignore
}
};
void loadProfiles();
}, [selectedToolType, selectedProject, fixedProjectId]);
// Load branches when selected repo changes
useEffect(() => {
const projectId = fixedProjectId || selectedProject;
if (!selectedRepo || !projectId || !showCloneMode) {
setBranches([]);
return;
}
const loadBranches = async () => {
setIsLoadingBranches(true);
try {
const response = await listRepositoryBranches(projectId, selectedRepo);
setBranches(response.branches);
if (response.default_branch) {
setBranch(response.default_branch);
setBaseBranch(response.default_branch);
}
} catch {
// ignore
} finally {
setIsLoadingBranches(false);
}
};
void loadBranches();
}, [selectedRepo, selectedProject, fixedProjectId, showCloneMode]);
// Filter repositories by selected project
const availableRepos = selectedProject
? repositories.filter((r) => r.project_id === selectedProject)
: [];
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
const projectId = fixedProjectId || selectedProject;
const repoId = fixedRepoId || selectedRepo;
if (!projectId || !repoId || !selectedToolType) {
setError("Project, repository, and tool type are required");
return;
}
if (showCloneMode && cloneMode === "clone") {
const repo = repositories.find((r) => r.id === repoId);
if (!repo?.ssh_key_id) {
setError("Repository must have an SSH key assigned for clone mode");
return;
}
}
setStatus("creating");
setProgress("Creating instance...");
try {
const instance = await createInstance(
projectId,
repoId,
selectedToolType,
displayName || undefined,
showCloneMode ? cloneMode : undefined,
showCloneMode && cloneMode === "clone"
? isCreatingNewBranch
? baseBranch
: branch
: undefined,
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
? newBranchName
: undefined,
selectedConfigProfile || undefined
);
setProgress("Starting container...");
await startInstance(projectId, repoId, instance.id);
// Reset form
if (!fixedProjectId) setSelectedProject("");
if (!fixedRepoId) setSelectedRepo("");
setSelectedToolType("");
setDisplayName("");
setCloneMode("mount");
setBranch("main");
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
setBranches([]);
setStatus("idle");
onSuccess?.(instance);
} catch {
setStatus("error");
setError("Failed to create session");
setProgress("");
}
};
const isSubmitting = status === "creating";
// Determine which steps are active/unlocked
const hasProject = !!(fixedProjectId || selectedProject);
const hasRepo = !!(fixedRepoId || selectedRepo);
const hasToolType = !!selectedToolType;
const renderStep = (
label: string,
number: number,
isActive: boolean,
isComplete: boolean,
children: React.ReactNode
) => {
const stepClass = `workflow-step ${isActive ? "active" : ""} ${isComplete ? "complete" : ""}`;
return (
<div className={stepClass}>
<div className="workflow-step-header">
<span className="workflow-step-number">{number}</span>
<span className="workflow-step-label">{label}</span>
</div>
<div className="workflow-step-content">
{children}
</div>
</div>
);
};
return (
<div className={`create-session-form-wrapper ${className}`}>
{isSubmitting && (
<div className="loading-overlay">
<div className="loading-content">
<Icon name="loading" size="lg" />
<p>{progress || "Creating session..."}</p>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="stack create-session-form workflow-form">
{/* Step 1: Project */}
{renderStep("Select Project", 1, true, hasProject,
fixedProjectId && showFixedFields ? (
<label className="form-field">
<input
type="text"
value={projectName || projects.find((p) => p.id === fixedProjectId)?.name || ""}
disabled
readOnly
/>
</label>
) : (
<label className="form-field">
<select
value={selectedProject}
onChange={(e) => {
const value = e.target.value;
setSelectedProject(value);
setSelectedRepo("");
setSelectedToolType("");
setCloneMode("mount");
setIsCreatingNewBranch(false);
onProjectChange?.(value);
}}
disabled={isSubmitting}
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
)
)}
{/* Step 2: Repository */}
{hasProject && renderStep("Select Repository", 2, true, hasRepo,
fixedRepoId && showFixedFields ? (
<label className="form-field">
<input
type="text"
value={repoName || repositories.find((r) => r.id === fixedRepoId)?.name || ""}
disabled
readOnly
/>
</label>
) : (
<label className="form-field">
<select
value={selectedRepo}
onChange={(e) => {
setSelectedRepo(e.target.value);
setSelectedToolType("");
setCloneMode("mount");
setIsCreatingNewBranch(false);
}}
disabled={!hasProject || isSubmitting}
>
<option value="">Select repository...</option>
{availableRepos.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
)
)}
{/* Step 3: Tool Type */}
{hasRepo && renderStep("Select Tool", 3, true, hasToolType,
<label className="form-field">
<select
value={selectedToolType}
onChange={(e) => {
setSelectedToolType(e.target.value);
setCloneMode("mount");
setIsCreatingNewBranch(false);
}}
disabled={!hasRepo || isSubmitting}
>
<option value="">Select tool...</option>
{toolTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.display_name}
</option>
))}
</select>
</label>
)}
{/* Step 4: Config Profile */}
{hasToolType && renderStep("Config Profile (optional)", 4, true, false,
<label className="form-field">
<select
value={selectedConfigProfile}
onChange={(e) => setSelectedConfigProfile(e.target.value)}
disabled={!hasToolType || isSubmitting}
>
<option value="">No profile (use tool defaults)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.is_default ? "(default)" : ""}
</option>
))}
</select>
</label>
)}
{/* Step 5: Clone Mode & Branch */}
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
<div className="form-row">
<label className="form-field">
<div className="radio-group">
<label className="radio-label">
<input
type="radio"
name="cloneMode"
value="mount"
checked={cloneMode === "mount"}
onChange={(e) => {
setCloneMode(e.target.value as "mount" | "clone");
setIsCreatingNewBranch(false);
}}
disabled={isSubmitting}
/>
Mount (live sync)
</label>
<label className="radio-label">
<input
type="radio"
name="cloneMode"
value="clone"
checked={cloneMode === "clone"}
onChange={(e) => {
setCloneMode(e.target.value as "mount" | "clone");
setIsCreatingNewBranch(false);
}}
disabled={isSubmitting}
/>
Clone fresh copy
</label>
</div>
</label>
{cloneMode === "clone" && (
<>
<label className="form-field">
Branch
{isLoadingBranches ? (
<span className="muted">Loading branches...</span>
) : (
<select
value={isCreatingNewBranch ? "__new__" : branch}
onChange={(e) => {
const value = e.target.value;
if (value === "__new__") {
setIsCreatingNewBranch(true);
setNewBranchName("");
} else {
setIsCreatingNewBranch(false);
setBranch(value);
setBaseBranch(value);
}
}}
disabled={isSubmitting}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
<option value="__new__">Create new branch...</option>
</select>
)}
</label>
{isCreatingNewBranch && (
<>
<label className="form-field">
New Branch Name
<input
type="text"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="feature/my-new-branch"
required
disabled={isSubmitting}
/>
</label>
<label className="form-field">
Base Branch
<select
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
disabled={isSubmitting}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
</select>
</label>
</>
)}
{selectedRepo && (
<div className="form-field ssh-key-info">
{(() => {
const repo = repositories.find((r) => r.id === selectedRepo);
if (!repo) return null;
if (repo.ssh_key_id) {
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
return (
<span className="success-text">
SSH key: {key?.name || "Assigned"}
</span>
);
}
return (
<span className="warning-text">
No SSH key assigned to this repository. Clone mode requires an SSH key.
</span>
);
})()}
</div>
)}
</>
)}
</div>
)}
{/* Step 6: Display Name */}
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
<label className="form-field">
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
disabled={isSubmitting}
/>
</label>
)}
{/* Error & Submit */}
{error && <p className="error-text">{error}</p>}
{hasToolType && (
<div className="form-actions">
{onCancel && (
<button
className="secondary-button"
type="button"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</button>
)}
<button
className="primary-button"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Icon name="loading" size="sm" />
Creating...
</>
) : (
<>
<Icon name="add" size="sm" />
{submitLabel}
</>
)}
</button>
</div>
)}
</form>
</div>
);
};
-34
View File
@@ -1,34 +0,0 @@
import { Icon } from "./icon";
interface LoadingStateProps {
message?: string;
}
export const LoadingState = ({ message = "Loading..." }: LoadingStateProps) => (
<p className="muted">{message}</p>
);
interface ErrorStateProps {
message?: string;
onRetry?: () => void;
}
export const ErrorState = ({ message = "Failed to load", onRetry }: ErrorStateProps) => (
<div className="card stack">
<p>{message}</p>
{onRetry && (
<button className="secondary-button" onClick={onRetry} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
)}
</div>
);
interface EmptyStateProps {
message: string;
}
export const EmptyState = ({ message }: EmptyStateProps) => (
<p className="muted">{message}</p>
);
+1 -9
View File
@@ -18,7 +18,6 @@ interface GitToolbarProps {
currentBranch: string;
branches: string[];
hasRemote: boolean;
isMirror: boolean;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
@@ -29,7 +28,6 @@ export const GitToolbar = ({
currentBranch,
branches,
hasRemote,
isMirror,
onBranchChange,
onRefresh,
}: GitToolbarProps) => {
@@ -138,13 +136,7 @@ export const GitToolbar = ({
return (
<div className="git-toolbar">
{error && <div className="toolbar-error">{error}</div>}
{isMirror && (
<div className="warning-message">
<Icon name="warning" size="sm" /> This repository is a bare mirror.
Editing, committing, pulling, and merging are not available.
Delete and recreate it to enable full workspace features.
</div>
)}
<div className="toolbar-row">
<div className="toolbar-group">
<select
+1 -4
View File
@@ -34,7 +34,6 @@ import {
Stop,
Terminal,
ArrowLeft,
DotsSixVertical,
} from "@phosphor-icons/react";
export type IconName =
@@ -76,8 +75,7 @@ export type IconName =
| "play"
| "stop"
| "terminal"
| "arrow-left"
| "drag";
| "arrow-left";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
@@ -119,7 +117,6 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
drag: DotsSixVertical,
};
export interface IconProps {
+80 -186
View File
@@ -4,6 +4,7 @@ import { Icon } from "./icon";
import type { ToolInstance } from "../api/sessions";
import {
checkInstanceHealth,
createInstance,
deleteInstance,
listInstances,
recreateInstanceTunnel,
@@ -12,40 +13,30 @@ import {
stopInstance,
} from "../api/sessions";
import type { ToolType } from "../api/tool_types";
import { CreateSessionForm } from "./create-session-form";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps {
projectId: string;
repoId: string;
projectName?: string;
repoName?: string;
toolTypes: ToolType[];
}
export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTypes }: InstanceListProps) => {
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
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 }>>({});
// Config profile selection for start/restart
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<string | null>(null);
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
// Per-instance busy state for actions
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
const loadInstances = useCallback(async () => {
setLoading(true);
try {
@@ -92,84 +83,65 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
return () => clearInterval(interval);
}, [instances, projectId, repoId]);
const handleCreateSuccess = async () => {
setShowCreate(false);
await loadInstances();
const handleCreate = async () => {
if (!selectedToolType) return;
setError(null);
try {
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
setShowCreate(false);
setSelectedToolType("");
setDisplayName("");
await loadInstances();
} catch {
setError("Failed to create instance");
}
};
const loadConfigProfiles = useCallback(async (toolTypeId: string) => {
const handleStart = async (instanceId: string) => {
try {
const profiles = await listConfigProfiles(projectId, toolTypeId);
setConfigProfiles(profiles);
} catch {
// ignore
}
}, [projectId]);
const handleStart = async (instanceId: string, configProfileId?: string) => {
setBusyInstanceId(instanceId);
try {
await startInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
await startInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to start instance");
} finally {
setBusyInstanceId(null);
}
};
const handleStop = async (instanceId: string) => {
setBusyInstanceId(instanceId);
try {
await stopInstance(projectId, repoId, instanceId);
setStopConfirmId(null);
await loadInstances();
} catch {
setError("Failed to stop instance");
} finally {
setBusyInstanceId(null);
}
};
const handleRestart = async (instanceId: string, configProfileId?: string) => {
setBusyInstanceId(instanceId);
const handleRestart = async (instanceId: string) => {
try {
await restartInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
await restartInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to restart instance");
} finally {
setBusyInstanceId(null);
}
};
const handleDelete = async (instanceId: string) => {
if (!confirm("Are you sure you want to delete this instance?")) return;
setBusyInstanceId(instanceId);
try {
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances(prev => prev.filter(i => i.id !== instanceId));
} catch {
setError("Failed to delete instance");
} finally {
setBusyInstanceId(null);
}
};
const handleRecreateTunnel = async (instanceId: string) => {
setBusyInstanceId(instanceId);
try {
await recreateInstanceTunnel(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to recreate tunnel");
} finally {
setBusyInstanceId(null);
}
};
@@ -220,12 +192,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
) : (
<div className="instance-grid">
{instances.map((instance) => (
<div key={instance.id} className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}>
{busyInstanceId === instance.id && (
<div className="instance-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div key={instance.id} className="instance-card">
<div className="instance-info">
<div className="instance-name">{instance.display_name}</div>
<div className="instance-meta">
@@ -241,13 +208,6 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
</span>
)}
</div>
{instance.selected_config_profile_id && (
<div className="instance-profile">
<span className="badge">
Profile: {configProfiles.find((p) => p.id === instance.selected_config_profile_id)?.name || instance.selected_config_profile_id}
</span>
</div>
)}
</div>
<div className="instance-actions">
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
@@ -267,7 +227,6 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
onClick={() => void handleRecreateTunnel(instance.id)}
type="button"
title="Recreate tunnel"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Fix Tunnel
@@ -280,67 +239,20 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<>
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) => setSelectedProfileForAction(e.target.value)}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
className="primary-button small"
onClick={() => void handleStart(instance.id, selectedProfileForAction || undefined)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => {
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(instance.selected_config_profile_id || "");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
)}
</>
<button
className="secondary-button small"
onClick={() => void handleStart(instance.id)}
type="button"
>
<Icon name="play" size="sm" />
Start
</button>
)}
{instance.status === "running" && (
<>
@@ -351,7 +263,6 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
Yes
</button>
@@ -359,7 +270,6 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
disabled={busyInstanceId === instance.id}
>
No
</button>
@@ -369,69 +279,23 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="stop" size="sm" />
</button>
)}
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) => setSelectedProfileForAction(e.target.value)}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
className="primary-button small"
onClick={() => void handleRestart(instance.id, selectedProfileForAction || undefined)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Restart
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => {
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(instance.selected_config_profile_id || "");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
</button>
)}
<button
className="ghost-button small"
onClick={() => void handleRestart(instance.id)}
type="button"
>
<Icon name="refresh" size="sm" />
</button>
</>
)}
<button
className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="delete" size="sm" />
</button>
@@ -445,18 +309,48 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>Launch Tool</h2>
<CreateSessionForm
projects={[]}
repositories={[]}
toolTypes={toolTypes}
fixedProjectId={projectId}
fixedRepoId={repoId}
projectName={projectName}
repoName={repoName}
onSuccess={handleCreateSuccess}
onCancel={() => setShowCreate(false)}
submitLabel="Launch"
/>
<div className="stack">
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select a tool...</option>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
</label>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
<div className="dialog-actions">
<button
className="secondary-button"
onClick={() => setShowCreate(false)}
type="button"
>
Cancel
</button>
<button
className="primary-button"
onClick={() => void handleCreate()}
disabled={!selectedToolType}
type="button"
>
Launch
</button>
</div>
</div>
</div>
</div>
)}
@@ -1,88 +0,0 @@
import { useEffect, useRef } from "react";
import { Icon } from "./icon";
import type { IconName } from "./icon";
export interface MobileActionSheetItem {
id: string;
label: string;
icon?: IconName;
variant?: "default" | "danger";
onClick: () => void;
}
interface MobileActionSheetProps {
isOpen: boolean;
onClose: () => void;
title: string;
actions: MobileActionSheetItem[];
}
export function MobileActionSheet({
isOpen,
onClose,
title,
actions,
}: MobileActionSheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="mobile-action-sheet-overlay" onClick={onClose}>
<div
ref={sheetRef}
className="mobile-action-sheet"
onClick={(e) => e.stopPropagation()}
>
<div className="mobile-action-sheet-header">
<div className="mobile-action-sheet-handle" />
<h3>{title}</h3>
</div>
<div className="mobile-action-sheet-actions">
{actions.map((action) => (
<button
key={action.id}
className={`mobile-action-sheet-button ${action.variant || "default"}`}
onClick={() => {
action.onClick();
onClose();
}}
type="button"
>
{action.icon && <Icon name={action.icon} size="md" />}
<span>{action.label}</span>
</button>
))}
</div>
<button
className="mobile-action-sheet-cancel"
onClick={onClose}
type="button"
>
Cancel
</button>
</div>
</div>
);
}
@@ -1,95 +0,0 @@
import { Icon } from "./icon";
interface Field {
label: string;
value: string | number | boolean | null;
type?: "text" | "code" | "json" | "boolean";
}
interface MobileDetailViewProps {
title: string;
subtitle?: string;
fields: Field[];
onEdit: () => void;
onDelete: () => void;
onBack: () => void;
}
export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
title,
subtitle,
fields,
onEdit,
onDelete,
onBack,
}) => {
const renderValue = (field: Field) => {
if (field.value === null || field.value === undefined) {
return <span className="text-muted">Not set</span>;
}
if (field.type === "boolean") {
return field.value ? (
<span className="badge badge-success">Yes</span>
) : (
<span className="badge badge-secondary">No</span>
);
}
if (field.type === "code" || field.type === "json") {
return (
<pre className="mobile-detail-code">
{typeof field.value === "string" ? field.value : JSON.stringify(field.value, null, 2)}
</pre>
);
}
return <span>{String(field.value)}</span>;
};
return (
<div className="mobile-detail-view">
<header className="mobile-detail-header">
<button
className="mobile-detail-back"
onClick={onBack}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="md" />
</button>
<div className="mobile-detail-header-content">
<h1 className="mobile-detail-title">{title}</h1>
{subtitle && <p className="mobile-detail-subtitle">{subtitle}</p>}
</div>
<div className="mobile-detail-actions">
<button
className="mobile-detail-action"
onClick={onEdit}
type="button"
aria-label="Edit"
>
<Icon name="edit" size="sm" />
</button>
<button
className="mobile-detail-action mobile-detail-action-danger"
onClick={onDelete}
type="button"
aria-label="Delete"
>
<Icon name="delete" size="sm" />
</button>
</div>
</header>
<div className="mobile-detail-fields">
{fields.map((field, index) => (
<div key={index} className="mobile-detail-field">
<label className="mobile-detail-field-label">{field.label}</label>
<div className="mobile-detail-field-value">{renderValue(field)}</div>
</div>
))}
</div>
</div>
);
};
@@ -1,162 +0,0 @@
import { useState } from "react";
import { Icon } from "./icon";
interface FormField {
name: string;
label: string;
type: "text" | "textarea" | "number" | "select" | "checkbox" | "code";
value: string | number | boolean;
options?: { value: string; label: string }[];
placeholder?: string;
required?: boolean;
rows?: number;
}
interface MobileEditViewProps {
title: string;
fields?: FormField[];
onSave: (data: Record<string, string | number | boolean>) => void;
onCancel: () => void;
isSaving?: boolean;
children?: React.ReactNode;
}
export const MobileEditView: React.FC<MobileEditViewProps> = ({
title,
fields,
onSave,
onCancel,
isSaving = false,
children,
}) => {
const [formData, setFormData] = useState<Record<string, string | number | boolean>>(
() => {
const initial: Record<string, string | number | boolean> = {};
fields?.forEach((field) => {
initial[field.name] = field.value;
});
return initial;
}
);
const handleChange = (name: string, value: string | number | boolean) => {
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<div className="mobile-edit-view">
<header className="mobile-edit-header">
<button
className="mobile-edit-cancel"
onClick={onCancel}
type="button"
disabled={isSaving}
>
Cancel
</button>
<h1 className="mobile-edit-title">{title}</h1>
<button
className="mobile-edit-save"
onClick={() => onSave(formData)}
type="button"
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
</header>
<form className="mobile-edit-form" onSubmit={handleSubmit}>
{children || fields?.map((field) => (
<div key={field.name} className="mobile-edit-field">
<label className="mobile-edit-field-label" htmlFor={field.name}>
{field.label}
{field.required && <span className="required">*</span>}
</label>
{field.type === "textarea" && (
<textarea
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) => handleChange(field.name, e.target.value)}
placeholder={field.placeholder}
required={field.required}
rows={field.rows || 4}
className="mobile-edit-input mobile-edit-textarea"
/>
)}
{field.type === "select" && (
<select
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) => handleChange(field.name, e.target.value)}
required={field.required}
className="mobile-edit-input"
>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{field.type === "checkbox" && (
<label className="mobile-edit-checkbox">
<input
type="checkbox"
id={field.name}
name={field.name}
checked={Boolean(formData[field.name])}
onChange={(e) => handleChange(field.name, e.target.checked)}
/>
<span>{field.label}</span>
</label>
)}
{field.type === "code" && (
<textarea
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) => handleChange(field.name, e.target.value)}
placeholder={field.placeholder}
required={field.required}
rows={field.rows || 8}
className="mobile-edit-input mobile-edit-code"
style={{ fontFamily: "monospace" }}
/>
)}
{(field.type === "text" || field.type === "number") && (
<input
type={field.type === "number" ? "number" : "text"}
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) =>
handleChange(
field.name,
field.type === "number"
? Number(e.target.value)
: e.target.value
)
}
placeholder={field.placeholder}
required={field.required}
className="mobile-edit-input"
/>
)}
</div>
))}
</form>
</div>
);
};
-22
View File
@@ -1,22 +0,0 @@
import { Icon } from "./icon";
interface MobileFABProps {
onClick: () => void;
label?: string;
}
export const MobileFAB: React.FC<MobileFABProps> = ({
onClick,
label = "Create new",
}) => {
return (
<button
className="mobile-fab"
onClick={onClick}
type="button"
aria-label={label}
>
<Icon name="add" size="md" />
</button>
);
};
@@ -1,78 +0,0 @@
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
interface MobileListItem {
id: string;
title: string;
subtitle?: string;
icon?: string;
status?: string;
}
interface MobileListViewProps {
items: MobileListItem[];
onItemClick: (id: string) => void;
onItemDelete?: (id: string) => void;
onItemDuplicate?: (id: string) => void;
emptyMessage?: string;
searchPlaceholder?: string;
onSearch?: (query: string) => void;
}
export const MobileListView: React.FC<MobileListViewProps> = ({
items,
onItemClick,
onItemDelete,
onItemDuplicate,
emptyMessage = "No items found",
searchPlaceholder = "Search...",
onSearch,
}) => {
return (
<div className="mobile-list-view">
{onSearch && (
<div className="mobile-list-search">
<input
type="search"
placeholder={searchPlaceholder}
onChange={(e) => onSearch(e.target.value)}
className="mobile-list-search-input"
/>
</div>
)}
{items.length === 0 ? (
<div className="mobile-list-empty">
<Icon name="folder" size="lg" />
<p>{emptyMessage}</p>
</div>
) : (
<div className="mobile-list-items">
{items.map((item) => (
<button
key={item.id}
className="mobile-list-item"
onClick={() => onItemClick(item.id)}
type="button"
>
{item.icon && (
<div className="mobile-list-item-icon">
<Icon name={item.icon as IconName} size="md" />
</div>
)}
<div className="mobile-list-item-content">
<div className="mobile-list-item-title">{item.title}</div>
{item.subtitle && (
<div className="mobile-list-item-subtitle">{item.subtitle}</div>
)}
</div>
<div className="mobile-list-item-actions" style={{ transform: "rotate(180deg)" }}>
<Icon name="arrow-left" size="sm" />
</div>
</button>
))}
</div>
)}
</div>
);
};
-87
View File
@@ -1,87 +0,0 @@
import { useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { Icon } from "./icon";
import { ToolsBottomSheet } from "./tools-bottom-sheet";
import type { IconName } from "../utils/icons";
interface MobileNavProps {
sessionCount?: number;
}
interface NavItem {
to: string;
label: string;
icon: IconName;
isGroup?: boolean;
}
const MOBILE_NAV_ITEMS: NavItem[] = [
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/sessions", label: "Sessions", icon: "terminal" },
{ to: "/tools", label: "Tools", icon: "settings", isGroup: true },
{ to: "/settings", label: "Settings", icon: "settings" },
];
export const MobileNav: React.FC<MobileNavProps> = ({ sessionCount }) => {
const location = useLocation();
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const isToolsActive =
location.pathname === "/tool-workshop" ||
location.pathname === "/config-profiles";
const handleNavClick = (item: NavItem) => {
if (item.isGroup) {
setToolsSheetOpen(true);
}
};
return (
<>
<nav className="mobile-nav" role="navigation" aria-label="Mobile navigation">
{MOBILE_NAV_ITEMS.map((item) => {
if (item.isGroup) {
return (
<button
key={item.to}
className={`mobile-nav-item ${isToolsActive ? "active" : ""}`}
onClick={() => handleNavClick(item)}
type="button"
>
<div className="mobile-nav-icon-wrapper">
<Icon name={item.icon} size="md" />
</div>
<span className="mobile-nav-label">{item.label}</span>
</button>
);
}
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`mobile-nav-item ${isActive ? "active" : ""}`
}
end={item.to === "/"}
>
<div className="mobile-nav-icon-wrapper">
<Icon name={item.icon} size="md" />
{item.to === "/sessions" && sessionCount ? (
<span className="mobile-nav-badge">{sessionCount}</span>
) : null}
</div>
<span className="mobile-nav-label">{item.label}</span>
</NavLink>
);
})}
</nav>
<ToolsBottomSheet
isOpen={toolsSheetOpen}
onClose={() => setToolsSheetOpen(false)}
/>
</>
);
};
@@ -1,33 +0,0 @@
import { useNavigate } from "react-router-dom";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { Icon } from "./icon";
interface MobilePageHeaderProps {
title: string;
showBack?: boolean;
actions?: React.ReactNode;
}
export function MobilePageHeader({ title, showBack = true, actions }: MobilePageHeaderProps) {
const navigate = useNavigate();
const isMobile = useMobileViewport();
if (!isMobile) return null;
return (
<div className="mobile-page-header">
{showBack && (
<button
className="mobile-page-header-back"
onClick={() => navigate(-1)}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="md" />
</button>
)}
<h1>{title}</h1>
{actions && <div className="mobile-page-header-actions">{actions}</div>}
</div>
);
}
@@ -1,94 +0,0 @@
import React from "react";
import { Icon } from "./icon";
interface MobileTerminalHeaderProps {
instanceName?: string;
onBack?: () => void;
onMenuToggle?: () => void;
onClose?: () => void;
onFontSizeChange?: (delta: number) => void;
isVisible: boolean;
connectionStatus?: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
instanceName,
onBack,
onMenuToggle,
onClose,
onFontSizeChange,
isVisible,
connectionStatus = "connecting",
}) => {
return (
<div
className={`mobile-terminal-header ${isVisible ? "visible" : "hidden"}`}
>
<div className="mobile-terminal-header-left">
{onBack && (
<button
className="mobile-terminal-header-button"
onClick={onBack}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="sm" />
</button>
)}
{onMenuToggle && (
<button
className="mobile-terminal-header-button"
onClick={onMenuToggle}
type="button"
aria-label="Toggle menu"
>
<Icon name="menu" size="sm" />
</button>
)}
</div>
<div className="mobile-terminal-header-center">
<span className="mobile-terminal-header-title">
{instanceName || "Terminal"}
</span>
<span
className={`mobile-terminal-header-status ${connectionStatus}`}
aria-label={`Connection status: ${connectionStatus}`}
/>
</div>
<div className="mobile-terminal-header-right">
{onFontSizeChange && (
<>
<button
className="mobile-terminal-header-button"
onClick={() => onFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button
className="mobile-terminal-header-button"
onClick={() => onFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
</>
)}
{onClose && (
<button
className="mobile-terminal-header-button"
onClick={onClose}
type="button"
aria-label="Close terminal"
>
<Icon name="close" size="sm" />
</button>
)}
</div>
</div>
);
};
@@ -1,116 +0,0 @@
import React, { useState, useCallback } from "react";
import { TerminalComponent } from "./terminal";
import { MobileTerminalHeader } from "./mobile-terminal-header";
import { SpecialKeysStrip } from "./special-keys-strip";
import { SpecialKeysPanel } from "./special-keys-panel";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useAutoHide } from "../hooks/use-auto-hide";
import type { ModifierKey } from "../hooks/use-special-keys";
interface MobileTerminalWrapperProps {
instanceId: string;
instanceName?: string;
onClose?: () => void;
onBack?: () => void;
onMenuToggle?: () => void;
}
export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
instanceId,
instanceName,
onClose,
onBack,
onMenuToggle,
}) => {
const isMobile = useMobileViewport();
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [showPanel, setShowPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
const [terminalRef, setTerminalRef] = useState<{
sendData: (data: string) => void;
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
focusInput: () => void;
changeFontSize: (delta: number) => void;
} | null>(null);
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const handleTerminalTap = useCallback(() => {
headerAutoHide.toggle();
}, [headerAutoHide]);
const handleTerminalReady = useCallback(
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
},
[]
);
const handleSendKey = useCallback(
(data: string) => {
terminalRef?.sendData(data);
},
[terminalRef]
);
if (!isMobile) {
return (
<TerminalComponent
instanceId={instanceId}
onClose={onClose}
isMobile={false}
/>
);
}
return (
<div className="mobile-terminal-wrapper">
<MobileTerminalHeader
instanceName={instanceName}
onBack={onBack}
onMenuToggle={onMenuToggle}
onClose={onClose}
onFontSizeChange={(delta) => terminalRef?.changeFontSize(delta)}
isVisible={headerAutoHide.isVisible}
connectionStatus={terminalRef?.connectionStatus}
/>
<div
className="mobile-terminal-content"
style={{
paddingBottom: isKeyboardOpen ? keyboardHeight : 0,
}}
onClick={handleTerminalTap}
>
<TerminalComponent
instanceId={instanceId}
onClose={onClose}
isMobile={true}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
onTerminalReady={handleTerminalReady}
/>
</div>
<SpecialKeysStrip
onSend={handleSendKey}
isVisible={!showPanel}
onMoreClick={() => setShowPanel(true)}
onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
<SpecialKeysPanel
onSend={handleSendKey}
isOpen={showPanel}
onClose={() => setShowPanel(false)}
onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
</div>
);
};
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { Icon } from "./icon";
type CreateMode = "clone" | "blank";
@@ -21,14 +20,12 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
const [owner, setOwner] = useState("");
const [repoName, setRepoName] = useState("");
const [advancedUrl, setAdvancedUrl] = useState("");
const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
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 [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@@ -38,19 +35,6 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
}
}, [open]);
useEffect(() => {
if (!open) return;
const loadKeys = async () => {
try {
const data = await listSSHKeys();
setSshKeys(data);
} catch {
// ignore
}
};
void loadKeys();
}, [open]);
useEffect(() => {
if (!open) return;
if (!useAdvancedUrl) {
@@ -97,10 +81,9 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
setOwner("");
setRepoName("");
setAdvancedUrl("");
setUseAdvancedUrl(true);
setUseAdvancedUrl(false);
setFormError(null);
setUrlValidation({ status: "idle", result: null });
setSelectedSshKey("");
};
const handleClose = () => {
@@ -137,9 +120,6 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
}
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
}
if (selectedSshKey) {
input.ssh_key_id = selectedSshKey;
}
}
await createRepository(projectId, input);
@@ -232,20 +212,6 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
placeholder="repo-name"
/>
</label>
<label className="form-field">
SSH Key
<select
value={selectedSshKey}
onChange={(event) => setSelectedSshKey(event.target.value)}
>
<option value="">Select SSH key (optional)...</option>
{sshKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</label>
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
<button
type="button"
@@ -257,61 +223,45 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
</>
)}
{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
<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>
)}
{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 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>
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
</label>
<label className="form-field">
SSH Key
<select
value={selectedSshKey}
onChange={(event) => setSelectedSshKey(event.target.value)}
>
<option value="">Select SSH key (optional)...</option>
{sshKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</label>
</div>
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
<button
type="button"
className="secondary-button small"
@@ -319,7 +269,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
>
Use owner/repo instead
</button>
</>
</label>
)}
{formError && (
<div className="error-message">
-338
View File
@@ -1,338 +0,0 @@
import { useState } from "react";
import type { Session } from "../api/sessions";
import { Icon } from "./icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { MobileActionSheet } from "./mobile-action-sheet";
import type { IconName } from "./icon";
export interface SessionCardProps {
session: Session;
onOpen?: (session: Session) => void;
onStart?: (session: Session) => void;
onStop?: (session: Session) => void;
onDelete?: (session: Session) => void;
onRecreateTunnel?: (session: Session) => void;
isBusy?: boolean;
tunnelHealth?: {
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;
} | null;
}
const statusConfig: Record<string, { color: string; label: string }> = {
running: { color: "green", label: "Running" },
building: { color: "yellow", label: "Building" },
starting: { color: "yellow", label: "Starting" },
probing: { color: "yellow", label: "Probing" },
pending: { color: "yellow", label: "Pending" },
stopped: { color: "gray", label: "Stopped" },
error: { color: "red", label: "Error" },
unhealthy: { color: "orange", label: "Unhealthy" },
};
export function SessionCard({
session,
onOpen,
onStart,
onStop,
onDelete,
onRecreateTunnel,
isBusy = false,
tunnelHealth = null,
}: SessionCardProps) {
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showActionSheet, setShowActionSheet] = useState(false);
const isMobile = useMobileViewport();
const status = statusConfig[session.status] || { color: "gray", label: session.status };
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
const hasTunnelError = !isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
const hasAppError = !isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
const handleStop = () => {
if (showStopConfirm) {
setShowStopConfirm(false);
onStop?.(session);
} else {
setShowStopConfirm(true);
}
};
const handleDelete = () => {
if (showDeleteConfirm) {
setShowDeleteConfirm(false);
onDelete?.(session);
} else {
setShowDeleteConfirm(true);
}
};
const handleCancelStop = () => setShowStopConfirm(false);
const handleCancelDelete = () => setShowDeleteConfirm(false);
const isActive = ["running", "building", "starting", "probing", "pending", "unhealthy"].includes(session.status);
return (
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
{isBusy && (
<div className="session-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div className="session-card-content">
<div className="session-card-header">
<div className="session-card-title">
<h4>{session.display_name}</h4>
<div className="session-card-status-badges">
<span className={`status-badge ${status.color}`}>{status.label}</span>
{hasTunnelError && (
<span className="status-badge error">Tunnel Error</span>
)}
{hasAppError && (
<span className="status-badge warning">App Error {tunnelHealth?.tunnel_status_code}</span>
)}
</div>
</div>
<p className="muted session-card-meta">
{session.tool_type_name}
{session.project_name && ` · ${session.project_name}`}
{session.repository_name && ` · ${session.repository_name}`}
</p>
{session.clone_mode && (
<p className="muted session-card-meta">
<Icon name="branch" size="sm" />
{session.clone_mode === "clone"
? `Clone${session.branch ? ` (${session.branch})` : ""}`
: "Mount"}
</p>
)}
{session.url && (
<p className="session-card-url">
<a href={session.url} target="_blank" rel="noopener noreferrer">
{session.url}
</a>
</p>
)}
{session.created_at && (
<p className="muted session-card-meta">
Created: {new Date(session.created_at).toLocaleString()}
</p>
)}
</div>
</div>
{isMobile ? (
<div className="session-card-actions mobile">
{isActive && (
<>
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button mobile-primary"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button
className="secondary-button mobile-primary"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
Open
</button>
)}
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
</>
)}
{!isActive && onStart && (
<button
className="secondary-button mobile-primary"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
Start
</button>
)}
{!isActive && (
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
)}
</div>
) : (
<div className="session-card-actions">
{isActive && (
<>
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</a>
) : (
<button
className="secondary-button small"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</button>
)}
{hasTunnelError && onRecreateTunnel && (
<button
className="secondary-button small"
onClick={() => onRecreateTunnel(session)}
type="button"
disabled={isBusy}
>
<Icon name="refresh" size="sm" />
<span className="action-label">Tunnel</span>
</button>
)}
{showStopConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Stop?</span>
<button
className="danger-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
Stop
</button>
<button
className="ghost-button small"
onClick={handleCancelStop}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
<Icon name="stop" size="sm" />
<span className="action-label">Stop</span>
</button>
)}
</>
)}
{!isActive && onStart && (
<button
className="secondary-button small"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
<span className="action-label">Start</span>
</button>
)}
{showDeleteConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Delete?</span>
<button
className="danger-button small"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
Delete
</button>
<button
className="ghost-button small"
onClick={handleCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
)}
<MobileActionSheet
isOpen={showActionSheet}
onClose={() => setShowActionSheet(false)}
title={session.display_name}
actions={[
...(isActive && hasTunnelError && onRecreateTunnel
? [{
id: "tunnel",
label: "Recreate Tunnel",
icon: "refresh" as IconName,
onClick: () => onRecreateTunnel(session),
}]
: []),
...(isActive && onStop
? [{
id: "stop",
label: "Stop",
icon: "stop" as IconName,
variant: "danger" as const,
onClick: () => onStop(session),
}]
: []),
...(onDelete
? [{
id: "delete",
label: "Delete",
icon: "delete" as IconName,
variant: "danger" as const,
onClick: () => onDelete(session),
}]
: []),
]}
/>
</article>
);
}
-125
View File
@@ -1,125 +0,0 @@
import type { Session } from "../api/sessions";
import { SessionCard } from "./session-card";
import type { InstanceHealth } from "../api/sessions";
export interface SessionListProps {
sessions: Session[];
onOpen?: (session: Session) => void;
onStart?: (session: Session) => void;
onStop?: (session: Session) => void;
onDelete?: (session: Session) => void;
onRecreateTunnel?: (session: Session) => void;
actionBusyId?: string | null;
tunnelHealth?: Record<string, InstanceHealth>;
showGrouping?: boolean;
activeTitle?: string;
recentTitle?: string;
maxRecent?: number;
emptyMessage?: string;
}
const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"];
const recentStatuses = ["stopped", "error"];
export function SessionList({
sessions,
onOpen,
onStart,
onStop,
onDelete,
onRecreateTunnel,
actionBusyId = null,
tunnelHealth = {},
showGrouping = true,
activeTitle = "Active Sessions",
recentTitle = "Recent Sessions",
maxRecent = 5,
emptyMessage = "No sessions",
}: SessionListProps) {
const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status));
const recentSessions = sessions
.filter((s) => recentStatuses.includes(s.status))
.slice(0, maxRecent);
if (!showGrouping) {
return (
<div className="sessions-grid">
{sessions.length === 0 ? (
<p className="muted">{emptyMessage}</p>
) : (
sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))
)}
</div>
);
}
return (
<div className="session-list">
{/* Active Sessions */}
<div className="session-group">
<div className="session-group-header">
<h3>{activeTitle}</h3>
{activeSessions.length > 0 && (
<span className="badge">{activeSessions.length}</span>
)}
</div>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions</p>
) : (
<div className="sessions-grid">
{activeSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))}
</div>
)}
</div>
{/* Recent Sessions */}
{recentSessions.length > 0 && (
<div className="session-group">
<div className="session-group-header">
<h3>{recentTitle}</h3>
<span className="badge">{recentSessions.length}</span>
</div>
<div className="sessions-grid">
{recentSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))}
</div>
</div>
)}
</div>
);
}
@@ -1,114 +0,0 @@
import React from "react";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
interface SpecialKeysPanelProps {
onSend: (data: string) => void;
isOpen: boolean;
onClose: () => void;
onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
}
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "home", label: "Home" },
{ key: "end", label: "End" },
{ key: "pageup", label: "PgUp" },
{ key: "pagedown", label: "PgDn" },
{ key: "ctrlc", label: "Ctrl+C" },
{ key: "ctrld", label: "Ctrl+D" },
{ key: "ctrlz", label: "Ctrl+Z" },
];
const F_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "f1", label: "F1" },
{ key: "f2", label: "F2" },
{ key: "f3", label: "F3" },
{ key: "f4", label: "F4" },
{ key: "f5", label: "F5" },
{ key: "f6", label: "F6" },
{ key: "f7", label: "F7" },
{ key: "f8", label: "F8" },
{ key: "f9", label: "F9" },
{ key: "f10", label: "F10" },
{ key: "f11", label: "F11" },
{ key: "f12", label: "F12" },
];
export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
onSend,
isOpen,
onClose,
onKeepFocus,
activeModifier,
onModifierChange,
}) => {
if (!isOpen) return null;
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault();
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
onClose();
// Always refocus terminal after sending
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
const handleOverlayPointerDown = (e: React.PointerEvent) => {
e.preventDefault();
onModifierChange(null);
onClose();
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
return (
<div
className="special-keys-panel-overlay"
onPointerDown={handleOverlayPointerDown}
>
<div
className="special-keys-panel"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="special-keys-panel-section">
{EXPANDED_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
>
{label}
</button>
))}
</div>
<div className="special-keys-panel-divider" />
<div className="special-keys-panel-section">
{F_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
>
{label}
</button>
))}
</div>
</div>
</div>
);
};
@@ -1,96 +0,0 @@
import React from "react";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
interface SpecialKeysStripProps {
onSend: (data: string) => void;
isVisible: boolean;
onMoreClick?: () => void;
onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
}
const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [
{ key: "escape", label: "Esc" },
{ key: "tab", label: "Tab" },
{ key: "ctrl", label: "Ctrl", isModifier: true },
{ key: "alt", label: "Alt", isModifier: true },
{ key: "up", label: "↑" },
{ key: "down", label: "↓" },
{ key: "left", label: "←" },
{ key: "right", label: "→" },
];
export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
onSend,
isVisible,
onMoreClick,
onKeepFocus,
activeModifier,
onModifierChange,
}) => {
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault();
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
onModifierChange(activeModifier === key ? null : key);
requestAnimationFrame(() => {
onKeepFocus?.();
});
return;
}
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
// Always refocus terminal after sending
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
const handleMorePointerDown = (e: React.PointerEvent) => {
e.preventDefault();
onMoreClick?.();
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
return (
<div className={`special-keys-strip ${isVisible ? "visible" : "hidden"}`}>
{PRIMARY_KEYS.map(({ key, label, isModifier }) => (
<button
key={key}
className={`special-key-button ${
isModifier && activeModifier === key ? "active-modifier" : ""
}`}
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
aria-label={`Send ${label}`}
aria-pressed={isModifier && activeModifier === key}
>
{label}
</button>
))}
{onMoreClick && (
<button
className="special-key-button special-key-more"
onPointerDown={handleMorePointerDown}
type="button"
tabIndex={-1}
aria-label="More special keys"
>
More
</button>
)}
</div>
);
};
+79 -539
View File
@@ -1,213 +1,29 @@
import React, { useEffect, useRef, useState, useCallback } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css";
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
interface TerminalProps {
instanceId: string;
onClose?: () => void;
isMobile?: boolean;
activeModifier?: ModifierKey | null;
onModifierChange?: (modifier: ModifierKey | null) => void;
onTerminalReady?: (
sendData: (data: string) => void,
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void
) => void;
}
const FONT_SIZE_KEY = "terminal-font-size";
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
const RECONNECT_ATTEMPTS = 3;
const RECONNECT_DELAY_BASE = 1000;
export const TerminalComponent: React.FC<TerminalProps> = ({
instanceId,
onClose,
isMobile = false,
activeModifier,
onModifierChange,
onTerminalReady,
}) => {
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
const terminalRef = useRef<HTMLDivElement>(null);
const hiddenInputRef = useRef<HTMLInputElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const reconnectAttemptsRef = useRef(0);
const onTerminalReadyRef = useRef(onTerminalReady);
onTerminalReadyRef.current = onTerminalReady;
const [status, setStatus] = useState<
"connecting" | "connected" | "disconnected" | "error" | "resetting"
>("connecting");
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
"connecting",
);
const [error, setError] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => {
if (typeof window === "undefined") return isMobile ? 16 : 14;
const stored = localStorage.getItem(FONT_SIZE_KEY);
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
});
const lastPingRef = useRef<number>(0);
const heartbeatCheckRef = useRef<number | null>(null);
const isUnmountingRef = useRef(false);
const calculateFontSize = useCallback(() => {
if (!isMobile) return fontSize;
const vw = window.innerWidth;
const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25));
return Math.round(calculated);
}, [isMobile, fontSize]);
const connectWebSocket = useCallback(() => {
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
console.log(`[Terminal WS] Connecting to ${wsUrl} (attempt ${reconnectAttemptsRef.current + 1}/${RECONNECT_ATTEMPTS + 1})`);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log(`[Terminal WS] Connected successfully`);
setStatus("connected");
setError(null);
reconnectAttemptsRef.current = 0;
lastPingRef.current = Date.now();
// Send current terminal size immediately on connect
if (termRef.current) {
const { cols, rows } = termRef.current;
// Only send if we have valid dimensions
if (cols > 0 && rows > 0) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
// Start heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
}
heartbeatCheckRef.current = window.setInterval(() => {
const elapsed = Date.now() - lastPingRef.current;
console.log(`[Terminal WS] Heartbeat check: lastPing=${elapsed}ms ago`);
if (elapsed > 60000) {
// No ping for 60 seconds, connection may be dead
console.warn("[Terminal WS] Heartbeat timeout (>60s), closing connection");
ws.close(4000, "Heartbeat timeout");
}
}, 30000);
};
ws.onmessage = (event) => {
if (!termRef.current) return;
if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer);
termRef.current?.write(data);
});
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status") {
if (msg.status === "connected") {
setStatus("connected");
setError(null);
// Clear terminal and refit after reset/reconnect
if (termRef.current) {
termRef.current.clear();
requestAnimationFrame(() => {
if (fitAddonRef.current && termRef.current) {
fitAddonRef.current.fit();
const { cols, rows } = termRef.current;
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
});
}
} else if (msg.status === "resetting") {
setStatus("resetting");
}
} else if (msg.type === "ping") {
// Respond with pong and update last ping time
lastPingRef.current = Date.now();
console.log(`[Terminal WS] Received ping, sending pong`);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
}
} catch {
termRef.current?.write(event.data);
}
}
};
ws.onclose = (event) => {
console.log(`[Terminal WS] Connection closed: code=${event.code}, reason="${event.reason}", wasClean=${event.wasClean}, attempts=${reconnectAttemptsRef.current}`);
setStatus("disconnected");
// Clean up heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null;
}
if (event.code !== 1000 && event.code !== 4000) {
setError(`Connection closed (code: ${event.code})`);
// Attempt reconnection
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
reconnectAttemptsRef.current++;
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
console.log(`[Terminal WS] Will retry in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})`);
setTimeout(() => {
if (isUnmountingRef.current) {
console.log(`[Terminal WS] Component unmounting, skipping reconnect`);
return;
}
if (document.visibilityState !== "hidden") {
connectWebSocket();
} else {
console.log(`[Terminal WS] Tab hidden, skipping reconnect`);
}
}, delay);
} else {
console.log(`[Terminal WS] Max reconnection attempts (${RECONNECT_ATTEMPTS}) reached`);
}
} else if (event.code === 4000) {
// Server closed old connection for concurrent connection - don't reconnect
// The new connection is already established
console.log(`[Terminal WS] Server closed old connection (concurrent/heartbeat)`);
}
};
ws.onerror = (error) => {
console.error(`[Terminal WS] Error event fired`, error);
setStatus("error");
setError("WebSocket error");
};
return ws;
}, [instanceId]);
useEffect(() => {
if (!terminalRef.current) return;
// Initialize terminal
const currentFontSize = calculateFontSize();
const term = new Terminal({
cursorBlink: true,
fontSize: currentFontSize,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#1e1e1e",
@@ -233,386 +49,110 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
},
});
termRef.current = term;
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
const container = terminalRef.current;
term.open(terminalRef.current);
fitAddon.fit();
// Define fitTerminal before connectWebSocket so it's available in onmessage
const fitTerminal = () => {
if (!fitAddonRef.current || !termRef.current) return;
const oldCols = termRef.current.cols;
const oldRows = termRef.current.rows;
try {
fitAddonRef.current.fit();
} catch {
// Ignore fit errors during initialization
return;
}
const { cols, rows } = termRef.current;
console.log(`[Terminal] fit() result: ${cols}x${rows} (was ${oldCols}x${oldRows})`);
// Force refresh if dimensions are valid
if (cols > 0 && rows > 0) {
// Build WebSocket URL
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
// Connect WebSocket
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
};
ws.onmessage = (event) => {
if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer);
term.write(data);
});
} else if (typeof event.data === "string") {
try {
termRef.current.refresh(0, rows - 1);
const msg = JSON.parse(event.data);
if (msg.type === "status" && msg.status === "connected") {
setStatus("connected");
}
} catch {
// Ignore refresh errors
term.write(event.data);
}
}
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) {
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
};
ws.onclose = (event) => {
setStatus("disconnected");
if (event.code !== 1000) {
setError(`Connection closed (code: ${event.code})`);
}
};
// Open xterm first (must happen before fit)
term.open(container);
const ws = connectWebSocket();
// Initial fit after layout settles (terminal must be opened first)
let fitAttempts = 0;
const doInitialFit = () => {
if (!container.isConnected) return;
fitAttempts++;
// Ensure container has dimensions before fitting
if (container.clientWidth > 0 && container.clientHeight > 0) {
console.log(`[Terminal] Container ready: ${container.clientWidth}x${container.clientHeight} (attempt ${fitAttempts})`);
fitTerminal();
} else if (fitAttempts < 50) {
// Container not ready yet, try again (max 50 attempts ~ 1s)
console.log(`[Terminal] Container not ready: ${container.clientWidth}x${container.clientHeight} (attempt ${fitAttempts})`);
requestAnimationFrame(doInitialFit);
} else {
console.warn(`[Terminal] Container never got dimensions after ${fitAttempts} attempts`);
}
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
requestAnimationFrame(doInitialFit);
// Refit after font load (metrics may change)
document.fonts.ready.then(() => {
requestAnimationFrame(() => fitTerminal());
});
// Handle terminal input
term.onData((data) => {
const currentWs = wsRef.current;
if (currentWs?.readyState !== WebSocket.OPEN) return;
// Apply active modifier to single-character input
const modifier = activeModifierRef.current;
if (modifier && data.length === 1) {
const modified = applyModifierToChar(data, modifier);
if (modified) {
currentWs.send(modified);
onModifierChange?.(null);
return;
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
currentWs.send(data);
});
// Handle container resize with ResizeObserver for accurate dimension tracking
let resizeTimeout: ReturnType<typeof setTimeout>;
let lastWidth = 0;
let lastHeight = 0;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
// Only trigger if dimensions actually changed
if (width === lastWidth && height === lastHeight) return;
lastWidth = width;
lastHeight = height;
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
requestAnimationFrame(() => {
if (!container.isConnected) return;
fitTerminal();
});
}, 50);
});
resizeObserver.observe(container);
// Window resize fallback (for viewport changes that don't affect container dimensions)
let windowResizeTimeout: ReturnType<typeof setTimeout>;
const handleWindowResize = () => {
clearTimeout(windowResizeTimeout);
windowResizeTimeout = setTimeout(() => {
requestAnimationFrame(() => fitTerminal());
}, 250);
};
window.addEventListener("resize", handleWindowResize);
// Refit after mobile header auto-hides (3s delay + 0.3s transition)
const headerHideTimeout = setTimeout(() => {
fitTerminal();
}, 4000);
// Notify parent about terminal readiness
if (onTerminalReadyRef.current) {
const sendData = (data: string) => {
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(data);
}
};
const focusInput = () => {
termRef.current?.focus();
};
const changeFontSize = (delta: number) => {
handleFontSizeChange(delta);
};
onTerminalReadyRef.current(sendData, status, focusInput, changeFontSize);
}
// Visibility API for reconnection
const handleVisibilityChange = () => {
console.log(`[Terminal WS] Visibility changed to: ${document.visibilityState}, wsState=${ws?.readyState}`);
if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) {
console.log(`[Terminal WS] Tab visible, resetting reconnect attempts and reconnecting`);
reconnectAttemptsRef.current = 0;
connectWebSocket();
// Handle resize
const handleResize = () => {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
}),
);
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("resize", handleResize);
// Initial resize
setTimeout(handleResize, 100);
return () => {
isUnmountingRef.current = true;
clearTimeout(resizeTimeout);
clearTimeout(windowResizeTimeout);
clearTimeout(headerHideTimeout);
resizeObserver.disconnect();
window.removeEventListener("resize", handleWindowResize);
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (ws) {
ws.close(1000, "Component unmounting");
}
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null;
}
window.removeEventListener("resize", handleResize);
ws.close();
term.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instanceId, connectWebSocket]);
// Update parent about status changes
useEffect(() => {
if (onTerminalReady && termRef.current) {
const sendData = (data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data);
}
};
const focusInput = () => {
termRef.current?.focus();
};
const changeFontSize = (delta: number) => {
handleFontSizeChange(delta);
};
onTerminalReady(sendData, status, focusInput, changeFontSize);
}
}, [status, onTerminalReady]);
const handleFontSizeChange = (delta: number) => {
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
setFontSize(newSize);
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
if (termRef.current && fitAddonRef.current) {
termRef.current.options.fontSize = newSize;
requestAnimationFrame(() => {
if (termRef.current && fitAddonRef.current) {
try {
fitAddonRef.current.fit();
const { cols, rows } = termRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: "resize",
cols,
rows,
})
);
}
} catch {
// Ignore fit errors during re-initialization
}
}
});
}
};
const handleCopy = async () => {
if (!termRef.current) return;
const selection = termRef.current.getSelection();
if (selection) {
try {
await navigator.clipboard.writeText(selection);
} catch {
// Fallback for older browsers
const textarea = document.createElement("textarea");
textarea.value = selection;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
}
};
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(text);
}
} catch {
// Clipboard API not available
}
};
// Focus terminal on mobile to keep keyboard open
const handleTerminalClick = () => {
if (isMobile && termRef.current) {
termRef.current.focus();
}
};
}, [instanceId]);
return (
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
<div className="terminal-wrapper">
<div className="terminal-header">
<div className="terminal-header-left">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">
{status === "resetting"
? "Resetting..."
: reconnectAttemptsRef.current > 0 && status !== "connected"
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
: status}
</span>
</div>
{isMobile && (
<>
<button
className="terminal-header-button"
onClick={handleCopy}
type="button"
aria-label="Copy selection"
>
Copy
</button>
<button
className="terminal-header-button"
onClick={handlePaste}
type="button"
aria-label="Paste from clipboard"
>
Paste
</button>
</>
)}
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">{status}</span>
</div>
<div className="terminal-header-right">
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={() => setShowResetConfirm(true)}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div>
)}
</div>
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>Reset terminal? This will kill the current shell session and start fresh.</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={() => setShowResetConfirm(false)}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
setShowResetConfirm(false);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "reset" }));
}
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
{error && (
<div className="terminal-error">
{error}
{status === "error" && (
<button
className="terminal-reconnect"
onClick={() => {
reconnectAttemptsRef.current = 0;
connectWebSocket();
}}
type="button"
>
Reconnect
</button>
)}
</div>
)}
<div
ref={terminalRef}
className="terminal-container"
onClick={handleTerminalClick}
/>
{isMobile && (
<input
ref={hiddenInputRef}
type="text"
className="terminal-hidden-input"
aria-hidden="true"
/>
)}
{error && <div className="terminal-error">{error}</div>}
<div ref={terminalRef} className="terminal-container" />
</div>
);
};
@@ -1,62 +0,0 @@
import { useLocation, useNavigate } from "react-router-dom";
import { Icon } from "./icon";
interface ToolsBottomSheetProps {
isOpen: boolean;
onClose: () => void;
}
const TOOLS_ITEMS = [
{ to: "/tool-workshop", label: "Tool Workshop" },
{ to: "/config-profiles", label: "Config Profiles" },
];
export const ToolsBottomSheet: React.FC<ToolsBottomSheetProps> = ({
isOpen,
onClose,
}) => {
const location = useLocation();
const navigate = useNavigate();
if (!isOpen) return null;
const handleSelect = (to: string) => {
onClose();
navigate(to);
};
return (
<div
className="mobile-bottom-sheet-overlay"
onClick={onClose}
role="presentation"
>
<div
className="mobile-bottom-sheet"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Tools menu"
>
<div className="mobile-bottom-sheet-header">
<div className="mobile-bottom-sheet-handle" />
<h3 className="mobile-bottom-sheet-title">Tools</h3>
</div>
<div className="mobile-bottom-sheet-content">
{TOOLS_ITEMS.map((item) => (
<button
key={item.to}
className={`mobile-bottom-sheet-item ${
location.pathname === item.to ? "active" : ""
}`}
onClick={() => handleSelect(item.to)}
type="button"
>
<span className="mobile-bottom-sheet-item-label">{item.label}</span>
{location.pathname === item.to && <Icon name="success" size="sm" />}
</button>
))}
</div>
</div>
</div>
);
};
-42
View File
@@ -1,42 +0,0 @@
import { useCallback, useEffect, useState } from "react";
type AsyncStatus = "idle" | "loading" | "ready" | "error";
interface UseAsyncDataResult<T> {
data: T | null;
status: AsyncStatus;
error: string | null;
reload: () => void;
}
export function useAsyncData<T>(
fetcher: () => Promise<T>,
deps: React.DependencyList = []
): UseAsyncDataResult<T> {
const [data, setData] = useState<T | null>(null);
const [status, setStatus] = useState<AsyncStatus>("idle");
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setStatus("loading");
setError(null);
try {
const result = await fetcher();
setData(result);
setStatus("ready");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data");
setStatus("error");
}
}, deps);
const reload = useCallback(() => {
void load();
}, [load]);
useEffect(() => {
void load();
}, [load]);
return { data, status, error, reload };
}
-68
View File
@@ -1,68 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
interface AutoHideOptions {
timeout?: number;
enabled?: boolean;
}
export function useAutoHide(options: AutoHideOptions = {}) {
const { timeout = 3000, enabled = true } = options;
const [isVisible, setIsVisible] = useState(true);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastInteractionRef = useRef(Date.now());
const show = useCallback(() => {
if (!enabled) return;
setIsVisible(true);
lastInteractionRef.current = Date.now();
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setIsVisible(false);
}, timeout);
}, [enabled, timeout]);
const hide = useCallback(() => {
if (!enabled) return;
setIsVisible(false);
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, [enabled]);
const toggle = useCallback(() => {
if (!enabled) return;
if (isVisible) {
hide();
} else {
show();
}
}, [enabled, isVisible, show, hide]);
useEffect(() => {
if (!enabled) {
setIsVisible(true);
return;
}
// Start the timer initially
show();
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [enabled, show]);
return {
isVisible,
show,
hide,
toggle,
};
}
-158
View File
@@ -1,158 +0,0 @@
import { useState, useCallback } from "react";
import {
stopInstance,
deleteInstance,
startInstance,
recreateInstanceTunnel,
} from "../api/sessions";
import type { Session } from "../api/sessions";
interface UseInstanceActionsOptions {
onRefresh: () => Promise<void>;
}
interface UseInstanceActionsReturn {
loadingSessionId: string | null;
dirtyDeleteSession: Session | null;
dirtyDeleteFiles: string[];
handleOpen: (session: Session) => void;
handleStart: (session: Session) => Promise<void>;
handleStop: (session: Session) => Promise<void>;
handleDelete: (session: Session) => Promise<void>;
handleForceDelete: (session: Session) => Promise<void>;
handleRecreateTunnel: (session: Session) => Promise<void>;
clearDirtyDelete: () => void;
}
export function useInstanceActions(
options: UseInstanceActionsOptions
): UseInstanceActionsReturn {
const { onRefresh } = options;
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const handleOpen = useCallback((session: Session) => {
if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer");
return;
}
if (session.tool_type_interfaces?.includes("terminal")) {
window.location.href = `/instances/${session.id}/terminal`;
return;
}
window.location.href = `/projects/${session.project_id}`;
}, []);
const handleStart = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await startInstance(session.project_id, session.repository_id, session.id);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh]
);
const handleStop = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await stopInstance(session.project_id, session.repository_id, session.id);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh]
);
const handleDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
await onRefresh();
} catch (error) {
const axiosError = error as {
response?: { status?: number; data?: { detail?: { changed_files?: string[] } } };
};
if (axiosError.response?.status === 409) {
const detail = axiosError.response.data?.detail;
if (detail?.changed_files) {
setDirtyDeleteSession(session);
setDirtyDeleteFiles(detail.changed_files);
return;
}
}
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh]
);
const handleForceDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await deleteInstance(session.project_id, session.repository_id, session.id, true);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh]
);
const handleRecreateTunnel = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh]
);
const clearDirtyDelete = useCallback(() => {
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
}, []);
return {
loadingSessionId,
dirtyDeleteSession,
dirtyDeleteFiles,
handleOpen,
handleStart,
handleStop,
handleDelete,
handleForceDelete,
handleRecreateTunnel,
clearDirtyDelete,
};
}
-21
View File
@@ -1,21 +0,0 @@
import { useState, useEffect } from "react";
const MOBILE_BREAKPOINT = 768;
export function useMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") return false;
return window.innerWidth < MOBILE_BREAKPOINT;
});
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return isMobile;
}
-140
View File
@@ -1,140 +0,0 @@
export type SpecialKey =
| "escape"
| "tab"
| "ctrl"
| "alt"
| "up"
| "down"
| "left"
| "right"
| "home"
| "end"
| "pageup"
| "pagedown"
| "ctrlc"
| "ctrld"
| "ctrlz"
| "f1"
| "f2"
| "f3"
| "f4"
| "f5"
| "f6"
| "f7"
| "f8"
| "f9"
| "f10"
| "f11"
| "f12";
export type ModifierKey = "ctrl" | "alt";
const KEY_SEQUENCES: Record<SpecialKey, string> = {
escape: "\x1B",
tab: "\t",
ctrl: "",
alt: "",
up: "\x1B[A",
down: "\x1B[B",
right: "\x1B[C",
left: "\x1B[D",
home: "\x1B[H",
end: "\x1B[F",
pageup: "\x1B[5~",
pagedown: "\x1B[6~",
ctrlc: "\x03",
ctrld: "\x04",
ctrlz: "\x1A",
f1: "\x1BOP",
f2: "\x1BOQ",
f3: "\x1BOR",
f4: "\x1BOS",
f5: "\x1B[15~",
f6: "\x1B[17~",
f7: "\x1B[18~",
f8: "\x1B[19~",
f9: "\x1B[20~",
f10: "\x1B[21~",
f11: "\x1B[23~",
f12: "\x1B[24~",
};
// Single character sequences with modifier prefixes
const MODIFIER_PREFIXES: Record<string, { ctrl: string; alt: string; ctrlAlt: string }> = {
// Letters
a: { ctrl: "\x01", alt: "\x1Ba", ctrlAlt: "\x1B\x01" },
b: { ctrl: "\x02", alt: "\x1Bb", ctrlAlt: "\x1B\x02" },
c: { ctrl: "\x03", alt: "\x1Bc", ctrlAlt: "\x1B\x03" },
d: { ctrl: "\x04", alt: "\x1Bd", ctrlAlt: "\x1B\x04" },
e: { ctrl: "\x05", alt: "\x1Be", ctrlAlt: "\x1B\x05" },
f: { ctrl: "\x06", alt: "\x1Bf", ctrlAlt: "\x1B\x06" },
g: { ctrl: "\x07", alt: "\x1Bg", ctrlAlt: "\x1B\x07" },
h: { ctrl: "\x08", alt: "\x1Bh", ctrlAlt: "\x1B\x08" },
i: { ctrl: "\x09", alt: "\x1Bi", ctrlAlt: "\x1B\x09" },
j: { ctrl: "\x0A", alt: "\x1Bj", ctrlAlt: "\x1B\x0A" },
k: { ctrl: "\x0B", alt: "\x1Bk", ctrlAlt: "\x1B\x0B" },
l: { ctrl: "\x0C", alt: "\x1Bl", ctrlAlt: "\x1B\x0C" },
m: { ctrl: "\x0D", alt: "\x1Bm", ctrlAlt: "\x1B\x0D" },
n: { ctrl: "\x0E", alt: "\x1Bn", ctrlAlt: "\x1B\x0E" },
o: { ctrl: "\x0F", alt: "\x1Bo", ctrlAlt: "\x1B\x0F" },
p: { ctrl: "\x10", alt: "\x1Bp", ctrlAlt: "\x1B\x10" },
q: { ctrl: "\x11", alt: "\x1Bq", ctrlAlt: "\x1B\x11" },
r: { ctrl: "\x12", alt: "\x1Br", ctrlAlt: "\x1B\x12" },
s: { ctrl: "\x13", alt: "\x1Bs", ctrlAlt: "\x1B\x13" },
t: { ctrl: "\x14", alt: "\x1Bt", ctrlAlt: "\x1B\x14" },
u: { ctrl: "\x15", alt: "\x1Bu", ctrlAlt: "\x1B\x15" },
v: { ctrl: "\x16", alt: "\x1Bv", ctrlAlt: "\x1B\x16" },
w: { ctrl: "\x17", alt: "\x1Bw", ctrlAlt: "\x1B\x17" },
x: { ctrl: "\x18", alt: "\x1Bx", ctrlAlt: "\x1B\x18" },
y: { ctrl: "\x19", alt: "\x1By", ctrlAlt: "\x1B\x19" },
z: { ctrl: "\x1A", alt: "\x1Bz", ctrlAlt: "\x1B\x1A" },
// Numbers
"0": { ctrl: "0", alt: "\x1B0", ctrlAlt: "\x1B0" },
"1": { ctrl: "1", alt: "\x1B1", ctrlAlt: "\x1B1" },
"2": { ctrl: "\x00", alt: "\x1B2", ctrlAlt: "\x1B\x00" },
"3": { ctrl: "\x1B", alt: "\x1B3", ctrlAlt: "\x1B\x1B" },
"4": { ctrl: "\x1C", alt: "\x1B4", ctrlAlt: "\x1B\x1C" },
"5": { ctrl: "\x1D", alt: "\x1B5", ctrlAlt: "\x1B\x1D" },
"6": { ctrl: "\x1E", alt: "\x1B6", ctrlAlt: "\x1B\x1E" },
"7": { ctrl: "\x1F", alt: "\x1B7", ctrlAlt: "\x1B\x1F" },
"8": { ctrl: "\x7F", alt: "\x1B8", ctrlAlt: "\x1B\x7F" },
"9": { ctrl: "9", alt: "\x1B9", ctrlAlt: "\x1B9" },
};
export function getSequenceWithModifier(
key: SpecialKey,
activeModifier: ModifierKey | null
): { sequence: string; clearModifier: boolean } | null {
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
return null; // Modifiers don't send anything themselves
}
const sequence = KEY_SEQUENCES[key];
if (!sequence) return null;
// Check if we have an active modifier and the key is a single character
if (activeModifier && sequence.length === 1) {
const char = sequence;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (mapping) {
return { sequence: mapping[activeModifier], clearModifier: true };
}
}
return { sequence, clearModifier: !!activeModifier };
}
export function applyModifierToChar(
char: string,
modifier: ModifierKey
): string | null {
if (char.length !== 1) return null;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (!mapping) return null;
return mapping[modifier];
}
export { KEY_SEQUENCES };
@@ -1,68 +0,0 @@
import { useState, useEffect, useCallback } from "react";
interface VirtualKeyboardState {
isOpen: boolean;
height: number;
viewportHeight: number;
}
export function useVirtualKeyboard() {
const [state, setState] = useState<VirtualKeyboardState>({
isOpen: false,
height: 0,
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
});
const updateKeyboardState = useCallback(() => {
const visualViewport = window.visualViewport;
const windowHeight = window.innerHeight;
if (visualViewport) {
const viewportHeight = visualViewport.height;
const keyboardHeight = windowHeight - viewportHeight;
const isOpen = keyboardHeight > 100; // Threshold to avoid false positives
setState({
isOpen,
height: keyboardHeight,
viewportHeight,
});
} else {
// Fallback: compare window height to a stored reference
// This is less reliable but works on older browsers
const currentHeight = windowHeight;
const isOpen = currentHeight < state.viewportHeight - 100;
setState((prev) => ({
isOpen,
height: isOpen ? prev.viewportHeight - currentHeight : 0,
viewportHeight: isOpen ? prev.viewportHeight : currentHeight,
}));
}
}, [state.viewportHeight]);
useEffect(() => {
const visualViewport = window.visualViewport;
if (visualViewport) {
visualViewport.addEventListener("resize", updateKeyboardState);
visualViewport.addEventListener("scroll", updateKeyboardState);
} else {
window.addEventListener("resize", updateKeyboardState);
}
// Initial check
updateKeyboardState();
return () => {
if (visualViewport) {
visualViewport.removeEventListener("resize", updateKeyboardState);
visualViewport.removeEventListener("scroll", updateKeyboardState);
} else {
window.removeEventListener("resize", updateKeyboardState);
}
};
}, [updateKeyboardState]);
return state;
}
File diff suppressed because it is too large Load Diff
+167 -96
View File
@@ -2,17 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { getUserSessions, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
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 { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
type HomeStatus = "loading" | "ready" | "error";
@@ -33,7 +29,11 @@ export const HomePage = () => {
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
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 loadHome = useCallback(async () => {
@@ -59,58 +59,6 @@ export const HomePage = () => {
void loadHome();
}, [loadHome]);
const {
loadingSessionId: actionBusy,
handleOpen,
handleStart,
handleStop,
handleDelete,
handleRecreateTunnel,
} = useInstanceActions({ onRefresh: loadHome });
// Poll tunnel health every 30 seconds for running instances
useEffect(() => {
const checkHealth = async () => {
const runningSessions = safeSessions.filter(
(s) => s.status === "running" && s.url
);
for (const session of runningSessions) {
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,
container_exit_code: null,
tunnel_status: "error",
tunnel_status_code: null,
probe_status: "error",
last_probe_output: null,
error: "check failed",
},
}));
}
}
};
void checkHealth();
const interval = setInterval(() => {
void checkHealth();
}, 30000);
return () => clearInterval(interval);
}, [safeSessions]);
useEffect(() => {
if (!selectedProject) {
setRepositories([]);
@@ -134,10 +82,71 @@ export const HomePage = () => {
[safeSessions]
);
const handleCreateSuccess = async (instance: { id: string }) => {
await updateUserConfig({ last_session_id: instance.id });
setSelectedProject("");
await loadHome();
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 (
@@ -154,9 +163,17 @@ export const HomePage = () => {
</div>
</header>
{status === "loading" && <LoadingState message="Loading overview..." />}
{status === "loading" && <p className="muted">Loading overview...</p>}
{status === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
{status === "error" && (
<div className="card stack">
<p>Unable to load your workspace overview.</p>
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{status === "ready" && summary && (
<>
@@ -179,20 +196,45 @@ export const HomePage = () => {
<div className="page-header">
<div>
<p className="eyebrow">Open sessions</p>
<h2>{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}</h2>
<h2>{activeSessions.length}</h2>
</div>
</div>
<SessionList
sessions={safeSessions}
onOpen={handleOpen}
onStop={handleStop}
onDelete={handleDelete}
onRecreateTunnel={handleRecreateTunnel}
actionBusyId={actionBusy}
tunnelHealth={tunnelHealth}
showGrouping={false}
emptyMessage="No active sessions right now."
/>
{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 className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
<Icon name="refresh" size="sm" />
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>
</div>
</article>
))}
</div>
)}
</section>
<section className="card stack home-section">
@@ -204,7 +246,7 @@ export const HomePage = () => {
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
</div>
{projects.length === 0 ? (
<EmptyState message="No projects yet." />
<p className="muted">No projects yet.</p>
) : (
<div className="home-project-grid">
{projects.map((project) => (
@@ -229,33 +271,62 @@ export const HomePage = () => {
<h2>Start a session</h2>
</div>
</div>
<CreateSessionForm
projects={projects}
repositories={repositories}
toolTypes={toolTypes}
onProjectChange={(projectId) => setSelectedProject(projectId)}
onSuccess={handleCreateSuccess}
/>
<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>
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
{recentSessions.length > 0 && (
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Recent sessions</p>
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
<h2>{recentSessions.length}</h2>
</div>
</div>
<SessionList
sessions={safeSessions}
onOpen={handleOpen}
onStart={handleStart}
onDelete={handleDelete}
actionBusyId={actionBusy}
showGrouping={false}
maxRecent={5}
emptyMessage="No recent sessions."
/>
<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>
)}
</>
+28 -21
View File
@@ -1,33 +1,39 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git_repositories";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
export const GitHistoryPage = () => {
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
const navigate = useNavigate();
const [commits, setCommits] = useState<CommitHistoryEntry[]>([]);
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
const [branches, setBranches] = useState<string[]>([]);
const [selectedBranch, setSelectedBranch] = useState<string>("");
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
const { data: historyData, status, reload } = useAsyncData<CommitHistoryResponse>(
async () => {
if (!projectId || !repoId) return { commits: [], branches: [], tags: [] };
return await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
},
[projectId, repoId, selectedBranch]
);
// Auto-select first branch when data loads
useEffect(() => {
if (historyData?.branches.length && !selectedBranch) {
setSelectedBranch(historyData.branches[0]);
const loadHistory = useCallback(async () => {
if (!projectId || !repoId) return;
setStatus("loading");
try {
const data = await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
setCommits(data.commits);
setBranches(data.branches);
if (data.branches.length > 0 && !selectedBranch) {
setSelectedBranch(data.branches[0]);
}
setStatus("ready");
} catch {
setStatus("error");
}
}, [historyData?.branches, selectedBranch]);
}, [projectId, repoId, selectedBranch]);
useEffect(() => {
void loadHistory();
}, [loadHistory]);
const handleCommitClick = async (hash: string) => {
if (!projectId || !repoId) return;
@@ -55,7 +61,7 @@ export const GitHistoryPage = () => {
if (status === "loading") {
return (
<section className="stack">
<LoadingState message="Loading commit history..." />
<p className="muted">Loading commit history...</p>
</section>
);
}
@@ -63,14 +69,15 @@ export const GitHistoryPage = () => {
if (status === "error") {
return (
<section className="stack">
<ErrorState message="Failed to load commit history" onRetry={reload} />
<p>Failed to load commit history</p>
<button className="secondary-button" onClick={() => void loadHistory()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</section>
);
}
const commits = historyData?.commits ?? [];
const branches = historyData?.branches ?? [];
return (
<section className="stack">
<div className="page-header">
+37 -19
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
@@ -6,38 +6,48 @@ import {
listRepositories,
} from "../api/git_repositories";
import type { GitRepository } from "../api/git_repositories";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
import { useAsyncData } from "../hooks/use-async-data";
type RepoStatus = "loading" | "ready" | "error";
export const GitRepositoriesPage = () => {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const [status, setStatus] = useState<RepoStatus>("loading");
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [showCreate, setShowCreate] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const { data: repositories, status, reload } = useAsyncData<GitRepository[]>(
async () => {
if (!projectId) return [];
return await listRepositories(projectId);
},
[projectId]
);
const loadRepositories = useCallback(async () => {
if (!projectId) return;
setStatus("loading");
try {
const data = await listRepositories(projectId);
setRepositories(data);
setStatus("ready");
} catch {
setRepositories([]);
setStatus("error");
}
}, [projectId]);
useEffect(() => {
void loadRepositories();
}, [loadRepositories]);
const handleDelete = async (repoId: string) => {
if (!projectId) return;
try {
await deleteRepository(projectId, repoId);
setDeleteConfirmId(null);
reload();
await loadRepositories();
} catch {
setDeleteConfirmId(null);
}
};
const safeRepositories = repositories ?? [];
const isEmpty = status === "ready" && safeRepositories.length === 0;
const isEmpty = status === "ready" && repositories.length === 0;
return (
<section className="stack">
@@ -49,15 +59,23 @@ export const GitRepositoriesPage = () => {
</button>
</div>
{status === "loading" && <LoadingState message="Loading repositories..." />}
{status === "loading" && <p className="muted">Loading repositories...</p>}
{status === "error" && <ErrorState message="Failed to load repositories" onRetry={reload} />}
{status === "error" && (
<div className="card stack">
<p>Failed to load repositories</p>
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{isEmpty && <EmptyState message="No repositories yet. Create your first repository above." />}
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
{status === "ready" && safeRepositories.length > 0 && (
{status === "ready" && repositories.length > 0 && (
<div className="repository-list">
{safeRepositories.map((repo) => (
{repositories.map((repo) => (
<article className="card repository-card" key={repo.id}>
<div className="repository-info">
<h3>{repo.name}</h3>
@@ -113,7 +131,7 @@ export const GitRepositoriesPage = () => {
open={showCreate}
title="Create Repository"
onClose={() => setShowCreate(false)}
onCreated={reload}
onCreated={loadRepositories}
/>
)}
</section>
+45 -35
View File
@@ -1,38 +1,39 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAuth } from "../state/auth";
import { useAsyncData } from "../hooks/use-async-data";
import type { UserProfile } from "../api/profile";
type ProfileStatus = "loading" | "ready" | "error" | "saving";
export const ProfilePage = () => {
const { refreshSession } = useAuth();
const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
const [status, setStatus] = useState<ProfileStatus>("loading");
const [profile, setProfile] = useState<UserProfile | null>(null);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync loaded profile into form fields
useEffect(() => {
if (profile) {
setName(profile.name);
setEmail(profile.email);
setDisplayStatus("ready");
setError(null);
const loadProfile = useCallback(async () => {
setStatus("loading");
setError(null);
try {
const data = await getProfile();
setProfile(data);
setName(data.name);
setEmail(data.email);
setStatus("ready");
} catch {
setProfile(null);
setStatus("error");
}
}, [profile]);
}, []);
useEffect(() => {
if (loadStatus === "error") {
setDisplayStatus("error");
}
}, [loadStatus]);
void loadProfile();
}, [loadProfile]);
const handleSave = useCallback(async () => {
if (!name.trim()) {
@@ -44,15 +45,16 @@ export const ProfilePage = () => {
return;
}
setDisplayStatus("saving");
setStatus("saving");
setError(null);
try {
await updateProfile({ name: name.trim(), email: email.trim() });
const updated = await updateProfile({ name: name.trim(), email: email.trim() });
setProfile(updated);
await refreshSession();
setDisplayStatus("ready");
setStatus("ready");
} catch {
setError("Failed to update profile");
setDisplayStatus("ready");
setStatus("ready");
}
}, [name, email, refreshSession]);
@@ -71,19 +73,19 @@ export const ProfilePage = () => {
return;
}
setDisplayStatus("saving");
setStatus("saving");
setError(null);
try {
await uploadAvatar(file);
const updated = await uploadAvatar(file);
setProfile(updated);
await refreshSession();
reload();
setDisplayStatus("ready");
setStatus("ready");
} catch {
setError("Failed to upload avatar");
setDisplayStatus("ready");
setStatus("ready");
}
},
[refreshSession, reload]
[refreshSession]
);
const avatarUrl = profile?.avatar_url ?? null;
@@ -92,11 +94,19 @@ export const ProfilePage = () => {
<section className="stack">
<h1>Profile</h1>
{displayStatus === "loading" && <LoadingState message="Loading profile..." />}
{status === "loading" && <p className="muted">Loading profile...</p>}
{displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />}
{status === "error" && (
<div className="card stack">
<p>Failed to load profile</p>
<button className="secondary-button" onClick={() => void loadProfile()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{(displayStatus === "ready" || displayStatus === "saving") && profile && (
{(status === "ready" || status === "saving") && profile && (
<div className="card stack">
<div className="profile-avatar-section">
<div className="avatar-preview">
@@ -108,11 +118,11 @@ export const ProfilePage = () => {
</div>
<button
className="secondary-button"
disabled={displayStatus === "saving"}
disabled={status === "saving"}
onClick={() => fileInputRef.current?.click()}
type="button"
>
{displayStatus === "saving" ? (
{status === "saving" ? (
<>
<Icon name="loading" size="sm" />
Uploading...
@@ -136,7 +146,7 @@ export const ProfilePage = () => {
<div className="form-group">
<label htmlFor="profile-name">Name</label>
<input
disabled={displayStatus === "saving"}
disabled={status === "saving"}
id="profile-name"
onChange={(e) => setName(e.target.value)}
type="text"
@@ -147,7 +157,7 @@ export const ProfilePage = () => {
<div className="form-group">
<label htmlFor="profile-email">Email</label>
<input
disabled={displayStatus === "saving"}
disabled={status === "saving"}
id="profile-email"
onChange={(e) => setEmail(e.target.value)}
type="email"
@@ -160,11 +170,11 @@ export const ProfilePage = () => {
<div className="form-actions">
<button
className="primary-button"
disabled={displayStatus === "saving"}
disabled={status === "saving"}
onClick={() => void handleSave()}
type="button"
>
{displayStatus === "saving" ? (
{status === "saving" ? (
<>
<Icon name="loading" size="sm" />
Saving...

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