Compare commits

...

186 Commits

Author SHA1 Message Date
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
alex c8da0ab6c4 fix: retry start/restart on network errors
When Docker starts a container, it creates network interfaces which
triggers Chrome's ERR_NETWORK_CHANGED error, aborting the request.
The backend successfully starts the container but the frontend never
gets the response, showing 'failed to create session' even though
the session is up.

Fix: Add retry with exponential backoff for startInstance and
restartInstance when network errors occur (no HTTP response).
Retries up to 2 times with 1.5s delay between attempts.

Fixes: False 'failed to create session' errors when launching tools.
2026-05-24 18:33:25 +00:00
alex b04a458975 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 18:29:10 +00:00
alex 2299bd51ba fix: handle already-serialized mount dicts in profile update
When FastAPI parses the request body and model_dump() is called,
nested MountItem models are already serialized to plain dicts.
The update handler was unconditionally calling model_dump() again,
causing AttributeError on dict objects.

Fix: Check if mount items are already dicts before calling model_dump().

Fixes: 422 error when updating profiles with mounts.
2026-05-24 18:29:03 +00:00
Fusion 45fb0c753c Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 20:24:49 +02:00
Fusion c82e628e6f fix: send stty resize on every resize with hidden command output
Backend:
- Remove _stty_sent guard to send stty on EVERY resize
- Use stty -echo to hide command, then delete the command line with ANSI escapes
- Change log level from info to debug

Frontend:
- Add window resize listener as fallback to ResizeObserver
- 250ms debounce to avoid excessive refits
- Proper cleanup on unmount
2026-05-24 20:24:41 +02:00
alex 383a874bcf Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 18:23:08 +00:00
alex 18634387c7 feat: config profiles top-level navigation with split-pane UI
- Move Config Profiles from settings to top-level navigation
- Implement split-pane layout: profile list on left, editor on right
- Add project and tool type dropdowns with live data
- Keep form open after save with success feedback
- Add sticky save bar at bottom of editor
- Remove Config Profiles tab from Settings page

OpenSpec: add-config-profiles
2026-05-24 18:23:01 +00:00
Fusion 4b09f611d6 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 20:13:17 +02:00
Fusion 5c998f5bf9 fix: revert all terminal resize fixes that caused layout issues
Reverted terminal.tsx, terminal_session.py, and terminal.py to clean state
from before the resize debugging saga. Removed:
- Debug console.log statements
- Explicit term.resize() calls that broke xterm.js
- position: relative CSS overrides on .xterm
- stty -echo wrapper and asyncio.sleep delay
- Extra requestAnimationFrame refresh calls

Kept:
- Mobile terminal features (special keys, modifiers, font size)
- ResizeObserver for container resize detection
- Basic fit() and WebSocket resize messaging
2026-05-24 20:13:10 +02:00
alex c595a513d5 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 18:10:56 +00:00
alex 08bd8bf7f9 fix: config profile form focus loss and path validation messages
- Fix React key stability in env vars, files, and mount file inputs
  to prevent focus loss on every keystroke
- Improve validation error messages to explain Files vs Mounts
- Add helper text in UI clarifying relative vs absolute paths

Fixes focus loss bug and improves UX for path validation errors.
2026-05-24 18:10:51 +00:00
Fusion 1b7308d091 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 20:04:02 +02:00
Fusion 1060cb60ed fix: remove position override on mobile xterm to prevent layout issues 2026-05-24 20:03:56 +02:00
alex b58696bb7e Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 18:00:54 +00:00
alex 4d0834069b chore: merge migration heads for remove_is_builtin and add_config_profiles 2026-05-24 18:00:49 +00:00
Fusion fc41b51bf0 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 19:59:49 +02:00
Fusion 3dbb6321fc fix: force explicit resize and add delayed refit after WebSocket connect 2026-05-24 19:59:35 +02:00
alex 7282b91d99 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 17:58:49 +00:00
alex 9ad11a021c feat: add config profiles
- Add ConfigProfile and ConfigProfileInclude data models with migrations
- Implement profile resolver service with ordered includes and merge rules
- Add profile CRUD API with validation, compatibility, and cycle detection
- Add instance API plumbing for profile selection on create/start/restart
- Add resolved profile preview and default resolution APIs
- Add frontend config profile API client and management UI
- Add launch/restart profile selection UI
- Add backend integration and unit tests (31 passing)

OpenSpec: add-config-profiles
Quality gates: ruff, TypeScript compile, 31 tests passing
2026-05-24 17:58:39 +00:00
Fusion 9f29ac15da fix: improve terminal resize with better stty command and logging 2026-05-24 19:52:00 +02:00
Fusion 3092038e40 debug: add console logging for terminal resize debugging 2026-05-24 19:44:21 +02:00
Fusion 738e01bb7c fix: add window resize fallback and delay refresh to next frame
- Add window resize listener as fallback for ResizeObserver
- Use 250ms debounce to avoid early layout reads
- Delay term.refresh() to next animation frame so renderer
  can process resize before we force redraw
- Clean up window resize listener on unmount
2026-05-24 19:31:15 +02:00
Fusion 4d0c70de98 fix: hide stty resize command from terminal output using ANSI escapes 2026-05-24 19:17:54 +02:00
Fusion 701bd57293 fix: send stty resize on every resize, not just first time
The _stty_sent guard prevented the container shell from updating its
terminal size after the first resize. This caused visual mismatches
where xterm.js displayed at the new size but the shell still wrapped
output at the old size.

Remove the guard so stty is sent on every resize event.
2026-05-24 19:07:37 +02:00
Fusion 85f04447ea fix: force xterm.js canvas redraw on resize via internal renderer 2026-05-24 18:51:15 +02:00
Fusion d931f3071d Revert "fix: add window resize listener and refresh on font size change"
This reverts commit 4a356fe88e.
2026-05-24 18:42:14 +02:00
Fusion 4a356fe88e fix: add window resize listener and refresh on font size change
- Add window resize listener to complement ResizeObserver
- Clear window resize timeout on cleanup
- Force term.refresh() after font size changes
- Send resize message after font size change
2026-05-24 18:18:41 +02:00
Fusion ca22e9c9d2 fix: reset xterm position to relative on mobile to prevent layout issues 2026-05-24 18:13:28 +02:00
Fusion 4de312c170 fix: terminal resize propagation and redraw
- Frontend: Add ResizeObserver with dimension tracking for accurate resize detection
- Frontend: Fix cleanup function to properly disconnect ResizeObserver
- Frontend: Use CSS grid for terminal wrapper layout
- Backend: Add duplicate dimension check to avoid unnecessary resizes
- Backend: Ensure stty command is sent correctly to container shell
2026-05-24 17:44:00 +02:00
Fusion e7a89a853f fix: ensure terminal container fills viewport and redraws on resize
Issues fixed:
1. Terminal container now has explicit width: 100% and height: 100%
2. Added term.refresh() after fit() to force redraw when dimensions change
3. Changed shell-body from min-height to height for definite sizing
4. Added .xterm-viewport width: 100% to ensure proper filling

This ensures the terminal properly fills the viewport and redraws
content when the window is resized.
2026-05-24 17:12:32 +02:00
Fusion 7b23618ae7 fix: use requestAnimationFrame before fit() on window resize
When window resize fires, CSS layout hasn't settled yet. Adding
requestAnimationFrame ensures the browser has calculated new sizes
before xterm.js fit() reads the container dimensions. Reduced
debounce from 250ms to 100ms since rAF handles the layout timing.
2026-05-24 17:03:25 +02:00
Fusion 8b4e1a7428 fix: ensure shell-content fills available viewport height
The terminal page uses height: 100% but parent .shell-content didn't
have explicit height, so the terminal couldn't fill the viewport.

Changes:
- .shell-content: added height: 100%
- .shell-body: added flex: 1 to fill flex parent
- Mobile .shell-content: added height: 100%

This ensures the terminal wrapper can properly calculate and fill
the available viewport space.
2026-05-24 17:01:15 +02:00
Fusion f37813a317 revert: remove ResizeObserver and stty-on-every-resize to fix infinite loop
The ResizeObserver detected size changes caused by the stty command
output appearing in the terminal, creating an infinite resize loop:
1. Resize detected -> fit() -> send resize to backend
2. Backend sends stty command through PTY
3. stty text appears in terminal output
4. ResizeObserver detects content height change
5. fit() calculates new rows -> send resize
6. Loop continues forever

Reverted to:
- Window resize event instead of ResizeObserver
- stty command only sent once on first resize

This means the container shell stays at the initial size and won't
dynamically resize when the browser window changes, but prevents
the infinite loop.
2026-05-24 16:54:05 +02:00
Fusion 6e600fdbcd fix: use ResizeObserver for more reliable terminal resize detection
Window resize events fire before CSS layout settles, so FitAddon
was reading stale container dimensions. ResizeObserver fires after
the element actually changes size, ensuring fit() gets correct
dimensions. Reduced debounce from 250ms to 100ms for snappier response.
2026-05-24 16:50:34 +02:00
Fusion f4211ad452 chore: remove debug console.log statements from terminal component 2026-05-24 16:40:05 +02:00
Fusion 058c501e4a fix: prevent terminal from growing beyond viewport on resize
Added max-height constraints at multiple levels:
- .mobile-terminal-wrapper: max-height 100vh/100dvh
- .mobile-terminal-content: max-height 100%, min-height 0
- .terminal-container: max-height 100%
- .xterm: max-height 100%
- .xterm-viewport: max-height 100% + overflow-y auto

This prevents xterm.js from expanding the container when fit() adds rows,
which was causing an infinite growth loop on window resize.
2026-05-24 16:37:18 +02:00
Fusion 74033243c9 feat: send stty resize command on every resize, not just first
Previously the stty command was only sent on the first resize. Now it
is sent every time the terminal dimensions change, so resizing the
browser window or rotating the device properly updates the container
shell size. Added a check to skip when dimensions haven't changed.
2026-05-24 16:27:37 +02:00
Fusion 9910fd4445 fix: send stty command to resize container shell on first resize
Docker exec doesn't forward PTY resize to the container process,
so the container bash stays at 80x24 regardless of frontend resize.
Work around this by sending a stty command through the terminal
on first resize to set the correct dimensions inside the container.
2026-05-24 16:25:14 +02:00
Fusion 4a66a4a384 fix: pass instance_id to _write_loop to resolve NameError
The write loop was crashing with 'name instance_id is not defined' when
processing resize messages. This caused the connection to drop with 1006
and the frontend to reconnect in a loop. Fixed by passing instance_id
as a parameter to _write_loop. Also cleaned up debug logging.
2026-05-24 16:18:53 +02:00
Fusion fa20d00d14 debug: add loop exit logging to terminal WebSocket handler 2026-05-24 16:13:44 +02:00
Fusion f59274ae64 revert: remove explicit WebSocket close that caused immediate disconnection 2026-05-24 16:12:21 +02:00
Fusion b89fb608b6 fix: explicitly close WebSocket with code 1000 when loops end
When any of the read/write/heartbeat loops ends, we were cancelling
remaining tasks but not explicitly closing the WebSocket. This caused
the connection to be dropped with 1006 abnormal closure instead of
a clean 1000 close. The frontend then reconnected, creating a loop.
2026-05-24 16:08:51 +02:00
Fusion 116cd22ff8 revert: remove stty resize workaround that caused 1006 loops 2026-05-24 16:06:00 +02:00
Fusion 0094ba01cd fix: send stty command to resize container shell
Docker exec doesn't forward PTY resize to the container process,
so the container bash stays at 80x24 regardless of frontend resize.
Work around this by sending a stty command through the terminal
on first resize to set the correct dimensions inside the container.
2026-05-24 16:04:00 +02:00
Fusion f6fb984ec6 revert: remove SIGWINCH signal that caused connection loops
Sending SIGWINCH to the docker exec process was crashing/killing it,
which closed the PTY and caused WebSocket 1006 abnormal closure loops.
Reverting to the original TIOCSWINSZ-only approach.
2026-05-24 16:00:25 +02:00
Fusion 0dba13a354 fix: send SIGWINCH to docker exec after PTY resize
When resizing the PTY, docker exec needs to be notified so it can
re-read the terminal size and propagate it to the container's PTY.
Without this, the container shell stays at 80x24 regardless of what
the frontend sends.
2026-05-24 15:57:29 +02:00
Fusion 5500552993 fix: send terminal resize immediately on WebSocket connect
- Backend PTY starts with default 80x24 dimensions
- Previous code only sent resize during layout changes
- Now sends current terminal size immediately when WebSocket opens
- Ensures PTY is properly sized before shell starts rendering
2026-05-24 15:42:22 +02:00
Fusion fed49dba6d debug: add logging and simplify fit logic
- Simplified fit logic: just fit after open, after fonts load, and on resize
- Added console logging to debug what FitAddon calculates
- Single fitTerminal() function used everywhere
- Removed complex retry logic that wasn't working
2026-05-24 15:30:34 +02:00
Fusion fa17b13413 fix: wait for fonts and retry fit until proper dimensions
- Wait for document.fonts.ready before fitting (ensures correct cell metrics)
- Retry fit every 100ms if rows <= 1 or cols <= 10 (layout still settling)
- Up to 30 retries (3 seconds) for layout to stabilize
- Remove redundant delayed fits, keep only header auto-hide fit at 4s
2026-05-24 15:25:47 +02:00
Fusion 38185b9659 fix: remove container ResizeObserver causing infinite growth loop
- Container-level ResizeObserver created feedback loop with fitAddon.fit()
- Removed it, kept initialization-time dimension check only
- Rely on window resize listener for viewport changes
2026-05-24 15:19:31 +02:00
Fusion 07e7c6ea0f fix: wait for container dimensions before xterm init
- xterm docs require parent to have dimensions when open() is called
- Added ResizeObserver to wait for non-zero dimensions before initializing
- Added container ResizeObserver to handle resizes (header hide, keyboard)
- Fixed cleanup to properly disconnect observers and handle uninitialized ws
2026-05-24 15:16:11 +02:00
Fusion b638dccd36 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 15:02:56 +02:00
Fusion 8f7e19fdb1 fix: remove conflicting CSS that broke terminal sizing
- Remove second .terminal-wrapper.mobile definition that overrode position:absolute
- Add flex display to .xterm for proper viewport filling
- Add position:relative to mobile terminal-container
- Remove manual dimension setting workaround from terminal.tsx
- Root cause: CSS specificity conflict caused FitAddon to read height=0
2026-05-24 15:02:40 +02:00
alex ba76f09a1c Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:59:36 +00:00
alex 8b08c3886c fix: prevent reconnection loop on concurrent connection close
- Don't reconnect when server closes old connection with code 4000
- Code 4000 means new connection was established, not an error
- Prevents infinite reconnection loop between old/new connections

Refs: terminal switching between 4000 error and connected
2026-05-24 12:59:28 +00:00
Fusion 5577e19782 fix: set explicit container dimensions before xterm init
- Measure parent dimensions and set them on container before term.open()
- Ensures FitAddon gets correct dimensions on initialization
- Prevents 1-row/1-col calculation that breaks scrolling and sizing
2026-05-24 14:57:03 +02:00
Fusion 268651bab0 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 14:48:45 +02:00
alex fc873e2d6b docs: add terminal API and user documentation
- Add docs/api/terminal.md with WebSocket protocol and reset endpoint
- Add docs/features/terminal.md with user guide for persistent sessions
- Add docs/features/terminal-troubleshooting.md with diagnostic steps
- Mark tasks 8.1-8.3 complete

Refs: persistent-terminal-sessions tasks 8.x
2026-05-24 12:48:36 +00:00
Fusion 7389344b6d fix: defer terminal manager idle check until event loop is running
TerminalManager was trying to create an asyncio task at module import time,
but no event loop exists yet during import. This caused RuntimeError on startup.

Changes:
- _start_idle_check() now checks if event loop is running before creating task
- If no loop exists, silently skips (will be started lazily)
- Added lazy start call in get_or_create_session() when websocket connects
2026-05-24 14:48:30 +02:00
alex 0a8f1419a6 docs: mark task 6.4 complete 2026-05-24 12:46:18 +00:00
alex 09938bede4 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:45:59 +00:00
alex 073013bc61 feat: add heartbeat/ping to terminal WebSocket
- Backend: Send ping every 30s from WebSocket endpoint
- Frontend: Respond to pings with pongs, detect missed pings (60s timeout)
- Update type definitions to include 'resetting' status

Refs: persistent-terminal-sessions task 6.4
2026-05-24 12:45:50 +00:00
Fusion 865b9411da fix: add 'resetting' status to terminal callback types
TypeScript build failed because 'resetting' status was not included
in the onTerminalReady callback type definition.

Updated types in:
- TerminalComponent props
- MobileTerminalWrapper state and callback
- MobileTerminalHeader props
2026-05-24 14:43:27 +02:00
Fusion 48d1a7d05c Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 14:39:20 +02:00
Fusion ab1d3a6aa1 fix: use CSS Grid for mobile terminal layout
Replace flexbox chains with CSS Grid to give content area definite height:
- grid-template-rows: auto 1fr auto for header/content/keys
- Use 100dvh for proper mobile viewport handling
- Terminal fills content area with position: absolute
- Remove mobile-terminal-shell wrapper (redundant)
- Content area gets real height from grid, fixing FitAddon calculations
2026-05-24 14:39:06 +02:00
alex d117047711 docs: update tasks for persistent terminal sessions
- Mark completed backend and frontend tasks
- Remaining: testing and documentation

Refs: persistent-terminal-sessions
2026-05-24 12:36:50 +00:00
alex d1c187ab16 feat: implement persistent terminal sessions
- Terminal sessions now persist across WebSocket disconnections
- Added circular output buffer (10KB) for replay on reconnect
- Added idle timeout cleanup (30 minutes)
- Added reset functionality via WebSocket message and HTTP endpoint
- Concurrent connections close old WebSocket when new one connects
- Frontend: Added reset button with confirmation dialog
- Frontend: Handle resetting status and reconnection

Refs: persistent-terminal-sessions
2026-05-24 12:35:52 +00:00
alex ecd3ba5918 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:29:44 +00:00
alex a919ff8611 feat: add persistent terminal sessions spec
- Add proposal, design, specs, and tasks for persistent terminal sessions
- Support reconnection, output buffer replay, reset, and idle timeout

Refs: persistent-terminal-sessions
2026-05-24 12:29:29 +00:00
Fusion 39cf01c3c9 fix: use absolute positioning for xterm.js to fill container
- Make .terminal-container position: relative with overflow: hidden
- Make xterm element absolutely positioned to fill container
- This ensures xterm.js always has concrete dimensions for fitAddon
- Remove conflicting height: 100% !important overrides
- Terminal now properly fills available space and calculates correct rows
2026-05-24 14:28:03 +02:00
Fusion c49bb028c4 fix: remove ResizeObserver to prevent infinite resize loop
The ResizeObserver triggered fit() which changed canvas dimensions,
triggering the observer again in an infinite loop. We already have
window resize handling and delayed fit() calls, so the observer was
redundant.
2026-05-24 14:23:34 +02:00
Fusion 0baf7f7750 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter 2026-05-24 14:15:26 +02:00
Fusion 6e496e102d fix: terminal sizing and newline rendering issues
- Add ResizeObserver to terminal container for responsive sizing
  (catches keyboard open/close, header auto-hide, layout changes)
- Remove padding from mobile terminal container to maximize space
- Fix CSS: ensure xterm viewport fills container height properly
- Fix session-card.tsx TypeScript error (removed non-existent port field)
- Remove explicit xterm-viewport/xterm-screen width overrides that
  interfered with xterm.js canvas sizing
2026-05-24 14:14:45 +02:00
alex 7cb88a3163 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:07:54 +00:00
alex ce1a73abce feat: add styles and polish for unified session list components
- Add CSS styles for SessionCard and SessionList components
- Add responsive styles for mobile viewport
- Fix TypeScript errors (remove unused port property)
- Fix ESLint errors (remove unused imports and variables)

Refs: session-list-overhaul tasks 5-6
2026-05-24 12:07:37 +00:00
Fusion 5fddc65468 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 14:05:38 +02:00
Fusion f065e2b8c0 fix: terminal sizing and container overflow
- Change .terminal-wrapper.mobile from height:100% to flex:1 for proper flex behavior
- Add explicit width/height to xterm-viewport and xterm-screen to prevent overflow
- Add delayed fit() at 4s to resize after mobile header auto-hides
- Remove min-height:100% which caused overflow issues
2026-05-24 14:05:23 +02:00
alex f4cf286bb5 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:03:34 +00:00
alex 549d13f469 feat: implement unified session list components
- Add SessionCard component with status indicators, actions, and confirmation dialogs
- Add SessionList component with grouping (active/recent) and filtering
- Refactor dashboard.tsx to use unified components
- Refactor sessions.tsx to use unified components
- Remove duplicated session rendering logic from both pages

Refs: session-list-overhaul tasks 1-4
2026-05-24 12:03:16 +00:00
Fusion 45cd192188 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 13:57:30 +02:00
Fusion 030a39dd5a fix: send resize message when font size changes
- After changing font size and calling fit(), send resize message to WebSocket
- OpenCode now receives correct terminal dimensions after font size adjustment
- Fixes issue where OpenCode UI didn't fill available space after font resize
2026-05-24 13:57:22 +02:00
alex ed15d53493 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 11:50:17 +00:00
alex ffb7ef0d21 feat: add session list overhaul proposal
- Add proposal for unified session list components
- Add design doc with component architecture
- Add specs for SessionCard and SessionList requirements
- Add implementation tasks

Refs: session-list-overhaul
2026-05-24 11:50:16 +00:00
Fusion e265c86997 fix: use flex layout for terminal container to ensure proper sizing
- Remove ResizeObserver that was causing infinite resize loop
- Add display: flex to terminal-container for proper child sizing
- Use flex: 1 on .xterm element instead of height: 100%
- Remove explicit height/width from xterm-viewport and xterm-screen
- Let flexbox handle the layout naturally
2026-05-24 13:47:40 +02:00
Fusion 23c876b558 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 13:42:54 +02:00
Fusion 74c65068c8 fix: add ResizeObserver and delayed fit for terminal sizing
- Add ResizeObserver to watch terminal container and trigger fit() on size changes
- Add delayed second fit() call 500ms after initialization
- Remove initial setTimeout resize in favor of ResizeObserver
- Ensure resizeObserver is cleaned up on unmount
2026-05-24 13:42:39 +02:00
alex 48277369f2 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 11:42:37 +00:00
alex 5f39267781 fix: use bind mount for instances in traefik compose
- Change from named volume instance_data to host bind mount
- Consistent with docker-compose.yml fix for clone mode

Refs: clone mode repo files not visible in containers
2026-05-24 11:42:29 +00:00
Fusion caf11cdb8a fix: improve terminal sizing with delayed fit and flex layout
- Use double requestAnimationFrame before initial fitAddon.fit() to ensure DOM is settled
- Add display: flex to mobile-terminal-content for proper child sizing
- Add width: 100% to terminal-wrapper.mobile
- Ensure terminal fills parent container both horizontally and vertically
2026-05-24 13:38:15 +02:00
Fusion 2f44306089 fix: ensure terminal fills entire viewport on mobile
- Add width: 100% to xterm, xterm-viewport, and xterm-screen
- Add explicit canvas display: block for proper sizing
- Remove padding from terminal-container on mobile
- Add min-height: 100% to terminal-wrapper.mobile
- Ensure xterm.js internal elements fill parent container
2026-05-24 13:31:46 +02:00
Fusion 6173d42ddf fix: allow terminal page to fill available space instead of using 100vh
- Change .terminal-page height from 100vh to 100% to fit within shell layout
- Add display: flex and min-height: 0 to .shell-content to allow flex children to expand
- Terminal container now properly fills available vertical space
2026-05-24 13:24:21 +02:00
Fusion 9afd559394 fix: reduce minimum font size and prevent reconnection on font size change
- Reduce MIN_FONT_SIZE from 16 to 10 for better range
- Remove calculateFontSize from useEffect dependencies to prevent
  terminal re-initialization when font size changes
- Font size changes now update xterm options directly without
  disposing/recreating the terminal (no WebSocket reconnection)
2026-05-24 13:20:49 +02:00
Fusion 25da0c149a Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 13:15:45 +02:00
Fusion 457f0d29ae fix: add safety guards to font size change and show buttons on all screen sizes
- Add null checks and try/catch around fitAddon.fit() to prevent viewport errors
- Use requestAnimationFrame to ensure DOM is stable before fitting
- Remove isMobile condition from font size buttons in TerminalComponent
- Font size controls now visible on both mobile and desktop terminals
2026-05-24 13:15:29 +02:00
alex dfbd3e60a8 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 11:14:06 +00:00
alex 075eb6a76b fix: ensure cloned repo is mounted in compose for clone mode
- Add safety check after rendering compose template to ensure REPO_PATH is mounted
- If compose template lacks volume mount, auto-add default mount to /workspace
- Add cloned repo verification to catch empty clone directories

Refs: clone mode repo not appearing in container workspace
2026-05-24 11:13:53 +00:00
Fusion 4571bebf8e feat: add font size controls to mobile terminal header and fix auto-hide space reclamation
- TerminalComponent: expose changeFontSize via onTerminalReady callback
- MobileTerminalWrapper: pass changeFontSize to header
- MobileTerminalHeader: add A- and A+ font size buttons
- CSS: collapse header height/padding/margin/border when hidden to reclaim space
2026-05-24 13:10:07 +02:00
Fusion ec45283257 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 13:02:24 +02:00
Fusion cce3fa773a feat: hide tunnel UI for terminal sessions and show session metadata
Backend:
- Add created_at to get_user_sessions response

Frontend:
- Hide tunnel error badges, probe output, and 'Recreate Tunnel' button for terminal-only sessions
- Show session start time (created_at) in active sessions list
- Show repository configuration (clone_mode, branch) for each session
- Skip health check polling for terminal-only sessions
- Update Session type to include created_at field
2026-05-24 13:02:06 +02:00
alex 50474f7b13 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 11:00:18 +00:00
alex 9c7043bab1 fix: add merge migration for alembic heads and make remove_is_builtin idempotent
- Create merge migration f3d2dc90ba3a to merge single_interface and clone_mode heads
- Make remove_is_builtin migration idempotent with IF EXISTS clause

Refs: alembic migration fix for dev branch
2026-05-24 11:00:03 +00:00
Fusion 2dd2f2ab06 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:45:21 +02:00
Fusion e555561a2d fix: lift modifier state to MobileTerminalWrapper for virtual keyboard integration
- Remove useSpecialKeys hook state, export pure utility functions instead
- MobileTerminalWrapper now owns activeModifier state
- SpecialKeysStrip and SpecialKeysPanel receive modifier via props
- TerminalComponent applies modifier to virtual keyboard input via activeModifier prop
- Modifier now works with both special keys AND virtual keyboard input
- Modifier clears after any key press (special or virtual keyboard)
2026-05-24 12:45:07 +02:00
alex 4ac3d593aa Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 10:32:58 +00:00
alex 1a3860a4d8 fix: mount /data/instances as host bind mount for API
When API runs in Docker with named volume instance_data:/data/instances,
generated docker-compose.yml files use bind mounts like
/data/instances/.../repo-clone:/workspace. Docker resolves bind mounts
on the host filesystem, not in named volumes, so containers see empty
 directories.

By mounting /data/instances as a host bind mount, both the API and
generated tool containers access the same host path.
2026-05-24 10:32:43 +00:00
Fusion 3d9ff44d1a Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:28:51 +02:00
Fusion 12378def4d feat: implement one-shot modifier keys for mobile terminal
- Redesign useSpecialKeys hook with modifier state tracking
- Add one-shot activation for Ctrl and Alt keys
- Visual feedback: active modifiers shown with yellow highlight
- Fix focusInput to use term.focus() instead of hidden input
- Always refocus terminal after sending any special key
- Add requestAnimationFrame for reliable focus restoration
2026-05-24 12:28:36 +02:00
alex f6f7853aa4 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 10:25:15 +00:00
alex 802d8f1e8c fix: strip remote prefix from branch names in list_branches
Git branch -a --format=%(refname:short) returns remote branches as
'origin/branch-name', not 'remotes/origin/branch-name'. The code was
only filtering 'remotes/' prefix, causing clone to fail with branch
names like 'origin/feat/foo'.

Now properly detects remote names using 'git remote' and strips the
remote prefix (e.g., 'origin/') from branch names.
2026-05-24 10:25:01 +00:00
Fusion 4f9aa7e3c2 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:17:50 +02:00
Fusion cb25b94cb7 fix: prevent special key buttons from stealing focus
- Add tabIndex={-1} to all special key buttons to prevent focus
- Add onFocus handler to immediately blur if focused
- Terminal focus stays intact when tapping special keys
2026-05-24 12:17:35 +02:00
alex 01aaf4c78f Merge remote dev and resolve conflicts in CreateSessionForm 2026-05-24 10:16:27 +00:00
alex 96c8dd7402 feat: make session creation a sequential workflow
- Refactor CreateSessionForm into step-by-step workflow
- Steps unlock sequentially: Project → Repository → Tool → Clone Mode → Branch
- Add visual step indicators with numbered badges
- Disable controls until prerequisites are met
- Add CSS for workflow step styling
2026-05-24 10:14:39 +00:00
Fusion f5c2c95af0 fix: focus xterm terminal on tap instead of hidden input
- Use term.focus() instead of hidden input focus
- This ensures keyboard opens properly when tapping anywhere on terminal
2026-05-24 12:14:11 +02:00
Fusion 06fe8623bc fix: move hidden input off-screen and fix branch loading
- Move terminal hidden input to off-screen position (-9999px) to prevent
  text selection/caret visibility on mobile
- Add user-select: none to prevent any selection UI
- Fix create-session-form to use new listRepositoryBranches API signature
  (projectId, repoId) and access response.branches/default_branch
2026-05-24 12:11:53 +02:00
Fusion f2fed518f0 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-24 12:06:38 +02:00
Fusion cc2a638c76 feat: always show special keys strip on mobile terminal
- Remove auto-hide behavior for special keys strip
- Keep header auto-hide functionality
- Special keys are now always visible for quick access
2026-05-24 12:06:22 +02:00
alex 5cec4a7a6f Merge branch 'feat/session-branch-selection' into dev
Resolved conflicts:
- Moved branch selection UI from inline sessions.tsx to CreateSessionForm component
- Integrated branch dropdown and new branch creation into CreateSessionForm
- Removed duplicate branch state management from sessions.tsx

All branch selection tests pass (7/7).
2026-05-24 10:06:14 +00:00
Fusion 7e0df57f8c fix: keep virtual keyboard open when tapping special keys
- Use onPointerDown with preventDefault() instead of onClick
- Add onKeepFocus callback to SpecialKeysStrip and SpecialKeysPanel
- Expose focusInput via onTerminalReady in TerminalComponent
- MobileTerminalWrapper passes focus callback to keep keyboard open
2026-05-24 11:57:55 +02:00
Fusion aea1ff95f6 fix: prevent infinite terminal re-initialization loop
- Remove status from TerminalComponent useEffect dependencies to prevent recreation on WebSocket status changes
- Use ref for onTerminalReady callback to avoid parent re-renders triggering terminal recreation
- Wrap MobileTerminalWrapper onTerminalReady with useCallback for stable reference
2026-05-24 11:35:32 +02:00
alex c8fdca7f60 Merge branch 'feat/session-branch-selection' into dev 2026-05-24 09:33:19 +00:00
alex 014b88ee56 test: add unit tests for session branch selection
- Test CreateInstanceRequest model with new_branch field
- Test local branch creation via git checkout -b
- Test instance branch storage logic
2026-05-24 09:31:09 +00:00
Fusion b6bda3d692 feat: implement mobile terminal UX
- Add mobile viewport detection hook
- Add virtual keyboard detection with fallback
- Add auto-hide hook for header/keys strip
- Add special keys mapping hook
- Create MobileTerminalHeader, SpecialKeysStrip, SpecialKeysPanel components
- Create MobileTerminalWrapper component
- Update TerminalComponent with mobile support, font scaling, copy/paste, reconnection
- Update AppShell to hide chrome on mobile terminal pages
- Update TerminalPage to use MobileTerminalWrapper
- Add comprehensive mobile terminal styles
- TypeScript check passes
- Build succeeds
2026-05-24 11:30:04 +02:00
alex d7fb51f427 feat: add branch dropdown and new branch creation UI
- Replace free-text branch input with dropdown of available branches
- Add 'Create new branch...' option with name and base branch inputs
- Load branches from API when repository is selected in clone mode
- Pass newBranch parameter to createInstance API
2026-05-24 09:25:48 +00:00
alex 0d57e3501a feat: add newBranch parameter to createInstance 2026-05-24 09:21:50 +00:00
alex 3672312028 feat: support creating local branch during session creation
- Add new_branch field to CreateInstanceRequest
- Run git checkout -b after cloning when new_branch is provided
- Store new branch name in ToolInstance record
2026-05-24 09:21:29 +00:00
alex c5f117e5b1 feat: add branch listing API function 2026-05-24 09:20:31 +00:00
alex 10a5c29702 docs: add session branch selection design spec
- Design for branch dropdown in session creation
- New local branch creation at clone time
- Frontend/backend changes overview
2026-05-24 09:18:29 +00:00
Fusion 312a646b89 feat: remove built-in tool types distinction
- Drop is_builtin column from tool_types table
- Remove built-in tool seeding from startup
- Remove is_builtin from API schemas and frontend types
- Update tool-types spec to reflect removal of built-in concept
- Add Alembic migration for column removal
- Update tests to work without built-in distinction
2026-05-23 20:02:19 +02:00
Fusion 01adc9a00f refactor: unify create session forms - show clone mode everywhere and display fixed fields as read-only 2026-05-23 19:53:12 +02:00
Fusion 2e9ca52cdb refactor: unify session creation form into CreateSessionForm component 2026-05-23 16:26:35 +02:00
Fusion cd4eba9803 fix: restore loading overlay for delete/stop operations on active sessions 2026-05-23 08:12:15 +02:00
Fusion 18646e3d1b fix: move creation loading indicator to create session form
Move the loading overlay from the active sessions section to the create
session section so it dims the form itself during creation, providing
better visual feedback to the user.
2026-05-23 08:08:24 +02:00
Fusion 8c5e1b931e fix: move creation loading indicator outside active sessions grid
The loading overlay for instance creation was inside the active sessions
grid, which doesn't render when there are no active sessions. Moved the
overlay to the parent container so it's always visible during creation
regardless of existing sessions.
2026-05-23 08:04:48 +02:00
Fusion d1be2e4951 feat: add loading indicators for long-running operations
Add loading overlay to sessions list during create, stop, delete,
and recreate tunnel operations. Show progress messages like
'Creating instance...' and 'Starting container...' during creation.
Dim the sessions grid while operations are in progress to prevent
user confusion and accidental duplicate actions.
2026-05-23 07:54:52 +02:00
Fusion 953ea05756 fix: show all probe attempts including successful ones
Remove 500-character truncation on probe output so users can see
all attempts including the final successful one. Add probe status
indicator (passed/failed/pending) that's always visible when probe
data exists.
2026-05-23 07:43:51 +02:00
Fusion 507b71c586 fix: React crash when opening terminal sessions
The backend was returning 'tool_type_interface_type' (string) but the
frontend expected 'tool_type_interfaces' (array). This caused
undefined.includes() crash when clicking Open on terminal sessions.

Changed both list_instances and get_user_sessions to return
tool_type_interfaces as an array. Also added clone_mode and branch
to get_user_sessions response.
2026-05-23 07:36:46 +02:00
Fusion aebcf25bf4 fix: OpenCode instances fail with 'no port configured' error
For terminal-only tools like OpenCode, default_port is 0 which is falsy
in Python. The code incorrectly treated port 0 as 'not configured' and
marked the instance as error. Now we only check if tool_type exists,
and default to port 0. Terminal tools skip tunnel creation anyway.
2026-05-23 07:29:56 +02:00
Fusion ae42cac61e fix: terminal tools always showing as unhealthy
For terminal-only tools (no URL), only check container status for
overall health instead of requiring tunnel health. Terminal tools
do not have tunnels, so tunnel_status stays as 'not_applicable'
which was failing the healthy check.
2026-05-22 23:55:51 +02:00
alex e2ad7d7fb6 fix: prevent null default_port for terminal tools 2026-05-22 21:49:31 +00:00
alex 9e1334eb6d fix: remove port exposure from terminal tool (opencode) 2026-05-22 21:47:02 +00:00
alex c41993310b Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:33:29 +00:00
alex cb25f21c44 feat: add SSH key signing and verification UI 2026-05-22 21:33:21 +00:00
Fusion 392e85ead4 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 23:30:54 +02:00
Fusion a4bf8afac9 fix: make full URL the default for repository cloning
When cloning a repository, the Full URL input is now shown by default
instead of the Owner/Repo Name fields.
2026-05-22 23:30:41 +02:00
alex a559470369 fix: add openssh-client to API Dockerfile for SSH git clone support 2026-05-22 21:29:28 +00:00
alex 8cab17472e Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:27:25 +00:00
alex e9d404b1ff feat: add SSH key payload signing and verification endpoints
- POST /ssh-keys/{id}/sign - sign payload with Ed25519 private key
- POST /ssh-keys/{id}/verify - verify signature with public key
- Returns base64-encoded signatures
2026-05-22 21:27:10 +00:00
Fusion dab6c74046 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 23:23:36 +02:00
alex ca8b255148 fix: log detailed error messages in repository preflight and clone 2026-05-22 21:21:45 +00:00
Fusion 02a2ad6df5 feat: allow modification of built-in tool types
Remove restrictions on updating and deleting built-in tool types.
Show delete button for all tool types in Tool Workshop.
2026-05-22 23:20:59 +02:00
alex 4ef0f108ea fix: use SSH key during repository preflight and clone 2026-05-22 21:19:15 +00:00
alex dc8ef0e463 fix: chain clone_mode migration after single_interface migration 2026-05-22 21:12:46 +00:00
Fusion 6a7657aeda chore: remove unused tool-types and tool-configs pages
These pages are superseded by the Tool Workshop page.
No functional changes.
2026-05-22 23:04:35 +02:00
alex eca8b8815b Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:02:13 +00:00
Fusion d0f7a97f92 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 23:01:59 +02:00
alex 765cb965e6 fix: sanitize template variables before YAML validation 2026-05-22 21:01:59 +00:00
Fusion 063a839790 feat: implement repository clone mode with SSH key support
- Add clone_mode and branch fields to tool_instances
- Add ssh_key_id to git_repositories for per-repo SSH key assignment
- Implement host-side git cloning with branch selection (default: main)
- Mount SSH keys into containers for git operations in clone mode
- Add dirty state check on clone-mode instance deletion with confirmation
- Update SessionsPage with mount/clone selector, branch input, SSH key display
- Add SSH key selector to repository creation form
- Add dirty delete confirmation modal with changed files list
- Update API schemas and endpoints for new fields
- Sync delta specs to main specs (git-repo, tool-instances, repo-clone-mode)
- Archive completed OpenSpec change: repo-clone-mode-with-ssh
- Document git requirement for custom tool types

Quality gates: Frontend typecheck and build passed
OpenSpec: repo-clone-mode-with-ssh archived with all tasks complete
2026-05-22 22:56:35 +02:00
alex ae41a64e66 fix: shorten migration revision ID to fit alembic_version column 2026-05-22 20:50:52 +00:00
alex 0901b1e832 fix: make migration database-agnostic for SQLite and PostgreSQL 2026-05-22 20:48:21 +00:00
alex 7cc720786e Merge branch 'feat/enforce-single-tool-type-with-port-config' into dev 2026-05-22 20:44:23 +00:00
alex e167a6be12 feat: enforce single tool type with port configuration
- Replace interfaces array with single interface_type string (web/terminal)
- Add requires_port boolean to indicate port/tunnel needs
- Create Alembic migration for database schema change
- Update backend model, API validation, and seed data
- Update frontend types and tool workshop UI with dropdown
- Add conditional port field rendering based on interface type
- Update all frontend and backend tests

OpenSpec change: enforce-single-tool-type-with-port-config
Quality gates: frontend typecheck PASS, lint PASS, tests 37/37 PASS
2026-05-22 20:44:17 +00:00
alex 0fa926284c feat: enforce single tool type with port config
- Replace interfaces array with interface_type string and requires_port boolean
- Add database migration for schema change
- Update backend model, API schemas, and validation
- Update frontend types and tool workshop UI
- Add dropdown for interface type selection
- Conditionally show/hide port fields based on requires_port
- Update tests and mock data
- All frontend tests pass (37/37)
- Frontend typecheck and lint pass
2026-05-22 20:32:10 +00:00
alex 5c17de0c3c fix: handle FastAPI validation error objects in tool workshop
- Add extractErrorMessage helper to safely stringify validation error arrays
- Apply to tool type, config, and folder save handlers
- Fixes React error #31 when rendering error objects directly in JSX

Closes: redesign-tool-workshop
2026-05-22 20:04:42 +00:00
alex 8efadc4432 fix: add defensive null checks to prevent filter crash
- Add fallback to empty arrays for toolTypes, configs, and folders
- Handle undefined API responses gracefully
- Prevent Cannot read properties of undefined (reading 'filter') error

Quality gates: npm run build passed
2026-05-22 19:54:40 +00:00
miguel 1e40540ef4 feat: show banner for bare mirror repositories
- Add isMirror prop to GitToolbar\n- Show warning banner when repo is a bare mirror\n- Explain that editing/committing/pulling/merging are unavailable\n- Suggest deleting and recreating to enable full features\n\nQuality gates: vitest (43 passed)
2026-05-22 21:51:17 +02:00
alex 7cbbb41661 feat: redesign tool workshop with split-pane layout
- Replace tabbed interface with split-pane layout
- Left sidebar: scrollable tool type list with selection and create button
- Right panel: editable tool type details with tabs for configs and folders
- Add dirty state tracking with unsaved changes warning
- Improve mobile responsiveness

Quality gates: npm run build passed
2026-05-22 19:49:54 +00:00
Fusion 952a9f3234 fix: correct merge migration to use down_revision tuple
The merge migration was using depends_on instead of down_revision,
which prevented Alembic from recognizing it as a merge point.
2026-05-22 21:47:42 +02:00
Fusion ab8872f79e fix: add merge migration to resolve multiple alembic heads
Resolves conflict between numeric migration branch (0013) and
tool-workshop migration branch (8ed7dd80973d) both depending on
0012_default_port_req.
2026-05-22 21:44:43 +02:00
Fusion be4893e2a7 fix: add missing migration for probe_result column
Adds probe_result JSON column to tool_instances table.
This column stores readiness probe results and was added to the
model but the migration was missing.
2026-05-22 21:42:10 +02:00
Fusion b3c6a5fdc9 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:39:59 +02:00
Fusion b7d17cea78 fix: complete in-progress OpenSpec changes
- git-repo-working-clones: Complete remaining test task
- opencode-web-terminal: Add port validation tests, fix model validator
- session-management-fixes: Mark frontend tasks as complete (already implemented)

All in-progress changes now complete.
2026-05-22 21:39:43 +02:00
miguel 36d6448f5f merge: integrate session management fixes and sessions hub 2026-05-22 21:39:37 +02:00
miguel 20a5f6a9a1 feat: session management fixes and sessions hub
- Add confirmation dialogs for stop/delete on dashboard
- Filter deleted sessions immediately without reload
- Add tunnel health polling with error badges
- Add Sessions nav item with active count badge
- Route /sessions to SessionsPage component

Quality gates: 43/43 tests pass, typecheck pass, lint pass

Refs: openspec/changes/session-management-fixes
Refs: openspec/changes/sessions-hub
2026-05-22 21:39:28 +02:00
miguel 1c94583307 fix: handle bare repos in branch creation and checkout
- Fall back to symbolic-ref when checkout --orphan fails on bare repos\n- Fall back to symbolic-ref when checkout fails on bare repos\n- Make get_current_branch handle bare repos with unborn branches\n- Add integration tests for bare repo branch operations\n\nQuality gates: pytest integration tests (12 passed)
2026-05-22 21:33:18 +02:00
miguel 95a7454bee fix: handle bare repos in branch creation and checkout
- Fall back to symbolic-ref when checkout --orphan fails on bare repos\n- Fall back to symbolic-ref when checkout fails on bare repos\n- Make get_current_branch handle bare repos with unborn branches\n- Add integration tests for bare repo branch operations\n\nQuality gates: pytest integration tests (12 passed)
2026-05-22 21:32:51 +02:00
Fusion 649496b762 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-22 21:28:59 +02:00
Fusion d13e16f5e1 feat(health-monitoring): complete instance health monitoring implementation
Backend:
- Container startup verification with docker inspect polling
- Readiness probe integration with ToolType configuration
- Enhanced health endpoint checking container + tunnel status
- Smart tunnel recovery distinguishing connection errors vs HTTP errors
- New status states: starting, probing, unhealthy

Frontend:
- Updated status badges for new states (starting, probing, unhealthy)
- Show tunnel error only when tunnel_status is unreachable
- Show app error badge with status code for error_response
- Add collapsible probe output section for diagnostics
- Only show Recreate Tunnel button for unreachable tunnels

Quality Gates:
- Frontend type checking: PASSED
- Frontend build: PASSED
- Backend unit tests: 56 passed

Addresses instance-health-monitoring OpenSpec change
2026-05-22 21:28:45 +02:00
Fusion d5f9df33b7 feat(frontend): update sessions page for enhanced health monitoring
- Add new status badges: starting, probing, unhealthy
- Show tunnel error only when tunnel_status is unreachable
- Show app error badge with status code for error_response
- Add collapsible probe output section for diagnostics
- Update health polling to check all active instances
- Only show Recreate Tunnel button for unreachable tunnels
2026-05-22 21:26:05 +02:00
miguel 468e0eacda merge: integrate UI redesign and test fixes into dev 2026-05-22 21:21:16 +02:00
Fusion 2a9e57ad0d chore: archive superseded cloudflare-tunnel-instances OpenSpec change
This change proposed using Cloudflare API for persistent tunnels.
Superseded by temporary tunnel approach using 'cloudflared tunnel --url'
which requires no API tokens, account IDs, or DNS configuration.
2026-05-22 21:01:44 +02:00
Fusion e4c5e7f2db chore: archive tool-workshop OpenSpec change
- Update tasks.md to mark all 140 tasks as complete
- Archive tool-workshop change to openspec/changes/archive/2026-05-22-tool-workshop/
2026-05-22 20:57:30 +02:00
Fusion 70957e462a fix: exclude test files from TypeScript build
- Add exclude pattern for **/*.test.ts and **/*.test.tsx in tsconfig.json
- Fixes deployment build failures caused by type mismatches in test mocks
2026-05-22 20:53:38 +02:00
Fusion 684a11610a docs: add git branching strategy and merge workflow to AGENTS.md
- Add branching strategy section with prefix conventions (feat/, fix/, refactor/, docs/, chore/)
- Add completion and merge workflow steps (branch from dev, merge back, push)
- Emphasize no direct commits to main or dev branches
2026-05-22 20:37:50 +02:00
224 changed files with 14751 additions and 3283 deletions
+195
View File
@@ -0,0 +1,195 @@
---
name: sift-backlog
description: Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open.
---
# Sift Backlog
Triage backlog tasks: prioritize, group into plans, set dependencies, and activate.
## Overview
1. List backlog tasks (`sf task backlog`)
2. Clarify and enrich each task (titles, descriptions)
3. Identify groupings and create draft plans
4. Add tasks to plans and set dependencies
5. Activate plans
6. Set task status to open
## Workflow
### Step 1: List Backlog Tasks
```bash
sf task backlog
```
### Step 2: Clarify and Enrich Tasks
Backlog tasks often have only a brief title with no description. Before organizing, ensure each task is well-defined.
**For each task, evaluate:**
- Is the title clear and actionable?
- Is there a description? Check with `sf task describe <task-id> --show`
- Is the scope unambiguous?
**If the title is unclear**, update it:
```bash
sf update <task-id> --title "Clear, actionable title"
```
**Add a description** with context, scope, and acceptance criteria:
```bash
sf task describe <task-id> --content "Description with:
- What needs to be done
- Why it matters
- Acceptance criteria
- Any relevant context"
```
**Use your best judgment** to interpret tasks and make reasonable decisions about scope, grouping, and priority. You have context about the codebase, project patterns, and typical development practices—leverage this knowledge rather than deferring to the user for routine decisions.
**Only ask the user for clarity when absolutely necessary:**
- The task is fundamentally ambiguous (multiple mutually exclusive interpretations)
- Critical business logic or user-facing behavior that could go wrong in meaningful ways
- External dependencies or integrations you cannot verify
**Do NOT ask about:**
- Implementation details you can reasonably infer
- Priority or grouping decisions—use your judgment
- Standard development practices (testing, code style, etc.)
- Tasks where a reasonable interpretation exists
### Step 3: Create Draft Plans
Group related tasks into plans using your best judgment. Plans start as drafts (tasks won't be dispatched until activated).
**Grouping guidance:**
- Group tasks that share a common theme, feature area, or goal
- Consider technical dependencies when grouping (tasks that touch the same files/modules)
- Separate unrelated work into distinct plans for parallel execution
- Don't over-group—if tasks are truly independent, separate plans enable better parallelism
- Don't under-group—related tasks benefit from shared context and coordinated execution
```bash
sf plan create --title "Plan Name"
```
**Example:**
```bash
sf plan create --title "Authentication Improvements"
# Output: Created plan el-abc123
```
### Step 4: Add Tasks to Plans
```bash
sf plan add-task <plan-id> <task-id>
```
**Example:**
```bash
sf plan add-task el-abc123 el-task1
sf plan add-task el-abc123 el-task2
```
### Step 5: Set Dependencies Between Tasks
Use `blocks` dependency when one task must complete before another can start.
```bash
sf dependency add <blocked-id> <blocker-id> --type blocks
```
**Semantics:** The first ID is blocked BY the second ID. The blocker must complete first.
**Example:** Task 2 can't start until Task 1 completes:
```bash
sf dependency add el-task2 el-task1 --type blocks
```
### Step 6: Update Priorities
Set priorities based on your assessment of impact, urgency, and dependencies. Use your judgment—you don't need user confirmation for routine prioritization.
**Priority guidance:**
- **Critical (1):** Blocking issues, security vulnerabilities, production bugs
- **High (2):** Important features with deadlines, significant user impact
- **Medium (3):** Standard feature work, most tasks default here
- **Low (4):** Nice-to-haves, minor improvements, tech debt
- **Minimal (5):** Backlog cleanup, documentation, exploratory work
```bash
sf update <task-id> --priority <1-5>
```
| Value | Level |
| ----- | -------- |
| 1 | Critical |
| 2 | High |
| 3 | Medium |
| 4 | Low |
| 5 | Minimal |
### Step 7: Activate Plans
Once tasks are organized with dependencies set, activate plans to enable dispatch.
```bash
sf plan activate <plan-id>
```
### Step 8: Set Task Status to Open
Move tasks from backlog to open so they become ready for work.
```bash
sf update <id> --status open
```
## Other Actions
**Close obsolete tasks:**
```bash
sf task close <id> --reason "Won't do: <reason>"
```
**Defer tasks:**
```bash
sf task defer <id> --until <date>
```
**View existing plans:**
```bash
sf plan list
```
**View tasks in a plan:**
```bash
sf plan tasks <plan-id>
```
## Tips
- **Use your best judgment** for grouping, prioritization, and task interpretation—don't defer routine decisions to the user
- **Only escalate to the user** when ambiguity is fundamental and could lead to wasted work (mutually exclusive interpretations, critical business decisions)
- Make reasonable inferences about implementation details, scope, and priority based on codebase context
- Create plans before setting dependencies to avoid dispatch race conditions
- Always activate plans after dependencies are set
- Focus on oldest backlog items first (sorted by creation date)
- Every task should have a clear title and description before activation
- When uncertain about a minor detail, make a reasonable choice and document it in the task description—workers can ask if needed
+1
View File
@@ -48,3 +48,4 @@ apps/web/dist/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
/.stoneforge/.worktrees/
+2
View File
@@ -0,0 +1,2 @@
262629
1779624255076
+6
View File
@@ -0,0 +1,6 @@
# Runtime data
*.db
*.db-journal
*.db-wal
*.db-shm
daemon-state.json
+20
View File
@@ -0,0 +1,20 @@
# Stoneforge Configuration
database: stoneforge.db
sync:
auto_export: true
elements_file: elements.jsonl
dependencies_file: dependencies.jsonl
playbooks:
paths:
- playbooks
identity:
mode: soft
merge:
auto_merge: true
target_branch: null
require_approval: false
workflow:
preset: auto
agents:
permission_model: unrestricted
+43
View File
@@ -0,0 +1,43 @@
{"blockedId":"el-1of","blockerId":"el-258","type":"parent-child","createdAt":"2026-05-24T09:44:58.759Z","createdBy":"el-2jua"}
{"blockedId":"el-5fe","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:40.892Z","createdBy":"el-2jua"}
{"blockedId":"el-1nj","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.010Z","createdBy":"el-2jua"}
{"blockedId":"el-1bn","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.127Z","createdBy":"el-2jua"}
{"blockedId":"el-4hr","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.244Z","createdBy":"el-2jua"}
{"blockedId":"el-62c","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.372Z","createdBy":"el-2jua"}
{"blockedId":"el-5z8","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.490Z","createdBy":"el-2jua"}
{"blockedId":"el-1t7","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.607Z","createdBy":"el-2jua"}
{"blockedId":"el-5j5","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.726Z","createdBy":"el-2jua"}
{"blockedId":"el-2xl","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.844Z","createdBy":"el-2jua"}
{"blockedId":"el-4bc","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.959Z","createdBy":"el-2jua"}
{"blockedId":"el-107","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.074Z","createdBy":"el-2jua"}
{"blockedId":"el-32e","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.195Z","createdBy":"el-2jua"}
{"blockedId":"el-3ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.311Z","createdBy":"el-2jua"}
{"blockedId":"el-14w","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.425Z","createdBy":"el-2jua"}
{"blockedId":"el-1ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.541Z","createdBy":"el-2jua"}
{"blockedId":"el-1nj","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.651Z","createdBy":"el-2jua"}
{"blockedId":"el-1bn","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.761Z","createdBy":"el-2jua"}
{"blockedId":"el-4hr","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.868Z","createdBy":"el-2jua"}
{"blockedId":"el-62c","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:42.979Z","createdBy":"el-2jua"}
{"blockedId":"el-62c","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.092Z","createdBy":"el-2jua"}
{"blockedId":"el-5z8","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.205Z","createdBy":"el-2jua"}
{"blockedId":"el-5z8","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.313Z","createdBy":"el-2jua"}
{"blockedId":"el-1t7","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.422Z","createdBy":"el-2jua"}
{"blockedId":"el-1t7","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.529Z","createdBy":"el-2jua"}
{"blockedId":"el-1t7","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:43.647Z","createdBy":"el-2jua"}
{"blockedId":"el-5j5","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.758Z","createdBy":"el-2jua"}
{"blockedId":"el-2xl","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.876Z","createdBy":"el-2jua"}
{"blockedId":"el-4bc","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.987Z","createdBy":"el-2jua"}
{"blockedId":"el-4bc","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:44.096Z","createdBy":"el-2jua"}
{"blockedId":"el-4bc","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:44.208Z","createdBy":"el-2jua"}
{"blockedId":"el-107","blockerId":"el-5z8","type":"blocks","createdAt":"2026-05-24T12:44:44.319Z","createdBy":"el-2jua"}
{"blockedId":"el-32e","blockerId":"el-5j5","type":"blocks","createdAt":"2026-05-24T12:44:44.429Z","createdBy":"el-2jua"}
{"blockedId":"el-32e","blockerId":"el-2xl","type":"blocks","createdAt":"2026-05-24T12:44:44.539Z","createdBy":"el-2jua"}
{"blockedId":"el-3ou","blockerId":"el-4bc","type":"blocks","createdAt":"2026-05-24T12:44:44.650Z","createdBy":"el-2jua"}
{"blockedId":"el-3ou","blockerId":"el-107","type":"blocks","createdAt":"2026-05-24T12:44:44.761Z","createdBy":"el-2jua"}
{"blockedId":"el-14w","blockerId":"el-32e","type":"blocks","createdAt":"2026-05-24T12:44:44.873Z","createdBy":"el-2jua"}
{"blockedId":"el-1ou","blockerId":"el-3ou","type":"blocks","createdAt":"2026-05-24T12:44:44.987Z","createdBy":"el-2jua"}
{"blockedId":"el-1ou","blockerId":"el-14w","type":"blocks","createdAt":"2026-05-24T12:44:45.107Z","createdBy":"el-2jua"}
{"blockedId":"el-375","blockerId":"el-26p","type":"replies-to","createdAt":"2026-05-24T13:21:42.486Z","createdBy":"el-2i1s"}
{"blockedId":"el-3n4","blockerId":"el-31p","type":"replies-to","createdAt":"2026-05-24T13:21:46.044Z","createdBy":"el-13ju"}
{"blockedId":"el-3jer","blockerId":"el-1xx","type":"replies-to","createdAt":"2026-05-24T13:24:47.580Z","createdBy":"el-4350"}
{"blockedId":"el-1afv","blockerId":"el-1ozw","type":"replies-to","createdAt":"2026-05-24T13:32:42.658Z","createdBy":"el-51a8"}
File diff suppressed because one or more lines are too long
+25
View File
@@ -87,6 +87,31 @@ Do not claim completion without verification evidence.
## Git workflow ## Git workflow
### Branching strategy
For every spec change or new functionality:
1. Create a new branch from `dev` with a proper prefix:
- `feat/` for new features (e.g., `feat/tool-workshop`)
- `fix/` for bug fixes (e.g., `fix/terminal-tty`)
- `refactor/` for refactors (e.g., `refactor/api-cleanup`)
- `docs/` for documentation (e.g., `docs/api-guide`)
- `chore/` for maintenance (e.g., `chore/update-deps`)
2. Branch name should reference the OpenSpec change name when applicable.
3. Do not commit directly to `main` or `dev`.
### Completion and merge
When implementation is complete and verified:
1. Ensure all tests pass and quality gates are met.
2. Stage all changes with `git add -A`.
3. Create a commit with a proper conventional commit message (see below).
4. Switch to `dev`: `git checkout dev`.
5. Merge the feature branch: `git merge --no-ff <branch-name>`.
6. Push to remote: `git push origin dev`.
7. Delete the local feature branch if desired: `git branch -d <branch-name>`.
### Auto-commit on spec completion ### Auto-commit on spec completion
When an OpenSpec change is fully implemented and all tasks are complete: When an OpenSpec change is fully implemented and all tasks are complete:
+1
View File
@@ -27,6 +27,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \ libpq5 \
git \ git \
openssh-client \
netcat-openbsd \ netcat-openbsd \
ca-certificates \ ca-certificates \
curl \ curl \
@@ -0,0 +1,29 @@
"""add probe_result to tool_instances
Revision ID: 0013_add_probe_result
Revises: 0012_default_port_req
Create Date: 2026-05-22 21:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0013_add_probe_result"
down_revision: Union[str, None] = "0012_default_port_req"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_instances",
sa.Column("probe_result", postgresql.JSON, nullable=True)
)
def downgrade() -> None:
op.drop_column("tool_instances", "probe_result")
@@ -0,0 +1,25 @@
"""merge migration heads
Revision ID: 0014_merge_heads
Revises: 0013_add_probe_result, 8ed7dd80973d
Create Date: 2026-05-22 21:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0014_merge_heads"
down_revision: Union[str, Sequence[str], None] = ("0013_add_probe_result", "8ed7dd80973d")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,110 @@
"""replace interfaces with interface_type and add requires_port
Revision ID: 0015_single_interface
Revises: 0014_merge_heads
Create Date: 2026-05-22 22:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = "0015_single_interface"
down_revision: Union[str, Sequence[str], None] = "0014_merge_heads"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _get_dialect() -> str:
"""Get the current database dialect name."""
conn = op.get_bind()
return conn.dialect.name
def upgrade() -> None:
dialect = _get_dialect()
# Add new columns
op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True))
op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true'))
# Migrate data: take first element from interfaces JSON array
if dialect == 'postgresql':
op.execute("""
UPDATE tool_types
SET interface_type = COALESCE(
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
'web'
),
requires_port = CASE
WHEN COALESCE(
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
'web'
) = 'web' THEN true
ELSE false
END
""")
else:
# SQLite: interfaces is stored as JSON text, extract first array element
op.execute("""
UPDATE tool_types
SET interface_type = COALESCE(
(SELECT json_extract(value, '$[0]')
FROM json_each(interfaces) AS value
WHERE json_valid(interfaces)
LIMIT 1),
'web'
),
requires_port = CASE
WHEN COALESCE(
(SELECT json_extract(value, '$[0]')
FROM json_each(interfaces) AS value
WHERE json_valid(interfaces)
LIMIT 1),
'web'
) = 'web' THEN true
ELSE false
END
""")
# Make interface_type non-nullable after data migration
op.alter_column('tool_types', 'interface_type', nullable=False)
# Drop old interfaces column
op.drop_column('tool_types', 'interfaces')
# Add CHECK constraint for interface_type (only on PostgreSQL; SQLite supports it too)
op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')"))
def downgrade() -> None:
dialect = _get_dialect()
# Drop CHECK constraint
op.drop_constraint('chk_interface_type', 'tool_types', type_='check')
# Add back interfaces column
if dialect == 'postgresql':
op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]'))
# Migrate data back: wrap interface_type in array
op.execute("""
UPDATE tool_types
SET interfaces = jsonb_build_array(interface_type)
""")
else:
op.add_column('tool_types', sa.Column('interfaces', sa.JSON(), nullable=False, server_default='["web"]'))
# Migrate data back: wrap interface_type in array for SQLite
op.execute("""
UPDATE tool_types
SET interfaces = json_array(interface_type)
""")
# Drop new columns
op.drop_column('tool_types', 'requires_port')
op.drop_column('tool_types', 'interface_type')
@@ -0,0 +1,36 @@
"""add_clone_mode_and_ssh_key_id
Revision ID: 2026_05_22_add_clone_mode
Revises: 0014_merge_heads
Create Date: 2026-05-22 20:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '2026_05_22_add_clone_mode'
down_revision = '0015_single_interface'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add ssh_key_id to git_repositories
op.add_column('git_repositories', sa.Column('ssh_key_id', postgresql.UUID(), nullable=True))
op.create_foreign_key('fk_git_repositories_ssh_key', 'git_repositories', 'ssh_keys', ['ssh_key_id'], ['id'])
# Add clone_mode and branch to tool_instances
op.add_column('tool_instances', sa.Column('clone_mode', sa.String(20), nullable=False, server_default='mount'))
op.add_column('tool_instances', sa.Column('branch', sa.String(255), nullable=True, server_default='main'))
def downgrade() -> None:
# Drop columns from tool_instances
op.drop_column('tool_instances', 'branch')
op.drop_column('tool_instances', 'clone_mode')
# Drop ssh_key_id from git_repositories
op.drop_constraint('fk_git_repositories_ssh_key', 'git_repositories', type_='foreignkey')
op.drop_column('git_repositories', 'ssh_key_id')
@@ -0,0 +1,25 @@
"""remove_is_builtin_from_tool_types
Revision ID: 2026_05_23_remove_is_builtin
Revises: 2026_05_22_add_clone_mode
Create Date: 2026-05-23 14:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2026_05_23_remove_is_builtin'
down_revision = 'f3d2dc90ba3a'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Drop the is_builtin column from tool_types
op.execute("ALTER TABLE tool_types DROP COLUMN IF EXISTS is_builtin")
def downgrade() -> None:
# Add the is_builtin column back to tool_types
op.add_column('tool_types', sa.Column('is_builtin', sa.Boolean(), nullable=False, server_default='false'))
@@ -0,0 +1,86 @@
"""add_config_profiles
Revision ID: 2026_05_24_add_config_profiles
Revises: f3d2dc90ba3a
Create Date: 2026-05-24 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "2026_05_24_add_config_profiles"
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create config_profiles table
op.create_table(
"config_profiles",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True),
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True),
sa.Column("env_vars", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
sa.Column("runtime_hints", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
sa.Column("mounts", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="[]"),
sa.Column("files", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
# Create indexes for config_profiles
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
# Create config_profile_includes table
op.create_table(
"config_profile_includes",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_profile_includes"),
)
# Create indexes for config_profile_includes
op.create_index("idx_config_profile_includes_profile", "config_profile_includes", ["profile_id"])
op.create_index("idx_config_profile_includes_included", "config_profile_includes", ["included_profile_id"])
# Add selected_config_profile_id to tool_instances
op.add_column(
"tool_instances",
sa.Column("selected_config_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True),
)
op.create_index("idx_tool_instances_config_profile", "tool_instances", ["selected_config_profile_id"])
def downgrade() -> None:
# Remove selected_config_profile_id from tool_instances
op.drop_index("idx_tool_instances_config_profile", table_name="tool_instances")
op.drop_column("tool_instances", "selected_config_profile_id")
# Drop config_profile_includes table
op.drop_index("idx_config_profile_includes_included", table_name="config_profile_includes")
op.drop_index("idx_config_profile_includes_profile", table_name="config_profile_includes")
op.drop_table("config_profile_includes")
# Drop config_profiles table
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
op.drop_table("config_profiles")
@@ -0,0 +1,25 @@
"""merge_remove_is_builtin_and_add_config_profiles
Revision ID: 6fc7bfcf199f
Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
Create Date: 2026-05-24 18:00:43.990361
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6fc7bfcf199f'
down_revision = ('2026_05_23_remove_is_builtin', '2026_05_24_add_config_profiles')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,25 @@
"""merge_single_interface_and_clone_mode
Revision ID: f3d2dc90ba3a
Revises: 0015_single_interface, 2026_05_22_add_clone_mode
Create Date: 2026-05-24 10:43:14.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f3d2dc90ba3a"
down_revision: Union[str, Sequence[str], None] = ("0015_single_interface", "2026_05_22_add_clone_mode")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+645
View File
@@ -0,0 +1,645 @@
"""Config profile API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.project import Project
from src.models.tool_type import ToolType
from src.services.config_profile_resolver import (
ConfigProfileCycleError,
check_include_cycle,
resolve_profile,
resolved_profile_to_dict,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def _validate_uuid(v: str | None) -> str | None:
if v is None:
return v
try:
uuid.UUID(v)
except ValueError:
raise ValueError(f"Invalid UUID: {v}")
return v
def _calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data."""
total = 0
for key, value in data.get("env_vars", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for key, value in data.get("runtime_hints", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for mount in data.get("mounts", []):
total += len(str(mount.get("target", "")).encode("utf-8"))
total += len(str(mount.get("mode", "")).encode("utf-8"))
for path, content in mount.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
for path, content in data.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
return total
class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
@field_validator("target")
@classmethod
def validate_target(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount target must be absolute (start with /)")
return v
@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
if v not in ("ro", "rw"):
raise ValueError("Mount mode must be 'ro' or 'rw'")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v.keys():
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"Mount file paths must be relative (got: {path}). "
f"The mount target defines the absolute container path."
)
return v
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict = Field(default_factory=dict, description="Environment variables")
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
for path in v.keys():
if ".." in path or not path:
raise ValueError(f"Invalid file path: {path}")
if path.startswith("/"):
raise ValueError(
f"File paths must be relative (got: {path}). "
f"Use Mounts for absolute container paths."
)
return v
@field_validator("env_vars")
@classmethod
def validate_env_vars(cls, v: dict) -> dict:
if not isinstance(v, dict):
raise ValueError("env_vars must be a JSON object")
return v
@field_validator("runtime_hints")
@classmethod
def validate_runtime_hints(cls, v: dict) -> dict:
if not isinstance(v, dict):
raise ValueError("runtime_hints must be a JSON object")
return v
@field_validator("mounts")
@classmethod
def validate_mounts(cls, v: list) -> list:
if not isinstance(v, list):
raise ValueError("mounts must be a JSON array")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
project_id: str | None = Field(default=None, description="Optional project ID")
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict | None = Field(default=None, description="Environment variables")
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
@field_validator("project_id", "tool_type_id")
@classmethod
def validate_uuids(cls, v: str | None) -> str | None:
return _validate_uuid(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
if v is None:
return v
for path in v.keys():
if ".." in path or path.startswith("/") or not path:
raise ValueError(f"Invalid file path: {path}")
return v
class ConfigProfileIncludeUpdate(BaseModel):
includes: list[str] = Field(description="Ordered list of included profile IDs")
@field_validator("includes")
@classmethod
def validate_includes(cls, v: list) -> list:
for item in v:
try:
uuid.UUID(item)
except ValueError:
raise ValueError(f"Invalid UUID in includes: {item}")
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
project_id: str | None
tool_type_id: str | None
env_vars: dict
runtime_hints: dict
mounts: list
files: dict
is_default: bool
includes: list[dict]
created_at: str
updated_at: str
async def _get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) -> ConfigProfile | None:
"""Fetch a profile with includes eagerly loaded."""
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile_id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one_or_none()
async def _check_access(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID | None = None,
tool_type_id: uuid.UUID | None = None,
) -> None:
"""Verify user has access to referenced project and tool type."""
if project_id is not None:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
# Add ownership check if needed; for now just verify existence
if tool_type_id is not None:
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"project_id": str(profile.project_id) if profile.project_id else None,
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
"env_vars": profile.env_vars or {},
"runtime_hints": profile.runtime_hints or {},
"mounts": profile.mounts or [],
"files": profile.files or {},
"is_default": profile.is_default,
"includes": [
{
"id": str(inc.id),
"included_profile_id": str(inc.included_profile_id),
"order_index": inc.order_index,
}
for inc in (includes or profile.includes)
],
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles(
project_id: str | None = Query(None, description="Filter by project compatibility"),
tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""List config profiles, optionally filtered by compatibility."""
user_uuid = current_user_id
query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes))
if project_id or tool_type_id:
# Compatibility filter: include portable profiles and matching scoped profiles
project_uuid = uuid.UUID(project_id) if project_id else None
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
from sqlalchemy import or_
conditions: list = []
# Portable profiles (no project, no tool)
conditions.append(
(ConfigProfile.project_id.is_(None)) & (ConfigProfile.tool_type_id.is_(None))
)
if project_uuid:
# Profiles matching this project (with or without tool)
conditions.append(ConfigProfile.project_id == project_uuid)
if tool_uuid:
# Profiles matching this tool (with or without project)
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
if project_uuid and tool_uuid:
# Exact match
conditions.append(
(ConfigProfile.project_id == project_uuid) & (ConfigProfile.tool_type_id == tool_uuid)
)
query = query.where(or_(*conditions))
result = await session.execute(query)
profiles = result.scalars().all()
return [_profile_to_response(p) for p in profiles]
@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED)
async def create_config_profile(
data: ConfigProfileCreate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Create a new config profile."""
user_uuid = current_user_id
# Check for duplicate name
existing = await session.execute(
select(ConfigProfile).where(
ConfigProfile.user_id == user_uuid,
ConfigProfile.name == data.name,
).options(selectinload(ConfigProfile.includes))
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{data.name}' already exists",
)
# Validate references
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await _check_access(session, user_uuid, project_uuid, tool_uuid)
# Check size
size = _calculate_profile_size(data.model_dump())
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
)
profile = ConfigProfile(
user_id=user_uuid,
name=data.name,
description=data.description,
project_id=project_uuid,
tool_type_id=tool_uuid,
env_vars=data.env_vars,
runtime_hints=data.runtime_hints,
mounts=[m.model_dump() for m in data.mounts],
files=data.files,
is_default=data.is_default,
)
session.add(profile)
await session.commit()
# Re-fetch with includes to avoid lazy loading issues
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
logger.info("Created config profile %s for user %s", profile.id, user_uuid)
return _profile_to_response(profile)
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
async def get_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Get a config profile by ID."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
if profile.user_id != current_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
return _profile_to_response(profile)
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
async def update_config_profile(
profile_id: str,
data: ConfigProfileUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Update a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
if profile.user_id != current_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
update_data = data.model_dump(exclude_unset=True)
# Handle name uniqueness
if "name" in update_data:
existing = await session.execute(
select(ConfigProfile).where(
ConfigProfile.user_id == profile.user_id,
ConfigProfile.name == update_data["name"],
ConfigProfile.id != profile.id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Profile with name '{update_data['name']}' already exists",
)
# Validate references
project_uuid = (
uuid.UUID(update_data["project_id"])
if "project_id" in update_data and update_data["project_id"]
else (profile.project_id if "project_id" not in update_data else None)
)
tool_uuid = (
uuid.UUID(update_data["tool_type_id"])
if "tool_type_id" in update_data and update_data["tool_type_id"]
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
# Check size
current_data = _profile_to_response(profile)
merged = {**current_data, **update_data}
size = _calculate_profile_size(merged)
if size > MAX_PROFILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
)
# Apply updates
for field_name, value in update_data.items():
if field_name in ("project_id", "tool_type_id"):
value = uuid.UUID(value) if value else None
elif field_name == "mounts" and value is not None:
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
setattr(profile, field_name, value)
await session.commit()
# Re-fetch with includes to avoid lazy loading issues
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile.id)
.options(selectinload(ConfigProfile.includes))
)
profile = result.scalar_one()
logger.info("Updated config profile %s", profile.id)
return _profile_to_response(profile)
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Delete a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
if profile.user_id != current_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
await session.delete(profile)
await session.commit()
logger.info("Deleted config profile %s", profile_id)
return None
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
async def update_profile_includes(
profile_id: str,
data: ConfigProfileIncludeUpdate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Update the ordered includes for a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
if profile.user_id != current_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
# Validate all included profiles exist and belong to the user
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
for inc_uuid in included_uuids:
inc_profile = await session.get(ConfigProfile, inc_uuid)
if inc_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Included profile not found: {inc_uuid}",
)
if inc_profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Not authorized to include profile: {inc_uuid}",
)
if inc_uuid == profile.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Profile cannot include itself",
)
# Check for cycles
cycle = await check_include_cycle(session, profile.id, None)
if cycle is None and included_uuids:
# Check each new include would not create a cycle
for inc_uuid in included_uuids:
cycle = await check_include_cycle(session, profile.id, inc_uuid)
if cycle is not None:
break
if cycle is not None:
cycle_str = " -> ".join(str(c) for c in cycle)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Include cycle detected: {cycle_str}",
)
# Remove existing includes
result = await session.execute(
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
)
for existing in result.scalars().all():
await session.delete(existing)
await session.flush()
# Add new includes
for order_index, inc_uuid in enumerate(included_uuids):
include = ConfigProfileInclude(
profile_id=profile.id,
included_profile_id=inc_uuid,
order_index=order_index,
)
session.add(include)
await session.flush()
await session.commit()
# Re-fetch profile (includes loaded separately due to SQLite async issue)
result = await session.execute(
select(ConfigProfile).where(ConfigProfile.id == profile.id)
)
profile = result.scalar_one()
inc_result = await session.execute(
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
)
direct_includes = inc_result.scalars().all()
logger.info("Updated includes for config profile %s", profile.id)
return _profile_to_response(profile, list(direct_includes))
@router.get("/{profile_id}/preview")
async def preview_config_profile(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Preview the resolved output of a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
if profile.user_id != current_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
try:
resolved = await resolve_profile(session, profile.id)
except ConfigProfileCycleError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
return resolved_profile_to_dict(resolved)
@router.get("/defaults/resolve")
async def resolve_default_profile(
project_id: str = Query(..., description="Project ID"),
tool_type_id: str = Query(..., description="Tool type ID"),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Resolve the default config profile for a project/tool combination.
Selects by specificity:
1. project+tool explicit default
2. project explicit default
3. tool explicit default
4. global/user explicit default
5. first created compatible profile
6. none (returns null)
"""
user_uuid = current_user_id
project_uuid = uuid.UUID(project_id)
tool_uuid = uuid.UUID(tool_type_id)
# Fetch all compatible profiles ordered by created_at
query = (
select(ConfigProfile)
.where(ConfigProfile.user_id == user_uuid)
.where(
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
| (ConfigProfile.project_id == project_uuid)
| (ConfigProfile.tool_type_id == tool_uuid)
| (
(ConfigProfile.project_id == project_uuid)
& (ConfigProfile.tool_type_id == tool_uuid)
)
)
.order_by(ConfigProfile.created_at)
)
result = await session.execute(query)
profiles = result.scalars().all()
if not profiles:
return {"profile_id": None, "profile_name": None}
# Check explicit defaults by specificity
explicit_defaults = [p for p in profiles if p.is_default]
# Most specific: project+tool
for p in explicit_defaults:
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: project only
for p in explicit_defaults:
if p.project_id == project_uuid and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: tool only
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id == tool_uuid:
return {"profile_id": str(p.id), "profile_name": p.name}
# Next: global/user (no project, no tool)
for p in explicit_defaults:
if p.project_id is None and p.tool_type_id is None:
return {"profile_id": str(p.id), "profile_name": p.name}
# Fall back to first created compatible profile
first = profiles[0]
return {"profile_id": str(first.id), "profile_name": first.name}
+139 -5
View File
@@ -14,6 +14,7 @@ from src.auth.dependencies import get_current_user_id, get_db_session
from src.config import Settings from src.config import Settings
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User from src.models.user import User
from src.utils.git_files import ( from src.utils.git_files import (
commit_file, commit_file,
@@ -34,6 +35,7 @@ from src.utils.git_control import (
) )
from src.utils.git_history import get_commit_detail, get_commit_history from src.utils.git_history import get_commit_detail, get_commit_history
from src.utils.git_url_parser import parse_git_url from src.utils.git_url_parser import parse_git_url
from src.services.ssh_keys import _get_fernet
router = APIRouter(prefix="/projects", tags=["git-repositories"]) router = APIRouter(prefix="/projects", tags=["git-repositories"])
@@ -94,41 +96,97 @@ def _build_provider_clone_url(owner: str, repo: str) -> str:
return f"git@git.commumedia.org:{owner}/{repo}.git" return f"git@git.commumedia.org:{owner}/{repo}.git"
def _preflight_remote_repository(remote_url: str) -> None: def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
"""Prepare environment variables for git commands with SSH authentication.
Returns a dict of extra env vars, or None if no SSH key provided.
The caller is responsible for cleaning up the temporary key file.
"""
if ssh_key is None:
return None
import tempfile
# Decrypt private key
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
# Write to temp file with restricted permissions
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
try:
os.write(fd, private_key.encode())
finally:
os.close(fd)
os.chmod(key_path, 0o600)
# Return env vars and the key path for cleanup
env = {
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
}
return env, key_path
def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
"""Verify a remote repository is reachable before cloning.""" """Verify a remote repository is reachable before cloning."""
env = None
key_path = None
if ssh_key is not None:
ssh_result = _prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try: try:
result = subprocess.run( result = subprocess.run(
["git", "ls-remote", remote_url], ["git", "ls-remote", remote_url],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60, timeout=60,
env={**os.environ, **env} if env else None,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0: if result.returncode != 0:
logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="repository not found or inaccessible", detail=f"repository not found or inaccessible: {result.stderr}",
) )
def _clone_working_repository(remote_url: str, repo_path: str) -> None: def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
env = None
key_path = None
if ssh_key is not None:
ssh_result = _prepare_ssh_env(ssh_key)
if ssh_result:
env, key_path = ssh_result
try: try:
result = subprocess.run( result = subprocess.run(
["git", "clone", remote_url, repo_path], ["git", "clone", remote_url, repo_path],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=300, timeout=300,
env={**os.environ, **env} if env else None,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
finally:
if key_path and os.path.exists(key_path):
os.unlink(key_path)
if result.returncode != 0: if result.returncode != 0:
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to clone repository: {result.stderr}", detail=f"failed to clone repository: {result.stderr}",
@@ -175,6 +233,7 @@ class GitRepositoryCreate(BaseModel):
name: str name: str
remote_url: str | None = None remote_url: str | None = None
force_original_url: bool = False force_original_url: bool = False
ssh_key_id: str | None = None
class URLParseRequest(BaseModel): class URLParseRequest(BaseModel):
@@ -202,6 +261,7 @@ class GitRepositoryResponse(BaseModel):
is_mirror: bool is_mirror: bool
remote_url: str | None remote_url: str | None
last_push: datetime | None last_push: datetime | None
ssh_key_id: uuid.UUID | None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -349,8 +409,23 @@ async def create_repository(
if parse_result["base_url"]: if parse_result["base_url"]:
remote_url = parse_result["base_url"] remote_url = parse_result["base_url"]
# Validate SSH key if provided
ssh_key_id = None
ssh_key = None
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
if remote_url: if remote_url:
_preflight_remote_repository(remote_url) _preflight_remote_repository(remote_url, ssh_key)
repo_path = _get_repo_path(user_id, project_id, data.name) repo_path = _get_repo_path(user_id, project_id, data.name)
@@ -358,7 +433,7 @@ async def create_repository(
os.makedirs(os.path.dirname(repo_path), exist_ok=True) os.makedirs(os.path.dirname(repo_path), exist_ok=True)
if remote_url: if remote_url:
_clone_working_repository(remote_url, repo_path) _clone_working_repository(remote_url, repo_path, ssh_key)
else: else:
_init_working_repository(repo_path) _init_working_repository(repo_path)
@@ -369,6 +444,7 @@ async def create_repository(
owner_id=user_id, owner_id=user_id,
is_mirror=False, is_mirror=False,
remote_url=remote_url, remote_url=remote_url,
ssh_key_id=ssh_key_id,
) )
session.add(repo) session.add(repo)
await session.commit() await session.commit()
@@ -376,6 +452,64 @@ async def create_repository(
return repo return repo
class UpdateSSHKeyRequest(BaseModel):
ssh_key_id: str | None = None
@router.patch(
"/{project_id}/repositories/{repo_id}/ssh-key",
response_model=GitRepositoryResponse,
summary="Update repository SSH key",
description="Update the SSH key associated with a repository.",
)
async def update_repository_ssh_key(
project_id: uuid.UUID,
repo_id: uuid.UUID,
data: UpdateSSHKeyRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Update the SSH key for a repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Update data containing the new SSH key ID.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated repository.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
repo = await session.get(GitRepository, repo_id)
if repo is None or repo.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
# Validate SSH key if provided
if data.ssh_key_id:
try:
ssh_key_id = uuid.UUID(data.ssh_key_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
ssh_key = await session.get(SSHKey, ssh_key_id)
if ssh_key is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
repo.ssh_key_id = ssh_key_id
else:
repo.ssh_key_id = None
await session.commit()
await session.refresh(repo)
return repo
@router.get( @router.get(
"/{project_id}/repositories/{repo_id}/history", "/{project_id}/repositories/{repo_id}/history",
summary="Get repository history", summary="Get repository history",
+95
View File
@@ -1,3 +1,4 @@
import base64
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -74,6 +75,23 @@ class SSHKeyResponse(BaseModel):
created_at: datetime created_at: datetime
class SignPayloadRequest(BaseModel):
payload: str
class SignatureResponse(BaseModel):
signature: str
class VerifySignatureRequest(BaseModel):
payload: str
signature: str
class VerifySignatureResponse(BaseModel):
valid: bool
@router.post( @router.post(
"", "",
response_model=SSHKeyResponse, response_model=SSHKeyResponse,
@@ -166,3 +184,80 @@ async def delete_ssh_key(
await session.delete(ssh_key) await session.delete(ssh_key)
await session.commit() await session.commit()
@router.post(
"/{key_id}/sign",
response_model=SignatureResponse,
summary="Sign payload",
description="Sign a payload using the SSH private key.",
)
async def sign_payload(
key_id: uuid.UUID,
data: SignPayloadRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> SignatureResponse:
"""Sign a payload with an SSH key.
Args:
key_id: UUID of the SSH key to use for signing.
data: Sign request containing the payload string.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Base64-encoded Ed25519 signature.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
fernet = _get_fernet()
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
private_key = serialization.load_ssh_private_key(
private_key_pem.encode(), password=None
)
signature = private_key.sign(data.payload.encode())
return SignatureResponse(signature=base64.b64encode(signature).decode())
@router.post(
"/{key_id}/verify",
response_model=VerifySignatureResponse,
summary="Verify signature",
description="Verify a signature against a payload using the SSH public key.",
)
async def verify_signature(
key_id: uuid.UUID,
data: VerifySignatureRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> VerifySignatureResponse:
"""Verify a signature with an SSH key's public key.
Args:
key_id: UUID of the SSH key to use for verification.
data: Verify request containing payload and base64-encoded signature.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Whether the signature is valid.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
try:
signature = base64.b64decode(data.signature)
public_key.verify(signature, data.payload.encode())
return VerifySignatureResponse(valid=True)
except Exception:
return VerifySignatureResponse(valid=False)
+169 -10
View File
@@ -26,6 +26,7 @@ async def terminal_websocket(
"""WebSocket endpoint for terminal access to a tool instance. """WebSocket endpoint for terminal access to a tool instance.
Provides an interactive terminal session inside a running tool instance container. Provides an interactive terminal session inside a running tool instance container.
Sessions persist across WebSocket disconnections.
Args: Args:
websocket: The WebSocket connection. websocket: The WebSocket connection.
@@ -70,32 +71,190 @@ async def terminal_websocket(
await websocket.close(code=4004, reason="Instance not running") await websocket.close(code=4004, reason="Instance not running")
return return
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id) # Get or create terminal session
# Create terminal session
try: try:
session = await terminal_manager.create_session( session = await terminal_manager.get_or_create_session(
instance_uuid, instance_uuid,
instance.container_id, instance.container_id,
websocket,
) )
logger.info("Terminal session created successfully for instance %s", instance_id) logger.info("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
# Attach WebSocket to session
await terminal_manager.attach_websocket(session, websocket)
logger.info("WebSocket attached to session for instance %s", instance_id)
# Send connected status # Send connected status
await websocket.send_json({"type": "status", "status": "connected"}) await websocket.send_json({"type": "status", "status": "connected"})
# Keep connection alive until session ends # Start I/O loops and heartbeat
# The terminal_manager handles I/O loops, we just wait here read_task = asyncio.create_task(_read_loop(session, websocket))
while session.is_alive() and not session._closed: write_task = asyncio.create_task(_write_loop(session, websocket, instance_id))
await asyncio.sleep(0.5) heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
# Wait for either task to complete (indicating disconnect or error)
done, pending = await asyncio.wait(
[read_task, write_task, heartbeat_task],
return_when=asyncio.FIRST_COMPLETED,
)
# Cancel remaining tasks
for task in pending:
task.cancel()
except Exception as exc: except Exception as exc:
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True) logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
await websocket.close(code=4000, reason=f"Error: {exc}") await websocket.close(code=4000, reason=f"Error: {exc}")
finally: finally:
# Cleanup will be handled by the session manager # Detach WebSocket, don't kill session
try:
if 'session' in locals():
await terminal_manager.detach_websocket(session, websocket)
logger.info("WebSocket detached from session for instance %s", instance_id)
except Exception:
pass
async def _read_loop(session, websocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
while session.is_alive() and not session._closed:
data = await session.read_output()
if data:
try:
await websocket.send_bytes(data)
except Exception:
break
else:
await asyncio.sleep(0.01)
except Exception:
pass pass
async def _write_loop(session, websocket, instance_id: str) -> None:
"""Read input from WebSocket and send to container."""
try:
while session.is_alive() and not session._closed:
message = await websocket.receive()
if message["type"] == "websocket.receive":
if "bytes" in message:
await session.write_input(message["bytes"])
elif "text" in message:
text = message["text"]
if text.startswith("{"):
# Control message (JSON)
import json
try:
ctrl = json.loads(text)
msg_type = ctrl.get("type")
if msg_type == "resize":
cols = ctrl.get("cols", 80)
rows = ctrl.get("rows", 24)
logger.info(f"Received resize message for instance {instance_id}: {cols}x{rows}")
await session.resize(cols, rows)
elif msg_type == "reset":
# Reset terminal session
logger.info("Resetting terminal session for instance %s", session.instance_id)
await websocket.send_json({"type": "status", "status": "resetting"})
# Reset the session
new_session = await terminal_manager.reset_session(
session.instance_id,
session.container_id,
)
# 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
except json.JSONDecodeError:
pass
else:
await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect":
break
except Exception:
pass
async def _heartbeat_loop(websocket: WebSocket) -> None:
"""Send periodic ping messages to detect disconnections."""
try:
while True:
await asyncio.sleep(30) # Ping every 30 seconds
try:
await websocket.send_json({"type": "ping"})
except Exception:
# WebSocket is closed or broken
break
except Exception:
pass
@router.post(
"/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset",
summary="Reset terminal session",
description="Reset the terminal session for a tool instance, killing the current shell and starting fresh.",
)
async def reset_terminal_session(
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Reset the terminal session for an instance.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the tool instance.
db_session: Database session.
Returns:
Dictionary with status message.
"""
# Get instance and verify it exists and is running
instance = await db_session.get(ToolInstance, instance_id)
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance not found"
)
if instance.status != "running" or not instance.container_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Instance is not running"
)
try:
# Reset the session
new_session = await terminal_manager.reset_session(
instance_id,
instance.container_id,
)
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
return {
"status": "success",
"message": "Terminal session reset successfully",
"instance_id": str(instance_id),
"session_id": new_session.session_id,
}
except Exception as exc:
logger.error("Failed to reset terminal session for instance %s: %s", instance_id, str(exc), exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset terminal session: {exc}"
)
async def _get_user_from_websocket( async def _get_user_from_websocket(
websocket: WebSocket, websocket: WebSocket,
db_session: AsyncSession, db_session: AsyncSession,
+473 -54
View File
@@ -2,6 +2,7 @@
import logging import logging
import os import os
import subprocess
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -18,11 +19,12 @@ from src.auth.dependencies import get_current_user_id
from src.auth.dependencies import get_db_session from src.auth.dependencies import get_db_session
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.config_profile import ConfigProfile
from src.models.tool_config import ToolConfig from src.models.tool_config import ToolConfig
from src.models.tool_instance import ToolInstance from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
from src.models.config_folder import ConfigFolder
from src.services.docker import ( from src.services.docker import (
check_tunnel_health, check_tunnel_health,
connect_container_to_network, connect_container_to_network,
@@ -30,18 +32,27 @@ from src.services.docker import (
execute_compose_command, execute_compose_command,
find_free_port, find_free_port,
get_container_id, get_container_id,
get_container_logs,
get_container_name, get_container_name,
get_container_status,
recreate_tunnel, recreate_tunnel,
render_compose_template, render_compose_template,
start_cloudflared_tunnel, start_cloudflared_tunnel,
stop_cloudflared_tunnel, stop_cloudflared_tunnel,
wait_for_container_running,
write_compose_file, write_compose_file,
write_config_files, write_config_files,
write_env_file, write_env_file,
write_config_folder_files,
) )
from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory
from src.services.docker_build import build_image from src.services.docker_build import build_image
from src.services.config_profile_resolver import (
ConfigProfileCycleError,
apply_resolved_profile,
resolve_profile,
)
from src.services.readiness_probe import execute_probe from src.services.readiness_probe import execute_probe
from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files
router = APIRouter(prefix="/projects", tags=["tool-instances"]) router = APIRouter(prefix="/projects", tags=["tool-instances"])
@@ -53,6 +64,81 @@ class CreateInstanceRequest(BaseModel):
tool_type_id: str = Field(description="UUID of the tool type to instantiate") tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(default=None, description="Optional display name for the instance") display_name: str | None = Field(default=None, description="Optional display name for the instance")
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
config_profile_id: str | None = Field(default=None, description="Optional config profile ID for launch")
class StartInstanceRequest(BaseModel):
"""Request body for starting a tool instance."""
model_config = {"extra": "ignore"}
config_profile_id: str | None = Field(default=None, description="Config profile ID to apply, or null for none")
async def _validate_config_profile(
session: AsyncSession,
profile_id: str | None,
user_id: uuid.UUID,
project_id: uuid.UUID,
tool_type_id: uuid.UUID,
) -> uuid.UUID | None:
"""Validate a config profile selection.
Args:
session: Database session.
profile_id: Profile ID string or None.
user_id: Authenticated user ID.
project_id: Project ID for compatibility check.
tool_type_id: Tool type ID for compatibility check.
Returns:
Validated UUID or None.
Raises:
HTTPException: If profile is not found, not owned, or incompatible.
"""
if profile_id is None:
return None
try:
profile_uuid = uuid.UUID(profile_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid config profile ID: {profile_id}",
)
profile = await session.get(ConfigProfile, profile_uuid)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Config profile not found: {profile_id}",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to use this config profile",
)
# Check compatibility: profile must be portable or match project/tool
is_compatible = (
(profile.project_id is None and profile.tool_type_id is None)
or (profile.project_id == project_id)
or (profile.tool_type_id == tool_type_id)
or (profile.project_id == project_id and profile.tool_type_id == tool_type_id)
)
if not is_compatible:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Selected config profile is not compatible with this project and tool type",
)
return profile_uuid
def _modify_compose_file( def _modify_compose_file(
@@ -189,7 +275,25 @@ async def create_instance(
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found" status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
) )
# Validate config profile if provided
selected_profile_id = await _validate_config_profile(
session, data.config_profile_id, user_id, project_id, tool_type_id
)
try: try:
# Validate clone mode requirements
if data.clone_mode == "clone":
if not repo.remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository does not have a remote URL for cloning"
)
if not repo.ssh_key_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository must have an SSH key assigned for clone mode"
)
# Generate unique name # Generate unique name
instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}" instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}"
@@ -201,6 +305,74 @@ async def create_instance(
# Find free port # Find free port
tool_port = find_free_port() tool_port = find_free_port()
# Determine repo path based on clone mode
if data.clone_mode == "clone":
# Get SSH key for cloning
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="repository SSH key not found"
)
# Prepare SSH key for clone operation
ssh_key_path = None
try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
ssh_key_path = os.path.join(ssh_dir, "id_ed25519")
# Clone repository
clone_path = clone_repository(
remote_url=repo.remote_url,
ssh_key_path=ssh_key_path,
instance_dir=instance_dir,
branch=data.branch or "main",
)
repo_path = clone_path
except Exception as exc:
logger.exception("Failed to clone repository: %s", exc)
cleanup_ssh_key_files(instance_dir)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clone repository: {exc}"
)
else:
repo_path = repo.path
# Verify cloned repo has files
if data.clone_mode == "clone" and repo_path:
try:
repo_contents = os.listdir(repo_path)
if not repo_contents or (len(repo_contents) == 1 and repo_contents[0] == ".git"):
logger.error("Cloned repository at %s appears empty", repo_path)
raise RuntimeError("Cloned repository is empty")
logger.info("Verified cloned repo at %s has %d items", repo_path, len(repo_contents))
except Exception as exc:
logger.exception("Failed to verify cloned repository: %s", exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Cloned repository verification failed: {exc}"
)
# Create new local branch if requested
if data.clone_mode == "clone" and data.new_branch:
try:
result = subprocess.run(
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr)
raise RuntimeError(f"Failed to create branch: {result.stderr}")
logger.info("Created local branch %s in cloned repository", data.new_branch)
except Exception as exc:
logger.exception("Failed to create local branch: %s", exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create local branch: {exc}"
)
# Handle based on definition type # Handle based on definition type
if tool_type.definition_type == "dockerfile": if tool_type.definition_type == "dockerfile":
# Build image from Dockerfile # Build image from Dockerfile
@@ -232,7 +404,7 @@ services:
ports: ports:
- "{tool_port}:{tool_type.default_port}" - "{tool_port}:{tool_type.default_port}"
volumes: volumes:
- {repo.path}:/workspace - {repo_path}:/workspace
restart: unless-stopped restart: unless-stopped
""" """
write_compose_file(instance_dir, compose_content) write_compose_file(instance_dir, compose_content)
@@ -240,7 +412,7 @@ services:
else: else:
# Render compose template # Render compose template
variables = { variables = {
"REPO_PATH": repo.path, "REPO_PATH": repo_path,
"INSTANCE_NAME": instance_name, "INSTANCE_NAME": instance_name,
"INSTANCE_ID": instance_name, "INSTANCE_ID": instance_name,
"TOOL_NAME": instance_name, "TOOL_NAME": instance_name,
@@ -249,6 +421,37 @@ services:
"PROJECT_ID": str(project_id), "PROJECT_ID": str(project_id),
} }
compose_content = render_compose_template(tool_type.compose_template, variables) compose_content = render_compose_template(tool_type.compose_template, variables)
# Safety check: for clone mode, ensure repo is mounted in compose file
if data.clone_mode == "clone" and repo_path:
import yaml
compose_data = yaml.safe_load(compose_content)
repo_mounted = False
if compose_data and "services" in compose_data:
for svc in compose_data["services"].values():
volumes = svc.get("volumes", [])
for vol in volumes:
vol_str = str(vol)
if repo_path in vol_str:
repo_mounted = True
break
if repo_mounted:
break
if not repo_mounted:
logger.warning(
"Compose template for tool type %s does not mount repo path; adding default mount",
tool_type.name,
)
# Add default mount to first service
if compose_data and "services" in compose_data:
for svc in compose_data["services"].values():
if "volumes" not in svc:
svc["volumes"] = []
svc["volumes"].append(f"{repo_path}:/workspace")
break
compose_content = yaml.dump(compose_data, default_flow_style=False)
write_compose_file(instance_dir, compose_content) write_compose_file(instance_dir, compose_content)
# Create database record # Create database record
@@ -262,6 +465,9 @@ services:
status="pending", status="pending",
compose_path=compose_path, compose_path=compose_path,
port=tool_port, port=tool_port,
clone_mode=data.clone_mode,
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
selected_config_profile_id=selected_profile_id,
) )
session.add(instance) session.add(instance)
await session.commit() await session.commit()
@@ -273,6 +479,9 @@ services:
"display_name": instance.display_name, "display_name": instance.display_name,
"tool_type_id": str(instance.tool_type_id), "tool_type_id": str(instance.tool_type_id),
"status": instance.status, "status": instance.status,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
"created_at": instance.created_at.isoformat(), "created_at": instance.created_at.isoformat(),
} }
except Exception as exc: except Exception as exc:
@@ -331,10 +540,12 @@ async def list_instances(
"display_name": i.display_name, "display_name": i.display_name,
"tool_type_id": str(i.tool_type_id), "tool_type_id": str(i.tool_type_id),
"tool_type_name": tool_type.name if tool_type else "unknown", "tool_type_name": tool_type.name if tool_type else "unknown",
"tool_type_interfaces": tool_type.interfaces if tool_type else [], "tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"status": i.status, "status": i.status,
"url": i.url, "url": i.url,
"port": i.port, "port": i.port,
"clone_mode": i.clone_mode,
"branch": i.branch,
"created_at": i.created_at.isoformat(), "created_at": i.created_at.isoformat(),
}) })
@@ -395,6 +606,9 @@ async def get_instance(
"compose_path": instance.compose_path, "compose_path": instance.compose_path,
"url": instance.url, "url": instance.url,
"port": instance.port, "port": instance.port,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
"last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None, "last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None,
"last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None, "last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None,
"created_at": instance.created_at.isoformat(), "created_at": instance.created_at.isoformat(),
@@ -410,6 +624,7 @@ async def start_instance(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
instance_id: uuid.UUID, instance_id: uuid.UUID,
data: StartInstanceRequest | None = None,
user_id: uuid.UUID = Depends(get_current_user_id), user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> dict: ) -> dict:
@@ -419,6 +634,7 @@ async def start_instance(
project_id: UUID of the project. project_id: UUID of the project.
repo_id: UUID of the repository. repo_id: UUID of the repository.
instance_id: UUID of the instance to start. instance_id: UUID of the instance to start.
data: Optional start configuration including config profile selection.
user_id: ID of the authenticated user. user_id: ID of the authenticated user.
session: Database session. session: Database session.
@@ -434,6 +650,14 @@ async def start_instance(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
) )
# Validate and store config profile selection
if data and data.config_profile_id is not None:
selected_profile_id = await _validate_config_profile(
session, data.config_profile_id, user_id, project_id, instance.tool_type_id
)
instance.selected_config_profile_id = selected_profile_id
await session.commit()
if not instance.compose_path or not os.path.exists(instance.compose_path): if not instance.compose_path or not os.path.exists(instance.compose_path):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found" status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
@@ -484,17 +708,45 @@ async def start_instance(
# Merge extra env vars # Merge extra env vars
env_vars.update(extra_env_vars) env_vars.update(extra_env_vars)
# Fetch active config folders for this user # Apply selected config profile if any
folder_query = select(ConfigFolder).where( instance_dir = os.path.dirname(instance.compose_path)
ConfigFolder.user_id == user_id, if instance.selected_config_profile_id is not None:
ConfigFolder.is_active == True, try:
) resolved = await resolve_profile(session, instance.selected_config_profile_id)
folder_result = await session.execute(folder_query) profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile(
config_folders = folder_result.scalars().all() instance_dir, resolved
logger.info("Found %d active config folders for instance %s", len(config_folders), instance.id) )
# Profile env vars override tool config env vars
env_vars.update(profile_env)
# Profile files are written by apply_resolved_profile
config_files.update(profile_files)
# Profile mounts are added to extra volumes
extra_volumes.extend(profile_mounts)
# Profile runtime hints override tool config values
if profile_hints.get("start_command"):
start_command = profile_hints["start_command"]
if profile_hints.get("working_directory"):
working_directory = profile_hints["working_directory"]
if profile_hints.get("port_override"):
port_override = profile_hints["port_override"]
logger.info(
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d)",
resolved.profile_name,
instance.id,
len(profile_env),
len(profile_files),
len(profile_mounts),
)
except ConfigProfileCycleError as exc:
logger.error("Cycle detected in config profile for instance %s: %s", instance.id, exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Config profile cycle detected: {exc}",
)
else:
logger.info("No config profile selected for instance %s", instance.id)
# Write env file and config files # Write env file and config files
instance_dir = os.path.dirname(instance.compose_path)
env_file_path = None env_file_path = None
if env_vars: if env_vars:
@@ -505,11 +757,22 @@ async def start_instance(
write_config_files(instance_dir, config_files) write_config_files(instance_dir, config_files)
logger.info("Wrote %d config files for instance %s", len(config_files), instance.id) logger.info("Wrote %d config files for instance %s", len(config_files), instance.id)
# Write config folder files # Mount SSH key for clone-mode instances
if config_folders: if instance.clone_mode == "clone":
folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id)) repo = await session.get(GitRepository, instance.repository_id)
extra_volumes.extend(folder_volumes) if repo and repo.ssh_key_id:
logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id) ssh_key = await session.get(SSHKey, repo.ssh_key_id)
if ssh_key:
try:
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
extra_volumes.append({
"source": ssh_dir,
"target": "/root/.ssh",
"type": "ro",
})
logger.info("Mounted SSH key for clone-mode instance %s", instance.id)
except Exception as exc:
logger.error("Failed to prepare SSH key for instance %s: %s", instance.id, exc)
# Modify compose file if needed (port override, start command, working dir, volumes) # Modify compose file if needed (port override, start command, working dir, volumes)
if port_override or start_command or working_directory or extra_volumes: if port_override or start_command or working_directory or extra_volumes:
@@ -552,20 +815,67 @@ async def start_instance(
else: else:
logger.warning("Failed to connect %s to backend network", container_name) logger.warning("Failed to connect %s to backend network", container_name)
instance.status = "starting" # Verify container reached running state
instance.last_started_at = datetime.now() if instance.container_id:
await session.commit() instance.status = "starting"
logger.info("Instance %s container is running, checking readiness", instance.id) instance.last_started_at = datetime.now()
await session.commit()
logger.info("Instance %s: verifying container startup...", instance.id)
startup_result = wait_for_container_running(instance.container_id, timeout=30, interval=2.0)
if not startup_result["success"]:
# Container failed to start
error_msg = f"Container failed to start: status={startup_result['status']}"
if startup_result["exit_code"] is not None:
error_msg += f", exit_code={startup_result['exit_code']}"
# Get logs for debugging
logs = get_container_logs(instance.container_id, tail=50)
instance.status = "error"
await session.commit()
logger.error(
"Instance %s container startup failed after %.1fs: %s\nLogs:\n%s",
instance.id,
startup_result["waited_seconds"],
error_msg,
logs,
)
return {
"status": "error",
"error": error_msg,
"logs": logs,
}
logger.info(
"Instance %s container started successfully after %.1fs",
instance.id,
startup_result["waited_seconds"],
)
# Execute readiness probe if configured # Execute readiness probe if configured
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
if tool_type and tool_type.readiness_probe: if tool_type and instance.container_id:
probe_config = tool_type.readiness_probe # Determine probe command
probe_command = probe_config.get("command", "") probe_command = None
probe_timeout = probe_config.get("timeout", 30) probe_timeout = 30
probe_interval = probe_config.get("interval", 2) probe_interval = 2
if probe_command and instance.container_id: if tool_type.readiness_probe:
probe_config = tool_type.readiness_probe
probe_command = probe_config.get("command", "")
probe_timeout = probe_config.get("timeout", 30)
probe_interval = probe_config.get("interval", 2)
elif tool_type.interface_type == "web":
# Default probe for web tools
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
probe_timeout = 30
probe_interval = 2
if probe_command:
instance.status = "probing"
await session.commit()
logger.info( logger.info(
"Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d", "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
instance.id, probe_command, probe_timeout, probe_interval instance.id, probe_command, probe_timeout, probe_interval
@@ -578,14 +888,25 @@ async def start_instance(
interval=probe_interval, interval=probe_interval,
) )
# Store probe result
instance.probe_result = {
"success": success,
"command": probe_command,
"logs": probe_logs,
"timestamp": datetime.now().isoformat(),
}
if not success: if not success:
instance.status = "failed" instance.status = "unhealthy"
instance.url = None
instance.public_url = None
await session.commit() await session.commit()
logger.error("Readiness probe failed for instance %s: %s", instance.id, "\n".join(probe_logs)) logger.error(
"Readiness probe failed for instance %s after %ds: %s",
instance.id,
probe_timeout,
"\n".join(probe_logs),
)
return { return {
"status": "failed", "status": "unhealthy",
"error": f"Readiness probe failed after {probe_timeout}s", "error": f"Readiness probe failed after {probe_timeout}s",
"probe_logs": probe_logs, "probe_logs": probe_logs,
} }
@@ -598,22 +919,21 @@ async def start_instance(
# Get tool type for default port # Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
if not tool_type or not tool_type.default_port: if not tool_type:
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.", logger.error("Tool type %s not found", instance.tool_type_id)
instance.tool_type_id)
instance.status = "error" instance.status = "error"
await session.commit() await session.commit()
return { return {
"status": "error", "status": "error",
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", "error": f"Tool type '{instance.tool_type_id}' not found",
} }
instance_port = tool_type.default_port instance_port = tool_type.default_port or 0
logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s", logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
instance.id, tool_type.name, instance_port, tool_type.interfaces) instance.id, tool_type.name, instance_port, tool_type.interface_type)
# Only create Cloudflare tunnel for web-enabled tools # Only create Cloudflare tunnel for web-enabled tools
if "web" in tool_type.interfaces: if tool_type.interface_type == "web":
# Create temporary Cloudflare tunnel for public access # Create temporary Cloudflare tunnel for public access
try: try:
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)", logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
@@ -754,7 +1074,30 @@ async def restart_instance(
except Exception as exc: except Exception as exc:
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc) logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
# Re-apply stored config profile on restart
if instance.compose_path and os.path.exists(instance.compose_path): if instance.compose_path and os.path.exists(instance.compose_path):
instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None:
try:
resolved = await resolve_profile(session, instance.selected_config_profile_id)
profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile(
instance_dir, resolved
)
# Write env file with resolved profile env vars
if profile_env:
write_env_file(instance_dir, profile_env)
logger.info(
"Re-applied config profile %s on restart for instance %s",
resolved.profile_name,
instance.id,
)
except ConfigProfileCycleError as exc:
logger.error(
"Cycle detected in stored config profile for instance %s: %s",
instance.id,
exc,
)
returncode, stdout, stderr = execute_compose_command( returncode, stdout, stderr = execute_compose_command(
instance.compose_path, "restart" instance.compose_path, "restart"
) )
@@ -778,7 +1121,7 @@ async def restart_instance(
instance_port = tool_type.default_port instance_port = tool_type.default_port
# Only create tunnel for web-enabled tools # Only create tunnel for web-enabled tools
if "web" in tool_type.interfaces: if tool_type.interface_type == "web":
# Create new temporary tunnel # Create new temporary tunnel
try: try:
tunnel_info = start_cloudflared_tunnel( tunnel_info = start_cloudflared_tunnel(
@@ -828,6 +1171,7 @@ async def delete_instance(
project_id: uuid.UUID, project_id: uuid.UUID,
repo_id: uuid.UUID, repo_id: uuid.UUID,
instance_id: uuid.UUID, instance_id: uuid.UUID,
force: bool = False,
user_id: uuid.UUID = Depends(get_current_user_id), user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> None: ) -> None:
@@ -852,6 +1196,23 @@ async def delete_instance(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
) )
# Check dirty state for clone-mode instances
if instance.clone_mode == "clone" and not force:
instance_dir = os.path.dirname(instance.compose_path) if instance.compose_path else None
if instance_dir:
clone_path = os.path.join(instance_dir, "repo-clone")
if os.path.exists(clone_path):
is_dirty, changed_files = check_dirty_state(clone_path)
if is_dirty:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Repository has uncommitted changes",
"changed_files": changed_files,
"force_required": True,
},
)
# Stop Cloudflare tunnel if exists # Stop Cloudflare tunnel if exists
if instance.tunnel_id: if instance.tunnel_id:
try: try:
@@ -864,7 +1225,7 @@ async def delete_instance(
if instance.compose_path and os.path.exists(instance.compose_path): if instance.compose_path and os.path.exists(instance.compose_path):
execute_compose_command(instance.compose_path, "down") execute_compose_command(instance.compose_path, "down")
# Remove instance directory # Remove instance directory (includes clone and SSH keys)
if instance.compose_path: if instance.compose_path:
instance_dir = os.path.dirname(instance.compose_path) instance_dir = os.path.dirname(instance.compose_path)
if os.path.exists(instance_dir): if os.path.exists(instance_dir):
@@ -956,6 +1317,17 @@ async def recreate_tunnel_endpoint(
detail="instance must be running to recreate tunnel", detail="instance must be running to recreate tunnel",
) )
# Validate tunnel is actually broken before recreating
if instance.url:
tunnel_health = check_tunnel_health(instance.url)
if tunnel_health["tunnel_status"] == "error_response":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Tunnel is working but application returned HTTP {tunnel_health.get('status_code')}. Recreating the tunnel will not fix this issue.",
)
elif tunnel_health["tunnel_status"] == "healthy":
return {"status": "healthy", "url": instance.url, "message": "Tunnel is already healthy"}
# Get tool type for default port # Get tool type for default port
tool_type = await session.get(ToolType, instance.tool_type_id) tool_type = await session.get(ToolType, instance.tool_type_id)
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080 instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
@@ -987,8 +1359,8 @@ async def recreate_tunnel_endpoint(
@router.get( @router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/health", "/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
summary="Check tunnel health", summary="Check instance health",
description="Check if the temporary Cloudflare tunnel for an instance is healthy.", description="Check container and tunnel health for an instance.",
) )
async def check_instance_tunnel_health( async def check_instance_tunnel_health(
project_id: uuid.UUID, project_id: uuid.UUID,
@@ -997,7 +1369,7 @@ async def check_instance_tunnel_health(
user_id: uuid.UUID = Depends(get_current_user_id), user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> dict: ) -> dict:
"""Check tunnel health for an instance. """Check health for an instance (container + tunnel).
Args: Args:
project_id: UUID of the project. project_id: UUID of the project.
@@ -1007,7 +1379,7 @@ async def check_instance_tunnel_health(
session: Database session. session: Database session.
Returns: Returns:
Dictionary with health status. Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag.
""" """
_user = await _get_user(session, user_id) _user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session) _project = await _get_owned_project(project_id, user_id, session)
@@ -1018,11 +1390,54 @@ async def check_instance_tunnel_health(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
) )
if not instance.url or instance.status != "running": # Check container status
return {"healthy": False, "status_code": None, "error": "instance not running"} container_info = {"status": "not_found", "exit_code": None, "health": None}
if instance.container_id:
container_info = get_container_status(instance.container_id)
health = check_tunnel_health(instance.url) # Build response
return health response = {
"healthy": False,
"container_status": container_info["status"],
"container_health": container_info["health"],
"tunnel_status": "not_applicable",
"tunnel_status_code": None,
"probe_status": "not_applicable",
"last_probe_output": None,
"error": None,
}
# Determine probe status
if instance.status == "probing":
response["probe_status"] = "pending"
elif instance.probe_result:
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))
# Check tunnel health if instance has a URL and is web-enabled
if instance.url and instance.status in ("running", "unhealthy"):
tunnel_health = check_tunnel_health(instance.url)
response["tunnel_status"] = tunnel_health["tunnel_status"]
response["tunnel_status_code"] = tunnel_health.get("status_code")
if tunnel_health.get("error"):
response["error"] = tunnel_health["error"]
# Overall healthy: web tools need running container + healthy tunnel;
# terminal tools only need running container
container_healthy = container_info["status"] == "running"
if instance.url:
tunnel_healthy = response["tunnel_status"] == "healthy"
response["healthy"] = container_healthy and tunnel_healthy
else:
response["healthy"] = container_healthy
# If container is not running, override error message
if not container_healthy:
response["error"] = f"Container is {container_info['status']}"
if container_info["exit_code"] is not None:
response["error"] += f" (exit code: {container_info['exit_code']})"
return response
@router.get( @router.get(
@@ -1198,13 +1613,17 @@ async def get_user_sessions(
"display_name": instance.display_name, "display_name": instance.display_name,
"tool_type_name": tool_type.name if tool_type else "unknown", "tool_type_name": tool_type.name if tool_type else "unknown",
"tool_icon": tool_type.name if tool_type else "code", "tool_icon": tool_type.name if tool_type else "code",
"tool_type_interfaces": tool_type.interfaces if tool_type else [], "tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
"repository_name": repo.name if repo else "unknown", "repository_name": repo.name if repo else "unknown",
"repository_id": str(instance.repository_id), "repository_id": str(instance.repository_id),
"project_name": project.name if project else "unknown", "project_name": project.name if project else "unknown",
"project_id": str(instance.project_id), "project_id": str(instance.project_id),
"status": instance.status, "status": instance.status,
"url": instance.url, "url": instance.url,
"clone_mode": instance.clone_mode,
"branch": instance.branch,
"selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None,
"created_at": instance.created_at.isoformat() if instance.created_at else None,
}) })
return {"sessions": sessions} return {"sessions": sessions}
+83 -54
View File
@@ -1,3 +1,4 @@
import re
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -7,6 +8,11 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
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.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
@@ -37,7 +43,7 @@ class ToolTypeCreate(BaseModel):
name: str name: str
display_name: str display_name: str
description: str | None = None description: str | None = None
default_port: int default_port: int = 0
definition_type: str = "compose" definition_type: str = "compose"
compose_template: str | None = None compose_template: str | None = None
dockerfile_template: str | None = None dockerfile_template: str | None = None
@@ -45,7 +51,8 @@ class ToolTypeCreate(BaseModel):
readiness_probe: dict | None = None readiness_probe: dict | None = None
required_variables: list[str] = [] required_variables: list[str] = []
category: str = "other" category: str = "other"
interfaces: list[str] = ["web"] interface_type: str = "web"
requires_port: bool = True
@field_validator("definition_type") @field_validator("definition_type")
@classmethod @classmethod
@@ -64,8 +71,12 @@ class ToolTypeCreate(BaseModel):
if v is None: if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'") 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: try:
parsed = yaml.safe_load(v) parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e: except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}") raise ValueError(f"Invalid YAML: {e}")
@@ -95,48 +106,22 @@ class ToolTypeCreate(BaseModel):
return v return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str) -> str:
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("default_port") @field_validator("default_port")
@classmethod @classmethod
def validate_default_port(cls, v: int, info) -> int: def validate_default_port(cls, v: int, info) -> int:
data = info.data
requires_port = data.get("requires_port", True)
if not requires_port:
return v
if v <= 0 or v > 65535: if v <= 0 or v > 65535:
raise ValueError("Port must be between 1 and 65535") raise ValueError("Port must be between 1 and 65535")
# Get compose_template from the model data
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
try:
parsed = yaml.safe_load(template)
except yaml.YAMLError:
return v
# Check if the port is exposed in any service
port_str = str(v)
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
for service_name, service_config in parsed["services"].items():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str):
# Format: "8443:8443" or "8443"
if port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == v:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
return v return v
@field_validator("required_variables") @field_validator("required_variables")
@@ -166,6 +151,35 @@ class ToolTypeCreate(BaseModel):
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'") raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if self.definition_type == "compose" and self.compose_template is None: if self.definition_type == "compose" and self.compose_template is None:
raise ValueError("compose_template is required when definition_type is 'compose'") raise ValueError("compose_template is required when definition_type is 'compose'")
# Validate that default_port is exposed in compose template (only if requires_port)
if self.requires_port and self.definition_type == "compose" and self.compose_template:
try:
sanitized = _sanitize_template_vars(self.compose_template)
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError:
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:
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
return self return self
@@ -180,7 +194,8 @@ class ToolTypeUpdate(BaseModel):
readiness_probe: dict | None = None readiness_probe: dict | None = None
required_variables: list[str] | None = None required_variables: list[str] | None = None
category: str | None = None category: str | None = None
interfaces: list[str] | None = None interface_type: str | None = None
requires_port: bool | None = None
@field_validator("definition_type") @field_validator("definition_type")
@classmethod @classmethod
@@ -191,6 +206,15 @@ class ToolTypeUpdate(BaseModel):
raise ValueError("definition_type must be 'compose' or 'dockerfile'") raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v return v
@field_validator("interface_type")
@classmethod
def validate_interface_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("web", "terminal"):
raise ValueError("interface_type must be 'web' or 'terminal'")
return v
@field_validator("compose_template") @field_validator("compose_template")
@classmethod @classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None: def validate_compose_template(cls, v: str | None, info) -> str | None:
@@ -202,8 +226,11 @@ class ToolTypeUpdate(BaseModel):
if definition_type and definition_type != "compose": if definition_type and definition_type != "compose":
return v return v
# Replace template variables with dummy values before YAML validation
sanitized = _sanitize_template_vars(v)
try: try:
parsed = yaml.safe_load(v) parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e: except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}") raise ValueError(f"Invalid YAML: {e}")
@@ -243,7 +270,8 @@ class ToolTypeResponse(BaseModel):
display_name: str display_name: str
description: str | None description: str | None
category: str category: str
interfaces: list[str] interface_type: str
requires_port: bool
default_port: int default_port: int
definition_type: str definition_type: str
compose_template: str | None compose_template: str | None
@@ -251,7 +279,6 @@ class ToolTypeResponse(BaseModel):
build_context: dict | None build_context: dict | None
readiness_probe: dict | None readiness_probe: dict | None
required_variables: list[str] required_variables: list[str]
is_builtin: bool
created_by_id: uuid.UUID | None created_by_id: uuid.UUID | None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -299,8 +326,8 @@ async def create_tool_type(
readiness_probe=data.readiness_probe, readiness_probe=data.readiness_probe,
required_variables=data.required_variables, required_variables=data.required_variables,
category=data.category, category=data.category,
interfaces=data.interfaces, interface_type=data.interface_type,
is_builtin=False, requires_port=data.requires_port,
created_by_id=user.id, created_by_id=user.id,
) )
session.add(tool_type) session.add(tool_type)
@@ -391,13 +418,13 @@ async def update_tool_type(
if tool_type is None: if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
if tool_type.is_builtin: # Built-in tool types can now be modified
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
# Validate port if being updated # Validate port if being updated
if "default_port" in update_data: requires_port = update_data.get("requires_port", tool_type.requires_port)
if "default_port" in update_data and requires_port:
new_port = update_data["default_port"] new_port = update_data["default_port"]
if new_port <= 0 or new_port > 65535: if new_port <= 0 or new_port > 65535:
raise HTTPException( raise HTTPException(
@@ -411,7 +438,8 @@ async def update_tool_type(
template = update_data.get("compose_template", tool_type.compose_template) template = update_data.get("compose_template", tool_type.compose_template)
if template: if template:
try: try:
parsed = yaml.safe_load(template) sanitized = _sanitize_template_vars(template)
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError: except yaml.YAMLError:
parsed = None parsed = None
@@ -502,7 +530,8 @@ async def validate_tool_type_template(
errors.append("Compose template is required") errors.append("Compose template is required")
else: else:
try: try:
parsed = yaml.safe_load(data.compose_template) sanitized = _sanitize_template_vars(data.compose_template)
parsed = yaml.safe_load(sanitized)
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping") errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed: elif "services" not in parsed:
@@ -559,7 +588,8 @@ async def validate_tool_type(
errors.append("Compose template is empty") errors.append("Compose template is empty")
else: else:
try: try:
parsed = yaml.safe_load(tool_type.compose_template) sanitized = _sanitize_template_vars(tool_type.compose_template)
parsed = yaml.safe_load(sanitized)
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping") errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed: elif "services" not in parsed:
@@ -609,8 +639,7 @@ async def delete_tool_type(
if tool_type is None: if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
if tool_type.is_builtin: # Built-in tool types can now be deleted
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
await session.delete(tool_type) await session.delete(tool_type)
await session.commit() await session.commit()
+3 -3
View File
@@ -2,7 +2,7 @@ import hmac
import hashlib import hashlib
import json import json
import base64 import base64
from datetime import UTC, datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from src.config import Settings from src.config import Settings
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
"""Create a signed session cookie value.""" """Create a signed session cookie value."""
payload = { payload = {
"user_id": user_id, "user_id": user_id,
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()), "exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
} }
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode()) header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
payload = json.loads(payload_bytes) payload = json.loads(payload_bytes)
# Check expiry # Check expiry
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()): if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
raise ValueError("session expired") raise ValueError("session expired")
return payload return payload
+4 -154
View File
@@ -7,7 +7,7 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import select, text from sqlalchemy import text
from src.api.auth import router as auth_router from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router from src.api.dashboard import router as dashboard_router
@@ -18,6 +18,7 @@ from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router from src.api.terminal import router as terminal_router
from src.api.instance_proxy import router as instance_proxy_router from src.api.instance_proxy import router as instance_proxy_router
from src.api.config_folders import router as config_folders_router from src.api.config_folders import router as config_folders_router
from src.api.config_profiles import router as config_profiles_router
from src.api.tool_configs import router as tool_configs_router from src.api.tool_configs import router as tool_configs_router
from src.api.tool_instances import router as tool_instances_router from src.api.tool_instances import router as tool_instances_router
from src.api.tool_instances import sessions_router from src.api.tool_instances import sessions_router
@@ -25,13 +26,12 @@ from src.api.tool_types import router as tool_types_router
from src.api.user_config import router as user_config_router from src.api.user_config import router as user_config_router
from src.api.users import router as users_router from src.api.users import router as users_router
from src.config import Settings from src.config import Settings
from src.database import SessionLocal, init_database from src.database import init_database
from src.logging_config import ( from src.logging_config import (
ExceptionLoggingMiddleware, ExceptionLoggingMiddleware,
RequestLoggingMiddleware, RequestLoggingMiddleware,
configure_logging, configure_logging,
) )
from src.models.tool_type import ToolType
# Configure logging early # Configure logging early
log_level = os.getenv("LOG_LEVEL", "INFO").upper() log_level = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -103,155 +103,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
) )
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
try:
result = await session.execute(
text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
async def seed_builtin_tool_types():
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
builtin_types = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interfaces": ["web"],
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: unless-stopped""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interfaces": ["web"],
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: unless-stopped""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interfaces": ["terminal"],
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /workspace
environment:
- HOME=/tmp
volumes:
- {{REPO_PATH}}:/workspace
- opencode_home:/tmp
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
echo 'cd /workspace' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
cd /workspace &&
exec tail -f /dev/null"
stdin_open: true
tty: true
restart: unless-stopped
volumes:
opencode_home:""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
for tool_data in builtin_types:
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
if not existing:
tool_type = ToolType(
name=tool_data["name"],
display_name=tool_data["display_name"],
description=tool_data["description"],
category=tool_data["category"],
interfaces=tool_data["interfaces"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
default_port=tool_data.get("default_port"),
is_builtin=True,
)
session.add(tool_type)
logger.info("Created built-in tool type: %s", tool_data["name"])
else:
# Update existing built-in tool types to reflect code changes
existing.display_name = tool_data["display_name"]
existing.description = tool_data["description"]
existing.category = tool_data["category"]
existing.interfaces = tool_data["interfaces"]
existing.definition_type = "compose"
existing.compose_template = tool_data["compose_template"]
existing.required_variables = tool_data["required_variables"]
existing.default_port = tool_data.get("default_port")
logger.info("Updated built-in tool type: %s", tool_data["name"])
await session.commit()
logger.info("Built-in tool types seeded successfully.")
@app.on_event("startup") @app.on_event("startup")
async def on_startup(): async def on_startup():
logger.info("Starting up Headquarter API...") logger.info("Starting up Headquarter API...")
@@ -263,8 +114,6 @@ async def on_startup():
import sys import sys
sys.exit(1) sys.exit(1)
# Seed built-in data
await seed_builtin_tool_types()
logger.info("Startup complete.") logger.info("Startup complete.")
app.include_router(health_router) app.include_router(health_router)
@@ -277,6 +126,7 @@ app.include_router(git_repositories_router)
app.include_router(user_config_router) app.include_router(user_config_router)
app.include_router(tool_types_router) app.include_router(tool_types_router)
app.include_router(config_folders_router) app.include_router(config_folders_router)
app.include_router(config_profiles_router)
app.include_router(tool_instances_router) app.include_router(tool_instances_router)
app.include_router(tool_configs_router) app.include_router(tool_configs_router)
app.include_router(sessions_router) app.include_router(sessions_router)
+2 -1
View File
@@ -1,5 +1,6 @@
from src.models.base import Base from src.models.base import Base
from src.models.config_folder import ConfigFolder from src.models.config_folder import ConfigFolder
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
@@ -8,4 +9,4 @@ from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
from src.models.user_config import UserConfig from src.models.user_config import UserConfig
__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"] __all__ = ["Base", "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
+74
View File
@@ -0,0 +1,74 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models.user import User
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profiles"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
project_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
)
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
)
env_vars: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"VAR_NAME": "value", ...}
runtime_hints: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"start_command": "...", "working_dir": "...", ...}
mounts: Mapped[list] = mapped_column(
JSON, default=list, nullable=False
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
files: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"rel/path": "content", ...}
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship()
project: Mapped["Project | None"] = relationship()
tool_type: Mapped["ToolType | None"] = relationship()
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
"ConfigProfileInclude",
foreign_keys="ConfigProfileInclude.profile_id",
order_by="ConfigProfileInclude.order_index",
cascade="all, delete-orphan",
)
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profile_includes"
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
included_profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="includes",
)
included_profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[included_profile_id],
)
+5
View File
@@ -10,6 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User from src.models.user import User
@@ -23,6 +24,10 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("ssh_keys.id"), nullable=True
)
project: Mapped["Project"] = relationship(back_populates="repositories") project: Mapped["Project"] = relationship(back_populates="repositories")
owner: Mapped["User"] = relationship() owner: Mapped["User"] = relationship()
ssh_key: Mapped["SSHKey | None"] = relationship()
+15 -1
View File
@@ -2,13 +2,14 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Integer, String from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
from sqlalchemy import Uuid as UUID from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
@@ -62,8 +63,21 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
last_stopped_at: Mapped[datetime | None] = mapped_column( last_stopped_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
probe_result: Mapped[dict | None] = mapped_column(
JSON, nullable=True
)
clone_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="mount"
)
branch: Mapped[str | None] = mapped_column(
String(255), nullable=True, default="main"
)
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
)
tool_type: Mapped["ToolType"] = relationship() tool_type: Mapped["ToolType"] = relationship()
repository: Mapped["GitRepository"] = relationship() repository: Mapped["GitRepository"] = relationship()
project: Mapped["Project"] = relationship() project: Mapped["Project"] = relationship()
owner: Mapped["User"] = relationship() owner: Mapped["User"] = relationship()
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
+2 -2
View File
@@ -18,7 +18,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
display_name: Mapped[str] = mapped_column(String(255), nullable=False) display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other") category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
default_port: Mapped[int] = mapped_column(nullable=False) default_port: Mapped[int] = mapped_column(nullable=False)
definition_type: Mapped[str] = mapped_column( definition_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="compose" String(20), nullable=False, default="compose"
@@ -30,7 +31,6 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
) )
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True) readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_by_id: Mapped[uuid.UUID | None] = mapped_column( created_by_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), UUID(),
ForeignKey("users.id"), ForeignKey("users.id"),
+97
View File
@@ -0,0 +1,97 @@
"""Clone service for repository cloning and dirty state checking."""
import logging
import os
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
def clone_repository(
remote_url: str,
ssh_key_path: str | None,
instance_dir: str,
branch: str = "main",
) -> str:
"""Clone a git repository into the instance directory.
Args:
remote_url: Git remote URL (SSH or HTTPS)
ssh_key_path: Path to SSH private key for authentication (optional)
instance_dir: Path to instance directory
branch: Branch to clone (default: main)
Returns:
Path to the cloned repository
"""
clone_path = Path(instance_dir) / "repo-clone"
clone_path.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
if ssh_key_path:
# Use SSH key for cloning
env["GIT_SSH_COMMAND"] = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
cmd = [
"git",
"clone",
"--branch", branch,
"--single-branch",
remote_url,
str(clone_path),
]
logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=300,
)
if result.returncode != 0:
logger.error("Git clone failed: %s", result.stderr)
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
logger.info("Successfully cloned repository into %s", clone_path)
return str(clone_path)
def check_dirty_state(clone_path: str) -> tuple[bool, list[str]]:
"""Check for uncommitted changes in a cloned repository.
Args:
clone_path: Path to the cloned repository
Returns:
Tuple of (is_dirty, list_of_changed_files)
"""
result = subprocess.run(
["git", "-C", clone_path, "status", "--short"],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.warning("Failed to check git status: %s", result.stderr)
return False, []
changed_files = [line.strip() for line in result.stdout.split("\n") if line.strip()]
is_dirty = len(changed_files) > 0
return is_dirty, changed_files
def remove_clone_directory(instance_dir: str) -> None:
"""Remove the cloned repository from the instance directory.
Args:
instance_dir: Path to instance directory
"""
clone_path = Path(instance_dir) / "repo-clone"
if clone_path.exists():
import shutil
shutil.rmtree(clone_path)
logger.info("Removed clone directory: %s", clone_path)
@@ -0,0 +1,453 @@
"""Config profile resolver service.
Provides recursive ordered include resolution with deterministic merge rules
and cycle protection.
"""
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
logger = logging.getLogger(__name__)
class ConfigProfileCycleError(Exception):
"""Raised when a cycle is detected in profile includes."""
pass
class ConfigProfileNotFoundError(Exception):
"""Raised when a referenced profile is not found."""
pass
@dataclass
class ResolvedMount:
"""A resolved mount with merged files and final mode."""
target: str
mode: str
files: dict[str, str] = field(default_factory=dict)
overridden_files: dict[str, str] = field(default_factory=dict)
@dataclass
class ResolvedProfile:
"""The fully resolved output of a config profile."""
profile_id: uuid.UUID
profile_name: str
env_vars: dict[str, str] = field(default_factory=dict)
runtime_hints: dict[str, Any] = field(default_factory=dict)
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
files: dict[str, str] = field(default_factory=dict)
env_overrides: dict[str, str] = field(default_factory=dict)
hint_overrides: dict[str, str] = field(default_factory=dict)
file_overrides: dict[str, str] = field(default_factory=dict)
mount_overrides: dict[str, str] = field(default_factory=dict)
included_profiles: list[dict[str, Any]] = field(default_factory=list)
def _detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) -> bool:
"""Detect if adding profile_id to path would create a cycle.
Args:
profile_id: The profile ID to check.
visited: Set of already-visited profile IDs in current resolution.
path: Current resolution path for error reporting.
Returns:
True if a cycle would be created.
"""
if profile_id in visited:
return True
return False
def _merge_env_vars(
base: dict[str, str],
overlay: dict[str, str],
overrides: dict[str, str],
source_name: str,
) -> dict[str, str]:
"""Merge env vars, tracking overrides.
Later values replace earlier values.
"""
result = dict(base)
for key, value in overlay.items():
if key in result and result[key] != value:
overrides[key] = source_name
result[key] = value
return result
def _merge_runtime_hints(
base: dict[str, Any],
overlay: dict[str, Any],
overrides: dict[str, str],
source_name: str,
) -> dict[str, Any]:
"""Merge runtime hints, tracking overrides.
Later values replace earlier values.
"""
result = dict(base)
for key, value in overlay.items():
if key in result and result[key] != value:
overrides[key] = source_name
result[key] = value
return result
def _merge_files(
base: dict[str, str],
overlay: dict[str, str],
overrides: dict[str, str],
source_name: str,
) -> dict[str, str]:
"""Merge file maps, tracking overrides.
Later relative file paths win.
"""
result = dict(base)
for path, content in overlay.items():
if path in result and result[path] != content:
overrides[path] = source_name
result[path] = content
return result
def _merge_mounts(
base: dict[str, ResolvedMount],
overlay: list[dict[str, Any]],
overrides: dict[str, str],
source_name: str,
) -> dict[str, ResolvedMount]:
"""Merge mounts, tracking overrides.
Mounts with the same target path have their file maps merged and later
relative file paths win. Mode conflicts: later layer wins.
"""
result = dict(base)
for mount_data in overlay:
target = mount_data["target"]
mode = mount_data.get("mode", "rw")
files = mount_data.get("files", {})
if target in result:
existing = result[target]
merged_files = dict(existing.files)
file_overrides = dict(existing.overridden_files)
for rel_path, content in files.items():
if rel_path in merged_files and merged_files[rel_path] != content:
file_overrides[rel_path] = source_name
merged_files[rel_path] = content
if existing.mode != mode:
overrides[target] = source_name
result[target] = ResolvedMount(
target=target,
mode=mode,
files=merged_files,
overridden_files=file_overrides,
)
else:
result[target] = ResolvedMount(
target=target,
mode=mode,
files=dict(files),
)
return result
async def _resolve_profile_recursive(
session: AsyncSession,
profile_id: uuid.UUID,
visited: set[uuid.UUID],
path: list[uuid.UUID],
) -> ResolvedProfile:
"""Recursively resolve a profile and its includes.
Args:
session: Database session.
profile_id: Profile ID to resolve.
visited: Set of already-visited profile IDs in current resolution chain.
path: Current resolution path for error reporting.
Returns:
ResolvedProfile with all includes merged.
Raises:
ConfigProfileCycleError: If a cycle is detected.
ConfigProfileNotFoundError: If the profile is not found.
"""
if _detect_cycle(profile_id, visited, path):
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
raise ConfigProfileCycleError(f"Cycle detected in profile includes: {cycle_path}")
profile = await session.get(ConfigProfile, profile_id)
if profile is None:
raise ConfigProfileNotFoundError(f"Config profile not found: {profile_id}")
new_visited = visited | {profile_id}
new_path = path + [profile_id]
result = ResolvedProfile(
profile_id=profile.id,
profile_name=profile.name,
)
# Resolve includes in order
include_query = (
select(ConfigProfileInclude)
.where(ConfigProfileInclude.profile_id == profile_id)
.order_by(ConfigProfileInclude.order_index)
)
include_result = await session.execute(include_query)
includes = include_result.scalars().all()
for include in includes:
included = await _resolve_profile_recursive(
session, include.included_profile_id, new_visited, new_path
)
result.included_profiles.append({
"id": str(included.profile_id),
"name": included.profile_name,
})
result.env_vars = _merge_env_vars(
result.env_vars, included.env_vars, result.env_overrides, included.profile_name
)
result.runtime_hints = _merge_runtime_hints(
result.runtime_hints,
included.runtime_hints,
result.hint_overrides,
included.profile_name,
)
result.files = _merge_files(
result.files, included.files, result.file_overrides, included.profile_name
)
result.mounts = _merge_mounts(
result.mounts,
[
{"target": m.target, "mode": m.mode, "files": m.files}
for m in included.mounts.values()
],
result.mount_overrides,
included.profile_name,
)
# Apply the profile's own settings (selected profile overrides includes)
result.env_vars = _merge_env_vars(
result.env_vars,
profile.env_vars or {},
result.env_overrides,
profile.name,
)
result.runtime_hints = _merge_runtime_hints(
result.runtime_hints,
profile.runtime_hints or {},
result.hint_overrides,
profile.name,
)
result.files = _merge_files(
result.files,
profile.files or {},
result.file_overrides,
profile.name,
)
result.mounts = _merge_mounts(
result.mounts,
profile.mounts or [],
result.mount_overrides,
profile.name,
)
return result
async def resolve_profile(
session: AsyncSession,
profile_id: uuid.UUID,
) -> ResolvedProfile:
"""Resolve a config profile with all includes.
Args:
session: Database session.
profile_id: Profile ID to resolve.
Returns:
ResolvedProfile with merged env vars, runtime hints, mounts, and files.
Raises:
ConfigProfileCycleError: If a cycle is detected in includes.
ConfigProfileNotFoundError: If the profile is not found.
"""
return await _resolve_profile_recursive(session, profile_id, set(), [])
async def check_include_cycle(
session: AsyncSession,
profile_id: uuid.UUID,
new_include_id: uuid.UUID | None = None,
) -> list[uuid.UUID] | None:
"""Check if adding an include would create a cycle.
Used at save time to validate include relationships before persisting.
Args:
session: Database session.
profile_id: The profile that would receive the new include.
new_include_id: Optional new profile to include. If None, checks existing includes.
Returns:
The cycle path as a list of UUIDs if a cycle exists, otherwise None.
"""
async def _check_from(
current_id: uuid.UUID,
target_id: uuid.UUID,
visited: set[uuid.UUID],
path: list[uuid.UUID],
) -> list[uuid.UUID] | None:
if current_id in visited:
if current_id == target_id:
return path + [current_id]
return None
if current_id == target_id and path:
return path + [current_id]
new_visited = visited | {current_id}
new_path = path + [current_id]
include_query = (
select(ConfigProfileInclude)
.where(ConfigProfileInclude.profile_id == current_id)
.order_by(ConfigProfileInclude.order_index)
)
include_result = await session.execute(include_query)
includes = include_result.scalars().all()
for include in includes:
cycle = await _check_from(
include.included_profile_id, target_id, new_visited, new_path
)
if cycle is not None:
return cycle
return None
# Check if new_include_id can reach profile_id (would create cycle)
if new_include_id is not None:
cycle = await _check_from(new_include_id, profile_id, set(), [])
if cycle is not None:
return cycle
# Also check existing includes for cycles
cycle = await _check_from(profile_id, profile_id, set(), [])
if cycle is not None and len(cycle) > 1:
return cycle
return None
def apply_resolved_profile(
instance_dir: str,
resolved: ResolvedProfile,
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
"""Apply a resolved profile to an instance directory.
Stages files, writes env vars, and prepares mount volumes.
Args:
instance_dir: Path to the instance directory.
resolved: The resolved profile.
Returns:
Tuple of (env_vars, files, volume_mounts, runtime_hints).
env_vars: Merged environment variables.
files: Relative file paths to content for the instance.
volume_mounts: List of Docker volume mount dicts.
runtime_hints: Extracted runtime hints.
"""
from pathlib import Path
instance_path = Path(instance_dir)
env_vars = dict(resolved.env_vars)
files = dict(resolved.files)
volume_mounts = []
# Write profile files to instance directory
for file_path, content in files.items():
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
logger.warning("Profile file path escapes instance directory: %s", file_path)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
# Stage mount files and prepare volume mounts
for mount in resolved.mounts.values():
mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_")
mount_dir.mkdir(parents=True, exist_ok=True)
for file_path, content in mount.files.items():
full_path = mount_dir / file_path
try:
full_path.resolve().relative_to(mount_dir.resolve())
except ValueError:
logger.warning("Mount file path escapes mount directory: %s", file_path)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
volume_mounts.append({
"source": str(mount_dir),
"target": mount.target,
"type": "bind",
})
return env_vars, files, volume_mounts, resolved.runtime_hints
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
"""Convert a ResolvedProfile to a plain dict for serialization.
Args:
resolved: The resolved profile.
Returns:
Dict with env_vars, runtime_hints, mounts, files, and metadata.
"""
return {
"profile_id": str(resolved.profile_id),
"profile_name": resolved.profile_name,
"env_vars": resolved.env_vars,
"runtime_hints": resolved.runtime_hints,
"mounts": [
{
"target": m.target,
"mode": m.mode,
"files": m.files,
"overridden_files": m.overridden_files,
}
for m in resolved.mounts.values()
],
"files": resolved.files,
"overrides": {
"env_vars": resolved.env_overrides,
"runtime_hints": resolved.hint_overrides,
"files": resolved.file_overrides,
"mounts": resolved.mount_overrides,
},
"included_profiles": resolved.included_profiles,
}
+121 -67
View File
@@ -92,59 +92,6 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
full_path.write_text(content) full_path.write_text(content)
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
"""Write config folder files to the instance directory and return volume mounts.
Args:
instance_dir: Path to instance directory
folders: List of ConfigFolder objects
project_id: Optional project ID for applying overrides
Returns:
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
"""
instance_path = Path(instance_dir)
volume_mounts = []
for folder in folders:
# Determine mount path (with project override if applicable)
mount_path = folder.mount_path
files = folder.files.copy()
if project_id and folder.project_overrides:
override = folder.project_overrides.get(str(project_id))
if override:
if override.get("mount_path"):
mount_path = override["mount_path"]
if override.get("files"):
files.update(override["files"])
# Write files to instance directory
folder_dir = instance_path / "volumes" / folder.name
folder_dir.mkdir(parents=True, exist_ok=True)
for file_path, content in files.items():
# Security: ensure path doesn't escape folder_dir
full_path = folder_dir / file_path
try:
full_path.resolve().relative_to(folder_dir.resolve())
except ValueError:
logger.warning("Config folder file path escapes directory: %s", file_path)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
# Add volume mount
volume_mounts.append({
"source": str(folder_dir),
"target": mount_path,
"type": "bind",
})
return volume_mounts
def execute_compose_command( def execute_compose_command(
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]: ) -> tuple[int, str, str]:
@@ -244,24 +191,94 @@ def connect_container_to_network(container_name: str, network_name: str = "backe
return result.returncode == 0 return result.returncode == 0
def get_container_status(container_id: str) -> str: def get_container_status(container_id: str) -> dict[str, Any]:
"""Get the status of a Docker container. """Get the status of a Docker container.
Args: Args:
container_id: Docker container ID container_id: Docker container ID
Returns: Returns:
Container status string (running, exited, etc.) Dict with 'status' (running, exited, restarting, not_found),
'exit_code' (int or None), and 'health' (health status or None)
""" """
result = subprocess.run( result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Status}}", container_id], [
"docker", "inspect", "-f",
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
container_id,
],
capture_output=True, capture_output=True,
text=True, text=True,
) )
if result.returncode == 0: if result.returncode != 0:
return result.stdout.strip() return {"status": "not_found", "exit_code": None, "health": None}
return "unknown"
parts = result.stdout.strip().split("|")
status = parts[0] if parts else "unknown"
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
return {"status": status, "exit_code": exit_code, "health": health}
def wait_for_container_running(
container_id: str, timeout: int = 30, interval: float = 2.0
) -> dict[str, Any]:
"""Wait for a container to reach the running state.
Polls docker inspect until the container status is "running" or timeout.
Args:
container_id: Docker container ID
timeout: Maximum seconds to wait
interval: Seconds between polls
Returns:
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
and 'waited_seconds' (float)
"""
import time
start_time = time.time()
while time.time() - start_time < timeout:
info = get_container_status(container_id)
if info["status"] == "running":
return {
"success": True,
"status": "running",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
if info["status"] == "exited":
return {
"success": False,
"status": "exited",
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
if info["status"] == "not_found":
return {
"success": False,
"status": "not_found",
"exit_code": None,
"waited_seconds": time.time() - start_time,
}
time.sleep(interval)
# Timeout reached
info = get_container_status(container_id)
return {
"success": False,
"status": info["status"],
"exit_code": info["exit_code"],
"waited_seconds": time.time() - start_time,
}
def get_container_logs(container_id: str, tail: int = 100) -> str: def get_container_logs(container_id: str, tail: int = 100) -> str:
@@ -424,14 +441,15 @@ def recreate_tunnel(
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy. """Check if a tunnel URL is healthy with smart error classification.
Args: Args:
url: The tunnel URL to check url: The tunnel URL to check
timeout: Request timeout in seconds timeout: Request timeout in seconds
Returns: Returns:
Dict with 'healthy' (bool) and 'status_code' (int or None) Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
""" """
import subprocess import subprocess
@@ -444,13 +462,49 @@ def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
timeout=timeout + 5, timeout=timeout + 5,
) )
status_code = int(result.stdout.strip()) status_code = int(result.stdout.strip())
if 200 <= status_code < 400:
return {
"tunnel_status": "healthy",
"status_code": status_code,
"healthy": True,
"error": None,
}
elif status_code in (502, 503, 504):
# Application error, not tunnel error
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"Application returned HTTP {status_code}",
}
else:
return {
"tunnel_status": "error_response",
"status_code": status_code,
"healthy": False,
"error": f"HTTP {status_code}",
}
except subprocess.TimeoutExpired:
return { return {
"healthy": 200 <= status_code < 400, "tunnel_status": "unreachable",
"status_code": status_code,
}
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
return {
"healthy": False,
"status_code": None, "status_code": None,
"healthy": False,
"error": "Tunnel request timed out",
}
except (ValueError, Exception) as e:
error_str = str(e).lower()
# Classify connection errors
if any(err in error_str for err in ["connection refused", "econnrefused", "could not resolve", "nodename"]):
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": f"Tunnel unreachable: {e}",
}
return {
"tunnel_status": "unreachable",
"status_code": None,
"healthy": False,
"error": str(e), "error": str(e),
} }
+73
View File
@@ -0,0 +1,73 @@
"""SSH key service utilities for preparing keys for container use."""
import os
from pathlib import Path
from cryptography.fernet import Fernet
from src.config import Settings
def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret."""
import base64
import hashlib
settings = Settings()
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
key = base64.urlsafe_b64encode(key_bytes)
return Fernet(key)
def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str:
"""Decrypt and write SSH key files to instance directory for container mounting.
Args:
instance_dir: Path to instance directory
ssh_key: SSHKey model instance with encrypted private key
Returns:
Path to the .ssh directory
"""
ssh_dir = Path(instance_dir) / ".ssh"
ssh_dir.mkdir(parents=True, exist_ok=True)
# Decrypt private key
fernet = _get_fernet()
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
# Write private key with restricted permissions
private_key_path = ssh_dir / "id_ed25519"
private_key_path.write_text(private_key)
os.chmod(private_key_path, 0o600)
# Write public key
public_key_path = ssh_dir / "id_ed25519.pub"
public_key_path.write_text(ssh_key.public_key)
os.chmod(public_key_path, 0o644)
# Write SSH config
config_path = ssh_dir / "config"
config_content = """Host *
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
"""
config_path.write_text(config_content)
os.chmod(config_path, 0o644)
return str(ssh_dir)
def cleanup_ssh_key_files(instance_dir: str) -> None:
"""Remove temporary SSH key files from instance directory.
Args:
instance_dir: Path to instance directory
"""
ssh_dir = Path(instance_dir) / ".ssh"
if ssh_dir.exists():
for file_path in ssh_dir.iterdir():
file_path.unlink()
ssh_dir.rmdir()
+122 -58
View File
@@ -1,6 +1,7 @@
"""Terminal session manager for WebSocket connections.""" """Terminal session manager for WebSocket connections."""
import asyncio import asyncio
import logging
import uuid import uuid
from typing import Any from typing import Any
@@ -8,81 +9,141 @@ from fastapi import WebSocket
from src.services.terminal_session import TerminalSession from src.services.terminal_session import TerminalSession
logger = logging.getLogger(__name__)
class TerminalManager: class TerminalManager:
"""Manages active terminal sessions.""" """Manages active terminal sessions with persistence support."""
def __init__(self) -> None: def __init__(self) -> None:
# Track sessions by instance_id for persistence
self._sessions: dict[str, TerminalSession] = {} self._sessions: dict[str, TerminalSession] = {}
self._idle_check_task: asyncio.Task | None = None
self._start_idle_check()
async def create_session( def _start_idle_check(self) -> None:
"""Start the idle timeout background task."""
if self._idle_check_task is not None and not self._idle_check_task.done():
return
try:
loop = asyncio.get_running_loop()
self._idle_check_task = loop.create_task(self._idle_check_loop())
except RuntimeError:
# No event loop running yet, will be started lazily
pass
async def _idle_check_loop(self) -> None:
"""Periodically check for idle sessions and clean them up."""
while True:
try:
await asyncio.sleep(60) # Check every minute
await self._cleanup_idle_sessions()
except Exception as exc:
logger.error("Error in idle check loop: %s", exc)
async def _cleanup_idle_sessions(self) -> None:
"""Clean up sessions that have been idle for too long."""
idle_sessions = []
for instance_id, session in list(self._sessions.items()):
if session.is_idle():
idle_sessions.append(instance_id)
for instance_id in idle_sessions:
logger.info("Cleaning up idle terminal session for instance %s", instance_id)
session = self._sessions.pop(instance_id, None)
if session:
await session.close()
async def get_or_create_session(
self, self,
instance_id: uuid.UUID, instance_id: uuid.UUID,
container_id: str, container_id: str,
websocket: WebSocket,
) -> TerminalSession: ) -> TerminalSession:
"""Create a new terminal session.""" """Get existing session or create a new one."""
# Ensure idle check is running (lazy start)
self._start_idle_check()
instance_id_str = str(instance_id)
# Check for existing session
if instance_id_str in self._sessions:
session = self._sessions[instance_id_str]
# Check if session is still alive
if session.is_alive():
logger.info("Reattaching to existing terminal session for instance %s", instance_id)
return session
else:
# Session died, clean it up
logger.info("Existing session for instance %s is dead, cleaning up", instance_id)
await session.close()
del self._sessions[instance_id_str]
# Create new session
logger.info("Creating new terminal session for instance %s", instance_id)
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id) session = TerminalSession(session_id, instance_id, container_id)
await session.start() await session.start()
self._sessions[session_id] = session self._sessions[instance_id_str] = session
# Start background tasks for I/O streaming
asyncio.create_task(self._read_loop(session, websocket))
asyncio.create_task(self._write_loop(session, websocket))
return session return session
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None: async def attach_websocket(
"""Read output from the container and send to WebSocket.""" self,
try: session: TerminalSession,
while session.is_alive() and not session._closed: websocket: WebSocket,
data = await session.read_output() ) -> None:
if data: """Attach a WebSocket to an existing session."""
await websocket.send_bytes(data) # Handle concurrent connections - close existing ones
else: if session.has_websockets():
await asyncio.sleep(0.01) logger.info("Closing existing WebSocket connections for instance %s", session.instance_id)
except Exception: for ws in list(session._websockets):
pass try:
finally: await ws.close(code=4000, reason="New connection established")
await self._cleanup_session(session) except Exception:
pass
session._websockets.clear()
# Attach new WebSocket
session.attach_websocket(websocket)
# Replay buffer
buffer = session.get_buffer()
if buffer:
try:
await websocket.send_bytes(buffer)
except Exception:
pass
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None: async def detach_websocket(
"""Read input from WebSocket and send to container.""" self,
try: session: TerminalSession,
while session.is_alive() and not session._closed: websocket: WebSocket,
message = await websocket.receive() ) -> None:
if message["type"] == "websocket.receive": """Detach a WebSocket from a session."""
if "bytes" in message: session.detach_websocket(websocket)
await session.write_input(message["bytes"])
elif "text" in message:
text = message["text"]
if text.startswith("{"):
# Control message (JSON)
import json
try:
ctrl = json.loads(text)
if ctrl.get("type") == "resize":
await session.resize(
ctrl.get("cols", 80),
ctrl.get("rows", 24),
)
except json.JSONDecodeError:
pass
else:
await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect":
break
except Exception:
pass
finally:
await self._cleanup_session(session)
async def _cleanup_session(self, session: TerminalSession) -> None: async def reset_session(
"""Clean up a session.""" self,
if session.session_id in self._sessions: instance_id: uuid.UUID,
del self._sessions[session.session_id] container_id: str,
await session.close() ) -> TerminalSession:
"""Reset a session by killing it and creating a new one."""
instance_id_str = str(instance_id)
# Close existing session if any
if instance_id_str in self._sessions:
logger.info("Resetting terminal session for instance %s", instance_id)
old_session = self._sessions.pop(instance_id_str)
await old_session.close()
# Create new session
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id)
await session.start()
self._sessions[instance_id_str] = session
return session
async def close_all(self) -> None: async def close_all(self) -> None:
"""Close all active sessions.""" """Close all active sessions."""
@@ -90,6 +151,9 @@ class TerminalManager:
self._sessions.clear() self._sessions.clear()
for session in sessions: for session in sessions:
await session.close() await session.close()
if self._idle_check_task and not self._idle_check_task.done():
self._idle_check_task.cancel()
# Global terminal manager instance # Global terminal manager instance
+117 -8
View File
@@ -1,17 +1,32 @@
"""Terminal session management for tool instances.""" """Terminal session management for tool instances."""
import asyncio import asyncio
import logging
import os import os
import pty import pty
import select import select
import struct import struct
import fcntl import fcntl
import time
import uuid import uuid
from collections import deque
from typing import Any from typing import Any
logger = logging.getLogger(__name__)
class TerminalSession: class TerminalSession:
"""Manages a single terminal session connected to a docker container.""" """Manages a single terminal session connected to a docker container.
Supports persistent sessions that survive WebSocket disconnections.
Multiple WebSocket connections can attach/detach from the same session.
"""
# Circular buffer size (10KB)
BUFFER_SIZE = 10 * 1024
# Idle timeout in seconds (30 minutes)
IDLE_TIMEOUT = 30 * 60
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None: def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
self.session_id = session_id self.session_id = session_id
@@ -21,6 +36,20 @@ class TerminalSession:
self._closed = False self._closed = False
self._master_fd: int | None = None self._master_fd: int | None = None
self._slave_fd: int | None = None self._slave_fd: int | None = None
# Circular buffer for output replay
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
self._buffer_size = 0
# WebSocket connections
self._websockets: set[Any] = set()
# Activity tracking
self.last_activity = time.time()
# Terminal size
self._cols = 80
self._rows = 24
async def start(self) -> None: async def start(self) -> None:
"""Start the docker exec process with a shell using a PTY.""" """Start the docker exec process with a shell using a PTY."""
@@ -28,14 +57,19 @@ class TerminalSession:
self._master_fd, self._slave_fd = pty.openpty() self._master_fd, self._slave_fd = pty.openpty()
# Set the terminal size initially # Set the terminal size initially
self._set_terminal_size(80, 24) 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}")
# Start docker exec with the slave fd as stdin/stdout/stderr # Start docker exec with the slave fd as stdin/stdout/stderr
# Using -it because the slave fd IS a TTY # Using -i (interactive) but NOT -t (tty) because:
# 1. The slave fd IS a TTY
# 2. docker exec -t creates its OWN PTY inside the container
# 3. This prevents host PTY resize from propagating to the container shell
# By using only -i, docker exec uses our PTY slave directly
self.process = await asyncio.create_subprocess_exec( self.process = await asyncio.create_subprocess_exec(
"docker", "docker",
"exec", "exec",
"-it", "-i",
"-e", "-e",
"TERM=xterm", "TERM=xterm",
self.container_id, self.container_id,
@@ -49,47 +83,90 @@ class TerminalSession:
# Close slave fd in parent process # Close slave fd in parent process
os.close(self._slave_fd) os.close(self._slave_fd)
self._slave_fd = None self._slave_fd = None
self.last_activity = time.time()
def _set_terminal_size(self, cols: int, rows: int) -> None: def _set_terminal_size(self, cols: int, rows: int) -> None:
"""Set the terminal size using TIOCSWINSZ.""" """Set the terminal size using TIOCSWINSZ."""
if self._master_fd is None: if self._master_fd is None:
logger.warning("Cannot resize: master_fd is None (session not started)")
return return
# TIOCSWINSZ = 0x5414 on Linux # TIOCSWINSZ = 0x5414 on Linux
TIOCSWINSZ = 0x5414 TIOCSWINSZ = 0x5414
size = struct.pack('HHHH', rows, cols, 0, 0) size = struct.pack('HHHH', rows, cols, 0, 0)
try: try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size) fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
except (OSError, IOError): logger.info(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
pass except (OSError, IOError) as e:
logger.error(f"Failed to resize PTY: {e}")
async def read_output(self) -> bytes: async def read_output(self) -> bytes:
"""Read output from the PTY master.""" """Read output from the PTY master and store in buffer."""
if self._master_fd is None or self._closed: if self._master_fd is None or self._closed:
return b"" return b""
try: try:
# Use select to check if data is available # Use select to check if data is available
readable, _, _ = select.select([self._master_fd], [], [], 0.1) readable, _, _ = select.select([self._master_fd], [], [], 0.1)
if readable: if readable:
return os.read(self._master_fd, 4096) data = os.read(self._master_fd, 4096)
if data:
self._add_to_buffer(data)
self.last_activity = time.time()
return data
return b"" return b""
except (OSError, IOError, ValueError): except (OSError, IOError, ValueError):
return b"" return b""
def _add_to_buffer(self, data: bytes) -> None:
"""Add data to circular buffer, maintaining size limit."""
self._output_buffer.append(data)
self._buffer_size += len(data)
# Trim if exceeds max size
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
removed = self._output_buffer.popleft()
self._buffer_size -= len(removed)
def get_buffer(self) -> bytes:
"""Get buffered output for replay."""
return b"".join(self._output_buffer)
async def write_input(self, data: bytes) -> None: async def write_input(self, data: bytes) -> None:
"""Write input to the PTY master.""" """Write input to the PTY master."""
if self._master_fd is None or self._closed: if self._master_fd is None or self._closed:
return return
try: try:
os.write(self._master_fd, data) os.write(self._master_fd, data)
self.last_activity = time.time()
except (OSError, IOError): except (OSError, IOError):
pass pass
async def resize(self, cols: int, rows: int) -> None: async def resize(self, cols: int, rows: int) -> None:
"""Resize the terminal.""" """Resize the terminal."""
if self._closed: if self._closed:
logger.warning("Cannot resize: session is closed")
return return
# Only resize if dimensions actually changed
if cols == self._cols and rows == self._rows:
return
self._cols = cols
self._rows = rows
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
self._set_terminal_size(cols, rows) self._set_terminal_size(cols, rows)
async def reset(self) -> None:
"""Reset the session by killing the process and clearing state."""
await self.close()
self._closed = False
self._output_buffer.clear()
self._buffer_size = 0
self._websockets.clear()
self.process = None
self._master_fd = None
self._slave_fd = None
async def close(self) -> None: async def close(self) -> None:
"""Close the session and cleanup.""" """Close the session and cleanup."""
if self._closed: if self._closed:
@@ -115,3 +192,35 @@ class TerminalSession:
if self.process is None: if self.process is None:
return False return False
return self.process.returncode is None return self.process.returncode is None
def is_idle(self) -> bool:
"""Check if the session has been idle for too long."""
if self._websockets:
return False
return time.time() - self.last_activity > self.IDLE_TIMEOUT
def attach_websocket(self, websocket: Any) -> None:
"""Attach a WebSocket to this session."""
self._websockets.add(websocket)
self.last_activity = time.time()
def detach_websocket(self, websocket: Any) -> None:
"""Detach a WebSocket from this session."""
self._websockets.discard(websocket)
def has_websockets(self) -> bool:
"""Check if any WebSockets are attached."""
return len(self._websockets) > 0
async def send_to_all(self, data: bytes) -> None:
"""Send data to all attached WebSockets."""
dead_sockets = set()
for ws in self._websockets:
try:
await ws.send_bytes(data)
except Exception:
dead_sockets.add(ws)
# Clean up dead sockets
for ws in dead_sockets:
self._websockets.discard(ws)
+23 -4
View File
@@ -124,7 +124,15 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
try: try:
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}") _run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
except RuntimeError: except RuntimeError:
_run_git_command(repo_path, "checkout", "--orphan", name) # No commits yet - empty repository
try:
_run_git_command(repo_path, "checkout", "--orphan", name)
except RuntimeError as e:
if "work tree" in str(e).lower():
# Bare repository - use symbolic-ref instead
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
return
raise
return return
_run_git_command(repo_path, "branch", name, base_branch) _run_git_command(repo_path, "branch", name, base_branch)
@@ -155,7 +163,14 @@ def checkout_branch(repo_path: str, name: str) -> None:
Raises: Raises:
RuntimeError: If checkout fails RuntimeError: If checkout fails
""" """
_run_git_command(repo_path, "checkout", name) try:
_run_git_command(repo_path, "checkout", name)
except RuntimeError as e:
if "work tree" in str(e).lower():
# Bare repository - use symbolic-ref instead
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
return
raise
def commit_changes( def commit_changes(
@@ -290,6 +305,10 @@ def get_current_branch(repo_path: str) -> str:
Current branch name Current branch name
""" """
try: try:
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip() branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
if branch != "HEAD":
return branch
except RuntimeError: except RuntimeError:
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip() pass
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
+26 -8
View File
@@ -289,19 +289,37 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
branches: list[BranchInfo] = [] branches: list[BranchInfo] = []
default_branch = "main" default_branch = "main"
# Get list of remote names to properly filter remote tracking branches
try:
remote_output = _run_git_command(repo_path, "remote")
remote_names = {r.strip() for r in remote_output.strip().split("\n") if r.strip()}
except RuntimeError:
remote_names = set()
for line in output.strip().split("\n"): for line in output.strip().split("\n"):
if not line: if not line:
continue continue
branch_name = line.strip() branch_name = line.strip()
# Skip remote tracking branches (they start with remotes/)
if branch_name.startswith("remotes/"): # Skip detached HEAD pointer
# Extract just the branch name part if branch_name == "HEAD":
parts = branch_name.split("/", 2) continue
if len(parts) >= 3:
branch_name = parts[2] # Skip remote tracking branches - they appear as "origin/branch-name"
else: # Check if first part is a remote name
continue if "/" in branch_name:
first_part = branch_name.split("/", 1)[0]
if first_part in remote_names:
# Extract just the branch name part (after "origin/")
branch_name = branch_name.split("/", 1)[1]
elif branch_name.startswith("remotes/"):
# Handle "remotes/origin/branch-name" format
parts = branch_name.split("/", 2)
if len(parts) >= 3:
branch_name = parts[2]
else:
continue
# Skip duplicates # Skip duplicates
if any(b.name == branch_name for b in branches): if any(b.name == branch_name for b in branches):
+26 -3
View File
@@ -47,10 +47,8 @@ def test_client() -> Generator[TestClient, None, None]:
app.dependency_overrides[get_db_session] = override_get_db_session app.dependency_overrides[get_db_session] = override_get_db_session
# Patch startup events to prevent PostgreSQL connection attempts # Patch startup events to prevent PostgreSQL connection attempts
with patch("src.main.init_database") as mock_init, \ with patch("src.main.init_database") as mock_init:
patch("src.main.seed_builtin_tool_types") as mock_seed:
mock_init.return_value = True mock_init.return_value = True
mock_seed.return_value = None
try: try:
with TestClient(app) as client: with TestClient(app) as client:
@@ -61,6 +59,31 @@ def test_client() -> Generator[TestClient, None, None]:
asyncio.run(engine.dispose()) asyncio.run(engine.dispose())
@pytest_asyncio.fixture
async def db_session(test_client) -> AsyncGenerator[AsyncSession, None]:
"""Provide an async database session for unit tests."""
# Get the override function from the test_client fixture
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
yield session
finally:
await gen.aclose()
else:
# Fallback: create a new engine and session
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
yield session
await engine.dispose()
@pytest.fixture @pytest.fixture
def authenticated_client(test_client) -> Generator[TestClient, None, None]: def authenticated_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with a test user.""" """Provide an authenticated test client with a test user."""
@@ -0,0 +1,322 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestConfigProfilesAPI:
"""Integration tests for config profiles API."""
def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None:
"""Test that listing config profiles requires authentication."""
response = test_client.get("/config-profiles")
assert response.status_code == 401
def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None:
"""Test that authenticated users can list their profiles."""
response = authenticated_client.get("/config-profiles")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test creating a config profile."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "test-profile",
"description": "Test profile",
"env_vars": {"VAR": "value"},
"runtime_hints": {"start_command": "npm start"},
"mounts": [{"target": "/app", "mode": "rw", "files": {}}],
"files": {"test.txt": "hello"},
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "test-profile"
assert data["env_vars"] == {"VAR": "value"}
assert data["files"] == {"test.txt": "hello"}
assert data["mounts"][0]["target"] == "/app"
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
"""Test that duplicate profile names are rejected."""
# Create first profile
response = authenticated_client.post(
"/config-profiles",
json={
"name": "duplicate-profile",
"env_vars": {},
"files": {},
},
)
assert response.status_code == 201
# Try to create second with same name
response = authenticated_client.post(
"/config-profiles",
json={
"name": "duplicate-profile",
"env_vars": {},
"files": {},
},
)
assert response.status_code == 409
def test_create_config_profile_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
"""Test that profiles exceeding 10MB are rejected."""
large_content = "x" * (11 * 1024 * 1024) # 11MB
response = authenticated_client.post(
"/config-profiles",
json={
"name": "large-profile",
"env_vars": {},
"files": {"large.txt": large_content},
},
)
assert response.status_code == 413
def test_create_config_profile_invalid_file_path(self, authenticated_client: TestClient) -> None:
"""Test that invalid file paths are rejected."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-profile",
"env_vars": {},
"files": {"../../../etc/passwd": "malicious"},
},
)
assert response.status_code == 422
def test_create_config_profile_invalid_mount_target(self, authenticated_client: TestClient) -> None:
"""Test that invalid mount targets are rejected."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-mount-profile",
"env_vars": {},
"files": {},
"mounts": [{"target": "relative/path", "mode": "rw", "files": {}}],
},
)
assert response.status_code == 422
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
"""Test getting a config profile by ID."""
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "get-test",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Get it back
response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "get-test"
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
"""Test getting a non-existent profile."""
response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}")
assert response.status_code == 404
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a config profile."""
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "update-test",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Update it
response = authenticated_client.put(
f"/config-profiles/{profile_id}",
json={
"name": "updated-name",
"env_vars": {"NEW_VAR": "new_value"},
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated-name"
assert data["env_vars"] == {"NEW_VAR": "new_value"}
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a config profile."""
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "delete-test",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Delete it
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
assert response.status_code == 204
# Verify it's gone
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert get_response.status_code == 404
def test_update_profile_includes_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating profile includes."""
# Create base profile
base_response = authenticated_client.post(
"/config-profiles",
json={
"name": "base-profile",
"env_vars": {"BASE_VAR": "base_value"},
"files": {},
},
)
base_id = base_response.json()["id"]
# Create child profile
child_response = authenticated_client.post(
"/config-profiles",
json={
"name": "child-profile",
"env_vars": {},
"files": {},
},
)
child_id = child_response.json()["id"]
# Update includes
response = authenticated_client.put(
f"/config-profiles/{child_id}/includes",
json={"includes": [base_id]},
)
assert response.status_code == 200
data = response.json()
print(f"Response data: {data}")
print(f"Includes: {data.get('includes', 'NO INCLUDES KEY')}")
assert len(data["includes"]) == 1, f"Expected 1 include, got {len(data.get('includes', []))}: {data.get('includes', [])}"
assert data["includes"][0]["included_profile_id"] == base_id
def test_update_profile_includes_cycle_detection(self, authenticated_client: TestClient) -> None:
"""Test that include cycles are detected."""
# Create profile A
a_response = authenticated_client.post(
"/config-profiles",
json={
"name": "profile-a",
"env_vars": {},
"files": {},
},
)
a_id = a_response.json()["id"]
# Create profile B
b_response = authenticated_client.post(
"/config-profiles",
json={
"name": "profile-b",
"env_vars": {},
"files": {},
},
)
b_id = b_response.json()["id"]
# Make B include A
authenticated_client.put(
f"/config-profiles/{b_id}/includes",
json={"includes": [a_id]},
)
# Try to make A include B (would create cycle)
response = authenticated_client.put(
f"/config-profiles/{a_id}/includes",
json={"includes": [b_id]},
)
assert response.status_code == 400
def test_preview_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test previewing a resolved config profile."""
# Create base profile
base_response = authenticated_client.post(
"/config-profiles",
json={
"name": "preview-base",
"env_vars": {"BASE_VAR": "base"},
"files": {},
},
)
base_id = base_response.json()["id"]
# Create child profile
child_response = authenticated_client.post(
"/config-profiles",
json={
"name": "preview-child",
"env_vars": {"CHILD_VAR": "child"},
"files": {},
},
)
child_id = child_response.json()["id"]
# Make child include base
authenticated_client.put(
f"/config-profiles/{child_id}/includes",
json={"includes": [base_id]},
)
# Preview child
response = authenticated_client.get(f"/config-profiles/{child_id}/preview")
assert response.status_code == 200
data = response.json()
assert data["profile_name"] == "preview-child"
assert data["env_vars"]["BASE_VAR"] == "base"
assert data["env_vars"]["CHILD_VAR"] == "child"
assert len(data["included_profiles"]) == 1
def test_resolve_default_profile(self, authenticated_client: TestClient) -> None:
"""Test resolving default profile for project/tool."""
# Create a global default profile (no project/tool scoping)
authenticated_client.post(
"/config-profiles",
json={
"name": "default-profile",
"env_vars": {},
"files": {},
"is_default": True,
},
)
# Resolve default with random project/tool (should fall back to global)
project_id = str(uuid.uuid4())
tool_type_id = str(uuid.uuid4())
response = authenticated_client.get(
"/config-profiles/defaults/resolve",
params={"project_id": project_id, "tool_type_id": tool_type_id},
)
assert response.status_code == 200
data = response.json()
assert data["profile_name"] == "default-profile"
def test_resolve_default_profile_no_match(self, authenticated_client: TestClient) -> None:
"""Test resolving default profile when no profiles exist."""
project_id = str(uuid.uuid4())
tool_type_id = str(uuid.uuid4())
response = authenticated_client.get(
"/config-profiles/defaults/resolve",
params={"project_id": project_id, "tool_type_id": tool_type_id},
)
assert response.status_code == 200
data = response.json()
assert data["profile_id"] is None
@@ -70,6 +70,20 @@ def test_get_current_branch_handles_unborn_main() -> None:
assert get_current_branch(tmpdir) == "main" assert get_current_branch(tmpdir) == "main"
def test_create_branch_on_bare_repo_with_no_commits() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
create_branch(f"{tmpdir}/bare.git", "main")
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
def test_checkout_branch_on_bare_repo_with_no_commits() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
checkout_branch(f"{tmpdir}/bare.git", "main")
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
class TestBranchOperations: class TestBranchOperations:
"""Tests for branch management functions.""" """Tests for branch management functions."""
@@ -98,7 +98,6 @@ def _insert_tool_type(
name: str, name: str,
display_name: str, display_name: str,
compose_template: str, compose_template: str,
is_builtin: bool = False,
created_by_id: str | None = None, created_by_id: str | None = None,
) -> None: ) -> None:
async def _run() -> None: async def _run() -> None:
@@ -120,7 +119,6 @@ def _insert_tool_type(
description="A test tool type", description="A test tool type",
compose_template=compose_template, compose_template=compose_template,
required_variables=["REPO_PATH", "TOOL_NAME"], required_variables=["REPO_PATH", "TOOL_NAME"],
is_builtin=is_builtin,
created_by_id=uuid.UUID(created_by_id) if created_by_id else None, created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
) )
await session.merge(tool_type) await session.merge(tool_type)
@@ -234,7 +232,6 @@ def test_create_tool_type_successfully() -> None:
assert data["name"] == "my-custom-tool" assert data["name"] == "my-custom-tool"
assert data["display_name"] == "My Custom Tool" assert data["display_name"] == "My Custom Tool"
assert data["description"] == "A custom development tool" assert data["description"] == "A custom development tool"
assert data["is_builtin"] == False
assert data["created_by_id"] == user_id assert data["created_by_id"] == user_id
assert "id" in data assert "id" in data
@@ -376,28 +373,7 @@ def test_update_tool_type_not_found() -> None:
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.integration
def test_update_builtin_tool_type_fails() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(
tool_type_id,
"builtin-tool",
"Built-in Tool",
"version: '3.8'\nservices:\n app:\n image: builtin",
is_builtin=True,
)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user_id))
payload = {"display_name": "Updated"}
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
assert response.status_code == 403
@pytest.mark.integration @pytest.mark.integration
@@ -442,53 +418,4 @@ def test_delete_tool_type_not_found() -> None:
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.integration
def test_delete_builtin_tool_type_fails() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(user_id)
_insert_tool_type(
tool_type_id,
"builtin-tool",
"Built-in Tool",
"version: '3.8'\nservices:\n app:\n image: builtin",
is_builtin=True,
)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user_id))
response = client.delete(f"/tool-types/{tool_type_id}")
assert response.status_code == 403
@pytest.mark.integration
def test_builtin_tool_types_seeded_on_startup() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
# Load app triggers startup event which seeds built-in types
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user_id))
response = client.get("/tool-types")
assert response.status_code == 200
data = response.json()
# Check that built-in types exist
builtin_names = [t["name"] for t in data if t["is_builtin"]]
assert "code-server" in builtin_names
assert "jupyter-notebook" in builtin_names
# Verify built-in types have correct attributes
code_server = next((t for t in data if t["name"] == "code-server"), None)
assert code_server is not None
assert code_server["display_name"] == "VS Code Server"
assert "services" in code_server["compose_template"]
assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"]
@@ -39,7 +39,7 @@ class TestToolTypesAPIExtended:
"interfaces": ["web"], "interfaces": ["web"],
"default_port": 8080, "default_port": 8080,
"definition_type": "compose", "definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"readiness_probe": { "readiness_probe": {
"command": "curl -f http://localhost:8080", "command": "curl -f http://localhost:8080",
"timeout": 30, "timeout": 30,
@@ -92,7 +92,7 @@ class TestToolTypesAPIExtended:
"display_name": "Update Test Tool", "display_name": "Update Test Tool",
"default_port": 8080, "default_port": 8080,
"definition_type": "compose", "definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"required_variables": [], "required_variables": [],
}, },
) )
@@ -167,7 +167,7 @@ class TestToolTypesAPIExtended:
"interfaces": ["web", "terminal"], "interfaces": ["web", "terminal"],
"default_port": 8443, "default_port": 8443,
"definition_type": "compose", "definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"", "compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
"readiness_probe": { "readiness_probe": {
"command": "curl -f http://localhost:8443", "command": "curl -f http://localhost:8443",
"timeout": 30, "timeout": 30,
@@ -186,3 +186,40 @@ class TestToolTypesAPIExtended:
assert data["category"] == "editor" assert data["category"] == "editor"
assert data["interfaces"] == ["web", "terminal"] assert data["interfaces"] == ["web", "terminal"]
assert "readiness_probe" in data assert "readiness_probe" in data
def test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) -> None:
"""Test that creating a tool type without default_port fails validation."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "no-port-tool",
"display_name": "No Port Tool",
"category": "utility",
"interfaces": ["web"],
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"required_variables": [],
},
)
assert response.status_code == 422
data = response.json()
assert "default_port" in str(data)
def test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) -> None:
"""Test that port mismatch between default_port and compose template fails."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "port-mismatch-tool",
"display_name": "Port Mismatch Tool",
"category": "utility",
"interfaces": ["web"],
"default_port": 9999,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
"required_variables": [],
},
)
assert response.status_code == 422
data = response.json()
assert "Port 9999 is not exposed" in str(data)
@@ -0,0 +1,383 @@
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.services.config_profile_resolver import (
ConfigProfileCycleError,
ConfigProfileNotFoundError,
ResolvedProfile,
check_include_cycle,
resolve_profile,
_merge_env_vars,
_merge_files,
_merge_mounts,
_merge_runtime_hints,
)
class TestMergeFunctions:
"""Unit tests for merge helper functions."""
def test_merge_env_vars_basic(self) -> None:
"""Test basic env var merging."""
result = _merge_env_vars(
{"A": "1", "B": "2"},
{"B": "3", "C": "4"},
{},
"source",
)
assert result == {"A": "1", "B": "3", "C": "4"}
def test_merge_env_vars_tracks_overrides(self) -> None:
"""Test that env var overrides are tracked."""
overrides = {}
_merge_env_vars(
{"A": "1"},
{"A": "2"},
overrides,
"source",
)
assert overrides == {"A": "source"}
def test_merge_runtime_hints_basic(self) -> None:
"""Test basic runtime hint merging."""
result = _merge_runtime_hints(
{"command": "old"},
{"command": "new", "port": 8080},
{},
"source",
)
assert result == {"command": "new", "port": 8080}
def test_merge_files_basic(self) -> None:
"""Test basic file merging."""
result = _merge_files(
{"a.txt": "old"},
{"a.txt": "new", "b.txt": "content"},
{},
"source",
)
assert result == {"a.txt": "new", "b.txt": "content"}
def test_merge_mounts_basic(self) -> None:
"""Test basic mount merging."""
from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts(
{},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
{},
"source",
)
assert "/app" in result
assert result["/app"].mode == "rw"
assert result["/app"].files == {"a.txt": "content"}
def test_merge_mounts_file_override(self) -> None:
"""Test mount file map merging with overrides."""
from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
{},
"source",
)
assert result["/app"].files == {"a.txt": "new"}
def test_merge_mounts_mode_conflict(self) -> None:
"""Test that mount mode conflicts are resolved (later wins)."""
from src.services.config_profile_resolver import ResolvedMount
overrides = {}
result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
[{"target": "/app", "mode": "ro", "files": {}}],
overrides,
"source",
)
assert result["/app"].mode == "ro"
assert overrides == {"/app": "source"}
class TestResolveProfile:
"""Unit tests for profile resolution."""
@pytest.mark.asyncio
async def test_resolve_simple_profile(self, db_session: AsyncSession) -> None:
"""Test resolving a profile with no includes."""
user_id = uuid.uuid4()
profile = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="simple",
env_vars={"VAR": "value"},
runtime_hints={"command": "run"},
files={"test.txt": "content"},
mounts=[{"target": "/app", "mode": "rw", "files": {}}],
)
db_session.add(profile)
await db_session.commit()
result = await resolve_profile(db_session, profile.id)
assert result.profile_name == "simple"
assert result.env_vars == {"VAR": "value"}
assert result.runtime_hints == {"command": "run"}
assert result.files == {"test.txt": "content"}
@pytest.mark.asyncio
async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None:
"""Test resolving a profile that includes another."""
user_id = uuid.uuid4()
# Create base profile
base = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="base",
env_vars={"BASE_VAR": "base_value"},
files={},
)
db_session.add(base)
# Create child profile
child = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="child",
env_vars={"CHILD_VAR": "child_value"},
files={},
)
db_session.add(child)
await db_session.commit()
# Create include relationship
include = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=child.id,
included_profile_id=base.id,
order_index=0,
)
db_session.add(include)
await db_session.commit()
result = await resolve_profile(db_session, child.id)
assert result.env_vars == {
"BASE_VAR": "base_value",
"CHILD_VAR": "child_value",
}
assert len(result.included_profiles) == 1
assert result.included_profiles[0]["name"] == "base"
@pytest.mark.asyncio
async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None:
"""Test that child profile values override parent values."""
user_id = uuid.uuid4()
base = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="base",
env_vars={"VAR": "base"},
files={},
)
db_session.add(base)
child = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="child",
env_vars={"VAR": "child"},
files={},
)
db_session.add(child)
await db_session.commit()
include = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=child.id,
included_profile_id=base.id,
order_index=0,
)
db_session.add(include)
await db_session.commit()
result = await resolve_profile(db_session, child.id)
assert result.env_vars == {"VAR": "child"}
assert result.env_overrides == {"VAR": "child"}
@pytest.mark.asyncio
async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None:
"""Test that cycles are detected during resolution."""
user_id = uuid.uuid4()
profile_a = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="a",
env_vars={},
files={},
)
db_session.add(profile_a)
profile_b = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="b",
env_vars={},
files={},
)
db_session.add(profile_b)
await db_session.commit()
# A includes B
include_ab = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_a.id,
included_profile_id=profile_b.id,
order_index=0,
)
db_session.add(include_ab)
# B includes A (creates cycle)
include_ba = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_b.id,
included_profile_id=profile_a.id,
order_index=0,
)
db_session.add(include_ba)
await db_session.commit()
with pytest.raises(ConfigProfileCycleError):
await resolve_profile(db_session, profile_a.id)
@pytest.mark.asyncio
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
"""Test resolving a non-existent profile."""
with pytest.raises(ConfigProfileNotFoundError):
await resolve_profile(db_session, uuid.uuid4())
class TestCheckIncludeCycle:
"""Unit tests for include cycle checking."""
@pytest.mark.asyncio
async def test_check_no_cycle(self, db_session: AsyncSession) -> None:
"""Test checking when no cycle exists."""
user_id = uuid.uuid4()
profile_a = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="a",
env_vars={},
files={},
)
db_session.add(profile_a)
profile_b = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="b",
env_vars={},
files={},
)
db_session.add(profile_b)
await db_session.commit()
# A includes B
include = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_a.id,
included_profile_id=profile_b.id,
order_index=0,
)
db_session.add(include)
await db_session.commit()
result = await check_include_cycle(db_session, profile_a.id)
assert result is None
@pytest.mark.asyncio
async def test_check_detects_cycle(self, db_session: AsyncSession) -> None:
"""Test detecting an existing cycle."""
user_id = uuid.uuid4()
profile_a = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="a",
env_vars={},
files={},
)
db_session.add(profile_a)
profile_b = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="b",
env_vars={},
files={},
)
db_session.add(profile_b)
await db_session.commit()
# A includes B
include_ab = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_a.id,
included_profile_id=profile_b.id,
order_index=0,
)
db_session.add(include_ab)
# B includes A
include_ba = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_b.id,
included_profile_id=profile_a.id,
order_index=0,
)
db_session.add(include_ba)
await db_session.commit()
result = await check_include_cycle(db_session, profile_a.id)
assert result is not None
assert len(result) > 1
@pytest.mark.asyncio
async def test_check_would_create_cycle(self, db_session: AsyncSession) -> None:
"""Test detecting a cycle that would be created."""
user_id = uuid.uuid4()
profile_a = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="a",
env_vars={},
files={},
)
db_session.add(profile_a)
profile_b = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
name="b",
env_vars={},
files={},
)
db_session.add(profile_b)
await db_session.commit()
# A includes B
include = ConfigProfileInclude(
id=uuid.uuid4(),
profile_id=profile_a.id,
included_profile_id=profile_b.id,
order_index=0,
)
db_session.add(include)
await db_session.commit()
# Check if adding B includes A would create cycle
result = await check_include_cycle(db_session, profile_b.id, profile_a.id)
assert result is not None
@@ -0,0 +1,182 @@
"""Tests for session creation with branch selection and new branch creation."""
import os
import subprocess
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from src.api.tool_instances import CreateInstanceRequest
class TestCreateInstanceRequest:
"""Tests for CreateInstanceRequest model."""
def test_default_values(self):
"""Test default values for CreateInstanceRequest."""
request = CreateInstanceRequest(tool_type_id="123")
assert request.clone_mode == "mount"
assert request.branch == "main"
assert request.new_branch is None
assert request.display_name is None
def test_clone_mode_with_branch(self):
"""Test CreateInstanceRequest with clone mode and branch."""
request = CreateInstanceRequest(
tool_type_id="123",
clone_mode="clone",
branch="dev",
)
assert request.clone_mode == "clone"
assert request.branch == "dev"
def test_new_branch_field(self):
"""Test CreateInstanceRequest with new_branch field."""
request = CreateInstanceRequest(
tool_type_id="123",
clone_mode="clone",
branch="main",
new_branch="feature/test",
)
assert request.new_branch == "feature/test"
class TestBranchCreationInClone:
"""Tests for branch creation logic in clone process."""
def test_create_local_branch_success(self):
"""Test successful local branch creation."""
with tempfile.TemporaryDirectory() as tmpdir:
# Initialize repo
subprocess.run(
["git", "init", tmpdir],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.name", "Test User"],
capture_output=True,
check=True,
)
# Create initial commit
readme = os.path.join(tmpdir, "README.md")
with open(readme, "w") as f:
f.write("# Test\n")
subprocess.run(
["git", "-C", tmpdir, "add", "README.md"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
capture_output=True,
check=True,
)
# Create new branch
result = subprocess.run(
["git", "-C", tmpdir, "checkout", "-b", "feature/new-branch"],
capture_output=True,
text=True,
)
assert result.returncode == 0
# Verify branch exists
branches_result = subprocess.run(
["git", "-C", tmpdir, "branch", "--show-current"],
capture_output=True,
text=True,
)
assert branches_result.stdout.strip() == "feature/new-branch"
def test_create_local_branch_invalid_name(self):
"""Test local branch creation with invalid name fails."""
with tempfile.TemporaryDirectory() as tmpdir:
# Initialize repo
subprocess.run(
["git", "init", tmpdir],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.email", "test@test.com"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "config", "user.name", "Test User"],
capture_output=True,
check=True,
)
# Create initial commit
readme = os.path.join(tmpdir, "README.md")
with open(readme, "w") as f:
f.write("# Test\n")
subprocess.run(
["git", "-C", tmpdir, "add", "README.md"],
capture_output=True,
check=True,
)
subprocess.run(
["git", "-C", tmpdir, "commit", "-m", "Initial commit"],
capture_output=True,
check=True,
)
# Try to create branch with invalid name (contains spaces)
result = subprocess.run(
["git", "-C", tmpdir, "checkout", "-b", "invalid branch name"],
capture_output=True,
text=True,
)
# Git accepts branch names with spaces but it's not recommended
# This test verifies the command structure
assert result.returncode == 0 or "fatal" in result.stderr
class TestCreateInstanceAPI:
"""Tests for create instance API endpoint with branch options."""
def test_create_instance_request_validation(self):
"""Test that CreateInstanceRequest validates correctly."""
# Valid request with new_branch
request = CreateInstanceRequest(
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
clone_mode="clone",
branch="main",
new_branch="feature/test",
)
assert request.new_branch == "feature/test"
# Valid request without new_branch
request2 = CreateInstanceRequest(
tool_type_id="550e8400-e29b-41d4-a716-446655440000",
clone_mode="clone",
branch="dev",
)
assert request2.new_branch is None
def test_create_instance_with_new_branch_sets_instance_branch(self):
"""Test that instance branch is set to new_branch when provided."""
# This tests the logic: data.new_branch if data.new_branch else data.branch
new_branch = "feature/test"
base_branch = "main"
# Simulate the logic from create_instance
stored_branch = new_branch if new_branch else base_branch
assert stored_branch == "feature/test"
# Without new_branch
stored_branch2 = None if None else base_branch
assert stored_branch2 == "main"
+145
View File
@@ -0,0 +1,145 @@
import { apiClient } from "./client";
export interface ConfigProfile {
id: string;
user_id: string;
name: string;
description: string | null;
project_id: string | null;
tool_type_id: string | null;
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ConfigProfileMount[];
files: Record<string, string>;
is_default: boolean;
includes: ConfigProfileInclude[];
created_at: string;
updated_at: string;
}
export interface ConfigProfileMount {
target: string;
mode: "ro" | "rw";
files: Record<string, string>;
}
export interface ConfigProfileInclude {
id: string;
included_profile_id: string;
order_index: number;
}
export interface ResolvedProfile {
profile_id: string;
profile_name: string;
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ResolvedMount[];
files: Record<string, string>;
overrides: {
env_vars: Record<string, string>;
runtime_hints: Record<string, string>;
files: Record<string, string>;
mounts: Record<string, string>;
};
included_profiles: Array<{ id: string; name: string }>;
}
export interface ResolvedMount {
target: string;
mode: "ro" | "rw";
files: Record<string, string>;
overridden_files: Record<string, string>;
}
export interface CreateConfigProfileRequest {
name: string;
description?: string;
project_id?: string;
tool_type_id?: string;
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
files?: Record<string, string>;
is_default?: boolean;
}
export interface UpdateConfigProfileRequest {
name?: string;
description?: string;
project_id?: string;
tool_type_id?: string;
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
files?: Record<string, string>;
is_default?: boolean;
}
export interface UpdateIncludesRequest {
includes: string[];
}
export const listConfigProfiles = async (
projectId?: string,
toolTypeId?: string
): Promise<ConfigProfile[]> => {
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
params: { project_id: projectId, tool_type_id: toolTypeId },
});
return response.data;
};
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
return response.data;
};
export const createConfigProfile = async (
data: CreateConfigProfileRequest
): Promise<ConfigProfile> => {
const response = await apiClient.post<ConfigProfile>("/config-profiles", data);
return response.data;
};
export const updateConfigProfile = async (
id: string,
data: UpdateConfigProfileRequest
): Promise<ConfigProfile> => {
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data);
return response.data;
};
export const deleteConfigProfile = async (id: string): Promise<void> => {
await apiClient.delete(`/config-profiles/${id}`);
};
export const updateProfileIncludes = async (
id: string,
data: UpdateIncludesRequest
): Promise<ConfigProfile> => {
const response = await apiClient.put<ConfigProfile>(
`/config-profiles/${id}/includes`,
data
);
return response.data;
};
export const previewConfigProfile = async (
id: string
): Promise<ResolvedProfile> => {
const response = await apiClient.get<ResolvedProfile>(
`/config-profiles/${id}/preview`
);
return response.data;
};
export const resolveDefaultProfile = async (
projectId: string,
toolTypeId: string
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
const response = await apiClient.get("/config-profiles/defaults/resolve", {
params: { project_id: projectId, tool_type_id: toolTypeId },
});
return response.data;
};
+35
View File
@@ -8,6 +8,7 @@ export interface GitRepository {
owner_id: string; owner_id: string;
is_mirror: boolean; is_mirror: boolean;
remote_url: string | null; remote_url: string | null;
ssh_key_id: string | null;
last_push: string | null; last_push: string | null;
created_at: string | null; created_at: string | null;
} }
@@ -16,6 +17,7 @@ export interface GitRepositoryCreate {
name: string; name: string;
remote_url?: string; remote_url?: string;
force_original_url?: boolean; force_original_url?: boolean;
ssh_key_id?: string;
} }
export interface URLParseResult { export interface URLParseResult {
@@ -50,6 +52,39 @@ export async function deleteRepository(projectId: string, repoId: string): Promi
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`); await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
} }
export async function updateRepositorySshKey(
projectId: string,
repoId: string,
sshKeyId: string | null
): Promise<GitRepository> {
const response = await apiClient.patch(
`/projects/${projectId}/repositories/${repoId}/ssh-key`,
{ ssh_key_id: sshKeyId }
);
return response.data;
}
export interface Branch {
name: string;
is_default: boolean;
last_commit: string | null;
}
export interface BranchesResponse {
branches: Branch[];
default_branch: string;
}
export async function listRepositoryBranches(
projectId: string,
repoId: string
): Promise<BranchesResponse> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/branches`
);
return response.data;
}
export interface CommitHistoryEntry { export interface CommitHistoryEntry {
hash: string; hash: string;
short_hash: string; short_hash: string;
+66 -14
View File
@@ -10,6 +10,7 @@ export interface ToolInstance {
status: string; status: string;
url: string | null; url: string | null;
port: number | null; port: number | null;
selected_config_profile_id: string | null;
created_at: string; created_at: string;
} }
@@ -25,6 +26,11 @@ export interface Session {
project_id: string; project_id: string;
status: string; status: string;
url: string | null; url: string | null;
container_status?: string;
probe_status?: string;
clone_mode?: string;
branch?: string | null;
created_at?: string;
} }
export async function listInstances( export async function listInstances(
@@ -41,13 +47,21 @@ export async function createInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
toolTypeId: string, toolTypeId: string,
displayName?: string displayName?: string,
cloneMode?: string,
branch?: string,
newBranch?: string,
configProfileId?: string
): Promise<ToolInstance> { ): Promise<ToolInstance> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`, `/projects/${projectId}/repositories/${repoId}/instances`,
{ {
tool_type_id: toolTypeId, tool_type_id: toolTypeId,
display_name: displayName, display_name: displayName,
clone_mode: cloneMode || "mount",
branch: branch || undefined,
new_branch: newBranch || undefined,
config_profile_id: configProfileId,
} }
); );
return response.data; return response.data;
@@ -56,12 +70,24 @@ export async function createInstance(
export async function startInstance( export async function startInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
configProfileId?: string,
retries = 2
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
const response = await apiClient.post( try {
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start` const response = await apiClient.post(
); `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
return response.data; { config_profile_id: configProfileId }
);
return response.data;
} catch (error: any) {
// Retry on network errors (e.g. Docker creating network interfaces)
if (retries > 0 && !error.response) {
await new Promise((r) => setTimeout(r, 1500));
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
throw error;
}
} }
export async function stopInstance( export async function stopInstance(
@@ -78,21 +104,35 @@ export async function stopInstance(
export async function restartInstance( export async function restartInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
configProfileId?: string,
retries = 2
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
const response = await apiClient.post( try {
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart` const response = await apiClient.post(
); `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
return response.data; { config_profile_id: configProfileId }
);
return response.data;
} catch (error: any) {
// Retry on network errors (e.g. Docker creating network interfaces)
if (retries > 0 && !error.response) {
await new Promise((r) => setTimeout(r, 1500));
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
}
throw error;
}
} }
export async function deleteInstance( export async function deleteInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
force?: boolean
): Promise<void> { ): Promise<void> {
await apiClient.delete( await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
{ params: { force } }
); );
} }
@@ -101,11 +141,23 @@ export async function getUserSessions(): Promise<Session[]> {
return response.data.sessions; return response.data.sessions;
} }
export interface InstanceHealth {
healthy: boolean;
container_status: string;
container_health: string | null;
container_exit_code: number | null;
tunnel_status: string;
tunnel_status_code: number | null;
probe_status: string;
last_probe_output: string | null;
error: string | null;
}
export async function checkInstanceHealth( export async function checkInstanceHealth(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> { ): Promise<InstanceHealth> {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
); );
+27
View File
@@ -24,3 +24,30 @@ export async function createSSHKey(data: SSHKeyCreate): Promise<SSHKey> {
export async function deleteSSHKey(keyId: string): Promise<void> { export async function deleteSSHKey(keyId: string): Promise<void> {
await apiClient.delete(`/ssh-keys/${keyId}`); await apiClient.delete(`/ssh-keys/${keyId}`);
} }
export interface SignPayloadRequest {
payload: string;
}
export interface SignatureResponse {
signature: string;
}
export interface VerifySignatureRequest {
payload: string;
signature: string;
}
export interface VerifySignatureResponse {
valid: boolean;
}
export async function signPayload(keyId: string, data: SignPayloadRequest): Promise<SignatureResponse> {
const response = await apiClient.post<SignatureResponse>(`/ssh-keys/${keyId}/sign`, data);
return response.data;
}
export async function verifySignature(keyId: string, data: VerifySignatureRequest): Promise<VerifySignatureResponse> {
const response = await apiClient.post<VerifySignatureResponse>(`/ssh-keys/${keyId}/verify`, data);
return response.data;
}
+6 -4
View File
@@ -12,7 +12,8 @@ export interface ToolType {
display_name: string; display_name: string;
description: string | null; description: string | null;
category: string; category: string;
interfaces: string[]; interface_type: string;
requires_port: boolean;
default_port: number | null; default_port: number | null;
definition_type: 'compose' | 'dockerfile'; definition_type: 'compose' | 'dockerfile';
compose_template: string | null; compose_template: string | null;
@@ -20,7 +21,6 @@ export interface ToolType {
build_context: Record<string, string> | null; build_context: Record<string, string> | null;
readiness_probe: ReadinessProbe | null; readiness_probe: ReadinessProbe | null;
required_variables: string[]; required_variables: string[];
is_builtin: boolean;
created_by_id: string | null; created_by_id: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -31,7 +31,8 @@ export interface CreateToolTypeRequest {
display_name: string; display_name: string;
description?: string; description?: string;
category?: string; category?: string;
interfaces?: string[]; interface_type?: string;
requires_port?: boolean;
default_port: number; default_port: number;
definition_type?: 'compose' | 'dockerfile'; definition_type?: 'compose' | 'dockerfile';
compose_template?: string; compose_template?: string;
@@ -45,7 +46,8 @@ export interface UpdateToolTypeRequest {
display_name?: string; display_name?: string;
description?: string; description?: string;
category?: string; category?: string;
interfaces?: string[]; interface_type?: string;
requires_port?: boolean;
default_port?: number; default_port?: number;
definition_type?: 'compose' | 'dockerfile'; definition_type?: 'compose' | 'dockerfile';
compose_template?: string; compose_template?: string;
+43 -6
View File
@@ -1,18 +1,21 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, NavLink, Outlet } from "react-router-dom"; import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { getUserSessions } from "../api/sessions"; import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions"; import type { Session } from "../api/sessions";
import { useTheme } from "../hooks/use-theme"; import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth"; import { useAuth } from "../state/auth";
import { useSessions } from "../state/sessions"; import { useSessions } from "../state/sessions";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { Icon } from "./icon"; import { Icon } from "./icon";
import type { IconName } from "../utils/icons"; import type { IconName } from "../utils/icons";
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [ const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
{ to: "/", label: "Home", icon: "dashboard" }, { to: "/", label: "Home", icon: "dashboard" },
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
{ to: "/projects", label: "Projects", icon: "projects" }, { to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" }, { to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
{ to: "/settings", label: "Settings", icon: "settings" } { to: "/settings", label: "Settings", icon: "settings" }
]; ];
@@ -38,6 +41,11 @@ export const AppShell = () => {
useTheme(); useTheme();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { sessions, setAllSessions } = useSessions(); 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 () => { const loadSessions = useCallback(async () => {
try { try {
@@ -57,6 +65,19 @@ export const AppShell = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadSessions]); }, [loadSessions]);
// Close mobile menu on route change
useEffect(() => {
setMobileMenuOpen(false);
}, [location.pathname]);
if (isMobileTerminal) {
return (
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
);
}
return ( return (
<div className="shell"> <div className="shell">
<header className="shell-header"> <header className="shell-header">
@@ -81,9 +102,18 @@ export const AppShell = () => {
</header> </header>
<div className="shell-body"> <div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation"> <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) => { {NAV_ITEMS.map((item) => {
const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length; const activeCount = sessions.filter((s) => s.status === "running").length;
return ( return (
<NavLink <NavLink
@@ -94,7 +124,7 @@ export const AppShell = () => {
> >
<Icon name={item.icon} size="sm" /> <Icon name={item.icon} size="sm" />
{item.label} {item.label}
{isHome && activeCount > 0 && ( {item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span> <span className="nav-badge">{activeCount}</span>
)} )}
</NavLink> </NavLink>
@@ -112,6 +142,13 @@ export const AppShell = () => {
)} )}
</aside> </aside>
{isMobile && mobileMenuOpen && (
<div
className="mobile-menu-overlay"
onClick={() => setMobileMenuOpen(false)}
/>
)}
<main className="shell-content"> <main className="shell-content">
<Outlet /> <Outlet />
</main> </main>
@@ -0,0 +1,521 @@
import { useState, useEffect } from "react";
import { Icon } from "./icon";
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
import type { Project } from "../types";
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
import type { ToolType } from "../api/tool_types";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
interface CreateSessionFormProps {
projects: Project[];
repositories: GitRepository[];
toolTypes: ToolType[];
fixedProjectId?: string;
fixedRepoId?: string;
projectName?: string;
repoName?: string;
showCloneMode?: boolean;
showFixedFields?: boolean;
onProjectChange?: (projectId: string) => void;
onSuccess?: (instance: ToolInstance) => void;
onCancel?: () => void;
submitLabel?: string;
className?: string;
}
export const CreateSessionForm = ({
projects,
repositories,
toolTypes,
fixedProjectId,
fixedRepoId,
projectName,
repoName,
showCloneMode = true,
showFixedFields = true,
onProjectChange,
onSuccess,
onCancel,
submitLabel = "Create Session",
className = "",
}: CreateSessionFormProps) => {
const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
const [branch, setBranch] = useState("main");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
const [branches, setBranches] = useState<Branch[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [baseBranch, setBaseBranch] = useState("");
const [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
const [progress, setProgress] = useState("");
const [error, setError] = useState<string | null>(null);
// Load SSH keys when clone mode is shown
useEffect(() => {
if (!showCloneMode) return;
const loadKeys = async () => {
try {
const keys = await listSSHKeys();
setSshKeys(keys);
} catch {
// ignore
}
};
void loadKeys();
}, [showCloneMode]);
// Load config profiles when tool type is selected
useEffect(() => {
const projectId = fixedProjectId || selectedProject;
if (!selectedToolType || !projectId) {
setConfigProfiles([]);
setSelectedConfigProfile("");
return;
}
const loadProfiles = async () => {
try {
const profiles = await listConfigProfiles(projectId, selectedToolType);
setConfigProfiles(profiles);
// Auto-select default if available
const defaultProfile = profiles.find((p) => p.is_default);
if (defaultProfile) {
setSelectedConfigProfile(defaultProfile.id);
}
} catch {
// ignore
}
};
void loadProfiles();
}, [selectedToolType, selectedProject, fixedProjectId]);
// Load branches when selected repo changes
useEffect(() => {
const projectId = fixedProjectId || selectedProject;
if (!selectedRepo || !projectId || !showCloneMode) {
setBranches([]);
return;
}
const loadBranches = async () => {
setIsLoadingBranches(true);
try {
const response = await listRepositoryBranches(projectId, selectedRepo);
setBranches(response.branches);
if (response.default_branch) {
setBranch(response.default_branch);
setBaseBranch(response.default_branch);
}
} catch {
// ignore
} finally {
setIsLoadingBranches(false);
}
};
void loadBranches();
}, [selectedRepo, selectedProject, fixedProjectId, showCloneMode]);
// Filter repositories by selected project
const availableRepos = selectedProject
? repositories.filter((r) => r.project_id === selectedProject)
: [];
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
const projectId = fixedProjectId || selectedProject;
const repoId = fixedRepoId || selectedRepo;
if (!projectId || !repoId || !selectedToolType) {
setError("Project, repository, and tool type are required");
return;
}
if (showCloneMode && cloneMode === "clone") {
const repo = repositories.find((r) => r.id === repoId);
if (!repo?.ssh_key_id) {
setError("Repository must have an SSH key assigned for clone mode");
return;
}
}
setStatus("creating");
setProgress("Creating instance...");
try {
const instance = await createInstance(
projectId,
repoId,
selectedToolType,
displayName || undefined,
showCloneMode ? cloneMode : undefined,
showCloneMode && cloneMode === "clone"
? isCreatingNewBranch
? baseBranch
: branch
: undefined,
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
? newBranchName
: undefined,
selectedConfigProfile || undefined
);
setProgress("Starting container...");
await startInstance(projectId, repoId, instance.id);
// Reset form
if (!fixedProjectId) setSelectedProject("");
if (!fixedRepoId) setSelectedRepo("");
setSelectedToolType("");
setDisplayName("");
setCloneMode("mount");
setBranch("main");
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
setBranches([]);
setStatus("idle");
onSuccess?.(instance);
} catch {
setStatus("error");
setError("Failed to create session");
setProgress("");
}
};
const isSubmitting = status === "creating";
// Determine which steps are active/unlocked
const hasProject = !!(fixedProjectId || selectedProject);
const hasRepo = !!(fixedRepoId || selectedRepo);
const hasToolType = !!selectedToolType;
const renderStep = (
label: string,
number: number,
isActive: boolean,
isComplete: boolean,
children: React.ReactNode
) => {
const stepClass = `workflow-step ${isActive ? "active" : ""} ${isComplete ? "complete" : ""}`;
return (
<div className={stepClass}>
<div className="workflow-step-header">
<span className="workflow-step-number">{number}</span>
<span className="workflow-step-label">{label}</span>
</div>
<div className="workflow-step-content">
{children}
</div>
</div>
);
};
return (
<div className={`create-session-form-wrapper ${className}`}>
{isSubmitting && (
<div className="loading-overlay">
<div className="loading-content">
<Icon name="loading" size="lg" />
<p>{progress || "Creating session..."}</p>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="stack create-session-form workflow-form">
{/* Step 1: Project */}
{renderStep("Select Project", 1, true, hasProject,
fixedProjectId && showFixedFields ? (
<label className="form-field">
<input
type="text"
value={projectName || projects.find((p) => p.id === fixedProjectId)?.name || ""}
disabled
readOnly
/>
</label>
) : (
<label className="form-field">
<select
value={selectedProject}
onChange={(e) => {
const value = e.target.value;
setSelectedProject(value);
setSelectedRepo("");
setSelectedToolType("");
setCloneMode("mount");
setIsCreatingNewBranch(false);
onProjectChange?.(value);
}}
disabled={isSubmitting}
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
)
)}
{/* Step 2: Repository */}
{hasProject && renderStep("Select Repository", 2, true, hasRepo,
fixedRepoId && showFixedFields ? (
<label className="form-field">
<input
type="text"
value={repoName || repositories.find((r) => r.id === fixedRepoId)?.name || ""}
disabled
readOnly
/>
</label>
) : (
<label className="form-field">
<select
value={selectedRepo}
onChange={(e) => {
setSelectedRepo(e.target.value);
setSelectedToolType("");
setCloneMode("mount");
setIsCreatingNewBranch(false);
}}
disabled={!hasProject || isSubmitting}
>
<option value="">Select repository...</option>
{availableRepos.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
)
)}
{/* Step 3: Tool Type */}
{hasRepo && renderStep("Select Tool", 3, true, hasToolType,
<label className="form-field">
<select
value={selectedToolType}
onChange={(e) => {
setSelectedToolType(e.target.value);
setCloneMode("mount");
setIsCreatingNewBranch(false);
}}
disabled={!hasRepo || isSubmitting}
>
<option value="">Select tool...</option>
{toolTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.display_name}
</option>
))}
</select>
</label>
)}
{/* Step 4: Config Profile */}
{hasToolType && renderStep("Config Profile (optional)", 4, true, false,
<label className="form-field">
<select
value={selectedConfigProfile}
onChange={(e) => setSelectedConfigProfile(e.target.value)}
disabled={!hasToolType || isSubmitting}
>
<option value="">No profile (use tool defaults)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.is_default ? "(default)" : ""}
</option>
))}
</select>
</label>
)}
{/* Step 5: Clone Mode & Branch */}
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
<div className="form-row">
<label className="form-field">
<div className="radio-group">
<label className="radio-label">
<input
type="radio"
name="cloneMode"
value="mount"
checked={cloneMode === "mount"}
onChange={(e) => {
setCloneMode(e.target.value as "mount" | "clone");
setIsCreatingNewBranch(false);
}}
disabled={isSubmitting}
/>
Mount (live sync)
</label>
<label className="radio-label">
<input
type="radio"
name="cloneMode"
value="clone"
checked={cloneMode === "clone"}
onChange={(e) => {
setCloneMode(e.target.value as "mount" | "clone");
setIsCreatingNewBranch(false);
}}
disabled={isSubmitting}
/>
Clone fresh copy
</label>
</div>
</label>
{cloneMode === "clone" && (
<>
<label className="form-field">
Branch
{isLoadingBranches ? (
<span className="muted">Loading branches...</span>
) : (
<select
value={isCreatingNewBranch ? "__new__" : branch}
onChange={(e) => {
const value = e.target.value;
if (value === "__new__") {
setIsCreatingNewBranch(true);
setNewBranchName("");
} else {
setIsCreatingNewBranch(false);
setBranch(value);
setBaseBranch(value);
}
}}
disabled={isSubmitting}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
<option value="__new__">Create new branch...</option>
</select>
)}
</label>
{isCreatingNewBranch && (
<>
<label className="form-field">
New Branch Name
<input
type="text"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="feature/my-new-branch"
required
disabled={isSubmitting}
/>
</label>
<label className="form-field">
Base Branch
<select
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
disabled={isSubmitting}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
</select>
</label>
</>
)}
{selectedRepo && (
<div className="form-field ssh-key-info">
{(() => {
const repo = repositories.find((r) => r.id === selectedRepo);
if (!repo) return null;
if (repo.ssh_key_id) {
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
return (
<span className="success-text">
SSH key: {key?.name || "Assigned"}
</span>
);
}
return (
<span className="warning-text">
No SSH key assigned to this repository. Clone mode requires an SSH key.
</span>
);
})()}
</div>
)}
</>
)}
</div>
)}
{/* Step 6: Display Name */}
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
<label className="form-field">
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
disabled={isSubmitting}
/>
</label>
)}
{/* Error & Submit */}
{error && <p className="error-text">{error}</p>}
{hasToolType && (
<div className="form-actions">
{onCancel && (
<button
className="secondary-button"
type="button"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</button>
)}
<button
className="primary-button"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Icon name="loading" size="sm" />
Creating...
</>
) : (
<>
<Icon name="add" size="sm" />
{submitLabel}
</>
)}
</button>
</div>
)}
</form>
</div>
);
};
+9 -1
View File
@@ -18,6 +18,7 @@ interface GitToolbarProps {
currentBranch: string; currentBranch: string;
branches: string[]; branches: string[];
hasRemote: boolean; hasRemote: boolean;
isMirror: boolean;
onBranchChange: (branch: string) => void; onBranchChange: (branch: string) => void;
onRefresh: () => void; onRefresh: () => void;
} }
@@ -28,6 +29,7 @@ export const GitToolbar = ({
currentBranch, currentBranch,
branches, branches,
hasRemote, hasRemote,
isMirror,
onBranchChange, onBranchChange,
onRefresh, onRefresh,
}: GitToolbarProps) => { }: GitToolbarProps) => {
@@ -136,7 +138,13 @@ export const GitToolbar = ({
return ( return (
<div className="git-toolbar"> <div className="git-toolbar">
{error && <div className="toolbar-error">{error}</div>} {error && <div className="toolbar-error">{error}</div>}
{isMirror && (
<div className="warning-message">
<Icon name="warning" size="sm" /> This repository is a bare mirror.
Editing, committing, pulling, and merging are not available.
Delete and recreate it to enable full workspace features.
</div>
)}
<div className="toolbar-row"> <div className="toolbar-row">
<div className="toolbar-group"> <div className="toolbar-group">
<select <select
+150 -79
View File
@@ -4,7 +4,6 @@ import { Icon } from "./icon";
import type { ToolInstance } from "../api/sessions"; import type { ToolInstance } from "../api/sessions";
import { import {
checkInstanceHealth, checkInstanceHealth,
createInstance,
deleteInstance, deleteInstance,
listInstances, listInstances,
recreateInstanceTunnel, recreateInstanceTunnel,
@@ -13,30 +12,37 @@ import {
stopInstance, stopInstance,
} from "../api/sessions"; } from "../api/sessions";
import type { ToolType } from "../api/tool_types"; import type { ToolType } from "../api/tool_types";
import { CreateSessionForm } from "./create-session-form";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps { interface InstanceListProps {
projectId: string; projectId: string;
repoId: string; repoId: string;
projectName?: string;
repoName?: string;
toolTypes: ToolType[]; toolTypes: ToolType[];
} }
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => { export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTypes }: InstanceListProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]); const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Stop confirmation // Stop confirmation
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null); const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
// Health check state // Health check state
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({}); const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
// Config profile selection for start/restart
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<string | null>(null);
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
const loadInstances = useCallback(async () => { const loadInstances = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -83,23 +89,25 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
return () => clearInterval(interval); return () => clearInterval(interval);
}, [instances, projectId, repoId]); }, [instances, projectId, repoId]);
const handleCreate = async () => { const handleCreateSuccess = async () => {
if (!selectedToolType) return; setShowCreate(false);
setError(null); await loadInstances();
try {
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
setShowCreate(false);
setSelectedToolType("");
setDisplayName("");
await loadInstances();
} catch {
setError("Failed to create instance");
}
}; };
const handleStart = async (instanceId: string) => { const loadConfigProfiles = useCallback(async (toolTypeId: string) => {
try { try {
await startInstance(projectId, repoId, instanceId); const profiles = await listConfigProfiles(projectId, toolTypeId);
setConfigProfiles(profiles);
} catch {
// ignore
}
}, [projectId]);
const handleStart = async (instanceId: string, configProfileId?: string) => {
try {
await startInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
await loadInstances(); await loadInstances();
} catch { } catch {
setError("Failed to start instance"); setError("Failed to start instance");
@@ -116,9 +124,11 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
} }
}; };
const handleRestart = async (instanceId: string) => { const handleRestart = async (instanceId: string, configProfileId?: string) => {
try { try {
await restartInstance(projectId, repoId, instanceId); await restartInstance(projectId, repoId, instanceId, configProfileId);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
await loadInstances(); await loadInstances();
} catch { } catch {
setError("Failed to restart instance"); setError("Failed to restart instance");
@@ -208,6 +218,13 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
</span> </span>
)} )}
</div> </div>
{instance.selected_config_profile_id && (
<div className="instance-profile">
<span className="badge">
Profile: {configProfiles.find((p) => p.id === instance.selected_config_profile_id)?.name || instance.selected_config_profile_id}
</span>
</div>
)}
</div> </div>
<div className="instance-actions"> <div className="instance-actions">
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && ( {instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
@@ -245,14 +262,57 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
</button> </button>
)} )}
{instance.status !== "running" && ( {instance.status !== "running" && (
<button <>
className="secondary-button small" {profileSelectInstanceId === instance.id ? (
onClick={() => void handleStart(instance.id)} <div className="inline-profile-select">
type="button" <select
> value={selectedProfileForAction}
<Icon name="play" size="sm" /> onChange={(e) => setSelectedProfileForAction(e.target.value)}
Start >
</button> <option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
className="primary-button small"
onClick={() => void handleStart(instance.id, selectedProfileForAction || undefined)}
type="button"
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => {
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(instance.selected_config_profile_id || "");
}}
type="button"
>
<Icon name="play" size="sm" />
Start
</button>
)}
</>
)} )}
{instance.status === "running" && ( {instance.status === "running" && (
<> <>
@@ -283,13 +343,54 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
<Icon name="stop" size="sm" /> <Icon name="stop" size="sm" />
</button> </button>
)} )}
<button {profileSelectInstanceId === instance.id ? (
className="ghost-button small" <div className="inline-profile-select">
onClick={() => void handleRestart(instance.id)} <select
type="button" value={selectedProfileForAction}
> onChange={(e) => setSelectedProfileForAction(e.target.value)}
<Icon name="refresh" size="sm" /> >
</button> <option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
className="primary-button small"
onClick={() => void handleRestart(instance.id, selectedProfileForAction || undefined)}
type="button"
>
<Icon name="refresh" size="sm" />
Restart
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => {
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(instance.selected_config_profile_id || "");
}}
type="button"
>
<Icon name="refresh" size="sm" />
</button>
)}
</> </>
)} )}
<button <button
@@ -309,48 +410,18 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
<div className="dialog-overlay" role="dialog" aria-modal="true"> <div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog"> <div className="dialog">
<h2>Launch Tool</h2> <h2>Launch Tool</h2>
<div className="stack"> <CreateSessionForm
<label className="form-field"> projects={[]}
Tool Type repositories={[]}
<select toolTypes={toolTypes}
value={selectedToolType} fixedProjectId={projectId}
onChange={(e) => setSelectedToolType(e.target.value)} fixedRepoId={repoId}
> projectName={projectName}
<option value="">Select a tool...</option> repoName={repoName}
{toolTypes.map((tool) => ( onSuccess={handleCreateSuccess}
<option key={tool.id} value={tool.id}> onCancel={() => setShowCreate(false)}
{tool.display_name} submitLabel="Launch"
</option> />
))}
</select>
</label>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
<div className="dialog-actions">
<button
className="secondary-button"
onClick={() => setShowCreate(false)}
type="button"
>
Cancel
</button>
<button
className="primary-button"
onClick={() => void handleCreate()}
disabled={!selectedToolType}
type="button"
>
Launch
</button>
</div>
</div>
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,94 @@
import React from "react";
import { Icon } from "./icon";
interface MobileTerminalHeaderProps {
instanceName?: string;
onBack?: () => void;
onMenuToggle?: () => void;
onClose?: () => void;
onFontSizeChange?: (delta: number) => void;
isVisible: boolean;
connectionStatus?: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
instanceName,
onBack,
onMenuToggle,
onClose,
onFontSizeChange,
isVisible,
connectionStatus = "connecting",
}) => {
return (
<div
className={`mobile-terminal-header ${isVisible ? "visible" : "hidden"}`}
>
<div className="mobile-terminal-header-left">
{onBack && (
<button
className="mobile-terminal-header-button"
onClick={onBack}
type="button"
aria-label="Go back"
>
<Icon name="arrow-left" size="sm" />
</button>
)}
{onMenuToggle && (
<button
className="mobile-terminal-header-button"
onClick={onMenuToggle}
type="button"
aria-label="Toggle menu"
>
<Icon name="menu" size="sm" />
</button>
)}
</div>
<div className="mobile-terminal-header-center">
<span className="mobile-terminal-header-title">
{instanceName || "Terminal"}
</span>
<span
className={`mobile-terminal-header-status ${connectionStatus}`}
aria-label={`Connection status: ${connectionStatus}`}
/>
</div>
<div className="mobile-terminal-header-right">
{onFontSizeChange && (
<>
<button
className="mobile-terminal-header-button"
onClick={() => onFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button
className="mobile-terminal-header-button"
onClick={() => onFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
</>
)}
{onClose && (
<button
className="mobile-terminal-header-button"
onClick={onClose}
type="button"
aria-label="Close terminal"
>
<Icon name="close" size="sm" />
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,116 @@
import React, { useState, useCallback } from "react";
import { TerminalComponent } from "./terminal";
import { MobileTerminalHeader } from "./mobile-terminal-header";
import { SpecialKeysStrip } from "./special-keys-strip";
import { SpecialKeysPanel } from "./special-keys-panel";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useAutoHide } from "../hooks/use-auto-hide";
import type { ModifierKey } from "../hooks/use-special-keys";
interface MobileTerminalWrapperProps {
instanceId: string;
instanceName?: string;
onClose?: () => void;
onBack?: () => void;
onMenuToggle?: () => void;
}
export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
instanceId,
instanceName,
onClose,
onBack,
onMenuToggle,
}) => {
const isMobile = useMobileViewport();
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [showPanel, setShowPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
const [terminalRef, setTerminalRef] = useState<{
sendData: (data: string) => void;
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
focusInput: () => void;
changeFontSize: (delta: number) => void;
} | null>(null);
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
const handleTerminalTap = useCallback(() => {
headerAutoHide.toggle();
}, [headerAutoHide]);
const handleTerminalReady = useCallback(
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
},
[]
);
const handleSendKey = useCallback(
(data: string) => {
terminalRef?.sendData(data);
},
[terminalRef]
);
if (!isMobile) {
return (
<TerminalComponent
instanceId={instanceId}
onClose={onClose}
isMobile={false}
/>
);
}
return (
<div className="mobile-terminal-wrapper">
<MobileTerminalHeader
instanceName={instanceName}
onBack={onBack}
onMenuToggle={onMenuToggle}
onClose={onClose}
onFontSizeChange={(delta) => terminalRef?.changeFontSize(delta)}
isVisible={headerAutoHide.isVisible}
connectionStatus={terminalRef?.connectionStatus}
/>
<div
className="mobile-terminal-content"
style={{
paddingBottom: isKeyboardOpen ? keyboardHeight : 0,
}}
onClick={handleTerminalTap}
>
<TerminalComponent
instanceId={instanceId}
onClose={onClose}
isMobile={true}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
onTerminalReady={handleTerminalReady}
/>
</div>
<SpecialKeysStrip
onSend={handleSendKey}
isVisible={!showPanel}
onMoreClick={() => setShowPanel(true)}
onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
<SpecialKeysPanel
onSend={handleSendKey}
isOpen={showPanel}
onClose={() => setShowPanel(false)}
onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
</div>
);
};
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories"; import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { Icon } from "./icon"; import { Icon } from "./icon";
type CreateMode = "clone" | "blank"; type CreateMode = "clone" | "blank";
@@ -20,12 +21,14 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
const [owner, setOwner] = useState(""); const [owner, setOwner] = useState("");
const [repoName, setRepoName] = useState(""); const [repoName, setRepoName] = useState("");
const [advancedUrl, setAdvancedUrl] = useState(""); const [advancedUrl, setAdvancedUrl] = useState("");
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false); const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [urlValidation, setUrlValidation] = useState<{ const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus; status: UrlValidationStatus;
result: URLParseResult | null; result: URLParseResult | null;
}>({ status: "idle", result: null }); }>({ status: "idle", result: null });
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
@@ -35,6 +38,19 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
} }
}, [open]); }, [open]);
useEffect(() => {
if (!open) return;
const loadKeys = async () => {
try {
const data = await listSSHKeys();
setSshKeys(data);
} catch {
// ignore
}
};
void loadKeys();
}, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (!useAdvancedUrl) { if (!useAdvancedUrl) {
@@ -81,9 +97,10 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
setOwner(""); setOwner("");
setRepoName(""); setRepoName("");
setAdvancedUrl(""); setAdvancedUrl("");
setUseAdvancedUrl(false); setUseAdvancedUrl(true);
setFormError(null); setFormError(null);
setUrlValidation({ status: "idle", result: null }); setUrlValidation({ status: "idle", result: null });
setSelectedSshKey("");
}; };
const handleClose = () => { const handleClose = () => {
@@ -120,6 +137,9 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
} }
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`; input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
} }
if (selectedSshKey) {
input.ssh_key_id = selectedSshKey;
}
} }
await createRepository(projectId, input); await createRepository(projectId, input);
@@ -212,6 +232,20 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
placeholder="repo-name" placeholder="repo-name"
/> />
</label> </label>
<label className="form-field">
SSH Key
<select
value={selectedSshKey}
onChange={(event) => setSelectedSshKey(event.target.value)}
>
<option value="">Select SSH key (optional)...</option>
{sshKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</label>
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p> <p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
<button <button
type="button" type="button"
@@ -223,45 +257,61 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
</> </>
)} )}
{createMode === "clone" && useAdvancedUrl && ( {createMode === "clone" && useAdvancedUrl && (
<label className="form-field"> <>
Remote URL <label className="form-field">
<input Remote URL
type="text" <input
value={advancedUrl} type="text"
onChange={(event) => setAdvancedUrl(event.target.value)} value={advancedUrl}
placeholder="https://github.com/user/repo.git" onChange={(event) => setAdvancedUrl(event.target.value)}
className={getUrlInputClass()} placeholder="https://github.com/user/repo.git"
/> className={getUrlInputClass()}
{urlValidation.status === "validating" && ( />
<span className="validation-status validating">Validating...</span> {urlValidation.status === "validating" && (
)} <span className="validation-status validating">Validating...</span>
{urlValidation.status === "valid" && ( )}
<span className="validation-status valid"> {urlValidation.status === "valid" && (
<Icon name="success" size="sm" /> Valid git URL <span className="validation-status valid">
</span> <Icon name="success" size="sm" /> Valid git URL
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
<Icon name="warning" size="sm" /> This looks like a browser URL
</span> </span>
<div className="suggestion-actions"> )}
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span> {urlValidation.status === "needs-parsing" && urlValidation.result && (
<button <div className="url-suggestion">
type="button" <span className="validation-status warning">
className="secondary-button small" <Icon name="warning" size="sm" /> This looks like a browser URL
onClick={handleUseSuggestedUrl} </span>
> <div className="suggestion-actions">
Use Suggested <span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
</button> <button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
</div>
</div> </div>
</div> )}
)} {urlValidation.status === "invalid" && (
{urlValidation.status === "invalid" && ( <span className="validation-status invalid">
<span className="validation-status invalid"> <Icon name="error" size="sm" /> Invalid URL
<Icon name="error" size="sm" /> Invalid URL </span>
</span> )}
)} </label>
<label className="form-field">
SSH Key
<select
value={selectedSshKey}
onChange={(event) => setSelectedSshKey(event.target.value)}
>
<option value="">Select SSH key (optional)...</option>
{sshKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</label>
<button <button
type="button" type="button"
className="secondary-button small" className="secondary-button small"
@@ -269,7 +319,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea
> >
Use owner/repo instead Use owner/repo instead
</button> </button>
</label> </>
)} )}
{formError && ( {formError && (
<div className="error-message"> <div className="error-message">
+235
View File
@@ -0,0 +1,235 @@
import { useState } from "react";
import type { Session } from "../api/sessions";
import { Icon } from "./icon";
export interface SessionCardProps {
session: Session;
onOpen?: (session: Session) => void;
onStart?: (session: Session) => void;
onStop?: (session: Session) => void;
onDelete?: (session: Session) => void;
onRecreateTunnel?: (session: Session) => void;
isBusy?: boolean;
tunnelHealth?: {
healthy: boolean;
container_status: string;
container_health: string | null;
tunnel_status: string;
tunnel_status_code: number | null;
probe_status: string;
last_probe_output: string | null;
error: string | null;
} | null;
}
const statusConfig: Record<string, { color: string; label: string }> = {
running: { color: "green", label: "Running" },
building: { color: "yellow", label: "Building" },
starting: { color: "yellow", label: "Starting" },
probing: { color: "yellow", label: "Probing" },
pending: { color: "yellow", label: "Pending" },
stopped: { color: "gray", label: "Stopped" },
error: { color: "red", label: "Error" },
unhealthy: { color: "orange", label: "Unhealthy" },
};
export function SessionCard({
session,
onOpen,
onStart,
onStop,
onDelete,
onRecreateTunnel,
isBusy = false,
tunnelHealth = null,
}: SessionCardProps) {
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const status = statusConfig[session.status] || { color: "gray", label: session.status };
const isTerminalOnly = session.tool_type_interfaces?.includes("terminal") && !session.tool_type_interfaces?.includes("web");
const hasTunnelError = !isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
const hasAppError = !isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
const handleStop = () => {
if (showStopConfirm) {
setShowStopConfirm(false);
onStop?.(session);
} else {
setShowStopConfirm(true);
}
};
const handleDelete = () => {
if (showDeleteConfirm) {
setShowDeleteConfirm(false);
onDelete?.(session);
} else {
setShowDeleteConfirm(true);
}
};
const handleCancelStop = () => setShowStopConfirm(false);
const handleCancelDelete = () => setShowDeleteConfirm(false);
const isActive = ["running", "building", "starting", "probing", "pending", "unhealthy"].includes(session.status);
return (
<article className="card session-card">
<div className="session-card-content">
<div className="session-card-header">
<div className="session-card-title">
<h4>{session.display_name}</h4>
<div className="session-card-status-badges">
<span className={`status-badge ${status.color}`}>{status.label}</span>
{hasTunnelError && (
<span className="status-badge error">Tunnel Error</span>
)}
{hasAppError && (
<span className="status-badge warning">App Error {tunnelHealth?.tunnel_status_code}</span>
)}
</div>
</div>
<p className="muted session-card-meta">
{session.tool_type_name}
{session.project_name && ` · ${session.project_name}`}
{session.repository_name && ` · ${session.repository_name}`}
</p>
{session.clone_mode && (
<p className="muted session-card-meta">
<Icon name="branch" size="sm" />
{session.clone_mode === "clone"
? `Clone${session.branch ? ` (${session.branch})` : ""}`
: "Mount"}
</p>
)}
{session.url && (
<p className="session-card-url">
<a href={session.url} target="_blank" rel="noopener noreferrer">
{session.url}
</a>
</p>
)}
{session.created_at && (
<p className="muted session-card-meta">
Created: {new Date(session.created_at).toLocaleDateString()}
</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>
<button
className="danger-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
Stop
</button>
<button
className="ghost-button small"
onClick={handleCancelStop}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={handleStop}
type="button"
disabled={isBusy}
>
<Icon name="stop" size="sm" />
<span className="action-label">Stop</span>
</button>
)}
</>
)}
{!isActive && onStart && (
<button
className="secondary-button small"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
<span className="action-label">Start</span>
</button>
)}
{showDeleteConfirm ? (
<div className="confirm-inline">
<span className="confirm-text">Delete?</span>
<button
className="danger-button small"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
Delete
</button>
<button
className="ghost-button small"
onClick={handleCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={handleDelete}
type="button"
disabled={isBusy}
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</article>
);
}
+125
View File
@@ -0,0 +1,125 @@
import type { Session } from "../api/sessions";
import { SessionCard } from "./session-card";
import type { InstanceHealth } from "../api/sessions";
export interface SessionListProps {
sessions: Session[];
onOpen?: (session: Session) => void;
onStart?: (session: Session) => void;
onStop?: (session: Session) => void;
onDelete?: (session: Session) => void;
onRecreateTunnel?: (session: Session) => void;
actionBusyId?: string | null;
tunnelHealth?: Record<string, InstanceHealth>;
showGrouping?: boolean;
activeTitle?: string;
recentTitle?: string;
maxRecent?: number;
emptyMessage?: string;
}
const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"];
const recentStatuses = ["stopped", "error"];
export function SessionList({
sessions,
onOpen,
onStart,
onStop,
onDelete,
onRecreateTunnel,
actionBusyId = null,
tunnelHealth = {},
showGrouping = true,
activeTitle = "Active Sessions",
recentTitle = "Recent Sessions",
maxRecent = 5,
emptyMessage = "No sessions",
}: SessionListProps) {
const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status));
const recentSessions = sessions
.filter((s) => recentStatuses.includes(s.status))
.slice(0, maxRecent);
if (!showGrouping) {
return (
<div className="sessions-grid">
{sessions.length === 0 ? (
<p className="muted">{emptyMessage}</p>
) : (
sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))
)}
</div>
);
}
return (
<div className="session-list">
{/* Active Sessions */}
<div className="session-group">
<div className="session-group-header">
<h3>{activeTitle}</h3>
{activeSessions.length > 0 && (
<span className="badge">{activeSessions.length}</span>
)}
</div>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions</p>
) : (
<div className="sessions-grid">
{activeSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))}
</div>
)}
</div>
{/* Recent Sessions */}
{recentSessions.length > 0 && (
<div className="session-group">
<div className="session-group-header">
<h3>{recentTitle}</h3>
<span className="badge">{recentSessions.length}</span>
</div>
<div className="sessions-grid">
{recentSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onOpen={onOpen}
onStart={onStart}
onStop={onStop}
onDelete={onDelete}
onRecreateTunnel={onRecreateTunnel}
isBusy={actionBusyId === session.id}
tunnelHealth={tunnelHealth[session.id] || null}
/>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,114 @@
import React from "react";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
interface SpecialKeysPanelProps {
onSend: (data: string) => void;
isOpen: boolean;
onClose: () => void;
onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
}
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "home", label: "Home" },
{ key: "end", label: "End" },
{ key: "pageup", label: "PgUp" },
{ key: "pagedown", label: "PgDn" },
{ key: "ctrlc", label: "Ctrl+C" },
{ key: "ctrld", label: "Ctrl+D" },
{ key: "ctrlz", label: "Ctrl+Z" },
];
const F_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "f1", label: "F1" },
{ key: "f2", label: "F2" },
{ key: "f3", label: "F3" },
{ key: "f4", label: "F4" },
{ key: "f5", label: "F5" },
{ key: "f6", label: "F6" },
{ key: "f7", label: "F7" },
{ key: "f8", label: "F8" },
{ key: "f9", label: "F9" },
{ key: "f10", label: "F10" },
{ key: "f11", label: "F11" },
{ key: "f12", label: "F12" },
];
export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
onSend,
isOpen,
onClose,
onKeepFocus,
activeModifier,
onModifierChange,
}) => {
if (!isOpen) return null;
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault();
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
onClose();
// Always refocus terminal after sending
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
const handleOverlayPointerDown = (e: React.PointerEvent) => {
e.preventDefault();
onModifierChange(null);
onClose();
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
return (
<div
className="special-keys-panel-overlay"
onPointerDown={handleOverlayPointerDown}
>
<div
className="special-keys-panel"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="special-keys-panel-section">
{EXPANDED_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
>
{label}
</button>
))}
</div>
<div className="special-keys-panel-divider" />
<div className="special-keys-panel-section">
{F_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
>
{label}
</button>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,96 @@
import React from "react";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey, KEY_SEQUENCES } from "../hooks/use-special-keys";
interface SpecialKeysStripProps {
onSend: (data: string) => void;
isVisible: boolean;
onMoreClick?: () => void;
onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
}
const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [
{ key: "escape", label: "Esc" },
{ key: "tab", label: "Tab" },
{ key: "ctrl", label: "Ctrl", isModifier: true },
{ key: "alt", label: "Alt", isModifier: true },
{ key: "up", label: "↑" },
{ key: "down", label: "↓" },
{ key: "left", label: "←" },
{ key: "right", label: "→" },
];
export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
onSend,
isVisible,
onMoreClick,
onKeepFocus,
activeModifier,
onModifierChange,
}) => {
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault();
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
onModifierChange(activeModifier === key ? null : key);
requestAnimationFrame(() => {
onKeepFocus?.();
});
return;
}
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
// Always refocus terminal after sending
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
const handleMorePointerDown = (e: React.PointerEvent) => {
e.preventDefault();
onMoreClick?.();
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
return (
<div className={`special-keys-strip ${isVisible ? "visible" : "hidden"}`}>
{PRIMARY_KEYS.map(({ key, label, isModifier }) => (
<button
key={key}
className={`special-key-button ${
isModifier && activeModifier === key ? "active-modifier" : ""
}`}
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
aria-label={`Send ${label}`}
aria-pressed={isModifier && activeModifier === key}
>
{label}
</button>
))}
{onMoreClick && (
<button
className="special-key-button special-key-more"
onPointerDown={handleMorePointerDown}
type="button"
tabIndex={-1}
aria-label="More special keys"
>
More
</button>
)}
</div>
);
};
+478 -82
View File
@@ -1,29 +1,180 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState, useCallback } from "react";
import { Terminal } from "xterm"; import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit"; import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links"; import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css"; import "xterm/css/xterm.css";
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
interface TerminalProps { interface TerminalProps {
instanceId: string; instanceId: string;
onClose?: () => void; onClose?: () => void;
isMobile?: boolean;
activeModifier?: ModifierKey | null;
onModifierChange?: (modifier: ModifierKey | null) => void;
onTerminalReady?: (
sendData: (data: string) => void,
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void
) => void;
} }
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => { const FONT_SIZE_KEY = "terminal-font-size";
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
const RECONNECT_ATTEMPTS = 3;
const RECONNECT_DELAY_BASE = 1000;
export const TerminalComponent: React.FC<TerminalProps> = ({
instanceId,
onClose,
isMobile = false,
activeModifier,
onModifierChange,
onTerminalReady,
}) => {
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const hiddenInputRef = useRef<HTMLInputElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">( const termRef = useRef<Terminal | null>(null);
"connecting", const fitAddonRef = useRef<FitAddon | null>(null);
); const reconnectAttemptsRef = useRef(0);
const onTerminalReadyRef = useRef(onTerminalReady);
onTerminalReadyRef.current = onTerminalReady;
const [status, setStatus] = useState<
"connecting" | "connected" | "disconnected" | "error" | "resetting"
>("connecting");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => {
if (typeof window === "undefined") return isMobile ? 16 : 14;
const stored = localStorage.getItem(FONT_SIZE_KEY);
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
});
const lastPingRef = useRef<number>(0);
const heartbeatCheckRef = useRef<number | null>(null);
const calculateFontSize = useCallback(() => {
if (!isMobile) return fontSize;
const vw = window.innerWidth;
const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25));
return Math.round(calculated);
}, [isMobile, fontSize]);
const connectWebSocket = useCallback(() => {
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
reconnectAttemptsRef.current = 0;
lastPingRef.current = Date.now();
// Send current terminal size immediately on connect
if (termRef.current) {
const { cols, rows } = termRef.current;
// Send resize immediately on connect
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
// Start heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
}
heartbeatCheckRef.current = window.setInterval(() => {
const elapsed = Date.now() - lastPingRef.current;
if (elapsed > 60000) {
// No ping for 60 seconds, connection may be dead
console.warn("Terminal heartbeat timeout, reconnecting...");
ws.close(4000, "Heartbeat timeout");
}
}, 30000);
};
ws.onmessage = (event) => {
if (!termRef.current) return;
if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer);
termRef.current?.write(data);
});
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status") {
if (msg.status === "connected") {
setStatus("connected");
setError(null);
} else if (msg.status === "resetting") {
setStatus("resetting");
}
} else if (msg.type === "ping") {
// Respond with pong and update last ping time
lastPingRef.current = Date.now();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
}
} catch {
termRef.current?.write(event.data);
}
}
};
ws.onclose = (event) => {
setStatus("disconnected");
// Clean up heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null;
}
if (event.code !== 1000 && event.code !== 4000) {
setError(`Connection closed (code: ${event.code})`);
// Attempt reconnection
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
reconnectAttemptsRef.current++;
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => {
if (document.visibilityState !== "hidden") {
connectWebSocket();
}
}, delay);
}
} else if (event.code === 4000) {
// Server closed old connection for concurrent connection - don't reconnect
// The new connection is already established
}
};
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
return ws;
}, [instanceId]);
useEffect(() => { useEffect(() => {
if (!terminalRef.current) return; if (!terminalRef.current) return;
// Initialize terminal // Initialize terminal
const currentFontSize = calculateFontSize();
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 14, fontSize: currentFontSize,
fontFamily: 'Menlo, Monaco, "Courier New", monospace', fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: { theme: {
background: "#1e1e1e", background: "#1e1e1e",
@@ -49,110 +200,355 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
}, },
}); });
termRef.current = term;
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon()); term.loadAddon(new WebLinksAddon());
term.open(terminalRef.current); const container = terminalRef.current;
fitAddon.fit(); let ws: WebSocket;
// Build WebSocket URL // Open xterm immediately
const apiUrl = import.meta.env.VITE_API_BASE_URL || ""; term.open(container);
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; ws = connectWebSocket();
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
// Connect WebSocket // Fit terminal and notify backend
const ws = new WebSocket(wsUrl); const fitTerminal = () => {
wsRef.current = ws; if (!fitAddonRef.current || !termRef.current) return;
const oldCols = termRef.current.cols;
ws.onopen = () => { const oldRows = termRef.current.rows;
setStatus("connected"); fitAddonRef.current.fit();
setError(null); const { cols, rows } = termRef.current;
}; // Force refresh if dimensions changed
if (cols !== oldCols || rows !== oldRows) {
ws.onmessage = (event) => { termRef.current.refresh(0, rows - 1);
if (event.data instanceof Blob) { }
event.data.arrayBuffer().then((buffer) => { if (ws.readyState === WebSocket.OPEN) {
const data = new Uint8Array(buffer); ws.send(JSON.stringify({ type: "resize", cols, rows }));
term.write(data);
});
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status" && msg.status === "connected") {
setStatus("connected");
}
} catch {
term.write(event.data);
}
} }
}; };
ws.onclose = (event) => { // Initial fit after layout settles
setStatus("disconnected"); requestAnimationFrame(() => {
if (event.code !== 1000) { requestAnimationFrame(() => {
setError(`Connection closed (code: ${event.code})`); fitTerminal();
} });
}; });
ws.onerror = () => { // Refit after font load (metrics may change)
setStatus("error"); document.fonts.ready.then(() => {
setError("WebSocket error"); requestAnimationFrame(() => fitTerminal());
}; });
// Handle terminal input // Handle terminal input
term.onData((data) => { term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState !== WebSocket.OPEN) return;
ws.send(data);
// Apply active modifier to single-character input
const modifier = activeModifierRef.current;
if (modifier && data.length === 1) {
const modified = applyModifierToChar(data, modifier);
if (modified) {
ws.send(modified);
onModifierChange?.(null);
return;
}
} }
ws.send(data);
}); });
// Handle resize // Handle container resize with ResizeObserver for accurate dimension tracking
const handleResize = () => { let resizeTimeout: ReturnType<typeof setTimeout>;
fitAddon.fit(); let lastWidth = 0;
const { cols, rows } = term; let lastHeight = 0;
if (ws.readyState === WebSocket.OPEN) { const resizeObserver = new ResizeObserver((entries) => {
ws.send( const entry = entries[0];
JSON.stringify({ if (!entry) return;
type: "resize",
cols, const { width, height } = entry.contentRect;
rows, // Only trigger if dimensions actually changed
}), if (width === lastWidth && height === lastHeight) return;
); lastWidth = width;
lastHeight = height;
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
requestAnimationFrame(() => {
if (!container.isConnected) return;
fitTerminal();
});
}, 50);
});
resizeObserver.observe(container);
// Window resize fallback (for viewport changes that don't affect container dimensions)
let windowResizeTimeout: ReturnType<typeof setTimeout>;
const handleWindowResize = () => {
clearTimeout(windowResizeTimeout);
windowResizeTimeout = setTimeout(() => {
requestAnimationFrame(() => fitTerminal());
}, 250);
};
window.addEventListener("resize", handleWindowResize);
// Refit after mobile header auto-hides (3s delay + 0.3s transition)
const headerHideTimeout = setTimeout(() => {
fitTerminal();
}, 4000);
// Notify parent about terminal readiness
if (onTerminalReadyRef.current) {
const sendData = (data: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
};
const focusInput = () => {
termRef.current?.focus();
};
const changeFontSize = (delta: number) => {
handleFontSizeChange(delta);
};
onTerminalReadyRef.current(sendData, status, focusInput, changeFontSize);
}
// Visibility API for reconnection
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) {
reconnectAttemptsRef.current = 0;
connectWebSocket();
} }
}; };
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("resize", handleResize);
// Initial resize
setTimeout(handleResize, 100);
return () => { return () => {
window.removeEventListener("resize", handleResize); clearTimeout(resizeTimeout);
ws.close(); clearTimeout(windowResizeTimeout);
clearTimeout(headerHideTimeout);
resizeObserver.disconnect();
window.removeEventListener("resize", handleWindowResize);
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (ws) {
ws.close();
}
term.dispose(); term.dispose();
}; };
}, [instanceId]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [instanceId, connectWebSocket]);
// Update parent about status changes
useEffect(() => {
if (onTerminalReady && termRef.current) {
const sendData = (data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data);
}
};
const focusInput = () => {
termRef.current?.focus();
};
const changeFontSize = (delta: number) => {
handleFontSizeChange(delta);
};
onTerminalReady(sendData, status, focusInput, changeFontSize);
}
}, [status, onTerminalReady]);
const handleFontSizeChange = (delta: number) => {
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
setFontSize(newSize);
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
if (termRef.current && fitAddonRef.current) {
termRef.current.options.fontSize = newSize;
requestAnimationFrame(() => {
if (termRef.current && fitAddonRef.current) {
try {
fitAddonRef.current.fit();
const { cols, rows } = termRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: "resize",
cols,
rows,
})
);
}
} catch {
// Ignore fit errors during re-initialization
}
}
});
}
};
const handleCopy = async () => {
if (!termRef.current) return;
const selection = termRef.current.getSelection();
if (selection) {
try {
await navigator.clipboard.writeText(selection);
} catch {
// Fallback for older browsers
const textarea = document.createElement("textarea");
textarea.value = selection;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
}
};
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(text);
}
} catch {
// Clipboard API not available
}
};
// Focus terminal on mobile to keep keyboard open
const handleTerminalClick = () => {
if (isMobile && termRef.current) {
termRef.current.focus();
}
};
return ( return (
<div className="terminal-wrapper"> <div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
<div className="terminal-header"> <div className="terminal-header">
<div className="terminal-status"> <div className="terminal-header-left">
<span <div className="terminal-status">
className={`status-dot ${status}`} <span
aria-label={`Terminal status: ${status}`} className={`status-dot ${status}`}
/> aria-label={`Terminal status: ${status}`}
<span className="status-text">{status}</span> />
<span className="status-text">
{status === "resetting"
? "Resetting..."
: reconnectAttemptsRef.current > 0 && status !== "connected"
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
: status}
</span>
</div>
{isMobile && (
<>
<button
className="terminal-header-button"
onClick={handleCopy}
type="button"
aria-label="Copy selection"
>
Copy
</button>
<button
className="terminal-header-button"
onClick={handlePaste}
type="button"
aria-label="Paste from clipboard"
>
Paste
</button>
</>
)}
</div> </div>
{onClose && ( <div className="terminal-header-right">
<button className="terminal-close" onClick={onClose} type="button"> <button
Close className="terminal-header-button"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button> </button>
)} <button
className="terminal-header-button"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={() => setShowResetConfirm(true)}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div>
</div> </div>
{error && <div className="terminal-error">{error}</div>} {showResetConfirm && (
<div ref={terminalRef} className="terminal-container" /> <div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>Reset terminal? This will kill the current shell session and start fresh.</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={() => setShowResetConfirm(false)}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
setShowResetConfirm(false);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "reset" }));
}
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
{error && (
<div className="terminal-error">
{error}
{status === "error" && (
<button
className="terminal-reconnect"
onClick={() => {
reconnectAttemptsRef.current = 0;
connectWebSocket();
}}
type="button"
>
Reconnect
</button>
)}
</div>
)}
<div
ref={terminalRef}
className="terminal-container"
onClick={handleTerminalClick}
/>
{isMobile && (
<input
ref={hiddenInputRef}
type="text"
className="terminal-hidden-input"
aria-hidden="true"
/>
)}
</div> </div>
); );
}; };
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect, useCallback, useRef } from "react";
interface AutoHideOptions {
timeout?: number;
enabled?: boolean;
}
export function useAutoHide(options: AutoHideOptions = {}) {
const { timeout = 3000, enabled = true } = options;
const [isVisible, setIsVisible] = useState(true);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastInteractionRef = useRef(Date.now());
const show = useCallback(() => {
if (!enabled) return;
setIsVisible(true);
lastInteractionRef.current = Date.now();
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setIsVisible(false);
}, timeout);
}, [enabled, timeout]);
const hide = useCallback(() => {
if (!enabled) return;
setIsVisible(false);
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, [enabled]);
const toggle = useCallback(() => {
if (!enabled) return;
if (isVisible) {
hide();
} else {
show();
}
}, [enabled, isVisible, show, hide]);
useEffect(() => {
if (!enabled) {
setIsVisible(true);
return;
}
// Start the timer initially
show();
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [enabled, show]);
return {
isVisible,
show,
hide,
toggle,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { useState, useEffect } from "react";
const MOBILE_BREAKPOINT = 768;
export function useMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") return false;
return window.innerWidth < MOBILE_BREAKPOINT;
});
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return isMobile;
}
+140
View File
@@ -0,0 +1,140 @@
export type SpecialKey =
| "escape"
| "tab"
| "ctrl"
| "alt"
| "up"
| "down"
| "left"
| "right"
| "home"
| "end"
| "pageup"
| "pagedown"
| "ctrlc"
| "ctrld"
| "ctrlz"
| "f1"
| "f2"
| "f3"
| "f4"
| "f5"
| "f6"
| "f7"
| "f8"
| "f9"
| "f10"
| "f11"
| "f12";
export type ModifierKey = "ctrl" | "alt";
const KEY_SEQUENCES: Record<SpecialKey, string> = {
escape: "\x1B",
tab: "\t",
ctrl: "",
alt: "",
up: "\x1B[A",
down: "\x1B[B",
right: "\x1B[C",
left: "\x1B[D",
home: "\x1B[H",
end: "\x1B[F",
pageup: "\x1B[5~",
pagedown: "\x1B[6~",
ctrlc: "\x03",
ctrld: "\x04",
ctrlz: "\x1A",
f1: "\x1BOP",
f2: "\x1BOQ",
f3: "\x1BOR",
f4: "\x1BOS",
f5: "\x1B[15~",
f6: "\x1B[17~",
f7: "\x1B[18~",
f8: "\x1B[19~",
f9: "\x1B[20~",
f10: "\x1B[21~",
f11: "\x1B[23~",
f12: "\x1B[24~",
};
// Single character sequences with modifier prefixes
const MODIFIER_PREFIXES: Record<string, { ctrl: string; alt: string; ctrlAlt: string }> = {
// Letters
a: { ctrl: "\x01", alt: "\x1Ba", ctrlAlt: "\x1B\x01" },
b: { ctrl: "\x02", alt: "\x1Bb", ctrlAlt: "\x1B\x02" },
c: { ctrl: "\x03", alt: "\x1Bc", ctrlAlt: "\x1B\x03" },
d: { ctrl: "\x04", alt: "\x1Bd", ctrlAlt: "\x1B\x04" },
e: { ctrl: "\x05", alt: "\x1Be", ctrlAlt: "\x1B\x05" },
f: { ctrl: "\x06", alt: "\x1Bf", ctrlAlt: "\x1B\x06" },
g: { ctrl: "\x07", alt: "\x1Bg", ctrlAlt: "\x1B\x07" },
h: { ctrl: "\x08", alt: "\x1Bh", ctrlAlt: "\x1B\x08" },
i: { ctrl: "\x09", alt: "\x1Bi", ctrlAlt: "\x1B\x09" },
j: { ctrl: "\x0A", alt: "\x1Bj", ctrlAlt: "\x1B\x0A" },
k: { ctrl: "\x0B", alt: "\x1Bk", ctrlAlt: "\x1B\x0B" },
l: { ctrl: "\x0C", alt: "\x1Bl", ctrlAlt: "\x1B\x0C" },
m: { ctrl: "\x0D", alt: "\x1Bm", ctrlAlt: "\x1B\x0D" },
n: { ctrl: "\x0E", alt: "\x1Bn", ctrlAlt: "\x1B\x0E" },
o: { ctrl: "\x0F", alt: "\x1Bo", ctrlAlt: "\x1B\x0F" },
p: { ctrl: "\x10", alt: "\x1Bp", ctrlAlt: "\x1B\x10" },
q: { ctrl: "\x11", alt: "\x1Bq", ctrlAlt: "\x1B\x11" },
r: { ctrl: "\x12", alt: "\x1Br", ctrlAlt: "\x1B\x12" },
s: { ctrl: "\x13", alt: "\x1Bs", ctrlAlt: "\x1B\x13" },
t: { ctrl: "\x14", alt: "\x1Bt", ctrlAlt: "\x1B\x14" },
u: { ctrl: "\x15", alt: "\x1Bu", ctrlAlt: "\x1B\x15" },
v: { ctrl: "\x16", alt: "\x1Bv", ctrlAlt: "\x1B\x16" },
w: { ctrl: "\x17", alt: "\x1Bw", ctrlAlt: "\x1B\x17" },
x: { ctrl: "\x18", alt: "\x1Bx", ctrlAlt: "\x1B\x18" },
y: { ctrl: "\x19", alt: "\x1By", ctrlAlt: "\x1B\x19" },
z: { ctrl: "\x1A", alt: "\x1Bz", ctrlAlt: "\x1B\x1A" },
// Numbers
"0": { ctrl: "0", alt: "\x1B0", ctrlAlt: "\x1B0" },
"1": { ctrl: "1", alt: "\x1B1", ctrlAlt: "\x1B1" },
"2": { ctrl: "\x00", alt: "\x1B2", ctrlAlt: "\x1B\x00" },
"3": { ctrl: "\x1B", alt: "\x1B3", ctrlAlt: "\x1B\x1B" },
"4": { ctrl: "\x1C", alt: "\x1B4", ctrlAlt: "\x1B\x1C" },
"5": { ctrl: "\x1D", alt: "\x1B5", ctrlAlt: "\x1B\x1D" },
"6": { ctrl: "\x1E", alt: "\x1B6", ctrlAlt: "\x1B\x1E" },
"7": { ctrl: "\x1F", alt: "\x1B7", ctrlAlt: "\x1B\x1F" },
"8": { ctrl: "\x7F", alt: "\x1B8", ctrlAlt: "\x1B\x7F" },
"9": { ctrl: "9", alt: "\x1B9", ctrlAlt: "\x1B9" },
};
export function getSequenceWithModifier(
key: SpecialKey,
activeModifier: ModifierKey | null
): { sequence: string; clearModifier: boolean } | null {
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
return null; // Modifiers don't send anything themselves
}
const sequence = KEY_SEQUENCES[key];
if (!sequence) return null;
// Check if we have an active modifier and the key is a single character
if (activeModifier && sequence.length === 1) {
const char = sequence;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (mapping) {
return { sequence: mapping[activeModifier], clearModifier: true };
}
}
return { sequence, clearModifier: !!activeModifier };
}
export function applyModifierToChar(
char: string,
modifier: ModifierKey
): string | null {
if (char.length !== 1) return null;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (!mapping) return null;
return mapping[modifier];
}
export { KEY_SEQUENCES };
@@ -0,0 +1,68 @@
import { useState, useEffect, useCallback } from "react";
interface VirtualKeyboardState {
isOpen: boolean;
height: number;
viewportHeight: number;
}
export function useVirtualKeyboard() {
const [state, setState] = useState<VirtualKeyboardState>({
isOpen: false,
height: 0,
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
});
const updateKeyboardState = useCallback(() => {
const visualViewport = window.visualViewport;
const windowHeight = window.innerHeight;
if (visualViewport) {
const viewportHeight = visualViewport.height;
const keyboardHeight = windowHeight - viewportHeight;
const isOpen = keyboardHeight > 100; // Threshold to avoid false positives
setState({
isOpen,
height: keyboardHeight,
viewportHeight,
});
} else {
// Fallback: compare window height to a stored reference
// This is less reliable but works on older browsers
const currentHeight = windowHeight;
const isOpen = currentHeight < state.viewportHeight - 100;
setState((prev) => ({
isOpen,
height: isOpen ? prev.viewportHeight - currentHeight : 0,
viewportHeight: isOpen ? prev.viewportHeight : currentHeight,
}));
}
}, [state.viewportHeight]);
useEffect(() => {
const visualViewport = window.visualViewport;
if (visualViewport) {
visualViewport.addEventListener("resize", updateKeyboardState);
visualViewport.addEventListener("scroll", updateKeyboardState);
} else {
window.addEventListener("resize", updateKeyboardState);
}
// Initial check
updateKeyboardState();
return () => {
if (visualViewport) {
visualViewport.removeEventListener("resize", updateKeyboardState);
visualViewport.removeEventListener("scroll", updateKeyboardState);
} else {
window.removeEventListener("resize", updateKeyboardState);
}
};
}, [updateKeyboardState]);
return state;
}
+822
View File
@@ -0,0 +1,822 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../components/icon";
import {
createConfigProfile,
deleteConfigProfile,
listConfigProfiles,
previewConfigProfile,
updateConfigProfile,
type ConfigProfile,
type CreateConfigProfileRequest,
type ResolvedProfile,
} from "../api/config_profiles";
import { listProjects } from "../api/projects";
import type { Project } from "../types";
import { listToolTypes, type ToolType } from "../api/tool_types";
type Status = "loading" | "ready" | "error";
export const ConfigProfilesPage = () => {
const [status, setStatus] = useState<Status>("loading");
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [error, setError] = useState<string | null>(null);
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [formData, setFormData] = useState<CreateConfigProfileRequest>({
name: "",
description: "",
env_vars: {},
runtime_hints: {},
mounts: [],
files: {},
is_default: false,
});
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
const loadData = useCallback(async () => {
setStatus("loading");
try {
const [profs, projs, types] = await Promise.all([
listConfigProfiles(),
listProjects(),
listToolTypes(),
]);
setProfiles(profs || []);
setProjects(projs || []);
setToolTypes(types || []);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const resetForm = () => {
setFormData({
name: "",
description: "",
env_vars: {},
runtime_hints: {},
mounts: [],
files: {},
is_default: false,
});
setError(null);
setSaveStatus("idle");
setPreviewData(null);
};
const populateForm = (profile: ConfigProfile) => {
setFormData({
name: profile.name,
description: profile.description || undefined,
project_id: profile.project_id || undefined,
tool_type_id: profile.tool_type_id || undefined,
env_vars: profile.env_vars,
runtime_hints: profile.runtime_hints,
mounts: profile.mounts,
files: profile.files,
is_default: profile.is_default,
});
setError(null);
setSaveStatus("idle");
setPreviewData(null);
};
const handleSelectProfile = (profile: ConfigProfile | null) => {
if (profile) {
setSelectedProfileId(profile.id);
setIsCreating(false);
populateForm(profile);
} else {
setSelectedProfileId(null);
}
};
const handleCreateNew = () => {
setSelectedProfileId(null);
setIsCreating(true);
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(", ");
}
return "Failed to save";
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSaveStatus("saving");
if (!formData.name?.trim()) {
setError("Name is required");
setSaveStatus("error");
return;
}
try {
if (isCreating) {
const newProfile = await createConfigProfile(formData);
setIsCreating(false);
setSelectedProfileId(newProfile.id);
setSaveStatus("saved");
await loadData();
// Re-populate with the new profile data
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
if (refreshed) populateForm(refreshed);
} else if (selectedProfile) {
await updateConfigProfile(selectedProfile.id, formData);
setSaveStatus("saved");
await loadData();
// Refresh the selected profile data
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
if (refreshed) populateForm(refreshed);
}
} catch (err) {
setError(extractErrorMessage(err));
setSaveStatus("error");
}
};
const handleDelete = async (id: string) => {
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
try {
await deleteConfigProfile(id);
if (selectedProfileId === id) {
setSelectedProfileId(null);
setIsCreating(false);
resetForm();
}
await loadData();
} catch {
alert("Failed to delete config profile");
}
};
const handlePreview = async (id: string) => {
try {
setPreviewingId(id);
const data = await previewConfigProfile(id);
setPreviewData(data);
} catch {
setError("Failed to preview config profile");
} finally {
setPreviewingId(null);
}
};
const updateFormField = <K extends keyof CreateConfigProfileRequest>(
key: K,
value: CreateConfigProfileRequest[K]
) => {
setFormData((prev) => ({ ...prev, [key]: value }));
setSaveStatus("idle");
};
const addEnvVar = () => {
setFormData((prev) => ({
...prev,
env_vars: { ...prev.env_vars, "": "" },
}));
setSaveStatus("idle");
};
const updateEnvVar = (oldKey: string, newKey: string, value: string) => {
setFormData((prev) => {
const envVars = { ...prev.env_vars };
if (oldKey !== newKey) {
delete envVars[oldKey];
}
envVars[newKey] = value;
return { ...prev, env_vars: envVars };
});
setSaveStatus("idle");
};
const removeEnvVar = (key: string) => {
setFormData((prev) => {
const envVars = { ...prev.env_vars };
delete envVars[key];
return { ...prev, env_vars: envVars };
});
setSaveStatus("idle");
};
const addFile = () => {
setFormData((prev) => ({
...prev,
files: { ...prev.files, "": "" },
}));
setSaveStatus("idle");
};
const updateFile = (oldPath: string, newPath: string, content: string) => {
setFormData((prev) => {
const files = { ...prev.files };
if (oldPath !== newPath) {
delete files[oldPath];
}
files[newPath] = content;
return { ...prev, files };
});
setSaveStatus("idle");
};
const removeFile = (path: string) => {
setFormData((prev) => {
const files = { ...prev.files };
delete files[path];
return { ...prev, files };
});
setSaveStatus("idle");
};
const addMount = () => {
setFormData((prev) => ({
...prev,
mounts: [...(prev.mounts || []), { target: "/", mode: "rw", files: {} }],
}));
setSaveStatus("idle");
};
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[index] = { ...mounts[index], ...updates };
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const removeMount = (index: number) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts.splice(index, 1);
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const addMountFile = (mountIndex: number) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[mountIndex] = {
...mounts[mountIndex],
files: { ...mounts[mountIndex].files, "": "" },
};
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const updateMountFile = (
mountIndex: number,
oldPath: string,
newPath: string,
content: string
) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
const files = { ...mounts[mountIndex].files };
if (oldPath !== newPath) {
delete files[oldPath];
}
files[newPath] = content;
mounts[mountIndex] = { ...mounts[mountIndex], files };
return { ...prev, mounts };
});
setSaveStatus("idle");
};
const removeMountFile = (mountIndex: number, path: string) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
const files = { ...mounts[mountIndex].files };
delete files[path];
mounts[mountIndex] = { ...mounts[mountIndex], files };
return { ...prev, mounts };
});
setSaveStatus("idle");
};
if (status === "loading") {
return (
<div className="container">
<p>Loading Config Profiles...</p>
</div>
);
}
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>
</div>
);
}
return (
<div className="container" style={{ display: "flex", height: "calc(100vh - 4rem)", gap: 0, padding: 0 }}>
{/* Left Sidebar - Profile List */}
<div
style={{
width: "280px",
minWidth: "280px",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
background: "var(--panel)",
}}
>
<div style={{ padding: "1rem", borderBottom: "1px solid var(--border)" }}>
<h2 style={{ margin: 0, fontSize: "1.125rem" }}>Config Profiles</h2>
<p className="muted" style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem" }}>
{profiles.length} profile{profiles.length !== 1 ? "s" : ""}
</p>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "0.5rem" }}>
{profiles.map((profile) => (
<button
key={profile.id}
onClick={() => handleSelectProfile(profile)}
style={{
width: "100%",
textAlign: "left",
padding: "0.75rem 1rem",
marginBottom: "0.25rem",
borderRadius: "0.375rem",
border: "none",
background: selectedProfileId === profile.id ? "var(--brand)" : "transparent",
color: selectedProfileId === profile.id ? "white" : "var(--ink)",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "0.75rem",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (selectedProfileId !== profile.id) {
e.currentTarget.style.background = "#ece7df";
}
}}
onMouseLeave={(e) => {
if (selectedProfileId !== profile.id) {
e.currentTarget.style.background = "transparent";
}
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: "0.9375rem", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{profile.name}
{profile.is_default && (
<span style={{
fontSize: "0.7rem",
marginLeft: "0.5rem",
opacity: 0.8,
textTransform: "uppercase",
letterSpacing: "0.025em"
}}>
default
</span>
)}
</div>
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
{profile.project_id && "Project scoped"}
{profile.tool_type_id && (profile.project_id ? " + Tool scoped" : "Tool scoped")}
{!profile.project_id && !profile.tool_type_id && "Global"}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(profile.id);
}}
style={{
background: "none",
border: "none",
color: selectedProfileId === profile.id ? "rgba(255,255,255,0.8)" : "var(--muted)",
cursor: "pointer",
padding: "0.25rem",
borderRadius: "0.25rem",
flexShrink: 0,
opacity: 0,
}}
className="delete-btn"
title="Delete profile"
>
<Icon name="delete" size="sm" />
</button>
</button>
))}
</div>
<div style={{ padding: "1rem", borderTop: "1px solid var(--border)" }}>
<button
onClick={handleCreateNew}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "2px dashed var(--border)",
background: "transparent",
color: "var(--muted)",
cursor: "pointer",
fontWeight: 600,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--brand)";
e.currentTarget.style.color = "var(--brand)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--muted)";
}}
>
<Icon name="add" size="sm" /> New Profile
</button>
</div>
</div>
{/* Right Panel - Editor */}
<div style={{ flex: 1, overflow: "auto", padding: "1.5rem", minWidth: 0 }}>
{!selectedProfileId && !isCreating ? (
<div style={{ textAlign: "center", paddingTop: "4rem", color: "var(--muted)" }}>
<div style={{ opacity: 0.3, marginBottom: "1rem" }}>
<Icon name="folder" size="lg" />
</div>
<h3 style={{ margin: "0 0 0.5rem 0", fontWeight: 500 }}>Select a config profile</h3>
<p style={{ margin: 0 }}>Choose a profile from the list to edit, or create a new one.</p>
</div>
) : (
<div>
{/* Header */}
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h1 style={{ margin: "0 0 0.5rem 0", fontSize: "1.5rem" }}>
{isCreating ? "Create Profile" : selectedProfile?.name}
</h1>
{!isCreating && selectedProfile && (
<p className="muted" style={{ margin: 0 }}>
{selectedProfile.project_id && `Project: ${projects.find((p) => p.id === selectedProfile.project_id)?.name || selectedProfile.project_id}`}
{selectedProfile.project_id && selectedProfile.tool_type_id && " · "}
{selectedProfile.tool_type_id && `Tool: ${toolTypes.find((t) => t.id === selectedProfile.tool_type_id)?.display_name || selectedProfile.tool_type_id}`}
</p>
)}
</div>
{!isCreating && selectedProfile && (
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
className="secondary-button"
onClick={() => handlePreview(selectedProfile.id)}
disabled={previewingId === selectedProfile.id}
>
{previewingId === selectedProfile.id ? (
<>
<Icon name="loading" size="sm" />
Previewing...
</>
) : (
<>
<Icon name="info" size="sm" />
Preview
</>
)}
</button>
</div>
)}
</div>
{error && (
<div className="error" style={{ marginBottom: "1rem" }}>
{error}
</div>
)}
{saveStatus === "saved" && (
<div style={{ marginBottom: "1rem", padding: "0.75rem 1rem", background: "var(--success-bg, #dcfce7)", color: "var(--success, #166534)", borderRadius: "0.375rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Icon name="success" size="sm" />
Profile saved successfully
</div>
)}
<form onSubmit={handleSubmit} className="stack" style={{ gap: "1.25rem", maxWidth: "800px" }}>
<div className="form-group">
<label htmlFor="profile-name">Name *</label>
<input
id="profile-name"
type="text"
value={formData.name}
onChange={(e) => updateFormField("name", e.target.value)}
placeholder="e.g., Development Environment"
className="form-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="profile-description">Description</label>
<input
id="profile-description"
type="text"
value={formData.description || ""}
onChange={(e) => updateFormField("description", e.target.value || undefined)}
placeholder="Optional description"
className="form-input"
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="profile-project">Project</label>
<select
id="profile-project"
value={formData.project_id || ""}
onChange={(e) => updateFormField("project_id", e.target.value || undefined)}
className="form-input"
>
<option value="">None (Global)</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="profile-tool">Tool Type</label>
<select
id="profile-tool"
value={formData.tool_type_id || ""}
onChange={(e) => updateFormField("tool_type_id", e.target.value || undefined)}
className="form-input"
>
<option value="">None</option>
{toolTypes.map((toolType) => (
<option key={toolType.id} value={toolType.id}>
{toolType.display_name}
</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={formData.is_default || false}
onChange={(e) => updateFormField("is_default", e.target.checked)}
/>
Set as default for this scope
</label>
</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) => (
<div key={idx} className="form-row" style={{ gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={key}
onChange={(e) => updateEnvVar(key, e.target.value, value)}
placeholder="VAR_NAME"
className="form-input"
style={{ flex: 1 }}
/>
<input
type="text"
value={value}
onChange={(e) => updateEnvVar(key, key, e.target.value)}
placeholder="value"
className="form-input"
style={{ flex: 1 }}
/>
<button
type="button"
className="ghost-button small"
onClick={() => removeEnvVar(key)}
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button type="button" className="secondary-button" onClick={addEnvVar}>
<Icon name="add" size="sm" />
Add Variable
</button>
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Runtime Hints</h4>
<textarea
value={JSON.stringify(formData.runtime_hints || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
updateFormField("runtime_hints", parsed);
} catch {
// Invalid JSON, ignore
}
}}
placeholder='{"start_command": "npm start"}'
rows={4}
className="form-input"
style={{ fontFamily: "monospace", fontSize: "0.875rem" }}
/>
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.5rem 0" }}>Files</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>
Relative paths written to the instance directory. Use Mounts below for absolute container paths.
</p>
{Object.entries(formData.files || {}).map(([path, content], idx) => (
<div key={idx} className="card" style={{ padding: "0.75rem", marginBottom: "0.5rem" }}>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={path}
onChange={(e) => updateFile(path, e.target.value, content)}
placeholder="relative/path/to/file"
className="form-input"
style={{ flex: 1 }}
/>
<button
type="button"
className="ghost-button small"
onClick={() => removeFile(path)}
>
<Icon name="delete" size="sm" />
</button>
</div>
<textarea
value={content}
onChange={(e) => updateFile(path, path, e.target.value)}
placeholder="File content"
rows={3}
className="form-input"
style={{ fontFamily: "monospace", fontSize: "0.875rem" }}
/>
</div>
))}
<button type="button" className="secondary-button" onClick={addFile}>
<Icon name="add" size="sm" />
Add File
</button>
</div>
<div className="form-section">
<h4 style={{ margin: "0 0 0.5rem 0" }}>Mounts</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>
Bind directories into the container at absolute paths. Files are relative to the mount target.
</p>
{(formData.mounts || []).map((mount, index) => (
<div key={index} className="card" style={{ padding: "1rem", marginBottom: "0.75rem" }}>
<div className="form-row" style={{ gap: "0.5rem", marginBottom: "0.75rem" }}>
<input
type="text"
value={mount.target}
onChange={(e) => updateMount(index, { target: e.target.value })}
placeholder="/target/path"
className="form-input"
style={{ flex: 1 }}
/>
<select
value={mount.mode}
onChange={(e) =>
updateMount(index, { mode: e.target.value as "ro" | "rw" })
}
className="form-input"
style={{ width: "120px" }}
>
<option value="rw">Read/Write</option>
<option value="ro">Read-Only</option>
</select>
<button
type="button"
className="ghost-button small"
onClick={() => removeMount(index)}
>
<Icon name="delete" size="sm" />
</button>
</div>
<div style={{ marginLeft: "1rem" }}>
{Object.entries(mount.files).map(([path, content], idx) => (
<div key={idx} style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
<input
type="text"
value={path}
onChange={(e) =>
updateMountFile(index, path, e.target.value, content)
}
placeholder="relative/path"
className="form-input"
style={{ flex: 1 }}
/>
<textarea
value={content}
onChange={(e) =>
updateMountFile(index, path, path, e.target.value)
}
placeholder="File content"
rows={2}
className="form-input"
style={{ flex: 2, fontFamily: "monospace", fontSize: "0.875rem" }}
/>
<button
type="button"
className="ghost-button small"
onClick={() => removeMountFile(index, path)}
>
<Icon name="delete" size="sm" />
</button>
</div>
))}
<button
type="button"
className="secondary-button small"
onClick={() => addMountFile(index)}
style={{ fontSize: "0.875rem" }}
>
<Icon name="add" size="sm" />
Add File to Mount
</button>
</div>
</div>
))}
<button type="button" className="secondary-button" onClick={addMount}>
<Icon name="add" size="sm" />
Add Mount
</button>
</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" />
{saveStatus === "saving" ? "Saving..." : isCreating ? "Create Profile" : "Save Changes"}
</button>
{(isCreating || saveStatus !== "idle") && (
<button
type="button"
onClick={() => {
if (isCreating) {
resetForm();
} else if (selectedProfile) {
populateForm(selectedProfile);
}
}}
className="button-secondary"
>
<Icon name="cancel" size="sm" /> Discard
</button>
)}
</div>
</form>
{previewData && (
<div className="card stack" style={{ marginTop: "2rem", padding: "1rem" }}>
<h3>Resolved Profile Preview</h3>
<pre style={{ overflow: "auto", maxHeight: "400px", fontSize: "0.8125rem" }}>
{JSON.stringify(previewData, null, 2)}
</pre>
<button className="secondary-button" onClick={() => setPreviewData(null)}>
Close Preview
</button>
</div>
)}
</div>
)}
</div>
</div>
);
};
+95 -114
View File
@@ -2,13 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard"; import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions"; import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
import { listProjects } from "../api/projects"; import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories"; import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types"; import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings"; import { updateUserConfig } from "../api/settings";
import type { Project } from "../types"; import type { Project } from "../types";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
type HomeStatus = "loading" | "ready" | "error"; type HomeStatus = "loading" | "ready" | "error";
@@ -29,11 +31,8 @@ export const HomePage = () => {
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState(""); const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState("");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
const [actionBusy, setActionBusy] = useState<string | null>(null); const [actionBusy, setActionBusy] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
const safeSessions = Array.isArray(sessions) ? sessions : []; const safeSessions = Array.isArray(sessions) ? sessions : [];
const loadHome = useCallback(async () => { const loadHome = useCallback(async () => {
@@ -59,6 +58,49 @@ export const HomePage = () => {
void loadHome(); void loadHome();
}, [loadHome]); }, [loadHome]);
// Poll tunnel health every 30 seconds for running instances
useEffect(() => {
const checkHealth = async () => {
const runningSessions = safeSessions.filter(
(s) => s.status === "running" && s.url
);
for (const session of runningSessions) {
try {
const health = await checkInstanceHealth(
session.project_id,
session.repository_id,
session.id
);
setTunnelHealth((prev) => ({
...prev,
[session.id]: health,
}));
} catch {
setTunnelHealth((prev) => ({
...prev,
[session.id]: {
healthy: false,
container_status: "unknown",
container_health: null,
container_exit_code: null,
tunnel_status: "error",
tunnel_status_code: null,
probe_status: "error",
last_probe_output: null,
error: "check failed",
},
}));
}
}
};
void checkHealth();
const interval = setInterval(() => {
void checkHealth();
}, 30000);
return () => clearInterval(interval);
}, [safeSessions]);
useEffect(() => { useEffect(() => {
if (!selectedProject) { if (!selectedProject) {
setRepositories([]); setRepositories([]);
@@ -82,29 +124,10 @@ export const HomePage = () => {
[safeSessions] [safeSessions]
); );
const recentSessions = useMemo( const handleCreateSuccess = async (instance: { id: string }) => {
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5), await updateUserConfig({ last_session_id: instance.id });
[safeSessions] setSelectedProject("");
); await loadHome();
const handleCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedProject || !selectedRepo || !selectedToolType) return;
setSaveState("saving");
try {
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setDisplayName("");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setSaveState("idle");
await loadHome();
} catch {
setSaveState("error");
}
}; };
const handleOpen = (session: SessionView) => { const handleOpen = (session: SessionView) => {
@@ -133,7 +156,9 @@ export const HomePage = () => {
setActionBusy(session.id); setActionBusy(session.id);
try { try {
await deleteInstance(session.project_id, session.repository_id, session.id); await deleteInstance(session.project_id, session.repository_id, session.id);
await loadHome(); setSessions((prev) => prev.filter((s) => s.id !== session.id));
} catch {
// error - session remains in state
} finally { } finally {
setActionBusy(null); setActionBusy(null);
} }
@@ -149,6 +174,16 @@ export const HomePage = () => {
} }
}; };
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 ( return (
<section className="stack home-page"> <section className="stack home-page">
<header className="home-hero card"> <header className="home-hero card">
@@ -196,45 +231,20 @@ export const HomePage = () => {
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Open sessions</p> <p className="eyebrow">Open sessions</p>
<h2>{activeSessions.length}</h2> <h2>{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}</h2>
</div> </div>
</div> </div>
{activeSessions.length === 0 ? ( <SessionList
<p className="muted">No active sessions right now.</p> sessions={safeSessions}
) : ( onOpen={handleOpen}
<div className="home-session-grid"> onStop={handleStop}
{activeSessions.map((session) => ( onDelete={handleDelete}
<article className="card session-card" key={session.id}> onRecreateTunnel={handleRecreateTunnel}
<div className="stack-sm"> actionBusyId={actionBusy}
<div className="row row-tight"> tunnelHealth={tunnelHealth}
<h3>{session.display_name}</h3> showGrouping={false}
<span className={`status-badge ${session.status}`}>{session.status}</span> emptyMessage="No active sessions right now."
</div> />
<p className="muted">{session.project_name} · {session.repository_name}</p>
<p className="muted">{session.tool_type_name}</p>
</div>
<div className="session-actions">
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
<Icon name="external" size="sm" />
Open
</button>
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
<Icon name="refresh" size="sm" />
Tunnel
</button>
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<Icon name="stop" size="sm" />
Stop
</button>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
</article>
))}
</div>
)}
</section> </section>
<section className="card stack home-section"> <section className="card stack home-section">
@@ -271,62 +281,33 @@ export const HomePage = () => {
<h2>Start a session</h2> <h2>Start a session</h2>
</div> </div>
</div> </div>
<form className="stack create-session-form" onSubmit={handleCreate}> <CreateSessionForm
<div className="form-row"> projects={projects}
<label className="form-field"> repositories={repositories}
Project toolTypes={toolTypes}
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}> onProjectChange={(projectId) => setSelectedProject(projectId)}
<option value="">Select project...</option> onSuccess={handleCreateSuccess}
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)} />
</select>
</label>
<label className="form-field">
Repository
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
<option value="">Select repository...</option>
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
</select>
</label>
<label className="form-field">
Tool type
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
<option value="">Select tool...</option>
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
</select>
</label>
</div>
<label className="form-field">
Display name
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
</label>
<div className="form-actions">
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
</button>
{saveState === "error" && <span className="error-text">Failed to create session</span>}
</div>
</form>
</section> </section>
{recentSessions.length > 0 && ( {safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
<section className="card stack home-section"> <section className="card stack home-section">
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Recent sessions</p> <p className="eyebrow">Recent sessions</p>
<h2>{recentSessions.length}</h2> <h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
</div> </div>
</div> </div>
<div className="recent-sessions-list"> <SessionList
{recentSessions.map((session) => ( sessions={safeSessions}
<article className="recent-session-item" key={session.id}> onOpen={handleOpen}
<div className="recent-session-info"> onStart={handleStart}
<span className="recent-session-name">{session.display_name}</span> onDelete={handleDelete}
<span className="muted">{session.project_name} · {session.tool_type_name}</span> actionBusyId={actionBusy}
</div> showGrouping={false}
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button> maxRecent={5}
</article> emptyMessage="No recent sessions."
))} />
</div>
</section> </section>
)} )}
</> </>
+3
View File
@@ -197,6 +197,7 @@ export const RepoWorkspace = () => {
currentBranch={currentBranch} currentBranch={currentBranch}
branches={branches} branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)} hasRemote={Boolean(selectedRepo?.remote_url)}
isMirror={Boolean(selectedRepo?.is_mirror)}
onBranchChange={(branch) => { onBranchChange={(branch) => {
setCurrentBranch(branch); setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
@@ -253,6 +254,8 @@ export const RepoWorkspace = () => {
<InstanceList <InstanceList
projectId={projectId!} projectId={projectId!}
repoId={selectedRepoId} repoId={selectedRepoId}
projectName={project?.name}
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
toolTypes={toolTypes} toolTypes={toolTypes}
/> />
)} )}
+150 -402
View File
@@ -9,17 +9,18 @@ import {
type Session, type Session,
deleteInstance, deleteInstance,
stopInstance, stopInstance,
startInstance,
checkInstanceHealth, checkInstanceHealth,
recreateInstanceTunnel, recreateInstanceTunnel,
} from "../api/sessions"; } from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types"; import { listToolTypes, type ToolType } from "../api/tool_types";
import { createInstance } from "../api/sessions";
import { getUserConfig, updateUserConfig } from "../api/settings"; import { getUserConfig, updateUserConfig } from "../api/settings";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { SessionCard } from "../components/session-card";
import type { InstanceHealth } from "../api/sessions";
type SessionsStatus = "loading" | "ready" | "error"; type SessionsStatus = "loading" | "ready" | "error";
type CreateStatus = "idle" | "creating" | "error";
export const SessionsPage = () => { export const SessionsPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -30,18 +31,14 @@ export const SessionsPage = () => {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState<string>(""); const [selectedProject, setSelectedProject] = useState<string>("");
const [selectedRepo, setSelectedRepo] = useState<string>("");
const [selectedToolType, setSelectedToolType] = useState<string>("");
const [displayName, setDisplayName] = useState("");
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
const [createError, setCreateError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null); const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, { healthy: boolean; status_code: number | null; error?: string }>>({});
const [recreatingId, setRecreatingId] = useState<string | null>(null); const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [loadingAction, setLoadingAction] = useState<string>("");
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
setStatus("loading"); setStatus("loading");
@@ -86,13 +83,16 @@ export const SessionsPage = () => {
void loadToolTypes(); void loadToolTypes();
}, []); }, []);
// Poll tunnel health every 30 seconds for running instances
// Poll health every 30 seconds for active web-enabled instances
useEffect(() => { useEffect(() => {
const checkHealth = async () => { const checkHealth = async () => {
const runningSessions = sessions.filter( const activeSessions = sessions.filter(
(s) => s.status === "running" && s.url (s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
&& s.tool_type_interfaces?.includes("web")
); );
for (const session of runningSessions) { for (const session of activeSessions) {
try { try {
const health = await checkInstanceHealth( const health = await checkInstanceHealth(
session.project_id, session.project_id,
@@ -106,7 +106,16 @@ export const SessionsPage = () => {
} catch { } catch {
setTunnelHealth((prev) => ({ setTunnelHealth((prev) => ({
...prev, ...prev,
[session.id]: { healthy: false, status_code: null, error: "check failed" }, [session.id]: {
healthy: false,
container_status: "unknown",
container_health: null,
tunnel_status: "unreachable",
tunnel_status_code: null,
probe_status: "unknown",
last_probe_output: null,
error: "check failed",
} as InstanceHealth,
})); }));
} }
} }
@@ -134,78 +143,75 @@ export const SessionsPage = () => {
void loadRepos(); void loadRepos();
}, [selectedProject]); }, [selectedProject]);
const activeSessions = useMemo(
() => sessions.filter((s) => ["running", "building", "pending"].includes(s.status)),
[sessions]
);
const recentSessions = useMemo(
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
[sessions]
);
const lastSession = useMemo( const lastSession = useMemo(
() => sessions.find((s) => s.id === lastSessionId) ?? null, () => sessions.find((s) => s.id === lastSessionId) ?? null,
[sessions, lastSessionId] [sessions, lastSessionId]
); );
const handleCreate = async (e: React.FormEvent) => { const handleCreateSuccess = async (instance: { id: string }) => {
e.preventDefault(); await updateUserConfig({ last_session_id: instance.id });
setCreateError(null); setSelectedProject("");
await loadSessions();
};
if (!selectedProject || !selectedRepo || !selectedToolType) { const handleStop = async (session: Session) => {
setCreateError("Project, repository, and tool type are required"); setLoadingSessionId(session.id);
return; setLoadingAction("Stopping...");
}
setCreateStatus("creating");
try { try {
const instance = await createInstance( await stopInstance(session.project_id, session.repository_id, session.id);
selectedProject,
selectedRepo,
selectedToolType,
displayName || undefined
);
// Auto-start the instance
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setCreateStatus("idle");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setDisplayName("");
await loadSessions(); await loadSessions();
} catch { } catch {
setCreateStatus("error"); // ignore
setCreateError("Failed to create session"); } finally {
setLoadingSessionId(null);
setLoadingAction("");
} }
}; };
const handleStop = async (sessionId: string, projectId: string, repoId: string) => { const handleDelete = async (session: Session) => {
setLoadingSessionId(session.id);
setLoadingAction("Deleting...");
try { try {
await stopInstance(projectId, repoId, sessionId); await deleteInstance(session.project_id, session.repository_id, session.id);
setStopConfirmId(null); setDirtyDeleteSession(null);
await loadSessions(); setDirtyDeleteFiles([]);
} catch {
setStopConfirmId(null);
}
};
const handleDelete = async (sessionId: string, projectId: string, repoId: string) => {
try {
await deleteInstance(projectId, repoId, sessionId);
setDeleteConfirmId(null);
// Remove from local state immediately // Remove from local state immediately
setSessions((prev) => prev.filter((s) => s.id !== sessionId)); 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 { } catch {
setDeleteConfirmId(null); // ignore
} finally {
setLoadingSessionId(null);
setLoadingAction("");
} }
}; };
const handleRecreateTunnel = async (session: Session) => { const handleRecreateTunnel = async (session: Session) => {
setRecreatingId(session.id); setLoadingSessionId(session.id);
setLoadingAction("Recreating tunnel...");
try { try {
await recreateInstanceTunnel( await recreateInstanceTunnel(
session.project_id, session.project_id,
@@ -217,7 +223,8 @@ export const SessionsPage = () => {
} catch { } catch {
// ignore // ignore
} finally { } finally {
setRecreatingId(null); setLoadingSessionId(null);
setLoadingAction("");
} }
}; };
@@ -231,15 +238,6 @@ export const SessionsPage = () => {
} }
}; };
const handleResumeLast = async () => {
if (!lastSession) return;
// Find the project and repo IDs
const project = projects.find((p) => p.name === lastSession.project_name);
if (project) {
navigate(`/projects/${project.id}`);
}
};
return ( return (
<section className="stack sessions-page"> <section className="stack sessions-page">
<div className="page-header"> <div className="page-header">
@@ -264,338 +262,88 @@ export const SessionsPage = () => {
{lastSession && ( {lastSession && (
<div className="last-session-section"> <div className="last-session-section">
<h2>Last Session</h2> <h2>Last Session</h2>
<div className="card last-session-card"> <SessionCard
<div className="last-session-info"> session={lastSession}
<h3>{lastSession.display_name}</h3> onOpen={handleOpen}
<p className="muted"> onDelete={handleDelete}
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name} isBusy={loadingSessionId === lastSession.id}
</p> tunnelHealth={tunnelHealth[lastSession.id] || null}
{lastSession.url && ( />
<p className="session-url">
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
{lastSession.url}
</a>
</p>
)}
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
</div>
<div className="last-session-actions">
{lastSession.url ? (
<a
href={lastSession.url}
target="_blank"
rel="noopener noreferrer"
className="primary-button"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button className="primary-button" onClick={handleResumeLast} type="button">
<Icon name="play" size="sm" />
Resume
</button>
)}
</div>
</div>
</div> </div>
)} )}
{/* Active Sessions */} {/* Session List */}
<div className="active-sessions-section"> <div className={`sessions-list-wrapper ${loadingSessionId ? "dimmed" : ""}`}>
<h2> {loadingSessionId && (
Active Sessions <div className="loading-overlay">
{activeSessions.length > 0 && ( <div className="loading-content">
<span className="badge">{activeSessions.length}</span> <Icon name="loading" size="lg" />
)} <p>{loadingAction}</p>
</h2> </div>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions</p>
) : (
<div className="sessions-grid">
{activeSessions.map((session) => (
<div className="card session-card" key={session.id}>
<div className="session-info">
<h4>{session.display_name}</h4>
<p className="muted">
{session.tool_type_name} · {session.project_name}
</p>
{session.url && (
<p className="session-url">
<a href={session.url} target="_blank" rel="noopener noreferrer">
{session.url}
</a>
</p>
)}
<span className={`status-badge ${session.status}`}>{session.status}</span>
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
<span className="status-badge error">tunnel error</span>
)}
</div>
<div className="session-actions">
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button
className="secondary-button small"
onClick={() => handleOpen(session)}
type="button"
>
<Icon name="external" size="sm" />
Open
</button>
)}
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
<button
className="secondary-button small"
onClick={() => void handleRecreateTunnel(session)}
type="button"
disabled={recreatingId === session.id}
>
<Icon name="refresh" size="sm" />
{recreatingId === session.id ? "Recreating..." : "Recreate Tunnel"}
</button>
)}
{stopConfirmId === session.id ? (
<div className="stop-confirm-inline">
<span className="confirm-text">Stop?</span>
<button
className="danger-button small"
onClick={() =>
void handleStop(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Stop
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => setStopConfirmId(session.id)}
type="button"
>
<Icon name="stop" size="sm" />
Stop
</button>
)}
{deleteConfirmId === session.id ? (
<div className="delete-confirm-inline">
<button
className="danger-button small"
onClick={() =>
void handleDelete(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Delete
</button>
<button
className="ghost-button small"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={() => setDeleteConfirmId(session.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</div>
))}
</div> </div>
)} )}
<SessionList
sessions={sessions}
onOpen={handleOpen}
onStop={handleStop}
onDelete={handleDelete}
onRecreateTunnel={handleRecreateTunnel}
actionBusyId={loadingSessionId}
tunnelHealth={tunnelHealth}
/>
</div> </div>
{/* Recent Sessions */}
{recentSessions.length > 0 && (
<div className="recent-sessions-section">
<h2>Recent Sessions</h2>
<div className="recent-sessions-list">
{recentSessions.map((session) => (
<div className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{session.display_name}</span>
<span className="muted">
{session.tool_type_name} · {session.project_name}
</span>
</div>
<div className="recent-session-actions">
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="ghost-button small"
>
Open
</a>
) : (
<button
className="ghost-button small"
onClick={() => handleOpen(session)}
type="button"
>
Open
</button>
)}
{deleteConfirmId === session.id ? (
<div className="delete-confirm-inline">
<button
className="danger-button small"
onClick={() =>
void handleDelete(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Delete
</button>
<button
className="ghost-button small"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={() => setDeleteConfirmId(session.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Create Session */} {/* Create Session */}
<div className="create-session-section"> <div className="create-session-section">
<h2>Create New Session</h2> <h2>Create New Session</h2>
<form onSubmit={handleCreate} className="card stack create-session-form"> <CreateSessionForm
<div className="form-row"> projects={projects}
<label className="form-field"> repositories={repositories}
Project toolTypes={toolTypes}
<select onProjectChange={(projectId) => {
value={selectedProject} setSelectedProject(projectId);
onChange={(e) => { }}
setSelectedProject(e.target.value); onSuccess={handleCreateSuccess}
setSelectedRepo(""); />
}}
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="form-field">
Repository
<select
value={selectedRepo}
onChange={(e) => setSelectedRepo(e.target.value)}
disabled={!selectedProject}
>
<option value="">Select repository...</option>
{repositories.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select tool...</option>
{toolTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.display_name}
</option>
))}
</select>
</label>
</div>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
{createError && <p className="error-text">{createError}</p>}
<div className="form-actions">
<button
className="primary-button"
type="submit"
disabled={createStatus === "creating"}
>
{createStatus === "creating" ? (
<>
<Icon name="loading" size="sm" />
Creating...
</>
) : (
<>
<Icon name="add" size="sm" />
Create Session
</>
)}
</button>
</div>
</form>
</div> </div>
{/* Dirty Delete Confirmation Modal */}
{dirtyDeleteSession && (
<div className="modal-overlay" onClick={() => setDirtyDeleteSession(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Uncommitted Changes</h3>
<p>
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has
uncommitted changes. Deleting this session will permanently lose these
changes.
</p>
<div className="changed-files-list">
<h4>Changed files:</h4>
<ul>
{dirtyDeleteFiles.map((file, idx) => (
<li key={idx}>{file}</li>
))}
</ul>
</div>
<div className="modal-actions">
<button
className="secondary-button"
onClick={() => setDirtyDeleteSession(null)}
type="button"
>
Cancel
</button>
<button
className="danger-button"
onClick={() => void handleForceDelete(dirtyDeleteSession)}
type="button"
>
Force Delete
</button>
</div>
</div>
</div>
)}
</> </>
)} )}
</section> </section>
+1 -3
View File
@@ -9,8 +9,6 @@ type SettingsStatus = "loading" | "ready" | "error";
const TABS = [ const TABS = [
{ label: "General", path: "general" }, { label: "General", path: "general" },
{ label: "SSH Keys", path: "ssh-keys" }, { label: "SSH Keys", path: "ssh-keys" },
{ label: "Tool Types", path: "tool-types" },
{ label: "Tool Configs", path: "tool-configs" },
] as const; ] as const;
const THEME_OPTIONS = [ const THEME_OPTIONS = [
@@ -106,7 +104,7 @@ export const SettingsPage = () => {
<p className="eyebrow">Configuration</p> <p className="eyebrow">Configuration</p>
<h1>Settings</h1> <h1>Settings</h1>
</div> </div>
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p> <p className="muted">General preferences, SSH keys, and config profiles.</p>
</header> </header>
<nav className="settings-tabs" aria-label="Settings sections"> <nav className="settings-tabs" aria-label="Settings sections">
+148 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys"; import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
export const SSHKeysPage = () => { export const SSHKeysPage = () => {
@@ -10,6 +10,13 @@ export const SSHKeysPage = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [newKeyName, setNewKeyName] = useState(""); const [newKeyName, setNewKeyName] = useState("");
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
const [signatures, setSignatures] = useState<Record<string, string>>({});
const [signing, setSigning] = useState<Record<string, boolean>>({});
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
useEffect(() => { useEffect(() => {
loadKeys(); loadKeys();
@@ -59,6 +66,42 @@ export const SSHKeysPage = () => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
} }
async function handleSign(keyId: string) {
const payload = signPayloads[keyId];
if (!payload?.trim()) return;
try {
setSigning((prev) => ({ ...prev, [keyId]: true }));
const result = await signPayload(keyId, { payload: payload.trim() });
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
setError(null);
} catch {
setError("Failed to sign payload");
} finally {
setSigning((prev) => ({ ...prev, [keyId]: false }));
}
}
async function handleVerify(keyId: string) {
const payload = verifyPayloads[keyId];
const signature = verifySignatures[keyId];
if (!payload?.trim() || !signature?.trim()) return;
try {
setVerifying((prev) => ({ ...prev, [keyId]: true }));
const result = await verifySignature(keyId, {
payload: payload.trim(),
signature: signature.trim(),
});
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
setError(null);
} catch {
setError("Failed to verify signature");
} finally {
setVerifying((prev) => ({ ...prev, [keyId]: false }));
}
}
if (loading) return <div>Loading...</div>; if (loading) return <div>Loading...</div>;
return ( return (
@@ -133,6 +176,110 @@ export const SSHKeysPage = () => {
Copy Full Key Copy Full Key
</button> </button>
</div> </div>
<div className="key-signing">
<h4>Sign Payload</h4>
<div className="form-group">
<textarea
value={signPayloads[key.id] || ""}
onChange={(e) =>
setSignPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
}
placeholder="Enter payload to sign..."
rows={3}
/>
</div>
<button
onClick={() => handleSign(key.id)}
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
className="primary-button"
>
{signing[key.id] ? (
<>
<Icon name="loading" size="sm" />
Signing...
</>
) : (
<>
<Icon name="edit" size="sm" />
Sign
</>
)}
</button>
{signatures[key.id] && (
<div className="signature-result">
<label>Signature (base64):</label>
<code>{signatures[key.id]}</code>
<button
onClick={() => copyToClipboard(signatures[key.id])}
className="secondary-button"
>
<Icon name="copy" size="sm" />
Copy Signature
</button>
</div>
)}
</div>
<div className="key-verification">
<h4>Verify Signature</h4>
<div className="form-group">
<textarea
value={verifyPayloads[key.id] || ""}
onChange={(e) =>
setVerifyPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
}
placeholder="Enter payload..."
rows={2}
/>
</div>
<div className="form-group">
<textarea
value={verifySignatures[key.id] || ""}
onChange={(e) =>
setVerifySignatures((prev) => ({ ...prev, [key.id]: e.target.value }))
}
placeholder="Enter base64 signature..."
rows={2}
/>
</div>
<button
onClick={() => handleVerify(key.id)}
disabled={
verifying[key.id] ||
!verifyPayloads[key.id]?.trim() ||
!verifySignatures[key.id]?.trim()
}
className="primary-button"
>
{verifying[key.id] ? (
<>
<Icon name="loading" size="sm" />
Verifying...
</>
) : (
<>
<Icon name="success" size="sm" />
Verify
</>
)}
</button>
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
{verifyResults[key.id] ? (
<>
<Icon name="success" size="sm" />
Signature is valid
</>
) : (
<>
<Icon name="error" size="sm" />
Signature is invalid
</>
)}
</div>
)}
</div>
</div> </div>
)) ))
)} )}
+14 -2
View File
@@ -1,11 +1,13 @@
import React from "react"; import React from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent } from "../components/terminal"; import { TerminalComponent } from "../components/terminal";
import { Icon } from "../components/icon"; import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
export const TerminalPage: React.FC = () => { export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>(); const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useMobileViewport();
if (!instanceId) { if (!instanceId) {
return ( return (
@@ -16,6 +18,16 @@ export const TerminalPage: React.FC = () => {
); );
} }
if (isMobile) {
return (
<MobileTerminalWrapper
instanceId={instanceId}
onBack={() => navigate(-1)}
onClose={() => navigate(-1)}
/>
);
}
return ( return (
<section className="terminal-page"> <section className="terminal-page">
<div className="terminal-page-header"> <div className="terminal-page-header">
@@ -24,7 +36,6 @@ export const TerminalPage: React.FC = () => {
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
type="button" type="button"
> >
<Icon name="arrow-left" size="sm" />
Back Back
</button> </button>
<h1>Terminal</h1> <h1>Terminal</h1>
@@ -32,6 +43,7 @@ export const TerminalPage: React.FC = () => {
<TerminalComponent <TerminalComponent
instanceId={instanceId} instanceId={instanceId}
onClose={() => navigate(-1)} onClose={() => navigate(-1)}
isMobile={false}
/> />
</section> </section>
); );
-354
View File
@@ -1,354 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "../components/icon";
import { listToolTypes, type ToolType } from "../api/tool_types";
import {
createToolConfig,
deleteToolConfig,
listToolConfigs,
updateToolConfig,
type ToolConfig,
} from "../api/tool_configs";
type ConfigStatus = "loading" | "ready" | "error";
export const ToolConfigsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ConfigStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [selectedToolType, setSelectedToolType] = useState<string>("");
const [showForm, setShowForm] = useState(false);
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
const [formData, setFormData] = useState({
key: "",
value: "",
config_type: "env",
file_path: "",
});
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const loadData = useCallback(async () => {
try {
const [typesData, configsData] = await Promise.all([
listToolTypes(),
listToolConfigs(),
]);
setToolTypes(typesData);
setConfigs(configsData);
if (typesData.length > 0 && !selectedToolType) {
setSelectedToolType(typesData[0].id);
}
setStatus("ready");
} catch {
setStatus("error");
}
}, [selectedToolType]);
useEffect(() => {
void loadData();
}, [loadData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaveStatus("saving");
try {
const data = {
tool_type_id: selectedToolType,
key: formData.key,
value: formData.value,
config_type: formData.config_type,
file_path: formData.config_type === "file" ? formData.file_path : undefined,
};
if (editingConfig) {
await updateToolConfig(editingConfig.id, data);
} else {
await createToolConfig(data);
}
setSaveStatus("saved");
setShowForm(false);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
await loadData();
} catch {
setSaveStatus("error");
}
};
const handleEdit = (config: ToolConfig) => {
setEditingConfig(config);
setFormData({
key: config.key,
value: config.value,
config_type: config.config_type,
file_path: config.file_path || "",
});
setSelectedToolType(config.tool_type_id);
setShowForm(true);
};
const handleDelete = async (id: string) => {
if (!window.confirm("Delete this config?")) return;
try {
await deleteToolConfig(id);
await loadData();
} catch {
// Error handled by UI state
}
};
const filteredConfigs = configs.filter(
(c) => c.tool_type_id === selectedToolType
);
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
if (status === "loading") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<p className="muted">Loading...</p>
</section>
);
}
if (status === "error") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<div className="card stack">
<p>Failed to load configurations</p>
<button className="secondary-button" onClick={() => void loadData()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
</section>
);
}
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Configurations</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
<p className="muted">
Manage environment variables and configuration files for your tools
</p>
</div>
{/* Tool Type Selector */}
<div className="card">
<label htmlFor="tool-type-select">Select Tool</label>
<select
id="tool-type-select"
value={selectedToolType}
onChange={(e) => {
setSelectedToolType(e.target.value);
setShowForm(false);
setEditingConfig(null);
}}
className="form-input"
>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
{selectedTool && (
<p className="muted" style={{ marginTop: "0.5rem" }}>
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
</p>
)}
</div>
{/* Config List */}
<div className="card stack">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<h2>Configuration Variables</h2>
<button
className="primary-button small"
onClick={() => {
setShowForm(true);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
}}
type="button"
>
<Icon name="add" size="sm" />
Add Config
</button>
</div>
{filteredConfigs.length === 0 ? (
<p className="muted">No configurations for this tool yet.</p>
) : (
<div className="stack" style={{ gap: "0.5rem" }}>
{filteredConfigs.map((config) => (
<div
key={config.id}
className="card"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem 1rem",
}}
>
<div>
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
<code style={{ fontWeight: 600 }}>{config.key}</code>
<span
className="badge"
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)",
color: "white",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
}}
>
{config.config_type}
</span>
</div>
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
{config.config_type === "file" && config.file_path
? `File: ${config.file_path}`
: "Environment variable"}
</p>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<button
className="ghost-button small"
onClick={() => handleEdit(config)}
type="button"
>
<Icon name="edit" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => void handleDelete(config.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Add/Edit Form */}
{showForm && (
<div className="card stack">
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
<form onSubmit={handleSubmit} className="stack">
<div>
<label htmlFor="config-key">Key</label>
<input
id="config-key"
type="text"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
/>
</div>
<div>
<label htmlFor="config-type">Type</label>
<select
id="config-type"
value={formData.config_type}
onChange={(e) =>
setFormData({ ...formData, config_type: e.target.value })
}
className="form-input"
>
<option value="env">Environment Variable</option>
<option value="file">Configuration File</option>
</select>
</div>
{formData.config_type === "file" && (
<div>
<label htmlFor="config-file-path">File Path</label>
<input
id="config-file-path"
type="text"
value={formData.file_path}
onChange={(e) =>
setFormData({ ...formData, file_path: e.target.value })
}
placeholder="e.g., /app/config.json"
className="form-input"
required
/>
</div>
)}
<div>
<label htmlFor="config-value">Value</label>
<textarea
id="config-value"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
placeholder={
formData.config_type === "env"
? "Enter value..."
: "Enter file contents..."
}
className="form-input"
rows={formData.config_type === "file" ? 8 : 2}
required
/>
</div>
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
<button
type="button"
className="secondary-button"
onClick={() => {
setShowForm(false);
setEditingConfig(null);
}}
>
Cancel
</button>
<button type="submit" className="primary-button">
{editingConfig ? "Update" : "Add"} Config
</button>
</div>
{saveStatus === "saved" && (
<p className="text-success" style={{ textAlign: "right" }}>
Saved successfully!
</p>
)}
{saveStatus === "error" && (
<p className="text-error" style={{ textAlign: "right" }}>
Failed to save. Please try again.
</p>
)}
</form>
</div>
)}
</section>
);
};
-380
View File
@@ -1,380 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
type CreateToolTypeRequest,
type UpdateToolTypeRequest,
} from "../api/tool_types";
import { Icon } from "../components/icon";
import type { ToolType } from "../api/tool_types";
type ToolTypesStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ToolTypesPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ToolTypesStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingToolType, setEditingToolType] = useState<ToolType | null>(null);
const [formName, setFormName] = useState("");
const [formDisplayName, setFormDisplayName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formCategory, setFormCategory] = useState("");
const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
const [formPort, setFormPort] = useState("");
const [formTemplate, setFormTemplate] = useState("");
const [formVariables, setFormVariables] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadToolTypes = useCallback(async () => {
setStatus("loading");
try {
const data = await listToolTypes();
setToolTypes(data);
setStatus("ready");
} catch {
setToolTypes([]);
setStatus("error");
}
}, []);
useEffect(() => {
void loadToolTypes();
}, [loadToolTypes]);
const openCreate = () => {
setFormName("");
setFormDisplayName("");
setFormDescription("");
setFormCategory("");
setFormInterfaces([]);
setFormPort("");
setFormTemplate("");
setFormVariables("");
setFormError(null);
setEditingToolType(null);
setDialogMode("create");
};
const openEdit = (toolType: ToolType) => {
setFormName(toolType.name);
setFormDisplayName(toolType.display_name);
setFormDescription(toolType.description ?? "");
setFormCategory(toolType.category ?? "");
setFormInterfaces(toolType.interfaces ?? []);
setFormPort(toolType.default_port?.toString() ?? "");
setFormTemplate(toolType.compose_template ?? "");
setFormVariables(toolType.required_variables.join(", "));
setFormError(null);
setEditingToolType(toolType);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingToolType(null);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
setFormError("Name, display name, and compose template are required");
return;
}
if (!formPort.trim() || isNaN(Number(formPort))) {
setFormError("Default port is required and must be a number");
return;
}
const variables = formVariables
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
try {
if (dialogMode === "create") {
const input: CreateToolTypeRequest = {
name: formName.trim(),
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
default_port: Number(formPort),
compose_template: formTemplate.trim(),
required_variables: variables,
};
await createToolType(input);
} else if (dialogMode === "edit" && editingToolType) {
const input: UpdateToolTypeRequest = {
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
default_port: Number(formPort),
compose_template: formTemplate.trim(),
required_variables: variables,
};
await updateToolType(editingToolType.id, input);
}
closeDialog();
await loadToolTypes();
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
const detail = axiosError?.response?.data?.detail || "Failed to save tool type";
setFormError(detail);
}
};
const handleDelete = async (id: string) => {
try {
await deleteToolType(id);
setDeleteConfirmId(null);
await loadToolTypes();
} catch {
alert("Failed to delete tool type");
}
};
if (status === "loading") {
return (
<div className="container">
<p>Loading tool types...</p>
</div>
);
}
if (status === "error") {
return (
<div className="container">
<p className="text-error">Failed to load tool types.</p>
<button onClick={loadToolTypes}>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
);
}
return (
<div className="container">
<div className="page-header" style={{ marginBottom: "1rem" }}>
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Types</h1>
</div>
<div className="row">
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
<button onClick={openCreate}>
<Icon name="add" size="sm" />
Create Tool Type
</button>
</div>
</div>
{toolTypes.length === 0 ? (
<p>No tool types found.</p>
) : (
<div className="card-grid">
{toolTypes.map((toolType) => (
<div key={toolType.id} className="card">
<div className="card-header">
<h3>{toolType.display_name}</h3>
{toolType.is_builtin && <span className="badge">Built-in</span>}
</div>
<p className="text-secondary">{toolType.description || "No description"}</p>
<div className="tool-type-meta">
<span>Port: {toolType.default_port || "N/A"}</span>
{toolType.interfaces?.length > 0 && (
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
)}
{toolType.category && <span>Category: {toolType.category}</span>}
</div>
<div className="card-actions">
{!toolType.is_builtin && (
<>
<button onClick={() => openEdit(toolType)} className="button-secondary">
<Icon name="edit" size="sm" />
Edit
</button>
<button
onClick={() => setDeleteConfirmId(toolType.id)}
className="button-danger"
>
<Icon name="delete" size="sm" />
Delete
</button>
</>
)}
</div>
{deleteConfirmId === toolType.id && (
<div className="dialog-overlay">
<div className="dialog">
<p>Delete tool type "{toolType.display_name}"?</p>
<div className="dialog-actions">
<button onClick={() => handleDelete(toolType.id)} className="button-danger">
<Icon name="delete" size="sm" />
Delete
</button>
<button onClick={() => setDeleteConfirmId(null)}>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{dialogMode !== "none" && (
<div className="dialog-overlay">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name (unique identifier)</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
disabled={dialogMode === "edit"}
placeholder="e.g., code-server"
/>
</div>
<div className="form-group">
<label>Display Name</label>
<input
type="text"
value={formDisplayName}
onChange={(e) => setFormDisplayName(e.target.value)}
placeholder="e.g., VS Code Server"
/>
</div>
<div className="form-group">
<label>Description</label>
<input
type="text"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="form-group">
<label>Category</label>
<input
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g., editor, notebook, ai-assistant"
/>
</div>
<div className="form-group">
<label>Interfaces</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("web")}
onChange={(e) => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "web"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "web"));
}
}}
/>
Web
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("terminal")}
onChange={(e) => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "terminal"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "terminal"));
}
}}
/>
Terminal
</label>
</div>
</div>
<div className="form-group">
<label>Default Port *</label>
<input
type="number"
value={formPort}
onChange={(e) => setFormPort(e.target.value)}
placeholder="e.g., 8443"
required
/>
</div>
<div className="form-group">
<label>Compose Template (YAML)</label>
<textarea
value={formTemplate}
onChange={(e) => setFormTemplate(e.target.value)}
rows={10}
placeholder="version: '3.8'&#10;services:&#10; app:&#10; image: ..."
/>
</div>
<div className="form-group">
<label>Required Variables (comma-separated)</label>
<input
type="text"
value={formVariables}
onChange={(e) => setFormVariables(e.target.value)}
placeholder="REPO_PATH, TOOL_NAME"
/>
</div>
{formError && <p className="text-error">{formError}</p>}
<div className="dialog-actions">
<button type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Update
</>
)}
</button>
<button type="button" onClick={closeDialog} className="button-secondary">
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+40 -168
View File
@@ -13,7 +13,8 @@ const mockToolTypes = [
display_name: "VS Code Server", display_name: "VS Code Server",
description: "VS Code in browser", description: "VS Code in browser",
category: "editor", category: "editor",
interfaces: ["web"], interface_type: "web",
requires_port: true,
default_port: 8443, default_port: 8443,
definition_type: "compose", definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server", compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
@@ -32,7 +33,8 @@ const mockToolTypes = [
display_name: "Custom Tool", display_name: "Custom Tool",
description: "My custom tool", description: "My custom tool",
category: "utility", category: "utility",
interfaces: ["terminal"], interface_type: "terminal",
requires_port: false,
default_port: 8080, default_port: 8080,
definition_type: "dockerfile", definition_type: "dockerfile",
compose_template: null, compose_template: null,
@@ -153,164 +155,10 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole("button", { name: /configs/i })); fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
});
expect(screen.getByText("advanced-config")).toBeInTheDocument();
});
it("switches to folders tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
expect(screen.getByText("project-configs")).toBeInTheDocument();
});
it("opens tool type creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
});
it("creates tool type with compose definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "New Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "8080" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-tool",
display_name: "New Tool",
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
})
);
});
});
it("creates tool type with dockerfile definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "docker-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "Docker Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "3000" },
});
// Switch to dockerfile
fireEvent.change(screen.getByLabelText("Definition Type"), {
target: { value: "dockerfile" },
});
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
target: { value: "FROM python:3.11\\nRUN pip install flask" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
})
);
});
});
it("shows readiness probe fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
expect(screen.getByText(/interval/i)).toBeInTheDocument();
});
it("opens config creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole("button", { name: /configs/i })); fireEvent.click(screen.getByRole("button", { name: /configs/i }));
@@ -321,8 +169,8 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /add config/i })); fireEvent.click(screen.getByRole("button", { name: /add config/i }));
expect(screen.getByLabelText(/key/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument();
expect(screen.getByLabelText(/value/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument();
}); });
it("creates config with advanced fields", async () => { it("creates config with advanced fields", async () => {
@@ -337,6 +185,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i })); fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => { await waitFor(() => {
@@ -345,16 +199,16 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /add config/i })); fireEvent.click(screen.getByRole("button", { name: /add config/i }));
fireEvent.change(screen.getByLabelText(/key/i), { fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), {
target: { value: "MY_CONFIG" }, target: { value: "MY_CONFIG" },
}); });
fireEvent.change(screen.getByLabelText(/value/i), { fireEvent.change(screen.getByPlaceholderText(/Enter value/i), {
target: { value: "my-value" }, target: { value: "my-value" },
}); });
fireEvent.change(screen.getByLabelText(/port override/i), { fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), {
target: { value: "9090" }, target: { value: "9090" },
}); });
fireEvent.change(screen.getByLabelText(/start command/i), { fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), {
target: { value: "python app.py" }, target: { value: "python app.py" },
}); });
@@ -384,6 +238,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i })); fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => { await waitFor(() => {
@@ -392,8 +252,8 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create folder/i })); fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument(); expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument();
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument(); expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument();
}); });
it("creates config folder successfully", async () => { it("creates config folder successfully", async () => {
@@ -408,6 +268,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i })); fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => { await waitFor(() => {
@@ -416,10 +282,10 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create folder/i })); fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
fireEvent.change(screen.getByLabelText("Name *"), { fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), {
target: { value: "new-folder" }, target: { value: "new-folder" },
}); });
fireEvent.change(screen.getByLabelText("Mount Path *"), { fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), {
target: { value: "/home/dev" }, target: { value: "/home/dev" },
}); });
@@ -447,6 +313,12 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i })); fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => { await waitFor(() => {
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -14,17 +14,14 @@ import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
import { TerminalPage } from "./pages/terminal"; import { TerminalPage } from "./pages/terminal";
import { ToolWorkshopPage } from "./pages/tool-workshop"; import { ToolWorkshopPage } from "./pages/tool-workshop";
import { SSHKeysPage } from "./pages/ssh-keys"; import { SSHKeysPage } from "./pages/ssh-keys";
import { ToolConfigsPage } from "./pages/tool-configs"; import { ConfigProfilesPage } from "./pages/config-profiles";
import { ToolTypesPage } from "./pages/tool-types"; import { SessionsPage } from "./pages/sessions";
export const AppRouter = () => { export const AppRouter = () => {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginRedirectPage />} /> <Route path="/login" element={<LoginRedirectPage />} />
<Route path="/sessions" element={<Navigate to="/" replace />} />
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} /> <Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
<Route <Route
path="/" path="/"
element={ element={
@@ -40,14 +37,14 @@ export const AppRouter = () => {
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} /> <Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} /> <Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
<Route path="profile" element={<ProfilePage />} /> <Route path="profile" element={<ProfilePage />} />
<Route path="config-profiles" element={<ConfigProfilesPage />} />
<Route path="settings" element={<SettingsPage />}> <Route path="settings" element={<SettingsPage />}>
<Route index element={<Navigate to="general" replace />} /> <Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<GeneralSettingsTab />} /> <Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} /> <Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="tool-configs" element={<ToolConfigsPage />} />
<Route path="*" element={<Navigate to="general" replace />} /> <Route path="*" element={<Navigate to="general" replace />} />
</Route> </Route>
<Route path="sessions" element={<SessionsPage />} />
<Route path="tool-workshop" element={<ToolWorkshopPage />} /> <Route path="tool-workshop" element={<ToolWorkshopPage />} />
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} /> <Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
</Route> </Route>
+736 -8
View File
@@ -120,7 +120,8 @@ a {
.shell-body { .shell-body {
display: grid; display: grid;
grid-template-columns: 230px 1fr; grid-template-columns: 230px 1fr;
min-height: calc(100vh - 57px); height: calc(100vh - 57px);
flex: 1;
} }
.shell-nav { .shell-nav {
@@ -166,6 +167,10 @@ a {
.shell-content { .shell-content {
padding: 1.25rem; padding: 1.25rem;
overflow-x: hidden; overflow-x: hidden;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
} }
.eyebrow { .eyebrow {
@@ -302,6 +307,7 @@ a {
.shell-content { .shell-content {
padding: var(--space-4); padding: var(--space-4);
height: 100%;
} }
} }
@@ -2482,7 +2488,8 @@ a.nav-item,
.terminal-page { .terminal-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100%;
min-height: 0;
padding: var(--space-4); padding: var(--space-4);
gap: var(--space-4); gap: var(--space-4);
} }
@@ -2499,8 +2506,8 @@ a.nav-item,
} }
.terminal-wrapper { .terminal-wrapper {
display: flex; display: grid;
flex-direction: column; grid-template-rows: auto 1fr;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -2585,19 +2592,36 @@ a.nav-item,
} }
.terminal-container { .terminal-container {
flex: 1; position: relative;
min-height: 0; width: 100%;
height: 100%;
padding: var(--space-2); padding: var(--space-2);
overflow: hidden;
} }
.terminal-container .xterm { .terminal-container .xterm {
height: 100%; position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
} }
.terminal-container canvas {
display: block;
}
/* Ensure xterm fills container */
.terminal-container .xterm-viewport { .terminal-container .xterm-viewport {
background: #1e1e1e !important; width: 100% !important;
} }
/* Mobile terminal container - no padding to maximize space */
.terminal-wrapper.mobile .terminal-container {
padding: 0;
}
/* Ensure xterm viewport fills container properly */
/* Responsive terminal */ /* Responsive terminal */
@media (max-width: 767px) { @media (max-width: 767px) {
.terminal-page { .terminal-page {
@@ -2652,9 +2676,15 @@ a.nav-item,
} }
.active-sessions-section { .active-sessions-section {
position: relative;
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.active-sessions-section.dimmed {
opacity: 0.5;
pointer-events: none;
}
.active-sessions-section h2 { .active-sessions-section h2 {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2713,10 +2743,175 @@ a.nav-item,
gap: var(--space-2); gap: var(--space-2);
} }
/* Session Card - Unified Component */
.session-card-content {
flex: 1;
}
.session-card-header {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.session-card-title {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-2);
}
.session-card-title h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.session-card-status-badges {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.session-card-meta {
font-size: 0.85rem;
margin: 0;
}
.session-card-meta .icon {
margin-right: var(--space-1);
vertical-align: text-bottom;
}
.session-card-url {
margin: var(--space-1) 0;
font-size: 0.8rem;
word-break: break-all;
}
.session-card-url a {
color: var(--color-primary);
text-decoration: none;
}
.session-card-url a:hover {
text-decoration: underline;
}
.session-card-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
margin-top: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
/* Session List - Unified Component */
.session-list {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.session-group {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.session-group-header {
display: flex;
align-items: center;
gap: var(--space-2);
}
.session-group-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
/* Action labels - hidden on mobile */
.action-label {
margin-left: var(--space-1);
}
@media (max-width: 640px) {
.action-label {
display: none;
}
.session-card-actions {
gap: var(--space-1);
}
.session-card-actions button,
.session-card-actions a {
padding: var(--space-1);
}
}
.recent-sessions-section { .recent-sessions-section {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
/* Loading overlay for sessions */
.sessions-grid {
position: relative;
}
.sessions-grid.dimmed {
opacity: 0.5;
pointer-events: none;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
background: rgba(255, 254, 249, 0.7);
border-radius: var(--space-2);
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-6);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--space-2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.loading-content .icon {
animation: spin 1s linear infinite;
color: var(--brand);
}
.loading-content p {
margin: 0;
font-size: var(--font-size-base);
color: var(--muted);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.recent-sessions-list { .recent-sessions-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2763,9 +2958,15 @@ a.nav-item,
} }
.create-session-section { .create-session-section {
position: relative;
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.create-session-section.dimmed {
opacity: 0.5;
pointer-events: none;
}
.create-session-form { .create-session-form {
max-width: 600px; max-width: 600px;
} }
@@ -2776,6 +2977,65 @@ a.nav-item,
gap: var(--space-4); gap: var(--space-4);
} }
/* Workflow Step Styles */
.workflow-form {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.workflow-step {
opacity: 0.4;
pointer-events: none;
transition: opacity 0.2s ease;
}
.workflow-step.active {
opacity: 1;
pointer-events: auto;
}
.workflow-step.complete {
opacity: 0.7;
pointer-events: auto;
}
.workflow-step-header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
font-weight: 600;
color: var(--text-muted);
}
.workflow-step.active .workflow-step-header {
color: var(--text-primary);
}
.workflow-step-number {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-muted);
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.workflow-step.active .workflow-step-number {
background: var(--primary);
color: white;
}
.workflow-step.complete .workflow-step-number {
background: var(--success);
color: white;
}
.status-badge { .status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2805,3 +3065,471 @@ a.nav-item,
background: var(--danger-light, #fee2e2); background: var(--danger-light, #fee2e2);
color: var(--danger, #dc2626); color: var(--danger, #dc2626);
} }
/* ============================================
Mobile Terminal Styles
============================================ */
.mobile-terminal-wrapper {
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"header"
"content"
"keys";
height: 100vh;
height: 100dvh; /* Dynamic viewport height for mobile */
max-height: 100vh;
max-height: 100dvh;
background: #1e1e1e;
position: relative;
overflow: hidden;
}
/* Mobile Terminal Header */
.mobile-terminal-header {
grid-area: header;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
transition: transform 0.3s ease, opacity 0.3s ease, height 0.3s ease, padding 0.3s ease, margin 0.3s ease;
z-index: 100;
overflow: hidden;
}
.mobile-terminal-header.hidden {
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
height: 0;
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
border-width: 0;
flex-shrink: 1;
}
.mobile-terminal-header.visible {
transform: translateY(0);
opacity: 1;
height: auto;
}
.mobile-terminal-header-left,
.mobile-terminal-header-right {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 0 0 auto;
}
.mobile-terminal-header-center {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
justify-content: center;
min-width: 0;
}
.mobile-terminal-header-title {
font-size: 0.875rem;
font-weight: 500;
color: #d4d4d4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-terminal-header-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: transparent;
border: 1px solid #3e3e3e;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.875rem;
transition: background 0.2s ease;
}
.mobile-terminal-header-button:hover {
background: #3e3e3e;
}
.mobile-terminal-header-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
flex-shrink: 0;
}
.mobile-terminal-header-status.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.mobile-terminal-header-status.connected {
background: #0dbc79;
}
.mobile-terminal-header-status.disconnected,
.mobile-terminal-header-status.error {
background: #cd3131;
}
/* Mobile Terminal Content */
.mobile-terminal-content {
grid-area: content;
overflow: hidden;
position: relative;
background: #1e1e1e;
min-height: 0;
max-height: 100%;
}
/* Terminal wrapper - fills content area */
.terminal-wrapper.mobile {
border: none;
border-radius: 0;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.terminal-wrapper.mobile .terminal-container {
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
position: relative;
}
/* xterm fills container */
.terminal-wrapper.mobile .terminal-container .xterm {
width: 100%;
height: 100%;
}
/* Special Keys Strip */
.special-keys-strip {
grid-area: keys;
display: flex;
align-items: center;
gap: 2px;
padding: var(--space-1) var(--space-2);
background: #2d2d2d;
border-top: 1px solid #3e3e3e;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
transition: transform 0.3s ease, opacity 0.3s ease;
z-index: 100;
}
.special-keys-strip.hidden {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
}
.special-keys-strip.visible {
transform: translateY(0);
opacity: 1;
}
.special-keys-strip::-webkit-scrollbar {
display: none;
}
.special-key-button {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 44px;
padding: 0 var(--space-2);
background: #3e3e3e;
border: 1px solid #4e4e4e;
border-radius: 6px;
color: #d4d4d4;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background 0.15s ease, transform 0.1s ease;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
.special-key-button:active {
background: #4e4e4e;
transform: scale(0.95);
}
.special-key-more {
background: #2472c8;
border-color: #2472c8;
color: white;
}
.special-key-more:active {
background: #1e5fa8;
}
.special-key-button.active-modifier {
background: #f5f543;
color: #1e1e1e;
border-color: #f5f543;
font-weight: 700;
box-shadow: 0 0 8px rgba(245, 245, 67, 0.5);
}
/* Special Keys Panel */
.special-keys-panel-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;
}
.special-keys-panel {
background: #2d2d2d;
border-top: 1px solid #3e3e3e;
border-radius: 12px 12px 0 0;
padding: var(--space-4);
width: 100%;
max-height: 70vh;
overflow-y: auto;
animation: slideUp 0.2s ease;
}
.special-keys-panel-section {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.special-keys-panel-section:last-child {
margin-bottom: 0;
}
.special-keys-panel-divider {
height: 1px;
background: #3e3e3e;
margin: var(--space-3) 0;
}
/* Terminal Component Updates */
/* Note: .terminal-wrapper.mobile is defined above with position: absolute to fill grid cell */
.terminal-wrapper.mobile .terminal-header {
display: none;
}
.terminal-header-left {
display: flex;
align-items: center;
gap: var(--space-2);
}
.terminal-header-right {
display: flex;
align-items: center;
gap: var(--space-2);
}
.terminal-header-button {
padding: var(--space-1) var(--space-2);
background: transparent;
border: 1px solid #666;
border-radius: 4px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.75rem;
transition: background 0.2s ease;
}
.terminal-header-button:hover {
background: #3e3e3e;
}
.terminal-reconnect {
margin-left: var(--space-2);
padding: var(--space-1) var(--space-2);
background: #2472c8;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 0.75rem;
}
.terminal-reset-confirm {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.terminal-reset-confirm-content {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: var(--space-4);
max-width: 400px;
text-align: center;
}
.terminal-reset-confirm-content p {
margin: 0 0 var(--space-4) 0;
color: var(--text-primary);
}
.terminal-reset-confirm-buttons {
display: flex;
gap: var(--space-2);
justify-content: center;
}
.terminal-reset-confirm-button {
padding: var(--space-2) var(--space-4);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.terminal-reset-confirm-button.cancel {
background: var(--bg-elevated);
color: var(--text-primary);
}
.terminal-reset-confirm-button.confirm {
background: #cd3131;
color: white;
}
.terminal-hidden-input {
position: fixed;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
/* Disable zoom on mobile terminal */
@media (max-width: 767px) {
.mobile-terminal-wrapper {
touch-action: none;
-webkit-text-size-adjust: none;
}
.mobile-terminal-wrapper * {
touch-action: manipulation;
}
.terminal-container {
touch-action: none;
padding: 0;
}
}
/* Animations */
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Mobile Menu Overlay */
.mobile-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
}
.mobile-menu-close {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: none;
color: var(--text);
cursor: pointer;
}
/* AppShell mobile menu */
@media (max-width: 767px) {
.shell-nav {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 260px;
background: var(--bg);
z-index: 100;
transform: translateX(-100%);
transition: transform 0.3s ease;
padding-top: var(--space-8);
}
.shell-nav.mobile-open {
transform: translateX(0);
}
}
+2 -1
View File
@@ -12,5 +12,6 @@
"isolatedModules": true, "isolatedModules": true,
"types": ["vite/client"] "types": ["vite/client"]
}, },
"include": ["src"] "include": ["src"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
} }
+1 -2
View File
@@ -93,7 +93,7 @@ services:
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-} AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
volumes: volumes:
- repo_data:/data/repos - repo_data:/data/repos
- instance_data:/data/instances - /data/instances:/data/instances
- avatar_uploads:/app/uploads - avatar_uploads:/app/uploads
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
depends_on: depends_on:
@@ -117,7 +117,6 @@ volumes:
postgres_data: postgres_data:
redis_data: redis_data:
repo_data: repo_data:
instance_data:
avatar_uploads: avatar_uploads:
networks: networks:
+1 -2
View File
@@ -58,7 +58,7 @@ services:
INSTANCE_BASE_PATH: /data/instances INSTANCE_BASE_PATH: /data/instances
volumes: volumes:
- repo_data:/data/repos - repo_data:/data/repos
- instance_data:/data/instances - /data/instances:/data/instances
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
@@ -92,7 +92,6 @@ volumes:
postgres_data: postgres_data:
redis_data: redis_data:
repo_data: repo_data:
instance_data:
networks: networks:
backend: backend:
+134
View File
@@ -0,0 +1,134 @@
# Terminal API
The Terminal API provides WebSocket-based terminal access to running tool instances.
## WebSocket Endpoint
### Connect to Terminal
```
GET /ws/tool-instances/{instance_id}/terminal
```
Establishes a WebSocket connection to an interactive terminal session inside a running tool instance container.
**Authentication:** Requires valid session cookie.
**Path Parameters:**
- `instance_id` (string, UUID): The tool instance ID
**Connection Flow:**
1. Client connects to WebSocket endpoint
2. Server authenticates user and verifies instance ownership
3. Server creates or reattaches to existing terminal session
4. Server sends `{"type": "status", "status": "connected"}` message
5. Bidirectional communication begins
**Message Types:**
#### Client to Server
**Terminal Input (bytes or string)**
- Send raw bytes for terminal input (key presses)
- Send text for terminal input (will be encoded as UTF-8)
**Resize Command (JSON)**
```json
{
"type": "resize",
"cols": 80,
"rows": 24
}
```
**Reset Command (JSON)**
```json
{
"type": "reset"
}
```
Kills the current terminal session and starts a fresh one.
**Pong Response (JSON)**
```json
{
"type": "pong"
}
```
Sent automatically in response to server ping messages.
#### Server to Client
**Terminal Output (bytes)**
Raw terminal output as binary data (Blob in browser).
**Status Messages (JSON)**
```json
{"type": "status", "status": "connected"}
{"type": "status", "status": "resetting"}
```
**Ping Messages (JSON)**
```json
{"type": "ping"}
```
Sent every 30 seconds to detect disconnections. Client should respond with `{"type": "pong"}`.
### Session Persistence
Terminal sessions persist across WebSocket disconnections:
- When a client disconnects, the terminal session remains active
- On reconnection, the client reattaches to the existing session
- Buffered output is replayed to the client on reconnection
- Sessions are cleaned up after 30 minutes of inactivity
### Concurrent Connections
Only one WebSocket connection is allowed per terminal session:
- New connections close existing connections with code 4000
- Previous client receives "New connection established" reason
## HTTP Endpoints
### Reset Terminal Session
```
POST /api/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset
```
Resets the terminal session for a tool instance, killing the current shell and starting fresh.
**Authentication:** Required
**Path Parameters:**
- `project_id` (string, UUID): Project ID
- `repo_id` (string, UUID): Repository ID
- `instance_id` (string, UUID): Instance ID
**Response:**
```json
{
"status": "success",
"message": "Terminal session reset successfully",
"instance_id": "...",
"session_id": "..."
}
```
**Error Responses:**
- `404 Not Found`: Instance not found
- `400 Bad Request`: Instance is not running
- `500 Internal Server Error`: Failed to reset terminal session
## Error Codes
WebSocket close codes:
- `1000`: Normal closure
- `4000`: Error/reset
- `4001`: Invalid instance ID
- `4003`: Unauthorized/Forbidden
- `4004`: Instance not found or not running
## Heartbeat
The server sends ping messages every 30 seconds. If no ping is received for 60 seconds, the client should assume the connection is dead and reconnect.
+172
View File
@@ -0,0 +1,172 @@
# Terminal Troubleshooting Guide
## Common Issues
### Cannot Connect to Terminal
**Symptom:** Terminal shows "Connection closed" or "Error" status immediately.
**Solutions:**
1. Verify the tool instance is running:
- Check instance status in the UI
- Start the instance if it's stopped
2. Check browser console for errors:
- Open browser DevTools (F12)
- Look for WebSocket connection errors
- Check for CORS or authentication errors
3. Verify network connectivity:
- Ensure you can reach the API server
- Check if WebSocket connections are blocked by firewall/proxy
- Try accessing from a different network
**If the issue persists:**
- Reset the terminal session
- Refresh the page
- Check server logs for errors
### Terminal Freezes or Becomes Unresponsive
**Symptom:** Terminal accepts no input or stops updating.
**Solutions:**
1. **Reset the terminal:**
- Click the Reset button in the terminal header
- Confirm the reset action
- Wait for the new session to start
2. **Check for stuck processes:**
- Try Ctrl+C to interrupt any running process
- If that doesn't work, reset the terminal
3. **Browser issues:**
- Close and reopen the browser tab
- Clear browser cache and cookies
- Try a different browser
### Output Not Showing
**Symptom:** Commands execute but no output appears.
**Solutions:**
1. Check terminal focus:
- Click inside the terminal area
- Look for the cursor indicator
2. Resize the terminal:
- The terminal may need a resize event to render properly
- Try resizing the browser window slightly
3. Reset the terminal session
### Reconnection Loop
**Symptom:** Terminal keeps disconnecting and reconnecting repeatedly.
**Solutions:**
1. Check instance health:
- The instance may be unhealthy or restarting
- Check instance logs for errors
2. Network stability:
- Unstable network causes repeated disconnections
- Try on a more stable connection
3. Multiple tabs:
- Only one tab can connect to a terminal session
- Close other tabs with the same terminal open
## Diagnostic Steps
### Check WebSocket Connection
1. Open browser DevTools (F12)
2. Go to Network tab
3. Filter by "WS" (WebSocket)
4. Look for the terminal WebSocket connection
5. Check:
- Connection status (should be 101 Switching Protocols)
- Messages tab for ping/pong traffic
- Any error messages in the connection
### Verify Terminal Session
To check if a terminal session exists on the server:
```bash
# Check server logs for session activity
docker logs hq-api | grep -i "terminal"
```
Look for:
- "Terminal session ready" - session created successfully
- "Reattaching to existing terminal session" - reconnecting to existing session
- "Cleaning up idle terminal session" - session expired
### Test Basic Connectivity
```bash
# Test if the WebSocket endpoint is reachable
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: test" \
-H "Sec-WebSocket-Version: 13" \
https://your-api-domain/ws/tool-instances/test/terminal
```
Expected: HTTP 400 (invalid instance ID) or redirect to auth
## Error Codes
### WebSocket Close Codes
- **1000**: Normal closure - connection closed cleanly
- **4000**: Generic error - check server logs
- **4001**: Invalid instance ID - verify the instance exists
- **4003**: Unauthorized - session expired or invalid
- **4004**: Instance not running - start the instance first
### HTTP Status Codes
- **404**: Instance not found - verify instance ID
- **400**: Instance not running - start the instance
- **500**: Server error - check server logs
## Resetting Everything
If all else fails:
1. **Reset terminal session:**
```
POST /api/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset
```
2. **Restart the tool instance:**
- Stop the instance
- Start the instance again
- Reconnect to the terminal
3. **Clear browser data:**
- Clear cookies for the domain
- Clear local storage
- Hard refresh the page (Ctrl+F5)
## Getting Help
If issues persist:
1. Collect diagnostic information:
- Browser console logs
- Network tab WebSocket messages
- Server logs (`docker logs hq-api`)
- Instance status and health
2. Check the [Terminal API documentation](/docs/api/terminal.md) for protocol details
3. Report issues with:
- Steps to reproduce
- Expected vs actual behavior
- Browser and OS version
- Instance type and configuration
+83
View File
@@ -0,0 +1,83 @@
# Terminal Sessions
Terminal sessions provide interactive shell access to your running tool instances directly in the browser.
## Persistent Sessions
Terminal sessions are **persistent** - they survive browser refreshes, network interruptions, and tab switches.
### How It Works
- When you open a terminal, a shell session starts inside the tool instance container
- If you close the browser or lose connection, the session keeps running
- When you reconnect, you reattach to the same session with all previous output preserved
- Sessions automatically clean up after 30 minutes of inactivity
### Reconnecting
If your connection drops:
1. The terminal shows "Reconnecting..." status
2. The client automatically attempts to reconnect with exponential backoff
3. On successful reconnection, buffered output is replayed
4. You can continue working where you left off
## Resetting the Terminal
If your terminal becomes unresponsive or you want a fresh start:
1. Click the **Reset** button in the terminal header
2. Confirm the reset action
3. The current shell is killed and a new one starts
4. All terminal history is cleared
**Note:** Resetting only affects the terminal session, not the tool instance itself. Any files you've created remain intact.
## Mobile Terminal
On mobile devices, the terminal includes:
- Special keys panel (Ctrl, Alt, Tab, arrows, etc.)
- Font size controls
- Auto-hiding header for maximum screen space
- Touch-friendly interface
## Keyboard Shortcuts
Standard terminal shortcuts work as expected:
- `Ctrl+C`: Send interrupt signal
- `Ctrl+D`: Send EOF (close shell if empty)
- `Ctrl+L`: Clear screen
- `Ctrl+Z`: Suspend process
Special keys can be accessed via the special keys panel on mobile or by using modifier combinations.
## Troubleshooting
### Connection Issues
**"Connection closed" error:**
- The tool instance may have stopped - check the instance status
- Network issues - the client will auto-reconnect
- Session timeout - sessions expire after 30 minutes of inactivity
**Terminal not responding:**
- Try resetting the terminal using the Reset button
- Check if the tool instance is still running
- Refresh the page to force reconnection
### Display Issues
**Text not visible:**
- Adjust font size using +/- buttons
- Check if the terminal has focus (click inside it)
- Try resizing the browser window
**Characters not appearing:**
- Ensure the terminal has focus
- Check if a modifier key is stuck (Ctrl, Alt)
- Reset the terminal if stuck
## Session Limits
- **One connection per terminal:** Only one browser tab can connect to a terminal session at a time. Opening a new connection closes the old one.
- **30-minute idle timeout:** Sessions without activity are automatically cleaned up
- **Buffer size:** Up to 10KB of output is buffered for replay on reconnection
+11
View File
@@ -62,6 +62,17 @@ services:
- `{{TOOL_NAME}}` - Unique name for the container - `{{TOOL_NAME}}` - Unique name for the container
- `{{REPO_PATH}}` - Path to the repository - `{{REPO_PATH}}` - Path to the repository
#### Git Requirement for Clone Mode
When users create instances in **clone mode** (fresh repository copy instead of bind mount), the container image must have `git` installed. This enables git operations (push, pull, branch) inside the container.
**Built-in types with git:**
- VS Code Server: Includes git
- Jupyter Notebook: Includes git
- OpenCode: Installs git during startup
**Custom tool types:** Ensure your base image includes git (e.g., `apt-get install -y git` in Dockerfile).
#### Validating Templates #### Validating Templates
The system validates templates: The system validates templates:
@@ -0,0 +1,415 @@
# Session Branch Selection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace free-text branch input with a dropdown of available branches and add "Create new branch" functionality in the session creation form.
**Architecture:** Frontend fetches branches from existing API, displays them in a dropdown with a "Create new branch..." option. When creating a new branch, frontend sends both base branch and new branch name to backend. Backend clones the base branch then creates a local branch in the cloned workspace.
**Tech Stack:** React + TypeScript (frontend), FastAPI + Python (backend), Git via subprocess
---
## File Structure
- `apps/web/src/api/git_repositories.ts` — Add `listRepositoryBranches` API function
- `apps/web/src/pages/sessions.tsx` — Replace branch input with dropdown + new branch form
- `apps/api/src/api/tool_instances.py` — Extend `CreateInstanceRequest`, add local branch creation
- `apps/web/src/api/sessions.ts` — Update `createInstance` signature to accept `newBranch`
---
### Task 1: Add Branch Listing API to Frontend
**Files:**
- Modify: `apps/web/src/api/git_repositories.ts`
- [ ] **Step 1: Add Branch types and listRepositoryBranches function**
Add after the existing imports and before `export interface CommitHistoryEntry`:
```typescript
export interface Branch {
name: string;
is_default: boolean;
last_commit: string | null;
}
export interface BranchesResponse {
branches: Branch[];
default_branch: string;
}
export async function listRepositoryBranches(
projectId: string,
repoId: string
): Promise<BranchesResponse> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/branches`
);
return response.data;
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/src/api/git_repositories.ts
git commit -m "feat: add branch listing API function"
```
---
### Task 2: Update Backend Request Model and Clone Logic
**Files:**
- Modify: `apps/api/src/api/tool_instances.py`
- [ ] **Step 1: Extend CreateInstanceRequest with new_branch field**
Change the `CreateInstanceRequest` class (around line 55-64):
```python
class CreateInstanceRequest(BaseModel):
"""Request body for creating a tool instance."""
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(default=None, description="Optional display name for the instance")
clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'")
branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')")
new_branch: str | None = Field(default=None, description="Create a new local branch after cloning")
```
- [ ] **Step 2: Add local branch creation after clone**
After the clone block (around line 248), add:
```python
# Create new local branch if requested
if data.clone_mode == "clone" and data.new_branch:
try:
result = subprocess.run(
["git", "-C", repo_path, "checkout", "-b", data.new_branch],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error("Failed to create branch %s: %s", data.new_branch, result.stderr)
raise RuntimeError(f"Failed to create branch: {result.stderr}")
logger.info("Created local branch %s in cloned repository", data.new_branch)
except Exception as exc:
logger.exception("Failed to create local branch: %s", exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create local branch: {exc}"
)
```
- [ ] **Step 3: Update ToolInstance record to store new branch name**
Change the instance creation (around line 321):
```python
branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None),
```
- [ ] **Step 4: Commit**
```bash
git add apps/api/src/api/tool_instances.py
git commit -m "feat: support creating local branch during session creation
- Add new_branch field to CreateInstanceRequest
- Run git checkout -b after cloning when new_branch is provided
- Store new branch name in ToolInstance record"
```
---
### Task 3: Update Frontend API to Support newBranch
**Files:**
- Modify: `apps/web/src/api/sessions.ts`
- [ ] **Step 1: Update createInstance signature**
Change the function signature (around line 44):
```typescript
export async function createInstance(
projectId: string,
repoId: string,
toolTypeId: string,
displayName?: string,
cloneMode?: string,
branch?: string,
newBranch?: string
): Promise<ToolInstance> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`,
{
tool_type_id: toolTypeId,
display_name: displayName,
clone_mode: cloneMode || "mount",
branch: branch || undefined,
new_branch: newBranch || undefined,
}
);
return response.data;
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/src/api/sessions.ts
git commit -m "feat: add newBranch parameter to createInstance"
```
---
### Task 4: Update Session Creation UI
**Files:**
- Modify: `apps/web/src/pages/sessions.tsx`
- [ ] **Step 1: Add new imports**
Add to existing imports:
```typescript
import { listRepositoryBranches, type Branch } from "../api/git_repositories";
```
- [ ] **Step 2: Add state variables**
Add after `const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);` (around line 44):
```typescript
const [branches, setBranches] = useState<Branch[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [baseBranch, setBaseBranch] = useState("");
```
- [ ] **Step 3: Add branch loading effect**
Add after the `loadSshKeys` effect (around line 117):
```typescript
useEffect(() => {
const loadBranches = async () => {
if (!selectedRepo || !selectedProject || cloneMode !== "clone") {
setBranches([]);
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
return;
}
setIsLoadingBranches(true);
try {
const data = await listRepositoryBranches(selectedProject, selectedRepo);
setBranches(data.branches);
const defaultBranch = data.default_branch;
setBaseBranch(defaultBranch);
if (!branch || !data.branches.find((b) => b.name === branch)) {
setBranch(defaultBranch);
}
} catch {
setBranches([]);
} finally {
setIsLoadingBranches(false);
}
};
void loadBranches();
}, [selectedRepo, selectedProject, cloneMode]);
```
- [ ] **Step 4: Replace branch input with dropdown**
Replace the branch input section (around lines 686-696):
```tsx
{cloneMode === "clone" && (
<>
<label className="form-field">
Branch
{isLoadingBranches ? (
<span className="muted">Loading branches...</span>
) : (
<select
value={isCreatingNewBranch ? "__new__" : branch}
onChange={(e) => {
const value = e.target.value;
if (value === "__new__") {
setIsCreatingNewBranch(true);
setNewBranchName("");
} else {
setIsCreatingNewBranch(false);
setBranch(value);
setBaseBranch(value);
}
}}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
<option value="__new__">Create new branch...</option>
</select>
)}
</label>
{isCreatingNewBranch && (
<>
<label className="form-field">
New Branch Name
<input
type="text"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="feature/my-new-branch"
required
/>
</label>
<label className="form-field">
Base Branch
<select
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
</select>
</label>
</>
)}
{selectedRepo && (
<div className="form-field ssh-key-info">
{(() => {
const repo = repositories.find((r) => r.id === selectedRepo);
if (!repo) return null;
if (repo.ssh_key_id) {
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
return (
<span className="success-text">
SSH key: {key?.name || "Assigned"}
</span>
);
}
return (
<span className="warning-text">
No SSH key assigned to this repository. Clone mode requires an SSH key.
</span>
);
})()}
</div>
)}
</>
)}
```
- [ ] **Step 5: Update handleCreate to pass newBranch**
Change the `createInstance` call (around line 210):
```typescript
const instance = await createInstance(
selectedProject,
selectedRepo,
selectedToolType,
displayName || undefined,
cloneMode,
isCreatingNewBranch ? baseBranch : branch,
isCreatingNewBranch ? newBranchName : undefined
);
```
- [ ] **Step 6: Reset new branch state on successful creation**
In the success handler (around line 227), add:
```typescript
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
setBranches([]);
```
- [ ] **Step 7: Commit**
```bash
git add apps/web/src/pages/sessions.tsx
git commit -m "feat: add branch dropdown and new branch creation UI
- Replace free-text branch input with dropdown of available branches
- Add 'Create new branch...' option with name and base branch inputs
- Load branches from API when repository is selected in clone mode
- Pass newBranch parameter to createInstance API"
```
---
### Task 5: Verify and Test
- [ ] **Step 1: Check TypeScript compilation**
```bash
cd apps/web && npm run typecheck
```
Expected: No errors
- [ ] **Step 2: Check Python syntax**
```bash
cd apps/api && python -m py_compile src/api/tool_instances.py
```
Expected: No errors
- [ ] **Step 3: Run backend tests if available**
```bash
cd apps/api && pytest src/tests/ -v -k "instance" || echo "No instance tests found"
```
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "test: verify branch selection implementation compiles"
```
---
## Spec Coverage Check
| Spec Requirement | Task |
|---|---|
| Branch dropdown with available branches | Task 4 |
| "Create new branch..." option | Task 4 |
| New branch name input | Task 4 |
| Base branch dropdown | Task 4 |
| Backend clone + local branch creation | Task 2 |
| Frontend API integration | Task 1, 3 |
## Placeholder Scan
- No TBD, TODO, or "implement later" references
- All code is complete and copy-paste ready
- No vague instructions like "add appropriate error handling"
## Type Consistency Check
- `Branch` interface used consistently in Task 1 and Task 4
- `new_branch` / `newBranch` naming consistent between frontend and backend
- `CreateInstanceRequest` fields match API call in `sessions.ts`
@@ -0,0 +1,155 @@
# Session Branch Selection with New Branch Creation
## Summary
Replace the free-text branch input in the session creation form with a dropdown of available branches from the repository. Add the ability to create a new local branch at clone time by selecting "Create new branch..." from the dropdown.
## Context
The current session creation UI (`apps/web/src/pages/sessions.tsx`) has a free-text input for the branch name when "Clone fresh copy" mode is selected. Users must manually type the branch name, which is error-prone and doesn't show what branches are available.
The backend already has:
- A `GET /projects/{project_id}/repositories/{repo_id}/branches` endpoint that returns all branches and the default branch
- A `clone_repository` service that clones a specific branch
- Branch creation APIs for the original repository
## Design
### Frontend Changes
#### 1. Branch API Integration
Add a new API function in `apps/web/src/api/git_repositories.ts`:
```typescript
export interface Branch {
name: string;
is_default: boolean;
last_commit: string | null;
}
export interface BranchesResponse {
branches: Branch[];
default_branch: string;
}
export async function listRepositoryBranches(
projectId: string,
repoId: string
): Promise<BranchesResponse> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/branches`
);
return response.data;
}
```
#### 2. UI State Management
In `apps/web/src/pages/sessions.tsx`, add state for:
- `branches`: `Branch[]` — loaded when a repository is selected and clone mode is active
- `isLoadingBranches`: `boolean`
- `isCreatingNewBranch`: `boolean` — toggled when "Create new branch..." is selected
- `newBranchName`: `string` — the name for the new branch
- `baseBranch`: `string` — the base branch for the new branch
#### 3. Branch Loading
When a repository is selected and clone mode is "clone", fetch branches:
- Call `listRepositoryBranches(selectedProject, selectedRepo)`
- Set `baseBranch` to `default_branch` from the response
- If the current `branch` state is not in the list, reset it to `default_branch`
#### 4. Branch Dropdown
Replace the free-text input with a `<select>`:
- Options populated from `branches` state
- Default branch marked visually: `"main (default)"`
- Last option: `"Create new branch..."` (disabled separator style or as a real option)
- When selected, set `isCreatingNewBranch = true`
#### 5. New Branch Form
When `isCreatingNewBranch` is true, show:
- **New branch name** input (required, validated for valid git branch name)
- **Base branch** dropdown (populated from `branches`, defaulting to `default_branch`)
#### 6. Form Submission
Update `handleCreate` to handle new branch creation:
- If `isCreatingNewBranch` is true, pass `newBranchName` and `baseBranch` to the API
- The `branch` parameter sent to the API should be:
- `newBranchName` if creating a new branch
- The selected existing branch otherwise
### Backend Changes
#### 1. Update `CreateInstanceRequest`
In `apps/api/src/api/tool_instances.py`, extend the request model:
```python
class CreateInstanceRequest(BaseModel):
model_config = {"extra": "ignore"}
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
display_name: str | None = Field(default=None, description="Optional display name")
clone_mode: str = Field(default="mount", description="'mount' or 'clone'")
branch: str | None = Field(default="main", description="Branch to clone")
new_branch: str | None = Field(default=None, description="Create a new branch from 'branch' after clone")
```
#### 2. Update Clone Logic
In `create_instance`, after cloning:
- If `data.new_branch` is provided:
1. Clone the `data.branch` (base branch) as usual
2. Run `git -C <clone_path> checkout -b <new_branch>` to create the local branch
3. Store `new_branch` in the `branch` field of the ToolInstance record
#### 3. Update `clone_repository` Service
No changes needed — it already clones a specific branch. The new branch creation happens after clone.
#### 4. Database Schema
No changes needed — the existing `branch` field on `ToolInstance` can store the new branch name.
### Data Flow
```
User selects repo + "Clone fresh copy"
→ Frontend fetches branches from GET /branches
→ User selects "Create new branch..."
→ User fills: newBranchName="feature-x", baseBranch="dev"
→ Frontend sends: { branch: "dev", new_branch: "feature-x", ... }
→ Backend clones "dev" branch
→ Backend runs: git checkout -b feature-x
→ Instance record stores branch="feature-x"
→ Container starts with the new branch checked out
```
### Error Handling
- **Branch fetch fails**: Show error, fallback to free-text input
- **Invalid branch name**: Frontend validation (regex for valid git branch names)
- **New branch creation fails**: Backend returns 400 with git error message
- **Branch already exists locally**: Backend handles gracefully (git checkout -b will fail if branch exists)
### Testing
1. **Frontend**: Test branch dropdown loads correctly, "Create new branch" toggle works, form submission sends correct payload
2. **Backend**: Test instance creation with `new_branch` parameter, verify git command runs correctly
3. **Integration**: End-to-end test creating a session with a new branch
## Files Changed
- `apps/web/src/api/git_repositories.ts` — Add `listRepositoryBranches` function
- `apps/web/src/pages/sessions.tsx` — Replace branch input with dropdown + new branch form
- `apps/api/src/api/tool_instances.py` — Extend `CreateInstanceRequest` and clone logic
- `apps/api/src/services/clone.py` — Add `create_local_branch` helper (optional)
## Trade-offs
- **Local branch only**: The new branch is created in the cloned workspace only, not pushed to the remote. This is intentional — it's a disposable work branch.
- **No branch deletion**: When the instance is deleted, the branch is lost with the clone. This matches the "disposable" mental model.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-24
@@ -0,0 +1,109 @@
## Context
The current system has tool configs for tool-type runtime fields and config folders for user-owned mounted files. Tool instance start currently discovers applicable tool configs and active config folders automatically, writes files/env vars into the instance directory, modifies Docker Compose, and starts the container. This creates useful building blocks but not a single user-facing launch profile that can be selected, composed, previewed, scoped to project/tool, or disabled for a launch.
The target model is a config profile: a user-owned, selectable launch configuration that owns UTF-8 text files, mount roots, plain environment variables, and runtime hints. A profile can include other profiles in an ordered graph. Launch chooses one profile or `None`; included profiles provide stacking without making the start form multi-select.
## Goals / Non-Goals
**Goals:**
- Provide one primary config abstraction for reusable launch setup.
- Allow one selected profile, or no profile, at session start.
- Allow ordered profile composition through includes with loop detection.
- Support portable, project-specific, tool-specific, and project+tool profiles through optional `project_id` and `tool_type_id` references.
- Resolve compatible defaults by specificity, falling back to the first created compatible profile.
- Store the selected profile on the instance so restart behavior is predictable.
- Replace legacy active config folder auto-mounting; compatibility with old config folder behavior is not required.
- Provide a settings editor for profile env vars, mount roots, text files, include order, defaults, and basic runtime hints.
**Non-Goals:**
- Secret storage or masking for env vars/files in v1.
- Binary file upload/editing in v1.
- Selecting multiple profiles directly at launch.
- Preserving legacy config folder semantics.
- Cross-user shared profiles.
## Decisions
### Config profiles own files directly
Profiles will own their file content instead of referencing the existing `ConfigFolder` model. Reuse comes from profile composition: a profile such as `OpenCode Kimi` can include `Git identity` and `Shell defaults`.
Alternative considered: keep `ConfigFolder` as a reusable file-bundle primitive. This adds another concept (`profile -> folder -> files`) and makes the UI harder to explain. Direct file ownership keeps the model centered on one abstraction.
### Scope is derived from optional project/tool references
Profiles do not need a separate scope enum. Scope is inferred from nullable references:
- portable: no project and no tool
- tool: tool only
- project: project only
- project+tool: both project and tool
This avoids storing redundant state and naturally supports `Headquarter OpenCode` profiles.
### Launch selects one profile, composition happens inside profiles
The start UI will expose a single config profile selector with `None` as an option. Profiles may include other profiles in ordered composition, so advanced stacking happens in the profile editor rather than the launch form.
Alternative considered: allow selecting multiple profiles at launch. This is more flexible but makes start behavior harder to understand and raises ordering questions for every launch.
### Includes use an ordered graph with cycle detection
Profile includes will be represented as ordered edges. Resolution processes included profiles in position order, then applies the selected profile itself. Later layers override earlier layers. Cycles must be rejected when saving include relationships and guarded against again during launch resolution.
### Mounts use target roots with relative UTF-8 text files
Each profile mount has a target path, mode (`ro` or `rw`), and a map/list of relative file paths to UTF-8 text content. The resolver stages each resolved mount into the instance directory and adds Docker bind mounts to the compose file.
Alternative considered: store absolute container paths on every file. Mount roots better match Docker volume behavior, simplify editing, and make merge/conflict rules clearer.
### Deterministic override rules
Resolution order is:
1. tool defaults already provided by the tool type/compose template
2. included profiles in configured order, recursively resolved
3. selected profile itself
4. start-time/runtime overrides if a future workflow exposes them
For env vars and runtime hints, later values replace earlier values. For mounts with the same target path, file maps are merged and later relative file paths win. For mount mode conflicts on the same target path, the later layer wins.
### Defaults are selected by specificity
The default selector will prefer explicit defaults by specificity:
1. project+tool
2. project
3. tool
4. global/user
5. first created compatible profile
6. none
If no explicit default exists, the first created compatible profile becomes the default launch selection. Users can still choose `None` to disable all profile config for a launch.
### Instance stores selected profile
Tool instances store the selected config profile ID, or null when `None` was selected. Restart uses the stored selection to avoid changing behavior when the user's default profile changes later.
## Risks / Trade-offs
- Existing config folder users may lose automatic mounts because legacy compatibility is explicitly out of scope. Mitigation: this is an accepted breaking change and can be handled by manually recreating profiles.
- Direct profile-owned files may duplicate content across profiles. Mitigation: include relationships provide reusable file-only profiles without adding another model.
- Plain env vars can contain secrets. Mitigation: label v1 env vars as non-secret/plain text and defer secret storage to a later change.
- Profile graph resolution can become complex. Mitigation: keep launch selection single-profile, use ordered includes, test cycle detection and override ordering thoroughly.
- Mount conflicts may surprise users. Mitigation: provide a resolved preview showing final env vars, mount targets, and overridden files before launch or in profile details.
## Migration Plan
- Add config profile tables and profile selection fields without preserving config folder behavior.
- Stop applying all active config folders during instance start.
- Apply selected config profile resolution during instance start/restart.
- Existing tool config runtime fields can remain available until replaced by profile runtime hints, but reusable file/env launch behavior moves to config profiles.
## Open Questions
- Should runtime hints initially include all existing tool config runtime fields, or only env vars/files/mounts with start command and working directory?
- Should resolved config preview be required before first implementation, or can it be shipped after CRUD/start selection?
@@ -0,0 +1,37 @@
## Why
Tool launches need reusable user-owned configuration that can combine editable files, mount targets, environment variables, and runtime hints without forcing every tool start to be configured from scratch. The existing config folder and tool config concepts provide pieces of this, but they do not model a single selectable, composable launch profile with project/tool-aware defaults and override behavior.
## What Changes
- Add config profiles as the primary reusable launch configuration model.
- Allow exactly one config profile, or no config profile, to be selected when starting a session.
- Allow profiles to include other profiles in ordered composition, with cycle detection at save and launch resolution.
- Let profiles be portable, tool-specific, project-specific, or project+tool-specific based on optional project and tool references.
- Store profile-owned UTF-8 text files under mount roots, with relative file paths and `ro`/`rw` mount modes.
- Store plain-text environment variables and runtime hints on profiles.
- Resolve profile layers deterministically: included profiles in order, then the selected profile, with later layers overriding earlier layers.
- Select defaults by specificity: project+tool, project, tool, global, then first compatible profile unless a default is explicitly configured.
- Store the selected config profile on started instances so restarts are predictable.
- **BREAKING**: Replace legacy always-active config folder mounting with explicit config profile selection. Legacy compatibility is not required for this change.
## Capabilities
### New Capabilities
- `config-profiles`: User-owned launch profiles that compose files, mounts, env vars, runtime hints, defaults, compatibility, and include relationships.
### Modified Capabilities
- `tool-instances`: Session creation/start behavior accepts an optional selected config profile, applies resolved profile output, supports no-profile starts, and persists the selected profile for restart behavior.
- `user-config`: User settings expose profile management/default selection surfaces for launch configuration.
- `tool-config-management`: Legacy config folder behavior is superseded by config profiles for file mounts and reusable launch setup.
## Impact
- Backend models and migrations for config profiles, ordered profile includes, and default selection.
- Backend APIs for profile CRUD, include management, default configuration, compatibility filtering, and resolved profile preview.
- Tool instance create/start/restart APIs and Docker compose staging logic to apply resolved profile env vars, mounts, files, and runtime hints.
- Settings UI for a small profile/file editor and default profile management.
- Start session UI to select one compatible config profile or `None` before launching.
- Tests for profile resolution, override ordering, cycle detection, compatibility filtering, defaults, and launch application.
@@ -0,0 +1,118 @@
## ADDED Requirements
### Requirement: Profile Ownership And Scope
The system SHALL manage config profiles as user-owned launch configuration records with optional project and tool type references that derive compatibility scope.
#### Scenario: Create portable profile
- **GIVEN** an authenticated user
- **WHEN** they create a config profile without a project or tool type
- **THEN** the profile is stored for that user
- **AND** the profile is compatible with any project and tool type owned or accessible by that user
#### Scenario: Create scoped profile
- **GIVEN** an authenticated user with access to a project and a tool type
- **WHEN** they create a config profile with `project_id`, `tool_type_id`, or both
- **THEN** the profile is stored with those references
- **AND** compatibility is derived from the non-null references
#### Scenario: Reject cross-user references
- **GIVEN** an authenticated user
- **WHEN** they create or update a profile with a project, tool type, include, or default reference they cannot access
- **THEN** the request is rejected
### Requirement: Profile Content
The system SHALL store profile content as plain environment variables, runtime hints, and one or more mount roots containing UTF-8 text files.
#### Scenario: Save env vars and runtime hints
- **GIVEN** an authenticated user editing a config profile
- **WHEN** they save plain-text environment variables and runtime hints such as start command, working directory, and port
- **THEN** the system persists those values on the profile
- **AND** returns them through the profile API
#### Scenario: Save mounted text files
- **GIVEN** an authenticated user editing a config profile
- **WHEN** they add a mount with an absolute `target_path`, mode `ro` or `rw`, and relative UTF-8 text file paths
- **THEN** the system persists the mount and files
- **AND** preserves file content exactly as UTF-8 text
#### Scenario: Reject unsafe file paths
- **GIVEN** an authenticated user editing a config profile mount
- **WHEN** they submit an absolute file path, an empty relative path, or a relative path containing `..`
- **THEN** the request is rejected
### Requirement: Ordered Profile Includes
The system SHALL allow a config profile to include other compatible profiles in a deterministic order.
#### Scenario: Add ordered includes
- **GIVEN** an authenticated user with multiple config profiles
- **WHEN** they configure profile A to include profile B then profile C
- **THEN** the include order is stored
- **AND** resolution processes B before C before A
#### Scenario: Reject include cycle on save
- **GIVEN** an authenticated user with profiles A and B where A already includes B
- **WHEN** they update B to include A
- **THEN** the request is rejected with a cycle error
#### Scenario: Guard against cycle during resolution
- **GIVEN** stored profile include data contains a cycle
- **WHEN** the system resolves a selected profile
- **THEN** resolution fails safely without launching a partially resolved configuration
### Requirement: Profile Resolution
The system SHALL resolve a selected profile by recursively applying included profiles in order and then applying the selected profile itself.
#### Scenario: Resolve layered env vars
- **GIVEN** profile A includes profile B then profile C
- **AND** B, C, and A define the same environment variable
- **WHEN** profile A is resolved
- **THEN** the value from A wins over C and B
- **AND** the value from C wins over B for keys not set by A
#### Scenario: Resolve mount file conflicts
- **GIVEN** multiple resolved layers define the same mount `target_path`
- **WHEN** they contain different files under that mount
- **THEN** their file trees are merged
- **AND** later layers replace earlier content for the same relative file path
#### Scenario: Resolve mount mode conflicts
- **GIVEN** multiple resolved layers define the same mount `target_path` with different modes
- **WHEN** the profile is resolved
- **THEN** the mode from the latest layer wins
### Requirement: Profile Defaults
The system SHALL select the default compatible profile by explicit default specificity, then by first created compatible profile, then no profile.
#### Scenario: Choose most specific explicit default
- **GIVEN** a user has explicit default profiles for global, tool, project, and project+tool scopes
- **WHEN** they start a matching project/tool combination
- **THEN** the project+tool default is selected
- **AND** project, tool, and global defaults are used only when no more-specific explicit default matches
#### Scenario: Fall back to first compatible profile
- **GIVEN** a user has compatible profiles but no explicit matching default
- **WHEN** they start a session for a project/tool combination
- **THEN** the oldest compatible profile is selected by default
#### Scenario: No compatible profile
- **GIVEN** a user has no compatible profile for a project/tool combination
- **WHEN** they start a session
- **THEN** the default profile selection is `None`
### Requirement: Profile Compatibility Filtering
The system SHALL list compatible config profiles for a selected project and tool type by default.
#### Scenario: List compatible profiles
- **GIVEN** an authenticated user has portable, project-specific, tool-specific, and unrelated profiles
- **WHEN** the launch UI requests profiles for a selected project and tool type
- **THEN** the response includes portable profiles and profiles matching that project, tool type, or both
- **AND** excludes unrelated project-specific or tool-specific profiles
### Requirement: Resolved Profile Preview
The system SHALL expose a resolved profile preview for a selected profile and project/tool context.
#### Scenario: Preview resolved output
- **GIVEN** an authenticated user selects a compatible config profile
- **WHEN** they request a resolved preview
- **THEN** the response includes the final environment variables, runtime hints, mount targets, mount modes, and relative file paths
- **AND** indicates overridden values where practical
@@ -0,0 +1,29 @@
## MODIFIED Requirements
### Requirement: Tool config supports runtime fields
The system SHALL keep tool config runtime fields available for tool configuration, while reusable launch setup for user-owned files, mounts, and env var bundles SHALL be handled by config profiles.
#### Scenario: Create config with runtime fields
- **WHEN** user creates a tool config with start_command="npm start", port=3000, working_directory="/app"
- **THEN** the config is saved with all fields populated
#### Scenario: Environment variables as JSON
- **WHEN** user sets environment_variables to {"NODE_ENV": "production", "API_KEY": "secret"}
- **THEN** the system stores and returns the config with the JSON object preserved
- **AND** reusable per-launch environment bundles are managed through config profiles
#### Scenario: Volumes as JSON
- **WHEN** user sets volumes to [{"host": "/data", "container": "/app/data", "mode": "rw"}]
- **THEN** the system stores and returns the config with the JSON array preserved
- **AND** user-owned mounted file trees are managed through config profiles
## REMOVED Requirements
### Requirement: Active config folders auto-mount during launch
The system SHALL NOT automatically mount all active config folders when starting tool instances.
#### Scenario: Start instance after config profiles replace folders
- **GIVEN** a user has legacy active config folders
- **WHEN** they start a tool instance without selecting a config profile
- **THEN** those folders are not automatically mounted
- **AND** only selected config profile output is applied for user-owned launch files and mounts
@@ -0,0 +1,55 @@
## MODIFIED Requirements
### Requirement: Clone mode instance creation
The system SHALL support creating tool instances with a clone mode and an optional selected config profile.
#### Scenario: Create instance in clone mode
- **GIVEN** an authenticated user with a repository that has an SSH key and remote URL
- **WHEN** they create an instance with `clone_mode: "clone"`, `branch: "main"`, and a compatible config profile selection
- **THEN** the system clones the repository into the instance directory
- **AND** the compose file uses the clone path as `REPO_PATH`
- **AND** the instance record stores `clone_mode="clone"`, `branch="main"`, and the selected config profile ID
#### Scenario: Create instance in mount mode
- **GIVEN** an authenticated user with a repository
- **WHEN** they create an instance with `clone_mode: "mount"` (or omit the field)
- **THEN** the compose file uses the host repository path as `REPO_PATH`
- **AND** the instance record stores `clone_mode="mount"`
#### Scenario: Create instance with no config profile
- **GIVEN** an authenticated user creating a tool instance
- **WHEN** they select `None` for config profile
- **THEN** the instance record stores no selected config profile
- **AND** profile-owned env vars, files, mounts, and runtime hints are not applied at start
#### Scenario: Reject incompatible config profile selection
- **GIVEN** an authenticated user creating a tool instance for a project and tool type
- **WHEN** they select a config profile scoped to a different project or tool type
- **THEN** instance creation or start is rejected
## ADDED Requirements
### Requirement: Config profile launch application
The system SHALL apply the selected config profile's resolved output when starting a tool instance.
#### Scenario: Start instance with selected profile
- **GIVEN** a pending tool instance with a selected compatible config profile
- **WHEN** the instance is started
- **THEN** the system resolves the selected profile
- **AND** writes resolved env vars into the instance environment
- **AND** stages resolved profile files under the instance directory
- **AND** adds Docker bind mounts for resolved profile mounts
- **AND** applies supported runtime hints before starting the container
#### Scenario: Restart uses stored profile selection
- **GIVEN** an existing instance was started with config profile A
- **AND** the user's default profile later changes to profile B
- **WHEN** the instance is restarted
- **THEN** the system uses stored profile A for restart behavior
- **AND** does not switch to profile B automatically
#### Scenario: Start fails on profile resolution error
- **GIVEN** an instance references a selected config profile that cannot be resolved
- **WHEN** the instance is started
- **THEN** the start request fails before launching the container
- **AND** the error explains the profile resolution problem
@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: Config Profile Settings Editor
The system SHALL expose a settings interface for managing the authenticated user's config profiles.
#### Scenario: Browse config profiles in settings
- **GIVEN** an authenticated user
- **WHEN** they open settings
- **THEN** they can view their config profiles
- **AND** see each profile's name, compatibility scope, default status, and included profiles
#### Scenario: Edit profile content in settings
- **GIVEN** an authenticated user editing a config profile
- **WHEN** they update env vars, runtime hints, mounts, text files, or include order
- **THEN** the settings UI saves those changes through the config profile API
- **AND** validation errors are shown without losing the user's draft edits
#### Scenario: Configure profile defaults in settings
- **GIVEN** an authenticated user editing config profiles
- **WHEN** they mark a profile as the default for a global, project, tool, or project+tool context
- **THEN** subsequent launches for matching contexts preselect that profile according to default specificity
### Requirement: Launch Profile Selection UI
The system SHALL allow the authenticated user to select one compatible config profile or `None` when starting a session.
#### Scenario: Preselect default compatible profile
- **GIVEN** an authenticated user has a default compatible profile for a selected project and tool type
- **WHEN** they open the start session form
- **THEN** the form preselects that profile
- **AND** also offers `None` as a selectable option
#### Scenario: Select no profile
- **GIVEN** an authenticated user starts a session
- **WHEN** they choose `None` in the config profile selector
- **THEN** the start request records no selected config profile
@@ -0,0 +1,32 @@
## 1. Sequential Foundation
- [x] 1.1 Backend data model and migrations. Add config profile, ordered include, mount/file, default selection, and tool instance selected-profile storage. Remove launch-time reliance on legacy active config folder auto-mounting. Depends on: none. Parallel with: none.
## 2. Parallel Backend Core After Foundation
- [x] 2.1 Profile resolver service. Implement recursive ordered include resolution, deterministic merge rules, save-independent cycle protection, and resolved output structures for env vars, runtime hints, mounts, file trees, and override metadata. Depends on: 1.1. Parallel with: 2.2, 2.3.
- [x] 2.2 Profile CRUD, validation, compatibility, and default APIs. Implement user-owned CRUD, access checks, path/content validation, ordered include save validation, compatibility-filtered listing, and default profile selection APIs. Depends on: 1.1. Parallel with: 2.1, 2.3.
- [x] 2.3 Instance API profile selection plumbing. Update instance create/start request and persistence paths to accept one compatible config profile ID or null, reject incompatible selections, and preserve `None`. Depends on: 1.1. Parallel with: 2.1, 2.2.
## 3. Sequential Backend Integration
- [x] 3.1 Resolved profile preview API. Return final env vars, runtime hints, mount targets, mount modes, relative file paths, and practical override indicators. Depends on: 2.1, 2.2. Parallel with: none.
- [x] 3.2 Launch and restart profile application. Apply resolved profile output during start by writing env vars, staging files, adding Docker bind mounts, applying supported runtime hints, skipping profile output for `None`, and using the stored profile on restart instead of current defaults. Depends on: 2.1, 2.3. Parallel with: none.
## 4. Parallel Frontend After Backend Contracts
- [x] 4.1 Frontend config profile API client and types. Add client methods/types for profile CRUD, includes, defaults, compatible listing, preview, and instance profile selection payloads. Depends on: 2.2, 2.3, 3.1. Parallel with: none.
- [x] 4.2 Settings profile management UI. Add settings surfaces for browsing profiles, editing env vars/runtime hints/mounts/text files/include order/defaults, and preserving draft edits on validation errors. Depends on: 4.1. Parallel with: 4.3.
- [x] 4.3 Launch profile selection UI. Update session start UI to load compatible profiles for the selected project/tool, preselect the resolved default, offer `None`, and submit selected profile ID or null. Depends on: 4.1. Parallel with: 4.2.
## 5. Parallel Test Suites
- [x] 5.1 Backend profile API and resolver tests. Cover CRUD, ownership, path validation, compatibility filtering, default precedence, include ordering, save-time cycle rejection, resolution-time cycle protection, and deterministic override behavior. Depends on: 2.1, 2.2, 3.1. Parallel with: 5.2, 5.3.
- [x] 5.2 Backend instance launch tests. Cover selected profile start/restart behavior, incompatible profile rejection, `None` selection, env var writing, file staging, Docker mount application, runtime hints, and legacy active config folders not auto-mounting. Depends on: 3.2. Parallel with: 5.1, 5.3.
- [x] 5.3 Frontend profile UI tests. Cover profile settings editing, validation error handling, compatible profile loading, default preselection, `None` selection, and submit payloads. Depends on: 4.2, 4.3. Parallel with: 5.1, 5.2.
## 6. Sequential Quality Gates
- [x] 6.1 Run backend quality gates for touched API/model/services code and fix failures. Depends on: 5.1, 5.2. Parallel with: none.
- [x] 6.2 Run frontend quality gates for touched settings/session UI code and fix failures. Depends on: 5.3. Parallel with: none.
- [x] 6.3 Run final OpenSpec status and implementation checklist review. Depends on: 6.1, 6.2. Parallel with: none.

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