Compare commits

...

57 Commits

Author SHA1 Message Date
Developer 597cfb9573 fix: notification center always on top
The notification dropdown was at z-index: 100, well below fullscreen
terminals (1000), modal overlays (1000), and dialog overlays (1000).
Because .notification-center creates a stacking context with no explicit
z-index, the dropdown was trapped behind any of those overlays and
became unclickable.

- Set .notification-center z-index to 9999 so it competes above all
  other overlays in the root stacking context.
- Set .notification-dropdown z-index to 9999 for consistency.

Quality gates: tsc --noEmit (pass), build (pass)
2026-06-04 18:47:46 +00:00
Developer 703cf1f88b fix: mobile terminal back and X buttons navigate to /sessions
Terminal sessions are opened in a new tab via window.open with
noopener,noreferrer. In a new tab, window.history has no previous
entry, so navigate(-1) silently does nothing. Both the back arrow
and the X button in the mobile terminal overlay called navigate(-1),
which made them appear broken.

Change both buttons to navigate('/sessions') so they always exit
to a sensible page regardless of how the terminal was opened.

Quality gates: tsc --noEmit (pass), build (pass)
2026-06-04 18:24:48 +00:00
alex 5dc7d44111 feat: improve session naming with workspace-aware scoped numbering
When a workspace is provided, auto-generated display names now use
workspace.name instead of repo.name:
  'myworkspace / VS Code Server'          # first
  'myworkspace / VS Code Server #2'       # second

Without a workspace, naming falls back to repo.name:
  'myrepo / VS Code Server'
  'myrepo / VS Code Server #2'

The counter is scoped to workspace+tool_type (or repo+tool_type),
so different tool types for the same workspace/repo are numbered
independently.

This replaces the old format of 'project / repo / tool #N' which
was always repo-based and included the project name even though
the sidebar already groups by project.

Quality gates: py_compile passed, ruff passed.
2026-06-04 16:23:40 +02:00
alex ee348643f8 fix: update main.py imports for service subpackages
services/correlation.py was moved to services/shared/correlation.py,
services/event_bus.py to services/instance/event_bus.py, and
services/health_monitor.py to services/instance/health_monitor.py
but main.py was still importing from the old flat paths.

Updated main.py to import from the new subpackage paths via
__init__.py re-exports.

Quality gates: py_compile passed, ruff passed.
2026-06-04 16:12:28 +02:00
alex 05a598812b style: fix import formatting after service reorganization
Minor ruff-driven import wrapping fixes in tool_instances.py and
workspace/__init__.py after service subpackage moves.
2026-06-04 12:49:28 +02:00
alex 7515d9106f docs: update progress.md — all refactoring complete, verification done 2026-06-04 12:47:28 +02:00
alex 8c7affc933 fix: correct relative imports after component reorganization
Fixed import paths for 43 components moved into features/ directories.
Key fixes:
- api/, types/, hooks/, state/, utils/ imports need ../../../ from features/*/
- components/ imports need ../../ from features/*/
- Cross-feature imports use relative paths (e.g., ../tool/tools-bottom-sheet)
- app-shell.tsx updated to import from features/ subdirectories

Frontend typecheck now passes except for one pre-existing error:
xterm-addon-webgl missing type declarations.

Quality gates: ruff passed on backend, py_compile passed on all backend files.
2026-06-04 12:46:45 +02:00
alex 2680a8c44a docs: update progress.md — all refactoring phases complete 2026-06-04 12:38:06 +02:00
alex 1021d61be3 refactor: organize frontend components into features/ directories
Moved 43 component files into 9 feature domains:
- features/git/ — commit-dialog, commit-panel, file-editor, git-mount-editor,
  git-toolbar, merge-dialog
- features/project/ — repositories-settings-tab, repository-create-dialog
- features/terminal/ — special-keys-panel, special-keys-strip,
  terminal-session-tabs, terminal
- features/workspace/ — workspace-card, workspace-create-form,
  workspace-header, workspace-instance-chips
- features/session/ — create-session-form, session-card, session-list
- features/tool/ — instance-list, manifest-editor, start-tool-fab,
  start-tool-modal, tool-starter, tools-bottom-sheet
- features/notification/ — event-toast-bridge, notification-center,
  notification-item
- features/settings/ — settings-tab-layout
- features/mobile/ — mobile-action-sheet, mobile-detail-view, mobile-edit-view,
  mobile-fab, mobile-list-view, mobile-nav, mobile-page-header,
  mobile-terminal-header, mobile-terminal-wrapper

Updated all imports across pages and components.
Root components/ now only contains generic UI pieces:
app-shell, code-editor, data-states, icon, protected-route, syntax-highlighter.

Quality gates: verified no remaining old imports.
2026-06-04 12:37:24 +02:00
alex 7224afafd1 refactor: rename frontend pages to PascalCase with Page suffix
Renamed 18 page files:
- dashboard.tsx → DashboardPage.tsx
- projects.tsx → ProjectsPage.tsx
- sessions.tsx → SessionsPage.tsx
- settings.tsx → SettingsPage.tsx
- ssh-keys.tsx → SshKeysPage.tsx
- terminal.tsx → TerminalPage.tsx
- tool-workshop.tsx → ToolWorkshopPage.tsx
- config-profiles.tsx → ConfigProfilesPage.tsx
- git-repositories.tsx → GitRepositoriesPage.tsx
- repo-workspace.tsx → RepoWorkspacePage.tsx
- workspaces.tsx → WorkspacesPage.tsx
- workspace-detail.tsx → WorkspaceDetailPage.tsx
- profile.tsx → ProfilePage.tsx
- project-settings.tsx → ProjectSettingsPage.tsx
- git-history.tsx → GitHistoryPage.tsx
- placeholder.tsx → PlaceholderPage.tsx

Updated router.tsx imports.

Quality gates: verified no remaining old imports.
2026-06-04 12:34:43 +02:00
alex 020f832eed refactor: rename frontend API files to kebab-case
Renamed:
- git_repositories.ts → git-repositories.ts
- ssh_keys.ts → ssh-keys.ts
- tool_types.ts → tool-types.ts
- tool_types.test.ts → tool-types.test.ts

Updated all imports across components, pages, and hooks.

Quality gates: verified no remaining old imports.
2026-06-04 12:33:10 +02:00
alex 7fe2790199 docs: update progress.md with API router reorganization 2026-06-04 12:30:33 +02:00
alex 38c51ed95e refactor: move API routers into domain subpackages
Moves 21 API router files into 6 domain subpackages (max 6 files each):
- api/tool/ — tool_instances, tool_types, tool_definitions, sessions
- api/config/ — config_profiles, user_config
- api/workspace/ — workspaces, workspace_files, workspace_git, workspace_instances
- api/user/ — users, auth, ssh_keys
- api/project/ — projects, git_repositories
- api/system/ — health, events, notifications, dashboard, terminal, instance_proxy

sessions_router extracted from tool_instances.py into tool/sessions.py.

main.py now imports from subpackage __init__.py re-exports.
Cross-router imports updated to use new paths.
Fixed pre-existing E712 in tool_definitions.py (is_base == False → is_(False)).

Quality gates: py_compile passed on all files, ruff passed.
2026-06-04 12:28:41 +02:00
alex 37ccaa4fdc refactor: organize API routers and services into subpackages
Service organization (19 files moved into 6 subpackages):
- services/instance/ — event_bus, health_monitor, lifecycle_hooks
- services/config/ — config_profile_resolver
- services/git/ — clone, git_operations, git_service
- services/build/ — docker_build, manifest_compiler
- services/terminal/ — terminal_manager, terminal_session
- services/shared/ — correlation, file_service, notification_service,
  permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager

API router organization (16 files moved into 6 subpackages):
- api/tool/ — tool_instances, tool_types, tool_definitions,
  tool_types_validation, sessions (extracted from tool_instances)
- api/config/ — config_profiles, user_config
- api/workspace/ — workspaces, workspace_files, workspace_git,
  workspace_instances
- api/user/ — users, auth, ssh_keys
- api/project/ — projects, git_repositories
- api/system/ — health, events, notifications, dashboard, terminal,
  instance_proxy

Updated main.py imports and all __init__.py re-exports.
Sessions router extracted from tool_instances.py into api/tool/sessions.py.

Quality gates: py_compile passed, ruff passed.
2026-06-04 12:24:14 +02:00
alex 8816ee02ce refactor: extract Pydantic schemas into schemas/ subpackages
Extract inline Pydantic models from 9 API routers into dedicated
schema modules under schemas/:

- schemas/tool/tool_type.py — ToolTypeCreate, ToolTypeUpdate, etc.
- schemas/tool/tool_instance.py — CreateInstanceRequest, StartInstanceRequest
- schemas/config/config_profile.py — ConfigProfileCreate, ConfigProfileUpdate,
  ConfigProfileResponse, DefaultProfilesUpdate, ValidateGitUrlRequest, etc.
- schemas/system/health.py — DatabaseHealth, DiskHealth, HealthResponse, etc.
- schemas/user/user.py — UserProfileResponse, UserProfileUpdate
- schemas/user/user_config.py — UserConfigResponse, UserConfigUpdate
- schemas/project/project.py — ProjectCreate, ProjectUpdate, etc.
- schemas/project/ssh_key.py — SSHKeyCreate, SSHKeyResponse, etc.
- schemas/project/git_repository.py — GitRepositoryCreate, etc.

API routers now import from src.schemas.* instead of defining inline.
Net change: -548 lines across 16 files.

Quality gates: py_compile passed, ruff passed on all 18 files.
2026-06-04 12:15:26 +02:00
alex 6104f592eb refactor: split services/docker.py into docker/ package
Split monolithic docker.py into focused modules:
- docker/compose.py — compose generation, execute_compose_command, volume sorting
- docker/container.py — container status, IP, logs, network, port finding
- docker/config_staging.py — instance dir, env file, config file staging
- docker/tunnel.py — cloudflared tunnel lifecycle (moved from services/tunnel.py)
- docker/__init__.py — re-exports all public symbols for backward compatibility
- services/tunnel.py — thin re-export wrapper for backward compatibility

Also includes schema extraction files created in prior work:
- schemas/config/config_profile.py
- schemas/project/*.py
- schemas/system/health.py
- schemas/tool/*.py
- schemas/user/*.py

All existing imports like 'from src.services.docker import X' and
'from src.services.tunnel import X' continue to work unchanged.

Quality gates: py_compile passed, ruff passed, import test passed.
2026-06-04 10:12:41 +02:00
alex 0591b00ded refactor: reorganize models into subpackages
Move models into domain subpackages (max 4 files each):
- models/tool/ — tool_type, tool_instance, tool_definition_manifest
- models/config/ — config_profile
- models/user/ — user, user_config, ssh_key
- models/project/ — project, git_repository, workspace
- models/system/ — health_check, notification, instance_event, terminal_session

models/__init__.py continues to re-export all symbols, so consumers
using 'from src.models import X' are unaffected. Updated direct file
imports across the backend to use the new paths.

Quality gates: py_compile passed, ruff passed.
2026-06-04 10:00:10 +02:00
alex 0127d283a6 docs: update refactoring spec with submodule architecture
Enforces max 5-10 files per directory using proper subpackages:
- api/tool/, api/config/, api/workspace/, api/user/, api/project/, api/system/
- services/docker/, services/instance/, services/config/, services/git/,
  services/build/, services/terminal/, services/shared/
- models/tool/, models/config/, models/user/, models/project/, models/system/
- schemas/tool/, schemas/config/, schemas/user/, schemas/project/, schemas/system/

Updated design.md module map and tasks.md with 9 phases.
2026-06-04 09:49:55 +02:00
alex 2757ef3b4f docs: add OpenSpec change for backend-frontend refactoring
Recovers and adapts the structural refactoring from overwritten
main merge (b6f89f9) to current dev reality.

Scope:
- Schema extraction into apps/api/src/schemas/
- Docker service split into services/docker/ package
- Instance lifecycle extraction from api/tool_instances.py
- Config profile service extraction from api/config_profiles.py
- Auth dependency refactor (get_current_user)
- Frontend reorganization into features/ dirs + kebab-case naming

Exclusions (already in dev): seeding, defaults, unique constraint,
SSH key mounting, terminal backend, tunnel regex, session auto-numbering.
2026-06-04 09:42:03 +02:00
alex ab55da280c fix: include wait-for-db.sh in Docker build context
The .dockerignore added in 8a0d82f incorrectly excluded wait-for-db.sh,
but the Dockerfile copies it as the container entrypoint. This caused
the Docker build to fail with 'failed to compute cache key: not found'.

Quality gates: verified file exists, py_compile passed.
2026-06-04 00:04:25 +02:00
alex 4e076c36d2 feat: add missing features from main merge
1. Built-in tool type seeding (apps/api/src/seeds/builtin_tool_types.py):
   - Seeds code-server, jupyter-notebook, and opencode on startup.
   - Adapts to current dev model: uses interface_type (single string)
     instead of interfaces array, and created_by_id=None instead of
     is_builtin flag.
   - Called from main.py startup event.

2. Config profile default management:
   - Adds default_profile_id and default_profiles properties to
     UserConfig model for JSON-backed per-tool-type defaults.
   - Adds GET /config-profiles/defaults, PUT /config-profiles/defaults,
     and GET /config-profiles/defaults/{tool_type_id} endpoints.
   - Validates that all profile IDs in default mappings belong to the
     authenticated user before persisting.

3. Config profile unique constraint:
   - Adds __table_args__ with UniqueConstraint(user_id, name) to
     ConfigProfile model. The constraint already exists in the DB
     from migration 2026_05_24_add_config_profiles.py; this just
     aligns the SQLAlchemy model with the schema.

Quality gates: py_compile passed, ruff passed on all modified files.
2026-06-04 00:00:23 +02:00
alex c6d62f84da fix: port useful fixes from overwritten main merge
From ae02e97 ('fix: tunnel URLs, session naming, git control bar placement'):

1. Tunnel URL regex: exclude api.trycloudflare.com from pattern.
   Real tunnel subdomains are 10+ random chars. Prevents matching the
   Cloudflare API endpoint instead of the actual tunnel URL.

2. Session auto-numbering: when user doesn't provide a display_name,
   auto-generate 'project / repo / tool_type #N' where N increments
   for each existing instance with the same project/repo/tool_type.
   Prevents confusing duplicate display names in the sidebar.

These fixes were lost when main's merge was overwritten. Ported to
our clean dev codebase.

Quality gates: py_compile passed, ruff passed on tool_instances.py and tunnel.py.
2026-06-03 23:35:16 +02:00
alex 8a0d82f49b fix: add .dockerignore and src volume mount for live code sync
Add .dockerignore to exclude __pycache__, .venv, test artifacts, and
other host-only files from Docker build context. Prevents stale .pyc
cache pollution in container images.

Add read-only bind mount for ./apps/api/src:/app/src in docker-compose.yml
so code changes on the host are reflected in the running container
without requiring image rebuild. This is a dev convenience that resolves
the persistent 'tool_configs' import error from stale container images.

Quality gates: docker-compose.yml syntax valid, .dockerignore parsed.
2026-06-03 23:19:45 +02:00
alex 0c74997cfe fix: add missing alembic merge migration for DB convergence
The production DB was already migrated to 86cec91fdb00 (merge of
0014_add_profile_resolver_fields and 2026_06_01_add_workspaces) during
earlier fixes. The clean base branch lacked these migration files,
causing startup failure: 'Can't locate revision identified by 86cec91fdb00'.

Copy the idempotent 0013/0014 migrations and the no-op merge revision
from the fix commits so the Alembic graph matches the DB state.

Quality gates: alembic heads returns single head (86cec91fdb00),
py_compile and ruff passed on all three files.
2026-06-03 22:23:19 +02:00
alex fc72c5f6e9 fix: resolve mapper configuration for ToolDefinitionManifest and Workspace
Move ToolDefinitionManifest import out of TYPE_CHECKING in tool_type.py
so SQLAlchemy can resolve the string-annotated relationship during mapper
configuration.

Add Workspace to models/__init__.py (before ToolInstance) so the
ToolInstance-Workspace relationship can be resolved.

Quality gates: py_compile passed, ruff passed, all mappers configure OK.
2026-06-03 14:24:22 +02:00
Alex Blank 51a399c775 feat: open all sessions in new tabs; sidebar terminal links
Sidebar SessionItem was only opening web tool URLs in new tabs.
Terminal sessions linked to the project page in the same tab.
SessionCard 'Open' buttons for terminal sessions navigated in-place.

Changes:
- app-shell.tsx: SessionItem now builds terminal URLs
  (/instances/:id/terminal) and always uses target=_blank
- session-card.tsx: compute openHref for both web and terminal sessions,
  render <a> links with target=_blank instead of callback buttons
- use-instance-actions.ts: handleOpen now uses window.open(..., '_blank')
  for terminal sessions and project fallback

All session opening (sidebar, cards, callbacks) now consistently opens
in a new tab.

Quality gates: tsc clean
2026-06-02 15:54:03 +02:00
Alex Blank fc75eeb76d fix: add SSH key selection to workspace tool starter + docker compose policy
tool-starter.tsx was hardcoding ssh_key_ids=[] and only showing a read-only
SSH key status. Users couldn't select keys when starting tools from workspaces.

Changes:
- tool-starter.tsx: add checkboxes for SSH key selection with repo key
  pre-selected, pass selected keys to createInstance/startInstance
- AGENTS.md: add explicit rule forbidding docker compose commands without
  user approval and proper isolation

The web container must be rebuilt to pick up the frontend changes:
  docker compose up -d --build web

Quality gates: tsc clean, pytest (19 passed, 1 skipped)
2026-06-02 15:40:20 +02:00
Alex Blank 37134b8c18 fix: terminal EOF detection and dead session cleanup
When a tool container stops, the docker exec PTY reaches EOF. Previously,
the event-driven reader silently returned on EOF, leaving websockets
attached to a dead session. Input writes then failed silently.

Changes:
- _on_fd_readable: detect EOF (empty read) and call _handle_eof()
- _handle_eof: stop reading, mark process dead, close all websockets
  with code 4001 to force frontend reconnection
- write_input: detect write errors and trigger EOF cleanup

Quality gates: pytest (19 passed, 1 skipped)
2026-06-02 15:23:16 +02:00
Alex Blank c6b804bf0a feat: fix SSH key mounting with multi-key support and unique filenames
SSH key mounting was broken because:
1. Each selected key was mounted to a separate source dir but all targeted
   the same ~/.ssh path in the container, causing Docker Compose's
   last-mount-wins behavior
2. All keys were named id_ed25519, so they'd overwrite each other

Changes:
- ssh_keys.py: add key_filename param to prepare_ssh_key_files for unique
  key names; add write_ssh_config for combined multi-key config
- tool_instances.py: collect all selected keys into a single ~/.ssh mount
  with sanitized unique filenames (id_ed25519_<name>); generate combined
  SSH config with all IdentityFile entries
- tests: add os.makedirs mock for SSH permission tests

Quality gates: pytest (19 passed, 1 skipped)
2026-06-02 15:03:26 +02:00
Alex Blank c754984df8 feat: high-performance web terminal with asyncio-native I/O
Complete rewrite of the terminal pipeline for VS Code Server-level
responsiveness. Key improvements:

Backend:
- Replace blocking select.select(0.1) with asyncio.add_reader() for
  event-driven PTY reading (eliminates ~110ms polling latency)
- Add output batching (2ms window) to reduce WebSocket frame overhead
- Add flow control: client acks processed bytes, server pauses PTY reads
  at 64KB threshold, resumes at 32KB
- Add 5s ack timeout fallback to prevent stuck sessions

Frontend:
- Switch WebSocket to binary mode (binaryType = 'arraybuffer')
- Eliminate Blob -> arrayBuffer async conversion overhead
- Add flow control ack messages (every 4096 bytes or 100ms)
- Add xterm-addon-webgl with graceful DOM fallback
- Add performance tuning (scrollback=10000, fastScrollSensitivity)

SDD artifacts:
- openspec/explorations/terminal-responsiveness.md
- openspec/proposals/terminal-responsiveness.md
- openspec/specs/terminal-responsiveness.md
- openspec/designs/terminal-responsiveness.md
- openspec/tasks/terminal-responsiveness.md

Quality gates: pytest (19 passed, 1 skipped), tsc --noEmit clean
2026-06-02 14:40:32 +02:00
Alex Blank 906aab3b73 fix: workspace creation with stale directories and missing bind mount
- workspace_manager.py: remove stale workspace directories before cloning
  to prevent 'already exists' errors from previous failed attempts
- workspaces.py: add ValueError -> 400 handling, keep 409 for duplicates
- test_tool_instances_legacy.py: fix broken patches for new helpers
  (get_container_name removed, _ensure_backend_network_in_compose added,
  workspace_id/ssh_key_ids mock attributes added)
- docker-compose.traefik.yml: add /data/working-copies bind mount

Quality gates: pytest (19 passed, 1 skipped)
2026-06-02 13:41:07 +02:00
Alex Blank e1aaf9f6fc Merge branch 'fix/workspace-mount-bind-mount' into dev
Resolve duplicate working-copies mount declaration.
2026-06-02 13:13:15 +02:00
Alex Blank 6170306d9e fix: add working-copies bind mount and replace repo_data volume
The workspace mount was failing because /data/working-copies/ was not
bind-mounted into the API container. The workspace management code writes
to /data/working-copies/ inside the API container, but tool instances
mount from the host filesystem. Without a shared bind mount, the host
saw an empty directory.

- docker-compose.yml: add /data/working-copies bind mount, replace repo_data
- docker-compose.traefik.yml: same changes
- Remove repo_data named volume declaration from both files
2026-06-02 12:59:40 +02:00
alex 04cd9ff472 chore: add diagnostic logging for manifest mount resolution
- Log REPO_PATH, SSH_PATH, EXTRA_VOLUMES, manifest mounts, and resolved
  volumes in compile_compose() to trace why mounts may be missing
- Log repo_path and generated compose content in _prepare_manifest_instance()
  to verify the full compose YAML at start time
2026-06-02 00:00:33 +02:00
alex 1bf42a7feb fix: workspace delete MissingGreenlet + nginx cache-busting
- Convert WorkspaceHasInstancesError to store plain dicts instead of
  SQLAlchemy ORM objects, preventing lazy-load failures outside async
  session context (MissingGreenlet)
- Update both delete endpoints (top-level and nested) to use exc.instances
  directly since they're already plain dicts
- Add no-cache headers for index.html in nginx.conf so browsers always
  fetch new hashed JS/CSS bundles on deploy
2026-06-01 23:52:18 +02:00
alex 56dd7d3fd3 fix: tool start hanging + SSE 429 errors
1. Remove Docker build from create_instance for manifest types — the build
   was blocking the HTTP request for several minutes, causing frontend
   timeouts and retries. Image is now built lazily on start (via the
   existing _prepare_manifest_instance path in start_instance).

2. Increase MAX_CONNECTIONS_PER_USER from 5 to 20 for SSE endpoint —
   aggressive reconnect loops from the frontend were exhausting the limit
   and causing 429 errors unrelated to tool starting.

Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces (9 passed)
2026-06-01 23:29:28 +02:00
alex 8837031fd2 fix: ESC key, .config, workspace permissions, terminal race condition 2026-06-01 23:16:06 +02:00
alex a0cfbbc2d2 fix: container mount permissions, terminal shift, ESC capture
Bug 1 — in-container repo mounting:
- docker-compose.yml: added /data/working-copies:/data/working-copies mount
  to API container so workspace dirs are visible on host filesystem
- Dockerfile: create /data/working-copies dir in image

Bug 2 — /home/user not writable:
- workspace_manager.py: chmod 777 workspace dirs + 666 files after clone
  and after sync, so any container user can write
- manifest_compiler.py: explicit mkdir + chown + chmod 755 for home dir
  in generated Dockerfile

Bug 3 — terminal text shifts left on typing:
- terminal.tsx: removed manual term.refresh() after fit (caused reflow)
- Track lastSentCols/lastSentRows and only send resize when dimensions
  actually changed, preventing resize feedback loops

Bug 4 — ESC key captured by terminal:
- terminal.tsx: attachCustomKeyEventHandler allows ESC to propagate to
  browser when not in alternate buffer (vim/tmux), so modals/navigation
  work; ESC still sent to PTY when in vim/tmux alternate screen

Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces (9 passed)
2026-06-01 22:36:31 +02:00
alex 280a6ff2fa feat: floating action button for starting tools globally
- New StartToolFAB component: fixed floating button (bottom-right) opens
  a modal with workspace selector + ToolStarter
- Added to AppShell: available on every page except mobile terminal
- Dashboard (home): removed old CreateSessionForm and 'Quick create' section,
  replaced with FAB + 'Workspaces quick access' prompt
- Sessions page: removed inline workspace selector + ToolStarter, now
  shows prompt to use the FAB
- Styles: .start-tool-fab with hover scale, shadow, mobile offset above tab bar

Quality gates: tsc --noEmit clean, pytest workspaces API (9 passed, 1 skipped)
2026-06-01 22:09:49 +02:00
alex 78e808bc54 feat: sessions page workspace-first flow + workspace instance chips
- Sessions page: replaced CreateSessionForm with workspace selector + ToolStarter
  - Fetches workspaces, shows dropdown, then renders ToolStarter for selected workspace
  - Removed old project/repo/tool-type/config-profile/clone-mode flow
- Workspace cards: new WorkspaceInstanceChips component fetches and displays
  running instances per workspace with status-colored chips and open links
- Styles: .instance-chip variants (running/starting/error), .tool-starter-header

Quality gates: tsc --noEmit clean, pytest workspaces API (9 passed, 1 skipped)
2026-06-01 21:53:48 +02:00
alex c1445976d7 feat: new ToolStarter component — unified workspace-first tool starting
- New ToolStarter component: workspace context, fetches real tool types,
  auto-fetches config profiles per tool type, shows SSH key status
- Backend: add repo_ssh_key_id to workspace list responses
- WorkspacesPage: uses ToolStarter in modal instead of StartToolModal
- WorkspaceDetailPage ToolsTab: uses ToolStarter in modal
- Removed old inline StartToolModal from workspace-detail.tsx
- Styles: .tool-starter-context, .context-row, .ssh-key-status

Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces API (9 passed, 1 skipped)
2026-06-01 21:27:21 +02:00
alex ff8aa2a4f5 refactor: unify tool-starting logic with shared useStartTool hook
- New useStartTool hook: calls working createInstance+startInstance API
- WorkspacesPage: uses shared hook instead of inline createInstance logic
- WorkspaceDetailPage ToolsTab: uses shared hook instead of broken createWorkspaceInstance
- Both pages now use identical StartToolModal + identical start logic

Quality gates: tsc --noEmit clean
2026-06-01 20:58:25 +02:00
alex 398436ecb5 refactor: unify StartToolModal — remove inline duplicate from workspace detail
- Delete inline hardcoded StartToolModal from workspace-detail.tsx
- Import shared StartToolModal that fetches real tool types from API
- Pass workspace object to ToolsTab so shared modal gets proper context
- onStart handler passes optional configProfileId through to useWorkspaceInstances.create()

Quality gates: tsc --noEmit clean
2026-06-01 20:45:19 +02:00
alex 06a4a27880 fix: StartToolModal hardcoded tool types caused UUID parse error
- Fetch real tool types from API instead of hardcoded string names
- Use actual tool type UUID (id) as select value
- Remove fake 'terminal' option — terminal is a feature, not a tool type
- Show display_name in dropdown, handle loading/error states

Quality gates: tsc --noEmit clean
2026-06-01 20:36:31 +02:00
alex f34c733706 fix: remove trailing slashes causing FastAPI redirect → mixed-content
- workspaceUrl: no trailing slash on /{workspaceId} (backend route has none)
- deleteWorkspace: /workspaces/{id}?force= (was /{id}/?force= with slash before ?)

Quality gates: tsc --noEmit clean, pytest workspaces API (9 passed, 1 skipped)
2026-06-01 20:20:23 +02:00
alex 95efa5d029 fix: workspace delete mixed-content error via top-level endpoint
- Add top-level DELETE /workspaces/{workspace_id} endpoint (avoids nested path)
- Frontend deleteWorkspace now uses /workspaces/{id}/?force=... (no project/repo needed)
- Update useWorkspaceActions, ProjectsPage, WorkspacesPage to match new signature

Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces API (9 passed, 1 skipped)
2026-06-01 20:03:24 +02:00
alex 9a036f1968 fix: SSH auth for workspace sync (fetch/pull/branch check)
- GitService.fetch(), pull(), branch_exists_remotely() now accept ssh_key param
- WorkspaceManager.sync() loads repo SSH key from DB via session
- sync_workspace endpoint passes session to manager.sync()

Quality gates: ruff clean, pytest workspaces API (9 passed, 1 skipped)
2026-06-01 19:50:07 +02:00
alex ec6d4ad496 fix: workspace creation SSH authentication
- GitService.clone() now accepts ssh_key and sets up GIT_SSH_COMMAND env
- WorkspaceManager.create() loads repo SSH key from DB and decrypts it
- Both workspace create endpoints pass session for SSH key lookup

Quality gates: ruff clean, pytest workspaces API (9 passed, 1 skipped)
2026-06-01 19:42:51 +02:00
alex d70b8e2363 fix: branches endpoint with SSH auth for remote fallback + better error messages
Backend (git_repositories.py):
- get_repository_branches: check for .git dir OR HEAD file (handles bare repos)
- When local repo is missing, git ls-remote fallback now uses SSH key auth
  via _prepare_ssh_env() for repos with ssh_key_id
- Cleans up temp SSH key file after ls-remote
- Logs ls-remote stderr/exit code for debugging
- Returns server's detail message instead of raw axios 404 text

Frontend (use-git-repo.ts):
- extractError() helper pulls server detail/message from axios responses
- User sees 'repository not found on disk — re-clone or re-create'
  instead of generic 'Request failed with status code 404'

Quality gates: ruff clean, tsc --noEmit clean, 11 passed + 1 pre-existing failure
2026-06-01 19:21:02 +02:00
alex ee3c5af7a4 fix: handle missing/corrupt repos when fetching branches + clearer manual fallback
Backend (git_repositories.py):
- get_repository_branches now checks for .git subdirectory (not just dir existence)
- If local repo is corrupt/missing but has remote_url, falls back to git ls-remote
  to list branches from the remote
- Returns 404 with actionable message instead of 400 with raw git stderr
- Pre-existing test failure in test_git_repository_clone_preflight.py unchanged

Frontend (workspace-create-form.tsx):
- When branch API fails, auto-switches to manual text input (no dropdown selection needed)
- Shows hint text: 'Couldn't load branches — type one manually'
- useGitRepo hook auto-fetches branches when projectId/repoId change

Quality gates: ruff clean, tsc --noEmit clean, 93 passed + 1 pre-existing failure
2026-06-01 19:00:59 +02:00
alex b02cd978c3 feat: unified git repo hook + fix branch dropdown in workspace creation
- New useGitRepo hook: centralizes all git operations (branches, status,
  history, commit, push, pull, fetch, checkout, create/delete branch, merge)
  for a given project+repo. Auto-refreshes after mutating ops.
- Fix WorkspaceCreateForm branch dropdown:
  - Always renders <select> (never input fallback)
  - Uses useGitRepo for branch fetching with loading/error states
  - Shows 'Loading branches...' while fetching
  - Shows 'Enter branch name manually...' if API fails
  - '+ Create new branch...' option with text input reveal
  - Auto-selects default branch on load
- ProjectsPage uses updated form props (defaultProjectId/defaultRepoId)

Quality gates: tsc --noEmit clean, eslint clean
2026-06-01 18:45:53 +02:00
alex e956d7c30d feat: unify workspace creation component with branch dropdown
- Rewrite WorkspaceCreateForm as unified component used in both pages
- Standalone mode (WorkspacesPage): shows project/repo/branch selectors
- Contextual mode (ProjectsPage): accepts defaultProjectId/defaultRepoId,
  skips project/repo selectors, shows only name + branch dropdown
- Branch dropdown fetched from repo via listRepositoryBranches API
- Auto-selects first/only option for project, repo, and branch
- '+ Create new branch...' option reveals text input for custom branch
- Falls back to free-text branch input if branch API fails
- Removes duplicated inline creation logic from WorkspacesPage
- TypeScript + eslint clean
2026-06-01 18:18:00 +02:00
alex b8fc4e6642 feat: workspace creation with branch dropdown and auto-select
- Fetch branches from selected repo via listRepositoryBranches API
- Branch dropdown with default branch pre-selected
- '+ Create new branch...' option reveals text input for custom branch
- Auto-select first option when only one available:
  - Project: auto-selects when only 1 project
  - Repo: auto-selects when only 1 repo
  - Branch: auto-selects when only 1 branch, otherwise defaults to remote default
- Falls back to free-text branch input if branch API fails
- TypeScript + eslint clean
2026-06-01 17:48:27 +02:00
alex ab1843b1c3 fix: allow workspace creation from workspaces page
- Replace awkward first-workspace-guessing logic with inline project/repo selector
- New WorkspaceCreateInline component with cascading dropdowns:
  - Select project → loads repositories for that project
  - Select repository → enter workspace name + branch
  - Submit creates workspace via top-level POST /workspaces/
- Add createWorkspaceTopLevel() API client for flat endpoint
- Works even with zero existing workspaces (shows create button in empty state)
- Add CSS grid layout for inline create form
- TypeScript + eslint clean
2026-06-01 17:32:35 +02:00
alex 88a973dc68 feat: workspace-first UI refresh - PR-3 projects page + routing cleanup
- Rewrite ProjectsPage with inline repository and workspace display
- Expandable project cards showing repos + workspace chips
- Inline workspace creation from project page (New Workspace button per repo)
- Workspace chips link to workspace detail page
- Sync/delete actions on workspace chips
- Update Project type: add ProjectWithRepos, RepositorySummary, WorkspaceSummary
- Update listProjects API to return ProjectWithRepos[]
- Update dashboard, sessions, config-profiles to use ProjectWithRepos
- Remove old /projects/:projectId route (RepoWorkspace)
- Add chevron icons to Icon component
- Projects page CSS: project-toggle, repo-block, workspace-grid, workspace-chip
- TypeScript + eslint clean

Quality gates: tsc --noEmit clean, eslint clean
2026-06-01 17:18:51 +02:00
alex 27c77af591 feat: workspace-first UI refresh - PR-2 workspace detail page
- Add workspace detail page (/workspaces/:id) with 4 tabs:
  - Files: file tree, viewer, editor, git toolbar (commit/push/pull/fetch)
  - Git: branch selector, commit history
  - Tools: instance grid, start tool modal
  - Settings: workspace info read-only
- Add workspace API clients: workspace-files, workspace-git, workspace-instances
- Add hooks: useWorkspaceFiles, useWorkspaceGit, useWorkspaceInstances
- WorkspaceCard links to detail page via router Link
- Add comprehensive CSS for workspace detail layout
- Mobile: bottom tab bar, responsive file tree/split
- TypeScript + eslint clean

Quality gates: tsc --noEmit clean, eslint clean
2026-06-01 17:04:44 +02:00
alex e7587ca9f5 feat: workspace-first UI refresh - PR-1 backend endpoints
- Add FileService for workspace-scoped file operations
- Add GitOperations service for workspace-scoped git commands
- Add workspace_files API: GET/POST /workspaces/{id}/files
- Add workspace_git API: status, branches, commit, push, pull, fetch, checkout, history
- Add workspace_instances API: list instances per workspace
- Add top-level POST /workspaces/ (accepts repo_id directly)
- Enrich GET /projects/ with nested repositories and workspaces
- Register all new routers in main.py
- 23 tests passing (17 existing + 6 new)

Quality gates: ruff clean
2026-06-01 16:47:09 +02:00
219 changed files with 11743 additions and 3311 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"fingerprint": "fdea8a74bb4c7449c01c4bd61646c895b10ede78"
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812"
}
+1 -2
View File
@@ -2,7 +2,7 @@
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
Last updated: 2026-05-28
Last updated: 2026-06-02
## Sources scanned
@@ -21,7 +21,6 @@ Last updated: 2026-05-28
| Skill | Trigger / description | Scope | Path |
| --- | --- | --- | --- |
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
| `openspec` | Use OpenSpec as the source of truth for planning, implementation, verification, and archive discipline. | user | `/home/alex/.config/opencode/skills/openspec/SKILL.md` |
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
+1
View File
@@ -75,6 +75,7 @@ Do not:
* Introduce new dependencies without clear justification.
* Treat existing code as more authoritative than OpenSpec for intended behavior.
* Decide product behavior silently when the spec is unclear.
* Run `docker compose` commands (build, up, down, etc.) without explicit user approval and proper isolation (e.g., feature branches, separate worktrees, or staged rollouts). Docker Compose operations are deployment-level changes that can affect running services, shared volumes, and network state. Always ask first.
If scope must change, propose an OpenSpec update first.
+42
View File
@@ -0,0 +1,42 @@
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
# Virtual environments
.venv/
venv/
env/
# Test artifacts
.pytest_cache/
.coverage
htmlcov/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Git
.git/
.gitignore
# Local env files
.env
.env.local
# Alembic cache
alembic/versions/__pycache__/
# Pi lens cache
.pi-lens/
# Documentation
docs/
*.md
# Scripts not needed in container
scripts/
+2 -2
View File
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
# Copy application code
COPY --chown=appuser:appgroup . .
# Create directories for repo and instance storage
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
# Create directories for repo, instance, and workspace storage
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
# Copy wait-for-db script
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
@@ -0,0 +1,204 @@
"""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 collections.abc import Sequence
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: str | None = "0012_default_port_req"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
return column_name in {
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
return index_name in {
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
}
def _foreign_key_exists(
table_name: str,
constrained_columns: list[str],
referred_table: str,
) -> bool:
if not _table_exists(table_name):
return False
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if (
foreign_key.get("constrained_columns") == constrained_columns
and foreign_key.get("referred_table") == referred_table
):
return True
return False
def upgrade() -> None:
# Earlier branches may already have created config_profiles. Keep this
# migration defensive so databases can converge onto the current graph.
if not _table_exists("config_profiles"):
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"
),
)
if not _index_exists("config_profiles", "idx_config_profiles_user"):
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
if not _table_exists("config_includes"):
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"
),
)
if not _index_exists("config_includes", "idx_config_includes_profile"):
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
if not _index_exists("config_includes", "idx_config_includes_included"):
op.create_index(
"idx_config_includes_included", "config_includes", ["included_profile_id"]
)
if not _table_exists("config_mounts"):
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"),
)
if not _index_exists("config_mounts", "idx_config_mounts_profile"):
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
if not _column_exists("tool_instances", "selected_profile_id"):
op.add_column(
"tool_instances",
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _foreign_key_exists(
"tool_instances", ["selected_profile_id"], "config_profiles"
):
op.create_foreign_key(
"fk_tool_instances_selected_profile",
"tool_instances",
"config_profiles",
["selected_profile_id"],
["id"],
ondelete="SET NULL",
)
if not _index_exists("tool_instances", "idx_tool_instances_selected_profile"):
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")
@@ -0,0 +1,180 @@
"""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 collections.abc import Sequence
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: str | None = "0013_add_config_profiles"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
return column_name in {
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
return index_name in {
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
}
def _foreign_key_exists(
table_name: str,
constrained_columns: list[str],
referred_table: str,
) -> bool:
if not _table_exists(table_name):
return False
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if (
foreign_key.get("constrained_columns") == constrained_columns
and foreign_key.get("referred_table") == referred_table
):
return True
return False
def _foreign_key_names_for_column(table_name: str, column_name: str) -> list[str]:
if not _table_exists(table_name):
return []
names: list[str] = []
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name):
if column_name in foreign_key.get("constrained_columns", []):
name = foreign_key.get("name")
if name:
names.append(name)
return names
def upgrade() -> None:
if not _column_exists("config_profiles", "project_id"):
op.add_column(
"config_profiles",
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _column_exists("config_profiles", "tool_type_id"):
op.add_column(
"config_profiles",
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _column_exists("config_profiles", "environment_variables"):
op.add_column(
"config_profiles",
sa.Column("environment_variables", sa.JSON(), nullable=True),
)
if not _column_exists("config_profiles", "start_command"):
op.add_column(
"config_profiles",
sa.Column("start_command", sa.Text(), nullable=True),
)
if not _column_exists("config_profiles", "working_directory"):
op.add_column(
"config_profiles",
sa.Column("working_directory", sa.Text(), nullable=True),
)
if not _column_exists("config_profiles", "port"):
op.add_column("config_profiles", sa.Column("port", sa.Integer(), nullable=True))
if not _column_exists("config_profiles", "is_default"):
op.add_column(
"config_profiles",
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
)
if not _foreign_key_exists("config_profiles", ["project_id"], "projects"):
op.create_foreign_key(
"fk_config_profiles_project",
"config_profiles",
"projects",
["project_id"],
["id"],
ondelete="CASCADE",
)
if not _foreign_key_exists("config_profiles", ["tool_type_id"], "tool_types"):
op.create_foreign_key(
"fk_config_profiles_tool_type",
"config_profiles",
"tool_types",
["tool_type_id"],
["id"],
ondelete="CASCADE",
)
if not _index_exists("config_profiles", "idx_config_profiles_project"):
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
if not _index_exists("config_profiles", "idx_config_profiles_tool_type"):
op.create_index(
"idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"]
)
if _column_exists("config_mounts", "mount_path") and not _column_exists(
"config_mounts", "target_path"
):
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
if not _column_exists("config_mounts", "mode"):
op.add_column(
"config_mounts",
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
)
if not _column_exists("config_mounts", "files"):
op.add_column(
"config_mounts",
sa.Column("files", sa.JSON(), nullable=True),
)
for constraint_name in _foreign_key_names_for_column(
"config_mounts", "source_profile_id"
):
op.drop_constraint(constraint_name, "config_mounts", type_="foreignkey")
if _column_exists("config_mounts", "content"):
op.drop_column("config_mounts", "content")
if _column_exists("config_mounts", "source_profile_id"):
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")
@@ -0,0 +1,20 @@
"""merge profile resolver and workspaces heads
Revision ID: 86cec91fdb00
Revises: 0014_add_profile_resolver_fields, 2026_06_01_add_workspaces
Create Date: 2026-06-03 12:48:36.145702
"""
# revision identifiers, used by Alembic.
revision = "86cec91fdb00"
down_revision = ("0014_add_profile_resolver_fields", "2026_06_01_add_workspaces")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+1 -6
View File
@@ -1,6 +1 @@
from src.api.auth import router as auth_router
from src.api.events import router as events_router
from src.api.notifications import router as notifications_router
from src.api.users import router as users_router
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
"""API routers package."""
+6
View File
@@ -0,0 +1,6 @@
"""Config API routers module."""
from src.api.config.config_profiles import router as config_profiles_router
from src.api.config.user_config import router as user_config_router
__all__ = ["config_profiles_router", "user_config_router"]
@@ -7,17 +7,25 @@ import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.api.shared_validators import validate_env_vars as _validate_env_vars
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models import ConfigProfile, ConfigProfileInclude
from src.models.project import Project
from src.models.tool_type import ToolType
from src.services.config_profile_resolver import (
from src.models import ToolType
from src.models import UserConfig
from src.schemas.config import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
ConfigProfileResponse,
ConfigProfileUpdate,
DefaultProfilesUpdate,
ValidateGitUrlRequest,
ValidateGitUrlResponse,
)
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError,
check_include_cycle,
resolve_profile,
@@ -33,16 +41,6 @@ MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def _validate_uuid(v: str | None) -> str | None:
if v is None:
return v
try:
uuid.UUID(v)
except ValueError:
raise ValueError(f"Invalid UUID: {v}")
return v
def _calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data."""
total = 0
@@ -60,247 +58,6 @@ def _calculate_profile_size(data: dict) -> int:
return total
class GitMountMapping(BaseModel):
source_path: str = Field(
description="Path within repository (supports glob patterns)"
)
target_path: str = Field(description="Absolute path inside container")
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str) -> str:
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
source_path: str | None = Field(
default=None, description="Path within repository (legacy single mapping)"
)
target_path: str | None = Field(
default=None,
description="Absolute path inside container (legacy single mapping)",
)
branch: str | None = Field(default=None, description="Optional branch or tag name")
mappings: list[GitMountMapping] | None = Field(
default=None, description="Multiple source/target mappings from the same repo"
)
@field_validator("remote_url")
@classmethod
def validate_remote_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://", "git@", "ssh://")):
raise ValueError(
"remote_url must be a valid git URL (https://, git@, or ssh://)"
)
return v
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str | None) -> str | None:
if v is None:
return v
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
@model_validator(mode="after")
def check_mappings_or_legacy(self):
has_legacy = self.source_path is not None and self.target_path is not None
has_mappings = self.mappings is not None and len(self.mappings) > 0
if not has_legacy and not has_mappings:
raise ValueError(
"Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'"
)
return self
class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
@field_validator("target")
@classmethod
def validate_target(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount target must be absolute (start with /)")
return v
@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
if v not in ("ro", "rw"):
raise ValueError("Mount mode must be 'ro' or 'rw'")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v.keys():
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"Mount file paths must be relative (got: {path}). "
f"The mount target defines the absolute container path."
)
return v
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict = Field(default_factory=dict, description="Environment variables")
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
mounts: list[MountItem] = Field(
default_factory=list, description="Mount definitions"
)
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] = Field(
default_factory=list, description="Git repository mounts"
)
is_default: bool = Field(
default=False, description="Whether this is the default profile for its scope"
)
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v.keys():
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"File paths must be relative (got: {path}). "
f"Use Mounts for absolute container paths."
)
return v
@field_validator("env_vars")
@classmethod
def validate_env_vars(cls, v: dict) -> dict:
result = _validate_env_vars(v)
if result is None:
raise ValueError("env_vars must be a JSON object")
return result
@field_validator("runtime_hints")
@classmethod
def validate_runtime_hints(cls, v: dict) -> dict:
if not isinstance(v, dict):
raise ValueError("runtime_hints must be a JSON object")
return v
@field_validator("mounts")
@classmethod
def validate_mounts(cls, v: list) -> list:
if not isinstance(v, list):
raise ValueError("mounts must be a JSON array")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict | None = Field(default=None, description="Environment variables")
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
mounts: list[MountItem] | None = Field(
default=None, description="Mount definitions"
)
files: dict | None = Field(
default=None, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] | None = Field(
default=None, description="Git repository mounts"
)
is_default: bool | None = Field(
default=None, description="Whether this is the default profile"
)
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
if v is None:
return v
for path in v.keys():
if ".." in path or path.startswith("/") or not path:
raise ValueError(f"Invalid file path: {path}")
return v
class ConfigProfileIncludeUpdate(BaseModel):
includes: list[str] = Field(description="Ordered list of included profile IDs")
@field_validator("includes")
@classmethod
def validate_includes(cls, v: list) -> list:
for item in v:
try:
uuid.UUID(item)
except ValueError:
raise ValueError(f"Invalid UUID in includes: {item}")
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
project_id: str | None
tool_type_id: str | None
env_vars: dict
runtime_hints: dict
mounts: list
files: dict
git_mounts: list
is_default: bool
includes: list[dict]
created_at: str
updated_at: str
async def _get_profile_with_includes(
session: AsyncSession, profile_id: uuid.UUID
) -> ConfigProfile | None:
@@ -840,20 +597,97 @@ async def resolve_default_profile(
return {"profile_id": str(first.id), "profile_name": first.name}
class ValidateGitUrlRequest(BaseModel):
url: str = Field(description="Git remote URL to validate")
ssh_key_id: str | None = Field(
default=None, description="Optional SSH key ID for private repos"
# ---------------------------------------------------------------------------
# Default profile management
# ---------------------------------------------------------------------------
async def _get_or_create_user_config(
session: AsyncSession,
user_id: uuid.UUID,
) -> UserConfig:
"""Get existing user config or create a new one."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
return user_config
class ValidateGitUrlResponse(BaseModel):
valid: bool
suggested_url: str | None = None
branches: list[str] | None = None
default_branch: str | None = None
error: str | None = None
error_code: str | None = None
async def _validate_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> None:
"""Validate that all profile IDs in default_profiles belong to the user."""
for tool_type_id, profile_id_str in default_profiles.items():
try:
profile_uuid = uuid.UUID(profile_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
)
profile = await session.get(ConfigProfile, profile_uuid)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Profile not found: {profile_id_str}",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Profile does not belong to user: {profile_id_str}",
)
@router.get("/defaults")
async def get_default_profiles_endpoint(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get all default profile mappings for the current user."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
return {"default_profiles": user_config.default_profiles if user_config else {}}
@router.put("/defaults")
async def set_default_profiles_endpoint(
data: DefaultProfilesUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Set default profile mappings for the current user."""
await _validate_default_profiles(session, user_id, data.default_profiles)
user_config = await _get_or_create_user_config(session, user_id)
user_config.config = {
**user_config.config,
"default_profiles": data.default_profiles,
}
await session.commit()
await session.refresh(user_config)
return {"default_profiles": user_config.default_profiles}
@router.get("/defaults/{tool_type_id}")
async def get_default_profile_for_tool_type_endpoint(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get the default profile ID for a specific tool type."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
@@ -891,8 +725,8 @@ async def validate_git_url(
env = None
key_path = None
if data.ssh_key_id:
from src.models.ssh_key import SSHKey
from src.services.ssh_keys import _get_fernet
from src.models import SSHKey
from src.services.shared.ssh_keys import _get_fernet
try:
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
@@ -2,12 +2,12 @@ import logging
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user_config import UserConfig
from src.models import UserConfig
from src.schemas.user import UserConfigResponse, UserConfigUpdate
logger = logging.getLogger(__name__)
@@ -38,28 +38,6 @@ async def _get_or_create_config(
return config
class UserConfigResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
default_editor: str | None = None
theme: str = "system"
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
class UserConfigUpdate(BaseModel):
default_editor: str | None = None
theme: str | None = None
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
@router.get(
"/config",
response_model=UserConfigResponse,
+6
View File
@@ -0,0 +1,6 @@
"""Project API routers module."""
from src.api.project.git_repositories import router as git_repositories_router
from src.api.project.projects import router as projects_router
__all__ = ["git_repositories_router", "projects_router"]
@@ -3,17 +3,28 @@ import os
import shutil
import subprocess
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel
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_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.config import Settings
from src.models.git_repository import GitRepository
from src.models.ssh_key import SSHKey
from src.models import GitRepository
from src.models import SSHKey
from src.schemas.project import (
GitRepositoryCreate,
GitRepositoryResponse,
URLParseRequest,
URLParseResponse,
UpdateSSHKeyRequest,
)
from src.utils.git_files import (
commit_file,
get_file_content,
@@ -33,7 +44,7 @@ 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
from src.services.shared.ssh_keys import _get_fernet
router = APIRouter(prefix="/projects", tags=["git-repositories"])
@@ -62,19 +73,19 @@ def _build_provider_clone_url(owner: str, repo: str) -> str:
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:
@@ -82,7 +93,7 @@ def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
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"
@@ -90,16 +101,18 @@ def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
return env, key_path
def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
def _preflight_remote_repository(
remote_url: str, ssh_key: SSHKey | None = None
) -> 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],
@@ -109,30 +122,40 @@ def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None)
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")
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")
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)
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}",
)
def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
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
try:
result = subprocess.run(
["git", "clone", remote_url, repo_path],
@@ -142,9 +165,14 @@ def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey |
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")
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")
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)
@@ -165,7 +193,10 @@ def _init_working_repository(repo_path: str) -> None:
text=True,
)
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="git command not found",
)
if result.returncode == 0:
return
@@ -193,43 +224,6 @@ def _init_working_repository(repo_path: str) -> None:
)
class GitRepositoryCreate(BaseModel):
name: str
remote_url: str | None = None
force_original_url: bool = False
ssh_key_id: str | None = None
class URLParseRequest(BaseModel):
url: str
class URLParseResponse(BaseModel):
original_url: str
base_url: str | None
is_valid_clone_url: bool
needs_parsing: bool
host: str | None
message: str
error_code: str | None
class GitRepositoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID | None
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],
@@ -310,7 +304,10 @@ async def create_external_repository(
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
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
@@ -336,13 +333,21 @@ async def create_external_repository(
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")
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")
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")
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)
@@ -369,7 +374,10 @@ async def create_external_repository(
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}")
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)
@@ -438,7 +446,9 @@ async def delete_repository(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
# Remove from disk
if os.path.exists(repo.path):
@@ -484,7 +494,10 @@ async def create_repository(
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
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
@@ -511,13 +524,21 @@ async def create_repository(
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")
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")
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")
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)
@@ -547,10 +568,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,
@@ -581,20 +598,30 @@ async def update_repository_ssh_key(
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")
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")
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")
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")
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:
@@ -640,16 +667,24 @@ async def get_repository_history(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
history = get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
history = get_commit_history(
repo.path, branch=branch, limit=limit, offset=offset
)
return history
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
)
@router.get(
@@ -681,10 +716,14 @@ async def get_repository_commit(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
detail = get_commit_detail(repo.path, commit_hash)
@@ -763,10 +802,14 @@ async def list_repository_files(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
entries = list_tree(repo.path, branch=branch, path=path)
@@ -829,10 +872,14 @@ async def get_repository_file_content(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
file_content = get_file_content(repo.path, branch=branch, path=path)
@@ -847,7 +894,9 @@ async def get_repository_file_content(
last_commit=file_content.last_commit,
)
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="file not found"
)
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@@ -880,32 +929,104 @@ async def get_repository_branches(
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")
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
try:
branches, default_branch = list_branches(repo.path)
return BranchesResponse(
branches=[
{
"name": b.name,
"is_default": b.is_default,
"last_commit": b.last_commit,
}
for b in branches
],
default_branch=default_branch,
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
except RuntimeError as e:
logger.error(
"Failed to list branches for repo %s: %s",
repo_id,
str(e),
exc_info=True,
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# Try local repo first (.git subdir for normal repos, HEAD for bare)
is_valid_git_repo = os.path.isdir(
os.path.join(repo.path, ".git")
) or os.path.isfile(os.path.join(repo.path, "HEAD"))
if is_valid_git_repo:
try:
branches, default_branch = list_branches(repo.path)
return BranchesResponse(
branches=[
{
"name": b.name,
"is_default": b.is_default,
"last_commit": b.last_commit,
}
for b in branches
],
default_branch=default_branch,
)
except RuntimeError as e:
logger.error(
"Failed to list branches for repo %s: %s",
repo_id,
str(e),
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
) from e
# Local repo missing/corrupt — try remote if available
if repo.remote_url:
ssh_key = None
if repo.ssh_key_id:
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
ssh_result = _prepare_ssh_env(ssh_key)
env = None
key_path = None
if ssh_result:
env, key_path = ssh_result
try:
result = subprocess.run(
["git", "ls-remote", "--heads", repo.remote_url],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **env} if env else None,
)
if result.returncode == 0:
remote_branches = []
default_branch = "main"
for line in result.stdout.strip().split("\n"):
if line:
parts = line.split("\t")
if len(parts) == 2:
ref = parts[1]
if ref.startswith("refs/heads/"):
branch_name = ref[len("refs/heads/") :]
remote_branches.append(branch_name)
if branch_name in ("main", "master"):
default_branch = branch_name
if remote_branches:
return BranchesResponse(
branches=[
{
"name": b,
"is_default": b == default_branch,
"last_commit": None,
}
for b in remote_branches
],
default_branch=default_branch,
)
else:
logger.warning(
"ls-remote returned %d for repo %s: %s",
result.returncode,
repo_id,
result.stderr,
)
except subprocess.TimeoutExpired:
logger.warning("ls-remote timed out for repo %s", repo_id)
except Exception as e:
logger.warning("ls-remote failed for repo %s: %s", repo_id, str(e))
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="repository not found on disk — re-clone or re-create the repository",
)
@router.post(
@@ -938,10 +1059,14 @@ async def update_repository_file(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
# Get user info for commit
user = await _get_user(session, user_id)
@@ -1009,10 +1134,14 @@ async def get_repository_status(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
status_result = get_status(repo.path)
@@ -1068,10 +1197,14 @@ async def create_repository_branch(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
create_branch(repo.path, data.name, data.base_branch)
@@ -1111,10 +1244,14 @@ async def delete_repository_branch(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
delete_branch(repo.path, branch_name, force)
@@ -1152,10 +1289,14 @@ async def checkout_repository_branch(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
checkout_branch(repo.path, data.branch)
@@ -1204,10 +1345,14 @@ async def commit_repository_changes(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
# Get user info for commit
user = await _get_user(session, user_id)
@@ -1262,10 +1407,14 @@ async def fetch_repository(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
fetch(repo.path)
@@ -1308,10 +1457,14 @@ async def pull_repository(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
pull(repo.path, branch)
@@ -1354,10 +1507,14 @@ async def push_repository(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
push(repo.path, branch)
@@ -1407,10 +1564,14 @@ async def merge_repository_branches(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
)
if not os.path.exists(repo.path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
)
try:
commit_hash = merge(
@@ -3,42 +3,29 @@ import shutil
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy import func, 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.models.git_repository import GitRepository
from src.auth.dependencies import (
_get_owned_project,
_get_user,
get_current_user_id,
get_db_session,
)
from src.models import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models import SSHKey
from src.models import ToolInstance
from src.schemas.project import (
ProjectCreate,
ProjectResponse,
ProjectUpdate,
SetDefaultSSHKeyRequest,
)
router = APIRouter(prefix="/projects", tags=["projects"])
class ProjectCreate(BaseModel):
name: str
description: str | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
default_ssh_key_id: uuid.UUID | None
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
@router.post(
"",
response_model=ProjectResponse,
@@ -76,26 +63,77 @@ async def create_project(
@router.get(
"",
response_model=list[ProjectResponse],
summary="List all projects",
description="Retrieve all projects owned by the authenticated user.",
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
)
async def list_projects(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[Project]:
) -> list[dict]:
"""List all projects for the authenticated user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of projects owned by the user.
Returns projects with nested repositories and workspaces for inline display.
"""
user = await _get_user(session, user_id)
result = await session.execute(select(Project).where(Project.owner_id == user.id))
return list(result.scalars().all())
result = await session.execute(
select(Project)
.where(Project.owner_id == user.id)
.order_by(Project.created_at.desc())
)
projects = result.scalars().all()
from src.models import Workspace
enriched = []
for project in projects:
repos_result = await session.execute(
select(GitRepository).where(GitRepository.project_id == project.id)
)
repositories = []
for repo in repos_result.scalars().all():
ws_result = await session.execute(
select(Workspace).where(Workspace.repo_id == repo.id)
)
workspaces = []
for ws in ws_result.scalars().all():
# Count instances
inst_result = await session.execute(
select(func.count()).where(ToolInstance.workspace_id == ws.id)
)
instance_count = inst_result.scalar() or 0
workspaces.append(
{
"id": str(ws.id),
"name": ws.name,
"branch": ws.branch,
"status": ws.status,
"instance_count": instance_count,
}
)
repositories.append(
{
"id": str(repo.id),
"name": repo.name,
"remote_url": repo.remote_url,
"workspaces": workspaces,
}
)
enriched.append(
{
"id": str(project.id),
"name": project.name,
"description": project.description,
"owner_id": str(project.owner_id),
"repositories": repositories,
"created_at": project.created_at.isoformat()
if project.created_at
else None,
}
)
return enriched
@router.get(
@@ -184,7 +222,9 @@ async def delete_project(
project = await _get_owned_project(project_id, user_id, session)
# Delete repositories from disk and database
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
result = await session.execute(
select(GitRepository).where(GitRepository.project_id == project_id)
)
repositories = result.scalars().all()
for repo in repositories:
if os.path.exists(repo.path):
+17
View File
@@ -0,0 +1,17 @@
"""System API routers module."""
from src.api.system.dashboard import router as dashboard_router
from src.api.system.events import router as events_router
from src.api.system.health import router as health_router
from src.api.system.instance_proxy import router as instance_proxy_router
from src.api.system.notifications import router as notifications_router
from src.api.system.terminal import router as terminal_router
__all__ = [
"dashboard_router",
"events_router",
"health_router",
"instance_proxy_router",
"notifications_router",
"terminal_router",
]
@@ -5,9 +5,9 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.git_repository import GitRepository
from src.models import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models import SSHKey
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -10,13 +10,13 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from src.auth.dependencies import get_current_user_id
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
router = APIRouter(prefix="/events", tags=["events"])
# In-memory connection counter per user (single-process assumption)
_connection_counts: dict[uuid.UUID, int] = {}
MAX_CONNECTIONS_PER_USER = 5
MAX_CONNECTIONS_PER_USER = 20
@router.get("/stream")
@@ -5,10 +5,16 @@ from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import text
from src.database import SessionLocal
from src.schemas.system import (
DatabaseHealth,
DatabaseHealthResponse,
DiskHealth,
HealthChecks,
HealthResponse,
)
router = APIRouter()
@@ -16,45 +22,6 @@ router = APIRouter()
_start_time = time.time()
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
@router.get(
"/health",
response_model=HealthResponse,
@@ -8,8 +8,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models import ToolInstance
from src.models import ToolType
logger = logging.getLogger(__name__)
@@ -9,8 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.models.user import User
from src.models.user_config import UserConfig
from src.services.notification_service import notification_service
from src.models import UserConfig
from src.services.shared.notification_service import notification_service
router = APIRouter(prefix="/notifications", tags=["notifications"])
@@ -12,10 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.websockets import WebSocketDisconnect
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.terminal_session import TerminalSessionModel
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
from src.models import TerminalSessionModel
from src.models import ToolInstance
from src.models import ToolType
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -222,8 +222,7 @@ async def _handle_terminal_websocket(
# Use mutable session reference so loops can survive reset
session_ref = SessionRef(session, slot_session_id)
# Start I/O loops and heartbeat
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
write_task = asyncio.create_task(
_write_loop(session_ref, websocket, instance_id)
)
@@ -232,7 +231,7 @@ async def _handle_terminal_websocket(
# Wait for either task to complete (indicating disconnect or error)
done, pending = await asyncio.wait(
[read_task, write_task, heartbeat_task],
[write_task, heartbeat_task],
return_when=asyncio.FIRST_COMPLETED,
)
@@ -267,28 +266,6 @@ async def _handle_terminal_websocket(
)
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 WebSocketDisconnect:
break
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:
@@ -319,6 +296,10 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
rows,
)
await session.resize(cols, rows)
elif msg_type == "ack":
char_count = ctrl.get("chars", 0)
if char_count > 0:
session.acknowledge_data(char_count)
elif msg_type == "reset":
# Reset terminal session (scoped to current slot)
logger.debug(
+13
View File
@@ -0,0 +1,13 @@
"""Tool API routers module."""
from src.api.tool.sessions import sessions_router
from src.api.tool.tool_definitions import router as tool_definitions_router
from src.api.tool.tool_instances import router as tool_instances_router
from src.api.tool.tool_types import router as tool_types_router
__all__ = [
"sessions_router",
"tool_definitions_router",
"tool_instances_router",
"tool_types_router",
]
+80
View File
@@ -0,0 +1,80 @@
"""Sessions API endpoints (running instances for current user)."""
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models import GitRepository
from src.models import Project
from src.models import ToolInstance
from src.models import ToolType
sessions_router = APIRouter(prefix="/users", tags=["sessions"])
@sessions_router.get(
"/me/sessions",
summary="Get user sessions",
description="Get all active sessions (running instances) for the current user.",
)
async def get_user_sessions(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get all active sessions for the current user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing list of active sessions with instance details.
"""
_user = await _get_user(session, user_id)
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.owner_id == user_id)
.where(
ToolInstance.status.in_(
["running", "building", "pending", "stopped", "error"]
)
)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
sessions = []
for instance in instances:
tool_type = await session.get(ToolType, instance.tool_type_id)
repo = await session.get(GitRepository, instance.repository_id)
project = await session.get(Project, instance.project_id)
sessions.append(
{
"id": str(instance.id),
"display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_icon": tool_type.name if tool_type else "code",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"repository_name": repo.name if repo else "unknown",
"repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown",
"project_id": str(instance.project_id),
"status": instance.status,
"url": instance.url,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id)
if instance.selected_config_profile_id
else None,
"created_at": instance.created_at.isoformat()
if instance.created_at
else None,
}
)
return {"sessions": sessions}
@@ -9,9 +9,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool_type import ToolType
from src.services.manifest_compiler import (
from src.models import ToolDefinitionManifest
from src.models import ToolType
from src.services.build.manifest_compiler import (
compile_compose,
compile_dockerfile,
compile_entrypoint,
@@ -173,7 +173,7 @@ async def list_tool_definitions(
"""
query = select(ToolDefinitionManifest)
if not include_bases:
query = query.where(ToolDefinitionManifest.is_base == False)
query = query.where(ToolDefinitionManifest.is_base.is_(False))
result = await session.execute(
query.order_by(ToolDefinitionManifest.created_at.desc())
@@ -11,14 +11,12 @@ from datetime import datetime
import httpx
from fastapi import (
APIRouter,
APIRouter as FastAPIRouter,
Depends,
HTTPException,
Request,
Response,
status,
)
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,16 +26,15 @@ from src.auth.dependencies import (
get_current_user_id,
get_db_session,
)
from src.services.event_bus import InstanceEventBus
from src.services.lifecycle_hooks import publish_lifecycle_event
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
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.clone import check_dirty_state, clone_repository
from src.services.config_profile_resolver import (
from src.services.instance.event_bus import InstanceEventBus
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
from src.models import ConfigProfile
from src.models import GitRepository
from src.models import SSHKey
from src.models import ToolInstance
from src.models import ToolType
from src.services.git.clone import check_dirty_state, clone_repository
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError,
ResolvedProfile,
apply_resolved_profile,
@@ -62,14 +59,14 @@ from src.services.docker import (
write_config_files,
write_env_file,
)
from src.services.tunnel import (
from src.services.shared.tunnel import (
check_tunnel_health,
recreate_tunnel,
start_tunnel,
stop_tunnel,
)
from src.services.docker_build import build_image
from src.services.manifest_compiler import (
from src.services.build.docker_build import build_image
from src.services.build.manifest_compiler import (
compile_compose,
compile_dockerfile,
compile_entrypoint,
@@ -79,9 +76,13 @@ from src.services.manifest_compiler import (
merge_with_config,
resolve_base,
)
from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions
from src.services.readiness_probe import execute_probe
from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
from src.services.shared.permission_fixer import (
apply_mount_permissions,
apply_ssh_permissions,
)
from src.services.shared.readiness_probe import execute_probe
from src.services.shared.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest
logger = logging.getLogger(__name__)
_event_bus = InstanceEventBus()
@@ -418,48 +419,6 @@ def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
router = APIRouter(prefix="/projects", tags=["tool-instances"])
class CreateInstanceRequest(BaseModel):
"""Request body for creating a tool instance."""
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(
default=None, description="Optional display name for the instance"
)
workspace_id: str | None = Field(
default=None, description="UUID of workspace to mount (replaces clone_mode)"
)
clone_mode: str = Field(
default="mount", description="Repository access mode: 'mount' or 'clone'"
)
branch: str | None = Field(
default="main", description="Branch to clone (when clone_mode='clone')"
)
new_branch: str | None = Field(
default=None, description="Create a new local branch after cloning"
)
config_profile_id: str | None = Field(
default=None, description="Optional config profile ID for launch"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
class StartInstanceRequest(BaseModel):
"""Request body for starting a tool instance."""
model_config = {"extra": "ignore"}
config_profile_id: str | None = Field(
default=None, description="Config profile ID to apply, or null for none"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
async def _validate_config_profile(
session: AsyncSession,
profile_id: str | None,
@@ -856,7 +815,7 @@ async def create_instance(
workspace = None
workspace_id = None
if data.workspace_id:
from src.models.workspace import Workspace as WorkspaceModel
from src.models import Workspace as WorkspaceModel
try:
workspace_id = uuid.UUID(data.workspace_id)
@@ -893,9 +852,37 @@ async def create_instance(
# Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
instance_display = (
data.display_name or f"{tool_type.display_name} - {repo.name}"
)
# Auto-generate display name with scoped numbering.
# When a workspace is provided, use workspace name + tool type.
# Otherwise fall back to repo name + tool type.
if data.display_name:
instance_display = data.display_name
else:
scope_name = workspace.name if workspace else repo.name
auto_name = f"{scope_name} / {tool_type.display_name}"
if workspace:
count_query = (
select(ToolInstance)
.where(ToolInstance.workspace_id == workspace_id)
.where(ToolInstance.tool_type_id == tool_type_id)
.where(ToolInstance.owner_id == user_id)
)
else:
count_query = (
select(ToolInstance)
.where(ToolInstance.repository_id == repo_id)
.where(ToolInstance.tool_type_id == tool_type_id)
.where(ToolInstance.owner_id == user_id)
)
result = await session.execute(count_query)
existing_count = len(result.scalars().all())
if existing_count > 0:
instance_display = f"{auto_name} #{existing_count + 1}"
else:
instance_display = auto_name
# Create instance directory
instance_dir = ensure_instance_directory(instance_name)
@@ -1039,8 +1026,8 @@ services:
write_compose_file(instance_dir, compose_content)
elif tool_type.definition_type == "manifest":
# Manifest-based: build image and generate compose
from src.models.tool_definition_manifest import ToolDefinitionManifest
# Manifest-based: generate compose only; image built lazily on start
from src.models import ToolDefinitionManifest
manifest_def = await session.get(
ToolDefinitionManifest, tool_type.manifest_id
@@ -1061,44 +1048,8 @@ services:
deep_merge(dict(base_def.manifest), manifest)
)
# Determine home directory for path expansion
_home_dir = get_manifest_home_dir(manifest)
image_tag = compute_image_tag(tool_type.name, manifest)
# Build image during creation so start is fast
dockerfile = compile_dockerfile(manifest)
entrypoint = compile_entrypoint(manifest)
build_ctx = {
"Dockerfile": dockerfile,
".headquarter/entrypoint.sh": entrypoint,
}
returncode, stdout, stderr = await asyncio.to_thread(
build_image,
instance_dir=instance_dir,
dockerfile=dockerfile,
tag=image_tag,
build_context=build_ctx,
)
if returncode != 0:
logger.error(
"Failed to build image for manifest instance %s: %s",
instance_name,
stderr,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to build Docker image: {stderr[:500]}",
)
logger.info(
"Built manifest image %s for instance %s",
image_tag,
instance_name,
)
variables = {
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance_name.lower(),
@@ -1361,7 +1312,7 @@ async def _prepare_manifest_instance(
Returns:
Tuple of (image_tag, compose_content, resolved_manifest, home_dir)
"""
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models import ToolDefinitionManifest
tool_type = await session.get(ToolType, instance.tool_type_id)
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
@@ -1466,6 +1417,18 @@ async def _prepare_manifest_instance(
compose_content = compile_compose(manifest, variables)
logger.debug(
"_prepare_manifest_instance for %s: repo_path=%s compose_volumes=%s",
instance.id,
repo_path or "<empty>",
manifest.get("mounts", []),
)
logger.debug(
"Generated compose for %s:\n%s",
instance.id,
compose_content,
)
# Cache
instance.image_tag = image_tag
instance.manifest_compiled_at = datetime.now()
@@ -1545,7 +1508,7 @@ async def start_instance(
container_uid = 0
container_gid = 0
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models import ToolDefinitionManifest
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
if manifest_def:
@@ -1635,38 +1598,14 @@ async def start_instance(
# Mount selected SSH keys into container home dir
if instance.ssh_key_ids:
from src.services.shared.ssh_keys import write_ssh_config, _sanitize_filename
# Collect all valid keys first
ssh_keys_to_mount = []
for key_id in instance.ssh_key_ids:
ssh_key = await session.get(SSHKey, uuid.UUID(key_id))
if ssh_key and ssh_key.user_id == user_id:
try:
ssh_dir = prepare_ssh_key_files(
instance_dir,
ssh_key,
subdir=f"mounts/ssh/{key_id}/.ssh",
uid=container_uid,
gid=container_gid,
)
ssh_target = os.path.join(home_dir, ".ssh")
extra_volumes.append(
{
"source": ssh_dir,
"target": ssh_target,
"type": "bind",
}
)
logger.debug(
"Mounted SSH key %s for instance %s to %s",
ssh_key.name,
instance.id,
ssh_target,
)
except Exception as exc:
logger.error(
"Failed to prepare SSH key %s for instance %s: %s",
key_id,
instance.id,
exc,
)
ssh_keys_to_mount.append(ssh_key)
else:
logger.warning(
"SSH key %s not found or not authorized for user %s",
@@ -1674,6 +1613,79 @@ async def start_instance(
user_id,
)
if ssh_keys_to_mount:
# Use a single shared .ssh directory so all keys are visible
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
os.makedirs(ssh_dir, exist_ok=True)
key_filenames = []
for ssh_key in ssh_keys_to_mount:
# Use sanitized key name as filename prefix to avoid collisions
key_name = _sanitize_filename(ssh_key.name)
# If multiple keys have the same name, append a short hash
base_filename = f"id_ed25519_{key_name}"
filename = base_filename
counter = 1
while filename in key_filenames:
filename = f"{base_filename}_{counter}"
counter += 1
key_filenames.append(filename)
try:
prepare_ssh_key_files(
instance_dir,
ssh_key,
subdir="mounts/ssh/.ssh",
uid=container_uid,
gid=container_gid,
key_filename=filename,
write_config=False,
)
logger.debug(
"Prepared SSH key %s as %s for instance %s",
ssh_key.name,
filename,
instance.id,
)
except Exception as exc:
logger.error(
"Failed to prepare SSH key %s for instance %s: %s",
ssh_key.id,
instance.id,
exc,
)
# Write combined SSH config with all keys
try:
write_ssh_config(
ssh_dir,
key_filenames,
uid=container_uid,
gid=container_gid,
)
except Exception as exc:
logger.error(
"Failed to write SSH config for instance %s: %s",
instance.id,
exc,
)
# Mount the single .ssh directory into container home
ssh_target = os.path.join(home_dir, ".ssh")
extra_volumes.append(
{
"source": ssh_dir,
"target": ssh_target,
"type": "bind",
}
)
logger.debug(
"Mounted %d SSH key(s) for instance %s to %s",
len(ssh_keys_to_mount),
instance.id,
ssh_target,
)
# ── MANIFEST-BASED FLOW ──────────────────────────────────────
resolved_manifest = None
@@ -1683,7 +1695,7 @@ async def start_instance(
# Determine repo path (workspace takes precedence)
repo_path = ""
if instance.workspace_id:
from src.models.workspace import Workspace as WorkspaceModel
from src.models import Workspace as WorkspaceModel
workspace = await session.get(WorkspaceModel, instance.workspace_id)
if workspace:
@@ -2726,7 +2738,7 @@ async def get_instance_events(
List of event dictionaries.
"""
from sqlalchemy import select
from src.models.instance_event import InstanceEvent
from src.models import InstanceEvent
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -2886,71 +2898,3 @@ async def proxy_to_instance(
status_code=response.status_code,
headers=response_headers,
)
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
@sessions_router.get(
"/me/sessions",
summary="Get user sessions",
description="Get all active sessions (running instances) for the current user.",
)
async def get_user_sessions(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get all active sessions for the current user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing list of active sessions with instance details.
"""
_user = await _get_user(session, user_id)
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.owner_id == user_id)
.where(
ToolInstance.status.in_(
["running", "building", "pending", "stopped", "error"]
)
)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
sessions = []
for instance in instances:
tool_type = await session.get(ToolType, instance.tool_type_id)
repo = await session.get(GitRepository, instance.repository_id)
project = await session.get(Project, instance.project_id)
sessions.append(
{
"id": str(instance.id),
"display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown",
"tool_icon": tool_type.name if tool_type else "code",
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"repository_name": repo.name if repo else "unknown",
"repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown",
"project_id": str(instance.project_id),
"status": instance.status,
"url": instance.url,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id)
if instance.selected_config_profile_id
else None,
"created_at": instance.created_at.isoformat()
if instance.created_at
else None,
}
)
return {"sessions": sessions}
@@ -1,19 +1,23 @@
import uuid
from datetime import datetime
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 (
from src.api.tool.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.models.tool_type import ToolType
from src.models import ToolType
from src.models.user import User
from src.schemas.tool import (
ToolTypeCreate,
ToolTypeResponse,
ToolTypeUpdate,
ToolTypeValidateRequest,
)
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
@@ -29,237 +33,6 @@ async def _require_admin(user: User) -> None:
pass
class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
default_port: int = 0
definition_type: str = "compose"
manifest_id: uuid.UUID | None = None
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
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None or not v.strip():
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None or not v.strip():
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@field_validator("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")
return v
@field_validator("required_variables")
@classmethod
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
for var in v:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise ValueError(
f"Required variable '{var}' not found in compose template"
)
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "manifest":
if self.manifest_id is None:
raise ValueError(
"manifest_id is required when definition_type is 'manifest'"
)
return self
if self.definition_type == "dockerfile" and (
self.dockerfile_template is None or not self.dockerfile_template.strip()
):
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if self.definition_type == "compose" and (
self.compose_template is None or not self.compose_template.strip()
):
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
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
description: str | None = None
default_port: int | None = None
definition_type: str | None = None
manifest_id: uuid.UUID | None = None
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] | None = None
category: str | None = None
interface_type: str | None = None
requires_port: bool | None = None
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
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)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "dockerfile":
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
display_name: str
description: str | None
category: str
interface_type: str
requires_port: bool
default_port: int
definition_type: str
manifest_id: uuid.UUID | None
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
startup_command: str | None
required_variables: list[str]
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@router.post(
"",
response_model=ToolTypeResponse,
@@ -461,12 +234,6 @@ async def update_tool_type(
return tool_type
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
@router.post(
"/validate",
summary="Validate tool type template",
+7
View File
@@ -0,0 +1,7 @@
"""User API routers module."""
from src.api.user.auth import router as auth_router
from src.api.user.ssh_keys import router as ssh_keys_router
from src.api.user.users import router as users_router
__all__ = ["auth_router", "ssh_keys_router", "users_router"]
@@ -1,18 +1,24 @@
import base64
import uuid
from datetime import datetime
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fastapi import APIRouter, Depends, HTTPException, status
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.config import Settings
from src.models.ssh_key import SSHKey
from src.models import SSHKey
from src.schemas.project import (
SSHKeyCreate,
SSHKeyResponse,
SignPayloadRequest,
SignatureResponse,
VerifySignatureRequest,
VerifySignatureResponse,
)
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
@@ -21,7 +27,7 @@ def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
import base64
import hashlib
settings = Settings()
# Derive a 32-byte key from the session secret using SHA256
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
@@ -53,36 +59,6 @@ def generate_ssh_key_pair() -> tuple[str, str]:
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
class SSHKeyCreate(BaseModel):
name: str
class SSHKeyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
public_key: str
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,
@@ -171,7 +147,9 @@ async def delete_ssh_key(
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")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
)
await session.delete(ssh_key)
await session.commit()
@@ -203,7 +181,9 @@ async def sign_payload(
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")
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()
@@ -242,7 +222,9 @@ async def verify_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")
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())
@@ -2,11 +2,11 @@ import uuid
from pathlib import Path
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.models.user import User
from src.schemas.user import UserProfileResponse, UserProfileUpdate
router = APIRouter(prefix="/users", tags=["users"])
@@ -16,20 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
@router.get(
"/me",
response_model=UserProfileResponse,
@@ -77,12 +63,16 @@ async def update_profile(
if data.name is not None:
if len(data.name.strip()) == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty"
)
user.name = data.name.strip()
if data.email is not None:
if "@" not in data.email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email"
)
user.email = data.email.strip()
await session.commit()
+17
View File
@@ -0,0 +1,17 @@
"""Workspace API routers module."""
from src.api.workspace.workspace_files import router as workspace_files_router
from src.api.workspace.workspace_git import router as workspace_git_router
from src.api.workspace.workspace_instances import router as workspace_instances_router
from src.api.workspace.workspaces import (
all_workspaces_router,
router as workspaces_router,
)
__all__ = [
"all_workspaces_router",
"workspace_files_router",
"workspace_git_router",
"workspace_instances_router",
"workspaces_router",
]
@@ -0,0 +1,114 @@
"""Workspace file API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import Workspace
from src.services.shared.file_service import FileService
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
from sqlalchemy import select
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/")
async def list_files(
workspace_id: uuid.UUID,
path: str = "",
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List files in a workspace directory."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
try:
entries = service.list_directory(workspace, path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"entries": [
{
"name": e.name,
"path": e.path,
"type": e.type,
"size": e.size,
}
for e in entries
],
}
@router.get("/content")
async def get_file_content(
workspace_id: uuid.UUID,
path: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get the content of a text file."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
try:
content = service.read_file(workspace, path)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"content": content, "path": path}
@router.post("/content")
async def write_file(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Write a file and optionally commit."""
workspace = await _get_workspace(session, workspace_id, user_id)
service = FileService()
file_path = data.get("path", "").strip()
content = data.get("content", "")
commit_message = data.get("message", "").strip()
if not file_path:
raise HTTPException(status_code=400, detail="File path is required")
try:
service.write_file(workspace, file_path, content)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if commit_message:
from src.services.git.git_operations import GitOperations
git = GitOperations(workspace)
try:
await git.commit(commit_message)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "saved", "path": file_path}
+203
View File
@@ -0,0 +1,203 @@
"""Workspace git API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import Workspace
from src.services.git.git_operations import GitOperations
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
from sqlalchemy import select
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/status")
async def git_status(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get git status for the workspace."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
status = await git.status()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"branch": status.branch,
"modified": status.modified,
"added": status.added,
"deleted": status.deleted,
"untracked": status.untracked,
"ahead": status.ahead,
"behind": status.behind,
}
@router.get("/branches")
async def git_branches(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List branches for the workspace."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
branches, current = await git.branches()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"branches": branches,
"current_branch": current,
}
@router.post("/commit")
async def git_commit(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Stage all changes and commit."""
workspace = await _get_workspace(session, workspace_id, user_id)
message = data.get("message", "").strip()
if not message:
raise HTTPException(status_code=400, detail="Commit message is required")
git = GitOperations(workspace)
try:
await git.commit(message)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "committed"}
@router.post("/push")
async def git_push(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Push current branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.push()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "pushed"}
@router.post("/pull")
async def git_pull(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Pull current branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.pull()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "pulled"}
@router.post("/fetch")
async def git_fetch(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Fetch from origin."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
await git.fetch()
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"status": "fetched"}
@router.post("/checkout")
async def git_checkout(
workspace_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Checkout a branch."""
workspace = await _get_workspace(session, workspace_id, user_id)
branch = data.get("branch", "").strip()
if not branch:
raise HTTPException(status_code=400, detail="Branch name is required")
git = GitOperations(workspace)
try:
await git.checkout(branch)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
workspace.branch = branch
await session.commit()
return {"status": "checked_out", "branch": branch}
@router.get("/history")
async def git_history(
workspace_id: uuid.UUID,
path: str | None = None,
limit: int = 50,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get commit history."""
workspace = await _get_workspace(session, workspace_id, user_id)
git = GitOperations(workspace)
try:
commits = await git.history(path, limit)
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
"commits": [
{
"hash": c.hash,
"message": c.message,
"author": c.author,
"date": c.date,
}
for c in commits
],
}
@@ -0,0 +1,60 @@
"""Workspace instance API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ToolInstance
from src.models import Workspace
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
async def _get_workspace(
session: AsyncSession,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> Workspace:
result = await session.execute(
select(Workspace).where(
Workspace.id == workspace_id,
Workspace.user_id == user_id,
)
)
workspace = result.scalar_one_or_none()
if not workspace:
raise HTTPException(status_code=404, detail="Workspace not found")
return workspace
@router.get("/")
async def list_workspace_instances(
workspace_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
"""List tool instances using this workspace."""
await _get_workspace(session, workspace_id, user_id)
result = await session.execute(
select(ToolInstance)
.where(ToolInstance.workspace_id == workspace_id)
.order_by(ToolInstance.created_at.desc())
)
instances = result.scalars().all()
return [
{
"id": str(i.id),
"name": i.name,
"display_name": i.display_name,
"status": i.status,
"tool_type_id": str(i.tool_type_id),
"url": i.url,
"port": i.port,
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in instances
]
@@ -9,10 +9,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.git_repository import GitRepository
from src.models.tool_instance import ToolInstance
from src.models.workspace import Workspace
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
from src.models import GitRepository
from src.models import ToolInstance
from src.models import Workspace
from src.services.shared.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
logger = logging.getLogger(__name__)
@@ -38,6 +38,7 @@ async def list_all_workspaces(
Workspace,
GitRepository.name.label("repo_name"),
GitRepository.project_id,
GitRepository.ssh_key_id.label("repo_ssh_key_id"),
instance_count.label("instance_count"),
)
.join(GitRepository, Workspace.repo_id == GitRepository.id)
@@ -52,8 +53,9 @@ async def list_all_workspaces(
"name": ws.name,
"repo_id": str(ws.repo_id),
"repo_name": repo_name or "",
"repo_ssh_key_id": str(ssh_key_id) if ssh_key_id else None,
"project_id": str(project_id) if project_id else "",
"project_name": "", # Could join with Project if needed
"project_name": "",
"user_id": str(ws.user_id),
"branch": ws.branch,
"path": ws.path,
@@ -63,10 +65,98 @@ async def list_all_workspaces(
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
"instance_count": count or 0,
}
for ws, repo_name, project_id, count in rows
for ws, repo_name, project_id, ssh_key_id, count in rows
]
@all_workspaces_router.delete("/{workspace_id}")
async def delete_workspace_top_level(
workspace_id: uuid.UUID,
force: bool = Query(False),
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Delete a workspace via top-level path."""
workspace = await session.get(Workspace, workspace_id)
if not workspace or workspace.user_id != user_id:
raise HTTPException(status_code=404, detail="Workspace not found")
manager = WorkspaceManager()
try:
await manager.delete(workspace, force=force, session=session)
await session.commit()
except WorkspaceHasInstancesError as exc:
await session.rollback()
raise HTTPException(
status_code=409,
detail={
"message": "Workspace has running tool instances",
"instances": exc.instances,
},
) from exc
except Exception as exc:
await session.rollback()
logger.error("Failed to delete workspace: %s", exc)
raise HTTPException(
status_code=500, detail="Failed to delete workspace"
) from exc
return {"status": "deleted"}
@all_workspaces_router.post("/")
async def create_workspace_top_level(
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a workspace directly (no nested project/repo path)."""
repo_id_str = data.get("repo_id", "").strip()
if not repo_id_str:
raise HTTPException(status_code=400, detail="repo_id is required")
try:
repo_id = uuid.UUID(repo_id_str)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc
repo = await session.get(GitRepository, repo_id)
if not repo or repo.owner_id != user_id:
raise HTTPException(status_code=404, detail="Repository not found")
name = data.get("name", "").strip()
branch = data.get("branch", "main").strip()
if not name:
raise HTTPException(status_code=400, detail="Workspace name is required")
manager = WorkspaceManager()
try:
workspace = await manager.create(repo, user_id, name, branch, session=session)
session.add(workspace)
await session.commit()
except Exception as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(
status_code=409,
detail="Workspace name already exists for this repository",
) from exc
await session.refresh(workspace)
return {
"id": str(workspace.id),
"name": workspace.name,
"repo_id": str(workspace.repo_id),
"branch": workspace.branch,
"path": workspace.path,
"status": workspace.status,
"created_at": workspace.created_at.isoformat()
if workspace.created_at
else None,
}
@router.get("/")
async def list_workspaces(
project_id: uuid.UUID,
@@ -102,6 +192,7 @@ async def list_workspaces(
"name": ws.name,
"repo_id": str(ws.repo_id),
"repo_name": repo.name,
"repo_ssh_key_id": str(repo.ssh_key_id) if repo.ssh_key_id else None,
"project_id": str(repo.project_id) if repo.project_id else "",
"project_name": repo.project.name if repo.project else "",
"user_id": str(ws.user_id),
@@ -138,9 +229,15 @@ async def create_workspace(
manager = WorkspaceManager()
try:
workspace = await manager.create(repo, user_id, name, branch)
workspace = await manager.create(repo, user_id, name, branch, session=session)
session.add(workspace)
await session.commit()
except HTTPException:
raise
except ValueError as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
await session.rollback()
logger.error("Failed to create workspace: %s", exc)
@@ -267,7 +364,7 @@ async def delete_workspace(
status_code=409,
detail={
"message": "Workspace has running tool instances",
"instances": [{"id": str(i.id), "name": i.name} for i in exc.instances],
"instances": exc.instances,
},
) from exc
except Exception as exc:
@@ -293,7 +390,7 @@ async def sync_workspace(
workspace = await _get_workspace(session, workspace_id, repo_id)
manager = WorkspaceManager()
result = await manager.sync(workspace)
result = await manager.sync(workspace, session=session)
if result.branch_deleted:
raise HTTPException(
+1 -1
View File
@@ -10,7 +10,7 @@ from collections.abc import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from src.services.correlation import get_correlation_id
from src.services.shared.correlation import get_correlation_id
logger = logging.getLogger(__name__)
+36 -23
View File
@@ -7,36 +7,42 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
from src.api.events import router as events_router
from src.api.git_repositories import router as git_repositories_router
from src.api.health import router as health_router
from src.api.projects import router as projects_router
from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router
from src.api.instance_proxy import router as instance_proxy_router
from src.api.config_profiles import router as config_profiles_router
from src.api.tool_definitions import router as tool_definitions_router
from src.api.tool_instances import router as tool_instances_router
from src.api.tool_instances import sessions_router
from src.api.tool_types import router as tool_types_router
from src.api.notifications import router as notifications_router
from src.api.user_config import router as user_config_router
from src.api.users import router as users_router
from src.api.workspaces import all_workspaces_router, router as workspaces_router
from src.api.config import config_profiles_router, user_config_router
from src.api.project import git_repositories_router, projects_router
from src.api.system import (
dashboard_router,
events_router,
health_router,
instance_proxy_router,
notifications_router,
terminal_router,
)
from src.api.tool import (
sessions_router,
tool_definitions_router,
tool_instances_router,
tool_types_router,
)
from src.api.user import auth_router, ssh_keys_router, users_router
from src.api.workspace import (
all_workspaces_router,
workspace_files_router,
workspace_git_router,
workspace_instances_router,
workspaces_router,
)
from src.config import Settings
from src.models.notification import Notification # noqa: F401 Alembic model discovery
from src.models.terminal_session import TerminalSessionModel # noqa: F401 Alembic model discovery
from src.models import Notification # noqa: F401 Alembic model discovery
from src.models import TerminalSessionModel # noqa: F401 Alembic model discovery
from src.database import init_database
from src.logging_config import (
ExceptionLoggingMiddleware,
RequestLoggingMiddleware,
configure_logging,
)
from src.services.correlation import CorrelationIdMiddleware
from src.services.event_bus import InstanceEventBus
from src.services.health_monitor import HealthMonitor
from src.seeds.builtin_tool_types import seed_builtin_tool_types
from src.services.instance import InstanceEventBus, HealthMonitor
from src.services.shared import CorrelationIdMiddleware
# Configure logging early
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -132,6 +138,10 @@ async def on_startup():
_health_monitor.start()
logger.info("Health monitor started")
# Seed built-in tool types
await seed_builtin_tool_types()
logger.info("Built-in tool types seeded")
logger.info("Startup complete.")
@@ -162,4 +172,7 @@ app.include_router(events_router)
app.include_router(notifications_router)
app.include_router(all_workspaces_router)
app.include_router(workspaces_router)
app.include_router(workspace_files_router)
app.include_router(workspace_git_router)
app.include_router(workspace_instances_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+15 -13
View File
@@ -1,17 +1,18 @@
from src.models.base import Base
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository
from src.models.health_check import HealthCheck
from src.models.instance_event import InstanceEvent
from src.models.notification import Notification
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.terminal_session import TerminalSessionModel
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.models.user import User
from src.models.user_config import UserConfig
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.project.git_repository import GitRepository
from src.models.project.project import Project
from src.models.project.workspace import Workspace
from src.models.system.health_check import HealthCheck
from src.models.system.instance_event import InstanceEvent
from src.models.system.notification import Notification
from src.models.system.terminal_session import TerminalSessionModel
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool.tool_instance import ToolInstance
from src.models.tool.tool_type import ToolType
from src.models.user.ssh_key import SSHKey
from src.models.user.user import User
from src.models.user.user_config import UserConfig
__all__ = [
"Base",
@@ -29,4 +30,5 @@ __all__ = [
"ToolType",
"User",
"UserConfig",
"Workspace",
]
+5
View File
@@ -0,0 +1,5 @@
"""Config models module."""
from src.models.config.config_profile import ConfigProfile, ConfigProfileInclude
__all__ = ["ConfigProfile", "ConfigProfileInclude"]
@@ -1,7 +1,15 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
from sqlalchemy import (
Boolean,
ForeignKey,
JSON,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -9,12 +17,15 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models import ToolType
from src.models.user import User
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profiles"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
+7
View File
@@ -0,0 +1,7 @@
"""Project models module."""
from src.models.project.git_repository import GitRepository
from src.models.project.project import Project
from src.models.project.workspace import Workspace
__all__ = ["GitRepository", "Project", "Workspace"]
@@ -10,7 +10,7 @@ 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 import SSHKey
from src.models.user import User
@@ -8,8 +8,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.git_repository import GitRepository
from src.models.ssh_key import SSHKey
from src.models import GitRepository
from src.models import SSHKey
from src.models.user import User
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin
if TYPE_CHECKING:
from src.models.git_repository import GitRepository
from src.models import GitRepository
from src.models.user import User
+8
View File
@@ -0,0 +1,8 @@
"""System models module."""
from src.models.system.health_check import HealthCheck
from src.models.system.instance_event import InstanceEvent
from src.models.system.notification import Notification
from src.models.system.terminal_session import TerminalSessionModel
__all__ = ["HealthCheck", "InstanceEvent", "Notification", "TerminalSessionModel"]
+7
View File
@@ -0,0 +1,7 @@
"""Tool models module."""
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool.tool_instance import ToolInstance
from src.models.tool.tool_type import ToolType
__all__ = ["ToolDefinitionManifest", "ToolInstance", "ToolType"]
@@ -9,12 +9,12 @@ 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
from src.models.git_repository import GitRepository
from src.models import ConfigProfile
from src.models import GitRepository
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models import ToolType
from src.models.user import User
from src.models.workspace import Workspace
from src.models import Workspace
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
@@ -6,9 +6,9 @@ from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from src.models.tool.tool_definition_manifest import ToolDefinitionManifest
if TYPE_CHECKING:
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.user import User
+7
View File
@@ -0,0 +1,7 @@
"""User models module."""
from src.models.user.ssh_key import SSHKey
from src.models.user.user import User
from src.models.user.user_config import UserConfig
__all__ = ["SSHKey", "User", "UserConfig"]
@@ -7,8 +7,8 @@ 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_config import UserConfig
from src.models import SSHKey
from src.models import UserConfig
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+51
View File
@@ -0,0 +1,51 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, JSON
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.user import User
class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_configs"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id"), nullable=False, unique=True
)
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:
"""Return the legacy global default profile ID from config JSON."""
profile_id = self.config.get("default_profile_id")
if isinstance(profile_id, str):
return uuid.UUID(profile_id)
return 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 per-tool-type default profile IDs from config JSON."""
value = self.config.get("default_profiles", {})
if isinstance(value, dict):
return {str(k): str(v) for k, v in value.items()}
return {}
@default_profiles.setter
def default_profiles(self, value: dict[str, str]) -> None:
self.config["default_profiles"] = value
-20
View File
@@ -1,20 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy import JSON, Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.user import User
class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_configs"
user_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False, unique=True)
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
user: Mapped["User"] = relationship(back_populates="user_config")
+27
View File
@@ -0,0 +1,27 @@
"""Config schemas module."""
from src.schemas.config.config_profile import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
ConfigProfileResponse,
ConfigProfileUpdate,
DefaultProfilesUpdate,
GitMountItem,
GitMountMapping,
MountItem,
ValidateGitUrlRequest,
ValidateGitUrlResponse,
)
__all__ = [
"ConfigProfileCreate",
"ConfigProfileIncludeUpdate",
"ConfigProfileResponse",
"ConfigProfileUpdate",
"DefaultProfilesUpdate",
"GitMountItem",
"GitMountMapping",
"MountItem",
"ValidateGitUrlRequest",
"ValidateGitUrlResponse",
]
@@ -0,0 +1,280 @@
"""Config profile request/response schemas."""
import uuid
from pydantic import BaseModel, Field, field_validator, model_validator
from src.api.shared_validators import validate_env_vars as _validate_env_vars
def _validate_uuid(v: str | None) -> str | None:
if v is None:
return v
try:
uuid.UUID(v)
except ValueError as exc:
raise ValueError(f"Invalid UUID: {v}") from exc
return v
class GitMountMapping(BaseModel):
source_path: str = Field(
description="Path within repository (supports glob patterns)"
)
target_path: str = Field(description="Absolute path inside container")
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str) -> str:
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
source_path: str | None = Field(
default=None, description="Path within repository (legacy single mapping)"
)
target_path: str | None = Field(
default=None,
description="Absolute path inside container (legacy single mapping)",
)
branch: str | None = Field(default=None, description="Optional branch or tag name")
mappings: list[GitMountMapping] | None = Field(
default=None, description="Multiple source/target mappings from the same repo"
)
@field_validator("remote_url")
@classmethod
def validate_remote_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://", "git@", "ssh://")):
raise ValueError(
"remote_url must be a valid git URL (https://, git@, or ssh://)"
)
return v
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str | None) -> str | None:
if v is None:
return v
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
@model_validator(mode="after")
def check_mappings_or_legacy(self):
has_legacy = self.source_path is not None and self.target_path is not None
has_mappings = self.mappings is not None and len(self.mappings) > 0
if not has_legacy and not has_mappings:
raise ValueError(
"Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'"
)
return self
class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
@field_validator("target")
@classmethod
def validate_target(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount target must be absolute (start with /)")
return v
@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
if v not in ("ro", "rw"):
raise ValueError("Mount mode must be 'ro' or 'rw'")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v:
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"Mount file paths must be relative (got: {path}). "
f"The mount target defines the absolute container path."
)
return v
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict = Field(default_factory=dict, description="Environment variables")
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
mounts: list[MountItem] = Field(
default_factory=list, description="Mount definitions"
)
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] = Field(
default_factory=list, description="Git repository mounts"
)
is_default: bool = Field(
default=False, description="Whether this is the default profile for its scope"
)
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v:
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"File paths must be relative (got: {path}). "
f"Use Mounts for absolute container paths."
)
return v
@field_validator("env_vars")
@classmethod
def validate_env_vars(cls, v: dict) -> dict:
result = _validate_env_vars(v)
if result is None:
raise ValueError("env_vars must be a JSON object")
return result
@field_validator("runtime_hints")
@classmethod
def validate_runtime_hints(cls, v: dict) -> dict:
if not isinstance(v, dict):
raise ValueError("runtime_hints must be a JSON object")
return v
@field_validator("mounts")
@classmethod
def validate_mounts(cls, v: list) -> list:
if not isinstance(v, list):
raise ValueError("mounts must be a JSON array")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict | None = Field(default=None, description="Environment variables")
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
mounts: list[MountItem] | None = Field(
default=None, description="Mount definitions"
)
files: dict | None = Field(
default=None, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] | None = Field(
default=None, description="Git repository mounts"
)
is_default: bool | None = Field(
default=None, description="Whether this is the default profile"
)
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
if v is None:
return v
for path in v:
if ".." in path or path.startswith("/") or not path:
raise ValueError(f"Invalid file path: {path}")
return v
class ConfigProfileIncludeUpdate(BaseModel):
includes: list[str] = Field(description="Ordered list of included profile IDs")
@field_validator("includes")
@classmethod
def validate_includes(cls, v: list) -> list:
for item in v:
try:
uuid.UUID(item)
except ValueError as exc:
raise ValueError(f"Invalid UUID in includes: {item}") from exc
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
project_id: str | None
tool_type_id: str | None
env_vars: dict
runtime_hints: dict
mounts: list
files: dict
git_mounts: list
is_default: bool
includes: list[dict]
created_at: str
updated_at: str
class DefaultProfilesUpdate(BaseModel):
default_profiles: dict[str, str] = Field(
description="Mapping of tool_type_id -> profile_id for default profiles"
)
class ValidateGitUrlRequest(BaseModel):
url: str = Field(description="Git remote URL to validate")
ssh_key_id: str | None = Field(
default=None, description="Optional SSH key ID for private repos"
)
class ValidateGitUrlResponse(BaseModel):
valid: bool
suggested_url: str | None = None
branches: list[str] | None = None
default_branch: str | None = None
error: str | None = None
error_code: str | None = None
+41
View File
@@ -0,0 +1,41 @@
"""Project schemas module."""
from src.schemas.project.git_repository import (
GitRepositoryCreate,
GitRepositoryResponse,
UpdateSSHKeyRequest,
URLParseRequest,
URLParseResponse,
)
from src.schemas.project.project import (
ProjectCreate,
ProjectResponse,
ProjectUpdate,
SetDefaultSSHKeyRequest,
)
from src.schemas.project.ssh_key import (
SSHKeyCreate,
SSHKeyResponse,
SignPayloadRequest,
SignatureResponse,
VerifySignatureRequest,
VerifySignatureResponse,
)
__all__ = [
"GitRepositoryCreate",
"GitRepositoryResponse",
"ProjectCreate",
"ProjectResponse",
"ProjectUpdate",
"SSHKeyCreate",
"SSHKeyResponse",
"SetDefaultSSHKeyRequest",
"SignPayloadRequest",
"SignatureResponse",
"URLParseRequest",
"URLParseResponse",
"UpdateSSHKeyRequest",
"VerifySignatureRequest",
"VerifySignatureResponse",
]
@@ -0,0 +1,47 @@
"""Git repository request/response schemas."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class GitRepositoryCreate(BaseModel):
name: str
remote_url: str | None = None
force_original_url: bool = False
ssh_key_id: str | None = None
class URLParseRequest(BaseModel):
url: str
class URLParseResponse(BaseModel):
original_url: str
base_url: str | None
is_valid_clone_url: bool
needs_parsing: bool
host: str | None
message: str
error_code: str | None
class GitRepositoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID | None
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
class UpdateSSHKeyRequest(BaseModel):
ssh_key_id: str | None = None
+29
View File
@@ -0,0 +1,29 @@
"""Project request/response schemas."""
import uuid
from pydantic import BaseModel, ConfigDict
class ProjectCreate(BaseModel):
name: str
description: str | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
default_ssh_key_id: uuid.UUID | None
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
+36
View File
@@ -0,0 +1,36 @@
"""SSH key request/response schemas."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class SSHKeyCreate(BaseModel):
name: str
class SSHKeyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
public_key: str
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
+17
View File
@@ -0,0 +1,17 @@
"""System schemas module."""
from src.schemas.system.health import (
DatabaseHealth,
DatabaseHealthResponse,
DiskHealth,
HealthChecks,
HealthResponse,
)
__all__ = [
"DatabaseHealth",
"DatabaseHealthResponse",
"DiskHealth",
"HealthChecks",
"HealthResponse",
]
+50
View File
@@ -0,0 +1,50 @@
"""Health check response schemas."""
from pydantic import BaseModel, Field
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(
description="Query response time in milliseconds", examples=[5.2]
)
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(
description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"]
)
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(
description="Server uptime in seconds", examples=[3600.0]
)
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(
description="Query response time in milliseconds", examples=[5.2]
)
+18
View File
@@ -0,0 +1,18 @@
"""Tool schemas module."""
from src.schemas.tool.tool_instance import CreateInstanceRequest, StartInstanceRequest
from src.schemas.tool.tool_type import (
ToolTypeCreate,
ToolTypeResponse,
ToolTypeUpdate,
ToolTypeValidateRequest,
)
__all__ = [
"CreateInstanceRequest",
"StartInstanceRequest",
"ToolTypeCreate",
"ToolTypeResponse",
"ToolTypeUpdate",
"ToolTypeValidateRequest",
]
@@ -0,0 +1,45 @@
"""Tool instance request/response schemas."""
from pydantic import BaseModel, Field
class CreateInstanceRequest(BaseModel):
"""Request body for creating a tool instance."""
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(
default=None, description="Optional display name for the instance"
)
workspace_id: str | None = Field(
default=None, description="UUID of workspace to mount (replaces clone_mode)"
)
clone_mode: str = Field(
default="mount", description="Repository access mode: 'mount' or 'clone'"
)
branch: str | None = Field(
default="main", description="Branch to clone (when clone_mode='clone')"
)
new_branch: str | None = Field(
default=None, description="Create a new local branch after cloning"
)
config_profile_id: str | None = Field(
default=None, description="Optional config profile ID for launch"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
class StartInstanceRequest(BaseModel):
"""Request body for starting a tool instance."""
model_config = {"extra": "ignore"}
config_profile_id: str | None = Field(
default=None, description="Config profile ID to apply, or null for none"
)
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
+230
View File
@@ -0,0 +1,230 @@
"""Tool type request/response schemas."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from src.api.tool.tool_types_validation import (
check_port_exposed,
validate_compose_yaml,
validate_required_variables,
)
class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
default_port: int = 0
definition_type: str = "compose"
manifest_id: uuid.UUID | None = None
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
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None or not v.strip():
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None or not v.strip():
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@field_validator("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")
return v
@field_validator("required_variables")
@classmethod
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
validate_required_variables(template, v)
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "manifest":
if self.manifest_id is None:
raise ValueError(
"manifest_id is required when definition_type is 'manifest'"
)
return self
if self.definition_type == "dockerfile" and (
self.dockerfile_template is None or not self.dockerfile_template.strip()
):
raise ValueError(
"dockerfile_template is required when definition_type is 'dockerfile'"
)
if self.definition_type == "compose" and (
self.compose_template is None or not self.compose_template.strip()
):
raise ValueError(
"compose_template is required when definition_type is 'compose'"
)
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
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
description: str | None = None
default_port: int | None = None
definition_type: str | None = None
manifest_id: uuid.UUID | None = None
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] | None = None
category: str | None = None
interface_type: str | None = None
requires_port: bool | None = None
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("compose", "dockerfile", "manifest"):
raise ValueError(
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
)
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
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
display_name: str
description: str | None
category: str
interface_type: str
requires_port: bool
default_port: int
definition_type: str
manifest_id: uuid.UUID | None
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
startup_command: str | None
required_variables: list[str]
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
+11
View File
@@ -0,0 +1,11 @@
"""User schemas module."""
from src.schemas.user.user import UserProfileResponse, UserProfileUpdate
from src.schemas.user.user_config import UserConfigResponse, UserConfigUpdate
__all__ = [
"UserConfigResponse",
"UserConfigUpdate",
"UserProfileResponse",
"UserProfileUpdate",
]
+19
View File
@@ -0,0 +1,19 @@
"""User response schemas."""
import uuid
from pydantic import BaseModel, ConfigDict
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
avatar_url: str | None
class UserProfileUpdate(BaseModel):
name: str | None = None
email: str | None = None
+25
View File
@@ -0,0 +1,25 @@
"""User config response schemas."""
from pydantic import BaseModel, ConfigDict
class UserConfigResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
default_editor: str | None = None
theme: str = "system"
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
class UserConfigUpdate(BaseModel):
default_editor: str | None = None
theme: str | None = None
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
notification_mute_categories: list[str] | None = None
notification_toast_level: str | None = None
+1
View File
@@ -0,0 +1 @@
"""Database seeding utilities."""
+168
View File
@@ -0,0 +1,168 @@
"""Seed built-in tool types into the database."""
import logging
from sqlalchemy import select, text
from src.database import SessionLocal
from src.models import ToolType
logger = logging.getLogger(__name__)
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():
"""Create or update built-in tool types in the database.
Built-in tool types have no creator (created_by_id=None) and provide
out-of-the-box tools for users without requiring manual tool creation.
"""
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",
"interface_type": "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",
"interface_type": "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",
"interface_type": "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"],
interface_type=tool_data["interface_type"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
default_port=tool_data.get("default_port", 0),
created_by_id=None,
)
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.interface_type = tool_data["interface_type"]
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", 0)
logger.info("Updated built-in tool type: %s", tool_data["name"])
await session.commit()
logger.info("Built-in tool types seeded successfully.")
@@ -26,7 +26,7 @@ def resolve_base(manifest: dict) -> dict:
result = deepcopy(manifest)
base_definition_id = result.pop("base_definition_id", None)
base_version = result.pop("base_version", "latest")
result.pop("base_version", None)
if base_definition_id:
# This will be provided by the caller (they have the DB session)
@@ -118,6 +118,11 @@ def compile_dockerfile(manifest: dict) -> str:
# System packages (apt)
apt_packages = manifest.get("packages", {}).get("apt", [])
if manifest.get("user"):
# Ensure sudo is available for permission-fixing startup scripts
apt_packages = list(apt_packages)
if "sudo" not in apt_packages:
apt_packages.append("sudo")
if apt_packages:
lines.append("RUN apt-get update && apt-get install -y \\")
for pkg in apt_packages[:-1]:
@@ -167,6 +172,17 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f"ENV HOME={home}")
lines.append(f"ENV USER={name}")
lines.append("")
# Ensure home directory exists and is writable by the user
lines.append(
f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}"
)
lines.append("")
# Configure passwordless sudo so startup scripts can fix permissions
lines.append(
f'RUN echo "{name} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/{name} && chmod 0440 /etc/sudoers.d/{name}'
)
lines.append("")
# Build scripts
build_scripts = manifest.get("scripts", {}).get("build", [])
@@ -181,6 +197,11 @@ def compile_dockerfile(manifest: dict) -> str:
if build_scripts:
lines.append("")
# After build scripts, ensure everything in home is owned by the user
if user and build_scripts:
lines.append(f"RUN chown -R {name}:{name} {home}")
lines.append("")
# Create mount target directories
mounts = manifest.get("mounts", [])
if mounts:
@@ -308,7 +329,21 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
service["volumes"] = sort_volumes_by_specificity(volumes)
compose = {"services": {"app": service}}
return yaml.dump(compose, default_flow_style=False)
result = yaml.dump(compose, default_flow_style=False)
# Debug: log mount resolution so we can diagnose missing mounts
import logging
logger = logging.getLogger(__name__)
logger.debug(
"compile_compose: REPO_PATH=%s SSH_PATH=%s EXTRA_VOLUMES=%s mounts=%s volumes=%s",
variables.get("REPO_PATH", "<empty>"),
variables.get("SSH_PATH", "<empty>"),
variables.get("EXTRA_VOLUMES", []),
manifest.get("mounts", []),
volumes,
)
return result
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
+25
View File
@@ -0,0 +1,25 @@
"""Config profile services module."""
from src.services.config.config_profile_resolver import (
ConfigProfileCycleError,
ConfigProfileNotFoundError,
ResolvedMount,
ResolvedProfile,
apply_resolved_profile,
check_include_cycle,
expand_container_path,
resolve_profile,
resolved_profile_to_dict,
)
__all__ = [
"ConfigProfileCycleError",
"ConfigProfileNotFoundError",
"ResolvedMount",
"ResolvedProfile",
"apply_resolved_profile",
"check_include_cycle",
"expand_container_path",
"resolve_profile",
"resolved_profile_to_dict",
]
@@ -13,7 +13,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models import ConfigProfile, ConfigProfileInclude
logger = logging.getLogger(__name__)
+55
View File
@@ -0,0 +1,55 @@
"""Docker services package for container and compose operations."""
from src.services.docker.compose import (
execute_compose_command,
render_compose_template,
sort_volumes_by_specificity,
write_compose_file,
)
from src.services.docker.config_staging import (
ensure_instance_directory,
write_config_files,
write_env_file,
)
from src.services.docker.container import (
connect_container_to_network,
find_free_port,
get_backend_network_name,
get_container_id,
get_container_ip_on_network,
get_container_logs,
get_container_name,
get_container_status,
is_container_on_network,
wait_for_container_running,
)
from src.services.docker.tunnel import (
check_tunnel_health,
recreate_tunnel,
start_tunnel,
stop_tunnel,
)
__all__ = [
"check_tunnel_health",
"connect_container_to_network",
"ensure_instance_directory",
"execute_compose_command",
"find_free_port",
"get_backend_network_name",
"get_container_id",
"get_container_ip_on_network",
"get_container_logs",
"get_container_name",
"get_container_status",
"is_container_on_network",
"recreate_tunnel",
"render_compose_template",
"sort_volumes_by_specificity",
"start_tunnel",
"stop_tunnel",
"wait_for_container_running",
"write_compose_file",
"write_config_files",
"write_env_file",
]
+120
View File
@@ -0,0 +1,120 @@
"""Docker Compose file generation and manipulation."""
import logging
import subprocess
from collections import Counter
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
"""Sort volume strings so parent paths come before child paths.
Docker Compose mounts volumes in array order. A later mount at a parent
path hides earlier mounts at child paths. By sorting shallow paths first
and deep paths last, deeper (more specific) mounts overlay correctly.
Volume format: source:target or source:target:type
Args:
volumes: List of Docker volume mount strings.
Returns:
Sorted list with parent paths before child paths.
"""
def _target_depth(vol: str) -> int:
parts = vol.split(":")
if len(parts) < 2:
return 0
target = parts[1].rstrip("/")
if not target or target == "/":
return 0
return target.count("/")
# Detect duplicate targets and warn
targets = []
for vol in volumes:
parts = vol.split(":")
targets.append(parts[1] if len(parts) > 1 else "")
dupes = [t for t, c in Counter(targets).items() if c > 1]
if dupes:
logger.warning("Duplicate mount targets detected: %s", dupes)
# Stable sort: parent paths first, child paths last
return sorted(volumes, key=_target_depth)
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
"""Render a Docker Compose template with variable substitution.
Args:
template: The compose template string
variables: Dictionary of variable names to values
Returns:
Rendered compose file content
"""
result = template
for key, value in variables.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
return result
def write_compose_file(instance_dir: str, content: str) -> str:
"""Write the rendered compose file to the instance directory.
Args:
instance_dir: Path to instance directory
content: Rendered compose content
Returns:
Path to the compose file
"""
compose_path = Path(instance_dir) / "docker-compose.yml"
compose_path.write_text(content)
return str(compose_path)
def execute_compose_command(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
"""Execute a docker compose command.
Args:
compose_path: Path to docker-compose.yml
action: The compose action (up, down, start, stop, restart)
timeout: Command timeout in seconds
env_file: Optional path to .env file for environment variables
Returns:
Tuple of (returncode, stdout, stderr)
"""
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d", "--force-recreate"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
cmd.append(action)
else:
raise ValueError(f"Unknown compose action: {action}")
result = subprocess.run(
cmd,
cwd=str(instance_dir),
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
@@ -0,0 +1,61 @@
"""Staging configuration files into instance directories."""
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
"""Create and return the instance directory path.
Args:
instance_id: Unique instance identifier
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
Returns:
Absolute path to instance directory
"""
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)
return str(instance_dir.absolute())
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
"""Write environment variables to a .env file.
Args:
instance_dir: Path to instance directory
env_vars: Dictionary of env var names to values
Returns:
Path to the env file
"""
env_path = Path(instance_dir) / ".env"
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
env_path.write_text("\n".join(lines) + "\n")
return str(env_path)
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
"""Write config files to the instance directory.
Args:
instance_dir: Path to instance directory
files: Dictionary of file paths (relative to instance dir) to content
"""
instance_path = Path(instance_dir)
for file_path, content in files.items():
# Ensure the path is within the instance directory (security)
full_path = instance_path / file_path
try:
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)
@@ -1,181 +1,13 @@
"""Docker service for managing tool instances."""
"""Docker container runtime queries and network management."""
import logging
import subprocess
import time
from collections import Counter
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
"""Sort volume strings so parent paths come before child paths.
Docker Compose mounts volumes in array order. A later mount at a parent
path hides earlier mounts at child paths. By sorting shallow paths first
and deep paths last, deeper (more specific) mounts overlay correctly.
Volume format: source:target or source:target:type
Args:
volumes: List of Docker volume mount strings.
Returns:
Sorted list with parent paths before child paths.
"""
def _target_depth(vol: str) -> int:
parts = vol.split(":")
if len(parts) < 2:
return 0
target = parts[1].rstrip("/")
if not target or target == "/":
return 0
return target.count("/")
# Detect duplicate targets and warn
targets = []
for vol in volumes:
parts = vol.split(":")
targets.append(parts[1] if len(parts) > 1 else "")
dupes = [t for t, c in Counter(targets).items() if c > 1]
if dupes:
logger.warning("Duplicate mount targets detected: %s", dupes)
# Stable sort: parent paths first, child paths last
return sorted(volumes, key=_target_depth)
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
"""Render a Docker Compose template with variable substitution.
Args:
template: The compose template string
variables: Dictionary of variable names to values
Returns:
Rendered compose file content
"""
result = template
for key, value in variables.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
return result
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
"""Create and return the instance directory path.
Args:
instance_id: Unique instance identifier
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
Returns:
Absolute path to instance directory
"""
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)
return str(instance_dir.absolute())
def write_compose_file(instance_dir: str, content: str) -> str:
"""Write the rendered compose file to the instance directory.
Args:
instance_dir: Path to instance directory
content: Rendered compose content
Returns:
Path to the compose file
"""
compose_path = Path(instance_dir) / "docker-compose.yml"
compose_path.write_text(content)
return str(compose_path)
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
"""Write environment variables to a .env file.
Args:
instance_dir: Path to instance directory
env_vars: Dictionary of env var names to values
Returns:
Path to the env file
"""
env_path = Path(instance_dir) / ".env"
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
env_path.write_text("\n".join(lines) + "\n")
return str(env_path)
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
"""Write config files to the instance directory.
Args:
instance_dir: Path to instance directory
files: Dictionary of file paths (relative to instance dir) to content
"""
instance_path = Path(instance_dir)
for file_path, content in files.items():
# Ensure the path is within the instance directory (security)
full_path = instance_path / file_path
try:
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 execute_compose_command(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
"""Execute a docker compose command.
Args:
compose_path: Path to docker-compose.yml
action: The compose action (up, down, start, stop, restart)
timeout: Command timeout in seconds
env_file: Optional path to .env file for environment variables
Returns:
Tuple of (returncode, stdout, stderr)
"""
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d", "--force-recreate"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
cmd.append(action)
else:
raise ValueError(f"Unknown compose action: {action}")
result = subprocess.run(
cmd,
cwd=str(instance_dir),
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service.
@@ -1,10 +1,8 @@
"""Clean tunnel service using cloudflared containers on the backend network.
"""Cloudflare tunnel management using cloudflared Docker containers.
Design:
- Each tunnel runs as a Docker container on the same 'backend' network as the API.
- cloudflared connects to the tool container by its Docker Compose service name
(e.g. http://code-server-headquarter-34837cd3:8443).
- This avoids host port conflicts and DNS resolution issues.
Each tunnel runs as a Docker container on the same 'backend' network as the API.
cloudflared connects to the tool container by its Docker Compose service name
(e.g. http://code-server-headquarter-34837cd3:8443).
"""
import logging
@@ -12,7 +10,7 @@ import re
import subprocess
from typing import Any
from src.services.docker import get_backend_network_name
from src.services.docker.container import get_backend_network_name
logger = logging.getLogger(__name__)
@@ -55,7 +53,7 @@ def _cleanup_stale_tunnel(tunnel_name: str) -> None:
)
def _get_container_logs(tunnel_name: str) -> tuple[str, str]:
def _get_tunnel_logs(tunnel_name: str) -> tuple[str, str]:
"""Get stdout and stderr logs from a container."""
result = subprocess.run(
["docker", "logs", tunnel_name],
@@ -65,7 +63,7 @@ def _get_container_logs(tunnel_name: str) -> tuple[str, str]:
return result.stdout, result.stderr
def _get_container_exit_code(tunnel_name: str) -> int | None:
def _get_tunnel_exit_code(tunnel_name: str) -> int | None:
"""Get exit code of a container if it has exited."""
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
@@ -133,13 +131,15 @@ def start_tunnel(
logger.debug("Tunnel container started: %s", container_id)
# Wait for URL to appear in logs
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
# Exclude api.trycloudflare.com which is the Cloudflare API endpoint,
# not a tunnel URL. Real tunnel URLs have random subdomains (10+ chars).
url_pattern = re.compile(r"https://(?!api\.)[a-z0-9-]{10,}\.trycloudflare\.com")
start_time = __import__("time").time()
url: str | None = None
combined_logs = ""
while __import__("time").time() - start_time < timeout:
stdout, stderr = _get_container_logs(tunnel_name)
stdout, stderr = _get_tunnel_logs(tunnel_name)
combined_logs = stdout + "\n" + stderr
match = url_pattern.search(combined_logs)
@@ -148,7 +148,7 @@ def start_tunnel(
break
# Check if container exited early
exit_code = _get_container_exit_code(tunnel_name)
exit_code = _get_tunnel_exit_code(tunnel_name)
if exit_code is not None and exit_code != 0:
_cleanup_stale_tunnel(tunnel_name)
raise RuntimeError(
@@ -159,9 +159,9 @@ def start_tunnel(
__import__("time").sleep(0.5)
if not url:
stdout, stderr = _get_container_logs(tunnel_name)
stdout, stderr = _get_tunnel_logs(tunnel_name)
combined_logs = stdout + "\n" + stderr
exit_code = _get_container_exit_code(tunnel_name)
exit_code = _get_tunnel_exit_code(tunnel_name)
_cleanup_stale_tunnel(tunnel_name)
raise RuntimeError(
+19
View File
@@ -0,0 +1,19 @@
"""Git services module."""
from src.services.git.clone import (
check_dirty_state,
clone_repository,
remove_clone_directory,
)
from src.services.git.git_operations import Commit, GitOperations, GitStatus
from src.services.git.git_service import GitService
__all__ = [
"check_dirty_state",
"clone_repository",
"remove_clone_directory",
"Commit",
"GitOperations",
"GitStatus",
"GitService",
]
+223
View File
@@ -0,0 +1,223 @@
"""Git commands scoped to a workspace directory."""
import asyncio
import logging
from dataclasses import dataclass
from src.models import Workspace
logger = logging.getLogger(__name__)
@dataclass
class GitStatus:
"""Parsed git status output."""
branch: str
modified: list[str]
added: list[str]
deleted: list[str]
untracked: list[str]
ahead: int = 0
behind: int = 0
@dataclass
class Commit:
"""A single git commit."""
hash: str
message: str
author: str
date: str
class GitOperations:
"""Run git commands within a workspace directory."""
def __init__(self, workspace: Workspace) -> None:
self.cwd = workspace.path
self.branch = workspace.branch
async def _run(self, *cmd: str) -> tuple[int, str, str]:
"""Run a git command and return (returncode, stdout, stderr)."""
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode or 0, stdout.decode(), stderr.decode()
async def status(self) -> GitStatus:
"""Get git status for the workspace."""
returncode, stdout, _ = await self._run(
"git", "-C", self.cwd, "status", "--porcelain", "-b"
)
modified: list[str] = []
added: list[str] = []
deleted: list[str] = []
untracked: list[str] = []
branch = self.branch
ahead = 0
behind = 0
for line in stdout.splitlines():
if line.startswith("##"):
# Branch info line
branch_info = line[3:].strip()
if "..." in branch_info:
branch = branch_info.split("...")[0]
if "[ahead " in branch_info:
ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
ahead = int(ahead_str.split(",")[0])
if "[behind " in branch_info:
behind_str = branch_info.split("[behind ")[1].split("]")[0]
behind = int(behind_str.split(",")[0])
else:
branch = branch_info
continue
if len(line) < 3:
continue
status_code = line[:2]
file_path = line[3:]
# XY format: X = index status, Y = working tree status
if status_code == "??":
untracked.append(file_path)
elif status_code[1] == "D" or status_code[0] == "D":
deleted.append(file_path)
elif status_code[0] == "A" or status_code[1] == "A":
added.append(file_path)
else:
modified.append(file_path)
return GitStatus(
branch=branch,
modified=modified,
added=added,
deleted=deleted,
untracked=untracked,
ahead=ahead,
behind=behind,
)
async def commit(self, message: str) -> None:
"""Stage all changes and commit."""
rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A")
if rc != 0:
raise RuntimeError(f"Git add failed: {err}")
rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
if rc != 0:
raise RuntimeError(f"Git commit failed: {err}")
logger.info("Committed in workspace: %s", self.cwd)
async def push(self) -> None:
"""Push current branch to origin."""
rc, _, err = await self._run(
"git", "-C", self.cwd, "push", "origin", self.branch
)
if rc != 0:
raise RuntimeError(f"Git push failed: {err}")
logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd)
async def pull(self) -> None:
"""Pull current branch from origin."""
rc, _, err = await self._run(
"git", "-C", self.cwd, "pull", "origin", self.branch
)
if rc != 0:
raise RuntimeError(f"Git pull failed: {err}")
logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd)
async def fetch(self) -> None:
"""Fetch from origin."""
rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin")
if rc != 0:
raise RuntimeError(f"Git fetch failed: {err}")
logger.info("Fetched origin for workspace: %s", self.cwd)
async def checkout(self, branch: str) -> None:
"""Checkout a branch."""
rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch)
if rc != 0:
raise RuntimeError(f"Git checkout failed: {err}")
self.branch = branch
logger.info("Checked out branch %s in workspace: %s", branch, self.cwd)
async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
"""Get commit history.
Args:
path: Optional file path to filter history.
limit: Maximum number of commits.
Returns:
List of commits.
"""
cmd = [
"git",
"-C",
self.cwd,
"log",
f"--max-count={limit}",
"--pretty=format:%H|%s|%an|%ad",
"--date=iso",
]
if path:
cmd.extend(["--", path])
rc, stdout, err = await self._run(*cmd)
if rc != 0:
raise RuntimeError(f"Git log failed: {err}")
commits = []
for line in stdout.strip().splitlines():
parts = line.split("|", 3)
if len(parts) >= 4:
commits.append(
Commit(
hash=parts[0],
message=parts[1],
author=parts[2],
date=parts[3],
)
)
return commits
async def branches(self) -> tuple[list[str], str]:
"""List all branches and current branch.
Returns:
Tuple of (all_branches, current_branch).
"""
rc, stdout, err = await self._run(
"git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)"
)
if rc != 0:
raise RuntimeError(f"Git branch failed: {err}")
branches = []
current = self.branch
for line in stdout.strip().splitlines():
line = line.strip()
if line.startswith("HEAD") or line.endswith("/HEAD"):
continue
if line.startswith("remotes/origin/"):
branch_name = line.replace("remotes/origin/", "")
if branch_name not in branches:
branches.append(branch_name)
elif line and line not in branches:
branches.append(line)
return branches, current
+176
View File
@@ -0,0 +1,176 @@
"""Git operations for workspace management."""
import asyncio
import logging
import os
import subprocess
import tempfile
logger = logging.getLogger(__name__)
class GitService:
"""Low-level git operations for creating and syncing workspaces."""
@staticmethod
def _prepare_ssh_env(
ssh_key: str | None,
) -> tuple[dict[str, str] | None, str | None]:
"""Prepare environment for git commands with SSH authentication.
Returns a tuple of (env_dict, temp_key_path). Caller must clean up key_path.
"""
if not ssh_key:
return None, None
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, ssh_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
return env, key_path
@staticmethod
async def clone(
remote_url: str, branch: str, path: str, ssh_key: str | None = None
) -> None:
"""Clone a repository to the given path.
Args:
remote_url: The git remote URL.
branch: The branch to clone.
path: The destination path for the clone.
ssh_key: Optional decrypted SSH private key for authentication.
Raises:
RuntimeError: If the clone fails.
"""
cmd = [
"git",
"clone",
"--branch",
branch,
"--single-branch",
remote_url,
path,
]
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **env} if env else None,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git clone failed: %s", error_msg)
raise RuntimeError(f"Git clone failed: {error_msg}")
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@staticmethod
async def fetch(path: str, ssh_key: str | None = None) -> None:
"""Fetch from origin.
Args:
path: The path to the local git repository.
ssh_key: Optional decrypted SSH private key for authentication.
Raises:
RuntimeError: If fetch fails.
"""
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
path,
"fetch",
"origin",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **env} if env else None,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git fetch failed: %s", error_msg)
raise RuntimeError(f"Git fetch failed: {error_msg}")
logger.debug("Fetched origin for %s", path)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@staticmethod
async def pull(path: str, branch: str, ssh_key: str | None = None) -> None:
"""Pull latest changes from origin.
Args:
path: The path to the local git repository.
branch: The branch to pull.
ssh_key: Optional decrypted SSH private key for authentication.
Raises:
RuntimeError: If pull fails.
"""
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
path,
"pull",
"origin",
branch,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **env} if env else None,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git pull failed: %s", error_msg)
raise RuntimeError(f"Git pull failed: {error_msg}")
logger.debug("Pulled origin/%s for %s", branch, path)
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
@staticmethod
def branch_exists_remotely(
path: str, branch: str, ssh_key: str | None = None
) -> bool:
"""Check if a branch exists on the remote.
Args:
path: The path to the local git repository.
branch: The branch name to check.
ssh_key: Optional decrypted SSH private key for authentication.
Returns:
True if the branch exists on origin, False otherwise.
"""
env, key_path = GitService._prepare_ssh_env(ssh_key)
try:
result = subprocess.run(
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
capture_output=True,
text=True,
env={**os.environ, **env} if env else None,
)
exists = result.returncode == 0 and result.stdout.strip() != ""
logger.debug("Branch %s exists on remote: %s", branch, exists)
return exists
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
-118
View File
@@ -1,118 +0,0 @@
"""Git operations for workspace management."""
import asyncio
import logging
import subprocess
logger = logging.getLogger(__name__)
class GitService:
"""Low-level git operations for creating and syncing workspaces."""
@staticmethod
async def clone(remote_url: str, branch: str, path: str) -> None:
"""Clone a repository to the given path.
Args:
remote_url: The git remote URL.
branch: The branch to clone.
path: The destination path for the clone.
Raises:
RuntimeError: If the clone fails.
"""
cmd = [
"git",
"clone",
"--branch",
branch,
"--single-branch",
remote_url,
path,
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git clone failed: %s", error_msg)
raise RuntimeError(f"Git clone failed: {error_msg}")
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
@staticmethod
async def fetch(path: str) -> None:
"""Fetch from origin.
Args:
path: The path to the local git repository.
Raises:
RuntimeError: If fetch fails.
"""
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
path,
"fetch",
"origin",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git fetch failed: %s", error_msg)
raise RuntimeError(f"Git fetch failed: {error_msg}")
logger.debug("Fetched origin for %s", path)
@staticmethod
async def pull(path: str, branch: str) -> None:
"""Pull latest changes from origin.
Args:
path: The path to the local git repository.
branch: The branch to pull.
Raises:
RuntimeError: If pull fails.
"""
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
path,
"pull",
"origin",
branch,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode().strip() if stderr else "unknown error"
logger.error("Git pull failed: %s", error_msg)
raise RuntimeError(f"Git pull failed: {error_msg}")
logger.debug("Pulled origin/%s for %s", branch, path)
@staticmethod
def branch_exists_remotely(path: str, branch: str) -> bool:
"""Check if a branch exists on the remote.
Args:
path: The path to the local git repository.
branch: The branch name to check.
Returns:
True if the branch exists on origin, False otherwise.
"""
result = subprocess.run(
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
capture_output=True,
text=True,
)
exists = result.returncode == 0 and result.stdout.strip() != ""
logger.debug("Branch %s exists on remote: %s", branch, exists)
return exists
@@ -0,0 +1,7 @@
"""Instance lifecycle services module."""
from src.services.instance.event_bus import InstanceEventBus
from src.services.instance.health_monitor import HealthMonitor
from src.services.instance.lifecycle_hooks import publish_lifecycle_event
__all__ = ["InstanceEventBus", "HealthMonitor", "publish_lifecycle_event"]
@@ -10,13 +10,13 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.database import SessionLocal
from src.models.health_check import HealthCheck
from src.models.tool_instance import ToolInstance
from src.services.correlation import get_correlation_id
from src.models import HealthCheck
from src.models import ToolInstance
from src.services.shared.correlation import get_correlation_id
from src.services.docker import get_container_status
from src.services.tunnel import check_tunnel_health
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.notification_service import notification_service
from src.services.shared.tunnel import check_tunnel_health
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.shared.notification_service import notification_service
logger = logging.getLogger(__name__)
@@ -6,11 +6,11 @@ from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.instance_event import InstanceEvent
from src.models.tool_instance import ToolInstance
from src.services.correlation import get_correlation_id
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.notification_service import notification_service
from src.models import InstanceEvent
from src.models import ToolInstance
from src.services.shared.correlation import get_correlation_id
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.shared.notification_service import notification_service
logger = logging.getLogger(__name__)
+51
View File
@@ -0,0 +1,51 @@
"""Shared services module."""
from src.services.shared.correlation import CorrelationIdMiddleware, get_correlation_id
from src.services.shared.file_service import FileEntry, FileService
from src.services.shared.notification_service import NotificationService
from src.services.shared.permission_fixer import (
PermissionFixError,
apply_mount_permissions,
apply_ssh_permissions,
check_root_user_available,
)
from src.services.shared.readiness_probe import execute_probe
from src.services.shared.ssh_keys import (
cleanup_ssh_key_files,
prepare_ssh_key_files,
write_ssh_config,
)
from src.services.shared.tunnel import (
check_tunnel_health,
recreate_tunnel,
start_tunnel,
stop_tunnel,
)
from src.services.shared.workspace_manager import (
SyncResult,
WorkspaceHasInstancesError,
WorkspaceManager,
)
__all__ = [
"CorrelationIdMiddleware",
"FileEntry",
"FileService",
"NotificationService",
"PermissionFixError",
"SyncResult",
"WorkspaceHasInstancesError",
"WorkspaceManager",
"apply_mount_permissions",
"apply_ssh_permissions",
"check_root_user_available",
"check_tunnel_health",
"cleanup_ssh_key_files",
"execute_probe",
"get_correlation_id",
"prepare_ssh_key_files",
"recreate_tunnel",
"start_tunnel",
"stop_tunnel",
"write_ssh_config",
]
@@ -0,0 +1,128 @@
"""File operations scoped to a workspace directory."""
import logging
import os
from dataclasses import dataclass
from src.models import Workspace
logger = logging.getLogger(__name__)
@dataclass
class FileEntry:
"""A single file or directory entry."""
name: str
path: str
type: str # "file" or "directory"
size: int | None = None
class FileService:
"""Read and write files within a workspace directory."""
def list_directory(
self,
workspace: Workspace,
relative_path: str = "",
) -> list[FileEntry]:
"""List entries in a workspace directory.
Args:
workspace: The workspace to list files in.
relative_path: Path relative to workspace root.
Returns:
List of file entries sorted by name (directories first).
"""
abs_path = os.path.join(workspace.path, relative_path)
abs_path = os.path.normpath(abs_path)
# Security: ensure we stay within workspace
if not abs_path.startswith(os.path.normpath(workspace.path)):
raise ValueError("Path escapes workspace directory")
if not os.path.exists(abs_path):
return []
entries = []
for item in sorted(os.listdir(abs_path)):
full = os.path.join(abs_path, item)
rel = os.path.join(relative_path, item) if relative_path else item
is_dir = os.path.isdir(full)
size = os.path.getsize(full) if os.path.isfile(full) else None
entries.append(
FileEntry(
name=item,
path=rel.replace("\\", "/"),
type="directory" if is_dir else "file",
size=size,
)
)
# Directories first, then files, both alphabetical
entries.sort(key=lambda e: (0 if e.type == "directory" else 1, e.name.lower()))
return entries
def read_file(self, workspace: Workspace, relative_path: str) -> str:
"""Read a text file from the workspace.
Args:
workspace: The workspace to read from.
relative_path: Path relative to workspace root.
Returns:
File contents as string.
Raises:
ValueError: If path escapes workspace or file is binary.
FileNotFoundError: If file does not exist.
"""
abs_path = self._resolve_path(workspace, relative_path)
if not os.path.isfile(abs_path):
raise FileNotFoundError(f"Not a file: {relative_path}")
# Basic binary check — read first 8KB and look for null bytes
with open(abs_path, "rb") as f:
chunk = f.read(8192)
if b"\x00" in chunk:
raise ValueError("Binary files cannot be viewed")
with open(abs_path, encoding="utf-8", errors="replace") as f:
return f.read()
def write_file(
self,
workspace: Workspace,
relative_path: str,
content: str,
) -> None:
"""Write a text file to the workspace.
Args:
workspace: The workspace to write to.
relative_path: Path relative to workspace root.
content: File contents.
Raises:
ValueError: If path escapes workspace.
"""
abs_path = self._resolve_path(workspace, relative_path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
f.write(content)
logger.info("Wrote file %s in workspace %s", relative_path, workspace.id)
def _resolve_path(self, workspace: Workspace, relative_path: str) -> str:
"""Resolve a relative path to absolute, with security check."""
abs_path = os.path.normpath(os.path.join(workspace.path, relative_path))
workspace_root = os.path.normpath(workspace.path)
if not abs_path.startswith(workspace_root):
raise ValueError("Path escapes workspace directory")
return abs_path
@@ -8,7 +8,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.notification import Notification
from src.models import Notification
class NotificationService:

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