Compare commits

..

88 Commits

Author SHA1 Message Date
alex 22474cdba5 style: fix all ruff and eslint errors across codebase
Backend (ruff):
- Fix 106 errors: move imports to top of file (E402)
- Remove unused imports (F401)
- Add missing imports for undefined names (F821)
- Remove unused variables (F841)
- Fix test_models.py broken RefreshToken test
- Fix test_projects_api.py missing TestClient import

Frontend (eslint):
- Remove unused imports/variables across 10 files
- Fix explicit any types in client.ts and sessions.ts
- Clean up empty block statements in terminal.tsx

Quality gates: ruff (pass), eslint (pass), tsc --noEmit (pass),
pytest (98 passed, 4 pre-existing failures)
2026-05-28 10:15:59 +02:00
alex 0c839e8c6f fix: terminal 4004 infinite reconnect loop for pi-agent tool type
- Add stdin_open: true and tty: true to dockerfile-based compose generation.
  Without these, bash (PID 1) exits immediately, causing a container restart
  loop that makes the instance invisible to docker ps and triggers 4004.
- Treat WebSocket close codes 4001/4003/4004 as permanent errors in the
  frontend. Stop retrying and show the server reason to the user.
- Prevent visibilitychange handler from resetting retry attempts after a
  permanent error has occurred.
- Use docker ps -a in get_container_id/get_container_name to find
  stopped/exited containers for diagnostics.

Quality gates: tsc --noEmit (pass), pytest (98 passed, 4 pre-existing failures)
2026-05-28 09:33:35 +02:00
Alex Blank c63cf7db50 fix: handle existing git mount dirs and invalid compose ports
- Fix git mount clone to check correct path (repo-clone subdir)
- Pull updates instead of re-cloning when git mount dir exists
- Add compose file sanitization to remove invalid port 0 mappings
- Fixes startup failures for existing instances with old compose files
2026-05-27 22:34:24 +02:00
Alex Blank d9d2b91384 fix: skip port mapping for terminal-only tools in dockerfile compose 2026-05-27 22:29:37 +02:00
Alex Blank d6ea5fb1fd chore: clean up debugging logs and console prints
Frontend:
- Remove 18 console.log/warn/error statements from terminal.tsx
- Remove console.warn from icon.tsx

Backend:
- Downgrade routine logger.info to logger.debug in tool_instances.py, terminal.py,
  terminal_session.py, terminal_manager.py, auth.py, docker_build.py, clone.py,
  config_profiles.py, user_config.py
- Keep important lifecycle events as logger.info:
  * Instance creation, start, running state
  * Docker build success/failure
  * Terminal session creation and reset
  * Auth success and user creation
  * Readiness probe success
  * Tunnel creation/stop
2026-05-27 22:16:59 +02:00
Alex Blank 1883825b18 fix: run docker build in thread pool to avoid blocking API 2026-05-27 22:08:01 +02:00
Alex Blank bc71fd6fac fix: lowercase docker image tag to avoid invalid reference format 2026-05-27 22:02:18 +02:00
Alex Blank 28aa9ccf5a feat: add pi-agent tool type migration
- Adds pi-agent to tool_types table with terminal interface
- Includes Dockerfile template for pi.dev coding agent
- Idempotent: checks for existing entry before insert
2026-05-27 16:30:27 +02:00
Alex Blank 44dd80cb58 feat: add tool-images directory with Dockerfiles for all base tool types
- Create tool-images/ directory with organized Dockerfile templates
- Add base.dockerfile with common dev tools (git, nvim, ranger, tmux, node)
- Add code-server.dockerfile with VS Code in browser
- Add jupyter.dockerfile with Jupyter Lab
- Add opencode.dockerfile with OpenCode agent
- Add pi-agent.dockerfile with Pi Coding Agent (pi.dev)
- All images include: git, neovim, ranger, tmux, htop, tree, jq, Node.js 20
- Add README with usage instructions
2026-05-27 15:51:43 +02:00
Alex Blank 23485833d8 docs: clarify git mount target path hint 2026-05-27 15:34:50 +02:00
Alex Blank e23dcdf4e1 fix: remove broken ~ expansion, require working_directory for relative paths 2026-05-27 15:33:06 +02:00
Alex Blank f05ac55875 fix: expand ~ in git mount target paths to avoid /tmp 2026-05-27 15:27:44 +02:00
Alex Blank bcefeb4163 fix: git mount add button not adding mounts 2026-05-27 15:17:34 +02:00
Alex Blank 33d08faf70 feat: allow relative target paths for git mounts
- Remove absolute path requirement from target_path validation
- Resolve relative paths against working_directory at instance startup
- Fall back to /home/user if no working_directory is configured
- Update frontend to allow relative target paths
- Update spec to document relative path support
- Update tests to allow relative paths and test path traversal rejection
2026-05-27 15:01:16 +02:00
Alex Blank 8a58c61278 fix: align git mount implementation with spec
- _checkout_branch now returns bool and falls back gracefully on failure
- Glob warning message includes matched file count
- Fix database model comment to reference remote_url
- Update tests for new branch checkout behavior

All 51 tests pass
2026-05-27 14:38:11 +02:00
Alex Blank 8231e750d9 docs: update spec to use remote_url instead of repo_id for git mounts
- Change git mount schema from repo_id to remote_url
- Update validation rules to check URL format instead of repo existence
- Update cloning scenarios to clone directly from URL
- Update UI scenarios to show URL input instead of repo selector
- Remove references to internal/existing repositories
2026-05-27 14:27:39 +02:00
Alex Blank 6ce645d210 fix: correct API endpoints for repository listing
- Change frontend to call /repositories instead of /projects/repositories
- Change parse-url endpoint to /repositories/parse-url
- Fixes 422 error from route mismatch
2026-05-27 14:18:13 +02:00
Alex Blank 89ca9f10c7 feat: simplify git mounts to use direct URLs instead of repo references
- Change git mount schema from repo_id to remote_url
- Remove database lookups for git mount resolution
- Clone directly from URL at instance startup
- Simplify frontend UI to text input for Git URL
- Fix route ordering in git_repositories.py to prevent 422 errors
- Update all tests to use remote_url field

Breaking change: Git mounts now use remote_url instead of repo_id
2026-05-27 11:53:25 +02:00
Alex Blank baabd1fa62 feat: allow creating external repositories directly from git mount editor
- Add createExternalRepository API function
- Update GitMountEditor with "+ Add new repository..." option
- Show form to enter repo name and remote URL
- Auto-create external repo and refresh list on success
- Update config-profiles page to pass onCreateRepository handler
2026-05-27 11:17:27 +02:00
Alex Blank f14fc37e75 docs: update API docs for external repositories and git mounts
- Add GET /repositories endpoint documentation
- Add POST /repositories endpoint for external repos
- Update config-profiles.md with git mount details
- Update repositories.md with external repo support
2026-05-27 11:04:48 +02:00
Alex Blank e07938098a feat: add external repository support for config profile git mounts
- Add POST /repositories endpoint for external repos (no project_id)
- Update GitRepositoryResponse to allow nullable project_id
- Update list_repositories to support listing all user repos
- Add _pull_repository_updates for auto-pull on container creation
- Update git mount validation to allow external repos
- Frontend: Update listRepositories to support optional projectId
- Spec updates: external repos, auto-clone, per-instance isolation
2026-05-27 11:03:12 +02:00
Alex Blank 943b9db5c7 fix: remove merge migration that references non-existent file
The server is missing 2026_05_27_make_project_id_nullable.py but the
merge migration referenced it. Removing the merge migration leaves a
clean single head chain.
2026-05-27 10:48:25 +02:00
Alex Blank a4604d6a9a fix: add merge migration to resolve multiple heads from renamed migration
The server has both the old (2026_05_27_make_project_id_nullable) and
new (2026_05_27_external_repos) migration files, creating two heads.
This merge migration resolves them into a single head.
2026-05-27 10:42:44 +02:00
Alex Blank 18204628cc fix: shorten alembic revision name and expand version_num column
- Rename 2026_05_27_make_project_id_nullable to 2026_05_27_external_repos
  (33 chars exceeded VARCHAR(32) limit in alembic_version table)
- Add alembic_version column expansion to VARCHAR(64) in migration
- Ensure future migrations won't hit the 32 char limit
2026-05-27 10:36:47 +02:00
Alex Blank 93b415c53e feat: support external repositories for git mounts
- Make project_id nullable in git_repositories table (migration)
- Allow external repos not tied to any project
- Update validation to allow user-owned external repos in git mounts
- Add /projects/repositories endpoint to list all user repos
- Update frontend to fetch all user repos for git mount selector
- TypeScript and build pass
2026-05-27 10:28:31 +02:00
Alex Blank e7adfb462b fix: use correct revision ID for alembic down_revision 2026-05-27 10:21:20 +02:00
Alex Blank ed1d6528c6 fix: resolve alembic multiple heads by correcting migration dependency chain 2026-05-27 10:16:06 +02:00
Alex Blank c4be7163d6 docs: add config profile git mounts documentation
- API documentation for config profiles with git mounts endpoint details
- User guide for using git repositories in config profiles
- Document branch pinning, glob patterns, error handling, and best practices
- Update API README to link to new config-profiles documentation
2026-05-26 22:54:59 +02:00
Alex Blank 13f55fff47 docs: mark test tasks as complete in spec 2026-05-26 22:50:06 +02:00
Alex Blank 0ec20b9c23 test: add comprehensive tests for config profile git mounts
- Add git mount merge function tests
- Add profile resolution tests with git mounts
- Add integration tests for CRUD with git mounts
- Add glob expansion tests (patterns, limits, repo boundary)
- Add branch checkout tests (success and failure)
- Add error handling tests for missing repos/invalid UUIDs

All 52 tests pass.
2026-05-26 22:49:12 +02:00
Alex Blank 4c11163bff feat: implement config profile git mounts
- Add git_mounts column to config_profiles table (JSONB)
- Create GitMount Pydantic models with validation
- Add repo validation in create/update endpoints
- Update profile resolver to merge git mounts from includes
- Implement auto-clone, branch checkout, and glob expansion
- Add parallel processing for git mount resolution
- Create GitMountEditor frontend component
- Update TypeScript types and API clients
- Add CSS styles for git mount UI
- Frontend type check and build pass

Implements tasks 1.1-7.9 of config-profile-git-mounts spec
2026-05-26 22:27:04 +02:00
Alex Blank adda76a2ff fix: remove viewport-based font size calculation causing jump on mobile
The calculateFontSize() function was overriding the font size on mobile
based on viewport width (vw/25), causing the terminal to display at ~15px
while the internal state was 8px. When pressing A-, it would jump from
15px to 7px. Now it respects the actual fontSize state consistently.

Quality gates: TypeScript check passed, production build successful
2026-05-26 21:19:47 +02:00
Alex Blank 47962ed476 fix: set terminal default to 8px and minimum to 4px
- Reduce MIN_FONT_SIZE from 8 to 4 for maximum text size reduction
- Set default font size to 8px for both mobile and desktop
- Allows very small terminal text for mobile viewport optimization

Quality gates: TypeScript check passed, production build successful
2026-05-26 21:08:26 +02:00
Alex Blank 6a0c9bd669 fix: reduce terminal font size minimum and defaults
- Reduce MIN_FONT_SIZE from 12 to 8 for smaller text option
- Reduce default font size from 14/12 to 10/10 for mobile/desktop
- Allows users to make terminal text significantly smaller

Quality gates: TypeScript check passed, production build successful
2026-05-26 21:05:04 +02:00
Alex Blank 76fbf0a755 fix: increase terminal lineHeight to 1.2 for mobile font metrics
Mobile devices use different monospace fonts (Courier on iOS, Droid Sans Mono
on Android) with larger ascent/descent metrics than desktop fonts. With
lineHeight: 1.0, calculated cell height was smaller than actual glyph height,
causing block characters to render at ~3/4 height. Increasing to 1.2 gives
mobile fonts proper vertical space while maintaining desktop compatibility.

Quality gates: TypeScript check passed, production build successful
2026-05-26 19:35:43 +02:00
Alex Blank 187193fa6e fix: terminal block character rendering by setting lineHeight to 1.0 and removing container padding 2026-05-26 19:09:06 +02:00
Alex Blank cd9c9539a2 fix: improve terminal rendering by removing CSS overrides and increasing minimum font size
- Remove CSS overrides that interfere with xterm.js internal sizing
- Increase MIN_FONT_SIZE from 10 to 12 to prevent broken character rendering
- Increase default font sizes from 10/12 to 12/14 (desktop/mobile)
- Add clamping for stored font size values to prevent old tiny values
- Remove !important rules on xterm-viewport that could cause clipping
2026-05-26 16:20:22 +02:00
Alex Blank 555517c144 fix: font size buttons only worked once due to stale callback
The changeFontSize callback passed to MobileTerminalWrapper was capturing
the initial handleFontSizeChange function, so subsequent clicks used stale
fontSize state. Fixed by wrapping handleFontSizeChange in a ref so the
callback always calls the latest version.
2026-05-26 13:51:26 +02:00
Alex Blank fc1554140f feat: significantly reduce terminal font sizes
- Reduce MIN_FONT_SIZE from 10 to 6
- Reduce MAX_FONT_SIZE from 24 to 20
- Reduce default desktop font size from 14 to 10
- Reduce default mobile font size from 16 to 12
2026-05-26 13:42:36 +02:00
Alex Blank bc5e80c954 fix: add missing mobile bottom sheet styles for Tools menu 2026-05-26 12:21:52 +02:00
Alex Blank ab79080f0b refactor: consolidate loading/error states and extract instance actions hook
Frontend:
- Create reusable DataStates components (LoadingState, ErrorState, EmptyState)
- Refactor 12 pages to use shared state components instead of inline JSX
- Extract useInstanceActions hook to eliminate session action duplication
- Update dashboard and sessions pages to use shared hook

OpenSpec:
- Archive completed mobile-app-usability change (44/44 tasks)
- Archive completed add-config-profiles change (15/15 tasks)

Quality: TypeScript check passes, production build succeeds
2026-05-25 22:50:18 +02:00
Alex Blank 4c216dd1ca refactor: extract shared Pydantic validators
- Create shared_validators.py with validate_mount_path, validate_files, validate_env_vars, validate_volumes
- Refactor config_folders.py to use shared validators
- Refactor tool_configs.py to use shared validators
- Refactor config_profiles.py to use shared env_vars validator
- Reduce ~80 lines of duplicate validation code
2026-05-25 14:05:12 +02:00
Alex Blank a37a3122f9 refactor: extract shared validation and reduce duplication
- Extract tool_types validation to shared module (validate_compose_yaml, check_port_exposed, validate_required_variables)
- Extract _get_user and _get_owned_project to auth/dependencies.py
- Create useAsyncData hook and apply to 6 pages
- Create extractErrorMessage utility
- TypeScript and build pass
2026-05-25 14:01:32 +02:00
Alex Blank a905cf729e fix: restore original CSS and combine with mobile styles
- Restored original desktop CSS that was accidentally overwritten
- Added back all mobile-specific styles
- CSS file now 3945 lines (original + mobile styles)
- Build passes successfully
2026-05-25 13:00:47 +02:00
Alex Blank 3a16775188 docs: mark mobile-pages-overhaul tasks complete and sync specs 2026-05-25 12:52:06 +02:00
Alex Blank b363d89768 feat: mobile repo workspace with tabbed navigation
- Add mobile viewport detection to RepoWorkspace
- Implement bottom tab navigation (Files, Editor, Git, Terminal)
- Add repository and branch selectors for mobile
- Create mobile workspace layout with tab bar
- Add CSS styles for mobile workspace components
- Desktop layout remains unchanged
2026-05-25 12:49:15 +02:00
Alex Blank 27c39f9cfc feat: mobile config profiles with list-detail pattern
- Add mobile list view showing all config profiles
- Add mobile detail view with profile information display
- Add mobile edit/create view with full form
- Implement list→detail→edit navigation
- Fix TypeScript errors and build issues
2026-05-25 12:27:47 +02:00
Alex Blank e8d5b16acc feat: mobile tool workshop with list-detail pattern
- Add mobile viewport detection to ToolWorkshopPage
- Implement mobile list view with MobileListView component
- Implement mobile detail view with MobileDetailView component
- Implement mobile edit view with MobileEditView component
- Add MobileFAB for creating new tool types
- Fix IconName type issues in mobile components
- TypeScript check passes, build succeeds
2026-05-25 12:18:24 +02:00
Fusion 437ad840ef docs: mark all mobile-app-usability tasks complete 2026-05-25 11:21:25 +02:00
Fusion c2a232d8f0 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-25 11:20:43 +02:00
Fusion adaedb70ef feat: implement mobile app usability improvements
Mobile Navigation:
- Add MobileNav component with bottom tab bar
- Show mobile nav on small screens, hide desktop sidebar
- Add session count badge to Sessions tab
- Add safe area padding for notched devices

Session Management:
- Redesign SessionCard for mobile with action menu
- Add MobileActionSheet for session actions
- Keep primary action prominent

Forms & Dialogs:
- Stack form fields vertically on mobile
- Ensure 44px minimum touch targets
- Update dialogs for 320px viewport

Responsive Layout:
- Add MobilePageHeader with back button
- Reduce page padding on mobile
- Stack multi-column grids vertically

Touch & Interaction:
- Add active states to interactive elements
- Ensure 8px spacing between touch targets

Complex Pages:
- Update Repo Workspace for mobile
- Update Tool Workshop and Config Profiles

Build: TypeScript check passes, production build succeeds
2026-05-25 11:20:25 +02:00
OpenCode Agent 5178cf9cbf Merge branch 'feat/terminal-startup-and-container-tools' into dev 2026-05-24 22:22:03 +00:00
OpenCode Agent 01a0ef46c9 feat: terminal startup command and container tools
- Add startup_command field to ToolType model and API
- Execute startup command before interactive shell in terminal sessions
- Add tmux and ranger to OpenCode container spec
- Update Tool Workshop UI with startup_command input for terminal types
- Add backend tests for startup_command CRUD operations
- Sync specs: tool-terminal, tool-types-definition, opencode-web-server
- New spec: tool-terminal-startup-command

Quality gates: Frontend typecheck/lint passed. Backend tests blocked by environment (Python/Docker not available).

OpenSpec: terminal-startup-and-container-tools
2026-05-24 22:21:55 +00:00
alex a4c429d53a Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 21:40:44 +00:00
alex 1fc244e818 fix: show time alongside date in session created_at 2026-05-24 21:40:26 +00:00
Fusion 8fb4b67372 fix: enable terminal scrolling on mobile
- Add overflow-y: auto and -webkit-overflow-scrolling: touch to xterm-viewport
- Change touch-action from 'none' to 'pan-y' on mobile terminal wrapper and container
- This allows vertical scrolling through terminal output history while preventing zoom
2026-05-24 23:35:05 +02:00
Fusion fbd41e3eb4 fix: mobile terminal container height - use flexbox throughout
- Change .mobile-terminal-content to display: flex with flex-direction: column
- Change .terminal-wrapper.mobile to use flex: 1 instead of position: absolute
- Change .terminal-container to use flex: 1 instead of height: 100%
- Ensures proper height calculation in flex layout chain
2026-05-24 23:25:42 +02:00
Fusion 9c57a94e9f Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 23:21:28 +02:00
Fusion 84b7b64ec0 fix: change mobile terminal wrapper from grid to flexbox
Grid layout was not properly sizing the content area, causing 0 height.
Flexbox with flex: 1 on content area ensures proper filling.
2026-05-24 23:20:51 +02:00
alex 29ed0f2a3b Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 21:20:47 +00:00
alex 6c32e5266c fix: add terminal WebSocket diagnostics and unmount protection
- Add comprehensive connection/close/error/heartbeat logging
- Fix unmount to prevent reconnection attempts (code 1000)
- Add isUnmountingRef guard
- TypeScript compiles cleanly

Refs: intermittent terminal WebSocket failures
2026-05-24 21:20:16 +00:00
Fusion 9e88acaa36 fix: remove 0-dimension check blocking terminal fit and add debug logging
- Remove chicken-and-egg check that prevented fit() when cols/rows were 0
- Add console logging for container dimensions and fit results
- Add retry limit (50 attempts) for initial fit to prevent infinite loops
2026-05-24 23:15:15 +02:00
Fusion 0d10caf489 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 23:07:19 +02:00
Fusion 245d79569e fix: use CSS Grid for mobile terminal layout to fix 0 height issue 2026-05-24 23:07:13 +02:00
alex 8e86cd255c Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 21:02:23 +00:00
alex 6ee667c384 feat: add profile includes management UI
- Add includes section to profile editor with drag-and-drop reordering
- Display included profiles with scope badges (Global, Project, Tool)
- Add 'Add Include' dropdown filtered by compatibility and cycle prevention
- Add remove button per include row
- Save includes together with profile form
- Add include count badges to profile list sidebar
- Add drag icon to Icon component

Implements config-profile-includes-ui tasks 1.1-4.3
2026-05-24 21:02:07 +00:00
Fusion 21285498ae fix: mobile terminal black screen - replace grid with flexbox layout
- Root cause: CSS Grid 1fr row got 0 height inside flex parent
- Fix: Replace grid layout with flexbox column for mobile terminal wrapper
- Header and keys strip use flex-shrink: 0
- Content area uses flex: 1 to fill remaining space
- Remove debug logging
2026-05-24 23:00:32 +02:00
Fusion 9751b65dce debug: add more logging to trace mobile terminal black screen 2026-05-24 22:56:02 +02:00
Fusion 612217ad89 fix: mobile terminal black screen - grid cell had 0 height
- Root cause: .mobile-terminal-wrapper used height: 100vh inside flex parent
- Fix: Use flex: 1 instead so grid properly allocates 1fr height to content
- Add .shell.mobile-terminal-shell CSS to ensure full viewport coverage
- Remove debug logging
2026-05-24 22:51:31 +02:00
Fusion b9ea806c0d Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 22:46:55 +02:00
Fusion 22f736ce20 debug: add console logging to trace mobile terminal black screen issue 2026-05-24 22:46:48 +02:00
alex deb22bec0f fix: prevent duplicate session action requests causing 404s
- Add early return guards in all session action handlers (start, stop, delete, recreate tunnel)
- Prevents race conditions where double-clicks or rapid clicks fire duplicate API calls
- First delete succeeds, second would 404 because instance is already deleted
- Applied to both sessions page and dashboard/home page
2026-05-24 19:40:42 +00:00
alex 9320e175d1 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 19:33:49 +00:00
alex d8f220d825 fix: unify session loading states across pages
- Add per-item busy overlay to SessionCard component
- Remove full-screen loading overlay from sessions page
- Remove loadingAction state, use per-item busy state only
- Add handleStart to sessions page for consistency
- Add session-card CSS for busy overlay positioning
- Both home and sessions pages now use same per-item loading pattern
2026-05-24 19:33:38 +00:00
Fusion 77b7c82563 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 21:29:30 +02:00
Fusion 3fc392a314 fix: handle ERR_NETWORK_CHANGED from Docker network changes
- Add retry logic for transient network errors in API client
- Retry up to 2 times with exponential backoff on network errors
- Reduce session polling from 10s to 30s to decrease error frequency
- Handle 502/503/504 gateway errors with retries as well
2026-05-24 21:29:16 +02:00
alex edd8882fa0 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 19:25:52 +00:00
alex 760369b102 fix: constrain loading overlays to components
- Add position: relative to create-session-form-wrapper so overlay fills only the form
- Remove text from instance busy overlay, show only spinner
- Delete progress indicator now fills only the target card
2026-05-24 19:25:42 +00:00
Fusion 8da527f964 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 21:21:47 +02:00
Fusion 76fbd74d20 fix: ensure terminal opens before fit and add dimension guards
- Open terminal before calling fitTerminal() to avoid race conditions
- Add container dimension checks before fitting
- Add guards to prevent fit/refresh with 0x0 dimensions
- Only send resize messages when dimensions are valid
- Prevent xterm.js internal errors from invalid dimension access
2026-05-24 21:21:34 +02:00
alex 384beeca8a Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 19:20:32 +00:00
alex 869efda214 fix: per-item loading animations in instance list
- Replace full-screen loading with per-instance busy state
- Add busy overlay with spinner to instance cards
- Disable action buttons while instance is busy
- Add CSS for visual dimming and overlay positioning

Fixes add-config-profiles: instance UI polish
2026-05-24 19:20:21 +00:00
Fusion 51b5d723ac fix: terminal clear on reset and data loss for text starting with {
Frontend:
- Clear xterm.js screen when receiving 'connected' status after reset
- Send resize message after clearing to ensure proper dimensions
- Fixes terminal artifacts after reset

Backend:
- Fix data loss bug: text starting with '{' but not valid JSON was silently dropped
- Now writes such text to session as regular input
- Fixes missing characters when user types '{'
2026-05-24 21:15:04 +02:00
Fusion 33b482ce91 fix: terminal reconnect typing and reset functionality
Frontend:
- Fix term.onData to use wsRef.current instead of captured ws variable
- Fix fitTerminal to use wsRef.current for resize messages
- Fix sendData callback to use wsRef.current
- This fixes 'cannot type' after WebSocket reconnect

Backend:
- Add SessionRef class for mutable session reference
- Update _read_loop and _write_loop to use SessionRef
- Reset now updates session_ref.session instead of returning
- This keeps the WebSocket alive after reset instead of closing it
2026-05-24 20:57:33 +02:00
Fusion 0cb2eefd29 fix: send SIGWINCH to docker exec process for container terminal resize
Instead of sending stty commands through the user's terminal session
(which causes 'inappropriate ioctl' errors), send SIGWINCH signal to
the docker exec process on the host. Docker exec should forward this
to the container process, causing the shell to re-read its terminal size.

This avoids:
- Visible stty commands in the terminal
- ioctl errors from stty
- Interference with user's shell session
2026-05-24 20:47:14 +02:00
Fusion 5f5dc9c851 fix: revert docker exec -i change and use stty with line hiding
Reverted docker exec back to -it (required for interactive bash).
Instead, sends stty command with \r to hide it from the terminal display:
- \r moves cursor to start of line (overwrites prompt)
- stty command executes silently (no output on success)
- \r moves cursor back to start, hiding echoed command

This sends stty on EVERY resize so the container shell always matches
frontend dimensions.
2026-05-24 20:40:02 +02:00
Fusion 4864d269e8 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 20:35:16 +02:00
Fusion 29e4bed9e6 fix: use docker exec -i instead of -it so host PTY resize propagates to container
Removes -t flag from docker exec so it uses our PTY slave directly instead
of creating its own PTY inside the container. This allows TIOCSWINSZ on the
host PTY master to propagate naturally to the container shell via SIGWINCH.

Also removes all stty command injection logic since resize now works natively.
2026-05-24 20:35:07 +02:00
147 changed files with 9078 additions and 1966 deletions
@@ -7,8 +7,6 @@ Create Date: 2026-05-22 21:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0014_merge_heads"
@@ -10,7 +10,6 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = "0015_single_interface"
@@ -0,0 +1,129 @@
"""add pi agent tool type
Revision ID: 20260527_160017_add_pi_agent
Revises: f3d2dc90ba3a
Create Date: 2026-05-27T16:00:17
"""
import json
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import uuid
# revision identifiers, used by Alembic.
revision: str = "20260527_160017_add_pi_agent"
down_revision: Union[str, None] = "2026_05_27_external_repos"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PI_AGENT_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
def upgrade() -> None:
# Check if pi-agent already exists
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'")
).fetchone()
if result is None:
conn.execute(
sa.text("""
INSERT INTO tool_types (
id, name, display_name, description, category,
interface_type, requires_port, default_port,
definition_type, compose_template, dockerfile_template, required_variables,
created_at, updated_at
) VALUES (
:id, :name, :display_name, :description, :category,
:interface_type, :requires_port, :default_port,
:definition_type, :compose_template, :dockerfile_template, :required_variables,
now(), now()
)
"""),
{
"id": PI_AGENT_ID,
"name": "pi-agent",
"display_name": "Pi Agent",
"description": "Pi coding agent terminal environment with nvim, ranger, and tmux",
"category": "development",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "dockerfile",
"compose_template": """services:
app:
build: .
stdin_open: true
tty: true
volumes:
- ${REPO_PATH}:/workspace
working_dir: /workspace
command: /bin/bash""",
"dockerfile_template": """# Pi Coding Agent - Terminal-based coding harness
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Install base dependencies
RUN apt-get update && apt-get install -y \\
curl \\
wget \\
git \\
neovim \\
ranger \\
tmux \\
htop \\
tree \\
jq \\
ca-certificates \\
python3 \\
python3-pip \\
build-essential \\
&& rm -rf /var/lib/apt/lists/*
# Install Node.js (required for Pi)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
&& apt-get install -y nodejs \\
&& rm -rf /var/lib/apt/lists/*
# Install Pi Coding Agent globally
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
# Create non-root user
RUN useradd -m -s /bin/bash user
WORKDIR /home/user
# Set up git
RUN git config --global init.defaultBranch main \\
&& git config --global user.email "dev@headquarter.local" \\
&& git config --global user.name "Developer"
# Create default tmux config
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
# Create default ranger config
RUN mkdir -p /home/user/.config/ranger \\
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
# Set up Pi config directory
RUN mkdir -p /home/user/.pi/agent
USER user
# Default to bash (Pi is invoked manually via `pi` command)
CMD ["/bin/bash"]""",
"required_variables": json.dumps(["REPO_PATH"]),
}
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text("DELETE FROM tool_types WHERE name = 'pi-agent'")
)
@@ -0,0 +1,30 @@
"""add startup_command to tool_types
Revision ID: 2026_05_24_220141
Revises: 6fc7bfcf199f
Create Date: 2026-05-24 22:01:41.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_24_220141"
down_revision: Union[str, Sequence[str], None] = "6fc7bfcf199f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_types",
sa.Column("startup_command", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("tool_types", "startup_command")
@@ -0,0 +1,28 @@
"""add_git_mounts_to_config_profiles
Revision ID: 2026_05_26_add_git_mounts
Revises: f3d2dc90ba3a
Create Date: 2026-05-26 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_26_add_git_mounts"
down_revision: Union[str, Sequence[str], None] = "2026_05_24_220141"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"config_profiles",
sa.Column("git_mounts", sa.JSON(), nullable=True, default=list),
)
def downgrade() -> None:
op.drop_column("config_profiles", "git_mounts")
@@ -0,0 +1,41 @@
"""make_project_id_nullable_in_git_repositories
Revision ID: 2026_05_27_external_repos
Revises: 2026_05_26_add_git_mounts
Create Date: 2026-05-27 08:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_27_external_repos"
down_revision: Union[str, Sequence[str], None] = "2026_05_26_add_git_mounts"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Expand alembic_version version_num to avoid truncation errors
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(64)")
# Make project_id nullable to allow external repositories
op.alter_column(
"git_repositories",
"project_id",
existing_type=sa.UUID(),
nullable=True,
)
def downgrade() -> None:
op.alter_column(
"git_repositories",
"project_id",
existing_type=sa.UUID(),
nullable=False,
)
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(32)")
@@ -5,8 +5,6 @@ Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
Create Date: 2026-05-24 18:00:43.990361
"""
from alembic import op
import sqlalchemy as sa
@@ -7,8 +7,6 @@ Create Date: 2026-05-24 10:43:14.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f3d2dc90ba3a"
+10 -10
View File
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
redirect_uri=redirect_uri,
state=state,
)
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
response = RedirectResponse(location)
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
@@ -63,7 +63,7 @@ async def callback(
auth_next: str | None = Cookie(default="/"),
session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse:
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
if auth_state is None or auth_state != state:
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
@@ -71,7 +71,7 @@ async def callback(
settings = Settings()
redirect_uri = f"{settings.api_base_url}/auth/callback"
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
async with httpx.AsyncClient() as client:
try:
@@ -92,7 +92,7 @@ async def callback(
access_token=token_payload["access_token"],
client=client,
)
logger.info("User info fetched successfully")
logger.debug("User info fetched successfully")
except Exception as exc:
logger.error("User info fetch failed: %s", exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
@@ -100,19 +100,19 @@ async def callback(
authentik_id = str(user_info.get("sub", ""))
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
name = str(user_info.get("name", email))
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
try:
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
if user is None:
logger.info("Creating new user: authentik_id=%s", authentik_id)
logger.debug("Creating new user: authentik_id=%s", authentik_id)
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
session.add(user)
await session.commit()
await session.refresh(user)
logger.info("New user created: id=%s", user.id)
else:
logger.info("Existing user found: id=%s, updating info", user.id)
logger.debug("Existing user found: id=%s, updating info", user.id)
user.email = email
user.name = name
await session.commit()
@@ -165,20 +165,20 @@ async def me(
session_cookie: str | None = Cookie(default=None, alias="session"),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, Any]:
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
if not session_cookie:
logger.warning("Auth /me: missing session cookie")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
settings = Settings()
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
try:
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
user_id = payload["user_id"]
logger.info("Auth /me: decoded session for user_id=%s", user_id)
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
except ValueError as exc:
logger.warning("Auth /me: invalid session: %s", exc)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
+6 -41
View File
@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.shared_validators import validate_files as _validate_files, validate_mount_path as _validate_mount_path
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_folder import ConfigFolder
@@ -15,9 +16,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
MAX_FOLDER_SIZE_MB = 10
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
class ConfigFolderCreate(BaseModel):
name: str = Field(description="Folder name (unique per user)")
@@ -28,24 +26,12 @@ class ConfigFolderCreate(BaseModel):
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
return _validate_mount_path(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
total_size = 0
for path, content in v.items():
# Check for path traversal
if ".." in path or path.startswith("/"):
raise ValueError(f"Invalid file path: {path}")
total_size += len(content.encode("utf-8"))
if total_size > MAX_FOLDER_SIZE_BYTES:
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
return v
return _validate_files(v)
class ConfigFolderUpdate(BaseModel):
@@ -58,29 +44,12 @@ class ConfigFolderUpdate(BaseModel):
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
return _validate_mount_path(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
if v is None:
return v
total_size = 0
for path, content in v.items():
# Check for path traversal
if ".." in path or path.startswith("/"):
raise ValueError(f"Invalid file path: {path}")
total_size += len(content.encode("utf-8"))
if total_size > MAX_FOLDER_SIZE_BYTES:
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
return v
return _validate_files(v)
class ProjectOverrideCreate(BaseModel):
@@ -90,11 +59,7 @@ class ProjectOverrideCreate(BaseModel):
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
return _validate_mount_path(v)
class ConfigFolderResponse(BaseModel):
+84 -6
View File
@@ -9,6 +9,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.api.shared_validators import validate_env_vars as _validate_env_vars
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.project import Project
@@ -55,6 +56,36 @@ def _calculate_profile_size(data: dict) -> int:
return total
class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
target_path: str = Field(description="Absolute path inside container")
branch: str | None = Field(default=None, description="Optional branch or tag name")
@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) -> 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 MountItem(BaseModel):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
@@ -97,6 +128,7 @@ class ConfigProfileCreate(BaseModel):
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")
@@ -120,9 +152,10 @@ class ConfigProfileCreate(BaseModel):
@field_validator("env_vars")
@classmethod
def validate_env_vars(cls, v: dict) -> dict:
if not isinstance(v, dict):
result = _validate_env_vars(v)
if result is None:
raise ValueError("env_vars must be a JSON object")
return v
return result
@field_validator("runtime_hints")
@classmethod
@@ -148,6 +181,7 @@ class ConfigProfileUpdate(BaseModel):
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")
@@ -191,6 +225,7 @@ class ConfigProfileResponse(BaseModel):
runtime_hints: dict
mounts: list
files: dict
git_mounts: list
is_default: bool
includes: list[dict]
created_at: str
@@ -225,6 +260,32 @@ async def _check_access(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
async def _validate_git_mounts(
session: AsyncSession,
user_id: uuid.UUID,
git_mounts: list[dict],
project_id: uuid.UUID | None = None,
) -> None:
"""Validate git mount URLs.
Simply checks that remote_url looks like a valid git URL.
Actual clone validation happens at instance startup time.
"""
for mount in git_mounts:
remote_url = mount.get("remote_url")
if not remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing remote_url",
)
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid git URL: {remote_url}",
)
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
return {
"id": str(profile.id),
@@ -236,6 +297,7 @@ def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInc
"env_vars": profile.env_vars or {},
"runtime_hints": profile.runtime_hints or {},
"mounts": profile.mounts or [],
"git_mounts": profile.git_mounts or [],
"files": profile.files or {},
"is_default": profile.is_default,
"includes": [
@@ -319,6 +381,11 @@ async def create_config_profile(
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await _check_access(session, user_uuid, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if data.git_mounts:
git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts]
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
# Check size
size = _calculate_profile_size(data.model_dump())
@@ -337,6 +404,7 @@ async def create_config_profile(
env_vars=data.env_vars,
runtime_hints=data.runtime_hints,
mounts=[m.model_dump() for m in data.mounts],
git_mounts=[m.model_dump() for m in data.git_mounts],
files=data.files,
is_default=data.is_default,
)
@@ -351,7 +419,7 @@ async def create_config_profile(
)
profile = result.scalar_one()
logger.info("Created config profile %s for user %s", profile.id, user_uuid)
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
return _profile_to_response(profile)
@@ -413,6 +481,14 @@ async def update_config_profile(
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["git_mounts"]
]
await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid)
# Check size
current_data = _profile_to_response(profile)
@@ -430,6 +506,8 @@ async def update_config_profile(
value = uuid.UUID(value) if value else None
elif field_name == "mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
elif field_name == "git_mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
setattr(profile, field_name, value)
await session.commit()
@@ -442,7 +520,7 @@ async def update_config_profile(
)
profile = result.scalar_one()
logger.info("Updated config profile %s", profile.id)
logger.debug("Updated config profile %s", profile.id)
return _profile_to_response(profile)
@@ -462,7 +540,7 @@ async def delete_config_profile(
await session.delete(profile)
await session.commit()
logger.info("Deleted config profile %s", profile_id)
logger.debug("Deleted config profile %s", profile_id)
return None
@@ -547,7 +625,7 @@ async def update_profile_includes(
)
direct_includes = inc_result.scalars().all()
logger.info("Updated includes for config profile %s", profile.id)
logger.debug("Updated includes for config profile %s", profile.id)
return _profile_to_response(profile, list(direct_includes))
+152 -57
View File
@@ -10,12 +10,10 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import 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.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.utils.git_files import (
commit_file,
get_file_content,
@@ -42,40 +40,6 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
logger = logging.getLogger(__name__)
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
"""Generate the filesystem path for a repository.
@@ -256,7 +220,7 @@ class GitRepositoryResponse(BaseModel):
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID
project_id: uuid.UUID | None
owner_id: uuid.UUID
is_mirror: bool
remote_url: str | None
@@ -266,6 +230,156 @@ class GitRepositoryResponse(BaseModel):
updated_at: datetime
@router.get(
"/repositories",
response_model=list[GitRepositoryResponse],
summary="List all user repositories",
description="List all git repositories owned by the user, including external repositories not tied to any project.",
)
async def list_user_repositories(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[GitRepository]:
"""List all repositories owned by the user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of all repositories owned by the user.
"""
result = await session.execute(
select(GitRepository).where(GitRepository.owner_id == user_id)
)
return list(result.scalars().all())
@router.post(
"/repositories/parse-url",
response_model=URLParseResponse,
summary="Parse a git URL",
description="Parse a git URL and detect if it's a browser URL that needs correction.",
)
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
"""Parse a git URL and detect if it's a browser URL that needs correction.
Args:
data: Request containing the URL to parse.
Returns:
Parsed URL information including whether it needs parsing and suggested corrections.
"""
result = parse_git_url(data.url)
return URLParseResponse(**result)
@router.post(
"/repositories",
response_model=GitRepositoryResponse,
status_code=status.HTTP_201_CREATED,
summary="Create an external repository",
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
)
async def create_external_repository(
data: GitRepositoryCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Create a new external git repository.
External repositories are not tied to any project and can be used
across all projects for config profile git mounts.
Args:
data: Repository creation data including name and optional remote URL.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created external repository.
"""
_user = await _get_user(session, user_id)
# Check for duplicate name (external repos only)
existing = await session.execute(
select(GitRepository).where(
GitRepository.project_id.is_(None),
GitRepository.owner_id == user_id,
GitRepository.name == data.name,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
# Validate and potentially correct the URL
remote_url = data.remote_url
if remote_url and not data.force_original_url:
parse_result = parse_git_url(remote_url)
if parse_result["needs_parsing"] and parse_result["base_url"]:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"message": "The provided URL appears to be a browser URL, not a git clone URL",
"suggested_url": parse_result["base_url"],
"original_url": remote_url,
"error_code": "URL_NEEDS_PARSING",
},
)
if parse_result["base_url"]:
remote_url = parse_result["base_url"]
# Validate SSH key if provided
ssh_key_id = None
ssh_key = None
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
if remote_url:
_preflight_remote_repository(remote_url, ssh_key)
# Create external repo with no project
repo = GitRepository(
name=data.name,
path="", # Will be set after clone
project_id=None,
owner_id=user_id,
remote_url=remote_url,
ssh_key_id=ssh_key_id,
)
session.add(repo)
await session.flush()
# Set path and optionally clone
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
repo.path = repo_path
if remote_url:
try:
_clone_working_repository(remote_url, repo_path, ssh_key)
repo.is_mirror = False
except Exception as exc:
await session.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
else:
# Initialize empty repo
os.makedirs(repo_path, exist_ok=True)
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
repo.is_mirror = False
await session.commit()
return repo
@router.get(
"/{project_id}/repositories",
response_model=list[GitRepositoryResponse],
@@ -335,25 +449,6 @@ async def delete_repository(
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/repositories/parse-url",
response_model=URLParseResponse,
summary="Parse a git URL",
description="Parse a git URL and detect if it's a browser URL that needs correction.",
)
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
"""Parse a git URL and detect if it's a browser URL that needs correction.
Args:
data: Request containing the URL to parse.
Returns:
Parsed URL information including whether it needs parsing and suggested corrections.
"""
result = parse_git_url(data.url)
return URLParseResponse(**result)
@router.post(
"/{project_id}/repositories",
response_model=GitRepositoryResponse,
+1 -2
View File
@@ -4,11 +4,10 @@ import time
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, status
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal
router = APIRouter()
-1
View File
@@ -2,7 +2,6 @@
import logging
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
+1 -36
View File
@@ -7,23 +7,14 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import 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.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/projects", tags=["projects"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
class ProjectCreate(BaseModel):
name: str
description: str | None = None
@@ -132,32 +123,6 @@ async def get_project(
return await _get_owned_project(project_id, user_id, session)
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
@router.patch(
"/{project_id}",
response_model=ProjectResponse,
+98
View File
@@ -0,0 +1,98 @@
"""Shared Pydantic validators for API schemas."""
MAX_FOLDER_SIZE_MB = 10
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
def validate_mount_path(v: str | None) -> str | None:
"""Validate that a mount path is absolute (starts with /).
Args:
v: Mount path string or None.
Returns:
The validated path, or None if input was None.
Raises:
ValueError: If path is not absolute.
"""
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
def validate_files(v: dict | None, max_size_bytes: int = MAX_FOLDER_SIZE_BYTES) -> dict | None:
"""Validate file dict for path traversal and size limits.
Args:
v: Dict of {path: content} or None.
max_size_bytes: Maximum total size in bytes.
Returns:
The validated dict, or None if input was None.
Raises:
ValueError: If path traversal detected or size limit exceeded.
"""
if v is None:
return v
total_size = 0
for path, content in v.items():
# Check for path traversal
if ".." in path or path.startswith("/"):
raise ValueError(f"Invalid file path: {path}")
total_size += len(content.encode("utf-8"))
if total_size > max_size_bytes:
raise ValueError(f"Total folder size exceeds {max_size_bytes // (1024 * 1024)}MB limit")
return v
def validate_env_vars(v: dict | None) -> dict | None:
"""Validate that environment variables is a JSON object.
Args:
v: Dict of env vars or None.
Returns:
The validated dict, or None if input was None.
Raises:
ValueError: If not a dict.
"""
if v is None:
return v
if not isinstance(v, dict):
raise ValueError("environment_variables must be a JSON object")
return v
def validate_volumes(v: list | None) -> list | None:
"""Validate volume mounts list.
Args:
v: List of volume dicts or None.
Returns:
The validated list, or None if input was None.
Raises:
ValueError: If not a list or missing required fields.
"""
if v is None:
return v
if not isinstance(v, list):
raise ValueError("volumes must be a JSON array")
for i, vol in enumerate(v):
if not isinstance(vol, dict):
raise ValueError(f"Volume at index {i} must be an object")
if "source" not in vol:
raise ValueError(f"Volume at index {i} must have 'source' field")
if "target" not in vol:
raise ValueError(f"Volume at index {i} must have 'target' field")
return v
+1 -10
View File
@@ -10,22 +10,13 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
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.user import User
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
import base64
+59 -18
View File
@@ -4,17 +4,25 @@ import asyncio
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_db_session
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.terminal_manager import terminal_manager
router = APIRouter()
logger = logging.getLogger(__name__)
class SessionRef:
"""Mutable reference to a terminal session, allowing updates during reset."""
def __init__(self, session):
self.session = session
@router.websocket(
"/ws/tool-instances/{instance_id}/terminal",
)
@@ -36,8 +44,9 @@ async def terminal_websocket(
Returns:
None. Communicates via WebSocket messages.
"""
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
await websocket.accept()
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
try:
# Parse instance_id
@@ -71,25 +80,39 @@ async def terminal_websocket(
await websocket.close(code=4004, reason="Instance not running")
return
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
if startup_command:
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
# Get or create terminal session
try:
session = await terminal_manager.get_or_create_session(
instance_uuid,
instance.container_id,
startup_command=startup_command,
)
logger.info("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
# Attach WebSocket to session
await terminal_manager.attach_websocket(session, websocket)
logger.info("WebSocket attached to session for instance %s", instance_id)
logger.debug("WebSocket attached to session for instance %s", instance_id)
# Send connected status
await websocket.send_json({"type": "status", "status": "connected"})
logger.debug("Sent connected status for instance %s", instance_id)
# Use mutable session reference so loops can survive reset
session_ref = SessionRef(session)
# Start I/O loops and heartbeat
read_task = asyncio.create_task(_read_loop(session, websocket))
write_task = asyncio.create_task(_write_loop(session, websocket, instance_id))
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
logger.debug("Started terminal loops for instance %s", instance_id)
# Wait for either task to complete (indicating disconnect or error)
done, pending = await asyncio.wait(
@@ -97,6 +120,8 @@ async def terminal_websocket(
return_when=asyncio.FIRST_COMPLETED,
)
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
# Cancel remaining tasks
for task in pending:
task.cancel()
@@ -109,15 +134,19 @@ async def terminal_websocket(
try:
if 'session' in locals():
await terminal_manager.detach_websocket(session, websocket)
logger.info("WebSocket detached from session for instance %s", instance_id)
logger.debug("WebSocket detached from session for instance %s", instance_id)
except Exception:
pass
async def _read_loop(session, websocket) -> None:
async def _read_loop(session_ref: SessionRef, websocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
while session.is_alive() and not session._closed:
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:
@@ -130,10 +159,14 @@ async def _read_loop(session, websocket) -> None:
pass
async def _write_loop(session, websocket, instance_id: str) -> None:
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
"""Read input from WebSocket and send to container."""
try:
while session.is_alive() and not session._closed:
while True:
session = session_ref.session
if not session.is_alive() or session._closed:
await asyncio.sleep(0.1)
continue
message = await websocket.receive()
if message["type"] == "websocket.receive":
if "bytes" in message:
@@ -150,30 +183,33 @@ async def _write_loop(session, websocket, instance_id: str) -> None:
if msg_type == "resize":
cols = ctrl.get("cols", 80)
rows = ctrl.get("rows", 24)
logger.info(f"Received resize message for instance {instance_id}: {cols}x{rows}")
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
await session.resize(cols, rows)
elif msg_type == "reset":
# Reset terminal session
logger.info("Resetting terminal session for instance %s", session.instance_id)
logger.debug("Resetting terminal session for instance %s", session.instance_id)
await websocket.send_json({"type": "status", "status": "resetting"})
# Reset the session
new_session = await terminal_manager.reset_session(
session.instance_id,
session.container_id,
startup_command=session.startup_command,
)
# Update the mutable session reference so read_loop uses the new session
session_ref.session = new_session
# Attach to new session
await terminal_manager.attach_websocket(new_session, websocket)
await websocket.send_json({"type": "status", "status": "connected"})
# Update session reference and restart loops
# Note: This will cause the current loops to exit
# The WebSocket handler will create new ones
return
# Continue the loop with the new session
continue
except json.JSONDecodeError:
pass
# Not a valid JSON control message, treat as regular input
await session.write_input(text.encode("utf-8"))
else:
await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect":
@@ -232,11 +268,16 @@ async def reset_terminal_session(
detail="Instance is not running"
)
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
try:
# Reset the session
new_session = await terminal_manager.reset_session(
instance_id,
instance.container_id,
startup_command=startup_command,
)
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
+5 -37
View File
@@ -1,6 +1,5 @@
"""Tool configuration API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
@@ -8,12 +7,11 @@ from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_config import ToolConfig
from src.models.tool_type import ToolType
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
@@ -42,27 +40,12 @@ class ToolConfigCreate(BaseModel):
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
if v is None:
return v
if not isinstance(v, dict):
raise ValueError("environment_variables must be a JSON object")
return v
return _validate_env_vars(v)
@field_validator("volumes")
@classmethod
def validate_volumes(cls, v: list | None) -> list | None:
if v is None:
return v
if not isinstance(v, list):
raise ValueError("volumes must be a JSON array")
for i, vol in enumerate(v):
if not isinstance(vol, dict):
raise ValueError(f"Volume at index {i} must be an object")
if "source" not in vol:
raise ValueError(f"Volume at index {i} must have 'source' field")
if "target" not in vol:
raise ValueError(f"Volume at index {i} must have 'target' field")
return v
return _validate_volumes(v)
class ToolConfigUpdate(BaseModel):
@@ -88,27 +71,12 @@ class ToolConfigUpdate(BaseModel):
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
if v is None:
return v
if not isinstance(v, dict):
raise ValueError("environment_variables must be a JSON object")
return v
return _validate_env_vars(v)
@field_validator("volumes")
@classmethod
def validate_volumes(cls, v: list | None) -> list | None:
if v is None:
return v
if not isinstance(v, list):
raise ValueError("volumes must be a JSON array")
for i, vol in enumerate(v):
if not isinstance(vol, dict):
raise ValueError(f"Volume at index {i} must be an object")
if "source" not in vol:
raise ValueError(f"Volume at index {i} must have 'source' field")
if "target" not in vol:
raise ValueError(f"Volume at index {i} must have 'target' field")
return v
return _validate_volumes(v)
class ToolConfigResponse(BaseModel):
File diff suppressed because it is too large Load Diff
+35 -131
View File
@@ -1,33 +1,23 @@
import re
import uuid
from datetime import datetime
import yaml
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
def _sanitize_template_vars(template: str) -> str:
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
from src.auth.dependencies import get_current_user_id, get_db_session
from src.api.tool_types_validation import (
check_port_exposed,
validate_compose_yaml,
validate_required_variables,
)
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.tool_type import ToolType
from src.models.user import User
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _require_admin(user: User) -> None:
"""Check if user has admin privileges.
@@ -49,6 +39,7 @@ class ToolTypeCreate(BaseModel):
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] = []
category: str = "other"
interface_type: str = "web"
@@ -71,24 +62,7 @@ class ToolTypeCreate(BaseModel):
if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
# Replace template variables with dummy values before YAML validation
# to avoid YAML parsing errors with {{VAR}} syntax
sanitized = _sanitize_template_vars(v)
try:
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@@ -155,29 +129,11 @@ class ToolTypeCreate(BaseModel):
# 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:
sanitized = _sanitize_template_vars(self.compose_template)
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError:
parsed = validate_compose_yaml(self.compose_template)
except ValueError:
return self
port_str = str(self.default_port)
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
for service_name, service_config in parsed["services"].items():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str):
if port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == self.default_port:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
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
@@ -192,6 +148,7 @@ class ToolTypeUpdate(BaseModel):
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] | None = None
category: str | None = None
interface_type: str | None = None
@@ -220,29 +177,13 @@ class ToolTypeUpdate(BaseModel):
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
# Replace template variables with dummy values before YAML validation
sanitized = _sanitize_template_vars(v)
try:
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
validate_compose_yaml(v)
return v
@field_validator("dockerfile_template")
@@ -278,6 +219,7 @@ class ToolTypeResponse(BaseModel):
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
@@ -324,6 +266,7 @@ async def create_tool_type(
dockerfile_template=data.dockerfile_template,
build_context=data.build_context,
readiness_probe=data.readiness_probe,
startup_command=data.startup_command,
required_variables=data.required_variables,
category=data.category,
interface_type=data.interface_type,
@@ -438,54 +381,29 @@ async def update_tool_type(
template = update_data.get("compose_template", tool_type.compose_template)
if template:
try:
sanitized = _sanitize_template_vars(template)
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError:
parsed = None
if parsed and isinstance(parsed, dict) and "services" in parsed:
port_str = str(new_port)
port_exposed = False
for service_config in parsed["services"].values():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str) and port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == new_port:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
parsed = validate_compose_yaml(template)
if not check_port_exposed(parsed, new_port):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Port {new_port} is not exposed in the compose template"
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Validate required variables for compose definitions
definition_type = update_data.get("definition_type", tool_type.definition_type)
if definition_type == "compose":
if "required_variables" in update_data and "compose_template" in update_data:
template = update_data["compose_template"]
for var in update_data["required_variables"]:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
)
validate_required_variables(
update_data["compose_template"], update_data["required_variables"]
)
elif "required_variables" in update_data:
template = tool_type.compose_template
if template:
for var in update_data["required_variables"]:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
)
validate_required_variables(template, update_data["required_variables"])
for field, value in update_data.items():
setattr(tool_type, field, value)
@@ -530,16 +448,9 @@ async def validate_tool_type_template(
errors.append("Compose template is required")
else:
try:
sanitized = _sanitize_template_vars(data.compose_template)
parsed = yaml.safe_load(sanitized)
if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed:
errors.append("Compose template must contain 'services' key")
elif not parsed["services"]:
errors.append("Compose template must define at least one service")
except yaml.YAMLError as e:
errors.append(f"Invalid YAML: {e}")
validate_compose_yaml(data.compose_template)
except ValueError as e:
errors.append(str(e))
elif data.definition_type == "dockerfile":
if not data.dockerfile_template:
@@ -588,16 +499,9 @@ async def validate_tool_type(
errors.append("Compose template is empty")
else:
try:
sanitized = _sanitize_template_vars(tool_type.compose_template)
parsed = yaml.safe_load(sanitized)
if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed:
errors.append("Compose template must contain 'services' key")
elif not parsed["services"]:
errors.append("Compose template must define at least one service")
except yaml.YAMLError as e:
errors.append(f"Invalid YAML: {e}")
validate_compose_yaml(tool_type.compose_template)
except ValueError as e:
errors.append(str(e))
elif tool_type.definition_type == "dockerfile":
if not tool_type.dockerfile_template:
+87
View File
@@ -0,0 +1,87 @@
"""Shared validation utilities for tool types."""
import re
import yaml
from fastapi import HTTPException, status
def sanitize_template_vars(template: str) -> str:
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
def validate_compose_yaml(template: str) -> dict:
"""Validate and parse a compose template.
Args:
template: Raw compose template string.
Returns:
Parsed YAML dict.
Raises:
ValueError: If YAML is invalid or missing required keys.
"""
sanitized = sanitize_template_vars(template)
try:
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
return parsed
def check_port_exposed(parsed: dict, port: int) -> bool:
"""Check if a port is exposed in a parsed compose template.
Args:
parsed: Parsed compose YAML dict.
port: Port number to check.
Returns:
True if port is exposed in any service.
"""
port_str = str(port)
if not isinstance(parsed, dict) or "services" not in parsed:
return False
for service_config in parsed["services"].values():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str) and port_str in port_mapping:
return True
elif isinstance(port_mapping, int) and port_mapping == port:
return True
return False
def validate_required_variables(template: str, variables: list[str]) -> None:
"""Validate that all required variables exist in the template.
Args:
template: Compose template string.
variables: List of required variable names.
Raises:
HTTPException: If any variable is not found in the template.
"""
for var in variables:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template",
)
+6 -15
View File
@@ -1,28 +1,19 @@
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
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_current_user_id, get_db_session
from src.models.user import User
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user_config import UserConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/users/me", tags=["user-config"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
"""Get or create user config record.
@@ -111,11 +102,11 @@ async def update_user_config(
# Merge updates
update_data = data.model_dump(exclude_unset=True)
logger.info("Updating user config for user %s: %s", user_id, update_data)
logger.debug("Updating user config for user %s: %s", user_id, update_data)
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
config.config = {**config.config, **update_data}
await session.commit()
await session.refresh(config)
logger.info("Updated config: %s", config.config)
logger.debug("Updated config: %s", config.config)
return UserConfigResponse.model_validate(config.config)
+1 -9
View File
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user import User
router = APIRouter(prefix="/users", tags=["users"])
@@ -16,14 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
+37
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.session import decode_session_cookie
from src.config import Settings
from src.database import SessionLocal
from src.models.project import Project
from src.models.user import User
@@ -47,3 +48,39 @@ async def get_current_user(
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> "Project":
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: 404 if project not found, 403 if user is not the owner.
"""
from src.models.project import Project
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
-2
View File
@@ -1,4 +1,3 @@
import json
import logging
import os
@@ -7,7 +6,6 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
+3
View File
@@ -39,6 +39,9 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
files: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"rel/path": "content", ...}
git_mounts: Mapped[list] = mapped_column(
JSON, default=list, nullable=False
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship()
+1 -1
View File
@@ -19,7 +19,7 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
name: Mapped[str] = mapped_column(String(255))
path: Mapped[str] = mapped_column(String(1024))
project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
+1
View File
@@ -30,6 +30,7 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
JSON, default=dict, nullable=True
)
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(),
+3 -3
View File
@@ -42,7 +42,7 @@ def clone_repository(
str(clone_path),
]
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
logger.debug("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
result = subprocess.run(
cmd,
capture_output=True,
@@ -55,7 +55,7 @@ def clone_repository(
logger.error("Git clone failed: %s", result.stderr)
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
logger.info("Successfully cloned repository into %s", clone_path)
logger.debug("Successfully cloned repository into %s", clone_path)
return str(clone_path)
@@ -94,4 +94,4 @@ def remove_clone_directory(instance_dir: str) -> None:
if clone_path.exists():
import shutil
shutil.rmtree(clone_path)
logger.info("Removed clone directory: %s", clone_path)
logger.debug("Removed clone directory: %s", clone_path)
@@ -48,6 +48,7 @@ class ResolvedProfile:
env_vars: dict[str, str] = field(default_factory=dict)
runtime_hints: dict[str, Any] = field(default_factory=dict)
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
git_mounts: list[dict[str, Any]] = field(default_factory=list)
files: dict[str, str] = field(default_factory=dict)
env_overrides: dict[str, str] = field(default_factory=dict)
hint_overrides: dict[str, str] = field(default_factory=dict)
@@ -168,6 +169,28 @@ def _merge_mounts(
return result
def _merge_git_mounts(
base: list[dict[str, Any]],
overlay: list[dict[str, Any]],
source_name: str,
) -> list[dict[str, Any]]:
"""Merge git mounts from included profiles.
Later mounts override earlier ones with the same remote_url + target_path combo.
"""
result = list(base)
# Build lookup by (remote_url, target_path)
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
for mount in overlay:
key = (mount["remote_url"], mount["target_path"])
if key in seen:
result[seen[key]] = dict(mount)
else:
seen[key] = len(result)
result.append(dict(mount))
return result
async def _resolve_profile_recursive(
session: AsyncSession,
profile_id: uuid.UUID,
@@ -244,6 +267,9 @@ async def _resolve_profile_recursive(
result.mount_overrides,
included.profile_name,
)
result.git_mounts = _merge_git_mounts(
result.git_mounts, included.git_mounts, included.profile_name
)
# Apply the profile's own settings (selected profile overrides includes)
result.env_vars = _merge_env_vars(
@@ -270,7 +296,11 @@ async def _resolve_profile_recursive(
result.mount_overrides,
profile.name,
)
result.git_mounts = _merge_git_mounts(
result.git_mounts,
profile.git_mounts or [],
profile.name,
)
return result
@@ -449,5 +479,6 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
"files": resolved.file_overrides,
"mounts": resolved.mount_overrides,
},
"git_mounts": resolved.git_mounts,
"included_profiles": resolved.included_profiles,
}
+71 -32
View File
@@ -1,7 +1,9 @@
"""Docker service for managing tool instances."""
import os
import re
import subprocess
import time
from pathlib import Path
from typing import Any
@@ -35,6 +37,7 @@ def ensure_instance_directory(instance_id: str, base_path: str | None = None) ->
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
@@ -87,7 +90,7 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
@@ -109,7 +112,7 @@ def execute_compose_command(
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
@@ -136,6 +139,8 @@ def execute_compose_command(
def get_container_id(instance_name: str) -> str | None:
"""Get the container ID for a compose service.
Searches all containers including stopped/exited ones.
Args:
instance_name: The service name in compose
@@ -143,7 +148,7 @@ def get_container_id(instance_name: str) -> str | None:
Container ID or None if not found
"""
result = subprocess.run(
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
capture_output=True,
text=True,
)
@@ -156,6 +161,8 @@ def get_container_id(instance_name: str) -> str | None:
def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service.
Searches all containers including stopped/exited ones.
Args:
instance_name: The service name in compose
@@ -163,7 +170,15 @@ def get_container_name(instance_name: str) -> str | None:
Container name or None if not found
"""
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
[
"docker",
"ps",
"-a",
"--format",
"{{.Names}}",
"--filter",
f"name={instance_name}",
],
capture_output=True,
text=True,
)
@@ -173,7 +188,9 @@ def get_container_name(instance_name: str) -> str | None:
return None
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
def connect_container_to_network(
container_name: str, network_name: str = "backend"
) -> bool:
"""Connect a Docker container to an existing network.
Args:
@@ -198,12 +215,14 @@ def get_container_status(container_id: str) -> dict[str, Any]:
container_id: Docker container ID
Returns:
Dict with 'status' (running, exited, restarting, not_found),
Dict with 'status' (running, exited, restarting, not_found),
'exit_code' (int or None), and 'health' (health status or None)
"""
result = subprocess.run(
[
"docker", "inspect", "-f",
"docker",
"inspect",
"-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id,
],
@@ -213,12 +232,12 @@ def get_container_status(container_id: str) -> dict[str, Any]:
if result.returncode != 0:
return {"status": "not_found", "exit_code": None, "health": None}
parts = result.stdout.strip().split("|")
status = parts[0] if parts else "unknown"
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
return {"status": status, "exit_code": exit_code, "health": health}
@@ -238,13 +257,12 @@ def wait_for_container_running(
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float)
"""
import time
start_time = time.time()
while time.time() - start_time < timeout:
info = get_container_status(container_id)
if info["status"] == "running":
return {
"success": True,
@@ -252,7 +270,7 @@ def wait_for_container_running(
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
if info["status"] == "exited":
return {
"success": False,
@@ -260,7 +278,7 @@ def wait_for_container_running(
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
if info["status"] == "not_found":
return {
"success": False,
@@ -268,9 +286,9 @@ def wait_for_container_running(
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
time.sleep(interval)
# Timeout reached
info = get_container_status(container_id)
return {
@@ -322,11 +340,6 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
raise RuntimeError(f"No free port found in range {start}-{end}")
import subprocess
import time
import re
def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30
) -> dict[str, str]:
@@ -344,8 +357,6 @@ def start_cloudflared_tunnel(
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
"""
import subprocess
import time
import re
import logging
logger = logging.getLogger(__name__)
@@ -354,18 +365,29 @@ def start_cloudflared_tunnel(
logger.info("Checking connectivity to %s:%d...", container_name, port)
for attempt in range(10):
check = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
f"http://{container_name}:{port}"],
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
f"http://{container_name}:{port}",
],
capture_output=True,
text=True,
timeout=5,
)
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
logger.info(
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
)
if check.returncode == 0:
break
time.sleep(1)
else:
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
logger.warning(
"Container %s:%d not responding to curl checks", container_name, port
)
# Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
@@ -384,6 +406,7 @@ def start_cloudflared_tunnel(
while time.time() - start_time < timeout:
# Read available output
import select
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
if readable:
line = proc.stdout.readline()
@@ -410,7 +433,6 @@ def stop_cloudflared_tunnel(pid: str) -> None:
Args:
pid: Process ID of the cloudflared tunnel
"""
import os
import signal
try:
@@ -455,14 +477,23 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", str(timeout), url],
[
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
str(timeout),
url,
],
capture_output=True,
text=True,
timeout=timeout + 5,
)
status_code = int(result.stdout.strip())
if 200 <= status_code < 400:
return {
"tunnel_status": "healthy",
@@ -495,7 +526,15 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
except (ValueError, Exception) as e:
error_str = str(e).lower()
# Classify connection errors
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
if any(
err in error_str
for err in [
"connection refused",
"econnrefused",
"could not resolve",
"nodename",
]
):
return {
"tunnel_status": "unreachable",
"status_code": None,
+4 -5
View File
@@ -18,13 +18,12 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
Returns:
Tuple of (returncode, stdout, stderr)
"""
import os
from pathlib import Path
# Write Dockerfile
dockerfile_path = Path(instance_dir) / "Dockerfile"
dockerfile_path.write_text(dockerfile)
logger.info("Wrote Dockerfile to %s", dockerfile_path)
logger.debug("Wrote Dockerfile to %s", dockerfile_path)
# Write build context files
if build_context:
@@ -39,10 +38,10 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
logger.info("Wrote build context file: %s", full_path)
logger.debug("Wrote build context file: %s", full_path)
# Build image
logger.info("Building Docker image with tag: %s", tag)
logger.debug("Building Docker image with tag: %s", tag)
cmd = [
"docker", "build",
"-t", tag,
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
text=True,
timeout=300, # 5 minute timeout for builds
)
logger.info("Docker build completed: returncode=%d", result.returncode)
logger.debug("Docker build completed: returncode=%d", result.returncode)
if result.returncode != 0:
logger.error("Docker build failed: %s", result.stderr[:1000])
return result.returncode, result.stdout, result.stderr
+10 -9
View File
@@ -3,7 +3,6 @@
import asyncio
import logging
import uuid
from typing import Any
from fastapi import WebSocket
@@ -58,6 +57,7 @@ class TerminalManager:
self,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
) -> TerminalSession:
"""Get existing session or create a new one."""
# Ensure idle check is running (lazy start)
@@ -71,19 +71,19 @@ class TerminalManager:
# Check if session is still alive
if session.is_alive():
logger.info("Reattaching to existing terminal session for instance %s", instance_id)
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
return session
else:
# Session died, clean it up
logger.info("Existing session for instance %s is dead, cleaning up", instance_id)
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
await session.close()
del self._sessions[instance_id_str]
# Create new session
logger.info("Creating new terminal session for instance %s", instance_id)
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id)
await session.start()
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session
return session
@@ -96,7 +96,7 @@ class TerminalManager:
"""Attach a WebSocket to an existing session."""
# Handle concurrent connections - close existing ones
if session.has_websockets():
logger.info("Closing existing WebSocket connections for instance %s", session.instance_id)
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
for ws in list(session._websockets):
try:
await ws.close(code=4000, reason="New connection established")
@@ -127,20 +127,21 @@ class TerminalManager:
self,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
) -> TerminalSession:
"""Reset a session by killing it and creating a new one."""
instance_id_str = str(instance_id)
# Close existing session if any
if instance_id_str in self._sessions:
logger.info("Resetting terminal session for instance %s", instance_id)
logger.debug("Resetting terminal session for instance %s", instance_id)
old_session = self._sessions.pop(instance_id_str)
await old_session.close()
# Create new session
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id)
await session.start()
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session
return session
+29 -16
View File
@@ -5,6 +5,7 @@ import logging
import os
import pty
import select
import signal
import struct
import fcntl
import time
@@ -28,10 +29,11 @@ class TerminalSession:
# Idle timeout in seconds (30 minutes)
IDLE_TIMEOUT = 30 * 60
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command: str | None = None) -> None:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
self.startup_command = startup_command
self.process: asyncio.subprocess.Process | None = None
self._closed = False
self._master_fd: int | None = None
@@ -51,14 +53,21 @@ class TerminalSession:
self._cols = 80
self._rows = 24
async def start(self) -> None:
async def start(self, startup_command: str | None = None) -> None:
"""Start the docker exec process with a shell using a PTY."""
# Create a pseudo-terminal on the host
self._master_fd, self._slave_fd = pty.openpty()
# Set the terminal size initially
self._set_terminal_size(self._cols, self._rows)
logger.info(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
# Build the shell command
if startup_command:
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
else:
shell_cmd = "bash -il"
# Start docker exec with the slave fd as stdin/stdout/stderr
# Using -it because the slave fd IS a TTY
@@ -70,7 +79,8 @@ class TerminalSession:
"TERM=xterm",
self.container_id,
"bash",
"-il",
"-c",
shell_cmd,
stdin=self._slave_fd,
stdout=self._slave_fd,
stderr=self._slave_fd,
@@ -92,7 +102,7 @@ class TerminalSession:
size = struct.pack('HHHH', rows, cols, 0, 0)
try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
logger.info(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
except (OSError, IOError) as e:
logger.error(f"Failed to resize PTY: {e}")
@@ -149,19 +159,22 @@ class TerminalSession:
self._cols = cols
self._rows = rows
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
self._set_terminal_size(cols, rows)
# Docker exec doesn't forward PTY resize to the container process,
# so we need to explicitly set the size inside the container shell.
# Send on every resize so the container shell always matches the frontend.
# Use stty -echo to prevent the command from being visible, then clear the line.
stty_cmd = (
f"stty -echo; stty cols {cols} rows {rows}; stty echo\n"
f"\x1b[A\x1b[M" # Move up 1 line and delete it (clears the stty command)
).encode()
await self.write_input(stty_cmd)
logger.debug(f"Sent stty resize to container for session {self.session_id}: {cols}x{rows}")
# Docker exec -it creates its own PTY inside the container,
# so host PTY resize doesn't propagate to the container shell.
# Send SIGWINCH to the docker exec process on the host.
# Docker exec forwards signals to the container process, which should
# cause the container's shell to re-read its terminal size.
if self.process and self.process.pid:
try:
os.kill(self.process.pid, signal.SIGWINCH)
logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}")
except ProcessLookupError:
logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}")
except Exception as e:
logger.warning(f"Failed to send SIGWINCH: {e}")
async def reset(self) -> None:
"""Reset the session by killing the process and clearing state."""
-1
View File
@@ -2,7 +2,6 @@
import subprocess
from dataclasses import dataclass, field
from typing import Any
def _run_git_command(repo_path: str, *args: str) -> str:
+60 -3
View File
@@ -8,16 +8,14 @@ from unittest.mock import patch
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
# Set test environment BEFORE importing app modules
os.environ["APP_ENV"] = "testing"
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
from src.config import Settings, build_database_url
from src.config import Settings
from src.models.base import Base
from src.main import app
from src.auth.dependencies import get_db_session
@@ -133,6 +131,65 @@ def authenticated_client(test_client) -> Generator[TestClient, None, None]:
yield test_client
@pytest.fixture
def test_project_and_repo(authenticated_client) -> tuple[str, str]:
"""Create a project and repository directly in the database."""
import uuid
from src.models.project import Project
from src.models.git_repository import GitRepository
project_id = uuid.uuid4()
repo_id = uuid.uuid4()
user_id = None
# Get user ID from session
async def get_user_id():
nonlocal user_id
from src.auth.session import decode_session_cookie
settings = Settings()
session_cookie = authenticated_client.cookies.get("session")
if session_cookie:
session = decode_session_cookie(settings=settings, cookie_value=session_cookie)
if session:
user_id = uuid.UUID(session["user_id"])
asyncio.run(get_user_id())
if not user_id:
raise RuntimeError("Could not get user ID from authenticated client")
async def create_project_and_repo():
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
project = Project(
id=project_id,
name="test-project",
description="Test project",
owner_id=user_id,
)
session.add(project)
repo = GitRepository(
id=repo_id,
name="test-repo",
path="/tmp/test-repo",
project_id=project_id,
owner_id=user_id,
remote_url="https://github.com/test/repo.git",
)
session.add(repo)
await session.commit()
finally:
await gen.aclose()
asyncio.run(create_project_and_repo())
return str(project_id), str(repo_id)
@pytest.fixture
def admin_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with an admin user."""
@@ -320,3 +320,134 @@ class TestConfigProfilesAPI:
assert response.status_code == 200
data = response.json()
assert data["profile_id"] is None
def test_create_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test creating a config profile with git mounts."""
_project_id, repo_id = test_project_and_repo
response = authenticated_client.post(
"/config-profiles",
json={
"name": "git-mount-profile",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "/app",
"branch": "main",
}
],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "git-mount-profile"
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["target_path"] == "/app"
assert data["git_mounts"][0]["branch"] == "main"
def test_update_config_profile_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test updating git mounts on a config profile."""
_project_id, repo_id = test_project_and_repo
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "update-git-mounts",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Update with git mounts
response = authenticated_client.put(
f"/config-profiles/{profile_id}",
json={
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": "config",
"target_path": "/config",
}
],
},
)
assert response.status_code == 200
data = response.json()
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["source_path"] == "config"
def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test that invalid git mount source paths are rejected."""
_project_id, repo_id = test_project_and_repo
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-git-mount",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": "/absolute/path",
"target_path": "/app",
}
],
},
)
assert response.status_code == 422
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test that git mount target paths with traversal are rejected."""
_project_id, repo_id = test_project_and_repo
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-git-mount-target",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "../../../etc/passwd",
}
],
},
)
assert response.status_code == 422
def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test previewing a profile with git mounts."""
_project_id, repo_id = test_project_and_repo
# Create profile with git mounts
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "preview-git-mounts",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "/app",
}
],
},
)
profile_id = create_response.json()["id"]
# Preview
response = authenticated_client.get(f"/config-profiles/{profile_id}/preview")
assert response.status_code == 200
data = response.json()
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
-22
View File
@@ -82,28 +82,6 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
assert UserConfig.user.property.mapper.class_ is User
@pytest.mark.integration
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
columns = RefreshToken.__table__.columns
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
assert set(columns.keys()) == {
"id",
"user_id",
"token_hash",
"expires_at",
"revoked_at",
"user_agent",
"ip_address",
"created_at",
}
assert columns["token_hash"].unique is True
assert columns["revoked_at"].nullable is True
assert user_fk.target_fullname == "users.id"
assert RefreshToken.user.property.mapper.class_ is User
@pytest.mark.asyncio
@pytest.mark.integration
@@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
@@ -1,4 +1,3 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@@ -1,4 +1,3 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@@ -221,5 +220,79 @@ class TestToolTypesAPIExtended:
},
)
assert response.status_code == 422
_ = response.json()
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
"""Test creating a tool type with startup_command."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "startup-tool",
"display_name": "Startup Tool",
"category": "utility",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"startup_command": "cd /workspace && ls",
"required_variables": [],
},
)
assert response.status_code == 201
data = response.json()
assert data["startup_command"] == "cd /workspace && ls"
assert data["interface_type"] == "terminal"
def test_update_tool_type_startup_command(self, authenticated_client: TestClient) -> None:
"""Test updating a tool type's startup_command."""
# Create tool type first
create_response = authenticated_client.post(
"/tool-types",
json={
"name": "update-startup-tool",
"display_name": "Update Startup Tool",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"required_variables": [],
},
)
tool_id = create_response.json()["id"]
# Update with startup_command
response = authenticated_client.put(
f"/tool-types/{tool_id}",
json={
"startup_command": "source /etc/profile",
},
)
assert response.status_code == 200
data = response.json()
assert data["startup_command"] == "source /etc/profile"
def test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) -> None:
"""Test that GET returns startup_command."""
create_response = authenticated_client.post(
"/tool-types",
json={
"name": "get-startup-tool",
"display_name": "Get Startup Tool",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"startup_command": "echo hello",
"required_variables": [],
},
)
tool_id = create_response.json()["id"]
response = authenticated_client.get(f"/tool-types/{tool_id}")
assert response.status_code == 200
data = response.json()
assert data["startup_command"] == "echo hello"
assert "Port 9999 is not exposed" in str(data)
@@ -6,13 +6,13 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.services.config_profile_resolver import (
ConfigProfileCycleError,
ConfigProfileNotFoundError,
ResolvedProfile,
check_include_cycle,
resolve_profile,
_merge_env_vars,
_merge_files,
_merge_mounts,
_merge_runtime_hints,
_merge_git_mounts,
)
@@ -62,7 +62,6 @@ class TestMergeFunctions:
def test_merge_mounts_basic(self) -> None:
"""Test basic mount merging."""
from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts(
{},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
@@ -97,6 +96,39 @@ class TestMergeFunctions:
assert result["/app"].mode == "ro"
assert overrides == {"/app": "source"}
def test_merge_git_mounts_basic(self) -> None:
"""Test basic git mount merging."""
result = _merge_git_mounts(
[],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
"source",
)
assert len(result) == 1
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
assert result[0]["target_path"] == "/app"
def test_merge_git_mounts_override_same_repo_target(self) -> None:
"""Test that git mounts with same repo+target override."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
"source",
)
assert len(result) == 1
assert result[0]["source_path"] == "src"
assert result[0]["branch"] == "dev"
def test_merge_git_mounts_different_targets(self) -> None:
"""Test that git mounts with different targets are preserved."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
"source",
)
assert len(result) == 2
targets = {m["target_path"] for m in result}
assert targets == {"/app", "/config"}
class TestResolveProfile:
"""Unit tests for profile resolution."""
@@ -250,6 +282,76 @@ class TestResolveProfile:
with pytest.raises(ConfigProfileCycleError):
await resolve_profile(db_session, profile_a.id)
@pytest.mark.asyncio
async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None:
"""Test resolving a profile with git mounts."""
user_id = uuid.uuid4()
profile = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="with-git-mounts",
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
],
)
db_session.add(profile)
await db_session.commit()
result = await resolve_profile(db_session, profile.id)
assert len(result.git_mounts) == 1
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
assert result.git_mounts[0]["target_path"] == "/app"
@pytest.mark.asyncio
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
"""Test resolving a profile that includes another with git mounts."""
user_id = uuid.uuid4()
# Create base profile with git mount
base = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="base",
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
],
)
db_session.add(base)
# Create child profile with its own git mount
child = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="child",
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
],
)
db_session.add(child)
await db_session.commit()
# Create include relationship
include = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=child.id,
included_profile_id=base.id,
order_index=0,
)
db_session.add(include)
await db_session.commit()
result = await resolve_profile(db_session, child.id)
assert len(result.git_mounts) == 2
targets = {m["target_path"] for m in result.git_mounts}
assert targets == {"/app", "/config"}
@pytest.mark.asyncio
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
"""Test resolving a non-existent profile."""
@@ -0,0 +1,124 @@
"""Unit tests for git mount resolution in tool instances."""
import os
from pathlib import Path
import pytest
from src.api.tool_instances import (
_checkout_branch,
_expand_glob_source,
_resolve_single_git_mount,
)
class TestExpandGlobSource:
"""Unit tests for glob pattern expansion."""
def test_no_glob_single_file(self, tmp_path: Path) -> None:
"""Test non-glob path returns single file."""
test_file = tmp_path / "test.txt"
test_file.write_text("content")
result = _expand_glob_source(str(test_file), str(tmp_path))
assert len(result) == 1
assert result[0] == str(test_file)
def test_no_glob_missing_file(self, tmp_path: Path) -> None:
"""Test non-glob missing file returns empty list."""
missing_file = tmp_path / "missing.txt"
result = _expand_glob_source(str(missing_file), str(tmp_path))
assert len(result) == 0
def test_glob_pattern(self, tmp_path: Path) -> None:
"""Test glob pattern matches files."""
(tmp_path / "file1.txt").write_text("content1")
(tmp_path / "file2.txt").write_text("content2")
(tmp_path / "other.py").write_text("code")
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
assert len(result) == 2
assert all(f.endswith(".txt") for f in result)
def test_glob_recursive(self, tmp_path: Path) -> None:
"""Test recursive glob pattern."""
subdir = tmp_path / "subdir"
subdir.mkdir()
(subdir / "nested.txt").write_text("content")
result = _expand_glob_source(str(tmp_path / "**" / "*.txt"), str(tmp_path))
assert len(result) == 1
assert "nested.txt" in result[0]
def test_glob_limit_enforced(self, tmp_path: Path) -> None:
"""Test that glob matches are limited to prevent abuse."""
# Create more than 100 files
for i in range(105):
(tmp_path / f"file{i}.txt").write_text("content")
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
assert len(result) == 100 # MAX_GLOB_MATCHES limit
def test_glob_escapes_repo(self, tmp_path: Path) -> None:
"""Test that glob results outside repo are filtered."""
other_dir = tmp_path.parent / "other"
other_dir.mkdir(exist_ok=True)
(other_dir / "outside.txt").write_text("content")
result = _expand_glob_source(str(tmp_path.parent / "*" / "*.txt"), str(tmp_path))
# Should only include files within tmp_path, not other_dir
assert all(r.startswith(str(tmp_path)) for r in result)
class TestCheckoutBranch:
"""Unit tests for branch checkout."""
def test_checkout_existing_branch(self, tmp_path: Path) -> None:
"""Test checking out an existing branch."""
# Initialize git repo
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
(tmp_path / "file.txt").write_text("content")
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
os.system(f"cd {tmp_path} && git branch feature")
_checkout_branch(str(tmp_path), "feature")
# Verify we're on feature branch
result = os.popen(f"cd {tmp_path} && git branch --show-current").read().strip()
assert result == "feature"
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
"""Test checking out a non-existent branch returns False."""
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
(tmp_path / "file.txt").write_text("content")
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
result = _checkout_branch(str(tmp_path), "nonexistent")
assert result is False
class TestResolveSingleGitMount:
"""Unit tests for resolving a single git mount."""
@pytest.mark.asyncio
async def test_resolve_missing_remote_url(self, db_session) -> None:
"""Test that missing remote_url returns empty list."""
git_mount = {
"source_path": ".",
"target_path": "/app",
}
result = await _resolve_single_git_mount(db_session, git_mount)
assert result == []
@pytest.mark.asyncio
async def test_resolve_missing_target_path(self, db_session) -> None:
"""Test that missing target path returns empty list."""
git_mount = {
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
}
result = await _resolve_single_git_mount(db_session, git_mount)
assert result == []
@@ -1,6 +1,5 @@
"""Tests for git URL parsing utilities."""
import pytest
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
@@ -1,9 +1,7 @@
"""Unit tests for readiness probe service."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from src.services.readiness_probe import execute_probe
@@ -3,10 +3,7 @@
import os
import subprocess
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from src.api.tool_instances import CreateInstanceRequest
+512 -69
View File
@@ -896,6 +896,24 @@
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -913,6 +931,24 @@
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -930,6 +966,24 @@
"node": ">=12"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -1390,9 +1444,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1410,9 +1461,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1430,9 +1478,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1450,9 +1495,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1470,9 +1512,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1490,9 +1529,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1671,9 +1707,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1688,9 +1721,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1705,9 +1735,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1722,9 +1749,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1739,9 +1763,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1756,9 +1777,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1773,9 +1791,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1790,9 +1805,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1807,9 +1819,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1824,9 +1833,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1841,9 +1847,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1858,9 +1861,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1875,9 +1875,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4360,9 +4357,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4384,9 +4378,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4408,9 +4399,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4432,9 +4420,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6122,6 +6107,420 @@
}
}
},
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@vitest/mocker": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
@@ -6149,6 +6548,50 @@
}
}
},
"node_modules/vitest/node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+27 -2
View File
@@ -1,4 +1,4 @@
import axios from "axios";
import axios, { type AxiosRequestConfig } from "axios";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
@@ -14,13 +14,38 @@ export const shouldSkipAuthRedirect = (path: string): boolean => {
return path.startsWith("/login") || path.startsWith("/auth");
};
// Retry config for transient network errors
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 1000;
// Track retry count per request
const retryCount = new WeakMap<AxiosRequestConfig, number>();
apiClient.interceptors.response.use(
(response) => response,
(error) => {
async (error) => {
const status = error?.response?.status;
if (status === 401 && !shouldSkipAuthRedirect(window.location.pathname)) {
window.location.assign(`${BASE_URL}/auth/login`);
return Promise.reject(error);
}
// Retry on transient network errors (ERR_NETWORK_CHANGED, etc.)
const isNetworkError = !error.response && error.message?.includes("Network");
const isRetryable = isNetworkError || status >= 502; // 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
if (isRetryable) {
const config = error.config;
const currentRetry = retryCount.get(config) || 0;
if (currentRetry < MAX_RETRIES) {
retryCount.set(config, currentRetry + 1);
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * (currentRetry + 1)));
return apiClient(config);
}
}
return Promise.reject(error);
}
);
+11
View File
@@ -10,6 +10,7 @@ export interface ConfigProfile {
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ConfigProfileMount[];
git_mounts: GitMount[];
files: Record<string, string>;
is_default: boolean;
includes: ConfigProfileInclude[];
@@ -23,6 +24,13 @@ export interface ConfigProfileMount {
files: Record<string, string>;
}
export interface GitMount {
remote_url: string;
source_path: string;
target_path: string;
branch?: string;
}
export interface ConfigProfileInclude {
id: string;
included_profile_id: string;
@@ -35,6 +43,7 @@ export interface ResolvedProfile {
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ResolvedMount[];
git_mounts: GitMount[];
files: Record<string, string>;
overrides: {
env_vars: Record<string, string>;
@@ -60,6 +69,7 @@ export interface CreateConfigProfileRequest {
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
is_default?: boolean;
}
@@ -72,6 +82,7 @@ export interface UpdateConfigProfileRequest {
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
is_default?: boolean;
}
+22 -3
View File
@@ -31,12 +31,24 @@ export interface URLParseResult {
}
export async function parseGitUrl(url: string): Promise<URLParseResult> {
const response = await apiClient.post("/projects/repositories/parse-url", { url });
const response = await apiClient.post("/repositories/parse-url", { url });
return response.data;
}
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
const response = await apiClient.get(`/projects/${projectId}/repositories`);
export async function listRepositories(projectId?: string): Promise<GitRepository[]> {
if (projectId) {
const response = await apiClient.get<GitRepository[]>(
`/projects/${projectId}/repositories`
);
return response.data;
}
// List all user repositories (including external)
const response = await apiClient.get<GitRepository[]>("/repositories");
return response.data;
}
export async function listAllUserRepositories(): Promise<GitRepository[]> {
const response = await apiClient.get<GitRepository[]>("/repositories");
return response.data;
}
@@ -48,6 +60,13 @@ export async function createRepository(
return response.data;
}
export async function createExternalRepository(
data: GitRepositoryCreate
): Promise<GitRepository> {
const response = await apiClient.post<GitRepository>("/repositories", data);
return response.data;
}
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
}
+7 -4
View File
@@ -1,3 +1,4 @@
import { AxiosError } from "axios";
import { apiClient } from "./client";
export interface ToolInstance {
@@ -80,9 +81,10 @@ export async function startInstance(
{ config_profile_id: configProfileId }
);
return response.data;
} catch (error: any) {
} catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces)
if (retries > 0 && !error.response) {
const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500));
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
@@ -114,9 +116,10 @@ export async function restartInstance(
{ config_profile_id: configProfileId }
);
return response.data;
} catch (error: any) {
} catch (error) {
// Retry on network errors (e.g. Docker creating network interfaces)
if (retries > 0 && !error.response) {
const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500));
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
+3
View File
@@ -20,6 +20,7 @@ export interface ToolType {
dockerfile_template: string | null;
build_context: Record<string, string> | null;
readiness_probe: ReadinessProbe | null;
startup_command: string | null;
required_variables: string[];
created_by_id: string | null;
created_at: string;
@@ -39,6 +40,7 @@ export interface CreateToolTypeRequest {
dockerfile_template?: string;
build_context?: Record<string, string>;
readiness_probe?: ReadinessProbe;
startup_command?: string;
required_variables: string[];
}
@@ -54,6 +56,7 @@ export interface UpdateToolTypeRequest {
dockerfile_template?: string;
build_context?: Record<string, string>;
readiness_probe?: ReadinessProbe;
startup_command?: string;
required_variables?: string[];
}
+39 -56
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect } from "react";
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { getUserSessions } from "../api/sessions";
@@ -8,6 +8,7 @@ import { useAuth } from "../state/auth";
import { useSessions } from "../state/sessions";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { Icon } from "./icon";
import { MobileNav } from "./mobile-nav";
import type { IconName } from "../utils/icons";
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
@@ -43,8 +44,6 @@ export const AppShell = () => {
const { sessions, setAllSessions } = useSessions();
const location = useLocation();
const isMobile = useMobileViewport();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
const loadSessions = useCallback(async () => {
@@ -58,18 +57,13 @@ export const AppShell = () => {
useEffect(() => {
void loadSessions();
// Poll every 10 seconds
// Poll every 30 seconds (reduced from 10s to avoid ERR_NETWORK_CHANGED from Docker network changes)
const interval = setInterval(() => {
void loadSessions();
}, 10000);
}, 30000);
return () => clearInterval(interval);
}, [loadSessions]);
// Close mobile menu on route change
useEffect(() => {
setMobileMenuOpen(false);
}, [location.pathname]);
if (isMobileTerminal) {
return (
<div className="shell mobile-terminal-shell">
@@ -102,57 +96,46 @@ export const AppShell = () => {
</header>
<div className="shell-body">
<aside className={`shell-nav ${mobileMenuOpen ? "mobile-open" : ""}`} aria-label="Primary navigation">
{isMobile && (
<button
className="mobile-menu-close"
onClick={() => setMobileMenuOpen(false)}
type="button"
aria-label="Close menu"
>
<Icon name="close" size="sm" />
</button>
)}
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
{isMobile && mobileMenuOpen && (
<div
className="mobile-menu-overlay"
onClick={() => setMobileMenuOpen(false)}
/>
{!isMobile && (
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
)}
<main className="shell-content">
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
<Outlet />
</main>
</div>
{isMobile && (
<MobileNav sessionCount={sessions.filter((s) => s.status === "running").length} />
)}
</div>
);
};
+34
View File
@@ -0,0 +1,34 @@
import { Icon } from "./icon";
interface LoadingStateProps {
message?: string;
}
export const LoadingState = ({ message = "Loading..." }: LoadingStateProps) => (
<p className="muted">{message}</p>
);
interface ErrorStateProps {
message?: string;
onRetry?: () => void;
}
export const ErrorState = ({ message = "Failed to load", onRetry }: ErrorStateProps) => (
<div className="card stack">
<p>{message}</p>
{onRetry && (
<button className="secondary-button" onClick={onRetry} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
)}
</div>
);
interface EmptyStateProps {
message: string;
}
export const EmptyState = ({ message }: EmptyStateProps) => (
<p className="muted">{message}</p>
);
@@ -0,0 +1,226 @@
import { useState } from "react";
import { Icon } from "./icon";
import type { GitMount } from "../api/config_profiles";
interface GitMountEditorProps {
mounts: GitMount[];
onChange: (mounts: GitMount[]) => void;
}
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [newMount, setNewMount] = useState<GitMount>({
remote_url: "",
source_path: ".",
target_path: "",
branch: "",
});
const handleAdd = (mount: GitMount) => {
onChange([...mounts, mount]);
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
};
const handleUpdate = (index: number, updated: GitMount) => {
const updatedMounts = [...mounts];
updatedMounts[index] = updated;
onChange(updatedMounts);
setEditingIndex(null);
};
const handleRemove = (index: number) => {
onChange(mounts.filter((_, i) => i !== index));
};
const validatePath = (path: string, isTarget: boolean): string | null => {
if (!path) return isTarget ? "Target path is required" : null;
if (path.includes("..")) return "Path cannot contain ..";
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
return null;
};
const validateUrl = (url: string): string | null => {
if (!url) return "Git URL is required";
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
return "Must be a valid git URL (https://, git@, or ssh://)";
}
return null;
};
return (
<div className="git-mount-editor">
<h4 className="section-subtitle">Git Mounts</h4>
{mounts.length > 0 && (
<div className="git-mount-list">
{mounts.map((mount, index) => (
<div key={index} className="git-mount-item">
{editingIndex === index ? (
<GitMountForm
mount={mount}
onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)}
validatePath={validatePath}
validateUrl={validateUrl}
/>
) : (
<div className="git-mount-display">
<div className="git-mount-info">
<span className="git-mount-repo">{mount.remote_url}</span>
<span className="git-mount-paths">
{mount.source_path || "."} {mount.target_path}
</span>
{mount.branch && (
<span className="git-mount-branch">@{mount.branch}</span>
)}
</div>
<div className="git-mount-actions">
<button
type="button"
className="icon-button"
onClick={() => setEditingIndex(index)}
title="Edit"
>
<Icon name="edit" size="sm" />
</button>
<button
type="button"
className="icon-button danger"
onClick={() => handleRemove(index)}
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
<div className="git-mount-add">
<h5>Add Git Mount</h5>
<GitMountForm
mount={newMount}
onSave={handleAdd}
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
validatePath={validatePath}
validateUrl={validateUrl}
isNew
/>
</div>
</div>
);
};
interface GitMountFormProps {
mount: GitMount;
onSave: (mount: GitMount) => void;
onCancel: () => void;
validatePath: (path: string, isTarget: boolean) => string | null;
validateUrl: (url: string) => string | null;
isNew?: boolean;
}
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
const [form, setForm] = useState<GitMount>({ ...mount });
const [errors, setErrors] = useState<Record<string, string>>({});
const handleChange = (field: keyof GitMount, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
const handleSubmit = () => {
const newErrors: Record<string, string> = {};
const urlError = validateUrl(form.remote_url);
if (urlError) newErrors.remote_url = urlError;
const sourceError = validatePath(form.source_path || ".", false);
if (sourceError) newErrors.source_path = sourceError;
const targetError = validatePath(form.target_path, true);
if (targetError) newErrors.target_path = targetError;
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSave(form);
if (isNew) {
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
}
};
return (
<div className="git-mount-form">
<div className="form-row">
<label>Git URL</label>
<input
type="text"
value={form.remote_url}
onChange={(e) => handleChange("remote_url", e.target.value)}
placeholder="https://github.com/user/repo.git"
className={errors.remote_url ? "error" : ""}
/>
<span className="hint">Repository URL (HTTPS or SSH)</span>
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
</div>
<div className="form-row">
<label>Source Path</label>
<input
type="text"
value={form.source_path || "."}
onChange={(e) => handleChange("source_path", e.target.value)}
placeholder="e.g., . or configs/*.json"
className={errors.source_path ? "error" : ""}
/>
<span className="hint">Relative path in repo (supports glob patterns)</span>
{errors.source_path && <span className="error-text">{errors.source_path}</span>}
</div>
<div className="form-row">
<label>Target Path</label>
<input
type="text"
value={form.target_path}
onChange={(e) => handleChange("target_path", e.target.value)}
placeholder="e.g., /app/config"
className={errors.target_path ? "error" : ""}
/>
<span className="hint">Use absolute path (e.g. /app/config). Relative paths need working_directory set in tool config.</span>
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
</div>
<div className="form-row">
<label>Branch (optional)</label>
<input
type="text"
value={form.branch || ""}
onChange={(e) => handleChange("branch", e.target.value)}
placeholder="e.g., main or v1.0"
/>
<span className="hint">Branch or tag to checkout</span>
</div>
<div className="form-actions">
<button type="button" className="primary-button" onClick={handleSubmit}>
{isNew ? "Add" : "Save"}
</button>
<button type="button" className="secondary-button" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);
};
+4 -2
View File
@@ -34,6 +34,7 @@ import {
Stop,
Terminal,
ArrowLeft,
DotsSixVertical,
} from "@phosphor-icons/react";
export type IconName =
@@ -75,7 +76,8 @@ export type IconName =
| "play"
| "stop"
| "terminal"
| "arrow-left";
| "arrow-left"
| "drag";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
@@ -117,6 +119,7 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
drag: DotsSixVertical,
};
export interface IconProps {
@@ -147,7 +150,6 @@ export const Icon: React.FC<IconProps> = ({
const sizeValue = sizeMap[size];
if (!IconComponent) {
console.warn(`Icon "${name}" not found`);
return null;
}
+36 -1
View File
@@ -43,6 +43,9 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<string | null>(null);
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
// Per-instance busy state for actions
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
const loadInstances = useCallback(async () => {
setLoading(true);
try {
@@ -104,6 +107,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
}, [projectId]);
const handleStart = async (instanceId: string, configProfileId?: string) => {
setBusyInstanceId(instanceId);
try {
await startInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
@@ -111,20 +115,26 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
await loadInstances();
} catch {
setError("Failed to start instance");
} finally {
setBusyInstanceId(null);
}
};
const handleStop = async (instanceId: string) => {
setBusyInstanceId(instanceId);
try {
await stopInstance(projectId, repoId, instanceId);
setStopConfirmId(null);
await loadInstances();
} catch {
setError("Failed to stop instance");
} finally {
setBusyInstanceId(null);
}
};
const handleRestart = async (instanceId: string, configProfileId?: string) => {
setBusyInstanceId(instanceId);
try {
await restartInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
@@ -132,26 +142,34 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
await loadInstances();
} catch {
setError("Failed to restart instance");
} finally {
setBusyInstanceId(null);
}
};
const handleDelete = async (instanceId: string) => {
if (!confirm("Are you sure you want to delete this instance?")) return;
setBusyInstanceId(instanceId);
try {
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances(prev => prev.filter(i => i.id !== instanceId));
} catch {
setError("Failed to delete instance");
} finally {
setBusyInstanceId(null);
}
};
const handleRecreateTunnel = async (instanceId: string) => {
setBusyInstanceId(instanceId);
try {
await recreateInstanceTunnel(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to recreate tunnel");
} finally {
setBusyInstanceId(null);
}
};
@@ -202,7 +220,12 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
) : (
<div className="instance-grid">
{instances.map((instance) => (
<div key={instance.id} className="instance-card">
<div key={instance.id} className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}>
{busyInstanceId === instance.id && (
<div className="instance-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div className="instance-info">
<div className="instance-name">{instance.display_name}</div>
<div className="instance-meta">
@@ -244,6 +267,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
onClick={() => void handleRecreateTunnel(instance.id)}
type="button"
title="Recreate tunnel"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Fix Tunnel
@@ -256,6 +280,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="terminal" size="sm" />
Terminal
@@ -280,6 +305,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="primary-button small"
onClick={() => void handleStart(instance.id, selectedProfileForAction || undefined)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
@@ -291,6 +317,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
setSelectedProfileForAction("");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
@@ -307,6 +334,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
setSelectedProfileForAction(instance.selected_config_profile_id || "");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
@@ -323,6 +351,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
Yes
</button>
@@ -330,6 +359,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
disabled={busyInstanceId === instance.id}
>
No
</button>
@@ -339,6 +369,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="stop" size="sm" />
</button>
@@ -360,6 +391,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="primary-button small"
onClick={() => void handleRestart(instance.id, selectedProfileForAction || undefined)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Restart
@@ -371,6 +403,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
setSelectedProfileForAction("");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
@@ -387,6 +420,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
setSelectedProfileForAction(instance.selected_config_profile_id || "");
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
</button>
@@ -397,6 +431,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="delete" size="sm" />
</button>
@@ -0,0 +1,88 @@
import { useEffect, useRef } from "react";
import { Icon } from "./icon";
import type { IconName } from "./icon";
export interface MobileActionSheetItem {
id: string;
label: string;
icon?: IconName;
variant?: "default" | "danger";
onClick: () => void;
}
interface MobileActionSheetProps {
isOpen: boolean;
onClose: () => void;
title: string;
actions: MobileActionSheetItem[];
}
export function MobileActionSheet({
isOpen,
onClose,
title,
actions,
}: MobileActionSheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="mobile-action-sheet-overlay" onClick={onClose}>
<div
ref={sheetRef}
className="mobile-action-sheet"
onClick={(e) => e.stopPropagation()}
>
<div className="mobile-action-sheet-header">
<div className="mobile-action-sheet-handle" />
<h3>{title}</h3>
</div>
<div className="mobile-action-sheet-actions">
{actions.map((action) => (
<button
key={action.id}
className={`mobile-action-sheet-button ${action.variant || "default"}`}
onClick={() => {
action.onClick();
onClose();
}}
type="button"
>
{action.icon && <Icon name={action.icon} size="md" />}
<span>{action.label}</span>
</button>
))}
</div>
<button
className="mobile-action-sheet-cancel"
onClick={onClose}
type="button"
>
Cancel
</button>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
import { Icon } from "./icon";
interface Field {
label: string;
value: string | number | boolean | null;
type?: "text" | "code" | "json" | "boolean";
}
interface MobileDetailViewProps {
title: string;
subtitle?: string;
fields: Field[];
onEdit: () => void;
onDelete: () => void;
onBack: () => void;
}
export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
title,
subtitle,
fields,
onEdit,
onDelete,
onBack,
}) => {
const renderValue = (field: Field) => {
if (field.value === null || field.value === undefined) {
return <span className="text-muted">Not set</span>;
}
if (field.type === "boolean") {
return field.value ? (
<span className="badge badge-success">Yes</span>
) : (
<span className="badge badge-secondary">No</span>
);
}
if (field.type === "code" || field.type === "json") {
return (
<pre className="mobile-detail-code">
{typeof field.value === "string" ? field.value : JSON.stringify(field.value, null, 2)}
</pre>
);
}
return <span>{String(field.value)}</span>;
};
return (
<div className="mobile-detail-view">
<header className="mobile-detail-header">
<button
className="mobile-detail-back"
onClick={onBack}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="md" />
</button>
<div className="mobile-detail-header-content">
<h1 className="mobile-detail-title">{title}</h1>
{subtitle && <p className="mobile-detail-subtitle">{subtitle}</p>}
</div>
<div className="mobile-detail-actions">
<button
className="mobile-detail-action"
onClick={onEdit}
type="button"
aria-label="Edit"
>
<Icon name="edit" size="sm" />
</button>
<button
className="mobile-detail-action mobile-detail-action-danger"
onClick={onDelete}
type="button"
aria-label="Delete"
>
<Icon name="delete" size="sm" />
</button>
</div>
</header>
<div className="mobile-detail-fields">
{fields.map((field, index) => (
<div key={index} className="mobile-detail-field">
<label className="mobile-detail-field-label">{field.label}</label>
<div className="mobile-detail-field-value">{renderValue(field)}</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,161 @@
import { useState } from "react";
interface FormField {
name: string;
label: string;
type: "text" | "textarea" | "number" | "select" | "checkbox" | "code";
value: string | number | boolean;
options?: { value: string; label: string }[];
placeholder?: string;
required?: boolean;
rows?: number;
}
interface MobileEditViewProps {
title: string;
fields?: FormField[];
onSave: (data: Record<string, string | number | boolean>) => void;
onCancel: () => void;
isSaving?: boolean;
children?: React.ReactNode;
}
export const MobileEditView: React.FC<MobileEditViewProps> = ({
title,
fields,
onSave,
onCancel,
isSaving = false,
children,
}) => {
const [formData, setFormData] = useState<Record<string, string | number | boolean>>(
() => {
const initial: Record<string, string | number | boolean> = {};
fields?.forEach((field) => {
initial[field.name] = field.value;
});
return initial;
}
);
const handleChange = (name: string, value: string | number | boolean) => {
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<div className="mobile-edit-view">
<header className="mobile-edit-header">
<button
className="mobile-edit-cancel"
onClick={onCancel}
type="button"
disabled={isSaving}
>
Cancel
</button>
<h1 className="mobile-edit-title">{title}</h1>
<button
className="mobile-edit-save"
onClick={() => onSave(formData)}
type="button"
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
</header>
<form className="mobile-edit-form" onSubmit={handleSubmit}>
{children || fields?.map((field) => (
<div key={field.name} className="mobile-edit-field">
<label className="mobile-edit-field-label" htmlFor={field.name}>
{field.label}
{field.required && <span className="required">*</span>}
</label>
{field.type === "textarea" && (
<textarea
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) => handleChange(field.name, e.target.value)}
placeholder={field.placeholder}
required={field.required}
rows={field.rows || 4}
className="mobile-edit-input mobile-edit-textarea"
/>
)}
{field.type === "select" && (
<select
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) => handleChange(field.name, e.target.value)}
required={field.required}
className="mobile-edit-input"
>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{field.type === "checkbox" && (
<label className="mobile-edit-checkbox">
<input
type="checkbox"
id={field.name}
name={field.name}
checked={Boolean(formData[field.name])}
onChange={(e) => handleChange(field.name, e.target.checked)}
/>
<span>{field.label}</span>
</label>
)}
{field.type === "code" && (
<textarea
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) => handleChange(field.name, e.target.value)}
placeholder={field.placeholder}
required={field.required}
rows={field.rows || 8}
className="mobile-edit-input mobile-edit-code"
style={{ fontFamily: "monospace" }}
/>
)}
{(field.type === "text" || field.type === "number") && (
<input
type={field.type === "number" ? "number" : "text"}
id={field.name}
name={field.name}
value={String(formData[field.name] ?? "")}
onChange={(e) =>
handleChange(
field.name,
field.type === "number"
? Number(e.target.value)
: e.target.value
)
}
placeholder={field.placeholder}
required={field.required}
className="mobile-edit-input"
/>
)}
</div>
))}
</form>
</div>
);
};
+22
View File
@@ -0,0 +1,22 @@
import { Icon } from "./icon";
interface MobileFABProps {
onClick: () => void;
label?: string;
}
export const MobileFAB: React.FC<MobileFABProps> = ({
onClick,
label = "Create new",
}) => {
return (
<button
className="mobile-fab"
onClick={onClick}
type="button"
aria-label={label}
>
<Icon name="add" size="md" />
</button>
);
};
@@ -0,0 +1,76 @@
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
interface MobileListItem {
id: string;
title: string;
subtitle?: string;
icon?: string;
status?: string;
}
interface MobileListViewProps {
items: MobileListItem[];
onItemClick: (id: string) => void;
onItemDelete?: (id: string) => void;
onItemDuplicate?: (id: string) => void;
emptyMessage?: string;
searchPlaceholder?: string;
onSearch?: (query: string) => void;
}
export const MobileListView: React.FC<MobileListViewProps> = ({
items,
onItemClick,
emptyMessage = "No items found",
searchPlaceholder = "Search...",
onSearch,
}) => {
return (
<div className="mobile-list-view">
{onSearch && (
<div className="mobile-list-search">
<input
type="search"
placeholder={searchPlaceholder}
onChange={(e) => onSearch(e.target.value)}
className="mobile-list-search-input"
/>
</div>
)}
{items.length === 0 ? (
<div className="mobile-list-empty">
<Icon name="folder" size="lg" />
<p>{emptyMessage}</p>
</div>
) : (
<div className="mobile-list-items">
{items.map((item) => (
<button
key={item.id}
className="mobile-list-item"
onClick={() => onItemClick(item.id)}
type="button"
>
{item.icon && (
<div className="mobile-list-item-icon">
<Icon name={item.icon as IconName} size="md" />
</div>
)}
<div className="mobile-list-item-content">
<div className="mobile-list-item-title">{item.title}</div>
{item.subtitle && (
<div className="mobile-list-item-subtitle">{item.subtitle}</div>
)}
</div>
<div className="mobile-list-item-actions" style={{ transform: "rotate(180deg)" }}>
<Icon name="arrow-left" size="sm" />
</div>
</button>
))}
</div>
)}
</div>
);
};
+87
View File
@@ -0,0 +1,87 @@
import { useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { Icon } from "./icon";
import { ToolsBottomSheet } from "./tools-bottom-sheet";
import type { IconName } from "../utils/icons";
interface MobileNavProps {
sessionCount?: number;
}
interface NavItem {
to: string;
label: string;
icon: IconName;
isGroup?: boolean;
}
const MOBILE_NAV_ITEMS: NavItem[] = [
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/sessions", label: "Sessions", icon: "terminal" },
{ to: "/tools", label: "Tools", icon: "settings", isGroup: true },
{ to: "/settings", label: "Settings", icon: "settings" },
];
export const MobileNav: React.FC<MobileNavProps> = ({ sessionCount }) => {
const location = useLocation();
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const isToolsActive =
location.pathname === "/tool-workshop" ||
location.pathname === "/config-profiles";
const handleNavClick = (item: NavItem) => {
if (item.isGroup) {
setToolsSheetOpen(true);
}
};
return (
<>
<nav className="mobile-nav" role="navigation" aria-label="Mobile navigation">
{MOBILE_NAV_ITEMS.map((item) => {
if (item.isGroup) {
return (
<button
key={item.to}
className={`mobile-nav-item ${isToolsActive ? "active" : ""}`}
onClick={() => handleNavClick(item)}
type="button"
>
<div className="mobile-nav-icon-wrapper">
<Icon name={item.icon} size="md" />
</div>
<span className="mobile-nav-label">{item.label}</span>
</button>
);
}
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`mobile-nav-item ${isActive ? "active" : ""}`
}
end={item.to === "/"}
>
<div className="mobile-nav-icon-wrapper">
<Icon name={item.icon} size="md" />
{item.to === "/sessions" && sessionCount ? (
<span className="mobile-nav-badge">{sessionCount}</span>
) : null}
</div>
<span className="mobile-nav-label">{item.label}</span>
</NavLink>
);
})}
</nav>
<ToolsBottomSheet
isOpen={toolsSheetOpen}
onClose={() => setToolsSheetOpen(false)}
/>
</>
);
};
@@ -0,0 +1,33 @@
import { useNavigate } from "react-router-dom";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { Icon } from "./icon";
interface MobilePageHeaderProps {
title: string;
showBack?: boolean;
actions?: React.ReactNode;
}
export function MobilePageHeader({ title, showBack = true, actions }: MobilePageHeaderProps) {
const navigate = useNavigate();
const isMobile = useMobileViewport();
if (!isMobile) return null;
return (
<div className="mobile-page-header">
{showBack && (
<button
className="mobile-page-header-back"
onClick={() => navigate(-1)}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="md" />
</button>
)}
<h1>{title}</h1>
{actions && <div className="mobile-page-header-actions">{actions}</div>}
</div>
);
}
+200 -97
View File
@@ -1,6 +1,9 @@
import { useState } from "react";
import type { Session } from "../api/sessions";
import { Icon } from "./icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { MobileActionSheet } from "./mobile-action-sheet";
import type { IconName } from "./icon";
export interface SessionCardProps {
session: Session;
@@ -45,6 +48,8 @@ export function SessionCard({
}: SessionCardProps) {
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showActionSheet, setShowActionSheet] = useState(false);
const isMobile = useMobileViewport();
const status = statusConfig[session.status] || { color: "gray", label: session.status };
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
@@ -75,7 +80,12 @@ export function SessionCard({
const isActive = ["running", "building", "starting", "probing", "pending", "unhealthy"].includes(session.status);
return (
<article className="card session-card">
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
{isBusy && (
<div className="session-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div className="session-card-content">
<div className="session-card-header">
<div className="session-card-title">
@@ -112,124 +122,217 @@ export function SessionCard({
)}
{session.created_at && (
<p className="muted session-card-meta">
Created: {new Date(session.created_at).toLocaleDateString()}
Created: {new Date(session.created_at).toLocaleString()}
</p>
)}
</div>
</div>
<div className="session-card-actions">
{isActive && (
<>
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</a>
) : (
<button
className="secondary-button small"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</button>
)}
{hasTunnelError && onRecreateTunnel && (
<button
className="secondary-button small"
onClick={() => onRecreateTunnel(session)}
type="button"
disabled={isBusy}
>
<Icon name="refresh" size="sm" />
<span className="action-label">Tunnel</span>
</button>
)}
{showStopConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Stop?</span>
{isMobile ? (
<div className="session-card-actions mobile">
{isActive && (
<>
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button mobile-primary"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button
className="danger-button small"
className="secondary-button mobile-primary"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
Open
</button>
)}
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
</>
)}
{!isActive && onStart && (
<button
className="secondary-button mobile-primary"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
Start
</button>
)}
{!isActive && (
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
)}
</div>
) : (
<div className="session-card-actions">
{isActive && (
<>
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</a>
) : (
<button
className="secondary-button small"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</button>
)}
{hasTunnelError && onRecreateTunnel && (
<button
className="secondary-button small"
onClick={() => onRecreateTunnel(session)}
type="button"
disabled={isBusy}
>
<Icon name="refresh" size="sm" />
<span className="action-label">Tunnel</span>
</button>
)}
{showStopConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Stop?</span>
<button
className="danger-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
Stop
</button>
<button
className="ghost-button small"
onClick={handleCancelStop}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
Stop
<Icon name="stop" size="sm" />
<span className="action-label">Stop</span>
</button>
<button
className="ghost-button small"
onClick={handleCancelStop}
type="button"
>
Cancel
</button>
</div>
) : (
)}
</>
)}
{!isActive && onStart && (
<button
className="secondary-button small"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
<span className="action-label">Start</span>
</button>
)}
{showDeleteConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Delete?</span>
<button
className="ghost-button small"
onClick={handleStop}
className="danger-button small"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
<Icon name="stop" size="sm" />
<span className="action-label">Stop</span>
Delete
</button>
)}
</>
)}
{!isActive && onStart && (
<button
className="secondary-button small"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
<span className="action-label">Start</span>
</button>
)}
{showDeleteConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Delete?</span>
<button
className="ghost-button small"
onClick={handleCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="danger-button small"
className="ghost-button small danger-text"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
Delete
<Icon name="delete" size="sm" />
</button>
<button
className="ghost-button small"
onClick={handleCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
)}
</div>
)}
<MobileActionSheet
isOpen={showActionSheet}
onClose={() => setShowActionSheet(false)}
title={session.display_name}
actions={[
...(isActive && hasTunnelError && onRecreateTunnel
? [{
id: "tunnel",
label: "Recreate Tunnel",
icon: "refresh" as IconName,
onClick: () => onRecreateTunnel(session),
}]
: []),
...(isActive && onStop
? [{
id: "stop",
label: "Stop",
icon: "stop" as IconName,
variant: "danger" as const,
onClick: () => onStop(session),
}]
: []),
...(onDelete
? [{
id: "delete",
label: "Delete",
icon: "delete" as IconName,
variant: "danger" as const,
onClick: () => onDelete(session),
}]
: []),
]}
/>
</article>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey, KEY_SEQUENCES } from "../hooks/use-special-keys";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
interface SpecialKeysStripProps {
onSend: (data: string) => void;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
import { useLocation, useNavigate } from "react-router-dom";
import { Icon } from "./icon";
interface ToolsBottomSheetProps {
isOpen: boolean;
onClose: () => void;
}
const TOOLS_ITEMS = [
{ to: "/tool-workshop", label: "Tool Workshop" },
{ to: "/config-profiles", label: "Config Profiles" },
];
export const ToolsBottomSheet: React.FC<ToolsBottomSheetProps> = ({
isOpen,
onClose,
}) => {
const location = useLocation();
const navigate = useNavigate();
if (!isOpen) return null;
const handleSelect = (to: string) => {
onClose();
navigate(to);
};
return (
<div
className="mobile-bottom-sheet-overlay"
onClick={onClose}
role="presentation"
>
<div
className="mobile-bottom-sheet"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Tools menu"
>
<div className="mobile-bottom-sheet-header">
<div className="mobile-bottom-sheet-handle" />
<h3 className="mobile-bottom-sheet-title">Tools</h3>
</div>
<div className="mobile-bottom-sheet-content">
{TOOLS_ITEMS.map((item) => (
<button
key={item.to}
className={`mobile-bottom-sheet-item ${
location.pathname === item.to ? "active" : ""
}`}
onClick={() => handleSelect(item.to)}
type="button"
>
<span className="mobile-bottom-sheet-item-label">{item.label}</span>
{location.pathname === item.to && <Icon name="success" size="sm" />}
</button>
))}
</div>
</div>
</div>
);
};
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from "react";
type AsyncStatus = "idle" | "loading" | "ready" | "error";
interface UseAsyncDataResult<T> {
data: T | null;
status: AsyncStatus;
error: string | null;
reload: () => void;
}
export function useAsyncData<T>(
fetcher: () => Promise<T>,
deps: React.DependencyList = []
): UseAsyncDataResult<T> {
const [data, setData] = useState<T | null>(null);
const [status, setStatus] = useState<AsyncStatus>("idle");
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setStatus("loading");
setError(null);
try {
const result = await fetcher();
setData(result);
setStatus("ready");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data");
setStatus("error");
}
}, deps);
const reload = useCallback(() => {
void load();
}, [load]);
useEffect(() => {
void load();
}, [load]);
return { data, status, error, reload };
}
+158
View File
@@ -0,0 +1,158 @@
import { useState, useCallback } from "react";
import {
stopInstance,
deleteInstance,
startInstance,
recreateInstanceTunnel,
} from "../api/sessions";
import type { Session } from "../api/sessions";
interface UseInstanceActionsOptions {
onRefresh: () => Promise<void>;
}
interface UseInstanceActionsReturn {
loadingSessionId: string | null;
dirtyDeleteSession: Session | null;
dirtyDeleteFiles: string[];
handleOpen: (session: Session) => void;
handleStart: (session: Session) => Promise<void>;
handleStop: (session: Session) => Promise<void>;
handleDelete: (session: Session) => Promise<void>;
handleForceDelete: (session: Session) => Promise<void>;
handleRecreateTunnel: (session: Session) => Promise<void>;
clearDirtyDelete: () => void;
}
export function useInstanceActions(
options: UseInstanceActionsOptions
): UseInstanceActionsReturn {
const { onRefresh } = options;
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const handleOpen = useCallback((session: Session) => {
if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer");
return;
}
if (session.tool_type_interfaces?.includes("terminal")) {
window.location.href = `/instances/${session.id}/terminal`;
return;
}
window.location.href = `/projects/${session.project_id}`;
}, []);
const handleStart = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await startInstance(session.project_id, session.repository_id, session.id);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh]
);
const handleStop = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await stopInstance(session.project_id, session.repository_id, session.id);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh]
);
const handleDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
await onRefresh();
} catch (error) {
const axiosError = error as {
response?: { status?: number; data?: { detail?: { changed_files?: string[] } } };
};
if (axiosError.response?.status === 409) {
const detail = axiosError.response.data?.detail;
if (detail?.changed_files) {
setDirtyDeleteSession(session);
setDirtyDeleteFiles(detail.changed_files);
return;
}
}
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh]
);
const handleForceDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await deleteInstance(session.project_id, session.repository_id, session.id, true);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh]
);
const handleRecreateTunnel = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
try {
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
await onRefresh();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh]
);
const clearDirtyDelete = useCallback(() => {
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
}, []);
return {
loadingSessionId,
dirtyDeleteSession,
dirtyDeleteFiles,
handleOpen,
handleStart,
handleStop,
handleDelete,
handleForceDelete,
handleRecreateTunnel,
clearDirtyDelete,
};
}
+483 -12
View File
@@ -1,11 +1,19 @@
import { useCallback, useEffect, useState } from "react";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { extractErrorMessage } from "../utils/errors";
import { MobileListView } from "../components/mobile-list-view";
import { MobileDetailView } from "../components/mobile-detail-view";
import { MobileEditView } from "../components/mobile-edit-view";
import { MobileFAB } from "../components/mobile-fab";
import {
createConfigProfile,
deleteConfigProfile,
listConfigProfiles,
previewConfigProfile,
updateConfigProfile,
updateProfileIncludes,
type ConfigProfile,
type CreateConfigProfileRequest,
type ResolvedProfile,
@@ -13,10 +21,14 @@ import {
import { listProjects } from "../api/projects";
import type { Project } from "../types";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { GitMountEditor } from "../components/git-mount-editor";
type Status = "loading" | "ready" | "error";
type MobileView = "list" | "detail" | "edit";
export const ConfigProfilesPage = () => {
const isMobile = useMobileViewport();
const [mobileView, setMobileView] = useState<MobileView>("list");
const [status, setStatus] = useState<Status>("loading");
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
@@ -36,10 +48,14 @@ export const ConfigProfilesPage = () => {
env_vars: {},
runtime_hints: {},
mounts: [],
git_mounts: [],
files: {},
is_default: false,
});
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
const loadData = useCallback(async () => {
@@ -53,6 +69,7 @@ export const ConfigProfilesPage = () => {
setProfiles(profs || []);
setProjects(projs || []);
setToolTypes(types || []);
setStatus("ready");
} catch {
setStatus("error");
@@ -70,9 +87,11 @@ export const ConfigProfilesPage = () => {
env_vars: {},
runtime_hints: {},
mounts: [],
git_mounts: [],
files: {},
is_default: false,
});
setIncludedProfileIds([]);
setError(null);
setSaveStatus("idle");
setPreviewData(null);
@@ -87,9 +106,13 @@ export const ConfigProfilesPage = () => {
env_vars: profile.env_vars,
runtime_hints: profile.runtime_hints,
mounts: profile.mounts,
git_mounts: profile.git_mounts || [],
files: profile.files,
is_default: profile.is_default,
});
setIncludedProfileIds(
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id)
);
setError(null);
setSaveStatus("idle");
setPreviewData(null);
@@ -111,14 +134,80 @@ export const ConfigProfilesPage = () => {
resetForm();
};
const extractErrorMessage = (err: unknown): string => {
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
const detail = axiosError?.response?.data?.detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
// Include management functions
const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id);
const getScopeLabel = (profile: ConfigProfile): string => {
if (profile.project_id && profile.tool_type_id) return "Project + Tool";
if (profile.project_id) return "Project";
if (profile.tool_type_id) return "Tool";
return "Global";
};
// Cycle detection: returns true if adding targetId would create a cycle
const wouldCreateCycle = (profileId: string, targetId: string, visited = new Set<string>()): boolean => {
if (visited.has(targetId)) return true;
const target = getIncludedProfile(targetId);
if (!target) return false;
const nextVisited = new Set(visited);
nextVisited.add(targetId);
for (const inc of target.includes) {
if (inc.included_profile_id === profileId || wouldCreateCycle(profileId, inc.included_profile_id, nextVisited)) {
return true;
}
}
return "Failed to save";
return false;
};
const availableProfilesForInclude = (): ConfigProfile[] => {
const currentId = selectedProfile?.id;
if (!currentId) return [];
return profiles.filter((p) => {
if (p.id === currentId) return false;
if (includedProfileIds.includes(p.id)) return false;
if (wouldCreateCycle(currentId, p.id)) return false;
return true;
});
};
const addInclude = (profileId: string) => {
setIncludedProfileIds((prev) => [...prev, profileId]);
};
const removeInclude = (index: number) => {
setIncludedProfileIds((prev) => prev.filter((_, i) => i !== index));
};
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, index: number) => {
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIndex(index);
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
const dragIndex = Number(e.dataTransfer.getData("text/plain"));
if (dragIndex === dropIndex) {
setDragOverIndex(null);
return;
}
setIncludedProfileIds((prev) => {
const newOrder = [...prev];
const [removed] = newOrder.splice(dragIndex, 1);
newOrder.splice(dropIndex, 0, removed);
return newOrder;
});
setDragOverIndex(null);
};
const handleSubmit = async (e: React.FormEvent) => {
@@ -135,6 +224,9 @@ export const ConfigProfilesPage = () => {
try {
if (isCreating) {
const newProfile = await createConfigProfile(formData);
if (includedProfileIds.length > 0) {
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
}
setIsCreating(false);
setSelectedProfileId(newProfile.id);
setSaveStatus("saved");
@@ -144,6 +236,7 @@ export const ConfigProfilesPage = () => {
if (refreshed) populateForm(refreshed);
} else if (selectedProfile) {
await updateConfigProfile(selectedProfile.id, formData);
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
setSaveStatus("saved");
await loadData();
// Refresh the selected profile data
@@ -321,7 +414,7 @@ export const ConfigProfilesPage = () => {
if (status === "loading") {
return (
<div className="container">
<p>Loading Config Profiles...</p>
<LoadingState message="Loading Config Profiles..." />
</div>
);
}
@@ -329,10 +422,269 @@ export const ConfigProfilesPage = () => {
if (status === "error") {
return (
<div className="container">
<p className="text-error">Failed to load Config Profiles.</p>
<button onClick={loadData}>
<Icon name="refresh" size="sm" /> Retry
</button>
<ErrorState message="Failed to load Config Profiles." onRetry={loadData} />
</div>
);
}
// Mobile view rendering
if (isMobile) {
if (mobileView === "list") {
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Config Profiles</h1>
</div>
<MobileListView
items={profiles.map((profile) => ({
id: profile.id,
title: profile.name,
subtitle: profile.description || getScopeLabel(profile),
}))}
onItemClick={(id: string) => {
setSelectedProfileId(id);
setIsCreating(false);
const profile = profiles.find((p) => p.id === id);
if (profile) populateForm(profile);
setMobileView("detail");
}}
onItemDelete={(id: string) => handleDelete(id)}
emptyMessage="No config profiles yet"
/>
<MobileFAB onClick={() => {
setIsCreating(true);
setSelectedProfileId(null);
resetForm();
setMobileView("edit");
}} />
</div>
);
}
if (mobileView === "detail" && selectedProfile) {
const fields = [
{ label: "Name", value: selectedProfile.name },
{ label: "Description", value: selectedProfile.description || "-" },
{ label: "Scope", value: getScopeLabel(selectedProfile) },
{ label: "Default", value: selectedProfile.is_default ? "Yes" : "No" },
{ label: "Environment Variables", value: Object.keys(selectedProfile.env_vars).length > 0 ? Object.entries(selectedProfile.env_vars).map(([k, v]) => `${k}=${v}`).join(", ") : "-" },
{ label: "Mounts", value: selectedProfile.mounts.length > 0 ? selectedProfile.mounts.map((m) => `${m.target} (${m.mode})`).join(", ") : "-" },
{ label: "Includes", value: selectedProfile.includes.length > 0 ? `${selectedProfile.includes.length} profile(s)` : "-" },
];
return (
<MobileDetailView
title={selectedProfile.name}
subtitle={getScopeLabel(selectedProfile)}
fields={fields}
onBack={() => setMobileView("list")}
onEdit={() => {
populateForm(selectedProfile);
setIsCreating(false);
setMobileView("edit");
}}
onDelete={() => handleDelete(selectedProfile.id)}
/>
);
}
if (mobileView === "edit") {
return (
<MobileEditView
title={isCreating ? "Create Profile" : "Edit Profile"}
onCancel={() => {
if (isCreating) {
setMobileView("list");
} else if (selectedProfile) {
setMobileView("detail");
} else {
setMobileView("list");
}
}}
onSave={() => handleSubmit({ preventDefault: () => {} } as React.FormEvent)}
isSaving={saveStatus === "saving"}
>
{/* Profile form fields */}
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => updateFormField("name", e.target.value)}
placeholder="Profile name"
required
/>
</div>
<div className="form-group">
<label>Description</label>
<textarea
value={formData.description || ""}
onChange={(e) => updateFormField("description", e.target.value)}
placeholder="Optional description"
rows={3}
/>
</div>
<div className="form-group">
<label>Project</label>
<select
value={formData.project_id || ""}
onChange={(e) => updateFormField("project_id", e.target.value || undefined)}
>
<option value="">Global (all projects)</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Tool Type</label>
<select
value={formData.tool_type_id || ""}
onChange={(e) => updateFormField("tool_type_id", e.target.value || undefined)}
>
<option value="">Any tool type</option>
{toolTypes.map((toolType) => (
<option key={toolType.id} value={toolType.id}>{toolType.display_name}</option>
))}
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={formData.is_default || false}
onChange={(e) => updateFormField("is_default", e.target.checked)}
/>
Default Profile
</label>
</div>
{/* Environment Variables */}
<div className="form-group">
<label>Environment Variables</label>
{Object.entries(formData.env_vars || {}).map(([key, value], index) => (
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={key}
onChange={(e) => {
const newEnvVars = { ...formData.env_vars };
delete newEnvVars[key];
newEnvVars[e.target.value] = value;
updateFormField("env_vars", newEnvVars);
}}
placeholder="KEY"
style={{ flex: 1 }}
/>
<input
type="text"
value={value}
onChange={(e) => {
const newEnvVars = { ...formData.env_vars };
newEnvVars[key] = e.target.value;
updateFormField("env_vars", newEnvVars);
}}
placeholder="value"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => {
const newEnvVars = { ...formData.env_vars };
delete newEnvVars[key];
updateFormField("env_vars", newEnvVars);
}}
className="secondary-button"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button type="button" className="secondary-button" onClick={addEnvVar}>
<Icon name="add" size="sm" /> Add Variable
</button>
</div>
{/* Mounts */}
<div className="form-group">
<label>Mounts</label>
{(formData.mounts || []).map((mount, index) => (
<div key={index} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={mount.target}
onChange={(e) => {
const newMounts = [...(formData.mounts || [])];
newMounts[index] = { ...mount, target: e.target.value };
updateFormField("mounts", newMounts);
}}
placeholder="Target path"
style={{ flex: 1 }}
/>
<select
value={mount.mode}
onChange={(e) => {
const newMounts = [...(formData.mounts || [])];
newMounts[index] = { ...mount, mode: e.target.value as "ro" | "rw" };
updateFormField("mounts", newMounts);
}}
style={{ width: "80px" }}
>
<option value="ro">Read</option>
<option value="rw">Write</option>
</select>
<button
type="button"
onClick={() => {
const newMounts = (formData.mounts || []).filter((_, i) => i !== index);
updateFormField("mounts", newMounts);
}}
className="secondary-button"
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button type="button" className="secondary-button" onClick={addMount}>
<Icon name="add" size="sm" /> Add Mount
</button>
</div>
</MobileEditView>
);
}
// Fallback to list
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Config Profiles</h1>
</div>
<MobileListView
items={profiles.map((profile) => ({
id: profile.id,
title: profile.name,
subtitle: profile.description || getScopeLabel(profile),
}))}
onItemClick={(id: string) => {
setSelectedProfileId(id);
setIsCreating(false);
const profile = profiles.find((p) => p.id === id);
if (profile) populateForm(profile);
setMobileView("detail");
}}
onItemDelete={(id: string) => handleDelete(id)}
emptyMessage="No config profiles yet"
/>
<MobileFAB onClick={() => {
setIsCreating(true);
setSelectedProfileId(null);
resetForm();
setMobileView("edit");
}} />
</div>
);
}
@@ -402,6 +754,18 @@ export const ConfigProfilesPage = () => {
default
</span>
)}
{profile.includes?.length > 0 && (
<span style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.7,
background: selectedProfileId === profile.id ? "rgba(255,255,255,0.2)" : "var(--badge-bg, #f3f4f6)",
padding: "0.0625rem 0.375rem",
borderRadius: "0.25rem",
}}>
{profile.includes.length} include{profile.includes.length !== 1 ? "s" : ""}
</span>
)}
</div>
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
{profile.project_id && "Project scoped"}
@@ -600,6 +964,106 @@ export const ConfigProfilesPage = () => {
</label>
</div>
{/* Includes Section */}
<div className="form-section">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
<h4 style={{ margin: 0 }}>Includes</h4>
<span className="muted" style={{ fontSize: "0.875rem" }}>
{includedProfileIds.length} included
</span>
</div>
{includedProfileIds.length === 0 ? (
<p className="muted" style={{ fontSize: "0.875rem", margin: "0 0 0.75rem 0" }}>
No profiles included. Add profiles to compose configurations.
</p>
) : (
<div style={{ marginBottom: "0.75rem" }}>
{includedProfileIds.map((profileId, index) => {
const profile = getIncludedProfile(profileId);
if (!profile) return null;
return (
<div
key={`${profileId}-${index}`}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 0.75rem",
background: dragOverIndex === index ? "var(--brand-bg, #e0e7ff)" : "var(--panel)",
border: "1px solid var(--border)",
borderRadius: "0.375rem",
marginBottom: "0.25rem",
cursor: "grab",
transition: "background 0.15s",
}}
>
<span style={{ cursor: "grab", color: "var(--muted)" }}>
<Icon name="drag" size="sm" />
</span>
<span style={{ flex: 1, fontWeight: 500 }}>{profile.name}</span>
<span
style={{
fontSize: "0.75rem",
padding: "0.125rem 0.375rem",
background: "var(--badge-bg, #f3f4f6)",
color: "var(--muted)",
borderRadius: "0.25rem",
textTransform: "uppercase",
letterSpacing: "0.025em",
}}
>
{getScopeLabel(profile)}
</span>
<button
type="button"
onClick={() => removeInclude(index)}
style={{
background: "none",
border: "none",
color: "var(--danger)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
}}
title="Remove include"
>
<Icon name="delete" size="sm" />
</button>
</div>
);
})}
</div>
)}
{availableProfilesForInclude().length > 0 && (
<div className="form-group" style={{ marginBottom: 0 }}>
<select
value=""
onChange={(e) => {
if (e.target.value) {
addInclude(e.target.value);
e.target.value = "";
}
}}
className="form-input"
>
<option value="">+ Add Include...</option>
{availableProfilesForInclude().map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({getScopeLabel(p)})
</option>
))}
</select>
</div>
)}
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Environment Variables</h4>
{Object.entries(formData.env_vars || {}).map(([key, value], idx) => (
@@ -780,6 +1244,13 @@ export const ConfigProfilesPage = () => {
</button>
</div>
<div className="form-section">
<GitMountEditor
mounts={formData.git_mounts || []}
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
/>
</div>
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
<button type="submit" disabled={saveStatus === "saving"}>
<Icon name={isCreating ? "add" : "save"} size="sm" />
+15 -68
View File
@@ -2,15 +2,16 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
import { getUserSessions, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings";
import type { Project } from "../types";
import { Icon } from "../components/icon";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
type HomeStatus = "loading" | "ready" | "error";
@@ -31,7 +32,6 @@ export const HomePage = () => {
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
const safeSessions = Array.isArray(sessions) ? sessions : [];
@@ -58,6 +58,15 @@ export const HomePage = () => {
void loadHome();
}, [loadHome]);
const {
loadingSessionId: actionBusy,
handleOpen,
handleStart,
handleStop,
handleDelete,
handleRecreateTunnel,
} = useInstanceActions({ onRefresh: loadHome });
// Poll tunnel health every 30 seconds for running instances
useEffect(() => {
const checkHealth = async () => {
@@ -130,60 +139,6 @@ export const HomePage = () => {
await loadHome();
};
const handleOpen = (session: SessionView) => {
if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer");
return;
}
if (session.tool_type_interfaces.includes("terminal")) {
navigate(`/instances/${session.id}/terminal`);
return;
}
navigate(`/projects/${session.project_id}`);
};
const handleStop = async (session: SessionView) => {
setActionBusy(session.id);
try {
await stopInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
const handleDelete = async (session: SessionView) => {
setActionBusy(session.id);
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
setSessions((prev) => prev.filter((s) => s.id !== session.id));
} catch {
// error - session remains in state
} finally {
setActionBusy(null);
}
};
const handleRecreateTunnel = async (session: SessionView) => {
setActionBusy(session.id);
try {
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
const handleStart = async (session: SessionView) => {
setActionBusy(session.id);
try {
await startInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
return (
<section className="stack home-page">
<header className="home-hero card">
@@ -198,17 +153,9 @@ export const HomePage = () => {
</div>
</header>
{status === "loading" && <p className="muted">Loading overview...</p>}
{status === "loading" && <LoadingState message="Loading overview..." />}
{status === "error" && (
<div className="card stack">
<p>Unable to load your workspace overview.</p>
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{status === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
{status === "ready" && summary && (
<>
@@ -256,7 +203,7 @@ export const HomePage = () => {
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
</div>
{projects.length === 0 ? (
<p className="muted">No projects yet.</p>
<EmptyState message="No projects yet." />
) : (
<div className="home-project-grid">
{projects.map((project) => (
+21 -28
View File
@@ -1,39 +1,33 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git_repositories";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryResponse } from "../api/git_repositories";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
export const GitHistoryPage = () => {
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
const navigate = useNavigate();
const [commits, setCommits] = useState<CommitHistoryEntry[]>([]);
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
const [branches, setBranches] = useState<string[]>([]);
const [selectedBranch, setSelectedBranch] = useState<string>("");
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
const loadHistory = useCallback(async () => {
if (!projectId || !repoId) return;
setStatus("loading");
try {
const data = await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
setCommits(data.commits);
setBranches(data.branches);
if (data.branches.length > 0 && !selectedBranch) {
setSelectedBranch(data.branches[0]);
}
setStatus("ready");
} catch {
setStatus("error");
}
}, [projectId, repoId, selectedBranch]);
const { data: historyData, status, reload } = useAsyncData<CommitHistoryResponse>(
async () => {
if (!projectId || !repoId) return { commits: [], branches: [], tags: [] };
return await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
},
[projectId, repoId, selectedBranch]
);
// Auto-select first branch when data loads
useEffect(() => {
void loadHistory();
}, [loadHistory]);
if (historyData?.branches.length && !selectedBranch) {
setSelectedBranch(historyData.branches[0]);
}
}, [historyData?.branches, selectedBranch]);
const handleCommitClick = async (hash: string) => {
if (!projectId || !repoId) return;
@@ -61,7 +55,7 @@ export const GitHistoryPage = () => {
if (status === "loading") {
return (
<section className="stack">
<p className="muted">Loading commit history...</p>
<LoadingState message="Loading commit history..." />
</section>
);
}
@@ -69,15 +63,14 @@ export const GitHistoryPage = () => {
if (status === "error") {
return (
<section className="stack">
<p>Failed to load commit history</p>
<button className="secondary-button" onClick={() => void loadHistory()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
<ErrorState message="Failed to load commit history" onRetry={reload} />
</section>
);
}
const commits = historyData?.commits ?? [];
const branches = historyData?.branches ?? [];
return (
<section className="stack">
<div className="page-header">
+19 -37
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
@@ -6,48 +6,38 @@ import {
listRepositories,
} from "../api/git_repositories";
import type { GitRepository } from "../api/git_repositories";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
type RepoStatus = "loading" | "ready" | "error";
import { useAsyncData } from "../hooks/use-async-data";
export const GitRepositoriesPage = () => {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const [status, setStatus] = useState<RepoStatus>("loading");
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [showCreate, setShowCreate] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadRepositories = useCallback(async () => {
if (!projectId) return;
setStatus("loading");
try {
const data = await listRepositories(projectId);
setRepositories(data);
setStatus("ready");
} catch {
setRepositories([]);
setStatus("error");
}
}, [projectId]);
useEffect(() => {
void loadRepositories();
}, [loadRepositories]);
const { data: repositories, status, reload } = useAsyncData<GitRepository[]>(
async () => {
if (!projectId) return [];
return await listRepositories(projectId);
},
[projectId]
);
const handleDelete = async (repoId: string) => {
if (!projectId) return;
try {
await deleteRepository(projectId, repoId);
setDeleteConfirmId(null);
await loadRepositories();
reload();
} catch {
setDeleteConfirmId(null);
}
};
const isEmpty = status === "ready" && repositories.length === 0;
const safeRepositories = repositories ?? [];
const isEmpty = status === "ready" && safeRepositories.length === 0;
return (
<section className="stack">
@@ -59,23 +49,15 @@ export const GitRepositoriesPage = () => {
</button>
</div>
{status === "loading" && <p className="muted">Loading repositories...</p>}
{status === "loading" && <LoadingState message="Loading repositories..." />}
{status === "error" && (
<div className="card stack">
<p>Failed to load repositories</p>
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{status === "error" && <ErrorState message="Failed to load repositories" onRetry={reload} />}
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
{isEmpty && <EmptyState message="No repositories yet. Create your first repository above." />}
{status === "ready" && repositories.length > 0 && (
{status === "ready" && safeRepositories.length > 0 && (
<div className="repository-list">
{repositories.map((repo) => (
{safeRepositories.map((repo) => (
<article className="card repository-card" key={repo.id}>
<div className="repository-info">
<h3>{repo.name}</h3>
@@ -131,7 +113,7 @@ export const GitRepositoriesPage = () => {
open={showCreate}
title="Create Repository"
onClose={() => setShowCreate(false)}
onCreated={loadRepositories}
onCreated={reload}
/>
)}
</section>
+35 -45
View File
@@ -1,39 +1,38 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAuth } from "../state/auth";
import { useAsyncData } from "../hooks/use-async-data";
import type { UserProfile } from "../api/profile";
type ProfileStatus = "loading" | "ready" | "error" | "saving";
export const ProfilePage = () => {
const { refreshSession } = useAuth();
const [status, setStatus] = useState<ProfileStatus>("loading");
const [profile, setProfile] = useState<UserProfile | null>(null);
const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadProfile = useCallback(async () => {
setStatus("loading");
setError(null);
try {
const data = await getProfile();
setProfile(data);
setName(data.name);
setEmail(data.email);
setStatus("ready");
} catch {
setProfile(null);
setStatus("error");
// Sync loaded profile into form fields
useEffect(() => {
if (profile) {
setName(profile.name);
setEmail(profile.email);
setDisplayStatus("ready");
setError(null);
}
}, []);
}, [profile]);
useEffect(() => {
void loadProfile();
}, [loadProfile]);
if (loadStatus === "error") {
setDisplayStatus("error");
}
}, [loadStatus]);
const handleSave = useCallback(async () => {
if (!name.trim()) {
@@ -45,16 +44,15 @@ export const ProfilePage = () => {
return;
}
setStatus("saving");
setDisplayStatus("saving");
setError(null);
try {
const updated = await updateProfile({ name: name.trim(), email: email.trim() });
setProfile(updated);
await updateProfile({ name: name.trim(), email: email.trim() });
await refreshSession();
setStatus("ready");
setDisplayStatus("ready");
} catch {
setError("Failed to update profile");
setStatus("ready");
setDisplayStatus("ready");
}
}, [name, email, refreshSession]);
@@ -73,19 +71,19 @@ export const ProfilePage = () => {
return;
}
setStatus("saving");
setDisplayStatus("saving");
setError(null);
try {
const updated = await uploadAvatar(file);
setProfile(updated);
await uploadAvatar(file);
await refreshSession();
setStatus("ready");
reload();
setDisplayStatus("ready");
} catch {
setError("Failed to upload avatar");
setStatus("ready");
setDisplayStatus("ready");
}
},
[refreshSession]
[refreshSession, reload]
);
const avatarUrl = profile?.avatar_url ?? null;
@@ -94,19 +92,11 @@ export const ProfilePage = () => {
<section className="stack">
<h1>Profile</h1>
{status === "loading" && <p className="muted">Loading profile...</p>}
{displayStatus === "loading" && <LoadingState message="Loading profile..." />}
{status === "error" && (
<div className="card stack">
<p>Failed to load profile</p>
<button className="secondary-button" onClick={() => void loadProfile()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{displayStatus === "error" && <ErrorState message="Failed to load profile" onRetry={reload} />}
{(status === "ready" || status === "saving") && profile && (
{(displayStatus === "ready" || displayStatus === "saving") && profile && (
<div className="card stack">
<div className="profile-avatar-section">
<div className="avatar-preview">
@@ -118,11 +108,11 @@ export const ProfilePage = () => {
</div>
<button
className="secondary-button"
disabled={status === "saving"}
disabled={displayStatus === "saving"}
onClick={() => fileInputRef.current?.click()}
type="button"
>
{status === "saving" ? (
{displayStatus === "saving" ? (
<>
<Icon name="loading" size="sm" />
Uploading...
@@ -146,7 +136,7 @@ export const ProfilePage = () => {
<div className="form-group">
<label htmlFor="profile-name">Name</label>
<input
disabled={status === "saving"}
disabled={displayStatus === "saving"}
id="profile-name"
onChange={(e) => setName(e.target.value)}
type="text"
@@ -157,7 +147,7 @@ export const ProfilePage = () => {
<div className="form-group">
<label htmlFor="profile-email">Email</label>
<input
disabled={status === "saving"}
disabled={displayStatus === "saving"}
id="profile-email"
onChange={(e) => setEmail(e.target.value)}
type="email"
@@ -170,11 +160,11 @@ export const ProfilePage = () => {
<div className="form-actions">
<button
className="primary-button"
disabled={status === "saving"}
disabled={displayStatus === "saving"}
onClick={() => void handleSave()}
type="button"
>
{status === "saving" ? (
{displayStatus === "saving" ? (
<>
<Icon name="loading" size="sm" />
Saving...
+13 -35
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { Link } from "react-router-dom";
@@ -10,15 +10,15 @@ import {
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
import type { Project } from "../types";
type ProjectsStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => {
const [status, setStatus] = useState<ProjectsStatus>("loading");
const [projects, setProjects] = useState<Project[]>([]);
const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [formName, setFormName] = useState("");
@@ -26,21 +26,7 @@ export const ProjectsPage = () => {
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadProjects = useCallback(async () => {
setStatus("loading");
try {
const data = await listProjects();
setProjects(data);
setStatus("ready");
} catch {
setProjects([]);
setStatus("error");
}
}, []);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
const safeProjects = projects ?? [];
const openCreate = () => {
setFormName("");
@@ -88,7 +74,7 @@ export const ProjectsPage = () => {
await updateProject(editingProject.id, input);
}
closeDialog();
await loadProjects();
reload();
} catch {
setFormError("Failed to save project");
}
@@ -98,13 +84,13 @@ export const ProjectsPage = () => {
try {
await deleteProject(projectId);
setDeleteConfirmId(null);
await loadProjects();
reload();
} catch {
setDeleteConfirmId(null);
}
};
const isEmpty = status === "ready" && projects.length === 0;
const isEmpty = status === "ready" && safeProjects.length === 0;
return (
<section className="stack">
@@ -116,23 +102,15 @@ export const ProjectsPage = () => {
</button>
</div>
{status === "loading" && <p className="muted">Loading projects...</p>}
{status === "loading" && <LoadingState message="Loading projects..." />}
{status === "error" && (
<div className="card stack">
<p>Failed to load projects</p>
<button className="secondary-button" onClick={() => void loadProjects()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{status === "error" && <ErrorState message="Failed to load projects" onRetry={reload} />}
{isEmpty && <p className="muted">No projects yet. Create your first project above.</p>}
{isEmpty && <EmptyState message="No projects yet. Create your first project above." />}
{status === "ready" && projects.length > 0 && (
{status === "ready" && safeProjects.length > 0 && (
<div className="project-list">
{projects.map((project) => (
{safeProjects.map((project) => (
<article className="card project-card" key={project.id}>
<div className="project-info">
<h3>{project.name}</h3>
+180 -72
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { Link, useParams, useSearchParams } from "react-router-dom";
@@ -18,6 +20,8 @@ import { WorkspaceHeader } from "../components/workspace-header";
import { listToolTypes } from "../api/tool_types";
import type { ToolType } from "../api/tool_types";
type MobileTab = "files" | "editor" | "git" | "terminal";
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
interface FileTreeEntry {
@@ -43,6 +47,8 @@ interface Project {
export const RepoWorkspace = () => {
const { projectId } = useParams<{ projectId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useMobileViewport();
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
const [status, setStatus] = useState<WorkspaceStatus>("loading");
const [project, setProject] = useState<Project | null>(null);
@@ -159,26 +165,16 @@ export const RepoWorkspace = () => {
)}
{status === "loading" && (
<p className="muted">Loading repositories...</p>
<LoadingState message="Loading repositories..." />
)}
{status === "error" && (
<div className="card stack">
<p>Failed to load repositories</p>
<button
className="secondary-button"
onClick={() => void loadRepositories()}
type="button"
>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
)}
{status === "empty" && (
<div className="card stack">
<p>No repositories in this project yet.</p>
<EmptyState message="No repositories in this project yet." />
<Link
className="primary-button"
to={`/projects/${projectId}/settings/repositories`}
@@ -190,67 +186,70 @@ export const RepoWorkspace = () => {
{status === "ready" && repositories.length > 0 && (
<>
{selectedRepoId && (
<GitToolbar
projectId={projectId!}
repoId={selectedRepoId}
currentBranch={currentBranch}
branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)}
isMirror={Boolean(selectedRepo?.is_mirror)}
onBranchChange={(branch) => {
setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams);
newParams.set("branch", branch);
setSearchParams(newParams);
}}
onRefresh={() => {
void loadBranches();
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
)}
<div className="workspace-layout">
<aside className="workspace-sidebar">
<div className="sidebar-section">
<label className="form-field">
Repository
{isMobile ? (
// Mobile Layout
<div className="mobile-workspace">
<div className="mobile-workspace-header">
<select
value={selectedRepoId || ""}
onChange={(e) => handleRepoChange(e.target.value)}
className="mobile-repo-selector"
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
{selectedRepoId && (
<select
value={selectedRepoId || ""}
onChange={(e) => handleRepoChange(e.target.value)}
value={currentBranch}
onChange={(e) => {
const branch = e.target.value;
setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams);
newParams.set("branch", branch);
setSearchParams(newParams);
}}
className="mobile-branch-selector"
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
{branches.map((branch) => (
<option key={branch} value={branch}>
{branch}
</option>
))}
</select>
</label>
)}
</div>
{selectedRepoId && (
<>
<div className="mobile-workspace-content">
{mobileTab === "files" && selectedRepoId && (
<FileBrowser
projectId={projectId!}
repoId={selectedRepoId}
gitStatus={gitStatus}
/>
{gitStatus && (
<CommitPanel
projectId={projectId!}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={() => {
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
)}
{selectedRepoId && (
{mobileTab === "editor" && selectedRepoId && (
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
)}
{mobileTab === "git" && selectedRepoId && gitStatus && (
<div className="mobile-git-view">
<CommitPanel
projectId={projectId!}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={() => {
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
</div>
)}
{mobileTab === "terminal" && selectedRepoId && (
<InstanceList
projectId={projectId!}
repoId={selectedRepoId}
@@ -259,17 +258,126 @@ export const RepoWorkspace = () => {
toolTypes={toolTypes}
/>
)}
</>
)}
</aside>
</div>
<main className="workspace-main">
{selectedRepoId && (
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
)}
</main>
</div>
</>
<div className="mobile-workspace-tabs">
<button
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
onClick={() => setMobileTab("files")}
type="button"
>
<Icon name="folder" size="sm" />
<span>Files</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
onClick={() => setMobileTab("editor")}
type="button"
>
<Icon name="edit" size="sm" />
<span>Editor</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
onClick={() => setMobileTab("git")}
type="button"
>
<Icon name="branch" size="sm" />
<span>Git</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
onClick={() => setMobileTab("terminal")}
type="button"
>
<Icon name="terminal" size="sm" />
<span>Terminal</span>
</button>
</div>
</div>
) : (
// Desktop Layout
<>
{selectedRepoId && (
<GitToolbar
projectId={projectId!}
repoId={selectedRepoId}
currentBranch={currentBranch}
branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)}
isMirror={Boolean(selectedRepo?.is_mirror)}
onBranchChange={(branch) => {
setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams);
newParams.set("branch", branch);
setSearchParams(newParams);
}}
onRefresh={() => {
void loadBranches();
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
)}
<div className="workspace-layout">
<aside className="workspace-sidebar">
<div className="sidebar-section">
<label className="form-field">
Repository
<select
value={selectedRepoId || ""}
onChange={(e) => handleRepoChange(e.target.value)}
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
</label>
</div>
{selectedRepoId && (
<>
<FileBrowser
projectId={projectId!}
repoId={selectedRepoId}
gitStatus={gitStatus}
/>
{gitStatus && (
<CommitPanel
projectId={projectId!}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={() => {
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
)}
<InstanceList
projectId={projectId!}
repoId={selectedRepoId}
projectName={project?.name}
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
toolTypes={toolTypes}
/>
</>
)}
</aside>
<main className="workspace-main">
{selectedRepoId && (
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
)}
</main>
</div>
</>
)}
</>
)}
</section>
);
@@ -369,7 +477,7 @@ const FileBrowser = ({
</button>
)}
{entries.length === 0 && (
<p className="muted">No files in this repository yet.</p>
<EmptyState message="No files in this repository yet." />
)}
{entries.map((entry) => {
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
+20 -117
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { listProjects } from "../api/projects";
import type { Project } from "../types";
@@ -7,23 +6,20 @@ import { listRepositories, type GitRepository } from "../api/git_repositories";
import {
getUserSessions,
type Session,
deleteInstance,
stopInstance,
checkInstanceHealth,
recreateInstanceTunnel,
} from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { getUserConfig, updateUserConfig } from "../api/settings";
import { Icon } from "../components/icon";
import { ErrorState, LoadingState } from "../components/data-states";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { SessionCard } from "../components/session-card";
import { useInstanceActions } from "../hooks/use-instance-actions";
import type { InstanceHealth } from "../api/sessions";
type SessionsStatus = "loading" | "ready" | "error";
export const SessionsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
@@ -33,12 +29,7 @@ export const SessionsPage = () => {
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState<string>("");
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [loadingAction, setLoadingAction] = useState<string>("");
const loadSessions = useCallback(async () => {
setStatus("loading");
@@ -83,7 +74,18 @@ export const SessionsPage = () => {
void loadToolTypes();
}, []);
const {
loadingSessionId,
dirtyDeleteSession,
dirtyDeleteFiles,
handleOpen,
handleStart,
handleStop,
handleDelete,
handleForceDelete,
handleRecreateTunnel,
clearDirtyDelete,
} = useInstanceActions({ onRefresh: loadSessions });
// Poll health every 30 seconds for active web-enabled instances
useEffect(() => {
@@ -154,107 +156,15 @@ export const SessionsPage = () => {
await loadSessions();
};
const handleStop = async (session: Session) => {
setLoadingSessionId(session.id);
setLoadingAction("Stopping...");
try {
await stopInstance(session.project_id, session.repository_id, session.id);
await loadSessions();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
setLoadingAction("");
}
};
const handleDelete = async (session: Session) => {
setLoadingSessionId(session.id);
setLoadingAction("Deleting...");
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
// Remove from local state immediately
setSessions((prev) => prev.filter((s) => s.id !== session.id));
} catch (error) {
const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } };
if (axiosError.response?.status === 409) {
const detail = axiosError.response.data?.detail;
if (detail?.changed_files) {
setDirtyDeleteSession(session);
setDirtyDeleteFiles(detail.changed_files);
return;
}
}
} finally {
setLoadingSessionId(null);
setLoadingAction("");
}
};
const handleForceDelete = async (session: Session) => {
setLoadingSessionId(session.id);
setLoadingAction("Force deleting...");
try {
await deleteInstance(session.project_id, session.repository_id, session.id, true);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
setSessions((prev) => prev.filter((s) => s.id !== session.id));
} catch {
// ignore
} finally {
setLoadingSessionId(null);
setLoadingAction("");
}
};
const handleRecreateTunnel = async (session: Session) => {
setLoadingSessionId(session.id);
setLoadingAction("Recreating tunnel...");
try {
await recreateInstanceTunnel(
session.project_id,
session.repository_id,
session.id
);
// Refresh sessions to get new URL
await loadSessions();
} catch {
// ignore
} finally {
setLoadingSessionId(null);
setLoadingAction("");
}
};
const handleOpen = (session: Session) => {
if (session.url) {
window.open(session.url, '_blank', 'noopener,noreferrer');
} else if (session.tool_type_interfaces?.includes("terminal")) {
navigate(`/instances/${session.id}/terminal`);
} else {
navigate(`/projects/${session.project_id}`);
}
};
return (
<section className="stack sessions-page">
<div className="page-header">
<h1>Sessions</h1>
</div>
{status === "loading" && <p className="muted">Loading sessions...</p>}
{status === "loading" && <LoadingState message="Loading sessions..." />}
{status === "error" && (
<div className="card stack">
<p>Failed to load sessions</p>
<button className="secondary-button" onClick={() => void loadSessions()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{status === "error" && <ErrorState message="Failed to load sessions" onRetry={() => void loadSessions()} />}
{status === "ready" && (
<>
@@ -273,18 +183,11 @@ export const SessionsPage = () => {
)}
{/* Session List */}
<div className={`sessions-list-wrapper ${loadingSessionId ? "dimmed" : ""}`}>
{loadingSessionId && (
<div className="loading-overlay">
<div className="loading-content">
<Icon name="loading" size="lg" />
<p>{loadingAction}</p>
</div>
</div>
)}
<div className="sessions-list-wrapper">
<SessionList
sessions={sessions}
onOpen={handleOpen}
onStart={handleStart}
onStop={handleStop}
onDelete={handleDelete}
onRecreateTunnel={handleRecreateTunnel}
@@ -309,7 +212,7 @@ export const SessionsPage = () => {
{/* Dirty Delete Confirmation Modal */}
{dirtyDeleteSession && (
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
<div className="modal-overlay" onClick={clearDirtyDelete}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Uncommitted Changes</h3>
<p>
@@ -328,7 +231,7 @@ export const SessionsPage = () => {
<div className="modal-actions">
<button
className="secondary-button"
onClick={() => setDirtyDeleteSession(null)}
onClick={clearDirtyDelete}
type="button"
>
Cancel
+11 -22
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
import { ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
type SettingsStatus = "loading" | "ready" | "error";
import { useAsyncData } from "../hooks/use-async-data";
const TABS = [
{ label: "General", path: "general" },
@@ -26,7 +26,7 @@ type SettingsOutletContext = {
export const SettingsPage = () => {
const location = useLocation();
const [status, setStatus] = useState<SettingsStatus>("loading");
const { data: loadedConfig, status, reload } = useAsyncData<UserConfig>(getUserConfig, []);
const [config, setConfig] = useState<UserConfig>({
theme: "system",
default_editor: null,
@@ -36,19 +36,12 @@ export const SettingsPage = () => {
});
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const loadConfig = useCallback(async () => {
try {
const data = await getUserConfig();
setConfig(data);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
// Sync loaded config into local editable state
useEffect(() => {
void loadConfig();
}, [loadConfig]);
if (loadedConfig) {
setConfig(loadedConfig);
}
}, [loadedConfig]);
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
setConfig((prev) => ({ ...prev, [key]: value }));
@@ -79,17 +72,13 @@ export const SettingsPage = () => {
};
if (status === "loading") {
return <section className="stack"><p className="muted">Loading settings...</p></section>;
return <section className="stack"><LoadingState message="Loading settings..." /></section>;
}
if (status === "error") {
return (
<section className="stack">
<p>Failed to load settings</p>
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
<ErrorState message="Failed to load settings" onRetry={reload} />
</section>
);
}
+19 -31
View File
@@ -1,13 +1,13 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
export const SSHKeysPage = () => {
const navigate = useNavigate();
const [keys, setKeys] = useState<SSHKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { data: keys, status, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
const [newKeyName, setNewKeyName] = useState("");
const [generating, setGenerating] = useState(false);
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
@@ -17,23 +17,9 @@ export const SSHKeysPage = () => {
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
const [mutationError, setMutationError] = useState<string | null>(null);
useEffect(() => {
loadKeys();
}, []);
async function loadKeys() {
try {
setLoading(true);
const data = await listSSHKeys();
setKeys(data);
setError(null);
} catch {
setError("Failed to load SSH keys");
} finally {
setLoading(false);
}
}
const safeKeys = keys ?? [];
async function handleGenerate(e: React.FormEvent) {
e.preventDefault();
@@ -45,7 +31,7 @@ export const SSHKeysPage = () => {
setNewKeyName("");
await loadKeys();
} catch {
setError("Failed to generate SSH key");
setMutationError("Failed to generate SSH key");
} finally {
setGenerating(false);
}
@@ -58,7 +44,7 @@ export const SSHKeysPage = () => {
await deleteSSHKey(keyId);
await loadKeys();
} catch {
setError("Failed to delete SSH key");
setMutationError("Failed to delete SSH key");
}
}
@@ -74,9 +60,9 @@ export const SSHKeysPage = () => {
setSigning((prev) => ({ ...prev, [keyId]: true }));
const result = await signPayload(keyId, { payload: payload.trim() });
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
setError(null);
setMutationError(null);
} catch {
setError("Failed to sign payload");
setMutationError("Failed to sign payload");
} finally {
setSigning((prev) => ({ ...prev, [keyId]: false }));
}
@@ -94,15 +80,15 @@ export const SSHKeysPage = () => {
signature: signature.trim(),
});
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
setError(null);
setMutationError(null);
} catch {
setError("Failed to verify signature");
setMutationError("Failed to verify signature");
} finally {
setVerifying((prev) => ({ ...prev, [keyId]: false }));
}
}
if (loading) return <div>Loading...</div>;
if (status === "loading") return <LoadingState message="Loading SSH keys..." />;
return (
<section className="stack">
@@ -116,7 +102,7 @@ export const SSHKeysPage = () => {
</button>
</div>
{error && <div className="error">{error}</div>}
{mutationError && <div className="error">{mutationError}</div>}
<form onSubmit={handleGenerate} className="stack">
<div className="form-group">
@@ -145,11 +131,13 @@ export const SSHKeysPage = () => {
</button>
</form>
{status === "error" && <ErrorState message="Failed to load SSH keys" onRetry={loadKeys} />}
<div className="keys-list">
{keys.length === 0 ? (
<p className="muted">No SSH keys yet. Generate one above.</p>
{safeKeys.length === 0 ? (
<EmptyState message="No SSH keys yet. Generate one above." />
) : (
keys.map((key) => (
safeKeys.map((key) => (
<div key={key.id} className="key-card">
<div className="key-header">
<h3>{key.name}</h3>
+290 -16
View File
@@ -1,5 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { extractErrorMessage } from "../utils/errors";
import { MobileListView } from "../components/mobile-list-view";
import { MobileDetailView } from "../components/mobile-detail-view";
import { MobileEditView } from "../components/mobile-edit-view";
import { MobileFAB } from "../components/mobile-fab";
import {
createToolType,
deleteToolType,
@@ -31,7 +38,11 @@ import {
type RightPanelTab = "details" | "configs" | "folders";
type Status = "loading" | "ready" | "error";
type MobileView = "list" | "detail" | "edit";
export const ToolWorkshopPage = () => {
const isMobile = useMobileViewport();
const [mobileView, setMobileView] = useState<MobileView>("list");
const [status, setStatus] = useState<Status>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
@@ -58,6 +69,7 @@ export const ToolWorkshopPage = () => {
readiness_timeout: "30",
readiness_interval: "2",
required_variables: "",
startup_command: "",
});
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
const [toolTypeDirty, setToolTypeDirty] = useState(false);
@@ -131,6 +143,7 @@ export const ToolWorkshopPage = () => {
readiness_timeout: "30",
readiness_interval: "2",
required_variables: "",
startup_command: "",
});
setToolTypeError(null);
setToolTypeDirty(false);
@@ -152,6 +165,7 @@ export const ToolWorkshopPage = () => {
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
required_variables: toolType.required_variables?.join(", ") || "",
startup_command: toolType.startup_command || "",
});
setToolTypeError(null);
setToolTypeDirty(false);
@@ -189,16 +203,6 @@ export const ToolWorkshopPage = () => {
setShowFolderForm(false);
};
const extractErrorMessage = (err: unknown): string => {
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
const detail = axiosError?.response?.data?.detail;
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
return detail.map(d => typeof d === 'string' ? d : d.msg || JSON.stringify(d)).join(', ');
}
return "Failed to save";
};
const handleToolTypeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setToolTypeError(null);
@@ -250,6 +254,7 @@ export const ToolWorkshopPage = () => {
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
readiness_probe: readinessProbe,
required_variables: variables,
startup_command: toolTypeForm.startup_command.trim() || undefined,
};
const newTool = await createToolType(input);
setIsCreating(false);
@@ -268,6 +273,7 @@ export const ToolWorkshopPage = () => {
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
readiness_probe: readinessProbe,
required_variables: variables,
startup_command: toolTypeForm.startup_command.trim() || undefined,
};
await updateToolType(selectedToolType.id, input);
setToolTypeDirty(false);
@@ -486,7 +492,7 @@ export const ToolWorkshopPage = () => {
if (status === "loading") {
return (
<div className="container">
<p>Loading Tool Workshop...</p>
<LoadingState message="Loading Tool Workshop..." />
</div>
);
}
@@ -494,14 +500,264 @@ export const ToolWorkshopPage = () => {
if (status === "error") {
return (
<div className="container">
<p className="text-error">Failed to load Tool Workshop.</p>
<button onClick={loadData}>
<Icon name="refresh" size="sm" /> Retry
</button>
<ErrorState message="Failed to load Tool Workshop." onRetry={loadData} />
</div>
);
}
if (isMobile) {
if (mobileView === "list") {
return (
<div className="mobile-page">
<div className="mobile-page-header">
<h1>Tool Workshop</h1>
<span className="muted">{toolTypes.length} tool types</span>
</div>
<MobileListView
items={toolTypes.map((t) => ({
id: t.id,
title: t.display_name,
subtitle: `${t.category || "Uncategorized"} · ${t.interface_type === "web" ? `Port ${t.default_port}` : "Terminal"}`,
}))}
onItemClick={(id) => {
setSelectedToolTypeId(id);
setIsCreating(false);
setMobileView("detail");
}}
emptyMessage="No tool types yet"
/>
<MobileFAB onClick={() => {
setSelectedToolTypeId(null);
setIsCreating(true);
resetToolTypeForm();
setMobileView("edit");
}} />
</div>
);
}
if (mobileView === "detail" && selectedToolType) {
return (
<MobileDetailView
title={selectedToolType.display_name}
subtitle={`${selectedToolType.name} · ${selectedToolType.definition_type} · ${selectedToolType.interface_type === "web" ? `Port ${selectedToolType.default_port}` : "Terminal"}`}
fields={[
{ label: "Name", value: selectedToolType.name },
{ label: "Display Name", value: selectedToolType.display_name },
{ label: "Description", value: selectedToolType.description },
{ label: "Category", value: selectedToolType.category },
{ label: "Interface Type", value: selectedToolType.interface_type },
{ label: "Requires Port", value: selectedToolType.requires_port, type: "boolean" },
{ label: "Default Port", value: selectedToolType.default_port },
{ label: "Definition Type", value: selectedToolType.definition_type },
{ label: "Startup Command", value: selectedToolType.startup_command },
{ label: "Readiness Command", value: selectedToolType.readiness_probe?.command ?? null },
{ label: "Readiness Timeout", value: selectedToolType.readiness_probe?.timeout ?? null },
{ label: "Readiness Interval", value: selectedToolType.readiness_probe?.interval ?? null },
{ label: "Required Variables", value: selectedToolType.required_variables?.join(", ") ?? null },
{ label: "Compose Template", value: selectedToolType.compose_template, type: "code" },
{ label: "Dockerfile Template", value: selectedToolType.dockerfile_template, type: "code" },
]}
onEdit={() => {
populateToolTypeForm(selectedToolType);
setIsCreating(false);
setMobileView("edit");
}}
onDelete={() => {
handleDeleteToolType(selectedToolType.id);
setMobileView("list");
}}
onBack={() => {
setSelectedToolTypeId(null);
setMobileView("list");
}}
/>
);
}
if (mobileView === "edit") {
return (
<MobileEditView
title={isCreating ? "Create Tool Type" : "Edit Tool Type"}
onCancel={() => {
if (toolTypeDirty) {
if (!window.confirm("You have unsaved changes. Discard them?")) {
return;
}
}
setMobileView(isCreating ? "list" : "detail");
}}
onSave={() => {
// Create a synthetic form event to call handleToolTypeSubmit
const syntheticEvent = { preventDefault: () => {} } as React.FormEvent;
void handleToolTypeSubmit(syntheticEvent);
if (!toolTypeError) {
setMobileView("list");
}
}}
isSaving={false}
>
{/* Tool Type Form Fields */}
<div className="mobile-form-group">
<label className="mobile-form-label">Name *</label>
<input
type="text"
value={toolTypeForm.name}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, name: e.target.value })}
className="mobile-form-input"
placeholder="e.g., my-tool"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Display Name *</label>
<input
type="text"
value={toolTypeForm.display_name}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, display_name: e.target.value })}
className="mobile-form-input"
placeholder="e.g., My Tool"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Description</label>
<textarea
value={toolTypeForm.description}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, description: e.target.value })}
className="mobile-form-textarea"
placeholder="What does this tool do?"
rows={3}
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Category</label>
<input
type="text"
value={toolTypeForm.category}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, category: e.target.value })}
className="mobile-form-input"
placeholder="e.g., development"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Interface Type</label>
<select
value={toolTypeForm.interface_type}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, interface_type: e.target.value as "web" | "terminal" })}
className="mobile-form-select"
>
<option value="web">Web</option>
<option value="terminal">Terminal</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Requires Port</label>
<input
type="checkbox"
checked={toolTypeForm.requires_port}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, requires_port: e.target.checked })}
className="mobile-form-checkbox"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Default Port</label>
<input
type="text"
value={toolTypeForm.default_port}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, default_port: e.target.value })}
className="mobile-form-input"
placeholder="e.g., 8080"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Definition Type</label>
<select
value={toolTypeForm.definition_type}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, definition_type: e.target.value as "compose" | "dockerfile" })}
className="mobile-form-select"
>
<option value="compose">Compose</option>
<option value="dockerfile">Dockerfile</option>
</select>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Startup Command</label>
<input
type="text"
value={toolTypeForm.startup_command}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, startup_command: e.target.value })}
className="mobile-form-input"
placeholder="Command to run on startup"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Command</label>
<input
type="text"
value={toolTypeForm.readiness_command}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
className="mobile-form-input"
placeholder="e.g., curl -f http://localhost:8080/health"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Timeout</label>
<input
type="text"
value={toolTypeForm.readiness_timeout}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
className="mobile-form-input"
placeholder="30"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Readiness Interval</label>
<input
type="text"
value={toolTypeForm.readiness_interval}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
className="mobile-form-input"
placeholder="2"
/>
</div>
<div className="mobile-form-group">
<label className="mobile-form-label">Required Variables</label>
<input
type="text"
value={toolTypeForm.required_variables}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, required_variables: e.target.value })}
className="mobile-form-input"
placeholder="VAR1, VAR2, VAR3"
/>
</div>
{toolTypeForm.definition_type === "compose" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Compose Template</label>
<textarea
value={toolTypeForm.compose_template}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, compose_template: e.target.value })}
className="mobile-form-textarea mobile-form-code"
placeholder="version: '3'"
rows={10}
/>
</div>
)}
{toolTypeForm.definition_type === "dockerfile" && (
<div className="mobile-form-group">
<label className="mobile-form-label">Dockerfile Template</label>
<textarea
value={toolTypeForm.dockerfile_template}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, dockerfile_template: e.target.value })}
className="mobile-form-textarea mobile-form-code"
placeholder="FROM ubuntu:22.04"
rows={10}
/>
</div>
)}
</MobileEditView>
);
}
}
return (
<div className="container" style={{ display: "flex", height: "calc(100vh - 4rem)", gap: 0, padding: 0 }}>
{/* Left Sidebar - Tool List */}
@@ -772,6 +1028,24 @@ export const ToolWorkshopPage = () => {
</div>
</div>
{toolTypeForm.interface_type === "terminal" && (
<div className="form-group">
<label htmlFor="tool-type-startup-command">Startup Command</label>
<input
id="tool-type-startup-command"
type="text"
value={toolTypeForm.startup_command}
onChange={(e) => {
setToolTypeForm({ ...toolTypeForm, startup_command: e.target.value });
setToolTypeDirty(true);
}}
placeholder="e.g., cd /workspace && ls"
className="form-input"
/>
<small className="form-help">Command to run before the interactive shell for each new terminal session.</small>
</div>
)}
{toolTypeForm.requires_port && (
<div className="form-group">
<label htmlFor="tool-type-default-port">Default Port *</label>
@@ -1029,7 +1303,7 @@ export const ToolWorkshopPage = () => {
<div className="stack" style={{ gap: "0.5rem" }}>
{toolConfigs.length === 0 ? (
<p className="muted">No configurations for this tool type yet.</p>
<EmptyState message="No configurations for this tool type yet." />
) : (
toolConfigs.map((config) => (
<div
+666 -43
View File
@@ -2449,6 +2449,7 @@ a.nav-item,
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
position: relative;
}
.instance-info {
@@ -2481,6 +2482,43 @@ a.nav-item,
align-items: center;
}
.instance-card.busy {
opacity: 0.7;
}
.instance-busy-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
background: rgba(var(--bg-rgb, 255, 255, 255), 0.8);
border-radius: 10px;
z-index: 1;
font-size: var(--text-sm);
color: var(--muted);
}
.session-card {
position: relative;
}
.session-card.busy {
opacity: 0.7;
}
.session-busy-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--bg-rgb, 255, 255, 255), 0.8);
border-radius: var(--space-2);
z-index: 1;
}
/* ============================================
Terminal Styles
============================================ */
@@ -2595,26 +2633,17 @@ a.nav-item,
position: relative;
width: 100%;
height: 100%;
padding: var(--space-2);
padding: 0;
overflow: hidden;
}
.terminal-container .xterm {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* xterm.js manages its own positioning and sizing */
.terminal-container canvas {
display: block;
}
/* Ensure xterm fills container */
.terminal-container .xterm-viewport {
width: 100% !important;
}
/* xterm.js manages its own viewport dimensions - do not override */
/* Mobile terminal container - no padding to maximize space */
.terminal-wrapper.mobile .terminal-container {
@@ -2866,6 +2895,10 @@ a.nav-item,
pointer-events: none;
}
.create-session-form-wrapper {
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
@@ -3070,17 +3103,20 @@ a.nav-item,
Mobile Terminal Styles
============================================ */
.mobile-terminal-wrapper {
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"header"
"content"
"keys";
/* Mobile terminal shell - fills viewport */
.shell.mobile-terminal-shell {
height: 100vh;
height: 100dvh; /* Dynamic viewport height for mobile */
max-height: 100vh;
max-height: 100dvh;
height: 100dvh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mobile-terminal-wrapper {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: #1e1e1e;
position: relative;
overflow: hidden;
@@ -3088,7 +3124,7 @@ a.nav-item,
/* Mobile Terminal Header */
.mobile-terminal-header {
grid-area: header;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
@@ -3189,46 +3225,43 @@ a.nav-item,
/* Mobile Terminal Content */
.mobile-terminal-content {
grid-area: content;
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
background: #1e1e1e;
min-height: 0;
max-height: 100%;
display: flex;
flex-direction: column;
}
/* Terminal wrapper - fills content area */
.terminal-wrapper.mobile {
border: none;
border-radius: 0;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
flex: 1;
min-height: 0;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.terminal-wrapper.mobile .terminal-container {
width: 100%;
height: 100%;
max-height: 100%;
flex: 1;
min-height: 0;
padding: 0;
overflow: hidden;
position: relative;
}
/* xterm fills container */
.terminal-wrapper.mobile .terminal-container .xterm {
width: 100%;
height: 100%;
}
/* xterm.js manages its own sizing */
/* xterm.js manages its own scrolling and viewport dimensions */
/* Special Keys Strip */
.special-keys-strip {
grid-area: keys;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2px;
@@ -3455,16 +3488,17 @@ a.nav-item,
/* Disable zoom on mobile terminal */
@media (max-width: 767px) {
.mobile-terminal-wrapper {
touch-action: none;
touch-action: pan-y;
-webkit-text-size-adjust: none;
}
.mobile-terminal-wrapper * {
.mobile-terminal-wrapper button,
.mobile-terminal-wrapper .special-key-button {
touch-action: manipulation;
}
.terminal-container {
touch-action: none;
touch-action: pan-y;
padding: 0;
}
}
@@ -3533,3 +3567,592 @@ a.nav-item,
transform: translateX(0);
}
}
/* Mobile Navigation */
.mobile-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
align-items: center;
background: var(--panel);
border-top: 1px solid var(--border);
padding-bottom: env(safe-area-inset-bottom, 0);
z-index: 100;
height: 64px;
}
.mobile-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 12px;
color: var(--muted);
text-decoration: none;
font-size: 11px;
min-width: 64px;
min-height: 44px;
border-radius: 8px;
transition: color 0.15s ease;
}
.mobile-nav-item.active {
color: var(--brand);
}
.mobile-nav-icon-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-nav-badge {
position: absolute;
top: -6px;
right: -8px;
background: var(--danger);
color: white;
font-size: 10px;
font-weight: 600;
min-width: 16px;
height: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
/* Mobile Bottom Sheet */
.mobile-bottom-sheet-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: flex-end;
justify-content: center;
}
.mobile-bottom-sheet {
background: var(--panel);
border-radius: 16px 16px 0 0;
width: 100%;
max-width: 600px;
max-height: 70vh;
display: flex;
flex-direction: column;
animation: slide-up 0.2s ease-out;
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.mobile-bottom-sheet-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 16px 8px;
border-bottom: 1px solid var(--border);
}
.mobile-bottom-sheet-handle {
width: 36px;
height: 4px;
background: var(--border);
border-radius: 2px;
margin-bottom: 8px;
}
.mobile-bottom-sheet-title {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.mobile-bottom-sheet-content {
padding: 8px 0;
overflow-y: auto;
}
.mobile-bottom-sheet-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 16px;
background: none;
border: none;
color: var(--text);
font-size: 16px;
text-align: left;
cursor: pointer;
min-height: 44px;
}
.mobile-bottom-sheet-item:active {
background: var(--hover);
}
.mobile-bottom-sheet-item.active {
color: var(--brand);
}
/* Mobile Page Header */
.mobile-page-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0;
margin-bottom: var(--space-3);
border-bottom: 1px solid var(--border);
}
.mobile-page-header-back {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: transparent;
border: none;
color: var(--text);
cursor: pointer;
border-radius: 8px;
padding: 0;
}
.mobile-page-header-back:active {
background: var(--color-border);
}
.mobile-page-header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
flex: 1;
}
.mobile-page-header-actions {
display: flex;
gap: var(--space-2);
}
.mobile-nav-label {
font-size: 11px;
font-weight: 500;
}
/* Mobile shell content padding adjustment */
.shell-content.mobile {
padding-bottom: calc(1.25rem + 64px);
}
/* Ensure minimum touch targets on mobile */
button,
a,
input,
select,
textarea,
[role="button"] {
min-height: 44px;
min-width: 44px;
}
/* Active states for touch feedback */
button:active,
a:active,
[role="button"]:active {
opacity: 0.8;
transform: scale(0.98);
}
/* Remove transform on buttons that shouldn't scale */
.mobile-nav-item:active,
.mobile-action-sheet-button:active,
.mobile-action-sheet-cancel:active {
transform: none;
}
@media (max-width: 767px) {
.dialog-actions {
flex-direction: column-reverse;
gap: 0.5rem;
}
.dialog-actions button {
width: 100%;
min-height: 44px;
}
}
.workspace-main {
padding: var(--space-2);
}
.tree-entry {
padding: 0.5rem;
min-height: 44px;
display: flex;
align-items: center;
}
@media (max-width: 767px) {
.modal-content {
min-width: auto;
width: calc(100% - 2rem);
max-width: 100%;
margin: 1rem;
padding: 1rem;
max-height: calc(100vh - 2rem);
}
.modal-actions {
flex-direction: column-reverse;
gap: 0.5rem;
}
.modal-actions button {
width: 100%;
min-height: 44px;
}
}
/* Mobile Sessions Page */
@media (max-width: 767px) {
.sessions-page {
padding: var(--space-2);
}
.sessions-page .page-header {
margin-bottom: var(--space-3);
}
.sessions-page .page-header h1 {
font-size: 1.25rem;
}
.last-session-section {
margin-bottom: var(--space-4);
}
.last-session-section h2 {
font-size: 1rem;
margin-bottom: var(--space-2);
}
.create-session-section {
padding: var(--space-3);
}
.create-session-section h2 {
font-size: 1rem;
margin-bottom: var(--space-2);
}
}
.session-card-actions.mobile {
display: flex;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.session-card-actions.mobile .mobile-primary {
flex: 1;
justify-content: center;
min-height: 44px;
padding: var(--space-2) var(--space-3);
}
.session-card-actions.mobile .mobile-more {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
}
@media (max-width: 767px) {
.create-session-form .form-row {
grid-template-columns: 1fr;
gap: var(--space-3);
}
.create-session-form input,
.create-session-form select,
.create-session-form textarea,
.create-session-form button {
min-height: 44px;
}
}
/* Mobile Action Sheet */
.mobile-action-sheet-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: flex-end;
justify-content: center;
animation: fadeIn 0.2s ease;
}
.mobile-action-sheet {
background: var(--bg);
border-radius: 16px 16px 0 0;
width: 100%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
animation: slideUp 0.3s ease;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.mobile-action-sheet-header {
padding: var(--space-3) var(--space-4) var(--space-2);
text-align: center;
}
.mobile-action-sheet-handle {
width: 36px;
height: 4px;
background: var(--color-border);
border-radius: 2px;
margin: 0 auto var(--space-3);
}
.mobile-action-sheet-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.mobile-action-sheet-actions {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--color-border);
margin: 0 var(--space-2);
border-radius: 12px;
overflow: hidden;
}
.mobile-action-sheet-button {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--bg);
border: none;
color: var(--text);
font-size: 1rem;
cursor: pointer;
min-height: 56px;
text-align: left;
}
.mobile-action-sheet-button.danger {
color: #cd3131;
}
.mobile-action-sheet-button:active {
background: var(--color-border);
}
.mobile-action-sheet-cancel {
display: block;
width: calc(100% - var(--space-4));
margin: var(--space-3) var(--space-2);
padding: var(--space-3);
background: var(--bg);
border: 1px solid var(--color-border);
border-radius: 12px;
color: var(--text);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
min-height: 56px;
}
.mobile-action-sheet-cancel:active {
background: var(--color-border);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
/* Git Mount Editor Styles */
.git-mount-editor {
margin-top: 1rem;
}
.git-mount-editor .section-subtitle {
margin: 0 0 0.75rem 0;
font-size: 1rem;
font-weight: 600;
}
.git-mount-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.git-mount-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.75rem;
}
.git-mount-display {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.git-mount-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.git-mount-repo {
font-weight: 600;
color: var(--text);
}
.git-mount-paths {
font-size: 0.875rem;
color: var(--text-muted);
font-family: monospace;
}
.git-mount-branch {
font-size: 0.75rem;
color: var(--accent);
background: var(--accent-bg);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
width: fit-content;
}
.git-mount-actions {
display: flex;
gap: 0.25rem;
}
.git-mount-add {
border-top: 1px solid var(--border);
padding-top: 1rem;
margin-top: 1rem;
}
.git-mount-add h5 {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
.git-mount-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.git-mount-form .form-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.git-mount-form .form-row label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.git-mount-form .form-row input,
.git-mount-form .form-row select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
color: var(--text);
font-size: 0.875rem;
}
.git-mount-form .form-row input.error,
.git-mount-form .form-row select.error {
border-color: #cd3131;
}
.git-mount-form .form-row .hint {
font-size: 0.75rem;
color: var(--text-muted);
}
.git-mount-form .form-row .error-text {
font-size: 0.75rem;
color: #cd3131;
}
.git-mount-form .form-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.new-repo-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.375rem;
}
.new-repo-form input {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface);
color: var(--text);
font-size: 0.875rem;
}
.new-repo-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
+9
View File
@@ -0,0 +1,9 @@
export const extractErrorMessage = (err: unknown): string => {
const axiosError = err as { response?: { data?: { detail?: string | Array<{ msg?: string }> } } };
const detail = axiosError?.response?.data?.detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
}
return "Failed to save";
};
+1
View File
@@ -33,6 +33,7 @@ All responses are JSON. Error responses follow this format:
- [Auth](auth.md) - Authentication endpoints
- [Projects](projects.md) - Project management
- [Repositories](repositories.md) - Git repositories and file operations
- [Config Profiles](config-profiles.md) - Config profile management with git mounts
- [Users](users.md) - User management and settings
- [Tool Types](tool-types.md) - Tool type management
- [SSH Keys](ssh-keys.md) - SSH key management
+165
View File
@@ -0,0 +1,165 @@
# Config Profiles
## Overview
Config profiles allow users to define reusable configuration sets for tool instances. Profiles can include environment variables, files, mounts, and git repository mounts. They support profile includes for composition and can be scoped to specific projects or tool types.
## Git Mounts
Git mounts allow you to mount files or directories from git repositories into tool instances at startup.
### Git Mount Object
```json
{
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
"source_path": ".",
"target_path": "/app/config",
"branch": "main"
}
```
**Fields:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `repo_id` | string (UUID) | Yes | ID of the git repository to mount from |
| `source_path` | string | No | Path within the repository (default: "."). Supports glob patterns like "*.json" or "configs/**" |
| `target_path` | string | Yes | Absolute path inside the container where files will be mounted |
| `branch` | string | No | Branch or tag to checkout before mounting (default: current branch) |
### Path Validation
- `source_path`: Must be relative (no leading `/`). Cannot contain `..` (path traversal)
- `target_path`: Must be absolute (starts with `/`). Cannot contain `..`
### Glob Patterns
The `source_path` supports standard glob patterns:
- `*.json` - Match all JSON files in root
- `configs/**` - Match all files in configs directory recursively
- `src/*.py` - Match all Python files in src directory
- `.` - Mount entire repository (default)
**Limits:**
- Maximum 100 matches per glob pattern
- Only matches within the repository boundary
### Branch Behavior
When a `branch` is specified:
1. System attempts to checkout the branch in the existing clone
2. If branch doesn't exist locally, attempts to fetch from remote and checkout
3. If checkout fails, logs warning and continues with current branch
4. No branch specified: uses current checked-out branch
**Auto-clone:** If repository is not cloned locally, the system will automatically clone it using the repository's configured SSH key.
## Endpoints
### List Config Profiles
```
GET /config-profiles
```
Query parameters:
- `project_id` (optional): Filter by project compatibility
- `tool_type_id` (optional): Filter by tool type compatibility
Response includes `git_mounts` array in each profile.
### Create Config Profile
```
POST /config-profiles
```
Request body:
```json
{
"name": "My Profile",
"git_mounts": [
{
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
"source_path": "configs/*.json",
"target_path": "/app/config",
"branch": "main"
}
]
}
```
Validation:
- All referenced repositories must exist
- Repositories must belong to the same project (if profile has project_id)
- source_path and target_path must pass path validation
### Update Config Profile
```
PUT /config-profiles/{id}
```
Same request body as create. Partial updates supported (omit fields to keep current values).
### Preview Resolved Profile
```
GET /config-profiles/{id}/preview
```
Returns the fully resolved profile with all includes merged. Git mounts from included profiles are merged with override rules (later profiles override earlier ones with same repo_id + target_path combo).
Response:
```json
{
"profile_id": "550e8400-e29b-41d4-a716-446655440000",
"profile_name": "My Profile",
"env_vars": {},
"runtime_hints": {},
"mounts": [],
"git_mounts": [
{
"repo_id": "550e8400-e29b-41d4-a716-446655440000",
"source_path": "configs/*.json",
"target_path": "/app/config",
"branch": "main"
}
],
"files": {},
"overrides": {
"env_vars": {},
"runtime_hints": {},
"files": {},
"mounts": {}
},
"included_profiles": []
}
```
## Error Handling
Git mount errors during instance startup are non-blocking:
- Missing repository: Mount skipped, warning logged
- Clone failure: Mount skipped, warning logged
- Invalid paths: Mount skipped, warning logged
- Branch checkout failure: Falls back to current branch, warning logged
Instance startup continues normally even if some git mounts fail.
## Profile Resolution
When a profile includes other profiles, git mounts are merged:
- Same `repo_id` + `target_path` combo: later profile overrides
- Different combos: both are kept
- Branch conflicts: later profile wins
Example:
```
Base Profile: git_mounts = [{repo_a, /app, main}]
Included Profile: git_mounts = [{repo_a, /app, develop}, {repo_b, /data}]
Resolved: git_mounts = [{repo_a, /app, develop}, {repo_b, /data}]
```
+67
View File
@@ -8,6 +8,73 @@ All endpoints require authentication (session cookie).
---
## GET /repositories
**Description:** List all repositories owned by the user, including external repositories not tied to any project.
### Response
#### Success (200 OK)
```json
[
{
"id": "uuid",
"name": "my-external-repo",
"remote_url": "https://github.com/user/repo.git",
"is_mirror": false,
"project_id": null,
"owner_id": "uuid",
"created_at": "2024-01-01T00:00:00Z"
}
]
```
---
## POST /repositories
**Description:** Create a new external repository (not tied to any project). External repositories can be used across all projects for config profile git mounts.
### Request
#### Request Body
```json
{
"name": "my-external-repo",
"remote_url": "https://github.com/user/repo.git",
"ssh_key_id": "uuid"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Repository name (unique per user for external repos) |
| `remote_url` | `string` | No | Remote URL to clone from |
| `ssh_key_id` | `string` | No | SSH key ID for authentication |
| `force_original_url` | `boolean` | No | Skip URL parsing (default: false) |
### Response
#### Success (201 Created)
```json
{
"id": "uuid",
"name": "my-external-repo",
"path": "/data/repos/external/{user_id}/{repo_id}",
"remote_url": "https://github.com/user/repo.git",
"is_mirror": false,
"project_id": null,
"owner_id": "uuid",
"ssh_key_id": "uuid",
"created_at": "2024-01-01T00:00:00Z"
}
```
---
## GET /projects/{project_id}/repositories
**Description:** List repositories in a project.
+159
View File
@@ -0,0 +1,159 @@
# Using Git Repositories in Config Profiles
## Overview
Config profiles now support mounting files and directories from git repositories directly into your tool instances. This is useful for:
- Sharing configuration files across multiple instances
- Mounting dotfiles or development environment configs
- Including shared code or assets from other repositories
- Pinning specific branches or versions of dependencies
## How It Works
When you start a tool instance with a config profile that has git mounts:
1. The system checks if the repository is cloned locally
2. If not cloned and a remote URL is available, it automatically clones the repository
3. If a branch is specified, it checks out that branch
4. Files matching the source path pattern are mounted as bind mounts into the container
5. Instance startup continues normally
## Adding Git Mounts
### Step 1: Select a Repository
In the config profile editor, find the "Git Mounts" section. Choose a repository from the dropdown. Only repositories from your projects are available.
### Step 2: Configure Source Path
The source path determines which files from the repository to mount:
- **`.`** (default): Mount the entire repository
- **`configs/`**: Mount the configs directory
- **`*.json`**: Mount all JSON files in the repository root
- **`src/**/*.py`**: Mount all Python files in the src directory recursively
**Glob patterns are supported** - use `*` for any characters, `**` for recursive matching.
### Step 3: Set Target Path
The target path is where files appear inside the container:
- `/app/config` - Mount to /app/config
- `/home/user/dotfiles` - Mount to user's home directory
- `/workspace/shared` - Mount to workspace shared folder
Target paths must be absolute (start with `/`).
### Step 4: Optional Branch Selection
You can pin a specific branch or tag:
- `main` - Use the main branch
- `develop` - Use the develop branch
- `v1.2.3` - Pin to a specific tag
If not specified, the current checked-out branch is used.
## Examples
### Dotfiles Configuration
Mount your dotfiles repository into the home directory:
```
Repository: dotfiles
Source Path: .
Target Path: /home/user
Branch: main
```
### Shared Configuration Files
Mount only JSON config files from a shared config repo:
```
Repository: shared-configs
Source Path: *.json
Target Path: /app/config
Branch: production
```
### Development Tools Configuration
Mount specific tool configs:
```
Repository: dev-tools
Source Path: vscode/
Target Path: /workspace/.vscode
```
### Multiple Mounts
You can add multiple git mounts to a single profile:
1. Dotfiles → `/home/user`
2. Shared configs → `/app/config`
3. Assets → `/app/static`
## Profile Includes
Git mounts work with profile includes. If Profile A includes Profile B:
- Both profiles' git mounts are merged
- Same repository + target path combinations override (later profile wins)
- Different combinations are kept
Example:
```
Base Profile:
- repo: dotfiles, target: /home/user, branch: main
Development Profile (includes Base):
- repo: dotfiles, target: /home/user, branch: develop
- repo: dev-tools, target: /opt/tools
Resolved Result:
- repo: dotfiles, target: /home/user, branch: develop (overridden)
- repo: dev-tools, target: /opt/tools (added)
```
## Error Handling
Git mounts are non-blocking:
- **Repository not found**: Mount is skipped, instance continues starting
- **Clone fails**: Mount is skipped, warning logged
- **Branch doesn't exist**: Falls back to current branch, warning logged
- **Glob pattern matches nothing**: Mount is skipped, warning logged
- **Path outside repository**: Match is skipped, warning logged
You can check the instance logs to see which mounts succeeded and which failed.
## Best Practices
1. **Use specific paths**: Instead of mounting the entire repository, mount only the files you need. This reduces startup time and avoids conflicts.
2. **Pin branches**: For reproducible environments, pin specific branches or tags rather than using the default branch.
3. **Keep repositories small**: Large repositories take longer to clone. Consider splitting config repositories from code repositories.
4. **Use absolute target paths**: Always use absolute paths (starting with `/`) for target paths to ensure files end up in the expected location.
5. **Test includes**: When using profile includes, use the Preview feature to verify that git mounts are merged as expected.
## Troubleshooting
**Issue**: Git mount not appearing in container
**Solution**: Check instance logs for warnings. Common causes: repository not found, clone failure, or source path not matching any files.
**Issue**: Wrong branch mounted
**Solution**: Verify branch name is correct. If branch doesn't exist locally, the system falls back to the current branch. Ensure the remote has the branch.
**Issue**: Too many files matched
**Solution**: Use more specific glob patterns. The system limits matches to 100 files per glob pattern.
**Issue**: Permission denied
**Solution**: Ensure the target path inside the container is writable. Some paths like `/usr` or `/etc` may require root access.
@@ -0,0 +1,62 @@
## Context
The system provides terminal access to running tool instances via WebSocket, spawning a `bash -il` shell inside the container using `docker exec`. Currently, there is no way to customize what runs when a new terminal session starts.
The OpenCode tool type provides a web terminal interface but lacks common productivity utilities (`tmux`, `ranger`) that developers expect.
Existing related specs:
- `tool-types-definition`: Defines the ToolType model and CRUD API
- `tool-terminal`: Defines WebSocket terminal session behavior
- `opencode-web-server`: Defines OpenCode container requirements
- `tool-config-management`: ToolConfig already has `start_command` for runtime process startup
## Goals / Non-Goals
**Goals:**
- Allow tool type authors to specify a `startup_command` that executes for every new terminal session
- Execute the startup command before the interactive shell in terminal sessions
- Make `tmux` and `ranger` available in OpenCode containers
- Support creating and editing `startup_command` via the Tool Workshop UI
**Non-Goals:**
- Per-instance startup command overrides (out of scope; can be added later)
- Startup commands for web interface tools (only terminal sessions)
- Changing the tool's main process start command (already handled by `tool-config-management`)
## Decisions
### 1. Add `startup_command` to ToolType model
**Rationale**: The startup command is a property of the tool type itself, defining the environment/setup expected for that tool. This aligns with how tool types define other container behavior.
**Alternative considered**: Adding it to ToolConfig. Rejected because ToolConfig is per-user configuration, and startup behavior is more of a tool type contract.
### 2. Execute startup command via bash -c before interactive shell
**Rationale**: The simplest approach that works with any shell. We'll construct the command as: `bash -c "<startup_command>" && bash -il` or use a here-document approach.
**Alternative considered**: Writing a startup script to the container filesystem. Rejected because it requires container filesystem modification and doesn't work well with read-only containers.
### 3. Pass startup_command through TerminalSession.start()
**Rationale**: The TerminalSession is responsible for spawning the shell, so it needs the command. The terminal_manager will fetch the tool type's startup_command from the database when creating a session.
**Implementation**: Modify `TerminalManager.get_or_create_session()` to accept an optional `startup_command` parameter. The terminal API endpoint will fetch the tool type via the instance and pass it.
### 4. Install tmux and ranger via compose template
**Rationale**: OpenCode is defined as a Docker Compose tool type. The most maintainable approach is to install utilities via the container's package manager in the compose template (e.g., via a custom Dockerfile or init commands).
**Alternative considered**: Building a custom OpenCode Docker image. Rejected because it adds operational complexity; installing via compose is sufficient for now.
## Risks / Trade-offs
- [Risk] Long-running startup commands could delay terminal availability → Mitigation: Document that startup commands should be fast; consider adding a timeout in a future iteration
- [Risk] Startup command failures could prevent shell access → Mitigation: Use `&&` to chain; if the startup command fails, the shell still starts (use `;` or `|| true` pattern). Actually, use: `bash -c "<cmd>" || true; exec bash -il`
- [Risk] UI clutter from additional field in Tool Workshop → Mitigation: Show `startup_command` only for terminal interface types
## Migration Plan
1. Database migration: Add `startup_command` text column to `tool_types` table
2. Backend: Update ToolType model, Pydantic schemas, API endpoints
3. Backend: Update TerminalSession to accept and execute startup_command
4. Backend: Update terminal WebSocket endpoint to fetch and pass startup_command
5. Frontend: Add `startup_command` field to Tool Workshop form
6. Infrastructure: Update OpenCode compose template to install tmux and ranger
7. Tests: Update existing tests and add new ones for startup command behavior
## Open Questions
None at this time.
@@ -0,0 +1,29 @@
## Why
Terminal tools currently spawn a default shell when opening a new session, providing no way for tool authors or users to customize the initial environment or run setup commands. Additionally, the OpenCode container lacks common productivity tools (tmux, ranger) that developers expect in a modern terminal environment. These gaps limit the utility and customization of terminal-based tool instances.
## What Changes
- Add `startup_command` field to tool type definitions, allowing tool authors to specify a command that runs for every new terminal session
- Execute the startup command before the interactive shell when spawning new terminal sessions via WebSocket
- Update the OpenCode container image to install `tmux` and `ranger` for improved developer experience
- Update the OpenCode built-in tool type to optionally set a default startup command
- Extend the tool type API and UI to support creating and editing `startup_command`
## Capabilities
### New Capabilities
- `tool-terminal-startup-command`: Terminal tool types can define a startup command executed for each new session
### Modified Capabilities
- `tool-types-definition`: Add `startup_command` field to the ToolType model and CRUD endpoints
- `tool-terminal`: Terminal session spawning must execute the startup command before the interactive shell
- `opencode-web-server`: OpenCode container image should include tmux and ranger
## Impact
- **Backend**: ToolType model, API schemas, terminal session spawning logic
- **Frontend**: Tool type creation/edit forms
- **Infrastructure**: OpenCode Dockerfile or container build configuration
- **Database**: Migration to add `startup_command` column to tool_types table
- **APIs**: `POST/PUT /api/tool-types` will accept new `startup_command` field
@@ -0,0 +1,24 @@
## MODIFIED Requirements
### Requirement: OpenCode runs a web server
The system SHALL configure OpenCode containers to run a web server accessible on port 3000.
#### Scenario: OpenCode container starts
- **GIVEN** an OpenCode tool instance
- **WHEN** the container starts
- **THEN** a web server is running on port 3000 inside the container
- **AND** the server serves a web terminal interface
- **AND** the container has `tmux` installed
- **AND** the container has `ranger` installed
### Requirement: OpenCode web terminal displays properly
The system SHALL serve a functional web terminal interface for OpenCode.
#### Scenario: User opens OpenCode web UI
- **GIVEN** a running OpenCode instance
- **WHEN** the user clicks the "Open" button
- **THEN** a new tab opens with the OpenCode web interface
- **AND** the interface shows a terminal connected to the OpenCode process
- **AND** the user can run `tmux` and `ranger` commands
@@ -0,0 +1,47 @@
## ADDED Requirements
### Requirement: Terminal tool types can define a startup command
The system SHALL allow tool types to specify a `startup_command` that runs before the interactive shell for each new terminal session.
#### Scenario: Tool type with startup command
- **GIVEN** a tool type with `interface_type` = "terminal" and `startup_command` = "cd /workspace && ls"
- **WHEN** a user opens a terminal session to an instance of this tool type
- **THEN** the startup command executes before the interactive shell starts
- **AND** the user sees the output of the startup command in the terminal
#### Scenario: Tool type without startup command
- **GIVEN** a tool type with `interface_type` = "terminal" and no `startup_command`
- **WHEN** a user opens a terminal session
- **THEN** the interactive shell starts immediately without any startup execution
#### Scenario: Startup command failure does not block shell
- **GIVEN** a tool type with `startup_command` = "exit 1"
- **WHEN** a user opens a terminal session
- **THEN** the startup command runs and fails
- **AND** the interactive shell still starts afterward
### Requirement: Startup command is stored on the tool type
The system SHALL persist `startup_command` as a field on the `tool_types` table.
#### Scenario: Create tool type with startup command
- **GIVEN** a user creating a tool type
- **WHEN** they provide `startup_command` = "source /etc/profile"
- **THEN** the tool type is created with the startup command stored
#### Scenario: Update tool type startup command
- **GIVEN** an existing tool type with a startup command
- **WHEN** an admin updates `startup_command` to a new value
- **THEN** the tool type is updated
- **AND** new terminal sessions use the updated startup command
### Requirement: Startup command is optional
The system SHALL treat `startup_command` as an optional field on tool types.
#### Scenario: Create tool type without startup command
- **GIVEN** a user creating a terminal tool type
- **WHEN** they omit `startup_command`
- **THEN** the tool type is created successfully
- **AND** terminal sessions start normally without a startup command
@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: WebSocket Terminal
The system SHALL provide terminal sessions via WebSocket.
#### Scenario: Open terminal with startup command
- GIVEN a running tool instance with a tool type that has `startup_command` set
- WHEN the user opens the terminal
- THEN a WebSocket connection is established
- AND the startup command is executed before the interactive shell
- AND the shell is spawned in the container via `docker exec`
#### Scenario: Open terminal without startup command
- GIVEN a running tool instance with a tool type that has no `startup_command`
- WHEN the user opens the terminal
- THEN a WebSocket connection is established
- AND the shell spawns directly without any startup execution
### Requirement: Session Management
The system SHALL manage terminal sessions.
#### Scenario: Reset terminal session runs startup command
- GIVEN an active terminal session
- WHEN the user resets the session
- THEN a new shell is spawned
- AND the startup command executes before the new interactive shell
@@ -0,0 +1,50 @@
## MODIFIED Requirements
### Requirement: Tool Type Model
The system SHALL provide a `ToolType` model to store tool definitions.
#### Scenario: Model structure
- GIVEN a tool type definition
- THEN the model SHALL have:
- `id`: UUID primary key
- `name`: unique string (e.g., "code-server")
- `display_name`: human-readable string (e.g., "VS Code Server")
- `description`: optional text
- `category`: string (e.g., "editor", "notebook")
- `interface_type`: single string — "web" or "terminal"
- `requires_port`: boolean indicating if port/tunnel configuration is needed
- `compose_template`: Docker Compose YAML string
- `dockerfile_template`: Dockerfile string
- `definition_type`: string — "compose" or "dockerfile"
- `required_variables`: list of required template variables
- `startup_command`: optional text — command to run before interactive shell for terminal sessions
- `is_builtin`: boolean flag for system-defined types
- `created_at`/`updated_at`: timestamps
### Requirement: CRUD API Endpoints
The system SHALL provide REST API endpoints for tool type management.
#### Scenario: Create tool type
- GIVEN an admin user
- WHEN they POST /api/tool-types with valid data
- THEN the system creates a new tool type
- AND validates `interface_type` is "web" or "terminal"
- AND validates `requires_port` is boolean
- AND validates the compose template YAML (if definition_type is "compose")
- AND validates all required variables are present in template
- AND accepts optional `startup_command` field
- AND returns 201 Created with the new tool type
#### Scenario: Update tool type
- GIVEN an admin user
- WHEN they PUT /api/tool-types/{id} with valid data
- THEN the system updates the tool type
- AND accepts optional `startup_command` field
- AND returns 200 OK with updated tool type
#### Scenario: Get tool type includes startup command
- GIVEN an authenticated user
- WHEN they GET /api/tool-types/{id}
- THEN the response includes `startup_command` if set
@@ -0,0 +1,43 @@
## 1. Database and Model
- [x] 1.1 Create Alembic migration to add `startup_command` text column to `tool_types` table
- [x] 1.2 Add `startup_command` field to ToolType SQLAlchemy model
## 2. Backend API
- [x] 2.1 Add `startup_command` to ToolTypeCreate Pydantic schema
- [x] 2.2 Add `startup_command` to ToolTypeUpdate Pydantic schema
- [x] 2.3 Add `startup_command` to ToolTypeResponse Pydantic schema
- [x] 2.4 Update `POST /tool-types` endpoint to handle `startup_command`
- [x] 2.5 Update `PUT /tool-types/{id}` endpoint to handle `startup_command`
## 3. Terminal Session Execution
- [x] 3.1 Update `TerminalSession.start()` to accept optional `startup_command` parameter
- [x] 3.2 Implement startup command execution using `bash -c "<cmd>" || true; exec bash -il` pattern
- [x] 3.3 Update `TerminalManager.get_or_create_session()` to accept and pass `startup_command`
- [x] 3.4 Update `TerminalManager.reset_session()` to accept and pass `startup_command`
- [x] 3.5 Update terminal WebSocket endpoint to fetch tool type via instance and pass `startup_command`
- [x] 3.6 Update terminal reset HTTP endpoint to pass `startup_command`
## 4. Frontend
- [x] 4.1 Add `startup_command` field to ToolType form state in Tool Workshop
- [x] 4.2 Add `startup_command` input to Tool Workshop UI (shown for terminal interface types)
- [x] 4.3 Update tool type submission to include `startup_command`
- [x] 4.4 Update ToolType type definition to include `startup_command`
## 5. OpenCode Container Tools
- [x] 5.1 Update OpenCode built-in tool type compose template to install tmux and ranger
- [x] 5.2 Ensure tmux and ranger are available in the container PATH
## 6. Tests and Verification
- [x] 6.1 Add backend tests for tool type create/update with `startup_command`
- [x] 6.2 Add backend tests for terminal session startup command execution (covered by implementation tests)
- [ ] 6.3 Run `pytest` and ensure all tests pass — BLOCKED: Python/Docker not available in environment
- [ ] 6.4 Run `mypy .` and fix any type errors — BLOCKED: Python/Docker not available in environment
- [ ] 6.5 Run `ruff check .` and fix any lint errors — BLOCKED: Python/Docker not available in environment
- [x] 6.6 Run frontend `npm run typecheck` and fix any errors — PASSED
- [x] 6.7 Run frontend `npm run lint` and fix any errors — PASSED (no new errors introduced)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-24

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