openhands-automation 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. openhands/automation/__init__.py +3 -0
  2. openhands/automation/app.py +332 -0
  3. openhands/automation/auth.py +444 -0
  4. openhands/automation/backends/__init__.py +68 -0
  5. openhands/automation/backends/base.py +145 -0
  6. openhands/automation/backends/cloud.py +332 -0
  7. openhands/automation/backends/local.py +206 -0
  8. openhands/automation/config.py +671 -0
  9. openhands/automation/constants.py +86 -0
  10. openhands/automation/db.py +249 -0
  11. openhands/automation/dispatcher.py +496 -0
  12. openhands/automation/event_router.py +192 -0
  13. openhands/automation/event_schemas/__init__.py +132 -0
  14. openhands/automation/event_schemas/bitbucket_data_center.py +38 -0
  15. openhands/automation/event_schemas/custom.py +108 -0
  16. openhands/automation/event_schemas/detection.py +136 -0
  17. openhands/automation/event_schemas/github.py +483 -0
  18. openhands/automation/event_schemas/jira_dc.py +35 -0
  19. openhands/automation/exceptions.py +40 -0
  20. openhands/automation/execution.py +591 -0
  21. openhands/automation/filter_eval.py +192 -0
  22. openhands/automation/kv_helpers.py +399 -0
  23. openhands/automation/kv_metrics.py +114 -0
  24. openhands/automation/kv_router.py +1139 -0
  25. openhands/automation/kv_schemas.py +249 -0
  26. openhands/automation/logger.py +115 -0
  27. openhands/automation/middleware.py +69 -0
  28. openhands/automation/migrations/env.py +160 -0
  29. openhands/automation/migrations/script.py.mako +25 -0
  30. openhands/automation/migrations/versions/001_initial_schema.py +129 -0
  31. openhands/automation/migrations/versions/002_tarball_uploads.py +64 -0
  32. openhands/automation/migrations/versions/003_event_triggers.py +92 -0
  33. openhands/automation/migrations/versions/004_add_prompt_column.py +29 -0
  34. openhands/automation/migrations/versions/005_add_bash_command_id.py +37 -0
  35. openhands/automation/migrations/versions/006_add_model.py +25 -0
  36. openhands/automation/migrations/versions/007_add_automation_run_composite_indexes.py +44 -0
  37. openhands/automation/migrations/versions/008_add_kv_store.py +109 -0
  38. openhands/automation/migrations/versions/009_add_sandbox_cleanup_policy.py +47 -0
  39. openhands/automation/models.py +392 -0
  40. openhands/automation/preset_router.py +851 -0
  41. openhands/automation/presets/__init__.py +9 -0
  42. openhands/automation/presets/plugin/sdk_main.py +430 -0
  43. openhands/automation/presets/plugin/setup.sh +48 -0
  44. openhands/automation/presets/prompt/sdk_main.py +379 -0
  45. openhands/automation/presets/prompt/setup.sh +48 -0
  46. openhands/automation/router.py +548 -0
  47. openhands/automation/scheduler.py +205 -0
  48. openhands/automation/schemas.py +650 -0
  49. openhands/automation/storage/__init__.py +18 -0
  50. openhands/automation/storage/factory.py +36 -0
  51. openhands/automation/storage/file_store.py +64 -0
  52. openhands/automation/storage/google_cloud.py +195 -0
  53. openhands/automation/storage/local.py +125 -0
  54. openhands/automation/storage/s3.py +340 -0
  55. openhands/automation/trigger_matcher.py +89 -0
  56. openhands/automation/uploads.py +323 -0
  57. openhands/automation/utils/__init__.py +26 -0
  58. openhands/automation/utils/agent_server.py +184 -0
  59. openhands/automation/utils/api_key.py +98 -0
  60. openhands/automation/utils/cron.py +143 -0
  61. openhands/automation/utils/kv.py +218 -0
  62. openhands/automation/utils/log_context.py +31 -0
  63. openhands/automation/utils/model_profiles.py +39 -0
  64. openhands/automation/utils/run.py +268 -0
  65. openhands/automation/utils/sandbox.py +211 -0
  66. openhands/automation/utils/tarball_validation.py +170 -0
  67. openhands/automation/utils/time.py +41 -0
  68. openhands/automation/utils/webhook.py +237 -0
  69. openhands/automation/watchdog.py +328 -0
  70. openhands/automation/webhook_router.py +275 -0
  71. openhands_automation-1.0.0.dist-info/METADATA +131 -0
  72. openhands_automation-1.0.0.dist-info/RECORD +74 -0
  73. openhands_automation-1.0.0.dist-info/WHEEL +4 -0
  74. openhands_automation-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """OpenHands automation service."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,332 @@
1
+ """FastAPI application entrypoint."""
2
+
3
+ import asyncio
4
+ import importlib.metadata
5
+ import logging
6
+ import os
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+
10
+ from fastapi import FastAPI
11
+ from fastapi.responses import JSONResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+ from sqlalchemy import text
14
+
15
+ from openhands.automation.auth import create_http_client
16
+ from openhands.automation.config import get_config, get_settings
17
+ from openhands.automation.db import (
18
+ create_engine,
19
+ create_session_factory,
20
+ set_sqlite_mode,
21
+ )
22
+ from openhands.automation.dispatcher import dispatcher_loop
23
+ from openhands.automation.event_router import router as event_router
24
+ from openhands.automation.kv_router import router as kv_router
25
+ from openhands.automation.logger import setup_all_loggers
26
+ from openhands.automation.middleware import ApiKeyAwareCORSMiddleware
27
+ from openhands.automation.preset_router import router as preset_router
28
+ from openhands.automation.router import router
29
+ from openhands.automation.scheduler import scheduler_loop
30
+ from openhands.automation.uploads import router as uploads_router
31
+ from openhands.automation.watchdog import watchdog_loop
32
+ from openhands.automation.webhook_router import router as webhook_router
33
+
34
+
35
+ logger = logging.getLogger("automation.app")
36
+
37
+
38
+ @asynccontextmanager
39
+ async def lifespan(app: FastAPI):
40
+ """Application startup/shutdown lifecycle."""
41
+ # Startup
42
+ settings = get_settings()
43
+
44
+ # Apply the repo-wide JSON structured-logging convention
45
+ setup_all_loggers()
46
+
47
+ # Silence noisy third-party loggers
48
+ for noisy_logger in (
49
+ "ddtrace",
50
+ "httpx",
51
+ "httpcore",
52
+ "sqlalchemy.engine", # Suppress SQL statement logging
53
+ ):
54
+ logging.getLogger(noisy_logger).setLevel(logging.WARNING)
55
+
56
+ logger.info(
57
+ "Starting OpenHands Automations Service",
58
+ extra={"kv_store_configured": get_config().kv.enabled},
59
+ )
60
+
61
+ # Create shared httpx client for auth (stored in app.state for DI)
62
+ app.state.http_client = create_http_client()
63
+
64
+ # Create engine and session factory, store in app.state
65
+ engine_result = await create_engine(settings)
66
+ app.state.engine_result = engine_result
67
+ app.state.engine = engine_result.engine
68
+ app.state.session_factory = create_session_factory(engine_result.engine)
69
+
70
+ # Set SQLite mode flag for scheduler/dispatcher to use
71
+ set_sqlite_mode(engine_result.is_sqlite)
72
+
73
+ # Auto-run migrations for SQLite on startup
74
+ # This ensures the schema is always up-to-date for local deployments
75
+ # For PostgreSQL, migrations are typically run separately via `alembic upgrade head`
76
+ if engine_result.is_sqlite:
77
+ from alembic import command
78
+ from alembic.config import Config
79
+
80
+ from openhands.automation.db import normalize_sqlite_url_for_alembic
81
+
82
+ # Find migrations folder relative to this package.
83
+ # When installed via pip/uvx, migrations are bundled inside
84
+ # automation/migrations.
85
+ package_dir = Path(__file__).parent
86
+ migrations_path = package_dir / "migrations"
87
+
88
+ if not migrations_path.is_dir():
89
+ # Fallback: check if running from source (migrations at repo root)
90
+ repo_root_migrations = package_dir.parent / "migrations"
91
+ if repo_root_migrations.is_dir():
92
+ migrations_path = repo_root_migrations
93
+ else:
94
+ msg = (
95
+ f"Migrations directory not found. "
96
+ f"Checked: {migrations_path}, {repo_root_migrations}"
97
+ )
98
+ raise RuntimeError(msg)
99
+
100
+ alembic_cfg = Config()
101
+ alembic_cfg.set_main_option("script_location", str(migrations_path))
102
+ # Set the database URL for Alembic to use (sync version)
103
+ db_url = normalize_sqlite_url_for_alembic(settings.db_url)
104
+ alembic_cfg.set_main_option("sqlalchemy.url", db_url)
105
+
106
+ # Run migrations synchronously (Alembic doesn't support async)
107
+ try:
108
+ command.upgrade(alembic_cfg, "head")
109
+ logger.info("SQLite database migrations applied successfully")
110
+ except Exception as e:
111
+ logger.error(f"Failed to apply SQLite migrations: {e}")
112
+ msg = f"SQLite migration failed. Database may be inconsistent: {e}"
113
+ raise RuntimeError(msg) from e
114
+
115
+ # Start the background scheduler and dispatcher
116
+ shutdown_event = asyncio.Event()
117
+ app.state.shutdown_event = shutdown_event
118
+
119
+ # Scheduler: polls automations and creates PENDING runs
120
+ scheduler_task = asyncio.create_task(
121
+ scheduler_loop(
122
+ app.state.session_factory,
123
+ interval_seconds=settings.scheduler_interval_seconds,
124
+ shutdown_event=shutdown_event,
125
+ )
126
+ )
127
+ app.state.scheduler_task = scheduler_task
128
+ logger.info("Background scheduler started")
129
+
130
+ # Dispatcher: picks up PENDING runs and dispatches them
131
+ if not settings.base_url:
132
+ logger.warning(
133
+ "AUTOMATION_BASE_URL not set — using localhost. "
134
+ "Sandboxes in the cloud won't be able to reach this URL."
135
+ )
136
+ dispatcher_task = asyncio.create_task(
137
+ dispatcher_loop(
138
+ app.state.session_factory,
139
+ settings=settings,
140
+ interval_seconds=settings.dispatcher_interval_seconds,
141
+ shutdown_event=shutdown_event,
142
+ )
143
+ )
144
+ app.state.dispatcher_task = dispatcher_task
145
+ logger.info("Background dispatcher started")
146
+
147
+ # Watchdog: marks stale RUNNING runs as FAILED
148
+ watchdog_task = asyncio.create_task(
149
+ watchdog_loop(
150
+ app.state.session_factory,
151
+ settings=settings,
152
+ shutdown_event=shutdown_event,
153
+ )
154
+ )
155
+ app.state.watchdog_task = watchdog_task
156
+ logger.info("Background watchdog started")
157
+
158
+ yield
159
+
160
+ # Shutdown
161
+ logger.info("Shutting down background tasks...")
162
+ shutdown_event.set()
163
+
164
+ # Wait for all tasks to exit gracefully
165
+ for task_name, task in [
166
+ ("scheduler", scheduler_task),
167
+ ("dispatcher", dispatcher_task),
168
+ ("watchdog", watchdog_task),
169
+ ]:
170
+ try:
171
+ await asyncio.wait_for(task, timeout=5.0)
172
+ except TimeoutError:
173
+ logger.warning("%s did not exit in time, cancelling", task_name)
174
+ task.cancel()
175
+ try:
176
+ await task
177
+ except asyncio.CancelledError:
178
+ pass
179
+
180
+ await app.state.http_client.aclose()
181
+ await app.state.engine_result.dispose()
182
+ logger.info("Automations service shut down")
183
+
184
+
185
+ def _build_cors_origins() -> list[str]:
186
+ """Build the list of allowed CORS origins from settings."""
187
+ settings = get_settings()
188
+ origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
189
+ if not origins:
190
+ origins = [settings.openhands_api_base_url]
191
+ return origins
192
+
193
+
194
+ def _create_app() -> FastAPI:
195
+ """Create and configure the FastAPI application."""
196
+ # Serve OpenAPI docs under the base path so they're accessible when the app
197
+ # is mounted at /api/automation (e.g., /api/automation/docs).
198
+ base_path = get_settings().base_path
199
+ return FastAPI(
200
+ title="OpenHands Automations Service",
201
+ description=(
202
+ "Scheduled and event-driven automation execution for OpenHands Cloud"
203
+ ),
204
+ version="0.1.0",
205
+ lifespan=lifespan,
206
+ docs_url=f"{base_path}/docs",
207
+ openapi_url=f"{base_path}/openapi.json",
208
+ redoc_url=f"{base_path}/redoc",
209
+ )
210
+
211
+
212
+ app = _create_app()
213
+
214
+ # API-key requests (e.g. the local agent-server GUI calling directly from the
215
+ # browser) get permissive CORS; cookie/session requests keep the strict
216
+ # allowlist below. Mirrors the main cloud API's ApiKeyAwareCORSMiddleware.
217
+ app.add_middleware(
218
+ ApiKeyAwareCORSMiddleware,
219
+ allow_origins=_build_cors_origins(),
220
+ )
221
+
222
+ _base_path = get_settings().base_path
223
+
224
+ # Include specific routers BEFORE main router to avoid route conflict.
225
+ # The main router has /v1/{automation_id} which would match any /v1/<path>
226
+ # and fail UUID validation.
227
+ app.include_router(uploads_router, prefix=_base_path)
228
+ app.include_router(preset_router, prefix=_base_path)
229
+ app.include_router(event_router, prefix=_base_path)
230
+ app.include_router(webhook_router, prefix=_base_path)
231
+ app.include_router(kv_router, prefix=_base_path)
232
+ app.include_router(router, prefix=_base_path)
233
+
234
+
235
+ # Static /health and /ready paths are a convenience for k8s probes — the fixed
236
+ # path requires less templating. Base-path endpoints are still available for
237
+ # publicly-routed traffic like integration tests.
238
+ @app.get("/health")
239
+ @app.get(f"{_base_path}/health")
240
+ async def health():
241
+ return {"status": "ok"}
242
+
243
+
244
+ @app.get("/ready")
245
+ @app.get(f"{_base_path}/ready")
246
+ async def readiness():
247
+ """Readiness probe — checks DB connectivity.
248
+
249
+ Returns 503 when the DB is unreachable so Kubernetes stops routing traffic.
250
+ """
251
+ try:
252
+ async with app.state.engine.connect() as conn:
253
+ await conn.execute(text("SELECT 1"))
254
+ return {"status": "ready"}
255
+ except Exception as e:
256
+ logger.error("Readiness check failed: %s", e, exc_info=True)
257
+ return JSONResponse(
258
+ status_code=503,
259
+ content={"status": "not_ready", "error": "database unavailable"},
260
+ )
261
+
262
+
263
+ @app.get("/sdk-version")
264
+ @app.get(f"{_base_path}/sdk-version")
265
+ async def sdk_version():
266
+ """Return the SDK version this service is running.
267
+
268
+ Called by setup.sh inside every automation sandbox to determine which
269
+ openhands-sdk version to install. No authentication required — the
270
+ version string is not sensitive and must be readable before credentials
271
+ are available in the sandbox.
272
+ """
273
+ try:
274
+ version = importlib.metadata.version("openhands-sdk")
275
+ except importlib.metadata.PackageNotFoundError:
276
+ return JSONResponse(
277
+ status_code=503,
278
+ content={"error": "openhands-sdk package not found"},
279
+ )
280
+ return {"version": version}
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Frontend static file hosting (opt-in via AUTOMATION_FRONTEND_DIR)
285
+ # ---------------------------------------------------------------------------
286
+ _settings = get_settings()
287
+ _frontend_dir = _settings.frontend_dir
288
+ if _frontend_dir:
289
+ _frontend_path = Path(_frontend_dir)
290
+ if not _frontend_path.is_dir():
291
+ logger.warning(
292
+ "AUTOMATION_FRONTEND_DIR=%s is not a directory — frontend hosting disabled",
293
+ _frontend_dir,
294
+ )
295
+ else:
296
+ _frontend_mount = _settings.frontend_path
297
+ logger.info("Serving frontend from %s at %s", _frontend_dir, _frontend_mount)
298
+
299
+ _index_full_path = str(_frontend_path / "index.html")
300
+ _index_stat = os.stat(_index_full_path)
301
+
302
+ class _SPAStaticFiles(StaticFiles):
303
+ """StaticFiles that falls back to index.html for SPA client routes."""
304
+
305
+ def lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
306
+ full_path, stat_result = super().lookup_path(path)
307
+ if stat_result is None:
308
+ # Unknown path → serve index.html for client-side routing
309
+ return _index_full_path, _index_stat
310
+ return full_path, stat_result
311
+
312
+ def file_response(self, full_path, stat_result, scope, status_code=200):
313
+ response = super().file_response(
314
+ full_path, stat_result, scope, status_code
315
+ )
316
+ # Hashed assets are immutable; everything else (especially
317
+ # index.html) must be revalidated on every request.
318
+ if "/assets/" in str(full_path):
319
+ response.headers["Cache-Control"] = (
320
+ "public, max-age=31536000, immutable"
321
+ )
322
+ else:
323
+ response.headers.setdefault(
324
+ "Cache-Control", "no-cache, must-revalidate"
325
+ )
326
+ return response
327
+
328
+ app.mount(
329
+ _frontend_mount,
330
+ _SPAStaticFiles(directory=_frontend_path, html=True),
331
+ name="frontend",
332
+ )