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.
- openhands/automation/__init__.py +3 -0
- openhands/automation/app.py +332 -0
- openhands/automation/auth.py +444 -0
- openhands/automation/backends/__init__.py +68 -0
- openhands/automation/backends/base.py +145 -0
- openhands/automation/backends/cloud.py +332 -0
- openhands/automation/backends/local.py +206 -0
- openhands/automation/config.py +671 -0
- openhands/automation/constants.py +86 -0
- openhands/automation/db.py +249 -0
- openhands/automation/dispatcher.py +496 -0
- openhands/automation/event_router.py +192 -0
- openhands/automation/event_schemas/__init__.py +132 -0
- openhands/automation/event_schemas/bitbucket_data_center.py +38 -0
- openhands/automation/event_schemas/custom.py +108 -0
- openhands/automation/event_schemas/detection.py +136 -0
- openhands/automation/event_schemas/github.py +483 -0
- openhands/automation/event_schemas/jira_dc.py +35 -0
- openhands/automation/exceptions.py +40 -0
- openhands/automation/execution.py +591 -0
- openhands/automation/filter_eval.py +192 -0
- openhands/automation/kv_helpers.py +399 -0
- openhands/automation/kv_metrics.py +114 -0
- openhands/automation/kv_router.py +1139 -0
- openhands/automation/kv_schemas.py +249 -0
- openhands/automation/logger.py +115 -0
- openhands/automation/middleware.py +69 -0
- openhands/automation/migrations/env.py +160 -0
- openhands/automation/migrations/script.py.mako +25 -0
- openhands/automation/migrations/versions/001_initial_schema.py +129 -0
- openhands/automation/migrations/versions/002_tarball_uploads.py +64 -0
- openhands/automation/migrations/versions/003_event_triggers.py +92 -0
- openhands/automation/migrations/versions/004_add_prompt_column.py +29 -0
- openhands/automation/migrations/versions/005_add_bash_command_id.py +37 -0
- openhands/automation/migrations/versions/006_add_model.py +25 -0
- openhands/automation/migrations/versions/007_add_automation_run_composite_indexes.py +44 -0
- openhands/automation/migrations/versions/008_add_kv_store.py +109 -0
- openhands/automation/migrations/versions/009_add_sandbox_cleanup_policy.py +47 -0
- openhands/automation/models.py +392 -0
- openhands/automation/preset_router.py +851 -0
- openhands/automation/presets/__init__.py +9 -0
- openhands/automation/presets/plugin/sdk_main.py +430 -0
- openhands/automation/presets/plugin/setup.sh +48 -0
- openhands/automation/presets/prompt/sdk_main.py +379 -0
- openhands/automation/presets/prompt/setup.sh +48 -0
- openhands/automation/router.py +548 -0
- openhands/automation/scheduler.py +205 -0
- openhands/automation/schemas.py +650 -0
- openhands/automation/storage/__init__.py +18 -0
- openhands/automation/storage/factory.py +36 -0
- openhands/automation/storage/file_store.py +64 -0
- openhands/automation/storage/google_cloud.py +195 -0
- openhands/automation/storage/local.py +125 -0
- openhands/automation/storage/s3.py +340 -0
- openhands/automation/trigger_matcher.py +89 -0
- openhands/automation/uploads.py +323 -0
- openhands/automation/utils/__init__.py +26 -0
- openhands/automation/utils/agent_server.py +184 -0
- openhands/automation/utils/api_key.py +98 -0
- openhands/automation/utils/cron.py +143 -0
- openhands/automation/utils/kv.py +218 -0
- openhands/automation/utils/log_context.py +31 -0
- openhands/automation/utils/model_profiles.py +39 -0
- openhands/automation/utils/run.py +268 -0
- openhands/automation/utils/sandbox.py +211 -0
- openhands/automation/utils/tarball_validation.py +170 -0
- openhands/automation/utils/time.py +41 -0
- openhands/automation/utils/webhook.py +237 -0
- openhands/automation/watchdog.py +328 -0
- openhands/automation/webhook_router.py +275 -0
- openhands_automation-1.0.0.dist-info/METADATA +131 -0
- openhands_automation-1.0.0.dist-info/RECORD +74 -0
- openhands_automation-1.0.0.dist-info/WHEEL +4 -0
- openhands_automation-1.0.0.dist-info/licenses/LICENSE +21 -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
|
+
)
|