Compare commits

...

5 Commits

Author SHA1 Message Date
openhands
68a3464a28 Revert "Refactor cleanup_stale_device_codes to use modern SQLAlchemy 2.0 select() API"
This reverts commit 53871f206b.
2025-12-18 20:47:59 +00:00
openhands
53871f206b Refactor cleanup_stale_device_codes to use modern SQLAlchemy 2.0 select() API
Replace legacy session.query().filter().limit().all() pattern with the modern
select().where().limit() + execute().scalars().all() pattern, which is more
idiomatic and consistent with other parts of the codebase (e.g., gitlab_webhook_store.py).

Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-18 20:40:04 +00:00
openhands
4f0bf5d474 Fix linting issues
- Remove unused imports (datetime, timezone)
- Fix trailing whitespace and end-of-file formatting
- Improve code formatting for long function signatures
- Apply ruff formatting standards

Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-18 18:15:58 +00:00
openhands
fc758f2bdf Add cleanup job for device code store
- Add cleanup_stale_device_codes method to DeviceCodeStore class
- Create clean_device_code_table.py script in enterprise/sync directory
- Add comprehensive unit tests for cleanup functionality
- Cleanup removes expired device codes (past expires_at time)
- Uses simple limit-based approach without batching loops

Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-18 18:11:40 +00:00
Wang Siyuan
d90579b398 fix: make local runtime use host-writable paths and local cache defaults (#12015)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-12-18 17:31:12 +01:00
5 changed files with 163 additions and 1 deletions

View File

@@ -4,9 +4,12 @@ import secrets
import string
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError
from storage.device_code import DeviceCode
from openhands.core.logger import openhands_logger as logger
class DeviceCodeStore:
"""Store for managing OAuth 2.0 device codes."""
@@ -165,3 +168,38 @@ class DeviceCodeStore:
session.commit()
return True
def cleanup_stale_device_codes(self, limit: int = 100) -> int:
"""Clean up expired device codes based on oldest creation dates.
Removes device codes that are expired (past their expires_at time).
Args:
limit: Maximum number of codes to delete
Returns:
Total number of device codes deleted
"""
with self.session_maker() as session:
# Get expired device codes, ordered by oldest first (using ID as proxy for creation order)
expired_codes = (
session.query(DeviceCode)
.filter(DeviceCode.expires_at < datetime.now(timezone.utc))
.order_by(DeviceCode.id.asc())
.limit(limit)
.all()
)
if not expired_codes:
logger.info('No expired device codes found')
return 0
# Delete the expired codes
code_ids = [code.id for code in expired_codes]
delete_stmt = delete(DeviceCode).where(DeviceCode.id.in_(code_ids))
result = session.execute(delete_stmt)
session.commit()
deleted_count = result.rowcount
logger.info(f'Deleted {deleted_count} expired device codes')
return deleted_count

View File

@@ -0,0 +1,30 @@
"""
Cleanup script for device code table.
This script removes expired device codes from the database. Device codes are considered
expired if they are past their expires_at time.
The cleanup is limited to a maximum number of codes to avoid overwhelming the database.
Usage:
python sync/clean_device_code_table.py
This script should be run periodically (e.g., via cron job) to maintain database hygiene.
"""
import asyncio
from storage.database import session_maker
from storage.device_code_store import DeviceCodeStore
LIMIT = 100 # Maximum number of device codes to delete
async def main():
device_code_store = DeviceCodeStore(session_maker)
deleted_count = device_code_store.cleanup_stale_device_codes(limit=LIMIT)
print(f'Cleanup completed. Deleted {deleted_count} expired device codes.')
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -191,3 +191,65 @@ class TestDeviceCodeStore:
assert result is True
mock_device.deny.assert_called_once()
mock_session.commit.assert_called_once()
def test_cleanup_stale_device_codes_empty(self, device_code_store, mock_session):
"""Test cleanup when no expired device codes exist."""
# Mock empty query result
mock_session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = []
result = device_code_store.cleanup_stale_device_codes(limit=50)
assert result == 0
mock_session.query.assert_called_once_with(DeviceCode)
def test_cleanup_stale_device_codes_with_data(
self, device_code_store, mock_session
):
"""Test cleanup when expired device codes exist."""
# Create mock device codes
mock_device1 = MagicMock()
mock_device1.id = 1
mock_device2 = MagicMock()
mock_device2.id = 2
# Mock query result with 2 expired codes
mock_session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
mock_device1,
mock_device2,
]
# Mock the delete execution result
mock_result = MagicMock()
mock_result.rowcount = 2
mock_session.execute.return_value = mock_result
result = device_code_store.cleanup_stale_device_codes(limit=50)
assert result == 2
mock_session.execute.assert_called_once()
mock_session.commit.assert_called_once()
def test_cleanup_stale_device_codes_with_limit(
self, device_code_store, mock_session
):
"""Test cleanup respects the limit parameter."""
# Create mock device codes
mock_devices = [MagicMock(id=i) for i in range(1, 4)] # 3 codes
# Mock query result with 3 expired codes
mock_session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = mock_devices
# Mock the delete execution result
mock_result = MagicMock()
mock_result.rowcount = 3
mock_session.execute.return_value = mock_result
result = device_code_store.cleanup_stale_device_codes(limit=3)
assert result == 3
mock_session.execute.assert_called_once()
mock_session.commit.assert_called_once()
# Verify the limit was applied in the query
mock_session.query.return_value.filter.return_value.order_by.return_value.limit.assert_called_with(
3
)

View File

@@ -1,8 +1,10 @@
import atexit
import json
import multiprocessing
import os
import time
import uuid
from pathlib import Path
import browsergym.core # noqa F401 (we register the openended task as a gym environment)
import gymnasium as gym
@@ -67,6 +69,16 @@ class BrowserEnv:
raise BrowserInitException('Failed to start browser environment.')
def browser_process(self) -> None:
def _is_local_runtime() -> bool:
runtime_flag = os.getenv('RUNTIME', '').lower()
return runtime_flag == 'local'
# Default Playwright cache for local runs only; do not override in docker
if _is_local_runtime() and 'PLAYWRIGHT_BROWSERS_PATH' not in os.environ:
os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(
Path.home() / '.cache' / 'playwright'
)
if self.eval_mode:
assert self.browsergym_eval_env is not None
logger.info('Initializing browser env for web browsing evaluation.')
@@ -87,6 +99,11 @@ class BrowserEnv:
)
env = gym.make(self.browsergym_eval_env, tags_to_mark='all', timeout=100000)
else:
downloads_path = os.getenv('BROWSERGYM_DOWNLOAD_DIR')
if not downloads_path and _is_local_runtime():
downloads_path = str(Path.home() / '.cache' / 'browsergym-downloads')
if not downloads_path:
downloads_path = '/workspace/.downloads/'
env = gym.make(
'browsergym/openended',
task_kwargs={'start_url': 'about:blank', 'goal': 'PLACEHOLDER_GOAL'},
@@ -96,7 +113,7 @@ class BrowserEnv:
tags_to_mark='all',
timeout=100000,
pw_context_kwargs={'accept_downloads': True},
pw_chromium_kwargs={'downloads_path': '/workspace/.downloads/'},
pw_chromium_kwargs={'downloads_path': downloads_path},
)
obs, info = env.reset()

View File

@@ -249,7 +249,22 @@ class LocalRuntime(ActionExecutionClient):
)
else:
# Set up workspace directory
# For local runtime, prefer a stable host path over /workspace defaults.
if (
self.config.workspace_base is None
and self.config.runtime
and self.config.runtime.lower() == 'local'
):
env_base = os.getenv('LOCAL_WORKSPACE_BASE')
if env_base:
self.config.workspace_base = os.path.abspath(env_base)
else:
self.config.workspace_base = os.path.abspath(
os.path.join(os.getcwd(), 'workspace', 'local')
)
if self.config.workspace_base is not None:
os.makedirs(self.config.workspace_base, exist_ok=True)
logger.warning(
f'Workspace base path is set to {self.config.workspace_base}. '
'It will be used as the path for the agent to run in. '