Compare commits

...

200 Commits

Author SHA1 Message Date
amanape
cd40a99455 Merge remote-tracking branch 'origin/v1-integration' into v1-conversation-api 2025-10-10 16:33:46 +04:00
amanape
1f04fb58e2 Merge remote-tracking branch 'origin/main' into v1-conversation-api 2025-10-10 16:33:09 +04:00
amanape
5903ed8e8f fix(frontend): Add eslint disable comments for console warnings
- Add eslint-disable-next-line no-console for debugging console statements
- Maintain console warnings for development debugging while satisfying linter

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-10 16:31:28 +04:00
amanape
14477ac688 feat(frontend): Add V1 conversation API with streaming setup flow
- Add new app-conversation-service API layer with streaming support
- Implement useStreamStartAppConversation hook with TanStack Query integration
- Add ConversationSetupFlow component with real-time progress tracking
- Integrate setup mode into conversation routing with visual progress indicators
- Support conversation startup phases: sandbox setup, repository preparation, git hooks, etc.
- Include cancellation support and automatic query invalidation
- Remove documentation markdown file

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-10 16:31:11 +04:00
Tim O'Farrell
a3d8472d59 Flexibility in config 2025-10-09 20:44:03 -06:00
Tim O'Farrell
b975b048b9 Removed segements for checking sandbox specs. 2025-10-09 20:34:37 -06:00
Tim O'Farrell
6690ff8a66 Search, Get, Pause, Resume and delete remote sandboxes now working 2025-10-09 13:39:31 -06:00
amanape
811c1e4b55 Merge remote-tracking branch 'origin/v1-integration' into v1-conversation-api 2025-10-09 21:08:58 +04:00
Tim O'Farrell
1568e8932c Fix for callbacks 2025-10-09 10:48:30 -06:00
amanape
aea6c638d2 Merge remote-tracking branch 'origin/v1-integration' into v1-conversation-api 2025-10-09 20:33:26 +04:00
Tim O'Farrell
174a288a86 Docs update 2025-10-09 10:18:33 -06:00
Tim O'Farrell
ac9fbd39f1 Moved imports to give some default variables 2025-10-09 09:52:14 -06:00
amanape
83cd4c5380 Merge remote-tracking branch 'origin/v1-integration' into v1-ws-events 2025-10-09 19:29:15 +04:00
Tim O'Farrell
339f66ede3 Remote runtime fixes 2025-10-09 08:30:29 -06:00
Tim O'Farrell
2bef22c873 Merge branch 'main' into v1-integration 2025-10-09 08:03:14 -06:00
Tim O'Farrell
cd726e43f8 Fixed nit 2025-10-09 08:02:14 -06:00
Tim O'Farrell
241b5d251a Lint fix 2025-10-09 07:41:00 -06:00
Engel Nyst
47135c484a Include conversation_metadata in initial V1 Alembic migration (#11266)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-10-08 22:05:29 -06:00
Engel Nyst
41d01be3b4 Fix PostgreSQL readiness in app server without dependency changes (#11267)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-10-08 21:56:16 -06:00
Tim O'Farrell
e0c43556cd Fix postgres connection 2025-10-08 21:52:55 -06:00
Tim O'Farrell
6b9c5033b9 Added ability to skip alembic migrations
Useful for when I connect to my local version of postgres that was already set up using alembic
2025-10-08 21:45:47 -06:00
Tim O'Farrell
a06ecaa063 Fix for timestamps 2025-10-08 18:28:28 -06:00
Tim O'Farrell
be5af1b233 Fix sync issue in db callbacks 2025-10-08 15:01:51 -06:00
Tim O'Farrell
e0a4274f1a V0 fixed again 2025-10-08 14:47:56 -06:00
Tim O'Farrell
63fc091b09 Refactor to blend 2025-10-08 14:43:54 -06:00
Tim O'Farrell
529d77caad Fix for tests 2025-10-08 13:50:59 -06:00
Tim O'Farrell
7425103fc5 V0 is working again 2025-10-08 13:31:14 -06:00
Tim O'Farrell
d6ec0c4b0b Major refactor of dependency injection
Making things simpler, and allowing DI to run outside requests
2025-10-08 13:06:42 -06:00
sp.wack
2580b69bd5 feat(frontend): V1 WebSocket handler (child) (#11252)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-08 22:59:56 +04:00
amanape
f0195d91db fix tests 2025-10-08 21:44:00 +04:00
amanape
227e96e59f lint 2025-10-08 21:17:41 +04:00
amanape
c0d583043e Merge branch 'main' into v1-ws-events 2025-10-08 21:14:22 +04:00
Tim O'Farrell
aece63a93b DB fixes 2025-10-08 08:44:19 -06:00
Tim O'Farrell
37797d5ead Lint fixes 2025-10-07 16:24:26 -06:00
Tim O'Farrell
3d90105a18 WIP 2025-10-07 16:15:38 -06:00
Tim O'Farrell
07335d12ae Skip flaky test 2025-10-07 16:02:16 -06:00
Tim O'Farrell
c497620f1d Lint fixes 2025-10-07 15:48:47 -06:00
Tim O'Farrell
a6a1229573 Enterprise test fixes 2025-10-07 15:44:26 -06:00
Tim O'Farrell
7ce536fff9 Skip flaky test 2025-10-07 15:33:01 -06:00
Tim O'Farrell
f2e4c488d9 Fix for webhooks 2025-10-07 14:45:10 -06:00
Tim O'Farrell
c9cf3086d3 More test fixes 2025-10-07 13:55:05 -06:00
Tim O'Farrell
dafca18261 Merge branch 'v1-integration' of github.com:All-Hands-AI/OpenHands into v1-integration 2025-10-07 13:36:27 -06:00
Tim O'Farrell
f73aeb54b8 Lint fixes 2025-10-07 13:36:06 -06:00
Tim O'Farrell
d7b1321a32 Merge branch 'main' into v1-integration 2025-10-07 13:33:00 -06:00
Tim O'Farrell
6d3281e532 Fixed issue caused by linter 2025-10-07 13:32:36 -06:00
Tim O'Farrell
5f6b609d65 Lint fixes 2025-10-07 13:24:34 -06:00
Tim O'Farrell
cb1c115a99 Lint fixes 2025-10-07 13:15:14 -06:00
Tim O'Farrell
9870344f21 Using user contexts 2025-10-07 12:41:39 -06:00
Tim O'Farrell
fe5ce8d09a Lint fixes 2025-10-07 12:27:06 -06:00
Tim O'Farrell
04076d09ab WIP 2025-10-07 11:38:32 -06:00
Tim O'Farrell
41d1ce33be Prevent breaking changes 2025-10-07 11:34:26 -06:00
Tim O'Farrell
4297f230c5 More lint fixes 2025-10-07 11:33:25 -06:00
Tim O'Farrell
3229e2d5d1 Adding default empty value 2025-10-07 11:03:22 -06:00
Tim O'Farrell
0a1f5825e3 Lint fixes 2025-10-07 10:54:29 -06:00
Tim O'Farrell
ebf748f0b3 Merge branch 'main' into v1-integration 2025-10-07 10:53:49 -06:00
Tim O'Farrell
9a2d92307e Unit tests passing 2025-10-07 10:53:18 -06:00
Tim O'Farrell
e2b225a3b9 Test fixes 2025-10-07 10:25:14 -06:00
Tim O'Farrell
53300b8e19 Docs update 2025-10-07 09:53:20 -06:00
Tim O'Farrell
3cd398ea99 Local lint passes 2025-10-07 09:52:28 -06:00
Tim O'Farrell
2009876ff7 SandboxSpecInjector 2025-10-07 04:06:05 -06:00
Tim O'Farrell
24a8818e6e Converted user_id to user_context 2025-10-07 03:58:53 -06:00
Tim O'Farrell
d152a92a3d Using reasoning tokens 2025-10-06 14:54:30 -06:00
Tim O'Farrell
d35a1b5499 Use module instead of script 2025-10-06 12:34:42 -06:00
Tim O'Farrell
7572ca9244 Merge branch 'v1-integration' of github.com:All-Hands-AI/OpenHands into v1-integration 2025-10-06 12:32:11 -06:00
Tim O'Farrell
58a661dc83 Fix docker build 2025-10-06 12:31:21 -06:00
Tim O'Farrell
3b630644d0 Merge branch 'main' into v1-integration 2025-10-06 11:45:32 -06:00
Tim O'Farrell
e4320bc1e5 Fix build error 2025-10-06 11:41:51 -06:00
Tim O'Farrell
46b869b8e8 Merge branch 'v1-integration' of github.com:All-Hands-AI/OpenHands into v1-integration 2025-10-06 11:38:11 -06:00
Tim O'Farrell
9f2406d034 Added required dependency 2025-10-06 11:37:51 -06:00
Tim O'Farrell
547dd449cd Merge branch 'main' into v1-integration 2025-10-06 11:05:15 -06:00
Tim O'Farrell
8ac437994a Lifespans fix 2025-10-06 10:22:23 -06:00
Tim O'Farrell
e520ed06ea No lifespan for SAAS 2025-10-06 10:01:25 -06:00
Tim O'Farrell
c3f0110b54 Unsecured mode can't start conversations 2025-10-06 09:36:11 -06:00
Tim O'Farrell
2776c83c3b Merge branch 'v1-integration' of github.com:All-Hands-AI/OpenHands into v1-integration 2025-10-06 09:24:43 -06:00
Tim O'Farrell
6df875caf7 Default web host 2025-10-06 09:24:20 -06:00
Tim O'Farrell
e15e40a920 Merge branch 'main' into v1-integration 2025-10-06 08:44:25 -06:00
Tim O'Farrell
d8bb9a9184 Fix version 2025-10-06 08:19:42 -06:00
Tim O'Farrell
38d836aab3 Lint fixes 2025-10-06 08:10:40 -06:00
Tim O'Farrell
68f6d1ac81 Merge branch 'main' into v1-integration 2025-10-06 08:09:00 -06:00
Tim O'Farrell
7fdf327fe6 Test fix 2025-10-06 08:06:27 -06:00
Tim O'Farrell
c47cda2e74 Session commits 2025-10-06 07:56:24 -06:00
Tim O'Farrell
681170b822 Added missing await 2025-10-06 07:54:12 -06:00
Tim O'Farrell
8f1d5bc52a Merge branch 'v1-integration' of github.com:All-Hands-AI/OpenHands into v1-integration 2025-10-06 07:53:00 -06:00
Tim O'Farrell
b052f4e380 Fixed AI slop! 2025-10-06 07:52:37 -06:00
Tim O'Farrell
e17964e56a Merge branch 'main' into v1-integration 2025-10-05 20:48:43 -06:00
Tim O'Farrell
5ab016ee4b Lint fixes 2025-10-05 20:38:30 -06:00
Tim O'Farrell
30f7e37002 Build fix 2025-10-05 20:13:39 -06:00
Tim O'Farrell
c28601df67 God grant me strength to deal with the linter settings on this project... 2025-10-05 20:00:31 -06:00
Tim O'Farrell
3cc6d0dd22 Ruff fix 2025-10-05 19:42:03 -06:00
Tim O'Farrell
ce77677e20 Ruff 2025-10-05 19:37:36 -06:00
Tim O'Farrell
af4272fde7 Fixed runtime build 2025-10-05 19:32:27 -06:00
Tim O'Farrell
64510bf6e7 Remove V1 args from V0 storage 2025-10-05 16:55:42 -06:00
Tim O'Farrell
1f1178613f Merge branch 'v1-integration' of github.com:All-Hands-AI/OpenHands into v1-integration 2025-10-05 16:31:08 -06:00
Tim O'Farrell
7948a8e02b Removed old test 2025-10-05 15:53:44 -06:00
openhands
e332ad7156 Fix ruff formatting issues in enterprise migration file
- Fix import order (sqlalchemy before alembic)
- Convert double quotes to single quotes
- Remove extra whitespace

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-05 21:24:37 +00:00
Tim O'Farrell
8864cb25c3 Lint fixes 2025-10-05 15:15:57 -06:00
Tim O'Farrell
c0dba38200 Lint fixes 2025-10-05 15:15:21 -06:00
Tim O'Farrell
14e17afa09 Added enterprise migration 2025-10-05 11:45:36 -06:00
Tim O'Farrell
39f04ae554 Added sandbox check 2025-10-05 11:37:54 -06:00
Tim O'Farrell
0581f94fdf Latest revision 2025-10-05 11:33:23 -06:00
Tim O'Farrell
1e2d878f38 Lint fixes 2025-10-05 11:28:21 -06:00
Tim O'Farrell
682849a6a6 Lint fixes 2025-10-05 11:28:04 -06:00
Tim O'Farrell
796d34f4ae Added remote runtime as default option 2025-10-05 11:14:52 -06:00
Tim O'Farrell
34207e70bd Merge branch 'v1-integration' of github.com:All-Hands-AI/OpenHands into v1-integration 2025-10-05 11:09:01 -06:00
Tim O'Farrell
813b9bd86c Remote sandbox updates 2025-10-05 11:08:41 -06:00
openhands
8802e5e4dd Implement search_sandbox_specs and get_sandbox_spec methods in RemoteSandboxSpecService
- Added search_sandbox_specs method with pagination support
- Added get_sandbox_spec method to find specs by ID
- Both methods now work with the local specs list instead of raising NotImplementedError
- Follows the same pattern as DockerSandboxSpecService implementation
- All existing tests continue to pass

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-05 15:08:06 +00:00
Tim O'Farrell
6a94492e9e Doc updates 2025-10-05 08:57:25 -06:00
Tim O'Farrell
451dbb83a7 Test fix 2025-10-05 08:54:30 -06:00
Tim O'Farrell
03a29dba17 Added remite sandbox spec service 2025-10-05 08:52:15 -06:00
openhands
46de352a50 Fix failing tests in test_conversation.py
- Add missing __init__.py file in app_conversation module
- Add app_conversation_service mock parameter to 10 failing test functions
- Fix base64 encoding issues in pagination tests by using proper encoded page IDs
- Update test assertions to expect base64-encoded next_page_id values
- All 26 tests now pass consistently

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-05 14:25:38 +00:00
Tim O'Farrell
b00feb0b29 Lint fix 2025-10-05 08:17:42 -06:00
Tim O'Farrell
d1c000d23a Lint fixes 2025-10-05 08:09:25 -06:00
Tim O'Farrell
d2a6c8d546 Fix for single test 2025-10-05 07:58:35 -06:00
Tim O'Farrell
7126c3c78d Removed example - we will add more dedicated examples later 2025-10-04 21:40:46 -06:00
Tim O'Farrell
2e3b418936 Merge branch 'main' into v1-integration 2025-10-04 21:27:43 -06:00
Tim O'Farrell
290ff987ec Removed summary 2025-10-04 12:18:34 -06:00
openhands
e967bd766d Implement RemoteSandboxService for HTTP-based remote runtime communication
- Add RemoteSandboxService that adapts legacy RemoteRuntime HTTP protocol to new Sandbox interface
- Support for start, pause, resume, delete operations via HTTP endpoints (/start, /pause, /resume, /stop)
- Comprehensive configuration with RemoteSandboxConfig using Pydantic validation
- ID mapping between sandbox IDs and runtime IDs for proper resource tracking
- Status translation from legacy runtime format to new SandboxStatus enum
- Full test suite with 17 test cases covering all major functionality
- Documentation and usage examples included

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 18:16:58 +00:00
openhands
b13f676568 Fix MCP server initialization JSON serialization error
- Remove dependencies=get_dependencies() parameter from FastMCP initialization
- Remove unused get_dependencies import from mcp.py
- Set dependencies=None explicitly to avoid JSON serialization of Depends objects
- Fixes failing tests: test_settings_api_endpoints, test_openapi_schema_generation, and MCP-related tests

The issue was that FastMCP was trying to serialize FastAPI Depends objects to JSON
for deprecation warnings, but Depends objects are not JSON serializable.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 17:42:20 +00:00
openhands
51dc936859 Fix formatting in enterprise/storage/database.py
Add blank lines after import statements to follow Python formatting conventions.
This fixes the lint enterprise python workflow failure.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 16:29:50 +00:00
Tim O'Farrell
12517393d3 Pre-commit 2025-10-04 09:58:32 -06:00
Tim O'Farrell
fdda78a0ea Ruff format 2025-10-04 09:25:37 -06:00
Tim O'Farrell
17b18d4da5 Ruff fixes 2025-10-04 09:21:46 -06:00
Tim O'Farrell
6ffb3ff7ec Added initial migration 2025-10-04 09:15:35 -06:00
Tim O'Farrell
8b6cf6f262 Update validator 2025-10-04 08:21:53 -06:00
openhands
063d19748e Implement alembic integration for app_server database migrations
- Configure alembic to use DbService from get_global_config for database connectivity
- Set up env.py to auto-detect models from app_server declarative base
- Update README with concise instructions for migration commands
- Replace create_all calls in OssAppLifespanService with alembic upgrade head
- Fix db_service.py import to use proper SQLAlchemy sessionmaker
- Add robust logging configuration handling in alembic env.py
- Fix mypy issues with optional password field handling
- Add noqa comments to suppress E402 linting errors for imports after sys.path modification

This integration keeps SQLite databases up to date in single-user deployments
using the declarative base from openhands.app_server.utils.sql_utils.Base.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 14:18:10 +00:00
openhands
d716195b57 Apply linting fixes to Docker sandbox service tests
- Fix quote style consistency (single quotes)
- Apply ruff formatting fixes
- Maintain test functionality while improving code style

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 14:15:30 +00:00
openhands
b581fc0622 Add comprehensive unit tests for Docker sandbox services
- Add 39 test cases for DockerSandboxService covering container lifecycle management
- Add 17 test cases for DockerSandboxSpecService covering image management
- Use unittest.mock for Docker API mocking to maintain consistency with existing codebase
- Cover success paths, error handling, pagination, and edge cases
- All 56 tests pass successfully

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 13:59:55 +00:00
openhands
783f4626a9 Add comprehensive tests for HttpxClientManager
- Test client reuse within the same request context
- Test client isolation between different requests
- Test timeout configuration (default and custom)
- Test async generator lifecycle behavior
- Test concurrent access to same request
- Test request state persistence and attribute handling
- Document current client cleanup behavior and suggest improvements

The tests ensure that httpx clients are properly managed:
- One client per request context (reused within same request)
- Different requests get different client instances
- Proper timeout configuration is applied
- Request state is correctly managed

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 07:58:52 +00:00
openhands
bf37ac92a0 Add comprehensive tests for DbService
- Test managed session reuse within request context
- Test unmanaged session behavior and reuse
- Test configuration processing from environment variables
- Test GCP database connection configuration
- Test PostgreSQL connection when host is defined
- Test SQLite fallback connection
- Test engine and session maker reuse
- Test edge cases and error conditions

All 24 tests pass successfully.
2025-10-04 07:58:00 +00:00
openhands
2ddf051ea6 Add comprehensive unit tests for JwtService
- Add tests for JWS token sign/verify round trip functionality
- Add tests for JWE token encrypt/decrypt round trip functionality
- Test key management and rotation scenarios
- Test error handling and edge cases
- Test complex payload structures and unicode characters
- Test token expiration timing
- All 25 tests pass successfully

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 07:51:19 +00:00
openhands
5f7f149633 refactor: Remove SQLModel dependencies from AppConversationStartTask and EventCallback services
- Refactored AppConversationStartTask and EventCallback models to pure Pydantic BaseModel
- Created StoredAppConversationStartTask and StoredEventCallback SQLAlchemy classes
- Updated SQLAppConversationStartTaskService and SQLEventCallbackService to use pure SQLAlchemy
- Added comprehensive test coverage for both refactored services
- Removed SQLModel imports from model files and converted SQLField to Field
- Fixed abstract method in AppConversationService to provide required parameters
- All tests passing (21/21) and pre-commit hooks satisfied

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 03:16:58 +00:00
openhands
f143346406 Add comprehensive tests for SQLAppConversationInfoService
- Created comprehensive test suite with 17 test cases covering:
  - Basic CRUD operations (save, get, round trips)
  - Search functionality with various filters
  - Count functionality with filters
  - Batch get operations
  - Pagination functionality
  - Sorting with different orders
- Fixed service implementation bugs:
  - Fixed search method to use result.scalars().all()
  - Fixed count method with proper func.count() query
  - Fixed batch_get method result processing
- Removed redundant func import (already imported at top)
- All tests pass successfully

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 02:21:11 +00:00
openhands
5364744e0c Fix Python lint issues in CI
- Fix F821 errors: Add anext compatibility import for Python < 3.10
- Fix B008 errors: Create module-level dependency variables to avoid function calls in argument defaults
- Remove invalid ASYNC101 noqa comments that don't exist in ruff
- All pre-commit hooks now pass: ruff, ruff-format, mypy, and other checks

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-04 01:22:33 +00:00
Tim O'Farrell
651fde9cf1 Pre-commit fixes 2025-10-03 19:02:46 -06:00
Tim O'Farrell
75fc2ed5ba Merge branch 'main' into v1-integration 2025-10-03 18:52:37 -06:00
Tim O'Farrell
89c79ce78a Stripped out SQLModel 2025-10-03 18:51:49 -06:00
Tim O'Farrell
607a3e6e1e DB Updates 2025-10-03 15:41:27 -06:00
Tim O'Farrell
028a66f028 Lifespans in place 2025-10-03 13:57:55 -06:00
Tim O'Farrell
2253048a2d Starting conversations works again! 2025-10-03 12:15:01 -06:00
amanape
38e7cd3f34 Merge branch 'main' into v1-ws-events 2025-10-03 21:47:24 +04:00
amanape
bf3f858d4e feat: Add sendMessage functionality to useWebSocket hook
- Add sendMessage function to send data through WebSocket connection
- Implement proper connection state checking (only send when OPEN)
- Support all WebSocket data types (string, ArrayBufferLike, Blob, ArrayBufferView)
- Add comprehensive test coverage for both connected and disconnected states
- Use React.useCallback for performance optimization
- Follow TDD approach with failing tests first

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-03 20:49:51 +04:00
amanape
2663d408d8 feat: Add WebSocket event handlers support to useWebSocket hook
- Add onOpen, onClose, onMessage, and onError callback options
- Implement TDD approach with comprehensive test coverage
- Support custom event handlers while maintaining existing functionality
- Call onError for both onerror events and error closures (non-1000 codes)
- All tests passing with MSW WebSocket mocking

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-03 20:47:03 +04:00
Tim O'Farrell
493c4bfa69 More fixes 2025-10-03 09:47:43 -06:00
Tim O'Farrell
39bbfac66d Dependency refactor passes lint check 2025-10-03 09:37:03 -06:00
amanape
02c9b66aac small refactor 2025-10-03 18:21:00 +04:00
amanape
4adb49da2a feat: Add query parameters support to useWebSocket hook
- Add optional second parameter for query parameters configuration
- Support Record<string, string> type for query parameters
- Use URLSearchParams for proper URL encoding and building
- Maintain full backward compatibility with existing usage
- Update useEffect dependencies to track options changes

Usage:
  useWebSocket('ws://example.com/ws', {
    queryParams: { token: 'abc123', userId: 'user456' }
  })
  // Results in: ws://example.com/ws?token=abc123&userId=user456

Tests:  5/5 passing
-  should establish a WebSocket connection
-  should handle incoming messages correctly
-  should handle connection errors gracefully
-  should close the WebSocket connection on unmount
-  should support query parameters in WebSocket URL

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-03 17:17:14 +04:00
amanape
90fbcb489b refactor: Convert useWebSocket hook to arrow function
- Convert function declaration to arrow function syntax for consistency
- Remove unused todo test for explicit close functionality
- All 4 tests still passing after refactor:
   should establish a WebSocket connection
   should handle incoming messages correctly
   should handle connection errors gracefully
   should close the WebSocket connection on unmount

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-03 17:09:03 +04:00
amanape
ac4821b3f2 feat: Add WebSocket hook with MSW testing infrastructure
- Implement basic useWebSocket hook with connection management
- Add comprehensive test suite using MSW for WebSocket mocking
- Support connection establishment, message handling, and error management
- Include state management for isConnected, messages, lastMessage, and error
- Handle both connection closure errors (onclose) and protocol errors (onerror)
- Follow TDD principles with failing tests first, then minimal implementation

Tests implemented:
-  should establish a WebSocket connection
-  should handle incoming messages correctly
-  should handle connection errors gracefully
- 🔄 should close the WebSocket connection on unmount (todo)
- 🔄 should close the WebSocket when called explicitly (todo)

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-03 17:02:41 +04:00
Tim O'Farrell
529b282924 Now passing secrets 2025-10-02 21:09:16 -06:00
Tim O'Farrell
4e84ddb6bc Updated sdk 2025-10-02 19:33:50 -06:00
Tim O'Farrell
2fa0215ae6 Secrets management 2025-10-02 19:20:03 -06:00
Tim O'Farrell
c87e08cdf4 Merge branch 'main' into v1-integration 2025-10-02 11:59:03 -06:00
amanape
e271e5fb5c v1 event store 2025-10-02 20:53:43 +04:00
Tim O'Farrell
e99d526836 Merge branch 'v1-integration' of github.com:All-Hands-AI/OpenHands into v1-integration 2025-10-02 09:55:54 -06:00
Tim O'Farrell
327dbfdfb0 Git integration 2025-10-02 09:55:31 -06:00
sp.wack
6eb9422d8a Merge branch 'main' into v1-integration 2025-10-02 19:04:28 +04:00
Tim O'Farrell
8ebe8ba085 Merge branch 'main' into v1-integration 2025-10-01 20:59:36 -06:00
Tim O'Farrell
e51cbe4cac Revert 2025-10-01 20:53:39 -06:00
Tim O'Farrell
ed29b49961 Revert auto fix 2025-10-01 20:47:59 -06:00
Tim O'Farrell
8e12ce9f06 WIP 2025-10-01 20:46:51 -06:00
Tim O'Farrell
b503cb2eb7 WIP 2025-10-01 20:43:14 -06:00
Tim O'Farrell
b88138f6c5 Revert 2025-10-01 20:37:41 -06:00
Tim O'Farrell
679396768e Code review update 2025-10-01 20:33:11 -06:00
Tim O'Farrell
d8b4410500 Moved db 2025-10-01 20:30:14 -06:00
Tim O'Farrell
9021ba3724 More fixes 2025-10-01 14:33:55 -06:00
Tim O'Farrell
355ae5e067 Working webhooks 2025-10-01 13:22:30 -06:00
Tim O'Farrell
e08eb5d503 TMUX Fix 2025-10-01 12:23:30 -06:00
Tim O'Farrell
cabeb6514f Merge branch 'main' into v1-integration 2025-10-01 12:00:48 -06:00
Tim O'Farrell
96a43dd80f WIP 2025-10-01 11:58:58 -06:00
Tim O'Farrell
3b8363d190 WIP 2025-10-01 11:29:24 -06:00
openhands
9c8982de80 Fix pre-commit failures on v1-integration branch
- Add target-version = 'py312' to ruff.toml to fix F821 undefined anext errors
- Fix B020 loop variable override by renaming 'task' to 'updated_task' in live_status_app_conversation_service.py
- Fix mypy arg-type error by changing method signatures to use Sequence instead of list
- Remove unused Optional import from vscode/__init__.py

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-01 15:41:20 +00:00
Tim O'Farrell
c8df5ceb83 Shim fixes 2025-10-01 09:23:58 -06:00
openhands
89b02b933a Apply pre-commit formatting and linting fixes
- Fix code formatting with ruff-format
- Fix import ordering and style issues
- Add missing newlines at end of files
- Fix various linting issues across multiple files

Note: Some unrelated linting issues exist in other files but are not part of this change
2025-10-01 13:55:04 +00:00
openhands
f8b1e1bb44 Merge remote changes and complete V1 conversation integration
- Resolve merge conflicts with remote branch
- Keep complete implementation of _to_conversation_info method
- Keep complete implementation of search_conversations with combined results
- Maintain proper exception handling and pagination logic
2025-10-01 13:53:35 +00:00
openhands
0c584d3bce Implement V1 conversation integration in manage_conversations.py
- Implement _to_conversation_info method to convert AppConversation to StoredConversation with V1 version
- Update search_conversations to combine results from both old and new conversation services
- Add proper status mapping from SandboxStatus to ConversationStatus
- Implement combined pagination using JSON-encoded page_ids for both sources
- Apply filters to both V0 and V1 conversation results
- Sort combined results by creation date (most recent first)
- Fix bare except clause to use specific exception types
2025-10-01 13:50:59 +00:00
Tim O'Farrell
06b861cc83 Started shim 2025-10-01 07:13:45 -06:00
Tim O'Farrell
f599683faf WIP 2025-10-01 06:53:44 -06:00
Tim O'Farrell
1663bfc64c Fixed trailing comma 2025-10-01 06:37:41 -06:00
Tim O'Farrell
2a8178aefa Streaming conversation starts 2025-10-01 03:37:29 -06:00
Tim O'Farrell
940f906196 Webhook callbacks now working 2025-09-30 20:20:56 -06:00
Tim O'Farrell
4ed966817b LocalWorkspace hack 2025-09-30 18:08:09 -06:00
Tim O'Farrell
a42200bf40 Updated workspace 2025-09-30 17:43:47 -06:00
Tim O'Farrell
f646c0d4af Updated start conversation service 2025-09-30 17:20:01 -06:00
openhands
8b9a89bc39 Implement SQLAppConversationStartTaskService
- Add SQLAppConversationStartTaskService with batch_get, get, and save methods
- Fix AppConversationStartTaskService interface return types and add missing get method
- Add SQLAppConversationStartTaskServiceResolver for dependency injection
- Register service in dependency resolver and config
- Follow same patterns as SQLAppConversationInfoService
- Include proper error handling, logging, and async support
- Apply code formatting fixes from pre-commit hooks

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-30 19:27:08 +00:00
Tim O'Farrell
07b9f20ccc WIP 2025-09-30 13:08:42 -06:00
Tim O'Farrell
868b3debf0 Simplified API
Getting, Pausing, Deleting sandboxes works. Getting sandbox specs works
2025-09-30 10:53:59 -06:00
Tim O'Farrell
15e62d323c Renamed sandboxed conversation to app conversation 2025-09-30 10:08:10 -06:00
Tim O'Farrell
d2bb37d8cf The server starts 2025-09-30 09:29:53 -06:00
Tim O'Farrell
f1d5e403cd Lint fixes 2025-09-30 09:14:03 -06:00
Tim O'Farrell
7998931bde Merge branch 'v1-integration' of github.com:All-Hands-AI/OpenHands into v1-integration 2025-09-30 09:13:01 -06:00
openhands
3468bad2f0 Apply code formatting fixes and update poetry.lock
- Fix import organization and line length formatting in SQL service files
- Update poetry.lock with latest dependency resolution
- These changes were automatically applied by pre-commit hooks
2025-09-30 15:11:59 +00:00
Tim O'Farrell
aae0681fab added docs 2025-09-30 08:51:42 -06:00
Tim O'Farrell
eaa29bb2e4 Updated import 2025-09-30 08:48:51 -06:00
openhands
b7a129ae31 Fix namespace collision between local openhands package and external dependencies
- Convert local openhands package to namespace package using pkgutil.extend_path
- Create separate _version.py module for version information
- Preserve backward compatibility for version imports
- Resolve ModuleNotFoundError for openhands.agent_server, openhands.sdk, openhands.tools
- Enable coexistence of local and external openhands modules
- Fix CLI version imports to use new _version module

This allows the project to import the three dependent submodules (openhands.agent_server,
openhands.sdk, openhands.tools) from external dependencies while maintaining the local
openhands package structure for legacy compatibility.
2025-09-30 14:44:19 +00:00
Tim O'Farrell
210cd76904 Added router 2025-09-30 08:23:29 -06:00
Tim O'Farrell
73238342cb Ruff fixes 2025-09-30 08:06:30 -06:00
Tim O'Farrell
7727acc7eb WIP 2025-09-30 07:41:31 -06:00
openhands
093b16143a Add comprehensive README.md files for app_server package and all sub-packages
- Updated main app_server README with overview and architecture
- Added README for conversation/ package covering sandboxed conversation management
- Added README for event/ package covering event storage and streaming
- Added README for event_callback/ package covering webhooks and callbacks
- Updated sandbox/ README with comprehensive sandbox management info
- Added README for user/ package covering authentication and user management
- Added README for services/ package covering JWT and core services
- Added README for utils/ package covering common utilities

All README files are concise (≤20 lines) and provide clear module overviews.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-30 02:12:09 +00:00
openhands
bcf2e090b0 Fix pre-commit checks: mypy errors, ruff linting, and formatting
- Fixed all 15 mypy type checking errors across 10 files
- Added missing @abstractmethod decorators to fix B027 ruff errors
- Added missing __aexit__ methods to async context manager classes
- Fixed type annotations and None handling issues
- Fixed docker API call overload issue with type ignore
- All pre-commit checks now pass successfully

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-30 00:32:07 +00:00
Tim O'Farrell
d21f7eb23d Ruff fixes 2025-09-29 16:07:31 -06:00
122 changed files with 17077 additions and 236 deletions

View File

@@ -126,7 +126,7 @@ jobs:
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Create source distribution and Dockerfile
run: poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
run: poetry run python3 -m openhands.runtime.utils.runtime_build --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV

View File

@@ -6,7 +6,7 @@ that depends on the `base_image` **AND** a [Python source distribution](https://
The following command will generate a `Dockerfile` file for `nikolaik/python-nodejs:python3.12-nodejs22` (the default base image), an updated `config.sh` and the runtime source distribution files/folders into `containers/runtime`:
```bash
poetry run python3 openhands/runtime/utils/runtime_build.py \
poetry run python3 -m openhands.runtime.utils.runtime_build \
--base_image nikolaik/python-nodejs:python3.12-nodejs22 \
--build_folder containers/runtime
```

View File

@@ -0,0 +1,259 @@
"""Sync DB with Models
Revision ID: 076
Revises: 075
Create Date: 2025-10-05 11:28:41.772294
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartTaskStatus,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResultStatus,
)
# revision identifiers, used by Alembic.
revision: str = '076'
down_revision: Union[str, Sequence[str], None] = '075'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
'conversation_metadata',
sa.Column('max_budget_per_task', sa.Float(), nullable=True),
)
op.add_column(
'conversation_metadata',
sa.Column('cache_read_tokens', sa.Integer(), server_default='0'),
)
op.add_column(
'conversation_metadata',
sa.Column('cache_write_tokens', sa.Integer(), server_default='0'),
)
op.add_column(
'conversation_metadata',
sa.Column('reasoning_tokens', sa.Integer(), server_default='0'),
)
op.add_column(
'conversation_metadata',
sa.Column('context_window', sa.Integer(), server_default='0'),
)
op.add_column(
'conversation_metadata',
sa.Column('per_turn_token', sa.Integer(), server_default='0'),
)
op.add_column(
'conversation_metadata',
sa.Column(
'conversation_version', sa.String(), nullable=False, server_default='V0'
),
)
op.create_index(
op.f('ix_conversation_metadata_conversation_version'),
'conversation_metadata',
['conversation_version'],
unique=False,
)
op.add_column('conversation_metadata', sa.Column('sandbox_id', sa.String()))
op.create_index(
op.f('ix_conversation_metadata_sandbox_id'),
'conversation_metadata',
['sandbox_id'],
unique=False,
)
op.create_table(
'app_conversation_start_task',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_by_user_id', sa.String(), nullable=True),
sa.Column('status', sa.Enum(AppConversationStartTaskStatus), nullable=True),
sa.Column('detail', sa.String(), nullable=True),
sa.Column('app_conversation_id', sa.UUID(), nullable=True),
sa.Column('sandbox_id', sa.String(), nullable=True),
sa.Column('agent_server_url', sa.String(), nullable=True),
sa.Column('request', sa.JSON(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_app_conversation_start_task_created_at'),
'app_conversation_start_task',
['created_at'],
unique=False,
)
op.create_index(
op.f('ix_app_conversation_start_task_created_by_user_id'),
'app_conversation_start_task',
['created_by_user_id'],
unique=False,
)
op.create_index(
op.f('ix_app_conversation_start_task_updated_at'),
'app_conversation_start_task',
['updated_at'],
unique=False,
)
op.create_table(
'event_callback',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('conversation_id', sa.UUID(), nullable=True),
sa.Column('processor', sa.JSON(), nullable=True),
sa.Column('event_kind', sa.String(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_event_callback_created_at'),
'event_callback',
['created_at'],
unique=False,
)
op.create_table(
'event_callback_result',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('status', sa.Enum(EventCallbackResultStatus), nullable=True),
sa.Column('event_callback_id', sa.UUID(), nullable=True),
sa.Column('event_id', sa.UUID(), nullable=True),
sa.Column('conversation_id', sa.UUID(), nullable=True),
sa.Column('detail', sa.String(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_event_callback_result_conversation_id'),
'event_callback_result',
['conversation_id'],
unique=False,
)
op.create_index(
op.f('ix_event_callback_result_created_at'),
'event_callback_result',
['created_at'],
unique=False,
)
op.create_index(
op.f('ix_event_callback_result_event_callback_id'),
'event_callback_result',
['event_callback_id'],
unique=False,
)
op.create_index(
op.f('ix_event_callback_result_event_id'),
'event_callback_result',
['event_id'],
unique=False,
)
op.create_table(
'v1_remote_sandbox',
sa.Column('id', sa.String(), nullable=False),
sa.Column('created_by_user_id', sa.String(), nullable=True),
sa.Column('sandbox_spec_id', sa.String(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_v1_remote_sandbox_created_at'),
'v1_remote_sandbox',
['created_at'],
unique=False,
)
op.create_index(
op.f('ix_v1_remote_sandbox_created_by_user_id'),
'v1_remote_sandbox',
['created_by_user_id'],
unique=False,
)
op.create_index(
op.f('ix_v1_remote_sandbox_sandbox_spec_id'),
'v1_remote_sandbox',
['sandbox_spec_id'],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
op.f('ix_v1_remote_sandbox_sandbox_spec_id'), table_name='v1_remote_sandbox'
)
op.drop_index(
op.f('ix_v1_remote_sandbox_created_by_user_id'), table_name='v1_remote_sandbox'
)
op.drop_index(
op.f('ix_v1_remote_sandbox_created_at'), table_name='v1_remote_sandbox'
)
op.drop_table('v1_remote_sandbox')
op.drop_index(
op.f('ix_event_callback_result_event_id'),
table_name='event_callback_result',
)
op.drop_index(
op.f('ix_event_callback_result_event_callback_id'),
table_name='event_callback_result',
)
op.drop_index(
op.f('ix_event_callback_result_created_at'),
table_name='event_callback_result',
)
op.drop_index(
op.f('ix_event_callback_result_conversation_id'),
table_name='event_callback_result',
)
op.drop_table('event_callback_result')
op.drop_index(op.f('ix_event_callback_created_at'), table_name='event_callback')
op.drop_table('event_callback')
op.drop_index(
op.f('ix_app_conversation_start_task_updated_at'),
table_name='app_conversation_start_task',
)
op.drop_index(
op.f('ix_app_conversation_start_task_created_by_user_id'),
table_name='app_conversation_start_task',
)
op.drop_index(
op.f('ix_app_conversation_start_task_created_at'),
table_name='app_conversation_start_task',
)
op.drop_table('app_conversation_start_task')
op.drop_column('conversation_metadata', 'sandbox_id')
op.drop_column('conversation_metadata', 'conversation_version')
op.drop_column('conversation_metadata', 'per_turn_token')
op.drop_column('conversation_metadata', 'context_window')
op.drop_column('conversation_metadata', 'reasoning_tokens')
op.drop_column('conversation_metadata', 'cache_write_tokens')
op.drop_column('conversation_metadata', 'cache_read_tokens')
op.drop_column('conversation_metadata', 'max_budget_per_task')
op.execute('DROP TYPE appconversationstarttaskstatus')
op.execute('DROP TYPE eventcallbackresultstatus')
# ### end Alembic commands ###

4056
enterprise/poetry.lock generated

File diff suppressed because one or more lines are too long

View File

@@ -223,6 +223,16 @@ class SaasUserAuth(UserAuth):
await rate_limiter.hit('auth_uid', user_id)
return instance
@classmethod
async def get_for_user(cls, user_id: str) -> UserAuth:
offline_token = await token_manager.load_offline_token(user_id)
assert offline_token is not None
return SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
)
def get_api_key_from_header(request: Request):
auth_header = request.headers.get('Authorization')

View File

@@ -424,7 +424,7 @@ async def refresh_tokens(
provider_handler = ProviderHandler(
create_provider_tokens_object([provider]), external_auth_id=user_id
)
service = provider_handler._get_service(provider)
service = provider_handler.get_service(provider)
token = await service.get_latest_token()
if not token:
raise HTTPException(

View File

@@ -2,6 +2,6 @@
Unified SQLAlchemy declarative base for all models.
"""
from sqlalchemy.orm import declarative_base
from openhands.app_server.utils.sql_utils import Base
Base = declarative_base()
__all__ = ['Base']

View File

@@ -1,7 +1,6 @@
import asyncio
import os
from google.cloud.sql.connector import Connector
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
@@ -26,6 +25,8 @@ def _get_db_engine():
if GCP_DB_INSTANCE: # GCP environments
def get_db_connection():
from google.cloud.sql.connector import Connector
connector = Connector()
instance_string = f'{GCP_PROJECT}:{GCP_REGION}:{GCP_DB_INSTANCE}'
return connector.connect(
@@ -52,6 +53,8 @@ def _get_db_engine():
async def async_creator():
from google.cloud.sql.connector import Connector
loop = asyncio.get_running_loop()
async with Connector(loop=loop) as connector:
conn = await connector.connect_async(

View File

@@ -52,6 +52,14 @@ class SaasConversationStore(ConversationStore):
# Convert string to ProviderType enum
kwargs['git_provider'] = ProviderType(kwargs['git_provider'])
# Remove V1 attributes
kwargs.pop('max_budget_per_task', None)
kwargs.pop('cache_read_tokens', None)
kwargs.pop('cache_write_tokens', None)
kwargs.pop('reasoning_tokens', None)
kwargs.pop('context_window', None)
kwargs.pop('per_turn_token', None)
return ConversationMetadata(**kwargs)
async def save_metadata(self, metadata: ConversationMetadata):

View File

@@ -1,41 +1,8 @@
import uuid
from datetime import UTC, datetime
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
StoredConversationMetadata as _StoredConversationMetadata,
)
from sqlalchemy import JSON, Column, DateTime, Float, Integer, String
from storage.base import Base
StoredConversationMetadata = _StoredConversationMetadata
class StoredConversationMetadata(Base): # type: ignore
__tablename__ = 'conversation_metadata'
conversation_id = Column(
String, primary_key=True, default=lambda: str(uuid.uuid4())
)
github_user_id = Column(String, nullable=True) # The GitHub user ID
user_id = Column(String, nullable=False) # The Keycloak User ID
selected_repository = Column(String, nullable=True)
selected_branch = Column(String, nullable=True)
git_provider = Column(
String, nullable=True
) # The git provider (GitHub, GitLab, etc.)
title = Column(String, nullable=True)
last_updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
)
trigger = Column(String, nullable=True)
pr_number = Column(
JSON, nullable=True
) # List of PR numbers associated with the conversation
# Cost and token metrics
accumulated_cost = Column(Float, default=0.0)
prompt_tokens = Column(Integer, default=0)
completion_tokens = Column(Integer, default=0)
total_tokens = Column(Integer, default=0)
# LLM model used for the conversation
llm_model = Column(String, nullable=True)
__all__ = ['StoredConversationMetadata']

View File

@@ -0,0 +1,121 @@
import {
AppConversationStartRequest,
AppConversationStartTask,
} from "../open-hands.types";
class AppConversationServiceCallback {
/**
* Start an app conversation with streaming updates using callback pattern
* This approach avoids the no-await-in-loop ESLint warning
* @param request The conversation start request
* @param onProgress Callback function called for each progress update
* @param onComplete Callback function called when streaming is complete
* @param onError Callback function called when an error occurs
* @returns Promise that resolves when the stream starts (not when it completes)
*/
static async streamStartAppConversation(
request: AppConversationStartRequest,
onProgress: (task: AppConversationStartTask) => void,
onComplete: (allTasks: AppConversationStartTask[]) => void,
onError: (error: Error) => void,
): Promise<void> {
const baseURL = `${window.location.protocol}//${
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host
}`;
const url = `${baseURL}/api/v1/app-conversations/stream-start`;
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const allTasks: AppConversationStartTask[] = [];
const processStream = async (): Promise<void> => {
try {
const { done, value } = await reader.read();
if (done) {
// Process any remaining data in buffer
if (buffer.trim()) {
const trimmedBuffer = buffer.trim();
if (trimmedBuffer !== "[" && trimmedBuffer !== "]") {
const cleanBuffer = trimmedBuffer.replace(/,$/, "");
if (cleanBuffer) {
try {
const task: AppConversationStartTask =
JSON.parse(cleanBuffer);
allTasks.push(task);
onProgress(task);
} catch (error) {
// eslint-disable-next-line no-console
console.warn(
"Failed to parse final JSON:",
cleanBuffer,
error,
);
}
}
}
}
onComplete(allTasks);
return;
}
buffer += decoder.decode(value, { stream: true });
// The API returns a JSON array that gets built incrementally
// We need to parse individual JSON objects as they come in
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // Keep the last incomplete line in buffer
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine && trimmedLine !== "[" && trimmedLine !== "]") {
// Remove trailing comma if present
const cleanLine = trimmedLine.replace(/,$/, "");
if (cleanLine) {
try {
const task: AppConversationStartTask = JSON.parse(cleanLine);
allTasks.push(task);
onProgress(task);
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Failed to parse JSON line:", cleanLine, error);
}
}
}
}
// Continue processing the stream
processStream();
} catch (error) {
reader.releaseLock();
onError(error instanceof Error ? error : new Error(String(error)));
}
};
// Start processing the stream
processStream();
} catch (error) {
onError(error instanceof Error ? error : new Error(String(error)));
}
}
}
export default AppConversationServiceCallback;

View File

@@ -0,0 +1,94 @@
import {
AppConversationStartRequest,
AppConversationStartTask,
} from "../open-hands.types";
class AppConversationService {
/**
* Start an app conversation with streaming updates
* @param request The conversation start request
* @returns AsyncGenerator that yields AppConversationStartTask updates
*/
static async *streamStartAppConversation(
request: AppConversationStartRequest,
): AsyncGenerator<AppConversationStartTask, void, unknown> {
const baseURL = `${window.location.protocol}//${
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host
}`;
const url = `${baseURL}/api/v1/app-conversations/stream-start`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
// eslint-disable-next-line no-await-in-loop -- Sequential reading from stream required
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// The API returns a JSON array that gets built incrementally
// We need to parse individual JSON objects as they come in
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // Keep the last incomplete line in buffer
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine && trimmedLine !== "[" && trimmedLine !== "]") {
// Remove trailing comma if present
const cleanLine = trimmedLine.replace(/,$/, "");
if (cleanLine) {
try {
const task: AppConversationStartTask = JSON.parse(cleanLine);
yield task;
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Failed to parse JSON line:", cleanLine, error);
}
}
}
}
}
// Process any remaining data in buffer
if (buffer.trim()) {
const trimmedBuffer = buffer.trim();
if (trimmedBuffer !== "[" && trimmedBuffer !== "]") {
const cleanBuffer = trimmedBuffer.replace(/,$/, "");
if (cleanBuffer) {
try {
const task: AppConversationStartTask = JSON.parse(cleanBuffer);
yield task;
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Failed to parse final JSON:", cleanBuffer, error);
}
}
}
}
} finally {
reader.releaseLock();
}
}
}
export default AppConversationService;

View File

@@ -139,3 +139,50 @@ export type GetFilesResponse = string[];
export interface GetFileResponse {
code: string;
}
// App Conversation Types
export interface SendMessageRequest {
message: string;
image_urls?: string[];
}
export interface EventCallbackProcessor {
type: string;
config: Record<string, unknown>;
}
export interface AppConversationStartRequest {
sandbox_id?: string | null;
initial_message?: SendMessageRequest | null;
processors?: EventCallbackProcessor[];
llm_model?: string | null;
selected_repository?: string | null;
selected_branch?: string | null;
git_provider?: Provider | null;
title?: string | null;
trigger?: ConversationTrigger | null;
pr_number?: number[];
}
export type AppConversationStartTaskStatus =
| "WORKING"
| "WAITING_FOR_SANDBOX"
| "PREPARING_REPOSITORY"
| "RUNNING_SETUP_SCRIPT"
| "SETTING_UP_GIT_HOOKS"
| "STARTING_CONVERSATION"
| "READY"
| "ERROR";
export interface AppConversationStartTask {
id: string;
created_by_user_id: string | null;
status: AppConversationStartTaskStatus;
detail?: string | null;
app_conversation_id?: string | null;
sandbox_id?: string | null;
agent_server_url?: string | null;
request: AppConversationStartRequest;
created_at: string;
updated_at: string;
}

View File

@@ -36,6 +36,7 @@ import { validateFiles } from "#/utils/file-validation";
import { useConversationStore } from "#/state/conversation-store";
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
import { isV0Event } from "#/types/v1/type-guards";
import { ConversationSetupFlow } from "../conversation/conversation-setup-flow";
function getEntryPoint(
hasRepository: boolean | null,
@@ -46,7 +47,15 @@ function getEntryPoint(
return "direct";
}
export function ChatInterface() {
interface ChatInterfaceProps {
isSetupMode?: boolean;
conversationId?: string;
}
export function ChatInterface({
isSetupMode,
conversationId,
}: ChatInterfaceProps = {}) {
const { setMessageToSend } = useConversationStore();
const { errorMessage } = useErrorMessageStore();
const { send, isLoadingMessages } = useWsClient();
@@ -176,6 +185,11 @@ export function ChatInterface() {
const userEventsExist = hasUserEvent(events);
// If in setup mode, show setup progress instead of regular chat
if (isSetupMode && conversationId) {
return <ConversationSetupFlow conversationId={conversationId} />;
}
return (
<ScrollProvider value={scrollProviderValue}>
<div className="h-full flex flex-col justify-between pr-0 md:pr-4 relative">

View File

@@ -3,10 +3,14 @@ import { ChatInterface } from "../../chat/chat-interface";
interface ChatInterfaceWrapperProps {
isRightPanelShown: boolean;
isSetupMode?: boolean;
conversationId?: string;
}
export function ChatInterfaceWrapper({
isRightPanelShown,
isSetupMode,
conversationId,
}: ChatInterfaceWrapperProps) {
return (
<div className="flex justify-center w-full h-full">
@@ -16,7 +20,10 @@ export function ChatInterfaceWrapper({
isRightPanelShown ? "max-w-4xl" : "max-w-6xl",
)}
>
<ChatInterface />
<ChatInterface
isSetupMode={isSetupMode}
conversationId={conversationId}
/>
</div>
</div>
);

View File

@@ -3,13 +3,33 @@ import { MobileLayout } from "./mobile-layout";
import { DesktopLayout } from "./desktop-layout";
import { useConversationStore } from "#/state/conversation-store";
export function ConversationMain() {
interface ConversationMainProps {
isSetupMode?: boolean;
conversationId?: string;
}
export function ConversationMain({
isSetupMode,
conversationId,
}: ConversationMainProps) {
const { width } = useWindowSize();
const { isRightPanelShown } = useConversationStore();
if (width && width <= 1024) {
return <MobileLayout isRightPanelShown={isRightPanelShown} />;
return (
<MobileLayout
isRightPanelShown={isRightPanelShown}
isSetupMode={isSetupMode}
taskId={conversationId}
/>
);
}
return <DesktopLayout isRightPanelShown={isRightPanelShown} />;
return (
<DesktopLayout
isRightPanelShown={isRightPanelShown}
isSetupMode={isSetupMode}
conversationId={conversationId}
/>
);
}

View File

@@ -6,9 +6,15 @@ import { useResizablePanels } from "#/hooks/use-resizable-panels";
interface DesktopLayoutProps {
isRightPanelShown: boolean;
isSetupMode?: boolean;
conversationId?: string;
}
export function DesktopLayout({ isRightPanelShown }: DesktopLayoutProps) {
export function DesktopLayout({
isRightPanelShown,
isSetupMode,
conversationId,
}: DesktopLayoutProps) {
const { leftWidth, rightWidth, isDragging, containerRef, handleMouseDown } =
useResizablePanels({
defaultLeftWidth: 50,
@@ -35,7 +41,11 @@ export function DesktopLayout({ isRightPanelShown }: DesktopLayoutProps) {
transitionProperty: isDragging ? "none" : "all",
}}
>
<ChatInterfaceWrapper isRightPanelShown={isRightPanelShown} />
<ChatInterfaceWrapper
isRightPanelShown={isRightPanelShown}
isSetupMode={isSetupMode}
conversationId={conversationId}
/>
</div>
{/* Resize Handle */}

View File

@@ -4,9 +4,15 @@ import { cn } from "#/utils/utils";
interface MobileLayoutProps {
isRightPanelShown: boolean;
isSetupMode?: boolean;
taskId?: string;
}
export function MobileLayout({ isRightPanelShown }: MobileLayoutProps) {
export function MobileLayout({
isRightPanelShown,
isSetupMode,
taskId,
}: MobileLayoutProps) {
return (
<div className="relative flex-1 flex flex-col">
{/* Chat area - shrinks when panel slides up */}
@@ -16,7 +22,7 @@ export function MobileLayout({ isRightPanelShown }: MobileLayoutProps) {
isRightPanelShown ? "h-160" : "flex-1",
)}
>
<ChatInterface />
<ChatInterface isSetupMode={isSetupMode} conversationId={taskId} />
</div>
{/* Bottom panel - slides up from bottom */}

View File

@@ -0,0 +1,238 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { useAgentStore } from "#/stores/agent-store";
import { AgentState } from "#/types/agent-state";
import { useStreamStartAppConversation } from "#/hooks/mutation/use-stream-start-app-conversation";
import {
AppConversationStartRequest,
AppConversationStartTask,
} from "#/api/open-hands.types";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
// Component that shows in the chat input area during setup
function ConversationSetupInput({
task,
}: {
task: AppConversationStartTask | null;
}) {
if (!task) {
return (
<div className="flex items-center justify-center p-4">
<LoadingSpinner />
<span className="ml-2">Initializing conversation...</span>
</div>
);
}
const getStatusMessage = (status: string) => {
const messages = {
WORKING: "Starting your conversation...",
WAITING_FOR_SANDBOX: "Setting up secure environment...",
PREPARING_REPOSITORY: "Preparing repository...",
RUNNING_SETUP_SCRIPT: "Running setup scripts...",
SETTING_UP_GIT_HOOKS: "Configuring git integration...",
STARTING_CONVERSATION: "Almost ready...",
READY: "Conversation ready!",
ERROR: "Setup failed",
};
return messages[status] || status;
};
const getProgress = (status: string) => {
const progress = {
WORKING: 10,
WAITING_FOR_SANDBOX: 25,
PREPARING_REPOSITORY: 50,
RUNNING_SETUP_SCRIPT: 70,
SETTING_UP_GIT_HOOKS: 85,
STARTING_CONVERSATION: 95,
READY: 100,
ERROR: 0,
};
return progress[status] || 0;
};
return (
<div className="space-y-3">
{/* Progress bar */}
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${getProgress(task.status)}%` }}
/>
</div>
{/* Status message */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{getStatusMessage(task.status)}
</span>
{task.status !== "ERROR" && task.status !== "READY" && (
<LoadingSpinner size="small" />
)}
</div>
{/* Detail message */}
{task.detail && <p className="text-xs text-gray-500">{task.detail}</p>}
{/* Error state */}
{task.status === "ERROR" && (
<div className="flex items-center justify-between">
<span className="text-red-600 text-sm">
Setup failed. Please try again.
</span>
<button
type="button"
onClick={() => {
window.location.href = "/";
}}
className="text-blue-500 hover:underline text-sm"
>
Return to Home
</button>
</div>
)}
</div>
);
}
// Component that shows setup steps in the main chat area
function ConversationSetupProgress({
task,
error,
}: {
task: AppConversationStartTask | null;
error: Error | null;
}) {
const setupSteps = [
{ key: "WORKING", label: "Initializing", completed: false },
{
key: "WAITING_FOR_SANDBOX",
label: "Setting up environment",
completed: false,
},
{
key: "PREPARING_REPOSITORY",
label: "Preparing repository",
completed: false,
},
{ key: "RUNNING_SETUP_SCRIPT", label: "Running setup", completed: false },
{ key: "SETTING_UP_GIT_HOOKS", label: "Configuring git", completed: false },
{
key: "STARTING_CONVERSATION",
label: "Starting conversation",
completed: false,
},
];
// Mark steps as completed based on current status
const currentStepIndex = setupSteps.findIndex(
(step) => step.key === task?.status,
);
const stepsWithStatus = setupSteps.map((step, index) => ({
...step,
completed: index < currentStepIndex,
current: index === currentStepIndex,
}));
return (
<div className="max-w-md space-y-4">
<h3 className="text-lg font-semibold text-center">
Setting up your conversation
</h3>
<div className="space-y-2">
{stepsWithStatus.map((step, index) => (
<div key={step.key} className="flex items-center space-x-3">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-sm ${
step.completed
? "bg-green-500 text-white"
: step.current
? "bg-blue-500 text-white"
: "bg-gray-200 text-gray-500"
}`}
>
{step.completed ? "✓" : index + 1}
</div>
<span
className={
step.current
? "font-medium text-blue-600"
: step.completed
? "text-green-600"
: "text-gray-500"
}
>
{step.label}
</span>
{step.current && <LoadingSpinner size="small" />}
</div>
))}
</div>
{error && (
<div className="text-red-600 text-sm text-center">{error.message}</div>
)}
</div>
);
}
interface ConversationSetupFlowProps {
conversationId: string;
}
export function ConversationSetupFlow({
conversationId,
}: ConversationSetupFlowProps) {
const navigate = useNavigate();
const [currentTask, setCurrentTask] =
useState<AppConversationStartTask | null>(null);
const { setCurrentAgentState } = useAgentStore();
const { mutate: startConversation, error } = useStreamStartAppConversation();
useEffect(() => {
// Set agent state to loading during setup
setCurrentAgentState(AgentState.LOADING);
// Start the V1 conversation creation
const request: AppConversationStartRequest = {
// Get from user settings, context, etc.
initial_message: {
message: "Hello! I'm ready to help you with your project.",
image_urls: [],
},
};
startConversation({
request,
onProgress: (task) => {
setCurrentTask(task);
// When ready, replace URL and let existing logic take over
if (task.status === "READY" && task.app_conversation_id) {
// Replace the URL without the setup parameter
navigate(`/conversations/${task.app_conversation_id}`, {
replace: true,
});
// The existing conversation logic will now load the real conversation
}
},
});
}, [conversationId, startConversation, setCurrentAgentState, navigate]);
return (
<div className="flex flex-col h-full">
{/* Empty messages area - could show setup steps here */}
<div className="flex-1 flex items-center justify-center">
<ConversationSetupProgress task={currentTask} error={error} />
</div>
{/* Setup progress in place of chat input */}
<div className="border-t bg-white p-4">
<ConversationSetupInput task={currentTask} />
</div>
</div>
);
}

View File

@@ -1,32 +1,21 @@
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { BrandButton } from "../../settings/brand-button";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
export function CreateConversationButton() {
const { t } = useTranslation();
const navigate = useNavigate();
const {
mutate: createConversation,
isPending,
isSuccess,
} = useCreateConversation();
const isCreatingConversationElsewhere = useIsCreatingConversation();
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
// We check for isCreatingConversationElsewhere to prevent multiple conversations
const isCreatingConversation = isCreatingConversationElsewhere;
const handleCreateConversation = () => {
createConversation(
{},
{
onSuccess: (data) => navigate(`/conversations/${data.conversation_id}`),
},
);
const taskId = crypto.randomUUID();
// Navigate with a special setup parameter
navigate(`/conversations/${taskId}?setup=true`);
};
return (

View File

@@ -0,0 +1,124 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useRef } from "react";
import AppConversationService from "#/api/app-conversation-service/app-conversation-service.api";
import {
AppConversationStartRequest,
AppConversationStartTask,
} from "#/api/open-hands.types";
interface StreamStartAppConversationVariables {
request: AppConversationStartRequest;
onProgress?: (task: AppConversationStartTask) => void;
}
interface StreamStartAppConversationResult {
finalTask: AppConversationStartTask | null;
allTasks: AppConversationStartTask[];
}
export const useStreamStartAppConversation = () => {
const queryClient = useQueryClient();
const abortControllerRef = useRef<AbortController | null>(null);
const cancelStream = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const mutation = useMutation({
mutationKey: ["stream-start-app-conversation"],
mutationFn: async (
variables: StreamStartAppConversationVariables,
): Promise<StreamStartAppConversationResult> => {
const { request, onProgress } = variables;
// Create a new AbortController for this request
abortControllerRef.current = new AbortController();
const allTasks: AppConversationStartTask[] = [];
let finalTask: AppConversationStartTask | null = null;
try {
// eslint-disable-next-line no-await-in-loop -- Sequential processing required for streaming
for await (const task of AppConversationService.streamStartAppConversation(
request,
)) {
// Check if the request was aborted
if (abortControllerRef.current?.signal.aborted) {
throw new Error("Request was cancelled");
}
allTasks.push(task);
finalTask = task;
// Call the progress callback if provided
if (onProgress) {
onProgress(task);
}
// If we reach READY or ERROR status, we're done
if (task.status === "READY" || task.status === "ERROR") {
break;
}
}
} catch (error) {
// If it's not a cancellation error, re-throw it
if (
error instanceof Error &&
error.message !== "Request was cancelled"
) {
throw error;
}
// For cancellation, we still return what we have so far
} finally {
abortControllerRef.current = null;
}
return { finalTask, allTasks };
},
onSuccess: async (result) => {
// Invalidate relevant queries when the conversation is successfully started
if (result.finalTask?.status === "READY") {
await queryClient.invalidateQueries({
queryKey: ["app-conversations"],
});
// You might also want to invalidate other related queries
await queryClient.invalidateQueries({
queryKey: ["user", "conversations"],
});
}
},
onError: (error) => {
// eslint-disable-next-line no-console
console.error("Error starting app conversation:", error);
},
});
return {
...mutation,
cancelStream,
isStreaming: mutation.isPending,
};
};
// Additional hook for simpler usage when you just want the final result
export const useStartAppConversation = () => {
const streamMutation = useStreamStartAppConversation();
const startConversation = useCallback(
(request: AppConversationStartRequest) =>
streamMutation.mutateAsync({ request }),
[streamMutation],
);
return {
startConversation,
isLoading: streamMutation.isPending,
error: streamMutation.error,
data: streamMutation.data,
reset: streamMutation.reset,
};
};

View File

@@ -1,5 +1,5 @@
import React from "react";
import { useNavigate } from "react-router";
import { useNavigate, useLocation } from "react-router";
import { useQueryClient } from "@tanstack/react-query";
import { useConversationId } from "#/hooks/use-conversation-id";
@@ -33,12 +33,25 @@ function AppContent() {
useConversationConfig();
const { conversationId } = useConversationId();
const { data: conversation, isFetched, refetch } = useActiveConversation();
const location = useLocation();
const navigate = useNavigate();
// Check if we're in setup mode
const searchParams = new URLSearchParams(location.search);
const isSetupMode = searchParams.get("setup") === "true";
// Only fetch conversation if NOT in setup mode
const {
data: conversation,
isFetched,
refetch,
} = useActiveConversation({
enabled: !isSetupMode,
});
const { mutate: startConversation } = useStartConversation();
const { data: isAuthed } = useIsAuthed();
const { providers } = useUserProviders();
const { resetConversationState } = useConversationStore();
const navigate = useNavigate();
const clearTerminal = useCommandStore((state) => state.clearTerminal);
const setCurrentAgentState = useAgentStore(
(state) => state.setCurrentAgentState,
@@ -59,8 +72,9 @@ function AppContent() {
});
}, [conversationId, queryClient]);
// Modified guard logic - don't redirect if in setup mode
React.useEffect(() => {
if (isFetched && !conversation && isAuthed) {
if (!isSetupMode && isFetched && !conversation && isAuthed) {
displayErrorToast(
"This conversation does not exist, or you do not have permission to access it.",
);
@@ -79,6 +93,7 @@ function AppContent() {
);
}
}, [
isSetupMode,
conversation?.conversation_id,
conversation?.status,
isFetched,
@@ -118,7 +133,10 @@ function AppContent() {
<ConversationTabs />
</div>
<ConversationMain />
<ConversationMain
isSetupMode={isSetupMode}
conversationId={conversationId}
/>
</div>
</EventHandler>
</ConversationSubscriptionsProvider>

View File

@@ -1,44 +1,9 @@
import os
from pathlib import Path
# This is a namespace package - extend the path to include installed packages
# (We need to do this to support dependencies openhands-sdk, openhands-tools and openhands-agent-server
# which all have a top level `openhands`` package.)
__path__ = __import__('pkgutil').extend_path(__path__, __name__)
__package_name__ = 'openhands_ai'
# Import version information for backward compatibility
from openhands.version import __version__, get_version
def get_version():
# Try getting the version from pyproject.toml
try:
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
candidate_paths = [
Path(root_dir) / 'pyproject.toml',
Path(root_dir) / 'openhands' / 'pyproject.toml',
]
for file_path in candidate_paths:
if file_path.is_file():
with open(file_path, 'r') as f:
for line in f:
if line.strip().startswith('version ='):
return line.split('=', 1)[1].strip().strip('"').strip("'")
except FileNotFoundError:
pass
try:
from importlib.metadata import PackageNotFoundError, version
return version(__package_name__)
except (ImportError, PackageNotFoundError):
pass
try:
from pkg_resources import DistributionNotFound, get_distribution # type: ignore
return get_distribution(__package_name__).version
except (ImportError, DistributionNotFound):
pass
return 'unknown'
try:
__version__ = get_version()
except Exception:
__version__ = 'unknown'
__all__ = ['__version__', 'get_version']

View File

@@ -0,0 +1,19 @@
# OpenHands App Server
FastAPI-based application server that provides REST API endpoints for OpenHands V1 integration.
## Overview
As of 2025-09-29, much of the code in the OpenHands repository can be regarded as legacy, having been superseded by the code in AgentSDK. This package provides endpoints to interface with the new agent SDK and bridge the gap with the existing OpenHands project.
## Architecture
The app server is organized into several key modules:
- **conversation/**: Manages sandboxed conversations and their lifecycle
- **event/**: Handles event storage, retrieval, and streaming
- **event_callback/**: Manages webhooks and event callbacks
- **sandbox/**: Manages sandbox environments for agent execution
- **user/**: User management and authentication
- **services/**: Core services like JWT authentication
- **utils/**: Utility functions for common operations

View File

View File

@@ -0,0 +1,20 @@
# Conversation Management
Manages app conversations and their lifecycle within the OpenHands app server.
## Overview
This module provides services and models for managing conversations that run within sandboxed environments. It handles conversation creation, retrieval, status tracking, and lifecycle management.
## Key Components
- **AppConversationService**: Abstract service for conversation CRUD operations
- **LiveStatusAppConversationService**: Real-time conversation status tracking
- **AppConversationRouter**: FastAPI router for conversation endpoints
## Features
- Conversation search and filtering by title, dates, and status
- Real-time conversation status updates
- Pagination support for large conversation lists
- Integration with sandbox environments

View File

@@ -0,0 +1 @@
# App conversation module

View File

@@ -0,0 +1,75 @@
import asyncio
from abc import ABC, abstractmethod
from datetime import datetime
from uuid import UUID
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
AppConversationInfoPage,
AppConversationSortOrder,
)
from openhands.app_server.services.injector import Injector
from openhands.sdk.utils.models import DiscriminatedUnionMixin
class AppConversationInfoService(ABC):
"""Service for accessing info on conversations without their current status."""
@abstractmethod
async def search_app_conversation_info(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
) -> AppConversationInfoPage:
"""Search for sandboxed conversations."""
@abstractmethod
async def count_app_conversation_info(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
) -> int:
"""Count sandboxed conversations."""
@abstractmethod
async def get_app_conversation_info(
self, conversation_id: UUID
) -> AppConversationInfo | None:
"""Get a single conversation info, returning None if missing."""
async def batch_get_app_conversation_info(
self, conversation_ids: list[UUID]
) -> list[AppConversationInfo | None]:
"""Get a batch of conversation info, return None for any missing."""
return await asyncio.gather(
*[
self.get_app_conversation_info(conversation_id)
for conversation_id in conversation_ids
]
)
# Mutators
@abstractmethod
async def save_app_conversation_info(
self, info: AppConversationInfo
) -> AppConversationInfo:
"""Store the sandboxed conversation info object given.
Return the stored info
"""
class AppConversationInfoServiceInjector(
DiscriminatedUnionMixin, Injector[AppConversationInfoService], ABC
):
pass

View File

@@ -0,0 +1,136 @@
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
from openhands.agent_server.models import SendMessageRequest
from openhands.agent_server.utils import utc_now
from openhands.app_server.event_callback.event_callback_models import (
EventCallbackProcessor,
)
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.integrations.service_types import ProviderType
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands.sdk.llm import MetricsSnapshot
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
class AppConversationInfo(BaseModel):
"""Conversation info which does not contain status."""
id: UUID = Field(default_factory=uuid4)
created_by_user_id: str | None
sandbox_id: str
selected_repository: str | None = None
selected_branch: str | None = None
git_provider: ProviderType | None = None
title: str | None = None
trigger: ConversationTrigger | None = None
pr_number: list[int] = Field(default_factory=list)
llm_model: str | None = None
metrics: MetricsSnapshot | None = None
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class AppConversationSortOrder(Enum):
CREATED_AT = 'CREATED_AT'
CREATED_AT_DESC = 'CREATED_AT_DESC'
UPDATED_AT = 'UPDATED_AT'
UPDATED_AT_DESC = 'UPDATED_AT_DESC'
TITLE = 'TITLE'
TITLE_DESC = 'TITLE_DESC'
class AppConversationInfoPage(BaseModel):
items: list[AppConversationInfo]
next_page_id: str | None = None
class AppConversation(AppConversationInfo): # type: ignore
sandbox_status: SandboxStatus = Field(
default=SandboxStatus.MISSING,
description='Current sandbox status. Will be MISSING if the sandbox does not exist.',
)
agent_status: AgentExecutionStatus | None = Field(
default=None,
description='Current agent status. Will be None if the sandbox_status is not RUNNING',
)
conversation_url: str | None = Field(
default=None, description='The URL where the conversation may be accessed'
)
session_api_key: str | None = Field(
default=None, description='The Session Api Key for REST operations.'
)
# JSON fields for complex data types
pr_number: list[int] = Field(default_factory=list)
metrics: MetricsSnapshot | None = Field(default=None)
class AppConversationPage(BaseModel):
items: list[AppConversation]
next_page_id: str | None = None
class AppConversationStartRequest(BaseModel):
"""Start conversation request object.
Although a user can go directly to the sandbox and start conversations, they
would need to manually supply required startup parameters such as LLM key. Starting
from the app server copies these from the user info.
"""
sandbox_id: str | None = Field(default=None)
initial_message: SendMessageRequest | None = None
processors: list[EventCallbackProcessor] = Field(default_factory=list)
llm_model: str | None = None
# Git parameters
selected_repository: str | None = None
selected_branch: str | None = None
git_provider: ProviderType | None = None
title: str | None = None
trigger: ConversationTrigger | None = None
pr_number: list[int] = Field(default_factory=list)
class AppConversationStartTaskStatus(Enum):
WORKING = 'WORKING'
WAITING_FOR_SANDBOX = 'WAITING_FOR_SANDBOX'
PREPARING_REPOSITORY = 'PREPARING_REPOSITORY'
RUNNING_SETUP_SCRIPT = 'RUNNING_SETUP_SCRIPT'
SETTING_UP_GIT_HOOKS = 'SETTING_UP_GIT_HOOKS'
STARTING_CONVERSATION = 'STARTING_CONVERSATION'
READY = 'READY'
ERROR = 'ERROR'
class AppConversationStartTask(BaseModel):
"""Object describing the start process for an app conversation.
Because starting an app conversation can be slow (And can involve starting a sandbox),
we kick off a background task for it. Once the conversation is started, the app_conversation_id
is populated."""
id: UUID = Field(default_factory=uuid4)
created_by_user_id: str | None
status: AppConversationStartTaskStatus = AppConversationStartTaskStatus.WORKING
detail: str | None = None
app_conversation_id: UUID | None = Field(
default=None, description='The id of the app_conversation, if READY'
)
sandbox_id: str | None = Field(
default=None, description='The id of the sandbox, if READY'
)
agent_server_url: str | None = Field(
default=None, description='The agent server url, if READY'
)
request: AppConversationStartRequest
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)

View File

@@ -0,0 +1,231 @@
"""Sandboxed Conversation router for OpenHands Server."""
import asyncio
import sys
from datetime import datetime
from typing import Annotated, AsyncGenerator
from uuid import UUID
from openhands.app_server.services.db_session_injector import set_db_session_keep_open
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.admin_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext
# Handle anext compatibility for Python < 3.10
if sys.version_info >= (3, 10):
from builtins import anext
else:
async def anext(async_iterator):
"""Compatibility function for anext in Python < 3.10"""
return await async_iterator.__anext__()
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversation,
AppConversationPage,
AppConversationStartRequest,
AppConversationStartTask,
)
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.app_server.config import (
depends_app_conversation_service,
depends_db_session,
depends_user_context,
get_app_conversation_service,
)
router = APIRouter(prefix='/app-conversations', tags=['Conversations'])
app_conversation_service_dependency = depends_app_conversation_service()
user_context_dependency = depends_user_context()
db_session_dependency = depends_db_session()
# Read methods
@router.get('/search')
async def search_app_conversations(
title__contains: Annotated[
str | None,
Query(title='Filter by title containing this string'),
] = None,
created_at__gte: Annotated[
datetime | None,
Query(title='Filter by created_at greater than or equal to this datetime'),
] = None,
created_at__lt: Annotated[
datetime | None,
Query(title='Filter by created_at less than this datetime'),
] = None,
updated_at__gte: Annotated[
datetime | None,
Query(title='Filter by updated_at greater than or equal to this datetime'),
] = None,
updated_at__lt: Annotated[
datetime | None,
Query(title='Filter by updated_at less than this datetime'),
] = None,
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int,
Query(
title='The max number of results in the page',
gt=0,
lte=100,
),
] = 100,
app_conversation_service: AppConversationService = (
app_conversation_service_dependency
),
) -> AppConversationPage:
"""Search / List sandboxed conversations."""
assert limit > 0
assert limit <= 100
return await app_conversation_service.search_app_conversations(
title__contains=title__contains,
created_at__gte=created_at__gte,
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
page_id=page_id,
limit=limit,
)
@router.get('/count')
async def count_app_conversations(
title__contains: Annotated[
str | None,
Query(title='Filter by title containing this string'),
] = None,
created_at__gte: Annotated[
datetime | None,
Query(title='Filter by created_at greater than or equal to this datetime'),
] = None,
created_at__lt: Annotated[
datetime | None,
Query(title='Filter by created_at less than this datetime'),
] = None,
updated_at__gte: Annotated[
datetime | None,
Query(title='Filter by updated_at greater than or equal to this datetime'),
] = None,
updated_at__lt: Annotated[
datetime | None,
Query(title='Filter by updated_at less than this datetime'),
] = None,
app_conversation_service: AppConversationService = (
app_conversation_service_dependency
),
) -> int:
"""Count sandboxed conversations matching the given filters."""
return await app_conversation_service.count_app_conversations(
title__contains=title__contains,
created_at__gte=created_at__gte,
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
)
@router.get('')
async def batch_get_app_conversations(
ids: Annotated[list[UUID], Query()],
app_conversation_service: AppConversationService = (
app_conversation_service_dependency
),
) -> list[AppConversation | None]:
"""Get a batch of sandboxed conversations given their ids. Return None for any missing."""
assert len(ids) < 100
app_conversations = await app_conversation_service.batch_get_app_conversations(ids)
return app_conversations
@router.post('')
async def start_app_conversation(
request: Request,
start_request: AppConversationStartRequest,
db_session: AsyncSession = db_session_dependency,
app_conversation_service: AppConversationService = (
app_conversation_service_dependency
),
) -> AppConversationStartTask:
# Because we are processing after the request finishes, keep the db connection open
set_db_session_keep_open(request.state, True)
"""Start an app conversation start task and return it."""
async_iter = app_conversation_service.start_app_conversation(start_request)
result = await anext(async_iter)
asyncio.create_task(_consume_remaining(async_iter, db_session))
return result
@router.post('/stream-start')
async def stream_app_conversation_start(
request: AppConversationStartRequest,
user_context: UserContext = user_context_dependency,
) -> list[AppConversationStartTask]:
"""Start an app conversation start task and stream updates from it.
Leaves the connection open until either the conversation starts or there was an error"""
response = StreamingResponse(
_stream_app_conversation_start(request, user_context),
media_type='application/json',
)
return response
@router.get('/start-tasks')
async def batch_get_app_conversation_start_tasks(
ids: Annotated[list[UUID], Query()],
app_conversation_service: AppConversationService = (
app_conversation_service_dependency
),
) -> list[AppConversationStartTask | None]:
"""Get a batch of start app conversation tasks given their ids. Return None for any missing."""
assert len(ids) < 100
start_tasks = await app_conversation_service.batch_get_app_conversation_start_tasks(
ids
)
return start_tasks
async def _consume_remaining(async_iter, db_session: AsyncSession):
"""Consume the remaining items from an async iterator"""
try:
while True:
await anext(async_iter)
except StopAsyncIteration:
return
finally:
await db_session.close()
async def _stream_app_conversation_start(
request: AppConversationStartRequest,
user_context: UserContext,
) -> AsyncGenerator[str, None]:
"""Stream a json list, item by item."""
# Because the original dependencies are closed after the method returns, we need
# a new dependency context which will continue intil the stream finishes.
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, user_context)
async with get_app_conversation_service(state) as app_conversation_service:
yield '[\n'
comma = False
async for task in app_conversation_service.start_app_conversation(request):
chunk = task.model_dump_json()
if comma:
chunk = ',\n' + chunk
comma = True
yield chunk
yield ']'

View File

@@ -0,0 +1,108 @@
import asyncio
from abc import ABC, abstractmethod
from datetime import datetime
from typing import AsyncGenerator
from uuid import UUID
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversation,
AppConversationPage,
AppConversationSortOrder,
AppConversationStartRequest,
AppConversationStartTask,
)
from openhands.app_server.services.injector import Injector
from openhands.sdk import Workspace
from openhands.sdk.utils.models import DiscriminatedUnionMixin
class AppConversationService(ABC):
"""Service for managing conversations running in sandboxes."""
@abstractmethod
async def search_app_conversations(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
) -> AppConversationPage:
"""Search for sandboxed conversations."""
@abstractmethod
async def count_app_conversations(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
) -> int:
"""Count sandboxed conversations."""
@abstractmethod
async def get_app_conversation(
self, conversation_id: UUID
) -> AppConversation | None:
"""Get a single sandboxed conversation info. Return None if missing."""
async def batch_get_app_conversations(
self, conversation_ids: list[UUID]
) -> list[AppConversation | None]:
"""Get a batch of sandboxed conversations, returning None for any missing."""
return await asyncio.gather(
*[
self.get_app_conversation(conversation_id)
for conversation_id in conversation_ids
]
)
@abstractmethod
async def start_app_conversation(
self, request: AppConversationStartRequest
) -> AsyncGenerator[AppConversationStartTask, None]:
"""Start a conversation, optionally specifying a sandbox in which to start.
If no sandbox is specified a default may be used or started. This is a convenience
method - the same effect should be achievable by creating / getting a sandbox
id, starting a conversation, attaching a callback, and then running the
conversation.
Yields an instance of AppConversationStartTask as updates occur, which can be used to determine
the progress of the task.
"""
# This is an abstract method - concrete implementations should provide real values
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
)
dummy_request = AppConversationStartRequest()
yield AppConversationStartTask(
created_by_user_id='dummy',
request=dummy_request,
)
@abstractmethod
async def batch_get_app_conversation_start_tasks(
self, task_ids: list[UUID]
) -> list[AppConversationStartTask | None]:
"""Get a batch AppConversationStartTask by id, returning none for those missing.
Typically used to poll and determine if a conversation started."""
@abstractmethod
async def run_setup_scripts(
self, task: AppConversationStartTask, workspace: Workspace
) -> AsyncGenerator[AppConversationStartTask, None]:
"""Run the setup scripts for the project and yield status updates"""
yield task
class AppConversationServiceInjector(
DiscriminatedUnionMixin, Injector[AppConversationService], ABC
):
pass

View File

@@ -0,0 +1,46 @@
import asyncio
from abc import ABC, abstractmethod
from uuid import UUID
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartTask,
)
from openhands.app_server.services.injector import Injector
from openhands.sdk.utils.models import DiscriminatedUnionMixin
class AppConversationStartTaskService(ABC):
"""Service for accessing start tasks for conversations."""
# TODO: We can add the standard search, count, and batch methods later
@abstractmethod
async def get_app_conversation_start_task(
self, task_id: UUID
) -> AppConversationStartTask | None:
"""Get a single start task, returning None if missing."""
async def batch_get_app_conversation_start_tasks(
self, task_ids: list[UUID]
) -> list[AppConversationStartTask | None]:
"""Get a batch of start tasks, return None for any missing."""
return await asyncio.gather(
*[self.get_app_conversation_start_task(task_id) for task_id in task_ids]
)
# Mutators
@abstractmethod
async def save_app_conversation_start_task(
self, info: AppConversationStartTask
) -> AppConversationStartTask:
"""Store the start task object given.
Return the stored task
"""
class AppConversationStartTaskServiceInjector(
DiscriminatedUnionMixin, Injector[AppConversationStartTaskService], ABC
):
pass

View File

@@ -0,0 +1 @@
This directory contains files used in git configuration.

View File

@@ -0,0 +1,11 @@
#!/bin/bash
# This hook was installed by OpenHands
# It calls the pre-commit script in the .openhands directory
if [ -x ".openhands/pre-commit.sh" ]; then
source ".openhands/pre-commit.sh"
exit $?
else
echo "Warning: .openhands/pre-commit.sh not found or not executable"
exit 0
fi

View File

@@ -0,0 +1,151 @@
import logging
import os
import tempfile
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from typing import AsyncGenerator
import base62
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartTask,
AppConversationStartTaskStatus,
)
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.async_remote_workspace import AsyncRemoteWorkspace
_logger = logging.getLogger(__name__)
PRE_COMMIT_HOOK = '.git/hooks/pre-commit'
PRE_COMMIT_LOCAL = '.git/hooks/pre-commit.local'
@dataclass
class GitAppConversationService(AppConversationService, ABC):
"""App Conversation service which adds git specific functionality.
Sets up repositories and installs hooks"""
init_git_in_empty_workspace: bool
user_context: UserContext
async def run_setup_scripts(
self,
task: AppConversationStartTask,
workspace: AsyncRemoteWorkspace,
) -> AsyncGenerator[AppConversationStartTask, None]:
task.status = AppConversationStartTaskStatus.PREPARING_REPOSITORY
yield task
await self.clone_or_init_git_repo(task, workspace)
task.status = AppConversationStartTaskStatus.RUNNING_SETUP_SCRIPT
yield task
await self.maybe_run_setup_script(workspace)
task.status = AppConversationStartTaskStatus.SETTING_UP_GIT_HOOKS
yield task
await self.maybe_setup_git_hooks(workspace)
async def clone_or_init_git_repo(
self,
task: AppConversationStartTask,
workspace: AsyncRemoteWorkspace,
):
request = task.request
if not request.selected_repository:
if self.init_git_in_empty_workspace:
_logger.debug('Initializing a new git repository in the workspace.')
await workspace.execute_command(
'git init && git config --global --add safe.directory '
+ workspace.working_dir
)
else:
_logger.info('Not initializing a new git repository.')
return
remote_repo_url: str = await self.user_context.get_authenticated_git_url(
request.selected_repository
)
if not remote_repo_url:
raise ValueError('Missing either Git token or valid repository')
dir_name = request.selected_repository.split('/')[-1]
# Clone the repo - this is the slow part!
clone_command = f'git clone {remote_repo_url} {dir_name}'
await workspace.execute_command(clone_command, workspace.working_dir)
# Checkout the appropriate branch
if request.selected_branch:
checkout_command = f'git checkout {request.selected_branch}'
else:
# Generate a random branch name to avoid conflicts
random_str = base62.encodebytes(os.urandom(16))
openhands_workspace_branch = f'openhands-workspace-{random_str}'
checkout_command = f'git checkout -b {openhands_workspace_branch}'
await workspace.execute_command(checkout_command, workspace.working_dir)
async def maybe_run_setup_script(
self,
workspace: AsyncRemoteWorkspace,
):
"""Run .openhands/setup.sh if it exists in the workspace or repository."""
setup_script = workspace.working_dir + '/.openhands/setup.sh'
await workspace.execute_command(
f'chmod +x {setup_script} && source {setup_script}', timeout=600
)
# TODO: Does this need to be done?
# Add the action to the event stream as an ENVIRONMENT event
# source = EventSource.ENVIRONMENT
# self.event_stream.add_event(action, source)
async def maybe_setup_git_hooks(
self,
workspace: AsyncRemoteWorkspace,
):
"""Set up git hooks if .openhands/pre-commit.sh exists in the workspace or repository."""
command = 'mkdir -p .git/hooks && chmod +x .openhands/pre-commit.sh'
result = await workspace.execute_command(command, workspace.working_dir)
if result.exit_code:
return
# Check if there's an existing pre-commit hook
with tempfile.TemporaryFile(mode='w+t') as temp_file:
result = workspace.file_download(PRE_COMMIT_HOOK, str(temp_file))
if result.get('success'):
_logger.info('Preserving existing pre-commit hook')
# an existing pre-commit hook exists
if 'This hook was installed by OpenHands' not in temp_file.read():
# Move the existing hook to pre-commit.local
command = (
f'mv {PRE_COMMIT_HOOK} {PRE_COMMIT_LOCAL} &&'
f'chmod +x {PRE_COMMIT_LOCAL}'
)
result = await workspace.execute_command(
command, workspace.working_dir
)
if result.exit_code != 0:
_logger.error(
f'Failed to preserve existing pre-commit hook: {result.stderr}',
)
return
# write the pre-commit hook
await workspace.file_upload(
source_path=Path(__file__).parent / 'git' / 'pre-commit.sh',
destination_path=PRE_COMMIT_HOOK,
)
# Make the pre-commit hook executable
result = await workspace.execute_command(f'chmod +x {PRE_COMMIT_HOOK}')
if result.exit_code:
_logger.error(f'Failed to make pre-commit hook executable: {result.stderr}')
return
_logger.info('Git pre-commit hook installed successfully')

View File

@@ -0,0 +1,543 @@
import asyncio
import logging
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from time import time
from typing import AsyncGenerator, Sequence
from uuid import UUID
import httpx
from fastapi import Request
from pydantic import Field, SecretStr, TypeAdapter
from openhands.agent_server.models import (
ConversationInfo,
NeverConfirm,
SendMessageRequest,
StartConversationRequest,
)
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversation,
AppConversationInfo,
AppConversationPage,
AppConversationSortOrder,
AppConversationStartRequest,
AppConversationStartTask,
AppConversationStartTaskStatus,
)
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
AppConversationServiceInjector,
)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
)
from openhands.app_server.app_conversation.git_app_conversation_service import (
GitAppConversationService,
)
from openhands.app_server.errors import SandboxError
from openhands.app_server.sandbox.docker_sandbox_service import DockerSandboxService
from openhands.app_server.sandbox.sandbox_models import (
AGENT_SERVER,
SandboxInfo,
SandboxStatus,
)
from openhands.app_server.sandbox.sandbox_service import SandboxService
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.services.jwt_service import JwtService
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.async_remote_workspace import AsyncRemoteWorkspace
from openhands.integrations.provider import ProviderType
from openhands.sdk import LocalWorkspace
from openhands.sdk.conversation.secret_source import LookupSecret, StaticSecret
from openhands.sdk.llm import LLM
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
from openhands.tools.preset.default import get_default_agent
_conversation_info_type_adapter = TypeAdapter(list[ConversationInfo | None])
_logger = logging.getLogger(__name__)
WORKSPACE_DIR = Path('/home/openhands/workspace')
GIT_TOKEN = 'GIT_TOKEN'
@dataclass
class LiveStatusAppConversationService(GitAppConversationService):
"""AppConversationService which combines live status info from the sandbox with stored data."""
user_context: UserContext
app_conversation_info_service: AppConversationInfoService
app_conversation_start_task_service: AppConversationStartTaskService
sandbox_service: SandboxService
jwt_service: JwtService
sandbox_startup_timeout: int
sandbox_startup_poll_frequency: int
httpx_client: httpx.AsyncClient
web_url: str | None
access_token_hard_timeout: timedelta | None
async def search_app_conversations(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 20,
) -> AppConversationPage:
"""Search for sandboxed conversations."""
page = await self.app_conversation_info_service.search_app_conversation_info(
title__contains=title__contains,
created_at__gte=created_at__gte,
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
conversations: list[AppConversation] = await self._build_app_conversations(
page.items
) # type: ignore
return AppConversationPage(items=conversations, next_page_id=page.next_page_id)
async def count_app_conversations(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
) -> int:
return await self.app_conversation_info_service.count_app_conversation_info(
title__contains=title__contains,
created_at__gte=created_at__gte,
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
)
async def get_app_conversation(
self, conversation_id: UUID
) -> AppConversation | None:
info = await self.app_conversation_info_service.get_app_conversation_info(
conversation_id
)
result = await self._build_app_conversations([info])
return result[0]
async def batch_get_app_conversations(
self, conversation_ids: list[UUID]
) -> list[AppConversation | None]:
info = await self.app_conversation_info_service.batch_get_app_conversation_info(
conversation_ids
)
conversations = await self._build_app_conversations(info)
return conversations
async def start_app_conversation(
self, request: AppConversationStartRequest
) -> AsyncGenerator[AppConversationStartTask, None]:
async for task in self._start_app_conversation(request):
await self.app_conversation_start_task_service.save_app_conversation_start_task(
task
)
yield task
async def _start_app_conversation(
self, request: AppConversationStartRequest
) -> AsyncGenerator[AppConversationStartTask, None]:
# Create and yield the start task
user_id = await self.user_context.get_user_id()
task = AppConversationStartTask(
created_by_user_id=user_id,
request=request,
)
yield task
try:
async for updated_task in self._wait_for_sandbox_start(task):
yield updated_task
# Get the sandbox
sandbox_id = task.sandbox_id
assert sandbox_id is not None
sandbox = await self.sandbox_service.get_sandbox(sandbox_id)
assert sandbox is not None
agent_server_url = self._get_agent_server_url(sandbox)
# Run setup scripts
workspace = AsyncRemoteWorkspace(
working_dir=str(WORKSPACE_DIR),
server_url=agent_server_url,
session_api_key=sandbox.session_api_key,
)
async for updated_task in self.run_setup_scripts(task, workspace):
yield updated_task
# Build the start request
start_conversation_request = (
await self._build_start_conversation_request_for_user(
request.initial_message, request.git_provider
)
)
# update status
task.status = AppConversationStartTaskStatus.STARTING_CONVERSATION
task.agent_server_url = agent_server_url
yield task
# Start conversation...
response = await self.httpx_client.post(
f'{agent_server_url}/api/conversations',
json=start_conversation_request.model_dump(
context={'expose_secrets': True}
),
headers={'X-Session-API-Key': sandbox.session_api_key},
timeout=self.sandbox_startup_timeout,
)
response.raise_for_status()
info = ConversationInfo.model_validate(response.json())
# Store info...
user_id = await self.user_context.get_user_id()
app_conversation_info = AppConversationInfo(
id=info.id,
# TODO: As of writing, StartConversationRequest from AgentServer does not have a title
title=f'Conversation {info.id}',
sandbox_id=sandbox.id,
created_by_user_id=user_id,
llm_model=start_conversation_request.agent.llm.model,
# Git parameters
selected_repository=request.selected_repository,
selected_branch=request.selected_branch,
git_provider=request.git_provider,
trigger=request.trigger,
pr_number=request.pr_number,
)
await self.app_conversation_info_service.save_app_conversation_info(
app_conversation_info
)
# Update the start task
task.status = AppConversationStartTaskStatus.READY
task.app_conversation_id = info.id
yield task
except Exception as exc:
_logger.exception('Error starting conversation', stack_info=True)
task.status = AppConversationStartTaskStatus.ERROR
task.detail = str(exc)
yield task
async def batch_get_app_conversation_start_tasks(
self, app_conversation_start_task_ids
):
return await self.app_conversation_start_task_service.batch_get_app_conversation_start_tasks(
app_conversation_start_task_ids
)
async def _build_app_conversations(
self, app_conversation_infos: Sequence[AppConversationInfo | None]
) -> list[AppConversation | None]:
sandbox_id_to_conversation_ids = self._get_sandbox_id_to_conversation_ids(
app_conversation_infos
)
# Get referenced sandboxes in a single batch operation...
sandboxes = await self.sandbox_service.batch_get_sandboxes(
list(sandbox_id_to_conversation_ids)
)
sandboxes_by_id = {sandbox.id: sandbox for sandbox in sandboxes if sandbox}
# Gather the running conversations
tasks = [
self._get_live_conversation_info(
sandbox, sandbox_id_to_conversation_ids.get(sandbox.id)
)
for sandbox in sandboxes
if sandbox and sandbox.status == SandboxStatus.RUNNING
]
if tasks:
sandbox_conversation_infos = await asyncio.gather(*tasks)
else:
sandbox_conversation_infos = []
# Collect the results into a single dictionary
conversation_info_by_id = {}
for conversation_infos in sandbox_conversation_infos:
for conversation_info in conversation_infos:
conversation_info_by_id[conversation_info.id] = conversation_info
# Build app_conversation from info
result = [
self._build_conversation(
app_conversation_info,
sandboxes_by_id.get(app_conversation_info.sandbox_id),
conversation_info_by_id.get(app_conversation_info.id),
)
if app_conversation_info
else None
for app_conversation_info in app_conversation_infos
]
return result
async def _get_live_conversation_info(
self,
sandbox: SandboxInfo,
conversation_ids: list[str],
) -> list[ConversationInfo]:
"""Get agent status for multiple conversations from the Agent Server."""
try:
# Build the URL with query parameters
agent_server_url = self._get_agent_server_url(sandbox)
url = f'{agent_server_url.rstrip("/")}/api/conversations'
params = {'ids': conversation_ids}
# Set up headers
headers = {}
if sandbox.session_api_key:
headers['X-Session-API-Key'] = sandbox.session_api_key
response = await self.httpx_client.get(url, params=params, headers=headers)
response.raise_for_status()
data = response.json()
conversation_info = _conversation_info_type_adapter.validate_python(data)
conversation_info = [c for c in conversation_info if c]
return conversation_info
except Exception:
# Not getting a status is not a fatal error - we just mark the conversation as stopped
_logger.exception(
f'Error getting conversation status from sandbox {sandbox.id}',
stack_info=True,
)
return []
def _build_conversation(
self,
app_conversation_info: AppConversationInfo | None,
sandbox: SandboxInfo | None,
conversation_info: ConversationInfo | None,
) -> AppConversation | None:
if app_conversation_info is None:
return None
sandbox_status = sandbox.status if sandbox else SandboxStatus.MISSING
agent_status = conversation_info.agent_status if conversation_info else None
conversation_url = None
session_api_key = None
if sandbox and sandbox.exposed_urls:
conversation_url = next(
(
exposed_url.url
for exposed_url in sandbox.exposed_urls
if exposed_url.name == AGENT_SERVER
),
None,
)
if conversation_url:
conversation_url += f'/api/conversations/{app_conversation_info.id.hex}'
session_api_key = sandbox.session_api_key
return AppConversation(
**app_conversation_info.model_dump(),
sandbox_status=sandbox_status,
agent_status=agent_status,
conversation_url=conversation_url,
session_api_key=session_api_key,
)
def _get_sandbox_id_to_conversation_ids(
self, stored_conversations: Sequence[AppConversationInfo | None]
):
result = defaultdict(list)
for stored_conversation in stored_conversations:
if stored_conversation:
result[stored_conversation.sandbox_id].append(stored_conversation.id)
return result
async def _wait_for_sandbox_start(
self, task: AppConversationStartTask
) -> AsyncGenerator[AppConversationStartTask, None]:
"""Wait for sandbox to start and return info."""
# Get the sandbox
if not task.request.sandbox_id:
sandbox = await self.sandbox_service.start_sandbox()
task.sandbox_id = sandbox.id
else:
sandbox_info = await self.sandbox_service.get_sandbox(
task.request.sandbox_id
)
if sandbox_info is None:
raise SandboxError(f'Sandbox not found: {task.request.sandbox_id}')
sandbox = sandbox_info
# Update the listener
task.status = AppConversationStartTaskStatus.WAITING_FOR_SANDBOX
task.sandbox_id = sandbox.id
yield task
if sandbox.status == SandboxStatus.PAUSED:
await self.sandbox_service.resume_sandbox(sandbox.id)
if sandbox.status in (None, SandboxStatus.ERROR):
raise SandboxError(f'Sandbox status: {sandbox.status}')
if sandbox.status == SandboxStatus.RUNNING:
return
if sandbox.status != SandboxStatus.STARTING:
raise SandboxError(f'Sandbox not startable: {sandbox.id}')
start = time()
while time() - start <= self.sandbox_startup_timeout:
await asyncio.sleep(self.sandbox_startup_poll_frequency)
sandbox_info = await self.sandbox_service.get_sandbox(sandbox.id)
if sandbox_info is None:
raise SandboxError(f'Sandbox not found: {sandbox.id}')
if sandbox.status not in (SandboxStatus.STARTING, SandboxStatus.RUNNING):
raise SandboxError(f'Sandbox not startable: {sandbox.id}')
if sandbox_info.status == SandboxStatus.RUNNING:
return
raise SandboxError(f'Sandbox failed to start: {sandbox.id}')
def _get_agent_server_url(self, sandbox: SandboxInfo) -> str:
"""Get agent server url for running sandbox."""
exposed_urls = sandbox.exposed_urls
assert exposed_urls is not None
agent_server_url = next(
exposed_url.url
for exposed_url in exposed_urls
if exposed_url.name == AGENT_SERVER
)
return agent_server_url
async def _build_start_conversation_request_for_user(
self,
initial_message: SendMessageRequest | None,
git_provider: ProviderType | None,
) -> StartConversationRequest:
user = await self.user_context.get_user_info()
# Set up a secret for the git token
secrets = await self.user_context.get_secrets()
if git_provider:
if self.web_url:
# If there is a web url, then we create an access token to access it.
# For security reasons, we are explicit here - only this user, and
# only this provider, with a timeout
access_token = self.jwt_service.create_jws_token(
payload={
'user_id': user.id,
'provider_type': git_provider.value,
},
expires_in=self.access_token_hard_timeout,
)
secrets[GIT_TOKEN] = LookupSecret(
url=self.web_url + '/ap/v1/webhooks/secrets',
headers={'X-Access-Token': access_token},
)
else:
# If there is no URL specified where the sandbox can access the app server
# then we supply a static secret with the most recent value. Depending
# on the type, this may eventually expire.
static_token = await self.user_context.get_latest_token(git_provider)
if static_token:
secrets[GIT_TOKEN] = StaticSecret(value=SecretStr(static_token))
workspace = LocalWorkspace(working_dir=str(WORKSPACE_DIR))
llm = LLM(
model=user.llm_model,
base_url=user.llm_base_url,
api_key=user.llm_api_key,
service_id='agent',
)
agent = get_default_agent(llm=llm)
start_conversation_request = StartConversationRequest(
agent=agent,
workspace=workspace,
confirmation_policy=AlwaysConfirm()
if user.confirmation_mode
else NeverConfirm(),
initial_message=initial_message,
secrets=secrets,
)
return start_conversation_request
class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
sandbox_startup_timeout: int = Field(
default=120, description='The max timeout time for sandbox startup'
)
sandbox_startup_poll_frequency: int = Field(
default=2, description='The frequency to poll for sandbox readiness'
)
init_git_in_empty_workspace: bool = Field(
default=True,
description='Whether to initialize a git repo when the workspace is empty',
)
access_token_hard_timeout: int | None = Field(
default=14 * 86400,
description=(
'A security measure - the time after which git tokens may no longer '
'be retrieved by a sandboxed conversation.'
),
)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[AppConversationService, None]:
from openhands.app_server.config import (
get_app_conversation_info_service,
get_app_conversation_start_task_service,
get_global_config,
get_httpx_client,
get_jwt_service,
get_sandbox_service,
get_user_context,
)
async with (
get_user_context(state, request) as user_context,
get_sandbox_service(state, request) as sandbox_service,
get_app_conversation_info_service(
state, request
) as app_conversation_info_service,
get_app_conversation_start_task_service(
state, request
) as app_conversation_start_task_service,
get_jwt_service(state, request) as jwt_service,
get_httpx_client(state, request) as httpx_client,
):
access_token_hard_timeout = None
if self.access_token_hard_timeout:
access_token_hard_timeout = timedelta(
seconds=float(self.access_token_hard_timeout)
)
config = get_global_config()
# If no web url has been set and we are using docker, we can use host.docker.internal
web_url = config.web_url
if web_url is None:
if isinstance(sandbox_service, DockerSandboxService):
web_url = f'http://host.docker.internal:{sandbox_service.host_port}'
yield LiveStatusAppConversationService(
init_git_in_empty_workspace=self.init_git_in_empty_workspace,
user_context=user_context,
sandbox_service=sandbox_service,
app_conversation_info_service=app_conversation_info_service,
app_conversation_start_task_service=app_conversation_start_task_service,
jwt_service=jwt_service,
sandbox_startup_timeout=self.sandbox_startup_timeout,
sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency,
httpx_client=httpx_client,
web_url=web_url,
access_token_hard_timeout=access_token_hard_timeout,
)

View File

@@ -0,0 +1,398 @@
"""SQL implementation of AppConversationService.
This implementation provides CRUD operations for sandboxed conversations focused purely
on SQL operations:
- Direct database access without permission checks
- Batch operations for efficient data retrieval
- Integration with SandboxService for sandbox information
- HTTP client integration for agent status retrieval
- Full async/await support using SQL async db_sessions
Security and permission checks are handled by wrapper services.
Key components:
- SQLAppConversationService: Main service class implementing all operations
- SQLAppConversationInfoServiceInjector: Dependency injection resolver for FastAPI
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from sqlalchemy import Column, DateTime, Float, Integer, Select, String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.agent_server.utils import utc_now
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
AppConversationInfoServiceInjector,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
AppConversationInfoPage,
AppConversationSortOrder,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.sql_utils import (
Base,
create_json_type_decorator,
)
from openhands.integrations.provider import ProviderType
from openhands.sdk.llm import MetricsSnapshot
from openhands.sdk.llm.utils.metrics import TokenUsage
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
logger = logging.getLogger(__name__)
class StoredConversationMetadata(Base): # type: ignore
__tablename__ = 'conversation_metadata'
conversation_id = Column(
String, primary_key=True, default=lambda: str(uuid.uuid4())
)
github_user_id = Column(String, nullable=True) # The GitHub user ID
user_id = Column(String, nullable=False) # The Keycloak User ID
selected_repository = Column(String, nullable=True)
selected_branch = Column(String, nullable=True)
git_provider = Column(
String, nullable=True
) # The git provider (GitHub, GitLab, etc.)
title = Column(String, nullable=True)
last_updated_at = Column(DateTime(timezone=True), default=utc_now) # type: ignore[attr-defined]
created_at = Column(DateTime(timezone=True), default=utc_now) # type: ignore[attr-defined]
trigger = Column(String, nullable=True)
pr_number = Column(create_json_type_decorator(list[int]))
# Cost and token metrics
accumulated_cost = Column(Float, default=0.0)
prompt_tokens = Column(Integer, default=0)
completion_tokens = Column(Integer, default=0)
total_tokens = Column(Integer, default=0)
max_budget_per_task = Column(Float, nullable=True)
cache_read_tokens = Column(Integer, default=0)
cache_write_tokens = Column(Integer, default=0)
reasoning_tokens = Column(Integer, default=0)
context_window = Column(Integer, default=0)
per_turn_token = Column(Integer, default=0)
# LLM model used for the conversation
llm_model = Column(String, nullable=True)
conversation_version = Column(String, nullable=False, default='V0', index=True)
sandbox_id = Column(String, nullable=True, index=True)
@dataclass
class SQLAppConversationInfoService(AppConversationInfoService):
"""SQL implementation of AppConversationInfoService focused on db operations.
This allows storing a record of a conversation even after its sandbox ceases to exist
"""
db_session: AsyncSession
user_context: UserContext
async def search_app_conversation_info(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
) -> AppConversationInfoPage:
"""Search for sandboxed conversations without permission checks."""
query = await self._secure_select()
query = self._apply_filters(
query=query,
title__contains=title__contains,
created_at__gte=created_at__gte,
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
)
# Add sort order
if sort_order == AppConversationSortOrder.CREATED_AT:
query = query.order_by(StoredConversationMetadata.created_at)
elif sort_order == AppConversationSortOrder.CREATED_AT_DESC:
query = query.order_by(StoredConversationMetadata.created_at.desc())
elif sort_order == AppConversationSortOrder.UPDATED_AT:
query = query.order_by(StoredConversationMetadata.updated_at)
elif sort_order == AppConversationSortOrder.UPDATED_AT_DESC:
query = query.order_by(StoredConversationMetadata.updated_at.desc())
elif sort_order == AppConversationSortOrder.TITLE:
query = query.order_by(StoredConversationMetadata.title)
elif sort_order == AppConversationSortOrder.TITLE_DESC:
query = query.order_by(StoredConversationMetadata.title.desc())
# Apply pagination
if page_id is not None:
try:
offset = int(page_id)
query = query.offset(offset)
except ValueError:
# If page_id is not a valid integer, start from beginning
offset = 0
else:
offset = 0
# Apply limit and get one extra to check if there are more results
query = query.limit(limit + 1)
result = await self.db_session.execute(query)
rows = result.scalars().all()
# Check if there are more results
has_more = len(rows) > limit
if has_more:
rows = rows[:limit]
items = [self._to_info(row) for row in rows]
# Calculate next page ID
next_page_id = None
if has_more:
next_page_id = str(offset + limit)
return AppConversationInfoPage(items=items, next_page_id=next_page_id)
async def count_app_conversation_info(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
) -> int:
"""Count sandboxed conversations matching the given filters."""
query = select(func.count(StoredConversationMetadata.conversation_id))
user_id = await self.user_context.get_user_id()
if user_id:
query = query.where(
StoredConversationMetadata.created_by_user_id == user_id
)
query = self._apply_filters(
query=query,
title__contains=title__contains,
created_at__gte=created_at__gte,
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
)
result = await self.db_session.execute(query)
count = result.scalar()
return count or 0
def _apply_filters(
self,
query: Select,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
) -> Select:
# Apply the same filters as search_app_conversations
conditions = []
if title__contains is not None:
conditions.append(
StoredConversationMetadata.title.like(f'%{title__contains}%')
)
if created_at__gte is not None:
conditions.append(StoredConversationMetadata.created_at >= created_at__gte)
if created_at__lt is not None:
conditions.append(StoredConversationMetadata.created_at < created_at__lt)
if updated_at__gte is not None:
conditions.append(
StoredConversationMetadata.last_updated_at >= updated_at__gte
)
if updated_at__lt is not None:
conditions.append(
StoredConversationMetadata.last_updated_at < updated_at__lt
)
if conditions:
query = query.where(*conditions)
return query
async def get_app_conversation_info(
self, conversation_id: UUID
) -> AppConversationInfo | None:
query = await self._secure_select()
query = query.where(
StoredConversationMetadata.conversation_id == str(conversation_id)
)
result_set = await self.db_session.execute(query)
result = result_set.scalar_one_or_none()
if result:
return self._to_info(result)
return None
async def batch_get_app_conversation_info(
self, conversation_ids: list[UUID]
) -> list[AppConversationInfo | None]:
conversation_id_strs = [
str(conversation_id) for conversation_id in conversation_ids
]
query = await self._secure_select()
query = query.where(
StoredConversationMetadata.conversation_id.in_(conversation_id_strs)
)
result = await self.db_session.execute(query)
rows = result.scalars().all()
info_by_id = {info.conversation_id: info for info in rows if info}
results: list[AppConversationInfo | None] = []
for conversation_id in conversation_id_strs:
info = info_by_id.get(conversation_id)
if info:
results.append(self._to_info(info))
else:
results.append(None)
return results
async def save_app_conversation_info(
self, info: AppConversationInfo
) -> AppConversationInfo:
user_id = await self.user_context.get_user_id()
if user_id:
query = select(StoredConversationMetadata).where(
StoredConversationMetadata.conversation_id == info.id
)
result = await self.db_session.execute(query)
existing = result.scalar_one_or_none()
assert existing is None or existing.created_by_user_id == user_id
metrics = info.metrics or MetricsSnapshot()
usage = metrics.accumulated_token_usage or TokenUsage()
stored = StoredConversationMetadata(
conversation_id=str(info.id),
github_user_id=None, # TODO: Should we add this to the conversation info?
user_id=info.created_by_user_id or '',
selected_repository=info.selected_repository,
selected_branch=info.selected_branch,
git_provider=info.git_provider.value if info.git_provider else None,
title=info.title,
last_updated_at=info.updated_at,
created_at=info.created_at,
trigger=info.trigger.value if info.trigger else None,
pr_number=info.pr_number,
# Cost and token metrics
accumulated_cost=metrics.accumulated_cost,
prompt_tokens=usage.prompt_tokens,
completion_tokens=usage.completion_tokens,
total_tokens=0,
max_budget_per_task=metrics.max_budget_per_task,
cache_read_tokens=usage.cache_read_tokens,
cache_write_tokens=usage.cache_write_tokens,
context_window=usage.context_window,
per_turn_token=usage.per_turn_token,
llm_model=info.llm_model,
conversation_version='V1',
sandbox_id=info.sandbox_id,
)
await self.db_session.merge(stored)
await self.db_session.commit()
return info
async def _secure_select(self):
query = select(StoredConversationMetadata).where(
StoredConversationMetadata.conversation_version == 'V1'
)
user_id = await self.user_context.get_user_id()
if user_id:
query = query.where(
StoredConversationMetadata.user_id == user_id,
)
return query
def _to_info(self, stored: StoredConversationMetadata) -> AppConversationInfo:
# V1 conversations should always have a sandbox_id
sandbox_id = stored.sandbox_id
assert sandbox_id is not None
# Rebuild token usage
token_usage = TokenUsage(
prompt_tokens=stored.prompt_tokens,
completion_tokens=stored.completion_tokens,
cache_read_tokens=stored.cache_read_tokens,
cache_write_tokens=stored.cache_write_tokens,
context_window=stored.context_window,
per_turn_token=stored.per_turn_token,
)
# Rebuild metrics object
metrics = MetricsSnapshot(
accumulated_cost=stored.accumulated_cost,
max_budget_per_task=stored.max_budget_per_task,
accumulated_token_usage=token_usage,
)
# Get timestamps
created_at = self._fix_timezone(stored.created_at)
updated_at = self._fix_timezone(stored.last_updated_at)
return AppConversationInfo(
id=UUID(stored.conversation_id),
created_by_user_id=stored.user_id if stored.user_id else None,
sandbox_id=stored.sandbox_id,
selected_repository=stored.selected_repository,
selected_branch=stored.selected_branch,
git_provider=ProviderType(stored.git_provider)
if stored.git_provider
else None,
title=stored.title,
trigger=ConversationTrigger(stored.trigger) if stored.trigger else None,
pr_number=stored.pr_number,
llm_model=stored.llm_model,
metrics=metrics,
created_at=created_at,
updated_at=updated_at,
)
def _fix_timezone(self, value: datetime) -> datetime:
"""Sqlite does not stpre timezones - and since we can't update the existing models
we assume UTC if the timezone is missing."""
if not value.tzinfo:
value = value.replace(tzinfo=UTC)
return value
class SQLAppConversationInfoServiceInjector(AppConversationInfoServiceInjector):
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[AppConversationInfoService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import (
get_db_session,
get_user_context,
)
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
service = SQLAppConversationInfoService(
db_session=db_session, user_context=user_context
)
yield service

View File

@@ -0,0 +1,154 @@
# pyright: reportArgumentType=false, reportAttributeAccessIssue=false, reportOptionalMemberAccess=false
"""SQL implementation of AppConversationStartTaskService.
This implementation provides CRUD operations for conversation start tasks focused purely
on SQL operations:
- Direct database access without permission checks
- Batch operations for efficient data retrieval
- Full async/await support using SQL async sessions
Security and permission checks are handled by wrapper services.
Key components:
- SQLAppConversationStartTaskService: Main service class implementing all operations
- SQLAppConversationStartTaskServiceInjector: Dependency injection resolver for FastAPI
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from sqlalchemy import UUID as SQLUUID
from sqlalchemy import Column, String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.agent_server.models import utc_now
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTask,
AppConversationStartTaskStatus,
)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
AppConversationStartTaskServiceInjector,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.utils.sql_utils import (
Base,
UtcDateTime,
create_enum_type_decorator,
create_json_type_decorator,
row2dict,
)
logger = logging.getLogger(__name__)
class StoredAppConversationStartTask(Base): # type: ignore
__tablename__ = 'app_conversation_start_task'
id = Column(SQLUUID, primary_key=True)
created_by_user_id = Column(String, index=True)
status = Column(create_enum_type_decorator(AppConversationStartTaskStatus))
detail = Column(String, nullable=True)
app_conversation_id = Column(SQLUUID, nullable=True)
sandbox_id = Column(String, nullable=True)
agent_server_url = Column(String, nullable=True)
request = Column(create_json_type_decorator(AppConversationStartRequest))
created_at = Column(UtcDateTime, server_default=func.now(), index=True)
updated_at = Column(UtcDateTime, onupdate=func.now(), index=True)
@dataclass
class SQLAppConversationStartTaskService(AppConversationStartTaskService):
"""SQL implementation of AppConversationStartTaskService focused on db operations.
This allows storing and retrieving conversation start tasks from the database."""
session: AsyncSession
user_id: str | None = None
async def batch_get_app_conversation_start_tasks(
self, task_ids: list[UUID]
) -> list[AppConversationStartTask | None]:
"""Get a batch of start tasks, return None for any missing."""
if not task_ids:
return []
query = select(StoredAppConversationStartTask).where(
StoredAppConversationStartTask.id.in_(task_ids)
)
if self.user_id:
query = query.where(
StoredAppConversationStartTask.created_by_user_id == self.user_id
)
result = await self.session.execute(query)
tasks_by_id = {task.id: task for task in result.scalars().all()}
# Return tasks in the same order as requested, with None for missing ones
return [
AppConversationStartTask(**row2dict(tasks_by_id[task_id]))
if task_id in tasks_by_id
else None
for task_id in task_ids
]
async def get_app_conversation_start_task(
self, task_id: UUID
) -> AppConversationStartTask | None:
"""Get a single start task, returning None if missing."""
query = select(StoredAppConversationStartTask).where(
StoredAppConversationStartTask.id == task_id
)
if self.user_id:
query = query.where(
StoredAppConversationStartTask.created_by_user_id == self.user_id
)
result = await self.session.execute(query)
stored_task = result.scalar_one_or_none()
if stored_task:
return AppConversationStartTask(**row2dict(stored_task))
return None
async def save_app_conversation_start_task(
self, task: AppConversationStartTask
) -> AppConversationStartTask:
if self.user_id:
query = select(StoredAppConversationStartTask).where(
StoredAppConversationStartTask.id == task.id
)
result = await self.session.execute(query)
existing = result.scalar_one_or_none()
assert existing is None or existing.created_by_user_id == self.user_id
task.updated_at = utc_now()
await self.session.merge(StoredAppConversationStartTask(**task.model_dump()))
await self.session.commit()
return task
class SQLAppConversationStartTaskServiceInjector(
AppConversationStartTaskServiceInjector
):
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[AppConversationStartTaskService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import (
get_db_session,
get_user_context,
)
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
user_id = await user_context.get_user_id()
service = SQLAppConversationStartTaskService(
session=db_session, user_id=user_id
)
yield service

View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# Note: The database URL is now configured dynamically in env.py using the DbSessionInjector
# from get_global_config(), so this placeholder is not used.
# sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1,15 @@
# OpenHands App Server Alembic Integration
This alembic integration keeps the SQLite database up to date in single user deployments by managing schema migrations for app_server models. Migrations are applied automatically on startup.
## Configuration
Uses `DbSessionInjector` from `get_global_config()` for database connectivity and auto-detects models from the declarative base in `openhands.app_server.utils.sql_utils.Base`.
## Key Commands
Generate migration from model changes:
```bash
cd openhands/app_server/app_lifespan
alembic revision --autogenerate -m 'Sync DB with Models'
```

View File

@@ -0,0 +1,115 @@
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
# Add the project root to the Python path so we can import OpenHands modules
# From alembic/env.py, we need to go up 5 levels to reach the OpenHands project root
project_root = Path(__file__).absolute().parent.parent.parent.parent.parent
sys.path.insert(0, str(project_root))
# Import the Base metadata for autogenerate support
# Import all models to ensure they are registered with the metadata
# This is necessary for alembic autogenerate to detect all tables
from openhands.app_server.app_conversation.sql_app_conversation_info_service import ( # noqa: E402
StoredConversationMetadata, # noqa: F401
)
from openhands.app_server.app_conversation.sql_app_conversation_start_task_service import ( # noqa: E402
StoredAppConversationStartTask, # noqa: F401
)
from openhands.app_server.config import get_global_config # noqa: E402
from openhands.app_server.event_callback.sql_event_callback_service import ( # noqa: E402
StoredEventCallback, # noqa: F401
)
from openhands.app_server.sandbox.remote_sandbox_service import ( # noqa: E402
StoredRemoteSandbox, # noqa: F401
)
from openhands.app_server.utils.sql_utils import Base # noqa: E402
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
import os
if os.path.exists(config.config_file_name):
fileConfig(config.config_file_name)
else:
# Use basic logging configuration if config file doesn't exist
import logging
logging.basicConfig(level=logging.INFO)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
# Get database URL from DbSessionInjector
global_config = get_global_config()
db_session = global_config.db_session
# Get the database URL from the DbSessionInjector
if db_session.host:
password_value = (
db_session.password.get_secret_value() if db_session.password else ''
)
url = f'postgresql://{db_session.user}:{password_value}@{db_session.host}:{db_session.port}/{db_session.name}'
else:
url = f'sqlite:///{db_session.persistence_dir}/openhands.db'
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={'paramstyle': 'named'},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# Use the DbSessionInjector engine instead of creating a new one
global_config = get_global_config()
db_session = global_config.db_session
connectable = db_session.get_db_engine()
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,268 @@
"""Sync DB with Models
Revision ID: 001
Revises:
Create Date: 2025-10-05 11:28:41.772294
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartTaskStatus,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResultStatus,
)
# revision identifiers, used by Alembic.
revision: str = '001'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
'app_conversation_start_task',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_by_user_id', sa.String(), nullable=True),
sa.Column('status', sa.Enum(AppConversationStartTaskStatus), nullable=True),
sa.Column('detail', sa.String(), nullable=True),
sa.Column('app_conversation_id', sa.UUID(), nullable=True),
sa.Column('sandbox_id', sa.String(), nullable=True),
sa.Column('agent_server_url', sa.String(), nullable=True),
sa.Column('request', sa.JSON(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_app_conversation_start_task_created_at'),
'app_conversation_start_task',
['created_at'],
unique=False,
)
op.create_index(
op.f('ix_app_conversation_start_task_created_by_user_id'),
'app_conversation_start_task',
['created_by_user_id'],
unique=False,
)
op.create_index(
op.f('ix_app_conversation_start_task_updated_at'),
'app_conversation_start_task',
['updated_at'],
unique=False,
)
op.create_table(
'event_callback',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('conversation_id', sa.UUID(), nullable=True),
sa.Column('processor', sa.JSON(), nullable=True),
sa.Column('event_kind', sa.String(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_event_callback_created_at'),
'event_callback',
['created_at'],
unique=False,
)
op.create_table(
'event_callback_result',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('status', sa.Enum(EventCallbackResultStatus), nullable=True),
sa.Column('event_callback_id', sa.UUID(), nullable=True),
sa.Column('event_id', sa.UUID(), nullable=True),
sa.Column('conversation_id', sa.UUID(), nullable=True),
sa.Column('detail', sa.String(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_event_callback_result_conversation_id'),
'event_callback_result',
['conversation_id'],
unique=False,
)
op.create_index(
op.f('ix_event_callback_result_created_at'),
'event_callback_result',
['created_at'],
unique=False,
)
op.create_index(
op.f('ix_event_callback_result_event_callback_id'),
'event_callback_result',
['event_callback_id'],
unique=False,
)
op.create_index(
op.f('ix_event_callback_result_event_id'),
'event_callback_result',
['event_id'],
unique=False,
)
op.create_table(
'v1_remote_sandbox',
sa.Column('id', sa.String(), nullable=False),
sa.Column('created_by_user_id', sa.String(), nullable=True),
sa.Column('sandbox_spec_id', sa.String(), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'),
nullable=True,
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_v1_remote_sandbox_created_at'),
'v1_remote_sandbox',
['created_at'],
unique=False,
)
op.create_index(
op.f('ix_v1_remote_sandbox_created_by_user_id'),
'v1_remote_sandbox',
['created_by_user_id'],
unique=False,
)
op.create_index(
op.f('ix_v1_remote_sandbox_sandbox_spec_id'),
'v1_remote_sandbox',
['sandbox_spec_id'],
unique=False,
)
op.create_table(
'conversation_metadata',
sa.Column('conversation_id', sa.String(), nullable=False),
sa.Column('github_user_id', sa.String(), nullable=True),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('selected_repository', sa.String(), nullable=True),
sa.Column('selected_branch', sa.String(), nullable=True),
sa.Column('git_provider', sa.String(), nullable=True),
sa.Column('title', sa.String(), nullable=True),
sa.Column('last_updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('trigger', sa.String(), nullable=True),
sa.Column('pr_number', sa.JSON(), nullable=True),
sa.Column('accumulated_cost', sa.Float(), nullable=True),
sa.Column('prompt_tokens', sa.Integer(), nullable=True),
sa.Column('completion_tokens', sa.Integer(), nullable=True),
sa.Column('total_tokens', sa.Integer(), nullable=True),
sa.Column('max_budget_per_task', sa.Float(), nullable=True),
sa.Column('cache_read_tokens', sa.Integer(), nullable=True),
sa.Column('cache_write_tokens', sa.Integer(), nullable=True),
sa.Column('reasoning_tokens', sa.Integer(), nullable=True),
sa.Column('context_window', sa.Integer(), nullable=True),
sa.Column('per_turn_token', sa.Integer(), nullable=True),
sa.Column('llm_model', sa.String(), nullable=True),
sa.Column('conversation_version', sa.String(), nullable=False),
sa.Column('sandbox_id', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('conversation_id'),
)
op.create_index(
op.f('ix_conversation_metadata_conversation_version'),
'conversation_metadata',
['conversation_version'],
unique=False,
)
op.create_index(
op.f('ix_conversation_metadata_sandbox_id'),
'conversation_metadata',
['sandbox_id'],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(
op.f('ix_conversation_metadata_sandbox_id'), table_name='conversation_metadata'
)
op.drop_index(
op.f('ix_conversation_metadata_conversation_version'),
table_name='conversation_metadata',
)
op.drop_table('conversation_metadata')
op.drop_index(
op.f('ix_v1_remote_sandbox_sandbox_spec_id'), table_name='v1_remote_sandbox'
)
op.drop_index(
op.f('ix_v1_remote_sandbox_created_by_user_id'), table_name='v1_remote_sandbox'
)
op.drop_index(
op.f('ix_v1_remote_sandbox_created_at'), table_name='v1_remote_sandbox'
)
op.drop_table('v1_remote_sandbox')
op.drop_index(
op.f('ix_event_callback_result_event_id'),
table_name='event_callback_result',
)
op.drop_index(
op.f('ix_event_callback_result_event_callback_id'),
table_name='event_callback_result',
)
op.drop_index(
op.f('ix_event_callback_result_created_at'),
table_name='event_callback_result',
)
op.drop_index(
op.f('ix_event_callback_result_conversation_id'),
table_name='event_callback_result',
)
op.drop_table('event_callback_result')
op.drop_index(op.f('ix_event_callback_created_at'), table_name='event_callback')
op.drop_table('event_callback')
op.drop_index(
op.f('ix_app_conversation_start_task_updated_at'),
table_name='app_conversation_start_task',
)
op.drop_index(
op.f('ix_app_conversation_start_task_created_by_user_id'),
table_name='app_conversation_start_task',
)
op.drop_index(
op.f('ix_app_conversation_start_task_created_at'),
table_name='app_conversation_start_task',
)
op.drop_table('app_conversation_start_task')
op.drop_index(
op.f('ix_app_conversation_info_updated_at'),
table_name='app_conversation_info',
)
op.drop_index(
op.f('ix_app_conversation_info_sandbox_id'),
table_name='app_conversation_info',
)
op.drop_index(
op.f('ix_app_conversation_info_created_by_user_id'),
table_name='app_conversation_info',
)
op.drop_index(
op.f('ix_app_conversation_info_created_at'),
table_name='app_conversation_info',
)
op.drop_table('app_conversation_info')

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from fastapi import FastAPI
from openhands.sdk.utils.models import DiscriminatedUnionMixin
class AppLifespanService(DiscriminatedUnionMixin, ABC):
def lifespan(self, api: FastAPI):
"""Return lifespan wrapper."""
return self
@abstractmethod
async def __aenter__(self):
"""Open lifespan."""
@abstractmethod
async def __aexit__(self, exc_type, exc_value, traceback):
"""Close lifespan."""

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import os
from pathlib import Path
from alembic import command
from alembic.config import Config
from openhands.app_server.app_lifespan.app_lifespan_service import AppLifespanService
class OssAppLifespanService(AppLifespanService):
run_alembic_on_startup: bool = True
async def __aenter__(self):
if self.run_alembic_on_startup:
self.run_alembic()
return self
async def __aexit__(self, exc_type, exc_value, traceback):
pass
def run_alembic(self):
# Run alembic upgrade head to ensure database is up to date
alembic_dir = Path(__file__).parent / 'alembic'
alembic_ini = alembic_dir / 'alembic.ini'
# Create alembic config with absolute paths
alembic_cfg = Config(str(alembic_ini))
alembic_cfg.set_main_option('script_location', str(alembic_dir))
# Change to alembic directory for the command execution
original_cwd = os.getcwd()
try:
os.chdir(str(alembic_dir.parent))
command.upgrade(alembic_cfg, 'head')
finally:
os.chdir(original_cwd)

View File

@@ -0,0 +1,348 @@
"""Configuration for the OpenHands App Server."""
import os
from pathlib import Path
from typing import AsyncContextManager
import httpx
from fastapi import Depends, Request
from pydantic import Field
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.agent_server.env_parser import from_env
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
AppConversationInfoServiceInjector,
)
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
AppConversationServiceInjector,
)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
AppConversationStartTaskServiceInjector,
)
from openhands.app_server.app_lifespan.app_lifespan_service import AppLifespanService
from openhands.app_server.app_lifespan.oss_app_lifespan_service import (
OssAppLifespanService,
)
from openhands.app_server.event.event_service import EventService, EventServiceInjector
from openhands.app_server.event_callback.event_callback_service import (
EventCallbackService,
EventCallbackServiceInjector,
)
from openhands.app_server.sandbox.sandbox_service import (
SandboxService,
SandboxServiceInjector,
)
from openhands.app_server.sandbox.sandbox_spec_service import (
SandboxSpecService,
SandboxSpecServiceInjector,
)
from openhands.app_server.services.db_session_injector import (
DbSessionInjector,
)
from openhands.app_server.services.httpx_client_injector import HttpxClientInjector
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.services.jwt_service import JwtService, JwtServiceInjector
from openhands.app_server.user.user_context import UserContext, UserContextInjector
from openhands.sdk.utils.models import OpenHandsModel
def get_default_persistence_dir() -> Path:
# Recheck env because this function is also used to generate other defaults
persistence_dir = os.getenv('OH_PERSISTENCE_DIR')
if persistence_dir:
result = Path(persistence_dir)
else:
result = Path.home() / '.openhands'
result.mkdir(parents=True, exist_ok=True)
return result
def get_default_web_url() -> str | None:
"""Get legacy web host parameter.
If present, we assume we are running under https."""
web_host = os.getenv('WEB_HOST')
if not web_host:
return None
return f'https://{web_host}'
def _get_default_lifespan():
# Check legacy parameters for saas mode. If we are in SAAS mode do not apply
# OSS alembic migrations
if 'saas' in (os.getenv('OPENHANDS_CONFIG_CLS') or '').lower():
return None
return OssAppLifespanService()
class AppServerConfig(OpenHandsModel):
persistence_dir: Path = Field(default_factory=get_default_persistence_dir)
web_url: str | None = Field(
default_factory=get_default_web_url,
description='The URL where OpenHands is running (e.g., http://localhost:3000)',
)
# Dependency Injection Injectors
event: EventServiceInjector | None = None
event_callback: EventCallbackServiceInjector | None = None
sandbox: SandboxServiceInjector | None = None
sandbox_spec: SandboxSpecServiceInjector | None = None
app_conversation_info: AppConversationInfoServiceInjector | None = None
app_conversation_start_task: AppConversationStartTaskServiceInjector | None = None
app_conversation: AppConversationServiceInjector | None = None
user: UserContextInjector | None = None
jwt: JwtServiceInjector | None = None
httpx: HttpxClientInjector = Field(default_factory=HttpxClientInjector)
db_session: DbSessionInjector = Field(
default_factory=lambda: DbSessionInjector(
persistence_dir=get_default_persistence_dir()
)
)
# Services
lifespan: AppLifespanService = Field(default_factory=_get_default_lifespan)
def config_from_env() -> AppServerConfig:
# Import defaults...
from openhands.app_server.app_conversation.live_status_app_conversation_service import ( # noqa: E501
LiveStatusAppConversationServiceInjector,
)
from openhands.app_server.app_conversation.sql_app_conversation_info_service import ( # noqa: E501
SQLAppConversationInfoServiceInjector,
)
from openhands.app_server.app_conversation.sql_app_conversation_start_task_service import ( # noqa: E501
SQLAppConversationStartTaskServiceInjector,
)
from openhands.app_server.event.filesystem_event_service import (
FilesystemEventServiceInjector,
)
from openhands.app_server.event_callback.sql_event_callback_service import (
SQLEventCallbackServiceInjector,
)
from openhands.app_server.sandbox.docker_sandbox_service import (
DockerSandboxServiceInjector,
)
from openhands.app_server.sandbox.docker_sandbox_spec_service import (
DockerSandboxSpecServiceInjector,
)
from openhands.app_server.sandbox.remote_sandbox_service import (
RemoteSandboxServiceInjector,
)
from openhands.app_server.sandbox.remote_sandbox_spec_service import (
RemoteSandboxSpecServiceInjector,
)
from openhands.app_server.user.auth_user_context import (
AuthUserContextInjector,
)
config: AppServerConfig = from_env(AppServerConfig, 'OH') # type: ignore
if config.event is None:
config.event = FilesystemEventServiceInjector()
if config.event_callback is None:
config.event_callback = SQLEventCallbackServiceInjector()
if config.sandbox is None:
# Legacy fallback
if os.getenv('RUNTIME') == 'remote':
config.sandbox = RemoteSandboxServiceInjector(
api_key=os.environ['SANDBOX_API_KEY'],
api_url=os.environ['SANDBOX_REMOTE_RUNTIME_API_URL'],
)
else:
config.sandbox = DockerSandboxServiceInjector()
if config.sandbox_spec is None:
if os.getenv('RUNTIME') == 'remote':
config.sandbox_spec = RemoteSandboxSpecServiceInjector()
else:
config.sandbox_spec = DockerSandboxSpecServiceInjector()
if config.app_conversation_info is None:
config.app_conversation_info = SQLAppConversationInfoServiceInjector()
if config.app_conversation_start_task is None:
config.app_conversation_start_task = (
SQLAppConversationStartTaskServiceInjector()
)
if config.app_conversation is None:
config.app_conversation = LiveStatusAppConversationServiceInjector()
if config.user is None:
config.user = AuthUserContextInjector()
if config.jwt is None:
config.jwt = JwtServiceInjector(persistence_dir=config.persistence_dir)
return config
_global_config: AppServerConfig | None = None
def get_global_config() -> AppServerConfig:
"""Get the default local server config shared across the server."""
global _global_config
if _global_config is None:
# Load configuration from environment...
_global_config = config_from_env()
return _global_config # type: ignore
def get_event_service(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[EventService]:
injector = get_global_config().event
assert injector is not None
return injector.context(state, request)
def get_event_callback_service(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[EventCallbackService]:
injector = get_global_config().event_callback
assert injector is not None
return injector.context(state, request)
def get_sandbox_service(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[SandboxService]:
injector = get_global_config().sandbox
assert injector is not None
return injector.context(state, request)
def get_sandbox_spec_service(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[SandboxSpecService]:
injector = get_global_config().sandbox_spec
assert injector is not None
return injector.context(state, request)
def get_app_conversation_info_service(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[AppConversationInfoService]:
injector = get_global_config().app_conversation_info
assert injector is not None
return injector.context(state, request)
def get_app_conversation_start_task_service(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[AppConversationStartTaskService]:
injector = get_global_config().app_conversation_start_task
assert injector is not None
return injector.context(state, request)
def get_app_conversation_service(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[AppConversationService]:
injector = get_global_config().app_conversation
assert injector is not None
return injector.context(state, request)
def get_user_context(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[UserContext]:
injector = get_global_config().user
assert injector is not None
return injector.context(state, request)
def get_httpx_client(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[httpx.AsyncClient]:
return get_global_config().httpx.context(state, request)
def get_jwt_service(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[JwtService]:
injector = get_global_config().jwt
assert injector is not None
return injector.context(state, request)
def get_db_session(
state: InjectorState, request: Request | None = None
) -> AsyncContextManager[AsyncSession]:
return get_global_config().db_session.context(state, request)
def get_app_lifespan_service() -> AppLifespanService:
config = get_global_config()
return config.lifespan
def depends_event_service():
injector = get_global_config().event
assert injector is not None
return Depends(injector.depends)
def depends_event_callback_service():
injector = get_global_config().event_callback
assert injector is not None
return Depends(injector.depends)
def depends_sandbox_service():
injector = get_global_config().sandbox
assert injector is not None
return Depends(injector.depends)
def depends_sandbox_spec_service():
injector = get_global_config().sandbox_spec
assert injector is not None
return Depends(injector.depends)
def depends_app_conversation_info_service():
injector = get_global_config().app_conversation_info
assert injector is not None
return Depends(injector.depends)
def depends_app_conversation_start_task_service():
injector = get_global_config().app_conversation_start_task
assert injector is not None
return Depends(injector.depends)
def depends_app_conversation_service():
injector = get_global_config().app_conversation
assert injector is not None
return Depends(injector.depends)
def depends_user_context():
injector = get_global_config().user
assert injector is not None
return Depends(injector.depends)
def depends_httpx_client():
return Depends(get_global_config().httpx.depends)
def depends_jwt_service():
injector = get_global_config().jwt
assert injector is not None
return Depends(injector.depends)
def depends_db_session():
return Depends(get_global_config().db_session.depends)

View File

@@ -0,0 +1,14 @@
class OpenHandsError(Exception):
pass
class AuthError(OpenHandsError):
"""Error in authentication."""
class PermissionsError(OpenHandsError):
"""Error in permissions."""
class SandboxError(OpenHandsError):
"""Error in Sandbox."""

View File

@@ -0,0 +1,21 @@
# Event Management
Handles event storage, retrieval, and streaming for the OpenHands app server.
## Overview
This module provides services for managing events within conversations, including event persistence, querying, and real-time streaming capabilities.
## Key Components
- **EventService**: Abstract service for event CRUD operations
- **FilesystemEventService**: File-based event storage implementation
- **EventRouter**: FastAPI router for event-related endpoints
## Features
- Event storage and retrieval by conversation ID
- Event filtering by kind, timestamp, and other criteria
- Sorting support and pagination for large event sets
- Real-time event streaming capabilities
- Multiple storage backend support (filesystem, database)

View File

@@ -0,0 +1,110 @@
"""Event router for OpenHands Server."""
from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Query
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.config import depends_event_service
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.sdk import Event
router = APIRouter(prefix='/events', tags=['Events'])
event_service_dependency = depends_event_service()
# Read methods
@router.get('/search')
async def search_events(
conversation_id__eq: Annotated[
UUID | None,
Query(title='Optional filter by conversation ID'),
] = None,
kind__eq: Annotated[
EventKind | None,
Query(title='Optional filter by event kind'),
] = None,
timestamp__gte: Annotated[
datetime | None,
Query(title='Optional filter by timestamp greater than or equal to'),
] = None,
timestamp__lt: Annotated[
datetime | None,
Query(title='Optional filter by timestamp less than'),
] = None,
sort_order: Annotated[
EventSortOrder,
Query(title='Sort order for results'),
] = EventSortOrder.TIMESTAMP,
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
] = 100,
event_service: EventService = event_service_dependency,
) -> EventPage:
"""Search / List events."""
assert limit > 0
assert limit <= 100
return await event_service.search_events(
conversation_id__eq=conversation_id__eq,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
@router.get('/count')
async def count_events(
conversation_id__eq: Annotated[
UUID | None,
Query(title='Optional filter by conversation ID'),
] = None,
kind__eq: Annotated[
EventKind | None,
Query(title='Optional filter by event kind'),
] = None,
timestamp__gte: Annotated[
datetime | None,
Query(title='Optional filter by timestamp greater than or equal to'),
] = None,
timestamp__lt: Annotated[
datetime | None,
Query(title='Optional filter by timestamp less than'),
] = None,
sort_order: Annotated[
EventSortOrder,
Query(title='Sort order for results'),
] = EventSortOrder.TIMESTAMP,
event_service: EventService = event_service_dependency,
) -> int:
"""Count events matching the given filters."""
return await event_service.count_events(
conversation_id__eq=conversation_id__eq,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
)
@router.get('')
async def batch_get_events(
id: Annotated[list[str], Query()],
event_service: EventService = event_service_dependency,
) -> list[Event | None]:
"""Get a batch of events given their ids, returning null for any missing event."""
assert len(id) <= 100
events = await event_service.batch_get_events(id)
return events

View File

@@ -0,0 +1,59 @@
import asyncio
import logging
from abc import ABC, abstractmethod
from datetime import datetime
from uuid import UUID
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import Injector
from openhands.sdk import Event
from openhands.sdk.utils.models import DiscriminatedUnionMixin
_logger = logging.getLogger(__name__)
class EventService(ABC):
"""Event Service for getting events."""
@abstractmethod
async def get_event(self, event_id: str) -> Event | None:
"""Given an id, retrieve an event."""
@abstractmethod
async def search_events(
self,
conversation_id__eq: UUID | None = None,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
) -> EventPage:
"""Search events matching the given filters."""
@abstractmethod
async def count_events(
self,
conversation_id__eq: UUID | None = None,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
) -> int:
"""Count events matching the given filters."""
@abstractmethod
async def save_event(self, conversation_id: UUID, event: Event):
"""Save an event. Internal method intended not be part of the REST api."""
async def batch_get_events(self, event_ids: list[str]) -> list[Event | None]:
"""Given a list of ids, get events (Or none for any which were not found)."""
return await asyncio.gather(
*[self.get_event(event_id) for event_id in event_ids]
)
class EventServiceInjector(DiscriminatedUnionMixin, Injector[EventService], ABC):
pass

View File

@@ -0,0 +1,318 @@
"""Filesystem-based EventService implementation."""
import glob
import json
import logging
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
)
from openhands.app_server.errors import OpenHandsError
from openhands.app_server.event.event_service import EventService, EventServiceInjector
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import InjectorState
from openhands.sdk import Event
_logger = logging.getLogger(__name__)
@dataclass
class FilesystemEventService(EventService):
"""Filesystem-based implementation of EventService.
Events are stored in files with the naming format:
{conversation_id}/{YYYYMMDDHHMMSS}_{kind}_{id.hex}
Uses an AppConversationInfoService to lookup conversations
"""
app_conversation_info_service: AppConversationInfoService
events_dir: Path
def _ensure_events_dir(self, conversation_id: UUID | None = None) -> Path:
"""Ensure the events directory exists."""
if conversation_id:
events_path = self.events_dir / str(conversation_id)
else:
events_path = self.events_dir
events_path.mkdir(parents=True, exist_ok=True)
return events_path
def _timestamp_to_str(self, timestamp: datetime | str) -> str:
"""Convert timestamp to YYYYMMDDHHMMSS format."""
if isinstance(timestamp, str):
# Parse ISO format timestamp string
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
return dt.strftime('%Y%m%d%H%M%S')
return timestamp.strftime('%Y%m%d%H%M%S')
def _get_event_filename(self, conversation_id: UUID, event: Event) -> str:
"""Generate filename using YYYYMMDDHHMMSS_kind_id.hex format."""
timestamp_str = self._timestamp_to_str(event.timestamp)
kind = event.__class__.__name__
# Handle both UUID objects and string UUIDs
if isinstance(event.id, str):
id_hex = event.id.replace('-', '')
else:
id_hex = event.id.hex
return f'{timestamp_str}_{kind}_{id_hex}'
def _save_event_to_file(self, conversation_id: UUID, event: Event) -> None:
"""Save an event to a file."""
events_path = self._ensure_events_dir(conversation_id)
filename = self._get_event_filename(conversation_id, event)
filepath = events_path / filename
with open(filepath, 'w') as f:
# Use model_dump with mode='json' to handle UUID serialization
data = event.model_dump(mode='json')
f.write(json.dumps(data, indent=2))
def _load_event_from_file(self, filepath: Path) -> Event | None:
"""Load an event from a file."""
try:
json_data = filepath.read_text()
return Event.model_validate_json(json_data)
except Exception:
return None
def _get_event_files_by_pattern(
self, pattern: str, conversation_id: UUID | None = None
) -> list[Path]:
"""Get event files matching a glob pattern, sorted by timestamp."""
if conversation_id:
search_path = self.events_dir / str(conversation_id) / pattern
else:
search_path = self.events_dir / '*' / pattern
files = glob.glob(str(search_path))
return sorted([Path(f) for f in files])
def _parse_filename(self, filename: str) -> dict[str, str] | None:
"""Parse filename to extract timestamp, kind, and event_id."""
try:
parts = filename.split('_')
if len(parts) >= 3:
timestamp_str = parts[0]
kind = '_'.join(parts[1:-1]) # Handle kinds with underscores
event_id = parts[-1]
return {'timestamp': timestamp_str, 'kind': kind, 'event_id': event_id}
except Exception:
pass
return None
def _get_conversation_id(self, file: Path) -> UUID | None:
try:
return UUID(file.parent.name)
except Exception:
return None
def _get_conversation_ids(self, files: list[Path]) -> set[UUID]:
result = set()
for file in files:
conversation_id = self._get_conversation_id(file)
if conversation_id:
result.add(conversation_id)
return result
async def _filter_files_by_conversation(self, files: list[Path]) -> list[Path]:
conversation_ids = list(self._get_conversation_ids(files))
conversations = (
await self.app_conversation_info_service.batch_get_app_conversation_info(
conversation_ids
)
)
permitted_conversation_ids = set()
for conversation in conversations:
if conversation:
permitted_conversation_ids.add(conversation.id)
result = [
file
for file in files
if self._get_conversation_id(file) in permitted_conversation_ids
]
return result
def _filter_files_by_criteria(
self,
files: list[Path],
conversation_id__eq: UUID | None = None,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
) -> list[Path]:
"""Filter files based on search criteria."""
filtered_files = []
for file_path in files:
# Check conversation_id filter
if conversation_id__eq:
if str(conversation_id__eq) not in str(file_path):
continue
# Parse filename for additional filtering
filename_info = self._parse_filename(file_path.name)
if not filename_info:
continue
# Check kind filter
if kind__eq and filename_info['kind'] != kind__eq:
continue
# Check timestamp filters
if timestamp__gte or timestamp__lt:
try:
file_timestamp = datetime.strptime(
filename_info['timestamp'], '%Y%m%d%H%M%S'
)
if timestamp__gte and file_timestamp < timestamp__gte:
continue
if timestamp__lt and file_timestamp >= timestamp__lt:
continue
except ValueError:
continue
filtered_files.append(file_path)
return filtered_files
async def get_event(self, event_id: str) -> Event | None:
"""Get the event with the given id, or None if not found."""
# Convert event_id to hex format (remove dashes) for filename matching
if isinstance(event_id, str) and '-' in event_id:
id_hex = event_id.replace('-', '')
else:
id_hex = event_id
# Use glob pattern to find files ending with the event_id
pattern = f'*_{id_hex}'
files = self._get_event_files_by_pattern(pattern)
if not files:
return None
# If there is no access to the conversation do not return the event
file = files[0]
conversation_id = self._get_conversation_id(file)
if not conversation_id:
return None
conversation = (
await self.app_conversation_info_service.get_app_conversation_info(
conversation_id
)
)
if not conversation:
return None
# Load and return the first matching event
return self._load_event_from_file(file)
async def search_events(
self,
conversation_id__eq: UUID | None = None,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
) -> EventPage:
"""Search for events matching the given filters."""
# Build the search pattern
pattern = '*'
files = self._get_event_files_by_pattern(pattern, conversation_id__eq)
files = await self._filter_files_by_conversation(files)
files = self._filter_files_by_criteria(
files, conversation_id__eq, kind__eq, timestamp__gte, timestamp__lt
)
files.sort(
key=lambda f: f.name,
reverse=(sort_order == EventSortOrder.TIMESTAMP_DESC),
)
# Handle pagination
start_index = 0
if page_id:
for i, file_path in enumerate(files):
if file_path.name == page_id:
start_index = i + 1
break
# Collect items for this page
page_files = files[start_index : start_index + limit]
next_page_id = None
if start_index + limit < len(files):
next_page_id = files[start_index + limit].name
# Load all events from files
page_events = []
for file_path in page_files:
event = self._load_event_from_file(file_path)
if event is not None:
page_events.append(event)
return EventPage(items=page_events, next_page_id=next_page_id)
async def count_events(
self,
conversation_id__eq: UUID | None = None,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
) -> int:
"""Count events matching the given filters."""
# Build the search pattern
pattern = '*'
files = self._get_event_files_by_pattern(pattern, conversation_id__eq)
files = await self._filter_files_by_conversation(files)
files = self._filter_files_by_criteria(
files, conversation_id__eq, kind__eq, timestamp__gte, timestamp__lt
)
return len(files)
async def save_event(self, conversation_id: UUID, event: Event):
"""Save an event. Internal method intended not be part of the REST api."""
conversation = (
await self.app_conversation_info_service.get_app_conversation_info(
conversation_id
)
)
if not conversation:
# This is either an illegal state or somebody is trying to hack
raise OpenHandsError('No such conversation: {conversaiont_id}')
self._save_event_to_file(conversation_id, event)
class FilesystemEventServiceInjector(EventServiceInjector):
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[EventService, None]:
from openhands.app_server.config import (
get_app_conversation_info_service,
get_global_config,
)
async with get_app_conversation_info_service(
state, request
) as app_conversation_info_service:
persistence_dir = get_global_config().persistence_dir
yield FilesystemEventService(
app_conversation_info_service=app_conversation_info_service,
events_dir=persistence_dir / 'v1' / 'events',
)

View File

@@ -0,0 +1,21 @@
# Event Callbacks
Manages webhooks and event callbacks for external system integration.
## Overview
This module provides webhook and callback functionality, allowing external systems to receive notifications when specific events occur within OpenHands conversations.
## Key Components
- **EventCallbackService**: Abstract service for callback CRUD operations
- **SqlEventCallbackService**: SQL-based callback storage implementation
- **EventWebhookRouter**: FastAPI router for webhook endpoints
## Features
- Webhook registration and management
- Event filtering by type and conversation
- Callback result tracking and status monitoring
- Retry logic for failed webhook deliveries
- Secure webhook authentication

View File

@@ -0,0 +1,83 @@
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from datetime import datetime
from typing import TYPE_CHECKING, Literal
from uuid import UUID, uuid4
from pydantic import Field
from openhands.agent_server.utils import utc_now
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResult,
EventCallbackResultStatus,
)
from openhands.sdk import Event
from openhands.sdk.utils.models import (
DiscriminatedUnionMixin,
OpenHandsModel,
get_known_concrete_subclasses,
)
_logger = logging.getLogger(__name__)
if TYPE_CHECKING:
EventKind = str
else:
EventKind = Literal[tuple(c.__name__ for c in get_known_concrete_subclasses(Event))]
class EventCallbackProcessor(DiscriminatedUnionMixin, ABC):
@abstractmethod
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult:
"""Process an event."""
class LoggingCallbackProcessor(EventCallbackProcessor):
"""Example implementation which logs callbacks."""
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult:
_logger.info(f'Callback {callback.id} Invoked for event {event}')
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
)
class CreateEventCallbackRequest(OpenHandsModel):
conversation_id: UUID | None = Field(
default=None,
description=(
'Optional filter on the conversation to which this callback applies'
),
)
processor: EventCallbackProcessor
event_kind: EventKind | None = Field(
default=None,
description=(
'Optional filter on the type of events to which this callback applies'
),
)
class EventCallback(CreateEventCallbackRequest):
id: UUID = Field(default_factory=uuid4)
created_at: datetime = Field(default_factory=utc_now)
class EventCallbackPage(OpenHandsModel):
items: list[EventCallback]
next_page_id: str | None = None

View File

@@ -0,0 +1,35 @@
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
from openhands.agent_server.utils import utc_now
from openhands.sdk.event.types import EventID
class EventCallbackResultStatus(Enum):
SUCCESS = 'SUCCESS'
ERROR = 'ERROR'
class EventCallbackResultSortOrder(Enum):
CREATED_AT = 'CREATED_AT'
CREATED_AT_DESC = 'CREATED_AT_DESC'
class EventCallbackResult(BaseModel):
"""Object representing the result of an event callback."""
id: UUID = Field(default_factory=uuid4)
status: EventCallbackResultStatus
event_callback_id: UUID
event_id: EventID
conversation_id: UUID
detail: str | None = None
created_at: datetime = Field(default_factory=utc_now)
class EventCallbackResultPage(BaseModel):
items: list[EventCallbackResult]
next_page_id: str | None = None

View File

@@ -0,0 +1,64 @@
import asyncio
from abc import ABC, abstractmethod
from uuid import UUID
from openhands.app_server.event_callback.event_callback_models import (
CreateEventCallbackRequest,
EventCallback,
EventCallbackPage,
EventKind,
)
from openhands.app_server.services.injector import Injector
from openhands.sdk import Event
from openhands.sdk.utils.models import DiscriminatedUnionMixin
class EventCallbackService(ABC):
"""CRUD service for managing event callbacks."""
@abstractmethod
async def create_event_callback(
self, request: CreateEventCallbackRequest
) -> EventCallback:
"""Create a new event callback."""
@abstractmethod
async def get_event_callback(self, id: UUID) -> EventCallback | None:
"""Get a single event callback, returning None if not found."""
@abstractmethod
async def delete_event_callback(self, id: UUID) -> bool:
"""Delete a event callback, returning True if deleted, False if not found."""
@abstractmethod
async def search_event_callbacks(
self,
conversation_id__eq: UUID | None = None,
event_kind__eq: EventKind | None = None,
event_id__eq: UUID | None = None,
page_id: str | None = None,
limit: int = 100,
) -> EventCallbackPage:
"""Search for event callbacks, optionally filtered by event_id."""
async def batch_get_event_callbacks(
self, event_callback_ids: list[UUID]
) -> list[EventCallback | None]:
"""Get a batch of event callbacks, returning None for any not found."""
results = await asyncio.gather(
*[
self.get_event_callback(event_callback_id)
for event_callback_id in event_callback_ids
]
)
return results
@abstractmethod
async def execute_callbacks(self, conversation_id: UUID, event: Event) -> None:
"""Execute any applicable callbacks for the event and store the results."""
class EventCallbackServiceInjector(
DiscriminatedUnionMixin, Injector[EventCallbackService], ABC
):
pass

View File

@@ -0,0 +1,231 @@
# pyright: reportArgumentType=false
"""SQL implementation of EventCallbackService."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from sqlalchemy import UUID as SQLUUID
from sqlalchemy import Column, String, and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.app_server.event_callback.event_callback_models import (
CreateEventCallbackRequest,
EventCallback,
EventCallbackPage,
EventCallbackProcessor,
EventKind,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.event_callback_service import (
EventCallbackService,
EventCallbackServiceInjector,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.utils.sql_utils import (
Base,
UtcDateTime,
create_enum_type_decorator,
create_json_type_decorator,
row2dict,
)
from openhands.sdk import Event
_logger = logging.getLogger(__name__)
# TODO: Add user level filtering to this class
class StoredEventCallback(Base): # type: ignore
__tablename__ = 'event_callback'
id = Column(SQLUUID, primary_key=True)
conversation_id = Column(SQLUUID, nullable=True)
processor = Column(create_json_type_decorator(EventCallbackProcessor))
event_kind = Column(String, nullable=True)
created_at = Column(UtcDateTime, server_default=func.now(), index=True)
class StoredEventCallbackResult(Base): # type: ignore
__tablename__ = 'event_callback_result'
id = Column(SQLUUID, primary_key=True)
status = Column(create_enum_type_decorator(EventCallbackResultStatus))
event_callback_id = Column(SQLUUID, index=True)
event_id = Column(SQLUUID, index=True)
conversation_id = Column(SQLUUID, index=True)
detail = Column(String, nullable=True)
created_at = Column(UtcDateTime, server_default=func.now(), index=True)
@dataclass
class SQLEventCallbackService(EventCallbackService):
"""SQL implementation of EventCallbackService."""
db_session: AsyncSession
async def create_event_callback(
self, request: CreateEventCallbackRequest
) -> EventCallback:
"""Create a new event callback."""
# Create EventCallback from request
event_callback = EventCallback(
conversation_id=request.conversation_id,
processor=request.processor,
event_kind=request.event_kind,
)
# Create stored version and add to db_session
stored_callback = StoredEventCallback(**event_callback.model_dump())
self.db_session.add(stored_callback)
await self.db_session.commit()
await self.db_session.refresh(stored_callback)
return EventCallback(**row2dict(stored_callback))
async def get_event_callback(self, id: UUID) -> EventCallback | None:
"""Get a single event callback, returning None if not found."""
stmt = select(StoredEventCallback).where(StoredEventCallback.id == id)
result = await self.db_session.execute(stmt)
stored_callback = result.scalar_one_or_none()
if stored_callback:
return EventCallback(**row2dict(stored_callback))
return None
async def delete_event_callback(self, id: UUID) -> bool:
"""Delete an event callback, returning True if deleted, False if not found."""
stmt = select(StoredEventCallback).where(StoredEventCallback.id == id)
result = await self.db_session.execute(stmt)
stored_callback = result.scalar_one_or_none()
if stored_callback is None:
return False
await self.db_session.delete(stored_callback)
await self.db_session.commit()
return True
async def search_event_callbacks(
self,
conversation_id__eq: UUID | None = None,
event_kind__eq: EventKind | None = None,
event_id__eq: UUID | None = None,
page_id: str | None = None,
limit: int = 100,
) -> EventCallbackPage:
"""Search for event callbacks, optionally filtered by parameters."""
# Build the query with filters
conditions = []
if conversation_id__eq is not None:
conditions.append(
StoredEventCallback.conversation_id == conversation_id__eq
)
if event_kind__eq is not None:
conditions.append(StoredEventCallback.event_kind == event_kind__eq)
# Note: event_id__eq is not stored in the event_callbacks table
# This parameter might be used for filtering results after retrieval
# or might be intended for a different use case
# Build the base query
stmt = select(StoredEventCallback)
if conditions:
stmt = stmt.where(and_(*conditions))
# Handle pagination
if page_id is not None:
# Parse page_id to get offset or cursor
try:
offset = int(page_id)
stmt = stmt.offset(offset)
except ValueError:
# If page_id is not a valid integer, start from beginning
offset = 0
else:
offset = 0
# Apply limit and get one extra to check if there are more results
stmt = stmt.limit(limit + 1).order_by(StoredEventCallback.created_at.desc())
result = await self.db_session.execute(stmt)
stored_callbacks = result.scalars().all()
# Check if there are more results
has_more = len(stored_callbacks) > limit
if has_more:
stored_callbacks = stored_callbacks[:limit]
# Calculate next page ID
next_page_id = None
if has_more:
next_page_id = str(offset + limit)
# Convert stored callbacks to domain models
callbacks = [EventCallback(**row2dict(cb)) for cb in stored_callbacks]
return EventCallbackPage(items=callbacks, next_page_id=next_page_id)
async def execute_callbacks(self, conversation_id: UUID, event: Event) -> None:
query = (
select(StoredEventCallback)
.where(
or_(
StoredEventCallback.event_kind == event.kind,
StoredEventCallback.event_kind.is_(None),
)
)
.where(
or_(
StoredEventCallback.conversation_id == conversation_id,
StoredEventCallback.conversation_id.is_(None),
)
)
)
result = await self.db_session.execute(query)
stored_callbacks = result.scalars().all()
if stored_callbacks:
callbacks = [EventCallback(**row2dict(cb)) for cb in stored_callbacks]
await asyncio.gather(
*[
self.execute_callback(conversation_id, callback, event)
for callback in callbacks
]
)
await self.db_session.commit()
async def execute_callback(
self, conversation_id: UUID, callback: EventCallback, event: Event
):
try:
result = await callback.processor(conversation_id, callback, event)
stored_result = StoredEventCallbackResult(**row2dict(result))
except Exception as exc:
_logger.exception(f'Exception in callback {callback.id}', stack_info=True)
stored_result = StoredEventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(exc),
)
self.db_session.add(stored_result)
async def __aexit__(self, exc_type, exc_value, traceback):
"""Stop using this event callback service."""
pass
class SQLEventCallbackServiceInjector(EventCallbackServiceInjector):
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[EventCallbackService, None]:
from openhands.app_server.config import get_db_session
async with get_db_session(state) as db_session:
yield SQLEventCallbackService(db_session=db_session)

View File

@@ -0,0 +1,185 @@
"""Event Callback router for OpenHands Server."""
import asyncio
import logging
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import APIKeyHeader
from jwt import InvalidTokenError
from openhands.app_server.services.injector import InjectorState
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.agent_server.models import ConversationInfo, Success
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
from openhands.app_server.config import (
depends_app_conversation_info_service,
depends_db_session,
depends_event_callback_service,
depends_event_service,
depends_jwt_service,
depends_sandbox_service,
get_event_callback_service,
get_global_config,
)
from openhands.app_server.errors import AuthError
from openhands.app_server.event.event_service import EventService
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
from openhands.app_server.sandbox.sandbox_service import SandboxService
from openhands.app_server.services.jwt_service import JwtService
from openhands.app_server.user.admin_user_context import USER_CONTEXT_ATTR, AdminUserContext, as_admin
from openhands.app_server.user.user_context import UserContext
from openhands.integrations.provider import ProviderType
from openhands.sdk import Event
router = APIRouter(prefix='/webhooks', tags=['Webhooks'])
sandbox_service_dependency = depends_sandbox_service()
event_service_dependency = depends_event_service()
app_conversation_info_service_dependency = depends_app_conversation_info_service()
jwt_dependency = depends_jwt_service()
config = get_global_config()
db_session_dependency = depends_db_session()
_logger = logging.getLogger(__name__)
async def valid_sandbox(
sandbox_id: str,
user_context: UserContext = Depends(as_admin),
session_api_key: str = Depends(
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
),
sandbox_service: SandboxService = sandbox_service_dependency,
) -> SandboxInfo:
sandbox_info = await sandbox_service.get_sandbox(sandbox_id)
if sandbox_info is None or sandbox_info.session_api_key != session_api_key:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
return sandbox_info
async def valid_conversation(
conversation_id: UUID,
sandbox_info: SandboxInfo,
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
) -> AppConversationInfo:
app_conversation_info = (
await app_conversation_info_service.get_app_conversation_info(conversation_id)
)
if not app_conversation_info:
# Conversation does not yet exist - create a stub
return AppConversationInfo(
id=conversation_id,
sandbox_id=sandbox_info.id,
created_by_user_id=sandbox_info.created_by_user_id,
)
if app_conversation_info.created_by_user_id != sandbox_info.created_by_user_id:
# Make sure that the conversation and sandbox were created by the same user
raise AuthError()
return app_conversation_info
@router.post('/{sandbox_id}/conversations')
async def on_conversation_update(
conversation_info: ConversationInfo,
sandbox_info: SandboxInfo = Depends(valid_sandbox),
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
) -> Success:
"""Webhook callback for when a conversation starts, pauses, resumes, or deletes."""
existing = await valid_conversation(
conversation_info.id, sandbox_info, app_conversation_info_service
)
app_conversation_info = AppConversationInfo(
id=conversation_info.id,
# TODO: As of writing, ConversationInfo from AgentServer does not have a title
title=existing.title or f'Conversation {conversation_info.id}',
sandbox_id=sandbox_info.id,
created_by_user_id=sandbox_info.created_by_user_id,
llm_model=conversation_info.agent.llm.model,
# Git parameters
selected_repository=existing.selected_repository,
selected_branch=existing.selected_branch,
git_provider=existing.git_provider,
trigger=existing.trigger,
pr_number=existing.pr_number,
)
await app_conversation_info_service.save_app_conversation_info(
app_conversation_info
)
return Success()
@router.post('/{sandbox_id}/events/{conversation_id}')
async def on_event(
events: list[Event],
conversation_id: UUID,
sandbox_info: SandboxInfo = Depends(valid_sandbox),
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
event_service: EventService = event_service_dependency,
) -> Success:
"""Webhook callback for when event stream events occur."""
app_conversation_info = await valid_conversation(
conversation_id, sandbox_info, app_conversation_info_service
)
try:
# Save events...
await asyncio.gather(
*[event_service.save_event(conversation_id, event) for event in events]
)
asyncio.create_task(
_run_callbacks_in_bg_and_close(conversation_id, app_conversation_info.created_by_user_id, events)
)
except Exception:
_logger.exception('Error in webhook', stack_info=True)
return Success()
@router.get('/secrets')
async def get_secret(
access_token: str = Depends(APIKeyHeader(name='X-Access-Token', auto_error=False)),
jwt_service: JwtService = jwt_dependency,
) -> str:
"""Given an access token, retrieve a user secret. The access token
is limited by user and provider type, and may include a timeout, limiting
the damage in the event that a token is ever leaked"""
try:
payload = jwt_service.verify_jws_token(access_token)
user_id = payload['user_id']
provider_type = ProviderType[payload['provider_type']]
user_injector = config.user
assert user_injector is not None
user_context = await user_injector.get_for_user(user_id)
secret = None
if user_context:
secret = await user_context.get_latest_token(provider_type)
if secret is None:
raise HTTPException(404, 'No such provider')
return secret
except InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
async def _run_callbacks_in_bg_and_close(
conversation_id: UUID,
user_id: str | None,
events: list[Event],
):
"""Run all callbacks and close the session"""
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, AdminUserContext(user_id=user_id))
async with get_event_callback_service(state) as event_callback_service:
# We don't use asynio.gather here because callbacks must be run in sequence.
for event in events:
await event_callback_service.execute_callbacks(conversation_id, event)

View File

@@ -0,0 +1,21 @@
# Sandbox Management
Manages sandbox environments for secure agent execution within OpenHands.
## Overview
Since agents can do things that may harm your system, they are typically run inside a sandbox (like a Docker container). This module provides services for creating, managing, and monitoring these sandbox environments.
## Key Components
- **SandboxService**: Abstract service for sandbox lifecycle management
- **DockerSandboxService**: Docker-based sandbox implementation
- **SandboxSpecService**: Manages sandbox specifications and templates
- **SandboxRouter**: FastAPI router for sandbox endpoints
## Features
- Secure containerized execution environments
- Sandbox lifecycle management (create, start, stop, destroy)
- Multiple sandbox backend support (Docker, Remote, Local)
- User-scoped sandbox access control

View File

@@ -0,0 +1,429 @@
import asyncio
import logging
import os
import socket
from dataclasses import dataclass, field
from datetime import datetime
from typing import AsyncGenerator
import base62
import docker
import httpx
from docker.errors import APIError, NotFound
from fastapi import Request
from pydantic import BaseModel, ConfigDict, Field
from openhands.agent_server.utils import utc_now
from openhands.app_server.errors import SandboxError
from openhands.app_server.sandbox.docker_sandbox_spec_service import get_docker_client
from openhands.app_server.sandbox.sandbox_models import (
AGENT_SERVER,
VSCODE,
ExposedUrl,
SandboxInfo,
SandboxPage,
SandboxStatus,
)
from openhands.app_server.sandbox.sandbox_service import (
SandboxService,
SandboxServiceInjector,
)
from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
from openhands.app_server.services.injector import InjectorState
_logger = logging.getLogger(__name__)
SESSION_API_KEY_VARIABLE = 'OH_SESSION_API_KEYS_0'
WEBHOOK_CALLBACK_VARIABLE = 'OH_WEBHOOKS_0_BASE_URL'
class VolumeMount(BaseModel):
"""Mounted volume within the container."""
host_path: str
container_path: str
mode: str = 'rw'
model_config = ConfigDict(frozen=True)
class ExposedPort(BaseModel):
"""Exposed port within container to be matched to a free port on the host."""
name: str
description: str
container_port: int = 8000
model_config = ConfigDict(frozen=True)
@dataclass
class DockerSandboxService(SandboxService):
"""Sandbox service built on docker.
The Docker API does not currently support async operations, so some of these operations will block.
Given that the docker API is intended for local use on a single machine, this is probably acceptable.
"""
sandbox_spec_service: SandboxSpecService
container_name_prefix: str
host_port: int
container_url_pattern: str
mounts: list[VolumeMount]
exposed_ports: list[ExposedPort]
health_check_path: str | None
httpx_client: httpx.AsyncClient
docker_client: docker.DockerClient = field(default_factory=get_docker_client)
def _find_unused_port(self) -> int:
"""Find an unused port on the host machine."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
s.listen(1)
port = s.getsockname()[1]
return port
def _docker_status_to_sandbox_status(self, docker_status: str) -> SandboxStatus:
"""Convert Docker container status to SandboxStatus."""
status_mapping = {
'running': SandboxStatus.RUNNING,
'paused': SandboxStatus.PAUSED,
'exited': SandboxStatus.MISSING,
'created': SandboxStatus.STARTING,
'restarting': SandboxStatus.STARTING,
'removing': SandboxStatus.MISSING,
'dead': SandboxStatus.ERROR,
}
return status_mapping.get(docker_status.lower(), SandboxStatus.ERROR)
def _get_container_env_vars(self, container) -> dict[str, str | None]:
env_vars_list = container.attrs['Config']['Env']
result = {}
for env_var in env_vars_list:
if '=' in env_var:
key, value = env_var.split('=', 1)
result[key] = value
else:
# Handle cases where an environment variable might not have a value
result[env_var] = None
return result
async def _container_to_sandbox_info(self, container) -> SandboxInfo | None:
"""Convert Docker container to SandboxInfo."""
# Convert Docker status to runtime status
status = self._docker_status_to_sandbox_status(container.status)
# Parse creation time
created_str = container.attrs.get('Created', '')
try:
created_at = datetime.fromisoformat(created_str.replace('Z', '+00:00'))
except (ValueError, AttributeError):
created_at = utc_now()
# Get URL and session key for running containers
exposed_urls = None
session_api_key = None
if status == SandboxStatus.RUNNING:
# Get the first exposed port mapping
exposed_urls = []
port_bindings = container.attrs.get('NetworkSettings', {}).get('Ports', {})
if port_bindings:
for container_port, host_bindings in port_bindings.items():
if host_bindings:
host_port = host_bindings[0]['HostPort']
exposed_port = next(
(
exposed_port
for exposed_port in self.exposed_ports
if container_port
== f'{exposed_port.container_port}/tcp'
),
None,
)
if exposed_port:
exposed_urls.append(
ExposedUrl(
name=exposed_port.name,
url=self.container_url_pattern.format(
port=host_port
),
)
)
# Get session API key
env = self._get_container_env_vars(container)
session_api_key = env[SESSION_API_KEY_VARIABLE]
return SandboxInfo(
id=container.name,
created_by_user_id=None,
sandbox_spec_id=container.image.tags[0],
status=status,
session_api_key=session_api_key,
exposed_urls=exposed_urls,
created_at=created_at,
)
async def _container_to_checked_sandbox_info(self, container) -> SandboxInfo | None:
sandbox_info = await self._container_to_sandbox_info(container)
if (
sandbox_info
and self.health_check_path is not None
and sandbox_info.exposed_urls
):
app_server_url = next(
exposed_url.url
for exposed_url in sandbox_info.exposed_urls
if exposed_url.name == AGENT_SERVER
)
try:
response = await self.httpx_client.get(
f'{app_server_url}{self.health_check_path}'
)
response.raise_for_status()
except asyncio.CancelledError:
raise
except Exception as exc:
_logger.info(f'Sandbox server not running: {exc}')
sandbox_info.status = SandboxStatus.ERROR
sandbox_info.exposed_urls = None
sandbox_info.session_api_key = None
return sandbox_info
async def search_sandboxes(
self,
page_id: str | None = None,
limit: int = 100,
) -> SandboxPage:
"""Search for sandboxes."""
try:
# Get all containers with our prefix
all_containers = self.docker_client.containers.list(all=True)
sandboxes = []
for container in all_containers:
if container.name.startswith(self.container_name_prefix):
sandbox_info = await self._container_to_checked_sandbox_info(
container
)
if sandbox_info:
sandboxes.append(sandbox_info)
# Sort by creation time (newest first)
sandboxes.sort(key=lambda x: x.created_at, reverse=True)
# Apply pagination
start_idx = 0
if page_id:
try:
start_idx = int(page_id)
except ValueError:
start_idx = 0
end_idx = start_idx + limit
paginated_containers = sandboxes[start_idx:end_idx]
# Determine next page ID
next_page_id = None
if end_idx < len(sandboxes):
next_page_id = str(end_idx)
return SandboxPage(items=paginated_containers, next_page_id=next_page_id)
except APIError:
return SandboxPage(items=[], next_page_id=None)
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
"""Get a single sandbox info."""
try:
if not sandbox_id.startswith(self.container_name_prefix):
return None
container = self.docker_client.containers.get(sandbox_id)
return await self._container_to_checked_sandbox_info(container)
except (NotFound, APIError):
return None
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox."""
if sandbox_spec_id is None:
sandbox_spec = await self.sandbox_spec_service.get_default_sandbox_spec()
else:
sandbox_spec_maybe = await self.sandbox_spec_service.get_sandbox_spec(
sandbox_spec_id
)
if sandbox_spec_maybe is None:
raise ValueError('Sandbox Spec not found')
sandbox_spec = sandbox_spec_maybe
# Generate container ID and session api key
container_name = (
f'{self.container_name_prefix}{base62.encodebytes(os.urandom(16))}'
)
session_api_key = base62.encodebytes(os.urandom(32))
# Prepare environment variables
env_vars = sandbox_spec.initial_env.copy()
env_vars[SESSION_API_KEY_VARIABLE] = session_api_key
env_vars[WEBHOOK_CALLBACK_VARIABLE] = (
f'http://host.docker.internal:{self.host_port}'
f'/api/v1/webhooks/{container_name}'
)
# Prepare port mappings and add port environment variables
port_mappings = {}
for exposed_port in self.exposed_ports:
host_port = self._find_unused_port()
port_mappings[exposed_port.container_port] = host_port
# Add port as environment variable
env_vars[exposed_port.name] = str(host_port)
# Prepare labels
labels = {
'sandbox_spec_id': sandbox_spec.id,
}
# Prepare volumes
volumes = {
mount.host_path: {
'bind': mount.container_path,
'mode': mount.mode,
}
for mount in self.mounts
}
try:
# Create and start the container
container = self.docker_client.containers.run( # type: ignore[call-overload]
image=sandbox_spec.id,
command=None, # Use default command from image
remove=False,
name=container_name,
environment=env_vars,
ports=port_mappings,
volumes=volumes,
working_dir=sandbox_spec.working_dir,
labels=labels,
detach=True,
)
sandbox_info = await self._container_to_sandbox_info(container)
assert sandbox_info is not None
return sandbox_info
except APIError as e:
raise SandboxError(f'Failed to start container: {e}')
async def resume_sandbox(self, sandbox_id: str) -> bool:
"""Resume a paused sandbox."""
try:
if not sandbox_id.startswith(self.container_name_prefix):
return False
container = self.docker_client.containers.get(sandbox_id)
if container.status == 'paused':
container.unpause()
elif container.status == 'exited':
container.start()
return True
except (NotFound, APIError):
return False
async def pause_sandbox(self, sandbox_id: str) -> bool:
"""Pause a running sandbox."""
try:
if not sandbox_id.startswith(self.container_name_prefix):
return False
container = self.docker_client.containers.get(sandbox_id)
if container.status == 'running':
container.pause()
return True
except (NotFound, APIError):
return False
async def delete_sandbox(self, sandbox_id: str) -> bool:
"""Delete a sandbox."""
try:
if not sandbox_id.startswith(self.container_name_prefix):
return False
container = self.docker_client.containers.get(sandbox_id)
# Stop the container if it's running
if container.status in ['running', 'paused']:
container.stop(timeout=10)
# Remove the container
container.remove()
# Remove associated volume
try:
volume_name = f'openhands-workspace-{sandbox_id}'
volume = self.docker_client.volumes.get(volume_name)
volume.remove()
except (NotFound, APIError):
# Volume might not exist or already removed
pass
return True
except (NotFound, APIError):
return False
class DockerSandboxServiceInjector(SandboxServiceInjector):
"""Dependency injector for docker sandbox services."""
container_url_pattern: str = 'http://localhost:{port}'
host_port: int = 3000
container_name_prefix: str = 'oh-agent-server-'
mounts: list[VolumeMount] = Field(default_factory=list)
exposed_ports: list[ExposedPort] = Field(
default_factory=lambda: [
ExposedPort(
name=AGENT_SERVER,
description=(
'The port on which the agent server runs within the container'
),
container_port=8000,
),
ExposedPort(
name=VSCODE,
description=(
'The port on which the VSCode server runs within the container'
),
container_port=8001,
),
]
)
health_check_path: str | None = Field(
default='/health',
description=(
'The url path in the sandbox agent server to check to '
'determine whether the server is running'
),
)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SandboxService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import (
get_httpx_client,
get_sandbox_spec_service,
)
async with (
get_httpx_client(state) as httpx_client,
get_sandbox_spec_service(state) as sandbox_spec_service,
):
yield DockerSandboxService(
sandbox_spec_service=sandbox_spec_service,
container_name_prefix=self.container_name_prefix,
host_port=self.host_port,
container_url_pattern=self.container_url_pattern,
mounts=self.mounts,
exposed_ports=self.exposed_ports,
health_check_path=self.health_check_path,
httpx_client=httpx_client,
)

View File

@@ -0,0 +1,145 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import AsyncGenerator
import docker
from docker.errors import APIError, NotFound
from fastapi import Request
from pydantic import Field
from openhands.agent_server.utils import utc_now
from openhands.app_server.sandbox.sandbox_spec_models import (
SandboxSpecInfo,
SandboxSpecInfoPage,
)
from openhands.app_server.sandbox.sandbox_spec_service import (
SandboxSpecService,
SandboxSpecServiceInjector,
)
from openhands.app_server.services.injector import InjectorState
_global_docker_client: docker.DockerClient | None = None
def get_docker_client() -> docker.DockerClient:
global _global_docker_client
if _global_docker_client is None:
_global_docker_client = docker.from_env()
return _global_docker_client
@dataclass
class DockerSandboxSpecService(SandboxSpecService):
"""Sandbox spec service for docker images.
By default, all images with the repository given are loaded and returned, though
they may have different tags. The combination of the repository and tag is treated
as the id in the resulting image.
"""
repository: str
command: str
initial_env: dict[str, str]
working_dir: str
docker_client: docker.DockerClient = field(default_factory=get_docker_client)
def _docker_image_to_sandbox_specs(self, image) -> SandboxSpecInfo:
"""Convert a Docker image to SandboxSpecInfo."""
# Extract repository and tag from image tags
# Use the first tag if multiple tags exist, or use the image ID if no tags
if image.tags:
image_id = image.tags[0] # Use repository:tag as ID
else:
image_id = image.id[:12] # Use short image ID if no tags
# Parse creation time from image attributes
created_str = image.attrs.get('Created', '')
try:
# Docker timestamps are in ISO format
created_at = datetime.fromisoformat(created_str.replace('Z', '+00:00'))
except (ValueError, AttributeError):
created_at = utc_now()
return SandboxSpecInfo(
id=image_id,
command=self.command,
created_at=created_at,
initial_env=self.initial_env,
working_dir=self.working_dir,
)
async def search_sandbox_specs(
self, page_id: str | None = None, limit: int = 100
) -> SandboxSpecInfoPage:
"""Search for runtime images."""
try:
# Get all images that match the repository
images = self.docker_client.images.list(name=self.repository)
# Convert Docker images to SandboxSpecInfo
sandbox_specs = []
for image in images:
# Only include images that have tags matching our repository
if image.tags:
for tag in image.tags:
if tag.startswith(self.repository):
sandbox_specs.append(
self._docker_image_to_sandbox_specs(image)
)
# Only add once per image, even if multiple matching tags
break
# Apply pagination
start_idx = 0
if page_id:
try:
start_idx = int(page_id)
except ValueError:
start_idx = 0
end_idx = start_idx + limit
paginated_images = sandbox_specs[start_idx:end_idx]
# Determine next page ID
next_page_id = None
if end_idx < len(sandbox_specs):
next_page_id = str(end_idx)
return SandboxSpecInfoPage(
items=paginated_images, next_page_id=next_page_id
)
except APIError:
# Return empty page if there's an API error
return SandboxSpecInfoPage(items=[], next_page_id=None)
async def get_sandbox_spec(self, sandbox_spec_id: str) -> SandboxSpecInfo | None:
"""Get a single runtime image info by ID."""
try:
# Try to get the image by ID (which should be repository:tag)
image = self.docker_client.images.get(sandbox_spec_id)
return self._docker_image_to_sandbox_specs(image)
except (NotFound, APIError):
return None
class DockerSandboxSpecServiceInjector(SandboxSpecServiceInjector):
repository: str = 'ghcr.io/all-hands-ai/agent-server'
command: str = '/usr/local/bin/openhands-agent-server'
initial_env: dict[str, str] = Field(
default_factory=lambda: {
'OPENVSCODE_SERVER_ROOT': '/openhands/.openvscode-server',
'LOG_JSON': 'true',
}
)
working_dir: str = '/home/openhands'
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SandboxSpecService, None]:
yield DockerSandboxSpecService(
repository=self.repository,
command=self.command,
initial_env=self.initial_env,
working_dir=self.working_dir,
)

View File

@@ -0,0 +1,645 @@
import asyncio
import logging
import os
from dataclasses import dataclass
from typing import Any, AsyncGenerator, Union
import base62
import httpx
from fastapi import Request
from pydantic import Field
from sqlalchemy import Column, String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.agent_server.models import ConversationInfo, EventPage
from openhands.agent_server.utils import utc_now
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
from openhands.app_server.errors import SandboxError
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event_callback.event_callback_service import (
EventCallbackService,
)
from openhands.app_server.sandbox.sandbox_models import (
AGENT_SERVER,
ExposedUrl,
SandboxInfo,
SandboxPage,
SandboxStatus,
)
from openhands.app_server.sandbox.sandbox_service import (
SandboxService,
SandboxServiceInjector,
)
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.admin_user_context import ADMIN, USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.sql_utils import Base, UtcDateTime
_logger = logging.getLogger(__name__)
WEBHOOK_CALLBACK_VARIABLE = 'OH_WEBHOOKS_0_BASE_URL'
polling_task: asyncio.Task | None = None
POD_STATUS_MAPPING = {
'ready': SandboxStatus.RUNNING,
'pending': SandboxStatus.STARTING,
'running': SandboxStatus.STARTING,
'failed': SandboxStatus.ERROR,
'unknown': SandboxStatus.ERROR,
'crashloopbackoff': SandboxStatus.ERROR,
}
STATUS_MAPPING = {
'running': SandboxStatus.RUNNING,
'paused': SandboxStatus.PAUSED,
'stopped': SandboxStatus.MISSING,
'starting': SandboxStatus.STARTING,
'error': SandboxStatus.ERROR,
}
class StoredRemoteSandbox(Base): # type: ignore
"""Local storage for remote sandbox info.
The remote runtime API does not return some variables we need, and does not
return stopped runtimes in list operations, so we need a local copy. We use
the remote api as a source of truth on what is currently running, not was
run historicallly."""
__tablename__ = 'v1_remote_sandbox'
id = Column(String, primary_key=True)
created_by_user_id = Column(String, nullable=True, index=True)
sandbox_spec_id = Column(String, index=True) # shadows runtime['image']
created_at = Column(UtcDateTime, server_default=func.now(), index=True)
@dataclass
class RemoteSandboxService(SandboxService):
"""Sandbox service that uses HTTP to communicate with a remote runtime API.
This service adapts the legacy RemoteRuntime HTTP protocol to work with
the new Sandbox interface.
"""
sandbox_spec_service: SandboxSpecService
api_url: str
api_key: str
web_url: str | None
agent_server_url_pattern: str
resource_factor: int
runtime_class: str | None
user_context: UserContext
httpx_client: httpx.AsyncClient
db_session: AsyncSession
async def _send_runtime_api_request(
self, method: str, path: str, **kwargs: Any
) -> httpx.Response:
"""Send a request to the remote runtime API."""
try:
url = self.api_url + path
return await self.httpx_client.request(
method, url, headers={'X-API-Key': self.api_key}, **kwargs
)
except httpx.TimeoutException:
_logger.error(f'No response received within timeout for URL: {url}')
raise
except httpx.HTTPError as e:
_logger.error(f'HTTP error for URL {url}: {e}')
raise
def _to_sandbox_info(
self, runtime: dict[str, Any] | None, stored: StoredRemoteSandbox
) -> SandboxInfo:
if runtime:
# Translate status
status = None
pod_status = (runtime.get('pod_status') or '').lower()
if pod_status:
status = POD_STATUS_MAPPING.get(pod_status, None)
if status is None:
runtime_status = runtime.get('status')
if runtime_status:
status = STATUS_MAPPING.get(runtime_status.lower(), None)
if status is None:
status = SandboxStatus.MISSING
session_api_key = runtime['session_api_key']
if status == SandboxStatus.RUNNING:
exposed_urls = []
url = runtime.get('url', None)
if url is None:
# TODO: Update the runtime API and remove this
# Hack - the RuntimeAPI is inconsistent, so we rebuild the url...
# https://runtime.staging.all-hands.dev
url = self.agent_server_url_pattern.format(runtime_id=runtime['runtime_id'])
exposed_urls.append(ExposedUrl(name=AGENT_SERVER, url=url))
else:
exposed_urls = None
else:
session_api_key = None
status = SandboxStatus.MISSING
exposed_urls = None
sandbox_spec_id = stored.sandbox_spec_id
return SandboxInfo(
id=stored.id,
created_by_user_id=stored.created_by_user_id,
sandbox_spec_id=sandbox_spec_id,
status=status,
session_api_key=session_api_key,
exposed_urls=exposed_urls,
created_at=stored.created_at,
)
async def _secure_select(self):
query = select(StoredRemoteSandbox)
user_id = await self.user_context.get_user_id()
if user_id:
query = query.where(StoredRemoteSandbox.created_by_user_id == user_id)
return query
async def _get_stored_sandbox(self, sandbox_id: str) -> StoredRemoteSandbox | None:
stmt = await self._secure_select()
stmt = stmt.where(StoredRemoteSandbox.id == sandbox_id)
result = await self.db_session.execute(stmt)
stored_sandbox = result.scalar_one_or_none()
return stored_sandbox
async def _get_runtime(self, sandbox_id: str) -> dict[str, Any]:
response = await self._send_runtime_api_request(
'GET',
f'/sessions/{sandbox_id}',
)
response.raise_for_status()
runtime_data = response.json()
return runtime_data
async def _init_environment(
self, sandbox_spec: SandboxSpecInfo, sandbox_id: str
) -> dict[str, str]:
"""Initialize the environment variables for the sandbox."""
environment = sandbox_spec.initial_env.copy()
# If a public facing url is defined, add a callback to the agent server environment.
if self.web_url:
environment[WEBHOOK_CALLBACK_VARIABLE] = (
f'{self.web_url}/api/v1/webhooks/{sandbox_id}'
)
return environment
async def search_sandboxes(
self,
page_id: str | None = None,
limit: int = 100,
) -> SandboxPage:
stmt = await self._secure_select()
# Handle pagination
if page_id is not None:
# Parse page_id to get offset or cursor
try:
offset = int(page_id)
stmt = stmt.offset(offset)
except ValueError:
# If page_id is not a valid integer, start from beginning
offset = 0
else:
offset = 0
# Apply limit and get one extra to check if there are more results
stmt = stmt.limit(limit + 1).order_by(StoredRemoteSandbox.created_at.desc())
result = await self.db_session.execute(stmt)
stored_sandboxes = result.scalars().all()
# Check if there are more results
has_more = len(stored_sandboxes) > limit
if has_more:
stored_sandboxes = stored_sandboxes[:limit]
# Calculate next page ID
next_page_id = None
if has_more:
next_page_id = str(offset + limit)
# Do a list to get running sandboxes
response = await self._send_runtime_api_request('GET', '/list')
response.raise_for_status()
runtimes_by_session_id = {
runtime['session_id']: runtime for runtime in response.json()['runtimes']
}
# Convert stored callbacks to domain models
items = []
for stored_sandbox in stored_sandboxes:
try:
sandbox_info = self._to_sandbox_info(runtimes_by_session_id.get(stored_sandbox.id), stored_sandbox)
items.append(sandbox_info)
except Exception as exc:
_logger.exception(f'Error loading sandbox {stored_sandbox.id}: {exc}', stack_info=True)
return SandboxPage(items=items, next_page_id=next_page_id)
async def get_sandbox(self, sandbox_id: str) -> Union[SandboxInfo, None]:
"""Get a single sandbox by checking its corresponding runtime."""
stored_sandbox = await self._get_stored_sandbox(sandbox_id)
if stored_sandbox is None:
return None
try:
runtime_data = await self._get_runtime(sandbox_id)
except Exception:
_logger.exception('Error getting runtime: {sandbox_id}', stack_info=True)
runtime_data = None
return self._to_sandbox_info(runtime_data, stored_sandbox)
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox by creating a remote runtime."""
try:
# Get sandbox spec
if sandbox_spec_id is None:
sandbox_spec = (
await self.sandbox_spec_service.get_default_sandbox_spec()
)
else:
sandbox_spec_maybe = await self.sandbox_spec_service.get_sandbox_spec(
sandbox_spec_id
)
if sandbox_spec_maybe is None:
raise ValueError('Sandbox Spec not found')
sandbox_spec = sandbox_spec_maybe
# Create a unique id
sandbox_id = base62.encodebytes(os.urandom(16))
# get user id
user_id = await self.user_context.get_user_id()
# Store the sandbox
stored_sandbox = StoredRemoteSandbox(
id=sandbox_id,
created_by_user_id=user_id,
sandbox_spec_id=sandbox_spec.id,
created_at=utc_now(),
)
self.db_session.add(stored_sandbox)
await self.db_session.commit()
# Prepare environment variables
environment = await self._init_environment(sandbox_spec, sandbox_id)
#TODO: This is all legacy and should be removed
environment['CONVERSATION_MANAGER_CLASS'] = (
'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager'
)
environment['LOG_JSON'] = '1'
environment['SERVE_FRONTEND'] = '0'
environment['RUNTIME'] = 'local'
environment['USER'] = 'openhands'
environment['PERMITTED_CORS_ORIGINS'] = 'http://localhost:3001'
environment['port'] = '60000'
environment['VSCODE_PORT'] = '60001'
environment['WORK_PORT_1'] = '12000'
environment['WORK_PORT_2'] = '12001'
environment['ALLOW_SET_CONVERSATION_ID'] = '1'
environment['FILE_STORE_PATH'] = '/workspace/.openhands/file_store'
environment['WORKSPACE_BASE'] = '/workspace/project'
environment['WORKSPACE_MOUNT_PATH_IN_SANDBOX'] = '/workspace/project'
environment['SANDBOX_CLOSE_DELAY'] = '0'
environment['SKIP_DEPENDENCY_CHECK'] = '1'
environment['INITIAL_NUM_WARM_SERVERS'] = '1'
environment['INIT_GIT_IN_EMPTY_WORKSPACE'] = '1'
environment['WEB_HOST'] = 'http://localhost:3001'
# Prepare start request
start_request: dict[str, Any] = {
'image': sandbox_spec.id, # Use sandbox_spec.id as the container image
# TODO: I need to import the agent server image into runtime api - this is a hack
#'command': sandbox_spec.command,
#'working_dir': sandbox_spec.working_dir,
'command': ["/openhands/micromamba/bin/micromamba","run","-n","openhands","poetry","run","python","-u","-m","openhands.server","60000","--working-dir","/workspace","--plugins","agent_skills","jupyter","vscode","--username","root","--user-id","0"],
'working_dir': '/openhands/code/',
'environment': environment,
'session_id': sandbox_id, # Use sandbox_id as session_id
'resource_factor': self.resource_factor,
}
# Add runtime class if specified
if self.runtime_class == 'sysbox':
start_request['runtime_class'] = 'sysbox-runc'
# Start the runtime
response = await self._send_runtime_api_request(
'POST',
'/start',
json=start_request,
)
response.raise_for_status()
runtime_data = response.json()
#Hack - result doesn't contain this
runtime_data['pod_status'] = 'pending'
return self._to_sandbox_info(runtime_data, stored_sandbox)
except httpx.HTTPError as e:
_logger.error(f'Failed to start sandbox: {e}')
raise SandboxError(f'Failed to start sandbox: {e}')
async def resume_sandbox(self, sandbox_id: str) -> bool:
"""Resume a paused sandbox."""
try:
if not await self._get_stored_sandbox(sandbox_id):
return False
runtime_data = await self._get_runtime(sandbox_id)
response = await self._send_runtime_api_request(
'POST',
'/resume',
json={'runtime_id': runtime_data['runtime_id']},
)
if response.status_code == 404:
return False
response.raise_for_status()
return True
except httpx.HTTPError as e:
_logger.error(f'Error resuming sandbox {sandbox_id}: {e}')
return False
async def pause_sandbox(self, sandbox_id: str) -> bool:
"""Pause a running sandbox."""
try:
if not await self._get_stored_sandbox(sandbox_id):
return False
runtime_data = await self._get_runtime(sandbox_id)
response = await self._send_runtime_api_request(
'POST',
'/pause',
json={'runtime_id': runtime_data['runtime_id']},
)
if response.status_code == 404:
return False
response.raise_for_status()
return True
except httpx.HTTPError as e:
_logger.error(f'Error pausing sandbox {sandbox_id}: {e}')
return False
async def delete_sandbox(self, sandbox_id: str) -> bool:
"""Delete a sandbox by stopping its runtime."""
try:
stored_sandbox = await self._get_stored_sandbox(sandbox_id)
if not stored_sandbox:
return False
runtime_data = await self._get_runtime(sandbox_id)
response = await self._send_runtime_api_request(
'POST',
'/stop',
json={'runtime_id': runtime_data['runtime_id']},
)
if response.status_code != 404:
response.raise_for_status()
await self.db_session.delete(stored_sandbox)
await self.db_session.commit()
return True
except httpx.HTTPError as e:
_logger.error(f'Error deleting sandbox {sandbox_id}: {e}')
return False
async def poll_agent_servers(api_url: str, api_key: str, sleep_interval: int):
"""When the app server does not have a public facing url, we poll the agent
servers for the most recent data.
This is because webhook callbacks cannot be invoked."""
from openhands.app_server.config import (
get_app_conversation_info_service,
get_event_callback_service,
get_event_service,
get_httpx_client,
)
while True:
try:
# Refresh the conversations associated with those sandboxes.
state = InjectorState()
try:
# Get the list of running sandboxes using the runtime api /list endpoint.
# (This will not return runtimes that have been stopped for a while)
async with get_httpx_client(state) as httpx_client:
response = await httpx_client.get(
f'{api_url}/list', headers={'X-API-Key': api_key}
)
response.raise_for_status()
runtimes = response.json()['runtimes']
runtimes_by_sandbox_id = {
runtime['session_id']: runtime
for runtime in runtimes
if runtime['status'] == 'running'
}
# We allow access to all items here
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(
state
) as app_conversation_info_service,
get_event_service(state) as event_service,
get_event_callback_service(state) as event_callback_service,
get_httpx_client(state) as httpx_client,
):
page_id = None
matches = 0
while True:
page = await app_conversation_info_service.search_app_conversation_info(
page_id=page_id
)
for app_conversation_info in page.items:
runtime = runtimes_by_sandbox_id.get(
app_conversation_info.sandbox_id
)
if runtime:
matches += 1
await refresh_conversation(
app_conversation_info_service=app_conversation_info_service,
event_service=event_service,
event_callback_service=event_callback_service,
app_conversation_info=app_conversation_info,
runtime=runtime,
httpx_client=httpx_client,
)
page_id = page.next_page_id
if page_id is None:
_logger.debug(f"Matched {len(runtimes_by_sandbox_id)} Runtimes with {matches} Conversations.")
break
except Exception as exc:
_logger.exception(
f'Error when polling agent servers: {exc}', stack_info=True
)
# Sleep between retries
await asyncio.sleep(sleep_interval)
except asyncio.CancelledError:
return
async def refresh_conversation(
app_conversation_info_service: AppConversationInfoService,
event_service: EventService,
event_callback_service: EventCallbackService,
app_conversation_info: AppConversationInfo,
runtime: dict[str, Any],
httpx_client: httpx.AsyncClient,
):
"""Refresh a conversation.
Grab ConversationInfo and all events from the agent server and make sure they
exist in the app server."""
_logger.debug(f'Started Refreshing Conversation {app_conversation_info.id}')
try:
url = runtime['url']
# TODO: Maybe we can use RemoteConversation here?
# First get conversation...
conversation_url = f'{url}/api/conversations/{app_conversation_info.id.hex}'
response = await httpx_client.get(
conversation_url, headers={'X-Session-API-Key': runtime['session_api_key']}
)
response.raise_for_status()
updated_conversation_info = ConversationInfo.model_validate(response.json())
# TODO: As of writing, ConversationInfo from AgentServer does not have a title to update...
app_conversation_info.updated_at = updated_conversation_info.updated_at
# TODO: Update other appropriate attributes...
await app_conversation_info_service.save_app_conversation_info(
app_conversation_info
)
# TODO: It would be nice to have an updated_at__gte filter parameter in the
# agent server so that we don't pull the full event list each time
event_url = (
f'{url}/ap/conversations/{app_conversation_info.id.hex}/events/search'
)
page_id = None
while True:
params: dict[str, str] = {}
if page_id:
params['page_id'] = page_id
response = await httpx_client.get(
event_url,
params=params,
headers={'X-Session-API-Key': runtime['session_api_key']},
)
response.raise_for_status()
page = EventPage.model_validate(response.json())
to_process = []
for event in page.items:
existing = await event_service.get_event(event.id)
if existing is None:
await event_service.save_event(app_conversation_info.id, event)
to_process.append(event)
for event in to_process:
await event_callback_service.execute_callbacks(
app_conversation_info.id, event
)
page_id = page.next_page_id
if page_id is None:
_logger.debug(
f'Finished Refreshing Conversation {app_conversation_info.id}'
)
break
except Exception as exc:
_logger.exception(f'Error Refreshing Conversation: {exc}', stack_info=True)
class RemoteSandboxServiceInjector(SandboxServiceInjector):
"""Dependency injector for remote sandbox services."""
api_url: str = Field(description='The API URL for remote runtimes')
api_key: str = Field(description='The API Key for remote runtimes')
polling_interval: int = Field(
default=15,
description=(
'The sleep time between poll operations against agent servers when there is '
'no public facing web_url'
),
)
resource_factor: int = Field(
default=1,
description='Factor by which to scale resources in sandbox: 1, 2, 4, or 8',
)
runtime_class: str = Field(
default='gvisor',
description='can be "gvisor" or "sysbox" (support docker inside runtime + more stable)',
)
agent_server_url_pattern: str = Field(
default='https://{runtime_id}.staging-runtime.all-hands.dev',
description=(
'As of writing, the RuntimeAPI is unconsitent on what it returns. This '
'pattern allows us to reconstruct an agent server url when the API does '
'not return one.'
)
)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SandboxService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import (
get_db_session,
get_global_config,
get_httpx_client,
get_sandbox_spec_service,
get_user_context,
)
# If no public facing web url is defined, poll for changes as callbacks will be unavailable.
config = get_global_config()
web_url = config.web_url
if web_url is None:
global polling_task
if polling_task is None:
polling_task = asyncio.create_task(
poll_agent_servers(
api_url=self.api_url,
api_key=self.api_key,
sleep_interval=self.polling_interval,
)
)
async with (
get_user_context(state, request) as user_context,
get_sandbox_spec_service(state, request) as sandbox_spec_service,
get_httpx_client(state, request) as httpx_client,
get_db_session(state, request) as db_session,
):
yield RemoteSandboxService(
sandbox_spec_service=sandbox_spec_service,
api_url=self.api_url,
api_key=self.api_key,
web_url=web_url,
resource_factor=self.resource_factor,
runtime_class=self.runtime_class,
agent_server_url_pattern=self.agent_server_url_pattern,
user_context=user_context,
httpx_client=httpx_client,
db_session=db_session,
)

View File

@@ -0,0 +1,95 @@
import asyncio
import logging
import os
from dataclasses import dataclass
from typing import AsyncGenerator
from fastapi import Request
import httpx
from pydantic import Field, PrivateAttr
from openhands.app_server.errors import OpenHandsError
from openhands.app_server.sandbox.sandbox_spec_models import (
SandboxSpecInfo,
SandboxSpecInfoPage,
)
from openhands.app_server.sandbox.sandbox_spec_service import (
SandboxSpecService,
SandboxSpecServiceInjector,
)
from openhands.app_server.services.injector import InjectorState
_logger = logging.getLogger(__name__)
@dataclass
class RemoteSandboxSpecService(SandboxSpecService):
"""Service for managing Sandbox specs in the Remote Runtime API.
At present, the runtime API exposes methods to check whether a paricular image
exists, but not to list existing images - so we maintain a list of images locally.
"""
specs: list[SandboxSpecInfo]
async def search_sandbox_specs(
self, page_id: str | None = None, limit: int = 100
) -> SandboxSpecInfoPage:
"""Search for sandbox specs with pagination support."""
# Apply pagination
start_idx = 0
if page_id:
try:
start_idx = int(page_id)
except ValueError:
start_idx = 0
end_idx = start_idx + limit
paginated_specs = self.specs[start_idx:end_idx]
# Determine next page ID
next_page_id = None
if end_idx < len(self.specs):
next_page_id = str(end_idx)
return SandboxSpecInfoPage(items=paginated_specs, next_page_id=next_page_id)
async def get_sandbox_spec(self, sandbox_spec_id: str) -> SandboxSpecInfo | None:
"""Get a single sandbox spec by ID, returning None if not found."""
for spec in self.specs:
if spec.id == sandbox_spec_id:
return spec
return None
async def get_default_sandbox_spec(self) -> SandboxSpecInfo:
return self.specs[0]
def _get_specs_from_legacy_parameter():
"""If no config for SnadboxSpecs is defined, build one using the legacy param."""
image = os.getenv('SANDBOX_RUNTIME_CONTAINER_IMAGE')
if not image:
raise OpenHandsError('Please set sandbox specs!')
return [
SandboxSpecInfo(
id=image,
command='/usr/local/bin/openhands-agent-server',
initial_env={
'OPENVSCODE_SERVER_ROOT': '/openhands/.openvscode-server',
'LOG_JSON': 'true',
},
working_dir='/home/openhands',
)
]
class RemoteSandboxSpecServiceInjector(SandboxSpecServiceInjector):
specs: list[SandboxSpecInfo] = Field(
default_factory=_get_specs_from_legacy_parameter,
description='Preset list of sandbox specs. Falls back to legacy parameter',
)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SandboxSpecService, None]:
yield RemoteSandboxSpecService(self.specs)

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
from openhands.agent_server.utils import utc_now
class SandboxStatus(Enum):
STARTING = 'STARTING'
RUNNING = 'RUNNING'
PAUSED = 'PAUSED'
ERROR = 'ERROR'
MISSING = 'MISSING'
"""Missing - possibly deleted"""
class ExposedUrl(BaseModel):
"""URL to access some named service within the container."""
name: str
url: str
# Standard names
AGENT_SERVER = 'AGENT_SERVER'
VSCODE = 'VSCODE'
class SandboxInfo(BaseModel):
"""Information about a sandbox."""
id: str
created_by_user_id: str | None
sandbox_spec_id: str
status: SandboxStatus
session_api_key: str | None = Field(
description=(
'Key to access sandbox, to be added as an `X-Session-API-Key` header '
'in each request. In cases where the sandbox statues is STARTING or '
'PAUSED, or the current user does not have full access '
'the session_api_key will be None.'
)
)
exposed_urls: list[ExposedUrl] | None = Field(
default_factory=lambda: [],
description=(
'URLs exposed by the sandbox (App server, Vscode, etc...)'
'Sandboxes with a status STARTING / PAUSED / ERROR may '
'not return urls.'
),
)
created_at: datetime = Field(default_factory=utc_now)
class SandboxPage(BaseModel):
items: list[SandboxInfo]
next_page_id: str | None = None

View File

@@ -0,0 +1,91 @@
"""Runtime Containers router for OpenHands Server."""
from typing import Annotated
from fastapi import APIRouter, HTTPException, Query, status
from openhands.agent_server.models import Success
from openhands.app_server.config import depends_sandbox_service
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxPage
from openhands.app_server.sandbox.sandbox_service import (
SandboxService,
)
router = APIRouter(prefix='/sandboxes', tags=['Sandbox'])
sandbox_service_dependency = depends_sandbox_service()
# Read methods
@router.get('/search')
async def search_sandboxes(
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
] = 100,
sandbox_service: SandboxService = sandbox_service_dependency,
) -> SandboxPage:
"""Search / list sandboxes owned by the current user."""
assert limit > 0
assert limit <= 100
return await sandbox_service.search_sandboxes(page_id=page_id, limit=limit)
@router.get('')
async def batch_get_sandboxes(
id: Annotated[list[str], Query()],
sandbox_service: SandboxService = sandbox_service_dependency,
) -> list[SandboxInfo | None]:
"""Get a batch of sandboxes given their ids, returning null for any missing."""
assert len(id) < 100
sandboxes = await sandbox_service.batch_get_sandboxes(id)
return sandboxes
# Write Methods
@router.post('')
async def start_sandbox(
sandbox_spec_id: str | None = None,
sandbox_service: SandboxService = sandbox_service_dependency,
) -> SandboxInfo:
info = await sandbox_service.start_sandbox(sandbox_spec_id)
return info
@router.post('/{id}/pause', responses={404: {'description': 'Item not found'}})
async def pause_sandbox(
sandbox_id: str,
sandbox_service: SandboxService = sandbox_service_dependency,
) -> Success:
exists = await sandbox_service.pause_sandbox(sandbox_id)
if not exists:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return Success()
@router.post('/{id}/resume', responses={404: {'description': 'Item not found'}})
async def resume_sandbox(
sandbox_id: str,
sandbox_service: SandboxService = sandbox_service_dependency,
) -> Success:
exists = await sandbox_service.resume_sandbox(sandbox_id)
if not exists:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return Success()
@router.delete('/{id}', responses={404: {'description': 'Item not found'}})
async def delete_sandbox(
sandbox_id: str,
sandbox_service: SandboxService = sandbox_service_dependency,
) -> Success:
exists = await sandbox_service.delete_sandbox(sandbox_id)
if not exists:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return Success()

View File

@@ -0,0 +1,65 @@
import asyncio
from abc import ABC, abstractmethod
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxPage
from openhands.app_server.services.injector import Injector
from openhands.sdk.utils.models import DiscriminatedUnionMixin
class SandboxService(ABC):
"""Service for accessing sandboxes in which conversations may be run."""
@abstractmethod
async def search_sandboxes(
self,
page_id: str | None = None,
limit: int = 100,
) -> SandboxPage:
"""Search for sandboxes."""
@abstractmethod
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
"""Get a single sandbox. Return None if the sandbox was not found."""
async def batch_get_sandboxes(
self, sandbox_ids: list[str]
) -> list[SandboxInfo | None]:
"""Get a batch of sandboxes, returning None for any which were not found."""
results = await asyncio.gather(
*[self.get_sandbox(sandbox_id) for sandbox_id in sandbox_ids]
)
return results
@abstractmethod
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Begin the process of starting a sandbox.
Return the info on the new sandbox. If no spec is selected, use the default.
"""
@abstractmethod
async def resume_sandbox(self, sandbox_id: str) -> bool:
"""Begin the process of resuming a sandbox.
Return True if the sandbox exists and is being resumed or is already running.
Return False if the sandbox did not exist.
"""
@abstractmethod
async def pause_sandbox(self, sandbox_id: str) -> bool:
"""Begin the process of pausing a sandbox.
Return True if the sandbox exists and is being paused or is already paused.
Return False if the sandbox did not exist.
"""
@abstractmethod
async def delete_sandbox(self, sandbox_id: str) -> bool:
"""Begin the process of deleting a sandbox (which may involve stopping it).
Return False if the sandbox did not exist.
"""
class SandboxServiceInjector(DiscriminatedUnionMixin, Injector[SandboxService], ABC):
pass

View File

@@ -0,0 +1,23 @@
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
from openhands.agent_server.utils import utc_now
class SandboxSpecInfo(BaseModel):
"""A template for creating a Sandbox (e.g: A Docker Image vs Container)."""
id: str
command: str
created_at: datetime = Field(default_factory=utc_now)
initial_env: dict[str, str] = Field(
default_factory=dict, description='Initial Environment Variables'
)
working_dir: str = '/openhands/code'
class SandboxSpecInfoPage(BaseModel):
items: list[SandboxSpecInfo]
next_page_id: str | None = None

View File

@@ -0,0 +1,49 @@
"""Runtime Images router for OpenHands Server."""
from typing import Annotated
from fastapi import APIRouter, Query
from openhands.app_server.config import depends_sandbox_spec_service
from openhands.app_server.sandbox.sandbox_spec_models import (
SandboxSpecInfo,
SandboxSpecInfoPage,
)
from openhands.app_server.sandbox.sandbox_spec_service import (
SandboxSpecService,
)
router = APIRouter(prefix='/sandbox-specs', tags=['Sandbox'])
sandbox_spec_service_dependency = depends_sandbox_spec_service()
# Read methods
@router.get('/search')
async def search_sandbox_specs(
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
] = 100,
sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency,
) -> SandboxSpecInfoPage:
"""Search / List sandbox specs."""
assert limit > 0
assert limit <= 100
return await sandbox_spec_service.search_sandbox_specs(page_id=page_id, limit=limit)
@router.get('')
async def batch_get_sandbox_specs(
id: Annotated[list[str], Query()],
sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency,
) -> list[SandboxSpecInfo | None]:
"""Get a batch of sandbox specs given their ids, returning null for any missing."""
assert len(id) <= 100
sandbox_specs = await sandbox_spec_service.batch_get_sandbox_specs(id)
return sandbox_specs

View File

@@ -0,0 +1,55 @@
import asyncio
from abc import ABC, abstractmethod
from openhands.app_server.errors import SandboxError
from openhands.app_server.sandbox.sandbox_spec_models import (
SandboxSpecInfo,
SandboxSpecInfoPage,
)
from openhands.app_server.services.injector import Injector
from openhands.sdk.utils.models import DiscriminatedUnionMixin
class SandboxSpecService(ABC):
"""Service for managing Sandbox specs.
At present this is read only. The plan is that later this class will allow building
and deleting sandbox specs and limiting access by user and group. It would also be
nice to be able to set the desired number of warm sandboxes for a spec and scale
this up and down.
"""
@abstractmethod
async def search_sandbox_specs(
self, page_id: str | None = None, limit: int = 100
) -> SandboxSpecInfoPage:
"""Search for sandbox specs."""
@abstractmethod
async def get_sandbox_spec(self, sandbox_spec_id: str) -> SandboxSpecInfo | None:
"""Get a single sandbox spec, returning None if not found."""
async def get_default_sandbox_spec(self) -> SandboxSpecInfo:
"""Get the default sandbox spec."""
page = await self.search_sandbox_specs()
if not page:
raise SandboxError('No sandbox specs available!')
return page.items[0]
async def batch_get_sandbox_specs(
self, sandbox_spec_ids: list[str]
) -> list[SandboxSpecInfo | None]:
"""Get a batch of sandbox specs, returning None for any not found."""
results = await asyncio.gather(
*[
self.get_sandbox_spec(sandbox_spec_id)
for sandbox_spec_id in sandbox_spec_ids
]
)
return results
class SandboxSpecServiceInjector(
DiscriminatedUnionMixin, Injector[SandboxSpecService], ABC
):
pass

View File

@@ -0,0 +1,19 @@
# Core Services
Provides essential services for authentication, security, and system operations.
## Overview
This module contains core services that support the OpenHands app server infrastructure, including authentication, token management, and security operations.
## Key Components
- **JwtService**: JSON Web Token signing, verification, and encryption
## JWT Service Features
- Token signing and verification for authentication
- JWE (JSON Web Encryption) support for sensitive data
- Multi-key support with key rotation capabilities
- Configurable algorithms (RS256, HS256, etc.)
- Secure token handling and validation

View File

@@ -0,0 +1,300 @@
"""Database configuration and session management for OpenHands Server."""
import asyncio
import logging
import os
from pathlib import Path
from typing import AsyncGenerator
from fastapi import Request
from pydantic import BaseModel, PrivateAttr, SecretStr, model_validator
from sqlalchemy import Engine, create_engine
from sqlalchemy.engine import URL
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio.engine import AsyncEngine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from sqlalchemy.util import await_only
from openhands.app_server.services.injector import Injector, InjectorState
_logger = logging.getLogger(__name__)
DB_SESSION_ATTR = 'db_session'
DB_SESSION_KEEP_OPEN_ATTR = 'db_session_keep_open'
class DbSessionInjector(BaseModel, Injector[async_sessionmaker]):
persistence_dir: Path
host: str | None = None
port: int | None = None
name: str | None = None
user: str | None = None
password: SecretStr | None = None
echo: bool = False
pool_size: int = 25
max_overflow: int = 10
gcp_db_instance: str | None = None
gcp_project: str | None = None
gcp_region: str | None = None
# Private attrs
_engine: Engine | None = PrivateAttr(default=None)
_async_engine: AsyncEngine | None = PrivateAttr(default=None)
_session_maker: sessionmaker | None = PrivateAttr(default=None)
_async_session_maker: async_sessionmaker | None = PrivateAttr(default=None)
@model_validator(mode='after')
def fill_empty_fields(self):
"""Override any defaults with values from legacy enviroment variables"""
if self.host is None:
self.host = os.getenv('DB_HOST')
if self.port is None:
self.port = int(os.getenv('DB_PORT', '5432'))
if self.name is None:
self.name = os.getenv('DB_NAME', 'openhands')
if self.user is None:
self.user = os.getenv('DB_USER', 'postgres')
if self.password is None:
self.password = SecretStr(os.getenv('DB_PASS', 'postgres'))
if self.gcp_db_instance is None:
self.gcp_db_instance = os.getenv('GCP_DB_INSTANCE')
if self.gcp_project is None:
self.gcp_project = os.getenv('GCP_PROJECT')
if self.gcp_region is None:
self.gcp_region = os.getenv('GCP_REGION')
return self
def _create_gcp_db_connection(self):
# Lazy import because lib does not import if user does not have posgres installed
from google.cloud.sql.connector import Connector
connector = Connector()
instance_string = f'{self.gcp_project}:{self.gcp_region}:{self.gcp_db_instance}'
password = self.password
assert password is not None
return connector.connect(
instance_string,
'pg8000',
user=self.user,
password=password.get_secret_value(),
db=self.name,
)
async def _create_async_gcp_db_connection(self):
# Lazy import because lib does not import if user does not have posgres installed
from google.cloud.sql.connector import Connector
loop = asyncio.get_running_loop()
async with Connector(loop=loop) as connector:
password = self.password
assert password is not None
conn = await connector.connect_async(
f'{self.gcp_project}:{self.gcp_region}:{self.gcp_db_instance}',
'asyncpg',
user=self.user,
password=password.get_secret_value(),
db=self.name,
)
return conn
def _create_gcp_engine(self):
engine = create_engine(
'postgresql+pg8000://',
creator=self._create_gcp_db_connection,
pool_size=self.pool_size,
max_overflow=self.max_overflow,
pool_pre_ping=True,
)
return engine
async def _create_async_gcp_creator(self):
from sqlalchemy.dialects.postgresql.asyncpg import (
AsyncAdapt_asyncpg_connection,
)
engine = self._create_gcp_engine()
return AsyncAdapt_asyncpg_connection(
engine.dialect.dbapi,
await self._create_async_gcp_db_connection(),
prepared_statement_cache_size=100,
)
async def _create_async_gcp_engine(self):
from sqlalchemy.dialects.postgresql.asyncpg import (
AsyncAdapt_asyncpg_connection,
)
base_engine = self._create_gcp_engine()
dbapi = base_engine.dialect.dbapi
def adapted_creator():
return AsyncAdapt_asyncpg_connection(
dbapi,
await_only(self._create_async_gcp_db_connection()),
prepared_statement_cache_size=100,
)
return create_async_engine(
'postgresql+asyncpg://',
creator=adapted_creator,
pool_size=self.pool_size,
max_overflow=self.max_overflow,
pool_pre_ping=True,
)
async def get_async_db_engine(self) -> AsyncEngine:
async_engine = self._async_engine
if async_engine:
return async_engine
if self.gcp_db_instance: # GCP environments
async_engine = await self._create_async_gcp_engine()
else:
if self.host:
try:
import asyncpg # noqa: F401
except Exception as e:
raise RuntimeError(
"PostgreSQL driver 'asyncpg' is required for async connections but is not installed."
) from e
password = self.password.get_secret_value() if self.password else None
url = URL.create(
'postgresql+asyncpg',
username=self.user or '',
password=password,
host=self.host,
port=self.port,
database=self.name,
)
else:
url = f'sqlite+aiosqlite:///{str(self.persistence_dir)}/openhands.db'
if self.host:
async_engine = create_async_engine(
url,
pool_size=self.pool_size,
max_overflow=self.max_overflow,
pool_pre_ping=True,
)
else:
async_engine = create_async_engine(
url,
poolclass=NullPool,
pool_pre_ping=True,
)
self._async_engine = async_engine
return async_engine
def get_db_engine(self) -> Engine:
engine = self._engine
if engine:
return engine
if self.gcp_db_instance: # GCP environments
engine = self._create_gcp_engine()
else:
if self.host:
try:
import pg8000 # noqa: F401
except Exception as e:
raise RuntimeError(
"PostgreSQL driver 'pg8000' is required for sync connections but is not installed."
) from e
password = self.password.get_secret_value() if self.password else None
url = URL.create(
'postgresql+pg8000',
username=self.user or '',
password=password,
host=self.host,
port=self.port,
database=self.name,
)
else:
url = f'sqlite:///{self.persistence_dir}/openhands.db'
engine = create_engine(
url,
pool_size=self.pool_size,
max_overflow=self.max_overflow,
pool_pre_ping=True,
)
self._engine = engine
return engine
def get_session_maker(self) -> sessionmaker:
session_maker = self._session_maker
if session_maker is None:
session_maker = sessionmaker(bind=self.get_db_engine())
self._session_maker = session_maker
return session_maker
async def get_async_session_maker(self) -> async_sessionmaker:
async_session_maker = self._async_session_maker
if async_session_maker is None:
db_engine = await self.get_async_db_engine()
async_session_maker = async_sessionmaker(
db_engine,
class_=AsyncSession,
expire_on_commit=False,
)
self._async_session_maker = async_session_maker
return async_session_maker
async def async_session(self) -> AsyncGenerator[AsyncSession, None]:
"""Dependency function that yields database sessions.
This function creates a new database session for each request
and ensures it's properly closed after use.
Yields:
AsyncSession: An async SQL session
"""
session_maker = await self.get_async_session_maker()
async with session_maker() as session:
try:
yield session
finally:
await session.close()
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[AsyncSession, None]:
"""Dependency function that manages database sessions through request state.
This function stores the database session in the request state to enable
session reuse across multiple dependencies within the same request.
If a session already exists in the request state, it returns that session.
Otherwise, it creates a new session and stores it in the request state.
Args:
request: The FastAPI request object
Yields:
AsyncSession: An async SQL session stored in request state
"""
db_session = getattr(state, DB_SESSION_ATTR, None)
if db_session:
yield db_session
else:
# Create a new session and store it in request state
session_maker = await self.get_async_session_maker()
db_session = session_maker()
try:
setattr(state, DB_SESSION_ATTR, db_session)
yield db_session
if not getattr(state, DB_SESSION_KEEP_OPEN_ATTR, False):
await db_session.commit()
except Exception:
_logger.exception('Rolling back SQL due to error', stack_info=True)
await db_session.rollback()
raise
finally:
# If instructed, do not close the db session at the end of the request.
if not getattr(state, DB_SESSION_KEEP_OPEN_ATTR, False):
# Clean up the session from request state
if hasattr(state, DB_SESSION_ATTR):
delattr(state, DB_SESSION_ATTR)
await db_session.close()
def set_db_session_keep_open(state: InjectorState, keep_open: bool):
"""Set whether the connection should be kept open after the request terminates."""
setattr(state, DB_SESSION_KEEP_OPEN_ATTR, keep_open)

View File

@@ -0,0 +1,38 @@
from typing import AsyncGenerator
import httpx
from fastapi import Request
from pydantic import BaseModel, Field
from openhands.app_server.services.injector import Injector, InjectorState
HTTPX_CLIENT_ATTR = 'httpx_client'
HTTPX_CLIENT_KEEP_OPEN_ATTR = 'httpx_client_keep_open'
class HttpxClientInjector(BaseModel, Injector[httpx.AsyncClient]):
"""Injector for a httpx client. By keeping a single httpx client alive in the
context of server requests handshakes are minimized while connection pool leaks
are prevented."""
timeout: int = Field(default=15, description='Default timeout on all http requests')
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[httpx.AsyncClient, None]:
httpx_client = getattr(state, HTTPX_CLIENT_ATTR, None)
if httpx_client:
yield httpx_client
return
httpx_client = httpx.AsyncClient(timeout=self.timeout)
try:
setattr(state, HTTPX_CLIENT_ATTR, httpx_client)
yield httpx_client
finally:
# If instructed, do not close the httpx client at the end of the request.
if not getattr(state, HTTPX_CLIENT_KEEP_OPEN_ATTR, False):
# Clean up the httpx client from request state
if hasattr(state, HTTPX_CLIENT_ATTR):
delattr(state, HTTPX_CLIENT_ATTR)
await httpx_client.aclose()

View File

@@ -0,0 +1,34 @@
import contextlib
from abc import ABC, abstractmethod
from typing import AsyncGenerator, Generic, TypeAlias, TypeVar
from fastapi import Request
from starlette.datastructures import State
T = TypeVar('T')
InjectorState: TypeAlias = State
class Injector(Generic[T], ABC):
"""Object designed to facilitate dependency injection"""
@abstractmethod
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[T, None]:
"""Inject an object. The state object may be used to store variables for
reuse by other injectors, as injection operations may be nested."""
yield None # type: ignore
@contextlib.asynccontextmanager
async def context(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[T, None]:
"""Context function suitable for use in async with clauses"""
async for result in self.inject(state, request):
yield result
async def depends(self, request: Request) -> AsyncGenerator[T, None]:
"""Depends function suitable for use with FastAPI dependency injection."""
async for result in self.inject(request.state, request):
yield result

View File

@@ -0,0 +1,248 @@
import hashlib
import json
from datetime import timedelta
from pathlib import Path
from typing import Any, AsyncGenerator
import jwt
from fastapi import Request
from jose import jwe
from jose.constants import ALGORITHMS
from pydantic import BaseModel, PrivateAttr
from openhands.agent_server.utils import utc_now
from openhands.app_server.services.injector import Injector, InjectorState
from openhands.app_server.utils.encryption_key import (
EncryptionKey,
get_default_encryption_keys,
)
class JwtService:
"""Service for signing/verifying JWS tokens and encrypting/decrypting JWE tokens."""
def __init__(self, keys: list[EncryptionKey]):
"""Initialize the JWT service with a list of keys.
Args:
keys: List of EncryptionKey objects. If None, will try to load from config.
Raises:
ValueError: If no keys are provided and config is not available
"""
active_keys = [key for key in keys if key.active]
if not active_keys:
raise ValueError('At least one active key is required')
# Store keys by ID for quick lookup
self._keys = {key.id: key for key in keys}
# Find the newest key as default
newest_key = max(active_keys, key=lambda k: k.created_at)
self._default_key_id = newest_key.id
@property
def default_key_id(self) -> str:
"""Get the default key ID."""
return self._default_key_id
def create_jws_token(
self,
payload: dict[str, Any],
key_id: str | None = None,
expires_in: timedelta | None = None,
) -> str:
"""Create a JWS (JSON Web Signature) token.
Args:
payload: The JWT payload
key_id: The key ID to use for signing. If None, uses the newest key.
expires_in: Token expiration time. If None, defaults to 1 hour.
Returns:
The signed JWS token
Raises:
ValueError: If key_id is invalid
"""
if key_id is None:
key_id = self._default_key_id
if key_id not in self._keys:
raise ValueError(f"Key ID '{key_id}' not found")
# Add standard JWT claims
now = utc_now()
if expires_in is None:
expires_in = timedelta(hours=1)
jwt_payload = {
**payload,
'iat': int(now.timestamp()),
'exp': int((now + expires_in).timestamp()),
}
# Use the raw key for JWT signing with key_id in header
secret_key = self._keys[key_id].key.get_secret_value()
return jwt.encode(
jwt_payload, secret_key, algorithm='HS256', headers={'kid': key_id}
)
def verify_jws_token(self, token: str, key_id: str | None = None) -> dict[str, Any]:
"""Verify and decode a JWS token.
Args:
token: The JWS token to verify
key_id: The key ID to use for verification. If None, extracts from
token's kid header.
Returns:
The decoded JWT payload
Raises:
ValueError: If token is invalid or key_id is not found
jwt.InvalidTokenError: If token verification fails
"""
if key_id is None:
# Try to extract key_id from the token's kid header
try:
unverified_header = jwt.get_unverified_header(token)
key_id = unverified_header.get('kid')
if not key_id:
raise ValueError("Token does not contain 'kid' header with key ID")
except jwt.DecodeError:
raise ValueError('Invalid JWT token format')
if key_id not in self._keys:
raise ValueError(f"Key ID '{key_id}' not found")
# Use the raw key for JWT verification
secret_key = self._keys[key_id].key.get_secret_value()
try:
payload = jwt.decode(token, secret_key, algorithms=['HS256'])
return payload
except jwt.InvalidTokenError as e:
raise jwt.InvalidTokenError(f'Token verification failed: {str(e)}')
def create_jwe_token(
self,
payload: dict[str, Any],
key_id: str | None = None,
expires_in: timedelta | None = None,
) -> str:
"""Create a JWE (JSON Web Encryption) token.
Args:
payload: The JWT payload to encrypt
key_id: The key ID to use for encryption. If None, uses the newest key.
expires_in: Token expiration time. If None, defaults to 1 hour.
Returns:
The encrypted JWE token
Raises:
ValueError: If key_id is invalid
"""
if key_id is None:
key_id = self._default_key_id
if key_id not in self._keys:
raise ValueError(f"Key ID '{key_id}' not found")
# Add standard JWT claims
now = utc_now()
if expires_in is None:
expires_in = timedelta(hours=1)
jwt_payload = {
**payload,
'iat': int(now.timestamp()),
'exp': int((now + expires_in).timestamp()),
}
# Get the raw key for JWE encryption and derive a 256-bit key
secret_key = self._keys[key_id].key.get_secret_value()
key_bytes = secret_key.encode() if isinstance(secret_key, str) else secret_key
# Derive a 256-bit key using SHA256
key_256 = hashlib.sha256(key_bytes).digest()
# Encrypt the payload (convert to JSON string first)
payload_json = json.dumps(jwt_payload)
encrypted_token = jwe.encrypt(
payload_json,
key_256,
algorithm=ALGORITHMS.DIR,
encryption=ALGORITHMS.A256GCM,
kid=key_id,
)
# Ensure we return a string
return (
encrypted_token.decode('utf-8')
if isinstance(encrypted_token, bytes)
else encrypted_token
)
def decrypt_jwe_token(
self, token: str, key_id: str | None = None
) -> dict[str, Any]:
"""Decrypt and decode a JWE token.
Args:
token: The JWE token to decrypt
key_id: The key ID to use for decryption. If None, extracts
from token header.
Returns:
The decrypted JWT payload
Raises:
ValueError: If token is invalid or key_id is not found
Exception: If token decryption fails
"""
if key_id is None:
# Try to extract key_id from the token's header
try:
header = jwe.get_unverified_header(token)
key_id = header.get('kid')
if not key_id:
raise ValueError("Token does not contain 'kid' header with key ID")
except Exception:
raise ValueError('Invalid JWE token format')
if key_id not in self._keys:
raise ValueError(f"Key ID '{key_id}' not found")
# Get the raw key for JWE decryption and derive a 256-bit key
secret_key = self._keys[key_id].key.get_secret_value()
key_bytes = secret_key.encode() if isinstance(secret_key, str) else secret_key
# Derive a 256-bit key using SHA256
key_256 = hashlib.sha256(key_bytes).digest()
try:
payload_json = jwe.decrypt(token, key_256)
assert payload_json is not None
# Parse the JSON string back to dictionary
payload = json.loads(payload_json)
return payload
except Exception as e:
raise Exception(f'Token decryption failed: {str(e)}')
class JwtServiceInjector(BaseModel, Injector[JwtService]):
persistence_dir: Path
_jwt_service: JwtService | None = PrivateAttr(default=None)
def get_jwt_service(self) -> JwtService:
jwt_service = self._jwt_service
if jwt_service is None:
keys = get_default_encryption_keys(self.persistence_dir)
jwt_service = JwtService(keys=keys)
self._jwt_service = jwt_service
return jwt_service
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[JwtService, None]:
yield self.get_jwt_service()

View File

@@ -0,0 +1,21 @@
# User Management
Handles user authentication, authorization, and profile management for the OpenHands app server.
## Overview
This module provides user management capabilities, including authentication, user profile access, and service resolution for user-scoped operations.
## Key Components
- **UserContext**: Abstract context for user operations
- **AuthUserContext**: Compatibility layer for user auth.
- **UserRouter**: FastAPI router for user-related endpoints
- **UserContextInjector**: Factory for getting user context with FastAPI dependency injection
## Features
- User authentication and session management
- Current user profile retrieval
- User-scoped service resolution
- JWT-based authentication integration

View File

@@ -0,0 +1,48 @@
from dataclasses import dataclass
from fastapi import Request
from openhands.app_server.errors import OpenHandsError
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import ProviderType
from openhands.sdk.conversation.secret_source import SecretSource
@dataclass(frozen=True)
class AdminUserContext(UserContext):
"""User context for use in admin operations which allows access beyond the scope of a single user"""
user_id: str | None
async def get_user_id(self) -> str | None:
return self.user_id
async def get_user_info(self) -> UserInfo:
raise NotImplementedError()
async def get_authenticated_git_url(self, repository: str) -> str:
raise NotImplementedError()
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
raise NotImplementedError()
async def get_secrets(self) -> dict[str, SecretSource]:
raise NotImplementedError()
USER_CONTEXT_ATTR = 'user_context'
ADMIN = AdminUserContext(user_id=None)
def as_admin(request: Request):
"""Service the request as an admin user without restrictions. The endpoint should
handle security."""
user_context = getattr(request.state, USER_CONTEXT_ATTR, None)
if user_context not in (None, ADMIN):
raise OpenHandsError(
'Non admin context already present! '
'(Do you need to move the as_admin dependency to the start of the args?)'
)
setattr(request.state, USER_CONTEXT_ATTR, ADMIN)
return ADMIN

View File

@@ -0,0 +1,99 @@
from dataclasses import dataclass
from typing import Any, AsyncGenerator
from fastapi import Request
from pydantic import PrivateAttr
from openhands.app_server.errors import AuthError
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.admin_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext, UserContextInjector
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth, get_user_auth
USER_AUTH_ATTR = 'user_auth'
@dataclass
class AuthUserContext(UserContext):
"""Interface to old user settings service. Eventually we want to migrate
this to use true database asyncio."""
user_auth: UserAuth
_user_info: UserInfo | None = None
_provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:
# If you have an auth object here you are logged in. If user_id is None
# it means we are in OSS mode.
user_id = await self.user_auth.get_user_id()
return user_id
async def get_user_info(self) -> UserInfo:
user_info = self._user_info
if user_info is None:
user_id = await self.get_user_id()
settings = await self.user_auth.get_user_settings()
assert settings is not None
user_info = UserInfo(
id=user_id,
**settings.model_dump(context={'expose_secrets': True}),
)
self._user_info = user_info
return user_info
async def get_provider_handler(self):
provider_handler = self._provider_handler
if not provider_handler:
provider_tokens = await self.user_auth.get_provider_tokens()
assert provider_tokens is not None
user_id = await self.get_user_id()
provider_handler = ProviderHandler(
provider_tokens=provider_tokens, external_auth_id=user_id
)
self._provider_handler = provider_handler
return provider_handler
async def get_authenticated_git_url(self, repository: str) -> str:
provider_handler = await self.get_provider_handler()
url = await provider_handler.get_authenticated_git_url(repository)
return url
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
provider_handler = await self.get_provider_handler()
service = provider_handler.get_service(provider_type)
token = await service.get_latest_token()
return token
async def get_secrets(self) -> dict[str, SecretSource]:
results = {}
# Include custom secrets...
secrets = await self.user_auth.get_user_secrets()
if secrets:
for name, custom_secret in secrets.custom_secrets.items():
results[name] = StaticSecret(value=custom_secret.secret)
return results
USER_ID_ATTR = 'user_id'
class AuthUserContextInjector(UserContextInjector):
_user_auth_class: Any = PrivateAttr(default=None)
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[UserContext, None]:
user_context = getattr(state, USER_CONTEXT_ATTR, None)
if user_context is None:
if request is None:
raise AuthError()
user_auth = await get_user_auth(request)
user_context = AuthUserContext(user_auth=user_auth)
setattr(state, USER_CONTEXT_ATTR, user_context)
yield user_context

View File

@@ -0,0 +1,41 @@
from abc import ABC, abstractmethod
from openhands.app_server.services.injector import Injector
from openhands.app_server.user.user_models import (
UserInfo,
)
from openhands.integrations.provider import ProviderType
from openhands.sdk.conversation.secret_source import SecretSource
from openhands.sdk.utils.models import DiscriminatedUnionMixin
class UserContext(ABC):
"""Service for managing users."""
# Read methods
@abstractmethod
async def get_user_id(self) -> str | None:
"""Get the user id"""
@abstractmethod
async def get_user_info(self) -> UserInfo:
"""Get the user info."""
@abstractmethod
async def get_authenticated_git_url(self, repository: str) -> str:
"""Get the provider tokens for the user"""
@abstractmethod
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
"""Get the latest token for the provider type given"""
@abstractmethod
async def get_secrets(self) -> dict[str, SecretSource]:
"""Get custom secrets and github provider secrets for the conversation."""
class UserContextInjector(DiscriminatedUnionMixin, Injector[UserContext], ABC):
"""Injector for user contexts."""
pass

View File

@@ -0,0 +1,13 @@
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.storage.data_models.settings import Settings
class UserInfo(Settings):
"""Model for user settings including the current user id."""
id: str | None = None
class ProviderTokenPage:
items: list[PROVIDER_TOKEN_TYPE]
next_page_id: str | None = None

View File

@@ -0,0 +1,23 @@
"""User router for OpenHands Server. For the moment, this simply implements the /me endpoint."""
from fastapi import APIRouter, HTTPException, status
from openhands.app_server.config import depends_user_context
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
router = APIRouter(prefix='/users', tags=['User'])
user_dependency = depends_user_context()
# Read methods
@router.get('/me')
async def get_current_user(
user_context: UserContext = user_dependency,
) -> UserInfo:
"""Get the current authenticated user."""
user = await user_context.get_user_info()
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
return user

View File

@@ -0,0 +1,20 @@
# Utilities
Common utility functions and helpers for the OpenHands app server.
## Overview
This module provides utility functions that are used across the app server for common operations like date handling, SQL operations, and dynamic imports.
## Key Components
- **date_utils**: Date and time utilities
- **sql_utils**: SQL database operation helpers
- **import_utils**: Dynamic module import utilities
## Key Functions
- **utc_now()**: Returns current UTC timestamp (replaces deprecated datetime.utcnow)
- Database connection and query helpers
- Dynamic module loading utilities
- Safe import error handling

View File

@@ -0,0 +1,256 @@
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
import httpx
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
_logger = logging.getLogger(__name__)
@dataclass
class AsyncRemoteWorkspace:
"""Mixin providing remote workspace operations."""
working_dir: str
server_url: str
session_api_key: str | None = None
client: httpx.AsyncClient = field(default_factory=httpx.AsyncClient)
def __post_init__(self) -> None:
# Set up remote host and API key
self.server_url = self.server_url.rstrip('/')
def _headers(self):
headers = {}
if self.session_api_key:
headers['X-Session-API-Key'] = self.session_api_key
return headers
async def execute_command(
self,
command: str,
cwd: str | Path | None = None,
timeout: float = 30.0,
) -> CommandResult:
"""Execute a bash command on the remote system.
This method starts a bash command via the remote agent server API,
then polls for the output until the command completes.
Args:
command: The bash command to execute
cwd: Working directory (optional)
timeout: Timeout in seconds
Returns:
CommandResult: Result with stdout, stderr, exit_code, and other metadata
"""
_logger.debug(f'Executing remote command: {command}')
# Step 1: Start the bash command
payload = {
'command': command,
'timeout': int(timeout),
}
if cwd is not None:
payload['cwd'] = str(cwd)
try:
# Start the command
response = await self.client.post(
f'{self.server_url}/api/bash/execute_bash_command',
json=payload,
timeout=timeout + 5.0, # Add buffer to HTTP timeout
headers=self._headers(),
)
response.raise_for_status()
bash_command = response.json()
command_id = bash_command['id']
_logger.debug(f'Started command with ID: {command_id}')
# Step 2: Poll for output until command completes
start_time = time.time()
stdout_parts = []
stderr_parts = []
exit_code = None
while time.time() - start_time < timeout:
# Search for all events and filter client-side
# (workaround for bash service filtering bug)
search_response = await self.client.get(
f'{self.server_url}/api/bash/bash_events/search',
params={
'sort_order': 'TIMESTAMP',
'limit': 100,
},
timeout=10.0,
headers=self._headers(),
)
search_response.raise_for_status()
search_result = search_response.json()
# Filter for BashOutput events for this command
for event in search_result.get('items', []):
if (
event.get('kind') == 'BashOutput'
and event.get('command_id') == command_id
):
if event.get('stdout'):
stdout_parts.append(event['stdout'])
if event.get('stderr'):
stderr_parts.append(event['stderr'])
if event.get('exit_code') is not None:
exit_code = event['exit_code']
# If we have an exit code, the command is complete
if exit_code is not None:
break
# Wait a bit before polling again
time.sleep(0.1)
# If we timed out waiting for completion
if exit_code is None:
_logger.warning(f'Command timed out after {timeout} seconds: {command}')
exit_code = -1
stderr_parts.append(f'Command timed out after {timeout} seconds')
# Combine all output parts
stdout = ''.join(stdout_parts)
stderr = ''.join(stderr_parts)
return CommandResult(
command=command,
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
timeout_occurred=exit_code == -1 and 'timed out' in stderr,
)
except Exception as e:
_logger.error(f'Remote command execution failed: {e}')
return CommandResult(
command=command,
exit_code=-1,
stdout='',
stderr=f'Remote execution error: {str(e)}',
timeout_occurred=False,
)
async def file_upload(
self,
source_path: str | Path,
destination_path: str | Path,
) -> FileOperationResult:
"""Upload a file to the remote system.
Reads the local file and sends it to the remote system via HTTP API.
Args:
source_path: Path to the local source file
destination_path: Path where the file should be uploaded on remote system
Returns:
FileOperationResult: Result with success status and metadata
"""
source = Path(source_path)
destination = Path(destination_path)
_logger.debug(f'Remote file upload: {source} -> {destination}')
try:
# Read the file content
with open(source, 'rb') as f:
file_content = f.read()
# Prepare the upload
files = {'file': (source.name, file_content)}
data = {'destination_path': str(destination)}
# Make synchronous HTTP call
response = await self.client.post(
'/api/files/upload',
files=files,
data=data,
timeout=60.0,
)
response.raise_for_status()
result_data = response.json()
# Convert the API response to our model
return FileOperationResult(
success=result_data.get('success', True),
source_path=str(source),
destination_path=str(destination),
file_size=result_data.get('file_size'),
error=result_data.get('error'),
)
except Exception as e:
_logger.error(f'Remote file upload failed: {e}')
return FileOperationResult(
success=False,
source_path=str(source),
destination_path=str(destination),
error=str(e),
)
async def file_download(
self,
source_path: str | Path,
destination_path: str | Path,
) -> FileOperationResult:
"""Download a file from the remote system.
Requests the file from the remote system via HTTP API and saves it locally.
Args:
source_path: Path to the source file on remote system
destination_path: Path where the file should be saved locally
Returns:
FileOperationResult: Result with success status and metadata
"""
source = Path(source_path)
destination = Path(destination_path)
_logger.debug(f'Remote file download: {source} -> {destination}')
try:
# Request the file from remote system
params = {'file_path': str(source)}
# Make synchronous HTTP call
response = await self.client.get(
'/api/files/download',
params=params,
timeout=60.0,
)
response.raise_for_status()
# Ensure destination directory exists
destination.parent.mkdir(parents=True, exist_ok=True)
# Write the file content
with open(destination, 'wb') as f:
f.write(response.content)
return FileOperationResult(
success=True,
source_path=str(source),
destination_path=str(destination),
file_size=len(response.content),
)
except Exception as e:
_logger.error(f'Remote file download failed: {e}')
return FileOperationResult(
success=False,
source_path=str(source),
destination_path=str(destination),
error=str(e),
)

View File

@@ -0,0 +1,58 @@
import os
from datetime import datetime
from pathlib import Path
from typing import Any
import base62
from pydantic import BaseModel, Field, SecretStr, TypeAdapter, field_serializer
from openhands.agent_server.utils import utc_now
class EncryptionKey(BaseModel):
"""Configuration for an encryption key."""
id: str = Field(default_factory=lambda: base62.encodebytes(os.urandom(32)))
key: SecretStr
active: bool = True
notes: str | None = None
created_at: datetime = Field(default_factory=utc_now)
@field_serializer('key')
def serialize_key(self, key: SecretStr, info: Any):
"""Conditionally serialize the key based on context."""
if info.context and info.context.get('reveal_secrets'):
return key.get_secret_value()
return str(key) # Returns '**********' by default
def get_default_encryption_keys(workspace_dir: Path) -> list[EncryptionKey]:
"""Generate default encryption keys."""
master_key = os.getenv('JWT_SECRET')
if master_key:
return [
EncryptionKey(
key=SecretStr(master_key),
active=True,
notes='jwt secret master key',
)
]
key_file = workspace_dir / '.keys'
type_adapter = TypeAdapter(list[EncryptionKey])
if key_file.exists():
encryption_keys = type_adapter.validate_json(key_file.read_text())
return encryption_keys
encryption_keys = [
EncryptionKey(
key=SecretStr(base62.encodebytes(os.urandom(32))),
active=True,
notes='generated master key',
)
]
json_data = type_adapter.dump_json(
encryption_keys, context={'expose_secrets': True}
)
key_file.write_bytes(json_data)
return encryption_keys

View File

@@ -0,0 +1,78 @@
import importlib
from functools import lru_cache
from typing import TypeVar
T = TypeVar('T')
def import_from(qual_name: str):
"""Import a value from its fully qualified name.
This function is a utility to dynamically import any Python value (class,
function, variable) from its fully qualified name. For example,
'openhands.server.user_auth.UserAuth' would import the UserAuth class from the
openhands.server.user_auth module.
Args:
qual_name: A fully qualified name in the format 'module.submodule.name'
e.g. 'openhands.server.user_auth.UserAuth'
Returns:
The imported value (class, function, or variable)
Example:
>>> UserAuth = import_from('openhands.server.user_auth.UserAuth')
>>> auth = UserAuth()
"""
parts = qual_name.split('.')
module_name = '.'.join(parts[:-1])
module = importlib.import_module(module_name)
result = getattr(module, parts[-1])
return result
@lru_cache()
def _get_impl(cls: type[T], impl_name: str | None) -> type[T]:
if impl_name is None:
return cls
impl_class = import_from(impl_name)
assert cls == impl_class or issubclass(impl_class, cls)
return impl_class
def get_impl(cls: type[T], impl_name: str | None) -> type[T]:
"""Import and validate a named implementation of a base class.
This function is an extensibility mechanism in OpenHands that allows runtime
substitution of implementations. It enables applications to customize behavior by
providing their own implementations of OpenHands base classes.
The function ensures type safety by validating that the imported class is either
the same as or a subclass of the specified base class.
Args:
cls: The base class that defines the interface
impl_name: Fully qualified name of the implementation class, or None to use
the base class
e.g. 'openhands.server.conversation_service.'
'StandaloneConversationService'
Returns:
The implementation class, which is guaranteed to be a subclass of cls
Example:
>>> # Get default implementation
>>> ConversationService = get_impl(ConversationService, None)
>>> # Get custom implementation
>>> CustomService = get_impl(
... ConversationService, 'myapp.CustomConversationService'
... )
Common Use Cases:
- Server components (ConversationService, UserAuth, etc.)
- Storage implementations (ConversationStore, SettingsStore, etc.)
- Service integrations (GitHub, GitLab, Bitbucket services)
The implementation is cached to avoid repeated imports of the same class.
"""
return _get_impl(cls, impl_name) # type: ignore

View File

@@ -0,0 +1,104 @@
from datetime import UTC, datetime
from enum import Enum
from typing import TypeVar
from pydantic import SecretStr, TypeAdapter
from sqlalchemy import JSON, DateTime, String, TypeDecorator
from sqlalchemy.orm import declarative_base
Base = declarative_base()
T = TypeVar('T', bound=Enum)
def create_json_type_decorator(object_type: type):
"""Create a decorator for a particular type. Introduced because SQLAlchemy could not process lists of enum values."""
type_adapter: TypeAdapter = TypeAdapter(object_type)
class JsonTypeDecorator(TypeDecorator):
impl = JSON
cache_ok = True
def process_bind_param(self, value, dialect):
return type_adapter.dump_python(
value, mode='json', context={'expose_secrets': True}
)
def process_result_param(self, value, dialect):
return type_adapter.validate_python(value)
return JsonTypeDecorator
class StoredSecretStr(TypeDecorator):
"""TypeDecorator for secret strings. Encrypts the value using the default key before storing."""
impl = String
cache_ok = True
def process_bind_param(self, value, dialect):
if value is not None:
from openhands.app_server.config import get_global_config
jwt_service_injector = get_global_config().jwt
assert jwt_service_injector is not None
jwt_service = jwt_service_injector.get_jwt_service()
token = jwt_service.create_jwe_token({'v': value.get_secret_value()})
return token
return None
def process_result_param(self, value, dialect):
if value is not None:
from openhands.app_server.config import get_global_config
jwt_service_injector = get_global_config().jwt
assert jwt_service_injector is not None
jwt_service = jwt_service_injector.get_jwt_service()
token = jwt_service.decrypt_jwe_token(value)
return SecretStr(token['v'])
return None
class UtcDateTime(TypeDecorator):
"""TypeDecorator for datetime - stores all datetimes in utc. Assumes datetime without
a specified timezone are utc. (Sqlite doesn't always return these)"""
impl = DateTime(timezone=True)
cache_ok = True
def process_bind_param(self, value, dialect):
if isinstance(value, datetime) and value.tzinfo != UTC:
value = value.astimezone(UTC)
return value
def process_result_param(self, value, dialect):
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
elif value.tzinfo != UTC:
value = value.astimezone(UTC)
return value
def create_enum_type_decorator(enum_type: type[T]):
class EnumTypeDecorator(TypeDecorator):
impl = String
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
return value.value
def process_result_param(self, value, dialect):
if value:
return enum_type[value]
return EnumTypeDecorator
def row2dict(row):
d = {}
for column in row.__table__.columns:
d[column.name] = getattr(row, column.name)
return d

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter
from openhands.app_server.app_conversation import app_conversation_router
from openhands.app_server.event import event_router
from openhands.app_server.event_callback import (
webhook_router,
)
from openhands.app_server.sandbox import sandbox_router, sandbox_spec_router
from openhands.app_server.user import user_router
# Include routers
router = APIRouter(prefix='/api/v1')
router.include_router(event_router.router)
router.include_router(app_conversation_router.router)
router.include_router(sandbox_router.router)
router.include_router(sandbox_spec_router.router)
router.include_router(user_router.router)
router.include_router(webhook_router.router)

View File

@@ -30,7 +30,9 @@ def main():
args = parser.parse_args()
if hasattr(args, 'version') and args.version:
print(f'OpenHands CLI version: {openhands.get_version()}')
from openhands import get_version
print(f'OpenHands CLI version: {get_version()}')
sys.exit(0)
if args.command == 'serve':

View File

@@ -167,11 +167,12 @@ def handle_fast_commands() -> bool:
# Handle --version or -v
if len(sys.argv) == 2 and sys.argv[1] in ('--version', '-v'):
import openhands
from openhands import get_version
print(f'OpenHands CLI version: {get_version()}')
display_deprecation_warning()
print(f'OpenHands CLI version: {openhands.get_version()}')
return True
return False

View File

@@ -12,7 +12,6 @@ import toml
from dotenv import load_dotenv
from pydantic import BaseModel, SecretStr, ValidationError
from openhands import __version__
from openhands.core import logger
from openhands.core.config.agent_config import AgentConfig
from openhands.core.config.arg_utils import get_headless_parser
@@ -785,9 +784,10 @@ def parse_arguments() -> argparse.Namespace:
"""Parse command line arguments."""
parser = get_headless_parser()
args = parser.parse_args()
from openhands import get_version
if args.version:
print(f'OpenHands version: {__version__}')
print(f'OpenHands version: {get_version()}')
sys.exit(0)
return args

View File

@@ -411,6 +411,7 @@ LOQUACIOUS_LOGGERS = [
'socketio',
'socketio.client',
'socketio.server',
'aiosqlite',
]
for logger_name in LOQUACIOUS_LOGGERS:

View File

@@ -238,7 +238,7 @@ async def run_controller(
file_path = config.save_trajectory_path
os.makedirs(os.path.dirname(file_path), exist_ok=True)
histories = controller.get_trajectory(config.save_screenshots_in_trajectory)
with open(file_path, 'w') as f: # noqa: ASYNC101
with open(file_path, 'w') as f:
json.dump(histories, f, indent=4)
return state

View File

@@ -1,9 +1,9 @@
from dataclasses import dataclass
from typing import Any
import openhands
from openhands.core.schema import ActionType
from openhands.events.action.action import Action, ActionSecurityRisk
from openhands.version import get_version
@dataclass
@@ -48,7 +48,7 @@ class SystemMessageAction(Action):
content: str
tools: list[Any] | None = None
openhands_version: str | None = openhands.__version__
openhands_version: str | None = get_version()
agent_class: str | None = None
action: ActionType = ActionType.SYSTEM

View File

@@ -146,7 +146,7 @@ class ProviderHandler:
"""Read-only access to provider tokens."""
return self._provider_tokens
def _get_service(self, provider: ProviderType) -> GitService:
def get_service(self, provider: ProviderType) -> GitService:
"""Helper method to instantiate a service for a given provider"""
token = self.provider_tokens[provider]
service_class = self.service_class_map[provider]
@@ -163,7 +163,7 @@ class ProviderHandler:
"""Get user information from the first available provider"""
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service = self.get_service(provider)
return await service.get_user()
except Exception:
continue
@@ -196,7 +196,7 @@ class ProviderHandler:
return None
async def get_github_installations(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.GITHUB))
service = cast(InstallationsService, self.get_service(ProviderType.GITHUB))
try:
return await service.get_installations()
except Exception as e:
@@ -205,7 +205,7 @@ class ProviderHandler:
return []
async def get_bitbucket_workspaces(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.BITBUCKET))
service = cast(InstallationsService, self.get_service(ProviderType.BITBUCKET))
try:
return await service.get_installations()
except Exception as e:
@@ -231,7 +231,7 @@ class ProviderHandler:
if not page or not per_page:
raise ValueError('Failed to provider params for paginating repos')
service = self._get_service(selected_provider)
service = self.get_service(selected_provider)
return await service.get_paginated_repos(
page, per_page, sort, installation_id
)
@@ -239,7 +239,7 @@ class ProviderHandler:
all_repos: list[Repository] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service = self.get_service(provider)
service_repos = await service.get_all_repositories(sort, app_mode)
all_repos.extend(service_repos)
except Exception as e:
@@ -252,7 +252,7 @@ class ProviderHandler:
tasks: list[SuggestedTask] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service = self.get_service(provider)
service_repos = await service.get_suggested_tasks()
tasks.extend(service_repos)
except Exception as e:
@@ -269,7 +269,7 @@ class ProviderHandler:
) -> list[Branch]:
"""Search for branches within a repository using the appropriate provider service."""
if selected_provider:
service = self._get_service(selected_provider)
service = self.get_service(selected_provider)
try:
return await service.search_branches(repository, query, per_page)
except Exception as e:
@@ -281,7 +281,7 @@ class ProviderHandler:
# If provider not specified, determine provider by verifying repository access
try:
repo_details = await self.verify_repo_provider(repository)
service = self._get_service(repo_details.git_provider)
service = self.get_service(repo_details.git_provider)
return await service.search_branches(repository, query, per_page)
except Exception as e:
logger.warning(f'Error searching branches for {repository}: {e}')
@@ -296,7 +296,7 @@ class ProviderHandler:
order: str,
) -> list[Repository]:
if selected_provider:
service = self._get_service(selected_provider)
service = self.get_service(selected_provider)
public = self._is_repository_url(query, selected_provider)
user_repos = await service.search_repositories(
query, per_page, sort, order, public
@@ -306,7 +306,7 @@ class ProviderHandler:
all_repos: list[Repository] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service = self.get_service(provider)
public = self._is_repository_url(query, provider)
service_repos = await service.search_repositories(
query, per_page, sort, order, public
@@ -454,14 +454,14 @@ class ProviderHandler:
if specified_provider:
try:
service = self._get_service(specified_provider)
service = self.get_service(specified_provider)
return await service.get_repository_details_from_repo_name(repository)
except Exception as e:
errors.append(f'{specified_provider.value}: {str(e)}')
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service = self.get_service(provider)
return await service.get_repository_details_from_repo_name(repository)
except Exception as e:
errors.append(f'{provider.value}: {str(e)}')
@@ -504,7 +504,7 @@ class ProviderHandler:
"""
if specified_provider:
try:
service = self._get_service(specified_provider)
service = self.get_service(specified_provider)
return await service.get_paginated_branches(repository, page, per_page)
except Exception as e:
logger.warning(
@@ -513,7 +513,7 @@ class ProviderHandler:
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service = self.get_service(provider)
return await service.get_paginated_branches(repository, page, per_page)
except Exception as e:
logger.warning(f'Error fetching branches from {provider}: {e}')
@@ -543,7 +543,7 @@ class ProviderHandler:
errors = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service = self.get_service(provider)
result = await service.get_microagents(repository)
# Only return early if we got a non-empty result
if result:
@@ -587,7 +587,7 @@ class ProviderHandler:
errors = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service = self.get_service(provider)
result = await service.get_microagent_content(repository, file_path)
# If we got content, return it immediately
if result:
@@ -691,7 +691,7 @@ class ProviderHandler:
True if PR is active (open), False if closed/merged, True if can't determine
"""
try:
service = self._get_service(git_provider)
service = self.get_service(git_provider)
return await service.is_pr_open(repository, pr_number)
except Exception as e:

View File

@@ -571,7 +571,7 @@ class IssueResolver:
# checkout the repo
repo_dir = os.path.join(self.output_dir, 'repo')
if not os.path.exists(repo_dir):
checkout_output = subprocess.check_output( # noqa: ASYNC101
checkout_output = subprocess.check_output(
[
'git',
'clone',
@@ -584,7 +584,7 @@ class IssueResolver:
# get the commit id of current repo for reproducibility
base_commit = (
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa: ASYNC101
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir)
.decode('utf-8')
.strip()
)
@@ -596,7 +596,7 @@ class IssueResolver:
repo_dir, '.openhands_instructions'
)
if os.path.exists(openhands_instructions_path):
with open(openhands_instructions_path, 'r') as f: # noqa: ASYNC101
with open(openhands_instructions_path, 'r') as f:
self.repo_instruction = f.read()
# OUTPUT FILE
@@ -605,7 +605,7 @@ class IssueResolver:
# Check if this issue was already processed
if os.path.exists(output_file):
with open(output_file, 'r') as f: # noqa: ASYNC101
with open(output_file, 'r') as f:
for line in f:
data = ResolverOutput.model_validate_json(line)
if data.issue.number == self.issue_number:
@@ -614,7 +614,7 @@ class IssueResolver:
)
return
output_fp = open(output_file, 'a') # noqa: ASYNC101
output_fp = open(output_file, 'a')
logger.info(
f'Resolving issue {self.issue_number} with Agent {AGENT_CLASS}, model {model_name}, max iterations {self.max_iterations}.'
@@ -633,20 +633,20 @@ class IssueResolver:
# Fetch the branch first to ensure it exists locally
fetch_cmd = ['git', 'fetch', 'origin', branch_to_use]
subprocess.check_output( # noqa: ASYNC101
subprocess.check_output(
fetch_cmd,
cwd=repo_dir,
)
# Checkout the branch
checkout_cmd = ['git', 'checkout', branch_to_use]
subprocess.check_output( # noqa: ASYNC101
subprocess.check_output(
checkout_cmd,
cwd=repo_dir,
)
base_commit = (
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa: ASYNC101
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir)
.decode('utf-8')
.strip()
)

View File

@@ -5,12 +5,12 @@ import time
import docker
from openhands import __version__ as oh_version
from openhands.core.exceptions import AgentRuntimeBuildError
from openhands.core.logger import RollingLogger
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder.base import RuntimeBuilder
from openhands.utils.term_color import TermColor, colorize
from openhands.version import get_version
class DockerRuntimeBuilder(RuntimeBuilder):
@@ -131,7 +131,7 @@ class DockerRuntimeBuilder(RuntimeBuilder):
'buildx',
'build',
'--progress=plain',
f'--build-arg=OPENHANDS_RUNTIME_VERSION={oh_version}',
f'--build-arg=OPENHANDS_RUNTIME_VERSION={get_version()}',
f'--build-arg=OPENHANDS_RUNTIME_BUILD_TIME={datetime.datetime.now().isoformat()}',
f'--tag={target_image_hash_name}',
'--load',

View File

@@ -78,7 +78,7 @@ class JupyterPlugin(Plugin):
# Using synchronous subprocess.Popen for Windows as asyncio.create_subprocess_shell
# has limitations on Windows platforms
self.gateway_process = subprocess.Popen( # type: ignore[ASYNC101] # noqa: ASYNC101
self.gateway_process = subprocess.Popen( # type: ignore[ASYNC101]
jupyter_launch_command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@@ -91,19 +91,19 @@ class JupyterPlugin(Plugin):
output = ''
while should_continue():
if self.gateway_process.stdout is None:
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
time.sleep(1) # type: ignore[ASYNC101]
continue
line = self.gateway_process.stdout.readline()
if not line:
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
time.sleep(1) # type: ignore[ASYNC101]
continue
output += line
if 'at' in line:
break
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
time.sleep(1) # type: ignore[ASYNC101]
logger.debug('Waiting for jupyter kernel gateway to start...')
logger.debug(

View File

@@ -231,7 +231,7 @@ class BashSession:
# Set history limit to a large number to avoid losing history
# https://unix.stackexchange.com/questions/43414/unlimited-history-in-tmux
self.session.set_option('history-limit', str(self.HISTORY_LIMIT), _global=True)
self.session.set_option('history-limit', str(self.HISTORY_LIMIT), global_=True)
self.session.history_limit = self.HISTORY_LIMIT
# We need to create a new pane because the initial pane's history limit is (default) 2000
_initial_window = self.session.active_window

View File

@@ -86,7 +86,7 @@ async def read_file(
)
try:
with open(whole_path, 'r', encoding='utf-8') as file: # noqa: ASYNC101
with open(whole_path, 'r', encoding='utf-8') as file:
lines = read_lines(file.readlines(), start, end)
except FileNotFoundError:
return ErrorObservation(f'File not found: {path}')
@@ -127,7 +127,7 @@ async def write_file(
os.makedirs(os.path.dirname(whole_path))
mode = 'w' if not os.path.exists(whole_path) else 'r+'
try:
with open(whole_path, mode, encoding='utf-8') as file: # noqa: ASYNC101
with open(whole_path, mode, encoding='utf-8') as file:
if mode != 'w':
all_lines = file.readlines()
new_file = insert_lines(insert, all_lines, start, end)

View File

@@ -12,10 +12,10 @@ from dirhash import dirhash
from jinja2 import Environment, FileSystemLoader
import openhands
from openhands import __version__ as oh_version
from openhands.core.exceptions import AgentRuntimeBuildError
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.builder import DockerRuntimeBuilder, RuntimeBuilder
from openhands.version import get_version
class BuildFromImageType(Enum):
@@ -93,11 +93,11 @@ def get_runtime_image_repo_and_tag(base_image: str) -> tuple[str, str]:
repo = f'{repo_hash}_{repo[-24:]}' # Use 8 char hash + last 24 chars
repo = repo.replace('/', '_s_')
new_tag = f'oh_v{oh_version}_image_{repo}_tag_{tag}'
new_tag = f'oh_v{get_version()}_image_{repo}_tag_{tag}'
# if it's still too long, hash the entire image name
if len(new_tag) > 128:
new_tag = f'oh_v{oh_version}_image_{hashlib.md5(new_tag.encode()).hexdigest()[:64]}'
new_tag = f'oh_v{get_version()}_image_{hashlib.md5(new_tag.encode()).hexdigest()[:64]}'
logger.warning(
f'The new tag [{new_tag}] is still too long, so we use an hash of the entire image name: {new_tag}'
)
@@ -177,10 +177,12 @@ def build_runtime_image_in_folder(
enable_browser: bool = True,
) -> str:
runtime_image_repo, _ = get_runtime_image_repo_and_tag(base_image)
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image, enable_browser)}'
lock_tag = (
f'oh_v{get_version()}_{get_hash_for_lock_files(base_image, enable_browser)}'
)
versioned_tag = (
# truncate the base image to 96 characters to fit in the tag max length (128 characters)
f'oh_v{oh_version}_{get_tag_for_versioned_image(base_image)}'
f'oh_v{get_version()}_{get_tag_for_versioned_image(base_image)}'
)
versioned_image_name = f'{runtime_image_repo}:{versioned_tag}'
source_tag = f'{lock_tag}_{get_hash_for_source_files()}'

View File

@@ -15,7 +15,8 @@ from fastapi import (
from fastapi.responses import JSONResponse
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.app_server import v1_router
from openhands.app_server.config import get_app_lifespan_service
from openhands.integrations.service_types import AuthenticationError
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
@@ -33,6 +34,7 @@ from openhands.server.routes.settings import app as settings_router
from openhands.server.routes.trajectory import app as trajectory_router
from openhands.server.shared import conversation_manager, server_config
from openhands.server.types import AppMode
from openhands.version import get_version
mcp_app = mcp_server.http_app(path='/mcp')
@@ -55,11 +57,17 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
yield
lifespans = [_lifespan, mcp_app.lifespan]
app_lifespan_ = get_app_lifespan_service()
if app_lifespan_:
lifespans.append(app_lifespan_.lifespan)
app = FastAPI(
title='OpenHands',
description='OpenHands: Code Less, Make More',
version=__version__,
lifespan=combine_lifespans(_lifespan, mcp_app.lifespan),
version=get_version(),
lifespan=combine_lifespans(*lifespans),
routes=[Mount(path='/mcp', app=mcp_app)],
)
@@ -82,5 +90,6 @@ app.include_router(settings_router)
app.include_router(secrets_router)
if server_config.app_mode == AppMode.OSS:
app.include_router(git_api_router)
app.include_router(v1_router.router)
app.include_router(trajectory_router)
add_health_endpoints(app)

View File

@@ -62,6 +62,7 @@ class DockerNestedConversationManager(ConversationManager):
async def __aenter__(self):
runtime_cls = get_runtime_cls(self.config.runtime)
runtime_cls.setup(self.config)
return self
async def __aexit__(self, exc_type, exc_value, traceback):
runtime_cls = get_runtime_cls(self.config.runtime)

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