Compare commits

..

14 Commits

Author SHA1 Message Date
Developer d894cd9723 fix: terminal shift-left bug and session sidebar naming/filtering
- Guard ResizeObserver in terminal against internal xterm DOM changes
  by tracking last width/height and only calling fit() on real resize
- Remove padding from .terminal-container and conflicting .xterm height
  override that caused measurement mismatches with xterm-addon-fit
- Filter live session sidebar to active statuses only (running, building,
  pending) instead of showing all sessions including stopped ones
- Add display name fallback across sidebar, sessions page, and instance
  list to prevent blank names when display_name is empty

Quality gates: tsc (pass), eslint (pass)
2026-06-02 14:03:47 +00:00
Developer 5a8eca814d fix: terminal left shift and instance naming scheme
- Debounce terminal ResizeObserver (100ms) and only send resize when cols/rows actually change
- Send initial resize on WebSocket connect/reconnect to prevent PTY default 80x24 shift
- Replace random hex instance names with sequential project-tool-NNN naming
- Add _sanitize_name() and _generate_instance_name() helpers for readable Docker names
2026-06-02 12:11:19 +00:00
alex 7000f2075d docs: add SDD verify report for responsive terminal 2026-05-27 21:48:55 +02:00
alex a01e6252f5 docs: add responsive terminal documentation
- Add docs/features/terminal.md with user guide, connection states,
  keyboard shortcuts, protocol details, and troubleshooting
- Update docs/architecture/frontend.md with terminal component stack,
  connection hook behavior, and data flow diagrams
- Update docs/architecture/backend.md with terminal system architecture,
  protocol reference, message batching, and reconnect behavior
- Update docs/README.md to include terminal in feature list
2026-05-27 21:46:57 +02:00
alex 6c8cfe9157 feat: responsive web terminal with auto-reconnect, heartbeat, and local echo
Implements a resilient, responsive web terminal that survives network blips,
provides instant typing feedback, and restores scrollback on reconnect.

Backend changes:
- Add heartbeat tracking (15s ping interval, 60s idle timeout)
- Add message batching (16ms flush window) for efficient I/O
- Add termios echo detection and set_echo_state control messages
- Add graceful session_ended notification before close
- Add ping/pong protocol support

Frontend changes:
- Rewrite TerminalComponent with status bar, connection indicator,
  session-ended overlay, reconnect banner, and ResizeObserver
- Add useTerminalConnection hook with:
  - Exponential backoff auto-reconnect (1s → 30s max, 10 attempts)
  - Heartbeat/ping-pong with latency tracking
  - Local echo for printable ASCII with server deduplication
  - Resize debounce (200ms) + throttle (500ms)
  - Scrollback serialization via xterm-addon-serialize
  - Ctrl+Shift+R manual reconnect shortcut
- Add WebSocket protocol types and encoding utilities
- Add xterm-addon-serialize dependency

Tests:
- 16 backend unit tests (TerminalSession + TerminalManager)
- 13 frontend hook tests (connection lifecycle, reconnect, resize,
  scrollback, callbacks)

Quality gates:
- Frontend typecheck: clean
- Frontend lint: clean
- Frontend tests: 48 passed
- Backend unit tests: 101 passed
- Backend ruff: clean

SDD artifacts: openspec/changes/responsive-terminal/
2026-05-27 21:27:49 +02:00
alex 48fa858090 Merge branch 'main' of ssh://git.commumedia.org:2222/alex/headquarter 2026-05-24 15:04:33 +00:00
alex 679b1693fc Merge commit 'ea174b1'
# Conflicts:
#	apps/web/src/pages/projects.test.tsx
2026-05-24 15:03:58 +00:00
alex ea174b1642 fix: review fixes for el-1bn
- Fix duplicate mode field in config_profiles.py mount response
- Fix datetime.UTC import for Python 3.10 compatibility
- Add API documentation for config profiles
- Update CHANGELOG
2026-05-24 15:02:32 +00:00
alex 9cc98455ef 3.2 Launch and restart profile application (el-5z8) 2026-05-24 14:41:17 +00:00
alex a1dbfcf2a8 feat: implement profile CRUD validation compatibility and defaults API
- Add ConfigProfile CRUD endpoints with user ownership and access checks
- Implement ordered include management with cycle detection
- Add mount management with path validation (absolute, no traversal)
- Implement compatibility-filtered listing by tool type
- Add default profile selection APIs (get/set defaults per tool type)
- Fix SQLAlchemy ambiguous foreign key relationships in config models
- Add comprehensive integration tests (29 tests, all passing)
- Merge upstream profile resolver service changes (task 2.1)
2026-05-24 14:06:03 +00:00
alex 13aceeb08d 2.1 Profile resolver service (el-1nj) 2026-05-24 13:58:39 +00:00
alex f0e19615ce 2.3 Instance API profile selection plumbing (el-4hr) 2026-05-24 13:39:45 +00:00
alex 0bea26c784 1.1 Backend data model and migrations (el-5fe) 2026-05-24 13:23:12 +00:00
alex fb0f2f7b9b feat: add project settings page navigation
- Remove inline edit modal from projects listing page
- Add Settings link to project cards navigating to /projects/:id/settings
- Reposition Open Workspace button to rightmost action for easier access
- Update tests for new UI flow
- Update documentation to reflect new editing workflow
- Sync specs: frontend-foundation and project-management

Quality gates: npm run lint passed
2026-05-22 18:43:40 +00:00
353 changed files with 10330 additions and 22739 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
+5 -1
View File
@@ -48,4 +48,8 @@ apps/web/dist/
# OS
.DS_Store
Thumbs.db
/.stoneforge/.worktrees/
# Local runtime state
.atl/
.pi/
swap-pane
-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
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **User Settings** - Theme selection, git identity, and preference management
- **SSH Key Management** - Ed25519 key generation with secure storage
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
### Changed
-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")
@@ -0,0 +1,119 @@
"""add profile resolver fields to config profiles and mounts
Revision ID: 0014_add_profile_resolver_fields
Revises: 0013_add_config_profiles
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 = "0014_add_profile_resolver_fields"
down_revision: Union[str, None] = "0013_add_config_profiles"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add fields to config_profiles
op.add_column(
"config_profiles",
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("environment_variables", sa.JSON(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("start_command", sa.Text(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("working_directory", sa.Text(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("port", sa.Integer(), nullable=True),
)
op.add_column(
"config_profiles",
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
)
# Add foreign keys for project and tool_type
op.create_foreign_key(
"fk_config_profiles_project",
"config_profiles",
"projects",
["project_id"],
["id"],
ondelete="CASCADE",
)
op.create_foreign_key(
"fk_config_profiles_tool_type",
"config_profiles",
"tool_types",
["tool_type_id"],
["id"],
ondelete="CASCADE",
)
# Create indices
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
# Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
op.add_column(
"config_mounts",
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
)
op.add_column(
"config_mounts",
sa.Column("files", sa.JSON(), nullable=True),
)
# Drop the source_profile foreign key if it exists
op.drop_constraint(
"config_mounts_source_profile_id_fkey",
"config_mounts",
type_="foreignkey",
)
op.drop_column("config_mounts", "content")
op.drop_column("config_mounts", "source_profile_id")
def downgrade() -> None:
# Restore config_mounts
op.add_column(
"config_mounts",
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"config_mounts",
sa.Column("content", sa.Text(), nullable=True),
)
op.drop_column("config_mounts", "files")
op.drop_column("config_mounts", "mode")
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
# Restore config_profiles
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_constraint("fk_config_profiles_tool_type", "config_profiles", type_="foreignkey")
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
op.drop_column("config_profiles", "is_default")
op.drop_column("config_profiles", "port")
op.drop_column("config_profiles", "working_directory")
op.drop_column("config_profiles", "start_command")
op.drop_column("config_profiles", "environment_variables")
op.drop_column("config_profiles", "tool_type_id")
op.drop_column("config_profiles", "project_id")
@@ -1,23 +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
# 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,109 +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
# 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,129 +0,0 @@
"""add pi agent tool type
Revision ID: 20260527_160017_add_pi_agent
Revises: f3d2dc90ba3a
Create Date: 2026-05-27T16:00:17
"""
import json
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import uuid
# revision identifiers, used by Alembic.
revision: str = "20260527_160017_add_pi_agent"
down_revision: Union[str, None] = "2026_05_27_external_repos"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PI_AGENT_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
def upgrade() -> None:
# Check if pi-agent already exists
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'")
).fetchone()
if result is None:
conn.execute(
sa.text("""
INSERT INTO tool_types (
id, name, display_name, description, category,
interface_type, requires_port, default_port,
definition_type, compose_template, dockerfile_template, required_variables,
created_at, updated_at
) VALUES (
:id, :name, :display_name, :description, :category,
:interface_type, :requires_port, :default_port,
:definition_type, :compose_template, :dockerfile_template, :required_variables,
now(), now()
)
"""),
{
"id": PI_AGENT_ID,
"name": "pi-agent",
"display_name": "Pi Agent",
"description": "Pi coding agent terminal environment with nvim, ranger, and tmux",
"category": "development",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "dockerfile",
"compose_template": """services:
app:
build: .
stdin_open: true
tty: true
volumes:
- ${REPO_PATH}:/workspace
working_dir: /workspace
command: /bin/bash""",
"dockerfile_template": """# Pi Coding Agent - Terminal-based coding harness
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Install base dependencies
RUN apt-get update && apt-get install -y \\
curl \\
wget \\
git \\
neovim \\
ranger \\
tmux \\
htop \\
tree \\
jq \\
ca-certificates \\
python3 \\
python3-pip \\
build-essential \\
&& rm -rf /var/lib/apt/lists/*
# Install Node.js (required for Pi)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
&& apt-get install -y nodejs \\
&& rm -rf /var/lib/apt/lists/*
# Install Pi Coding Agent globally
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
# Create non-root user
RUN useradd -m -s /bin/bash user
WORKDIR /home/user
# Set up git
RUN git config --global init.defaultBranch main \\
&& git config --global user.email "dev@headquarter.local" \\
&& git config --global user.name "Developer"
# Create default tmux config
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
# Create default ranger config
RUN mkdir -p /home/user/.config/ranger \\
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
# Set up Pi config directory
RUN mkdir -p /home/user/.pi/agent
USER user
# Default to bash (Pi is invoked manually via `pi` command)
CMD ["/bin/bash"]""",
"required_variables": json.dumps(["REPO_PATH"]),
}
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text("DELETE FROM tool_types WHERE name = 'pi-agent'")
)
@@ -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,28 +0,0 @@
"""add_git_mounts_to_config_profiles
Revision ID: 2026_05_26_add_git_mounts
Revises: f3d2dc90ba3a
Create Date: 2026-05-26 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_26_add_git_mounts"
down_revision: Union[str, Sequence[str], None] = "2026_05_24_220141"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"config_profiles",
sa.Column("git_mounts", sa.JSON(), nullable=True, default=list),
)
def downgrade() -> None:
op.drop_column("config_profiles", "git_mounts")
@@ -1,41 +0,0 @@
"""make_project_id_nullable_in_git_repositories
Revision ID: 2026_05_27_external_repos
Revises: 2026_05_26_add_git_mounts
Create Date: 2026-05-27 08:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_27_external_repos"
down_revision: Union[str, Sequence[str], None] = "2026_05_26_add_git_mounts"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Expand alembic_version version_num to avoid truncation errors
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(64)")
# Make project_id nullable to allow external repositories
op.alter_column(
"git_repositories",
"project_id",
existing_type=sa.UUID(),
nullable=True,
)
def downgrade() -> None:
op.alter_column(
"git_repositories",
"project_id",
existing_type=sa.UUID(),
nullable=False,
)
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(32)")
@@ -1,23 +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
"""
# 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,23 +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
# 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
+10 -10
View File
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
redirect_uri=redirect_uri,
state=state,
)
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
response = RedirectResponse(location)
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
@@ -63,7 +63,7 @@ async def callback(
auth_next: str | None = Cookie(default="/"),
session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse:
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
if auth_state is None or auth_state != state:
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
@@ -71,7 +71,7 @@ async def callback(
settings = Settings()
redirect_uri = f"{settings.api_base_url}/auth/callback"
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
async with httpx.AsyncClient() as client:
try:
@@ -92,7 +92,7 @@ async def callback(
access_token=token_payload["access_token"],
client=client,
)
logger.debug("User info fetched successfully")
logger.info("User info fetched successfully")
except Exception as exc:
logger.error("User info fetch failed: %s", exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
@@ -100,19 +100,19 @@ async def callback(
authentik_id = str(user_info.get("sub", ""))
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
name = str(user_info.get("name", email))
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
try:
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
if user is None:
logger.debug("Creating new user: authentik_id=%s", authentik_id)
logger.info("Creating new user: authentik_id=%s", authentik_id)
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
session.add(user)
await session.commit()
await session.refresh(user)
logger.info("New user created: id=%s", user.id)
else:
logger.debug("Existing user found: id=%s, updating info", user.id)
logger.info("Existing user found: id=%s, updating info", user.id)
user.email = email
user.name = name
await session.commit()
@@ -165,20 +165,20 @@ async def me(
session_cookie: str | None = Cookie(default=None, alias="session"),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, Any]:
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
if not session_cookie:
logger.warning("Auth /me: missing session cookie")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
settings = Settings()
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
try:
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
user_id = payload["user_id"]
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
logger.info("Auth /me: decoded session for user_id=%s", user_id)
except ValueError as exc:
logger.warning("Auth /me: invalid session: %s", exc)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
+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):
File diff suppressed because it is too large Load Diff
+62 -291
View File
@@ -10,10 +10,11 @@ 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.ssh_key import SSHKey
from src.models.project import Project
from src.models.user import User
from src.utils.git_files import (
commit_file,
get_file_content,
@@ -33,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.
@@ -60,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}",
@@ -197,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):
@@ -220,166 +197,15 @@ class GitRepositoryResponse(BaseModel):
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID | None
project_id: uuid.UUID
owner_id: uuid.UUID
is_mirror: bool
remote_url: str | None
last_push: datetime | None
ssh_key_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@router.get(
"/repositories",
response_model=list[GitRepositoryResponse],
summary="List all user repositories",
description="List all git repositories owned by the user, including external repositories not tied to any project.",
)
async def list_user_repositories(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[GitRepository]:
"""List all repositories owned by the user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of all repositories owned by the user.
"""
result = await session.execute(
select(GitRepository).where(GitRepository.owner_id == user_id)
)
return list(result.scalars().all())
@router.post(
"/repositories/parse-url",
response_model=URLParseResponse,
summary="Parse a git URL",
description="Parse a git URL and detect if it's a browser URL that needs correction.",
)
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
"""Parse a git URL and detect if it's a browser URL that needs correction.
Args:
data: Request containing the URL to parse.
Returns:
Parsed URL information including whether it needs parsing and suggested corrections.
"""
result = parse_git_url(data.url)
return URLParseResponse(**result)
@router.post(
"/repositories",
response_model=GitRepositoryResponse,
status_code=status.HTTP_201_CREATED,
summary="Create an external repository",
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
)
async def create_external_repository(
data: GitRepositoryCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Create a new external git repository.
External repositories are not tied to any project and can be used
across all projects for config profile git mounts.
Args:
data: Repository creation data including name and optional remote URL.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created external repository.
"""
_user = await _get_user(session, user_id)
# Check for duplicate name (external repos only)
existing = await session.execute(
select(GitRepository).where(
GitRepository.project_id.is_(None),
GitRepository.owner_id == user_id,
GitRepository.name == data.name,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
# Validate and potentially correct the URL
remote_url = data.remote_url
if remote_url and not data.force_original_url:
parse_result = parse_git_url(remote_url)
if parse_result["needs_parsing"] and parse_result["base_url"]:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "The provided URL appears to be a browser URL, not a git clone URL",
"suggested_url": parse_result["base_url"],
"original_url": remote_url,
"error_code": "URL_NEEDS_PARSING",
},
)
if parse_result["base_url"]:
remote_url = parse_result["base_url"]
# Validate SSH key if provided
ssh_key_id = None
ssh_key = None
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
if remote_url:
_preflight_remote_repository(remote_url, ssh_key)
# Create external repo with no project
repo = GitRepository(
name=data.name,
path="", # Will be set after clone
project_id=None,
owner_id=user_id,
remote_url=remote_url,
ssh_key_id=ssh_key_id,
)
session.add(repo)
await session.flush()
# Set path and optionally clone
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
repo.path = repo_path
if remote_url:
try:
_clone_working_repository(remote_url, repo_path, ssh_key)
repo.is_mirror = False
except Exception as exc:
await session.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
else:
# Initialize empty repo
os.makedirs(repo_path, exist_ok=True)
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
repo.is_mirror = False
await session.commit()
return repo
@router.get(
"/{project_id}/repositories",
response_model=list[GitRepositoryResponse],
@@ -449,6 +275,25 @@ async def delete_repository(
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/repositories/parse-url",
response_model=URLParseResponse,
summary="Parse a git URL",
description="Parse a git URL and detect if it's a browser URL that needs correction.",
)
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
"""Parse a git URL and detect if it's a browser URL that needs correction.
Args:
data: Request containing the URL to parse.
Returns:
Parsed URL information including whether it needs parsing and suggested corrections.
"""
result = parse_git_url(data.url)
return URLParseResponse(**result)
@router.post(
"/{project_id}/repositories",
response_model=GitRepositoryResponse,
@@ -504,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)
@@ -528,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)
@@ -539,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()
@@ -547,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",
+2 -1
View File
@@ -4,10 +4,11 @@ import time
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, status
from pydantic import BaseModel, Field
from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal
router = APIRouter()
+1
View File
@@ -2,6 +2,7 @@
import logging
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
+36 -1
View File
@@ -7,14 +7,23 @@ 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
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
@@ -123,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,
-98
View File
@@ -1,98 +0,0 @@
"""Shared Pydantic validators for API schemas."""
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
+10 -96
View File
@@ -1,4 +1,3 @@
import base64
import uuid
from datetime import datetime
@@ -10,13 +9,22 @@ 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
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
@@ -66,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,
@@ -175,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)
+53 -219
View File
@@ -4,25 +4,17 @@ import asyncio
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from fastapi import APIRouter, Depends, WebSocket
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,11 @@ 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.
Supports:
- Auto-reconnection (client reconnects, server spawns new session)
- Heartbeat ping/pong
- Binary and text input frames
- Graceful session end notifications
Args:
websocket: The WebSocket connection.
@@ -43,27 +39,27 @@ async def terminal_websocket(
Returns:
None. Communicates via WebSocket messages.
"""
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
await websocket.accept()
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
try:
# Parse instance_id
instance_uuid = uuid.UUID(instance_id)
except ValueError:
logger.error("Invalid instance ID: %s", instance_id)
await websocket.close(code=4001, reason="Invalid instance ID")
return
# Authenticate user from session cookie
user_id = await _get_user_from_websocket(websocket, db_session)
if user_id is None:
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
logger.warning(
"Unauthorized terminal access attempt for instance %s",
instance_id,
)
await websocket.close(code=4003, reason="Unauthorized")
return
# Get instance and verify ownership
instance = await db_session.get(ToolInstance, instance_uuid)
if instance is None:
logger.warning("Instance %s not found", instance_id)
@@ -71,229 +67,66 @@ async def terminal_websocket(
return
if instance.owner_id != user_id:
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
logger.warning(
"Forbidden terminal access for instance %s by user %s",
instance_id,
user_id,
)
await websocket.close(code=4003, reason="Forbidden")
return
if instance.status != "running" or not instance.container_id:
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
logger.warning(
"Instance %s not running (status=%s, container_id=%s)",
instance_id,
instance.status,
instance.container_id,
)
await websocket.close(code=4004, reason="Instance not running")
return
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
if startup_command:
logger.debug("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,
)
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 created successfully for instance %s",
instance_id,
)
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
# Attach WebSocket to session
await terminal_manager.attach_websocket(session, websocket)
logger.debug("WebSocket attached to session for instance %s", instance_id)
# Send connected status
await websocket.send_json({"type": "status", "status": "connected"})
logger.debug("Sent connected status for instance %s", instance_id)
# Use mutable session reference so loops can survive reset
session_ref = SessionRef(session)
# Monitor session health and echo state
while session.is_alive() and not session.closed:
# Check echo state periodically
new_echo_state = await session.check_echo_state()
if new_echo_state is not None:
await websocket.send_json(
{"type": "set_echo_state", "enabled": new_echo_state},
)
await asyncio.sleep(1.0)
# 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.debug("Started terminal loops for instance %s", instance_id)
# Session ended — determine reason and notify client
exit_reason = session.get_exit_reason() or "process_exit"
await websocket.send_json({"type": "session_ended", "reason": exit_reason})
await websocket.close(code=1000, reason=f"Session ended: {exit_reason}")
# 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.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
# Cancel remaining tasks
for task in pending:
task.cancel()
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.debug("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:
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.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
await session.resize(cols, rows)
elif msg_type == "reset":
# Reset terminal session
logger.debug("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(
logger.exception(
"Terminal session error for instance %s",
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}"
)
await websocket.close(code=4000, reason="Terminal session error")
finally:
pass
async def _get_user_from_websocket(
@@ -308,6 +141,7 @@ async def _get_user_from_websocket(
Returns:
The user's UUID if authenticated, None otherwise.
"""
from src.auth.session import decode_session_cookie
from src.config import Settings
+37 -5
View File
@@ -1,5 +1,6 @@
"""Tool configuration API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
@@ -7,11 +8,12 @@ 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):
File diff suppressed because it is too large Load Diff
+143 -76
View File
@@ -1,23 +1,27 @@
import uuid
from datetime import datetime
import yaml
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.tool_types_validation import (
check_port_exposed,
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.
@@ -33,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
@@ -62,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")
@@ -80,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")
@@ -125,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
@@ -148,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
@@ -163,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")
@@ -211,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
@@ -266,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)
@@ -361,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(
@@ -381,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)
@@ -448,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:
@@ -499,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:
@@ -543,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",
)
+15 -6
View File
@@ -1,19 +1,28 @@
import logging
import uuid
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
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
logger = logging.getLogger(__name__)
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.
@@ -102,11 +111,11 @@ async def update_user_config(
# Merge updates
update_data = data.model_dump(exclude_unset=True)
logger.debug("Updating user config for user %s: %s", user_id, update_data)
logger.info("Updating user config for user %s: %s", user_id, update_data)
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
config.config = {**config.config, **update_data}
await session.commit()
await session.refresh(config)
logger.debug("Updated config: %s", config.config)
logger.info("Updated config: %s", config.config)
return UserConfigResponse.model_validate(config.config)
+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)
-37
View File
@@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.session import decode_session_cookie
from src.config import Settings
from src.database import SessionLocal
from src.models.project import Project
from src.models.user import User
@@ -48,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
+155 -1
View File
@@ -1,3 +1,4 @@
import json
import logging
import os
@@ -6,6 +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 select, text
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
@@ -24,12 +26,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()
@@ -101,6 +104,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...")
@@ -112,6 +264,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)
+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],
)
+31
View File
@@ -0,0 +1,31 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, JSON, String
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
)
target_path: Mapped[str] = mapped_column(String(1024), nullable=False)
mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw")
files: Mapped[dict[str, str] | None] = mapped_column(
JSON, default=dict, 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",
)
+26 -44
View File
@@ -1,13 +1,15 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
from sqlalchemy import ForeignKey, Integer, JSON, 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.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models.user import User
@@ -15,63 +17,43 @@ if TYPE_CHECKING:
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", ...}
git_mounts: Mapped[list] = mapped_column(
JSON, default=list, nullable=False
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
environment_variables: Mapped[dict[str, str] | None] = mapped_column(
JSON, default=dict, nullable=True
)
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_default: Mapped[bool] = mapped_column(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",
primaryjoin="ConfigProfile.id == 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",
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigMount.order_index",
)
+1 -6
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
@@ -19,15 +18,11 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
name: Mapped[str] = mapped_column(String(255))
path: Mapped[str] = mapped_column(String(1024))
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
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.debug("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.debug("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.debug("Removed clone directory: %s", clone_path)
@@ -1,484 +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)
git_mounts: list[dict[str, Any]] = field(default_factory=list)
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
def _merge_git_mounts(
base: list[dict[str, Any]],
overlay: list[dict[str, Any]],
source_name: str,
) -> list[dict[str, Any]]:
"""Merge git mounts from included profiles.
Later mounts override earlier ones with the same remote_url + target_path combo.
"""
result = list(base)
# Build lookup by (remote_url, target_path)
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
for mount in overlay:
key = (mount["remote_url"], mount["target_path"])
if key in seen:
result[seen[key]] = dict(mount)
else:
seen[key] = len(result)
result.append(dict(mount))
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,
)
result.git_mounts = _merge_git_mounts(
result.git_mounts, included.git_mounts, 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,
)
result.git_mounts = _merge_git_mounts(
result.git_mounts,
profile.git_mounts or [],
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,
},
"git_mounts": resolved.git_mounts,
"included_profiles": resolved.included_profiles,
}
+84 -177
View File
@@ -1,9 +1,7 @@
"""Docker service for managing tool instances."""
import os
import re
import subprocess
import time
from pathlib import Path
from typing import Any
@@ -37,7 +35,6 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) ->
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
@@ -90,11 +87,64 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
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]:
@@ -112,7 +162,7 @@ def execute_compose_command(
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
@@ -139,8 +189,6 @@ def execute_compose_command(
def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service.
Searches all containers including stopped/exited ones.
Args:
instance_name: The service name in compose
@@ -148,7 +196,7 @@ def get_container_id(instance_name: str) -> str | None:
Container ID or None if not found
"""
result = subprocess.run(
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
capture_output=True,
text=True,
)
@@ -161,8 +209,6 @@ def get_container_id(instance_name: str) -> str | None:
def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service.
Searches all containers including stopped/exited ones.
Args:
instance_name: The service name in compose
@@ -170,15 +216,7 @@ def get_container_name(instance_name: str) -> str | None:
Container name or None if not found
"""
result = subprocess.run(
[
"docker",
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
f"name={instance_name}",
],
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
capture_output=True,
text=True,
)
@@ -188,9 +226,7 @@ def get_container_name(instance_name: str) -> str | None:
return None
def connect_container_to_network(
container_name: str, network_name: str = "backend"
) -> bool:
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
"""Connect a Docker container to an existing network.
Args:
@@ -208,95 +244,24 @@ def connect_container_to_network(
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)
"""
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:
@@ -340,6 +305,11 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
raise RuntimeError(f"No free port found in range {start}-{end}")
import subprocess
import time
import re
def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30
) -> dict[str, str]:
@@ -357,6 +327,8 @@ def start_cloudflared_tunnel(
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
"""
import subprocess
import time
import re
import logging
logger = logging.getLogger(__name__)
@@ -365,29 +337,18 @@ def start_cloudflared_tunnel(
logger.info("Checking connectivity to %s:%d...", container_name, port)
for attempt in range(10):
check = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
f"http://{container_name}:{port}",
],
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
f"http://{container_name}:{port}"],
capture_output=True,
text=True,
timeout=5,
)
logger.info(
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
)
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
if check.returncode == 0:
break
time.sleep(1)
else:
logger.warning(
"Container %s:%d not responding to curl checks", container_name, port
)
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
# Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
@@ -406,7 +367,6 @@ def start_cloudflared_tunnel(
while time.time() - start_time < timeout:
# Read available output
import select
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
if readable:
line = proc.stdout.readline()
@@ -433,6 +393,7 @@ def stop_cloudflared_tunnel(pid: str) -> None:
Args:
pid: Process ID of the cloudflared tunnel
"""
import os
import signal
try:
@@ -463,87 +424,33 @@ 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
try:
result = subprocess.run(
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
str(timeout),
url,
],
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", str(timeout), url],
capture_output=True,
text=True,
timeout=timeout + 5,
)
status_code = int(result.stdout.strip())
if 200 <= status_code < 400:
return {
"tunnel_status": "healthy",
"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),
}
+5 -4
View File
@@ -18,12 +18,13 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
Returns:
Tuple of (returncode, stdout, stderr)
"""
import os
from pathlib import Path
# Write Dockerfile
dockerfile_path = Path(instance_dir) / "Dockerfile"
dockerfile_path.write_text(dockerfile)
logger.debug("Wrote Dockerfile to %s", dockerfile_path)
logger.info("Wrote Dockerfile to %s", dockerfile_path)
# Write build context files
if build_context:
@@ -38,10 +39,10 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
logger.debug("Wrote build context file: %s", full_path)
logger.info("Wrote build context file: %s", full_path)
# Build image
logger.debug("Building Docker image with tag: %s", tag)
logger.info("Building Docker image with tag: %s", tag)
cmd = [
"docker", "build",
"-t", tag,
@@ -56,7 +57,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
text=True,
timeout=300, # 5 minute timeout for builds
)
logger.debug("Docker build completed: returncode=%d", result.returncode)
logger.info("Docker build completed: returncode=%d", result.returncode)
if result.returncode != 0:
logger.error("Docker build failed: %s", result.stderr[:1000])
return result.returncode, result.stdout, result.stderr
+251
View File
@@ -0,0 +1,251 @@
"""Profile resolver service for recursive ordered include resolution.
Provides deterministic merge rules, save-independent cycle protection,
and resolved output structures for env vars, runtime hints, mounts,
file trees, and override metadata.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
@dataclass
class ResolvedMount:
"""A resolved mount with merged file tree and final mode."""
target_path: str
mode: str # "ro" or "rw"
files: dict[str, str] = field(default_factory=dict)
"""Relative file paths to UTF-8 text content."""
overridden_files: dict[str, list[str]] = field(default_factory=dict)
"""Map of relative file path to list of profile names that contributed
(latest is the winner)."""
mode_overridden_by: str | None = None
"""Name of the profile that set the final mode, if different from first."""
@dataclass
class ResolvedRuntimeHints:
"""Resolved runtime hints from profile layers."""
start_command: str | None = None
working_directory: str | None = None
port: int | None = None
overridden_hints: dict[str, str] = field(default_factory=dict)
"""Map of hint key to profile name that provided the winning value."""
@dataclass
class ResolvedProfileOutput:
"""Complete resolved output for a config profile."""
profile_id: uuid.UUID
profile_name: str
environment_variables: dict[str, str] = field(default_factory=dict)
"""Final merged env vars (later layers win)."""
env_var_sources: dict[str, list[str]] = field(default_factory=dict)
"""Map of env var key to ordered list of contributing profile names
(latest is the winner)."""
runtime_hints: ResolvedRuntimeHints = field(
default_factory=lambda: ResolvedRuntimeHints()
)
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
"""Map of target_path to ResolvedMount."""
resolution_order: list[str] = field(default_factory=list)
"""Ordered list of profile names as they were resolved."""
cycle_detected: bool = False
cycle_path: list[str] | None = None
class ProfileResolutionError(Exception):
"""Raised when profile resolution fails."""
pass
class ProfileCycleError(ProfileResolutionError):
"""Raised when a cycle is detected during profile resolution."""
def __init__(self, cycle_path: list[str]) -> None:
self.cycle_path = cycle_path
path_str = " -> ".join(cycle_path)
super().__init__(f"Profile include cycle detected: {path_str}")
def _merge_env_vars(
current: dict[str, str],
sources: dict[str, list[str]],
profile: ConfigProfile,
) -> None:
"""Merge a profile's env vars into the current dict, tracking sources."""
if not profile.environment_variables:
return
for key, value in profile.environment_variables.items():
current[key] = value
if key not in sources:
sources[key] = []
sources[key].append(profile.name)
def _merge_runtime_hints(
hints: ResolvedRuntimeHints,
profile: ConfigProfile,
) -> None:
"""Merge a profile's runtime hints, tracking overrides."""
if profile.start_command is not None:
hints.start_command = profile.start_command
hints.overridden_hints["start_command"] = profile.name
if profile.working_directory is not None:
hints.working_directory = profile.working_directory
hints.overridden_hints["working_directory"] = profile.name
if profile.port is not None:
hints.port = profile.port
hints.overridden_hints["port"] = profile.name
def _merge_mounts(
mounts: dict[str, ResolvedMount],
profile_mounts: list[ConfigMount],
profile: ConfigProfile,
) -> None:
"""Merge a profile's mounts into the current mounts dict."""
for mount in profile_mounts:
target = mount.target_path
if target not in mounts:
mounts[target] = ResolvedMount(
target_path=target,
mode=mount.mode,
files={},
overridden_files={},
)
resolved = mounts[target]
# Mode override: later wins
if resolved.mode != mount.mode:
resolved.mode = mount.mode
resolved.mode_overridden_by = profile.name
# File tree merge: later wins for same relative path
if mount.files:
for rel_path, content in mount.files.items():
if rel_path not in resolved.files:
resolved.overridden_files[rel_path] = []
else:
if rel_path not in resolved.overridden_files:
resolved.overridden_files[rel_path] = []
resolved.overridden_files[rel_path].append(profile.name)
resolved.files[rel_path] = content
def _resolve_profile_recursive(
profile: ConfigProfile,
visited: set[uuid.UUID],
path: list[str],
resolution_order: list[str],
env_vars: dict[str, str],
env_var_sources: dict[str, list[str]],
runtime_hints: ResolvedRuntimeHints,
mounts: dict[str, ResolvedMount],
) -> None:
"""Recursively resolve a profile and its includes.
Args:
profile: The profile to resolve
visited: Set of already-resolved profile IDs to avoid duplicates
path: Current recursion path for cycle detection
resolution_order: Ordered list of profile names being resolved
env_vars: Accumulated environment variables
env_var_sources: Tracking of which profiles contributed each env var
runtime_hints: Accumulated runtime hints
mounts: Accumulated mounts
Raises:
ProfileCycleError: If a cycle is detected
"""
if profile.name in path:
# Cycle detected
cycle_start = path.index(profile.name)
cycle_path = path[cycle_start:] + [profile.name]
raise ProfileCycleError(cycle_path)
if profile.id in visited:
# Already resolved in another branch (diamond graph)
return
visited.add(profile.id)
path.append(profile.name)
resolution_order.append(profile.name)
# Resolve includes first (in order)
includes: list[ConfigInclude] = list(profile.includes)
includes.sort(key=lambda inc: inc.order_index)
for include in includes:
included_profile = include.included_profile
if included_profile is not None:
_resolve_profile_recursive(
included_profile,
visited,
path,
resolution_order,
env_vars,
env_var_sources,
runtime_hints,
mounts,
)
# Apply this profile's values (later layers win)
_merge_env_vars(env_vars, env_var_sources, profile)
_merge_runtime_hints(runtime_hints, profile)
_merge_mounts(mounts, list(profile.mounts), profile)
path.pop()
def resolve_profile(profile: ConfigProfile) -> ResolvedProfileOutput:
"""Resolve a config profile with all its includes.
Processes included profiles in configured order, then applies the
selected profile itself. Later layers override earlier layers.
Args:
profile: The root profile to resolve
Returns:
ResolvedProfileOutput with merged env vars, runtime hints, mounts,
and override metadata
Raises:
ProfileCycleError: If a cycle is detected in the include graph
"""
env_vars: dict[str, str] = {}
env_var_sources: dict[str, list[str]] = {}
runtime_hints = ResolvedRuntimeHints()
mounts: dict[str, ResolvedMount] = {}
resolution_order: list[str] = []
_resolve_profile_recursive(
profile,
set(),
[],
resolution_order,
env_vars,
env_var_sources,
runtime_hints,
mounts,
)
return ResolvedProfileOutput(
profile_id=profile.id,
profile_name=profile.name,
environment_variables=env_vars,
env_var_sources=env_var_sources,
runtime_hints=runtime_hints,
mounts=mounts,
resolution_order=resolution_order,
)
-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()
+149 -117
View File
@@ -1,8 +1,13 @@
"""Terminal session manager for WebSocket connections."""
import asyncio
import contextlib
import json
import logging
import time
import uuid
from collections.abc import Coroutine
from typing import Any
from fastapi import WebSocket
@@ -10,151 +15,178 @@ from src.services.terminal_session import TerminalSession
logger = logging.getLogger(__name__)
_READ_BATCH_INTERVAL_S = 0.016 # 16ms max batching delay
_READ_POLL_TIMEOUT_S = 0.005
_READ_POLL_SLEEP_S = 0.001
_HEARTBEAT_INTERVAL_S = 15.0
_IDLE_TIMEOUT_S = 60.0
class TerminalManager:
"""Manages active terminal sessions with persistence support."""
"""Manages active terminal sessions."""
def __init__(self) -> None:
# Track sessions by instance_id for persistence
"""Initialise the terminal manager."""
self._sessions: dict[str, TerminalSession] = {}
self._idle_check_task: asyncio.Task | None = None
self._start_idle_check()
self._last_client_message: dict[str, float] = {}
self._background_tasks: set[asyncio.Task[Any]] = set()
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
self._last_client_message[session_id] = time.monotonic()
# Start background tasks for I/O streaming
self._start_task(self._read_loop(session, websocket))
self._start_task(self._write_loop(session, websocket))
self._start_task(self._heartbeat_loop(session, websocket))
return session
def _start_task(self, coro: Coroutine[Any, Any, None]) -> None:
"""Start a background task and store a reference to prevent GC."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
async def _read_loop(
self,
session: TerminalSession,
websocket: WebSocket,
) -> None:
"""Read output from the container and send to WebSocket with batching."""
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
pass
buffer = bytearray()
last_flush = time.monotonic()
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)
while session.is_alive() and not session.closed:
data = await session.read_output(select_timeout=_READ_POLL_TIMEOUT_S)
if data:
buffer.extend(data)
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()
now = time.monotonic()
flush_due = buffer and (
now - last_flush >= _READ_BATCH_INTERVAL_S or not data
)
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.debug("Reattaching to existing terminal session for instance %s", instance_id)
return session
else:
# Session died, clean it up
logger.debug("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
if flush_due:
await websocket.send_bytes(bytes(buffer))
buffer.clear()
last_flush = now
elif not data:
await asyncio.sleep(_READ_POLL_SLEEP_S)
async def attach_websocket(
# Flush any remaining data
if buffer:
with contextlib.suppress(Exception):
await websocket.send_bytes(bytes(buffer))
except Exception:
logger.exception("Read loop error for session %s", session.session_id)
finally:
await self._cleanup_session(session)
async def _write_loop(
self,
session: TerminalSession,
websocket: WebSocket,
) -> None:
"""Attach a WebSocket to an existing session."""
# Handle concurrent connections - close existing ones
if session.has_websockets():
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
for ws in list(session._websockets):
try:
await ws.close(code=4000, reason="New connection established")
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
"""Read input from WebSocket and send to container."""
try:
while session.is_alive() and not session.closed:
message = await websocket.receive()
self._last_client_message[session.session_id] = time.monotonic()
async def detach_websocket(
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("{"):
try:
ctrl = json.loads(text)
await self._handle_control_message(
session,
websocket,
ctrl,
)
except json.JSONDecodeError:
logger.debug("Invalid JSON control message: %s", text)
else:
await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect":
break
except Exception:
logger.exception("Write loop error for session %s", session.session_id)
finally:
await self._cleanup_session(session)
async def _handle_control_message(
self,
session: TerminalSession,
websocket: WebSocket,
ctrl: dict[str, Any],
) -> None:
"""Handle a JSON control message from the client."""
msg_type = ctrl.get("type")
if msg_type == "resize":
await session.resize(
ctrl.get("cols", 80),
ctrl.get("rows", 24),
)
elif msg_type == "ping":
await websocket.send_json(
{"type": "pong", "id": ctrl.get("id")},
)
async def _heartbeat_loop(
self,
session: TerminalSession,
websocket: WebSocket,
) -> None:
"""Detach a WebSocket from a session."""
session.detach_websocket(websocket)
"""Monitor client activity and close idle connections."""
try:
while session.is_alive() and not session.closed:
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
last_msg = self._last_client_message.get(session.session_id, 0)
if time.monotonic() - last_msg > _IDLE_TIMEOUT_S:
# Client has been silent for 60s — close connection
with contextlib.suppress(Exception):
await websocket.close(
code=1000,
reason="Idle timeout",
)
break
except Exception:
logger.exception(
"Heartbeat loop error for session %s",
session.session_id,
)
finally:
await self._cleanup_session(session)
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.debug("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]
self._last_client_message.pop(session.session_id, None)
await session.close()
async def close_all(self) -> None:
"""Close all active sessions."""
sessions = list(self._sessions.values())
self._sessions.clear()
self._last_client_message.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
+77 -161
View File
@@ -1,191 +1,132 @@
"""Terminal session management for tool instances."""
import asyncio
import contextlib
import fcntl
import logging
import os
import pty
import select
import signal
import struct
import fcntl
import time
import termios
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:
"""Initialize a terminal session."""
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
self._echo_enabled = True
self._exit_reason: str | None = None
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.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
# Build the shell command
if startup_command:
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
else:
shell_cmd = "bash -il"
# Start docker exec with the slave fd as stdin/stdout/stderr
# Using -it because the slave fd IS a TTY
self._set_terminal_size(80, 24)
self.process = await asyncio.create_subprocess_exec(
"docker",
"exec",
"-it",
"-e",
"TERM=xterm",
"TERM=xterm-256color",
self.container_id,
"bash",
"-c",
shell_cmd,
"-il",
stdin=self._slave_fd,
stdout=self._slave_fd,
stderr=self._slave_fd,
)
# Close slave fd in parent process
os.close(self._slave_fd)
self._slave_fd = None
self.last_activity = time.time()
self._echo_enabled = self._detect_echo_state()
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.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
except (OSError, IOError) as e:
logger.error(f"Failed to resize PTY: {e}")
tiocswinsz = 0x5414
size = struct.pack("HHHH", rows, cols, 0, 0)
with contextlib.suppress(OSError):
fcntl.ioctl(self._master_fd, tiocswinsz, size)
async def read_output(self) -> bytes:
"""Read output from the PTY master and store in buffer."""
def _detect_echo_state(self) -> bool:
"""Detect whether the PTY has echo enabled via termios."""
if self._master_fd is None:
return True
try:
attrs = termios.tcgetattr(self._master_fd)
return bool(attrs[3] & termios.ECHO)
except OSError:
return True
async def check_echo_state(self) -> bool | None:
"""Check if echo state changed. Returns new state if changed, None otherwise."""
current = self._detect_echo_state()
if current != self._echo_enabled:
self._echo_enabled = current
return current
return None
@property
def echo_enabled(self) -> bool:
"""Return whether the PTY currently has echo enabled."""
return self._echo_enabled
@property
def closed(self) -> bool:
"""Return whether the session has been closed."""
return self._closed
async def read_output(self, select_timeout: float = 0.1) -> bytes:
"""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)
readable, _, _ = select.select(
[self._master_fd],
[],
[],
select_timeout,
)
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, 8192)
return b""
except (OSError, IOError, ValueError):
except (OSError, 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:
with contextlib.suppress(OSError):
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.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
self._set_terminal_size(cols, rows)
# Docker exec -it creates its own PTY inside the container,
# 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
def get_exit_reason(self) -> str | None:
"""Return the reason the session ended, if known."""
return self._exit_reason
async def close(self) -> None:
"""Close the session and cleanup."""
@@ -193,18 +134,25 @@ class TerminalSession:
return
self._closed = True
# Determine exit reason
if self.process is not None and self.process.returncode is not None:
if self.process.returncode == 0:
self._exit_reason = "process_exit"
else:
self._exit_reason = "process_exit"
else:
self._exit_reason = "timeout"
if self._master_fd is not None:
try:
with contextlib.suppress(OSError):
os.close(self._master_fd)
except OSError:
pass
self._master_fd = None
if self.process is not None:
try:
self.process.kill()
await asyncio.wait_for(self.process.wait(), timeout=2.0)
except (asyncio.TimeoutError, ProcessLookupError):
except (TimeoutError, ProcessLookupError):
pass
def is_alive(self) -> bool:
@@ -212,35 +160,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)
+5 -23
View File
@@ -2,6 +2,7 @@
import subprocess
from dataclasses import dataclass, field
from typing import Any
def _run_git_command(repo_path: str, *args: str) -> str:
@@ -123,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)
@@ -162,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(
@@ -304,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):
+6 -86
View File
@@ -8,14 +8,16 @@ from unittest.mock import patch
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
# Set test environment BEFORE importing app modules
os.environ["APP_ENV"] = "testing"
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
from src.config import Settings
from src.config import Settings, build_database_url
from src.models.base import Base
from src.main import app
from src.auth.dependencies import get_db_session
@@ -45,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:
@@ -57,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."""
@@ -131,65 +110,6 @@ def authenticated_client(test_client) -> Generator[TestClient, None, None]:
yield test_client
@pytest.fixture
def test_project_and_repo(authenticated_client) -> tuple[str, str]:
"""Create a project and repository directly in the database."""
import uuid
from src.models.project import Project
from src.models.git_repository import GitRepository
project_id = uuid.uuid4()
repo_id = uuid.uuid4()
user_id = None
# Get user ID from session
async def get_user_id():
nonlocal user_id
from src.auth.session import decode_session_cookie
settings = Settings()
session_cookie = authenticated_client.cookies.get("session")
if session_cookie:
session = decode_session_cookie(settings=settings, cookie_value=session_cookie)
if session:
user_id = uuid.UUID(session["user_id"])
asyncio.run(get_user_id())
if not user_id:
raise RuntimeError("Could not get user ID from authenticated client")
async def create_project_and_repo():
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
project = Project(
id=project_id,
name="test-project",
description="Test project",
owner_id=user_id,
)
session.add(project)
repo = GitRepository(
id=repo_id,
name="test-repo",
path="/tmp/test-repo",
project_id=project_id,
owner_id=user_id,
remote_url="https://github.com/test/repo.git",
)
session.add(repo)
await session.commit()
finally:
await gen.aclose()
asyncio.run(create_project_and_repo())
return str(project_id), str(repo_id)
@pytest.fixture
def admin_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with an admin user."""
@@ -1,4 +1,7 @@
"""Integration tests for config profiles API."""
import uuid
import pytest
from fastapi.testclient import TestClient
@@ -17,7 +20,9 @@ class TestConfigProfilesAPI:
response = authenticated_client.get("/config-profiles")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert isinstance(data, dict)
assert "profiles" in data
assert isinstance(data["profiles"], list)
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test creating a config profile."""
@@ -26,99 +31,48 @@ class TestConfigProfilesAPI:
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"
assert data["description"] == "Test profile"
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(
authenticated_client.post(
"/config-profiles",
json={
"name": "duplicate-profile",
"env_vars": {},
"files": {},
},
json={"name": "duplicate-profile"},
)
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": {},
},
json={"name": "duplicate-profile"},
)
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
def test_create_config_profile_empty_name(self, authenticated_client: TestClient) -> None:
"""Test that empty profile names are rejected."""
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": {}}],
},
json={"name": " "},
)
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": {},
},
json={"name": "get-test"},
)
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"
assert "includes" in data
assert "mounts" in data
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
"""Test getting a non-existent profile."""
@@ -127,327 +81,381 @@ class TestConfigProfilesAPI:
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": {},
},
json={"name": "update-test"},
)
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"},
},
json={"name": "updated-name", "description": "updated desc"},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated-name"
assert data["env_vars"] == {"NEW_VAR": "new_value"}
assert data["description"] == "updated desc"
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": {},
},
json={"name": "delete-test"},
)
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(
def test_profile_access_check(self, authenticated_client: TestClient) -> None:
"""Test that users can only access their own profiles."""
# Create a profile
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "base-profile",
"env_vars": {"BASE_VAR": "base_value"},
"files": {},
},
json={"name": "access-test"},
)
base_id = base_response.json()["id"]
profile_id = create_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]},
)
# The profile should be accessible
response = authenticated_client.get(f"/config-profiles/{profile_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(
@pytest.mark.integration
class TestConfigProfileIncludes:
"""Integration tests for config profile includes."""
def test_add_include_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding an include to a profile."""
# Create two profiles
profile1 = 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(
json={"name": "profile-1"},
).json()
profile2 = 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
def test_create_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test creating a config profile with git mounts."""
_project_id, repo_id = test_project_and_repo
json={"name": "profile-2"},
).json()
# Add include
response = authenticated_client.post(
"/config-profiles",
json={
"name": "git-mount-profile",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "/app",
"branch": "main",
}
],
},
f"/config-profiles/{profile1['id']}/includes",
json={"included_profile_id": profile2["id"], "order_index": 0},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "git-mount-profile"
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["target_path"] == "/app"
assert data["git_mounts"][0]["branch"] == "main"
assert data["included_profile_id"] == profile2["id"]
assert data["included_profile_name"] == "profile-2"
def test_update_config_profile_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test updating git mounts on a config profile."""
_project_id, repo_id = test_project_and_repo
# Create profile first
create_response = authenticated_client.post(
def test_add_self_include_rejected(self, authenticated_client: TestClient) -> None:
"""Test that self-includes are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={
"name": "update-git-mounts",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
json={"name": "self-include-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/includes",
json={"included_profile_id": profile["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_include_cycle_rejected(self, authenticated_client: TestClient) -> None:
"""Test that circular includes are rejected."""
profile1 = authenticated_client.post(
"/config-profiles",
json={"name": "cycle-1"},
).json()
profile2 = authenticated_client.post(
"/config-profiles",
json={"name": "cycle-2"},
).json()
# Add profile1 includes profile2
authenticated_client.post(
f"/config-profiles/{profile1['id']}/includes",
json={"included_profile_id": profile2["id"], "order_index": 0},
)
# Try to add profile2 includes profile1 (creates cycle)
response = authenticated_client.post(
f"/config-profiles/{profile2['id']}/includes",
json={"included_profile_id": profile1["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_deep_cycle_rejected(self, authenticated_client: TestClient) -> None:
"""Test that deep circular includes are rejected."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "deep-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "deep-2"}
).json()
p3 = authenticated_client.post(
"/config-profiles", json={"name": "deep-3"}
).json()
# p1 -> p2 -> p3
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
authenticated_client.post(
f"/config-profiles/{p2['id']}/includes",
json={"included_profile_id": p3["id"], "order_index": 0},
)
# Try p3 -> p1 (creates cycle)
response = authenticated_client.post(
f"/config-profiles/{p3['id']}/includes",
json={"included_profile_id": p1["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_duplicate_include_rejected(self, authenticated_client: TestClient) -> None:
"""Test that duplicate includes are rejected."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "dup-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "dup-2"}
).json()
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
response = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 1},
)
assert response.status_code == 409
def test_list_includes(self, authenticated_client: TestClient) -> None:
"""Test listing includes for a profile."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "list-inc-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "list-inc-2"}
).json()
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
response = authenticated_client.get(f"/config-profiles/{p1['id']}/includes")
assert response.status_code == 200
data = response.json()
assert len(data["includes"]) == 1
def test_update_include_order(self, authenticated_client: TestClient) -> None:
"""Test updating include order index."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "order-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "order-2"}
).json()
inc = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
).json()
# Update with git mounts
response = authenticated_client.put(
f"/config-profiles/{profile_id}",
json={
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": "config",
"target_path": "/config",
}
],
},
f"/config-profiles/{p1['id']}/includes/{inc['id']}",
json={"order_index": 5},
)
assert response.status_code == 200
data = response.json()
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["source_path"] == "config"
assert response.json()["order_index"] == 5
def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test that invalid git mount source paths are rejected."""
_project_id, repo_id = test_project_and_repo
def test_remove_include(self, authenticated_client: TestClient) -> None:
"""Test removing an include."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "rem-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "rem-2"}
).json()
inc = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
).json()
response = authenticated_client.delete(
f"/config-profiles/{p1['id']}/includes/{inc['id']}"
)
assert response.status_code == 204
@pytest.mark.integration
class TestConfigProfileMounts:
"""Integration tests for config profile mounts."""
def test_add_mount_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding a mount to a profile."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "mount-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}, "order_index": 0},
)
assert response.status_code == 201
data = response.json()
assert data["target_path"] == "/etc/config"
assert data["files"] == {"test.txt": "hello"}
def test_add_mount_relative_path_rejected(self, authenticated_client: TestClient) -> None:
"""Test that relative mount paths are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-git-mount",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": "/absolute/path",
"target_path": "/app",
}
],
},
json={"name": "rel-path-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "etc/config", "files": {"test.txt": "hello"}},
)
assert response.status_code == 422
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test that git mount target paths with traversal are rejected."""
_project_id, repo_id = test_project_and_repo
def test_add_target_path_traversal_rejected(self, authenticated_client: TestClient) -> None:
"""Test that path traversal in mount paths is rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "traversal-test"},
).json()
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-git-mount-target",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "../../../etc/passwd",
}
],
},
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/../passwd", "files": {"test.txt": "hello"}},
)
assert response.status_code == 422
def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test previewing a profile with git mounts."""
_project_id, repo_id = test_project_and_repo
# Create profile with git mounts
create_response = authenticated_client.post(
def test_add_duplicate_mount_rejected(self, authenticated_client: TestClient) -> None:
"""Test that duplicate mount paths are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={
"name": "preview-git-mounts",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "/app",
}
],
},
)
profile_id = create_response.json()["id"]
json={"name": "dup-mount-test"},
).json()
# Preview
response = authenticated_client.get(f"/config-profiles/{profile_id}/preview")
authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}},
)
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "world"}},
)
assert response.status_code == 409
def test_update_mount(self, authenticated_client: TestClient) -> None:
"""Test updating a mount."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "update-mount-test"},
).json()
mount = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/old/path", "files": {"test.txt": "old"}},
).json()
response = authenticated_client.put(
f"/config-profiles/{profile['id']}/mounts/{mount['id']}",
json={"target_path": "/new/path", "files": {"test.txt": "new"}, "order_index": 2},
)
assert response.status_code == 200
data = response.json()
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
assert data["target_path"] == "/new/path"
assert data["files"] == {"test.txt": "new"}
assert data["order_index"] == 2
def test_remove_mount(self, authenticated_client: TestClient) -> None:
"""Test removing a mount."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "rem-mount-test"},
).json()
mount = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/tmp/test", "files": {"test.txt": "x"}},
).json()
response = authenticated_client.delete(
f"/config-profiles/{profile['id']}/mounts/{mount['id']}"
)
assert response.status_code == 204
@pytest.mark.integration
class TestConfigProfileDefaults:
"""Integration tests for default profile APIs."""
def test_get_default_profiles_empty(self, authenticated_client: TestClient) -> None:
"""Test getting default profiles when none are set."""
response = authenticated_client.get("/config-profiles/defaults")
assert response.status_code == 200
data = response.json()
assert data["default_profiles"] == {}
def test_set_default_profiles(self, authenticated_client: TestClient) -> None:
"""Test setting default profiles."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "default-test"},
).json()
response = authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"code-server": profile["id"]}},
)
assert response.status_code == 200
data = response.json()
assert data["default_profiles"]["code-server"] == profile["id"]
def test_set_default_profiles_invalid_profile(self, authenticated_client: TestClient) -> None:
"""Test setting default profiles with invalid profile ID."""
response = authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"code-server": str(uuid.uuid4())}},
)
assert response.status_code == 404
def test_get_default_profile_for_tool_type(self, authenticated_client: TestClient) -> None:
"""Test getting default profile for a specific tool type."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "tool-default-test"},
).json()
authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"jupyter-notebook": profile["id"]}},
)
response = authenticated_client.get("/config-profiles/defaults/jupyter-notebook")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == "jupyter-notebook"
assert data["profile_id"] == profile["id"]
def test_get_default_profile_for_tool_type_not_set(self, authenticated_client: TestClient) -> None:
"""Test getting default profile when not set."""
response = authenticated_client.get("/config-profiles/defaults/opencode")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == "opencode"
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."""
+22
View File
@@ -82,6 +82,28 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
assert UserConfig.user.property.mapper.class_ is User
@pytest.mark.integration
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
columns = RefreshToken.__table__.columns
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
assert set(columns.keys()) == {
"id",
"user_id",
"token_hash",
"expires_at",
"revoked_at",
"user_agent",
"ip_address",
"created_at",
}
assert columns["token_hash"].unique is True
assert columns["revoked_at"].nullable is True
assert user_fk.target_fullname == "users.id"
assert RefreshToken.user.property.mapper.class_ is User
@pytest.mark.asyncio
@pytest.mark.integration
@@ -1,9 +1,8 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
@@ -59,7 +58,7 @@ def _mint_token(user_id: str) -> str:
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
@@ -1,3 +1,4 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import pytest
@@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str:
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
@@ -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"]
@@ -1,3 +1,4 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@@ -38,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,
@@ -91,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": [],
},
)
@@ -166,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,
@@ -185,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
_ = 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)
+2 -2
View File
@@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import io
@@ -83,7 +83,7 @@ def _create_auth_cookie(user_id: str) -> str:
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
@@ -1,485 +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,
check_include_cycle,
resolve_profile,
_merge_env_vars,
_merge_files,
_merge_mounts,
_merge_runtime_hints,
_merge_git_mounts,
)
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."""
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"}
def test_merge_git_mounts_basic(self) -> None:
"""Test basic git mount merging."""
result = _merge_git_mounts(
[],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
"source",
)
assert len(result) == 1
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
assert result[0]["target_path"] == "/app"
def test_merge_git_mounts_override_same_repo_target(self) -> None:
"""Test that git mounts with same repo+target override."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
"source",
)
assert len(result) == 1
assert result[0]["source_path"] == "src"
assert result[0]["branch"] == "dev"
def test_merge_git_mounts_different_targets(self) -> None:
"""Test that git mounts with different targets are preserved."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
"source",
)
assert len(result) == 2
targets = {m["target_path"] for m in result}
assert targets == {"/app", "/config"}
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_with_git_mounts(self, db_session: AsyncSession) -> None:
"""Test resolving a profile with git mounts."""
user_id = uuid.uuid4()
profile = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="with-git-mounts",
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
],
)
db_session.add(profile)
await db_session.commit()
result = await resolve_profile(db_session, profile.id)
assert len(result.git_mounts) == 1
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
assert result.git_mounts[0]["target_path"] == "/app"
@pytest.mark.asyncio
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
"""Test resolving a profile that includes another with git mounts."""
user_id = uuid.uuid4()
# Create base profile with git mount
base = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="base",
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
],
)
db_session.add(base)
# Create child profile with its own git mount
child = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="child",
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
],
)
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 len(result.git_mounts) == 2
targets = {m["target_path"] for m in result.git_mounts}
assert targets == {"/app", "/config"}
@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
@@ -1,124 +0,0 @@
"""Unit tests for git mount resolution in tool instances."""
import os
from pathlib import Path
import pytest
from src.api.tool_instances import (
_checkout_branch,
_expand_glob_source,
_resolve_single_git_mount,
)
class TestExpandGlobSource:
"""Unit tests for glob pattern expansion."""
def test_no_glob_single_file(self, tmp_path: Path) -> None:
"""Test non-glob path returns single file."""
test_file = tmp_path / "test.txt"
test_file.write_text("content")
result = _expand_glob_source(str(test_file), str(tmp_path))
assert len(result) == 1
assert result[0] == str(test_file)
def test_no_glob_missing_file(self, tmp_path: Path) -> None:
"""Test non-glob missing file returns empty list."""
missing_file = tmp_path / "missing.txt"
result = _expand_glob_source(str(missing_file), str(tmp_path))
assert len(result) == 0
def test_glob_pattern(self, tmp_path: Path) -> None:
"""Test glob pattern matches files."""
(tmp_path / "file1.txt").write_text("content1")
(tmp_path / "file2.txt").write_text("content2")
(tmp_path / "other.py").write_text("code")
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
assert len(result) == 2
assert all(f.endswith(".txt") for f in result)
def test_glob_recursive(self, tmp_path: Path) -> None:
"""Test recursive glob pattern."""
subdir = tmp_path / "subdir"
subdir.mkdir()
(subdir / "nested.txt").write_text("content")
result = _expand_glob_source(str(tmp_path / "**" / "*.txt"), str(tmp_path))
assert len(result) == 1
assert "nested.txt" in result[0]
def test_glob_limit_enforced(self, tmp_path: Path) -> None:
"""Test that glob matches are limited to prevent abuse."""
# Create more than 100 files
for i in range(105):
(tmp_path / f"file{i}.txt").write_text("content")
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
assert len(result) == 100 # MAX_GLOB_MATCHES limit
def test_glob_escapes_repo(self, tmp_path: Path) -> None:
"""Test that glob results outside repo are filtered."""
other_dir = tmp_path.parent / "other"
other_dir.mkdir(exist_ok=True)
(other_dir / "outside.txt").write_text("content")
result = _expand_glob_source(str(tmp_path.parent / "*" / "*.txt"), str(tmp_path))
# Should only include files within tmp_path, not other_dir
assert all(r.startswith(str(tmp_path)) for r in result)
class TestCheckoutBranch:
"""Unit tests for branch checkout."""
def test_checkout_existing_branch(self, tmp_path: Path) -> None:
"""Test checking out an existing branch."""
# Initialize git repo
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
(tmp_path / "file.txt").write_text("content")
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
os.system(f"cd {tmp_path} && git branch feature")
_checkout_branch(str(tmp_path), "feature")
# Verify we're on feature branch
result = os.popen(f"cd {tmp_path} && git branch --show-current").read().strip()
assert result == "feature"
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
"""Test checking out a non-existent branch returns False."""
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
(tmp_path / "file.txt").write_text("content")
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
result = _checkout_branch(str(tmp_path), "nonexistent")
assert result is False
class TestResolveSingleGitMount:
"""Unit tests for resolving a single git mount."""
@pytest.mark.asyncio
async def test_resolve_missing_remote_url(self, db_session) -> None:
"""Test that missing remote_url returns empty list."""
git_mount = {
"source_path": ".",
"target_path": "/app",
}
result = await _resolve_single_git_mount(db_session, git_mount)
assert result == []
@pytest.mark.asyncio
async def test_resolve_missing_target_path(self, db_session) -> None:
"""Test that missing target path returns empty list."""
git_mount = {
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
}
result = await _resolve_single_git_mount(db_session, git_mount)
assert result == []
@@ -1,5 +1,6 @@
"""Tests for git URL parsing utilities."""
import pytest
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
@@ -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"
@@ -0,0 +1,463 @@
"""Unit tests for the profile resolver service."""
import uuid
from unittest.mock import MagicMock
import pytest
from src.services.profile_resolver import (
ProfileCycleError,
ResolvedProfileOutput,
resolve_profile,
)
def _make_profile(
name: str,
env_vars: dict[str, str] | None = None,
start_command: str | None = None,
working_directory: str | None = None,
port: int | None = None,
mounts: list[MagicMock] | None = None,
includes: list[MagicMock] | None = None,
) -> MagicMock:
"""Create a mock ConfigProfile for testing."""
profile = MagicMock()
profile.id = uuid.uuid4()
profile.name = name
profile.environment_variables = env_vars or {}
profile.start_command = start_command
profile.working_directory = working_directory
profile.port = port
profile.mounts = mounts or []
profile.includes = includes or []
return profile
def _make_include(included_profile: MagicMock, order_index: int = 0) -> MagicMock:
"""Create a mock ConfigInclude for testing."""
include = MagicMock()
include.included_profile = included_profile
include.order_index = order_index
return include
def _make_mount(
target_path: str,
mode: str = "rw",
files: dict[str, str] | None = None,
order_index: int = 0,
) -> MagicMock:
"""Create a mock ConfigMount for testing."""
mount = MagicMock()
mount.target_path = target_path
mount.mode = mode
mount.files = files or {}
mount.order_index = order_index
return mount
class TestResolveProfileBasic:
"""Tests for basic profile resolution without includes."""
def test_empty_profile(self) -> None:
"""Resolving an empty profile returns empty output."""
profile = _make_profile("empty")
result = resolve_profile(profile)
assert isinstance(result, ResolvedProfileOutput)
assert result.profile_name == "empty"
assert result.environment_variables == {}
assert result.runtime_hints.start_command is None
assert result.runtime_hints.working_directory is None
assert result.runtime_hints.port is None
assert result.mounts == {}
assert result.resolution_order == ["empty"]
def test_env_vars_only(self) -> None:
"""Profile with env vars resolves correctly."""
profile = _make_profile(
"env-only",
env_vars={"FOO": "bar", "BAZ": "qux"},
)
result = resolve_profile(profile)
assert result.environment_variables == {"FOO": "bar", "BAZ": "qux"}
assert result.env_var_sources == {
"FOO": ["env-only"],
"BAZ": ["env-only"],
}
def test_runtime_hints_only(self) -> None:
"""Profile with runtime hints resolves correctly."""
profile = _make_profile(
"hints-only",
start_command="python app.py",
working_directory="/app",
port=8080,
)
result = resolve_profile(profile)
assert result.runtime_hints.start_command == "python app.py"
assert result.runtime_hints.working_directory == "/app"
assert result.runtime_hints.port == 8080
assert result.runtime_hints.overridden_hints == {
"start_command": "hints-only",
"working_directory": "hints-only",
"port": "hints-only",
}
def test_mounts_only(self) -> None:
"""Profile with mounts resolves correctly."""
profile = _make_profile(
"mounts-only",
mounts=[
_make_mount(
"/config",
mode="ro",
files={"settings.json": '{"key": "value"}'},
),
],
)
result = resolve_profile(profile)
assert "/config" in result.mounts
mount = result.mounts["/config"]
assert mount.target_path == "/config"
assert mount.mode == "ro"
assert mount.files == {"settings.json": '{"key": "value"}'}
class TestResolveProfileIncludes:
"""Tests for profile resolution with includes."""
def test_single_include(self) -> None:
"""Profile with one include resolves in correct order."""
base = _make_profile("base", env_vars={"FOO": "base"})
derived = _make_profile(
"derived",
env_vars={"BAR": "derived"},
includes=[_make_include(base, order_index=0)],
)
result = resolve_profile(derived)
assert result.resolution_order == ["derived", "base"]
assert result.environment_variables == {
"FOO": "base",
"BAR": "derived",
}
def test_multiple_includes_ordered(self) -> None:
"""Multiple includes are resolved in order_index order."""
first = _make_profile("first", env_vars={"KEY": "first"})
second = _make_profile("second", env_vars={"KEY": "second"})
main = _make_profile(
"main",
includes=[
_make_include(first, order_index=0),
_make_include(second, order_index=1),
],
)
result = resolve_profile(main)
assert result.resolution_order == ["main", "first", "second"]
# second overrides first
assert result.environment_variables == {"KEY": "second"}
assert result.env_var_sources["KEY"] == ["first", "second"]
def test_include_order_matters(self) -> None:
"""Changing include order changes resolution."""
a = _make_profile("a", env_vars={"KEY": "a"})
b = _make_profile("b", env_vars={"KEY": "b"})
main1 = _make_profile(
"main",
includes=[
_make_include(a, order_index=0),
_make_include(b, order_index=1),
],
)
main2 = _make_profile(
"main",
includes=[
_make_include(b, order_index=0),
_make_include(a, order_index=1),
],
)
result1 = resolve_profile(main1)
result2 = resolve_profile(main2)
assert result1.environment_variables["KEY"] == "b"
assert result2.environment_variables["KEY"] == "a"
def test_nested_includes(self) -> None:
"""Deeply nested includes resolve recursively."""
deep = _make_profile("deep", env_vars={"DEEP": "value"})
mid = _make_profile(
"mid",
env_vars={"MID": "value"},
includes=[_make_include(deep, order_index=0)],
)
top = _make_profile(
"top",
env_vars={"TOP": "value"},
includes=[_make_include(mid, order_index=0)],
)
result = resolve_profile(top)
assert result.resolution_order == ["top", "mid", "deep"]
assert result.environment_variables == {
"TOP": "value",
"MID": "value",
"DEEP": "value",
}
class TestResolveProfileOverrides:
"""Tests for deterministic override rules."""
def test_env_var_override(self) -> None:
"""Later layers override earlier env vars."""
base = _make_profile("base", env_vars={"KEY": "base"})
override = _make_profile("override", env_vars={"KEY": "override"})
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
assert result.environment_variables["KEY"] == "override"
assert result.env_var_sources["KEY"] == ["base", "override"]
def test_main_profile_wins_over_includes(self) -> None:
"""The main profile itself wins over all includes."""
base = _make_profile("base", env_vars={"KEY": "base"})
main = _make_profile(
"main",
env_vars={"KEY": "main"},
includes=[_make_include(base, order_index=0)],
)
result = resolve_profile(main)
assert result.environment_variables["KEY"] == "main"
assert result.env_var_sources["KEY"] == ["base", "main"]
def test_runtime_hint_override(self) -> None:
"""Later layers override earlier runtime hints."""
base = _make_profile("base", start_command="python old.py")
override = _make_profile("override", start_command="python new.py")
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
assert result.runtime_hints.start_command == "python new.py"
assert result.runtime_hints.overridden_hints["start_command"] == "override"
def test_mount_file_override(self) -> None:
"""Later layers override earlier files in the same mount."""
base = _make_profile(
"base",
mounts=[
_make_mount(
"/config",
files={"app.json": '{"v": 1}'},
),
],
)
override = _make_profile(
"override",
mounts=[
_make_mount(
"/config",
files={"app.json": '{"v": 2}'},
),
],
)
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
mount = result.mounts["/config"]
assert mount.files["app.json"] == '{"v": 2}'
assert mount.overridden_files["app.json"] == ["override"]
def test_mount_mode_override(self) -> None:
"""Later layers override mount mode."""
base = _make_profile(
"base",
mounts=[_make_mount("/data", mode="ro")],
)
override = _make_profile(
"override",
mounts=[_make_mount("/data", mode="rw")],
)
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
assert result.mounts["/data"].mode == "rw"
assert result.mounts["/data"].mode_overridden_by == "override"
def test_mount_file_merge(self) -> None:
"""Different files in the same mount are merged."""
base = _make_profile(
"base",
mounts=[
_make_mount(
"/config",
files={"a.json": "1"},
),
],
)
override = _make_profile(
"override",
mounts=[
_make_mount(
"/config",
files={"b.json": "2"},
),
],
)
main = _make_profile(
"main",
includes=[
_make_include(base, order_index=0),
_make_include(override, order_index=1),
],
)
result = resolve_profile(main)
mount = result.mounts["/config"]
assert mount.files == {"a.json": "1", "b.json": "2"}
class TestResolveProfileCycles:
"""Tests for cycle detection during resolution."""
def test_direct_cycle(self) -> None:
"""A -> B -> A is detected."""
a = _make_profile("a")
b = _make_profile("b", includes=[_make_include(a, order_index=0)])
a.includes = [_make_include(b, order_index=0)]
with pytest.raises(ProfileCycleError) as exc_info:
resolve_profile(a)
assert "a" in exc_info.value.cycle_path
assert "b" in exc_info.value.cycle_path
def test_indirect_cycle(self) -> None:
"""A -> B -> C -> A is detected."""
a = _make_profile("a")
c = _make_profile("c")
b = _make_profile("b", includes=[_make_include(c, order_index=0)])
a.includes = [_make_include(b, order_index=0)]
c.includes = [_make_include(a, order_index=0)]
with pytest.raises(ProfileCycleError) as exc_info:
resolve_profile(a)
assert "a" in exc_info.value.cycle_path
assert "b" in exc_info.value.cycle_path
assert "c" in exc_info.value.cycle_path
def test_self_cycle(self) -> None:
"""A -> A is detected."""
a = _make_profile("a")
a.includes = [_make_include(a, order_index=0)]
with pytest.raises(ProfileCycleError) as exc_info:
resolve_profile(a)
assert exc_info.value.cycle_path == ["a", "a"]
def test_cycle_does_not_partially_resolve(self) -> None:
"""Cycle detection prevents any partial resolution."""
a = _make_profile("a", env_vars={"A": "a"})
b = _make_profile("b", env_vars={"B": "b"})
a.includes = [_make_include(b, order_index=0)]
b.includes = [_make_include(a, order_index=0)]
with pytest.raises(ProfileCycleError):
resolve_profile(a)
class TestResolveProfileDiamond:
"""Tests for diamond-shaped include graphs."""
def test_diamond_resolution(self) -> None:
"""Diamond graph resolves correctly without duplication issues."""
base = _make_profile("base", env_vars={"BASE": "base"})
left = _make_profile(
"left",
env_vars={"LEFT": "left"},
includes=[_make_include(base, order_index=0)],
)
right = _make_profile(
"right",
env_vars={"RIGHT": "right"},
includes=[_make_include(base, order_index=0)],
)
top = _make_profile(
"top",
env_vars={"TOP": "top"},
includes=[
_make_include(left, order_index=0),
_make_include(right, order_index=1),
],
)
result = resolve_profile(top)
# base should appear once (via left, then right skips because visited)
assert result.resolution_order == ["top", "left", "base", "right"]
assert result.environment_variables == {
"TOP": "top",
"LEFT": "left",
"RIGHT": "right",
"BASE": "base",
}
def test_diamond_override(self) -> None:
"""Diamond graph with conflicting overrides resolves correctly."""
base = _make_profile("base", env_vars={"KEY": "base"})
left = _make_profile(
"left",
env_vars={"KEY": "left"},
includes=[_make_include(base, order_index=0)],
)
right = _make_profile(
"right",
env_vars={"KEY": "right"},
includes=[_make_include(base, order_index=0)],
)
top = _make_profile(
"top",
includes=[
_make_include(left, order_index=0),
_make_include(right, order_index=1),
],
)
result = resolve_profile(top)
# right wins because it's later
assert result.environment_variables["KEY"] == "right"
assert result.env_var_sources["KEY"] == ["base", "left", "right"]
# Note: base appears once because visited set skips duplicate resolution in diamond graphs
@@ -1,7 +1,9 @@
"""Unit tests for readiness probe service."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from src.services.readiness_probe import execute_probe
@@ -1,179 +0,0 @@
"""Tests for session creation with branch selection and new branch creation."""
import os
import subprocess
import tempfile
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"
@@ -0,0 +1,112 @@
"""Unit tests for TerminalManager."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.services.terminal_manager import TerminalManager
from src.services.terminal_session import TerminalSession
@pytest.fixture
def manager():
return TerminalManager()
@pytest.fixture
def mock_websocket():
ws = AsyncMock()
ws.send_bytes = AsyncMock()
ws.send_json = AsyncMock()
ws.close = AsyncMock()
ws.receive = AsyncMock()
return ws
@pytest.fixture
def mock_session():
session = MagicMock(spec=TerminalSession)
session.session_id = "sess-123"
session.is_alive.return_value = True
session._closed = False
session.read_output = AsyncMock(return_value=b"")
session.write_input = AsyncMock()
session.resize = AsyncMock()
session.close = AsyncMock()
session.get_exit_reason.return_value = None
return session
class TestCreateSession:
@patch("src.services.terminal_manager.asyncio.create_task")
@patch("src.services.terminal_manager.uuid.uuid4", return_value="sess-123")
async def test_create_session_registers_and_starts_loops(
self, mock_uuid, mock_create_task, manager, mock_websocket
):
instance_id = __import__("uuid").uuid4()
mock_sess = MagicMock()
mock_sess.session_id = "sess-123"
mock_sess.is_alive.return_value = True
mock_sess._closed = False
mock_sess.start = AsyncMock()
mock_sess.read_output = AsyncMock(return_value=b"")
mock_sess.write_input = AsyncMock()
mock_sess.resize = AsyncMock()
mock_sess.close = AsyncMock()
mock_sess.get_exit_reason.return_value = None
with (
patch.object(manager, "_read_loop", new=AsyncMock()),
patch.object(manager, "_write_loop", new=AsyncMock()),
patch.object(manager, "_heartbeat_loop", new=AsyncMock()),
patch(
"src.services.terminal_manager.TerminalSession",
return_value=mock_sess,
),
):
session = await manager.create_session(
instance_id, "container-abc", mock_websocket
)
assert session.session_id == "sess-123"
assert "sess-123" in manager._sessions
assert "sess-123" in manager._last_client_message
class TestHandleControlMessage:
async def test_handle_resize(self, manager, mock_session, mock_websocket):
ctrl = {"type": "resize", "cols": 120, "rows": 40}
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
mock_session.resize.assert_awaited_once_with(120, 40)
async def test_handle_ping(self, manager, mock_session, mock_websocket):
ctrl = {"type": "ping", "id": 42}
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
mock_websocket.send_json.assert_awaited_once_with({"type": "pong", "id": 42})
async def test_handle_unknown_type(self, manager, mock_session, mock_websocket):
ctrl = {"type": "unknown", "data": "test"}
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
mock_websocket.send_json.assert_not_awaited()
mock_session.resize.assert_not_awaited()
class TestCleanupSession:
async def test_cleanup_removes_session(self, manager, mock_session):
manager._sessions["sess-123"] = mock_session
manager._last_client_message["sess-123"] = 123.0
await manager._cleanup_session(mock_session)
assert "sess-123" not in manager._sessions
assert "sess-123" not in manager._last_client_message
mock_session.close.assert_awaited_once()
class TestCloseAll:
async def test_close_all_clears_sessions(self, manager, mock_session):
manager._sessions["sess-123"] = mock_session
manager._last_client_message["sess-123"] = 123.0
await manager.close_all()
assert len(manager._sessions) == 0
assert len(manager._last_client_message) == 0
mock_session.close.assert_awaited_once()
@@ -0,0 +1,168 @@
"""Unit tests for TerminalSession."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from src.services.terminal_session import TerminalSession
@pytest.fixture
def mock_pty():
"""Mock pty.openpty to return predictable fds."""
master_fd = 10
slave_fd = 11
with (
patch(
"src.services.terminal_session.pty.openpty",
return_value=(master_fd, slave_fd),
),
patch("src.services.terminal_session.os.close") as mock_close,
):
yield master_fd, slave_fd, mock_close
class TestTerminalSessionStart:
def test_init_state(self, mock_pty):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
assert session.session_id == "sess-1"
assert session.container_id == "container-abc"
assert session._echo_enabled is True
assert session._exit_reason is None
class TestTerminalSessionEchoDetection:
@patch("src.services.terminal_session.termios.tcgetattr")
def test_detect_echo_state_enabled(self, mock_tcgetattr):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._master_fd = 10
# termios.ECHO flag set
attrs = [[], [], [], __import__("termios").ECHO, [], [], []]
mock_tcgetattr.return_value = attrs
result = session._detect_echo_state()
assert result is True
@patch("src.services.terminal_session.termios.tcgetattr")
def test_detect_echo_state_disabled(self, mock_tcgetattr):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._master_fd = 10
# termios.ECHO flag NOT set
attrs = [[], [], [], 0, [], [], []]
mock_tcgetattr.return_value = attrs
result = session._detect_echo_state()
assert result is False
def test_detect_echo_state_no_master_fd(self):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._master_fd = None
result = session._detect_echo_state()
assert result is True # default
class TestTerminalSessionResize:
@patch("src.services.terminal_session.fcntl.ioctl")
def test_resize_sets_size(self, mock_ioctl):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._master_fd = 10
# Should not raise
asyncio.run(session.resize(120, 40))
mock_ioctl.assert_called_once()
def test_resize_when_closed(self):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._closed = True
# Should not raise
asyncio.run(session.resize(120, 40))
class TestTerminalSessionWriteInput:
@patch("src.services.terminal_session.os.write")
def test_write_input(self, mock_write):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._master_fd = 10
asyncio.run(session.write_input(b"hello"))
mock_write.assert_called_once_with(10, b"hello")
def test_write_input_when_closed(self):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._closed = True
# Should not raise
asyncio.run(session.write_input(b"hello"))
class TestTerminalSessionReadOutput:
@patch("src.services.terminal_session.select.select")
@patch("src.services.terminal_session.os.read")
def test_read_output_with_data(self, mock_read, mock_select):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._master_fd = 10
mock_select.return_value = ([10], [], [])
mock_read.return_value = b"output"
result = asyncio.run(session.read_output())
assert result == b"output"
@patch("src.services.terminal_session.select.select")
def test_read_output_no_data(self, mock_select):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._master_fd = 10
mock_select.return_value = ([], [], [])
result = asyncio.run(session.read_output())
assert result == b""
class TestTerminalSessionClose:
@patch("src.services.terminal_session.os.close")
@patch("src.services.terminal_session.asyncio.wait_for")
async def test_close_sets_exit_reason(self, mock_wait_for, mock_close):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._master_fd = 10
session.process = MagicMock()
session.process.returncode = 0
await session.close()
assert session._exit_reason == "process_exit"
assert session._closed is True
async def test_close_idempotent(self):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session._closed = True
# Should not raise
await session.close()
class TestTerminalSessionIsAlive:
def test_is_alive_with_running_process(self):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session.process = MagicMock()
session.process.returncode = None
assert session.is_alive() is True
def test_is_alive_with_exited_process(self):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session.process = MagicMock()
session.process.returncode = 0
assert session.is_alive() is False
def test_is_alive_no_process(self):
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
session.process = None
assert session.is_alive() is False
+80 -512
View File
@@ -19,6 +19,7 @@
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-serialize": "^0.11.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
@@ -896,24 +897,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 +914,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 +931,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 +1391,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1461,6 +1411,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1478,6 +1431,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1495,6 +1451,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1512,6 +1471,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1529,6 +1491,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1707,6 +1672,9 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1721,6 +1689,9 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1735,6 +1706,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1749,6 +1723,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1763,6 +1740,9 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1777,6 +1757,9 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1791,6 +1774,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1805,6 +1791,9 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1819,6 +1808,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1833,6 +1825,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1847,6 +1842,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1861,6 +1859,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1875,6 +1876,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4357,6 +4361,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4378,6 +4385,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4399,6 +4409,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4420,6 +4433,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6107,420 +6123,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 +6150,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",
@@ -6815,6 +6373,16 @@
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-serialize": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/xterm-addon-serialize/-/xterm-addon-serialize-0.11.0.tgz",
"integrity": "sha512-2CNDnmLdLkNWfsxNFkGsI5FE9W/BbsMzeOrbu59yNqH9L6k1gmL+Ab6VXxEp2NQUJSzaiqi6t0nFR5k5EDkVIg==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-serialize instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-web-links": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
+1
View File
@@ -22,6 +22,7 @@
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-serialize": "^0.11.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
+2 -27
View File
@@ -1,4 +1,4 @@
import axios, { type AxiosRequestConfig } from "axios";
import axios from "axios";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
@@ -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<AxiosRequestConfig, 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);
}
);
-156
View File
@@ -1,156 +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[];
git_mounts: GitMount[];
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 GitMount {
remote_url: string;
source_path: string;
target_path: string;
branch?: 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[];
git_mounts: GitMount[];
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[];
git_mounts?: GitMount[];
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[];
git_mounts?: GitMount[];
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;
};
+3 -57
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 {
@@ -31,24 +29,12 @@ export interface URLParseResult {
}
export async function parseGitUrl(url: string): Promise<URLParseResult> {
const response = await apiClient.post("/repositories/parse-url", { url });
const response = await apiClient.post("/projects/repositories/parse-url", { url });
return response.data;
}
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
if (projectId) {
const response = await apiClient.get<GitRepository[]>(
`/projects/${projectId}/repositories`
);
return response.data;
}
// List all user repositories (including external)
const response = await apiClient.get<GitRepository[]>("/repositories");
return response.data;
}
export async function listAllUserRepositories(): Promise<GitRepository[]> {
const response = await apiClient.get<GitRepository[]>("/repositories");
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
const response = await apiClient.get(`/projects/${projectId}/repositories`);
return response.data;
}
@@ -60,50 +46,10 @@ export async function createRepository(
return response.data;
}
export async function createExternalRepository(
data: GitRepositoryCreate
): Promise<GitRepository> {
const response = await apiClient.post<GitRepository>("/repositories", data);
return response.data;
}
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
}
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 -69
View File
@@ -1,4 +1,3 @@
import { AxiosError } from "axios";
import { apiClient } from "./client";
export interface ToolInstance {
@@ -11,7 +10,6 @@ export interface ToolInstance {
status: string;
url: string | null;
port: number | null;
selected_config_profile_id: string | null;
created_at: string;
}
@@ -27,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(
@@ -48,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;
@@ -71,25 +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) {
// Retry on network errors (e.g. Docker creating network interfaces)
const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500));
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
throw error;
}
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`
);
return response.data;
}
export async function stopInstance(
@@ -106,36 +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) {
// Retry on network errors (e.g. Docker creating network interfaces)
const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500));
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
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}`
);
}
@@ -144,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[];
}
+40 -55
View File
@@ -1,27 +1,26 @@
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" }
];
const ACTIVE_STATUSES = ["running", "building", "pending"];
const SessionItem = ({ session }: { session: Session }) => {
const isRunning = session.status === "running";
const displayName = session.display_name || session.tool_type_name || "Unnamed Session";
return (
<a
@@ -29,11 +28,11 @@ const SessionItem = ({ session }: { session: Session }) => {
target={session.url ? "_blank" : undefined}
rel={session.url ? "noopener noreferrer" : undefined}
className="nav-item session-item"
title={`${session.display_name} (${session.status})`}
title={`${displayName} (${session.status})`}
>
<span className={`session-status ${isRunning ? "running" : ""}`} />
<Icon name={session.tool_icon as IconName} size="sm" />
<span className="session-name">{session.display_name}</span>
<span className="session-name">{displayName}</span>
</a>
);
};
@@ -42,9 +41,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 +53,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 +84,43 @@ 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) => (
<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.filter((s) => ACTIVE_STATUSES.includes(s.status)).length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions
.filter((s) => ACTIVE_STATUSES.includes(s.status))
.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
)}
</>
)}
</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,226 +0,0 @@
import { useState } from "react";
import { Icon } from "./icon";
import type { GitMount } from "../api/config_profiles";
interface GitMountEditorProps {
mounts: GitMount[];
onChange: (mounts: GitMount[]) => void;
}
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [newMount, setNewMount] = useState<GitMount>({
remote_url: "",
source_path: ".",
target_path: "",
branch: "",
});
const handleAdd = (mount: GitMount) => {
onChange([...mounts, mount]);
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
};
const handleUpdate = (index: number, updated: GitMount) => {
const updatedMounts = [...mounts];
updatedMounts[index] = updated;
onChange(updatedMounts);
setEditingIndex(null);
};
const handleRemove = (index: number) => {
onChange(mounts.filter((_, i) => i !== index));
};
const validatePath = (path: string, isTarget: boolean): string | null => {
if (!path) return isTarget ? "Target path is required" : null;
if (path.includes("..")) return "Path cannot contain ..";
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
return null;
};
const validateUrl = (url: string): string | null => {
if (!url) return "Git URL is required";
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
return "Must be a valid git URL (https://, git@, or ssh://)";
}
return null;
};
return (
<div className="git-mount-editor">
<h4 className="section-subtitle">Git Mounts</h4>
{mounts.length > 0 && (
<div className="git-mount-list">
{mounts.map((mount, index) => (
<div key={index} className="git-mount-item">
{editingIndex === index ? (
<GitMountForm
mount={mount}
onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)}
validatePath={validatePath}
validateUrl={validateUrl}
/>
) : (
<div className="git-mount-display">
<div className="git-mount-info">
<span className="git-mount-repo">{mount.remote_url}</span>
<span className="git-mount-paths">
{mount.source_path || "."} {mount.target_path}
</span>
{mount.branch && (
<span className="git-mount-branch">@{mount.branch}</span>
)}
</div>
<div className="git-mount-actions">
<button
type="button"
className="icon-button"
onClick={() => setEditingIndex(index)}
title="Edit"
>
<Icon name="edit" size="sm" />
</button>
<button
type="button"
className="icon-button danger"
onClick={() => handleRemove(index)}
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
<div className="git-mount-add">
<h5>Add Git Mount</h5>
<GitMountForm
mount={newMount}
onSave={handleAdd}
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
validatePath={validatePath}
validateUrl={validateUrl}
isNew
/>
</div>
</div>
);
};
interface GitMountFormProps {
mount: GitMount;
onSave: (mount: GitMount) => void;
onCancel: () => void;
validatePath: (path: string, isTarget: boolean) => string | null;
validateUrl: (url: string) => string | null;
isNew?: boolean;
}
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
const [form, setForm] = useState<GitMount>({ ...mount });
const [errors, setErrors] = useState<Record<string, string>>({});
const handleChange = (field: keyof GitMount, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
const handleSubmit = () => {
const newErrors: Record<string, string> = {};
const urlError = validateUrl(form.remote_url);
if (urlError) newErrors.remote_url = urlError;
const sourceError = validatePath(form.source_path || ".", false);
if (sourceError) newErrors.source_path = sourceError;
const targetError = validatePath(form.target_path, true);
if (targetError) newErrors.target_path = targetError;
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSave(form);
if (isNew) {
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
}
};
return (
<div className="git-mount-form">
<div className="form-row">
<label>Git URL</label>
<input
type="text"
value={form.remote_url}
onChange={(e) => handleChange("remote_url", e.target.value)}
placeholder="https://github.com/user/repo.git"
className={errors.remote_url ? "error" : ""}
/>
<span className="hint">Repository URL (HTTPS or SSH)</span>
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
</div>
<div className="form-row">
<label>Source Path</label>
<input
type="text"
value={form.source_path || "."}
onChange={(e) => handleChange("source_path", e.target.value)}
placeholder="e.g., . or configs/*.json"
className={errors.source_path ? "error" : ""}
/>
<span className="hint">Relative path in repo (supports glob patterns)</span>
{errors.source_path && <span className="error-text">{errors.source_path}</span>}
</div>
<div className="form-row">
<label>Target Path</label>
<input
type="text"
value={form.target_path}
onChange={(e) => handleChange("target_path", e.target.value)}
placeholder="e.g., /app/config"
className={errors.target_path ? "error" : ""}
/>
<span className="hint">Use absolute path (e.g. /app/config). Relative paths need working_directory set in tool config.</span>
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
</div>
<div className="form-row">
<label>Branch (optional)</label>
<input
type="text"
value={form.branch || ""}
onChange={(e) => handleChange("branch", e.target.value)}
placeholder="e.g., main or v1.0"
/>
<span className="hint">Branch or tag to checkout</span>
</div>
<div className="form-actions">
<button type="button" className="primary-button" onClick={handleSubmit}>
{isNew ? "Add" : "Save"}
</button>
<button type="button" className="secondary-button" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);
};
+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
+2 -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 {
@@ -150,6 +147,7 @@ export const Icon: React.FC<IconProps> = ({
const sizeValue = sizeMap[size];
if (!IconComponent) {
console.warn(`Icon "${name}" not found`);
return null;
}
+81 -187
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,14 +192,9 @@ 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-name">{instance.display_name || instance.tool_type_name || "Unnamed Instance"}</div>
<div className="instance-meta">
<span
className="status-dot"
@@ -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,161 +0,0 @@
import { useState } from "react";
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,76 +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,
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)}
/>
</>
);
};

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