diff --git a/skyvern/library/local_server_runner.py b/skyvern/library/local_server_runner.py new file mode 100644 index 00000000..eccc2eab --- /dev/null +++ b/skyvern/library/local_server_runner.py @@ -0,0 +1,131 @@ +import asyncio +import atexit +import socket +import threading + +import structlog +import uvicorn + +from skyvern.config import settings + +LOG = structlog.get_logger() + +# Global server tracker for cleanup +_server: uvicorn.Server | None = None +_server_thread: threading.Thread | None = None + + +def _is_port_in_use(port: int) -> bool: + """Check if a port is already in use.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("localhost", port)) + return False + except OSError: + return True + + +def _cleanup_on_exit() -> None: + """Synchronous cleanup handler for atexit.""" + global _server, _server_thread + + if _server is None: + return + + LOG.info("Shutting down local Skyvern server (atexit)...") + + # Signal server to exit + _server.should_exit = True + + # Wait for server thread to finish + if _server_thread is not None and _server_thread.is_alive(): + _server_thread.join(timeout=5.0) + + _server = None + _server_thread = None + + +async def _wait_for_server(port: int, timeout: float = 10.0, interval: float = 0.5) -> bool: + """Wait for the server to become available on the specified port.""" + start_time = asyncio.get_event_loop().time() + while asyncio.get_event_loop().time() - start_time < timeout: + if _is_port_in_use(port): + return True + await asyncio.sleep(interval) + return False + + +async def ensure_local_server_running() -> None: + """Ensure a local Skyvern server is running. + + If the server is not running, starts it in a separate thread with its own event loop. + The server will automatically stop when the process exits. + """ + global _server, _server_thread + + port = settings.PORT + + # Check if server is already running + if _is_port_in_use(port): + LOG.info(f"Local Skyvern server already running on port {port}") + return + + # Check if we already have a server instance + if _server is not None: + LOG.info("Local Skyvern server already started by this process") + return + + # Server not running, start it in a separate thread + LOG.info(f"Starting local Skyvern server on port {port}...") + + # Import here to avoid circular imports + from skyvern.forge.api_app import app # noqa: PLC0415 + + # Create uvicorn server configuration (disable reload in programmatic mode) + uvicorn_config = uvicorn.Config( + app=app, + host="127.0.0.1", + port=port, + log_level="info", + reload=False, + access_log=False, + ) + + _server = uvicorn.Server(uvicorn_config) + + # Run server in a separate thread with its own event loop + def _run_server_in_thread() -> None: + """Run the server in a separate thread with its own event loop.""" + asyncio.run(_server.serve()) + + _server_thread = threading.Thread(target=_run_server_in_thread, daemon=True, name="skyvern-server") + _server_thread.start() + + # Register atexit handler to ensure cleanup + atexit.register(_cleanup_on_exit) + + # Wait for server to start + if await _wait_for_server(port, timeout=10.0): + LOG.info("Local Skyvern server started successfully") + else: + LOG.error("Failed to start local Skyvern server (timeout)") + await _stop_local_server() + raise RuntimeError(f"Local Skyvern server failed to start on port {port}") + + +async def _stop_local_server() -> None: + """Stop the local server if it was started by this process.""" + global _server, _server_thread + + if _server is not None: + LOG.info("Shutting down local Skyvern server...") + _server.should_exit = True + + # Wait for server thread to finish (in a thread pool to avoid blocking event loop) + if _server_thread is not None and _server_thread.is_alive(): + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _server_thread.join, 5.0) + + _server_thread = None + _server = None + LOG.info("Local Skyvern server stopped") diff --git a/skyvern/library/skyvern_browser.py b/skyvern/library/skyvern_browser.py index f78686dd..154b0c32 100644 --- a/skyvern/library/skyvern_browser.py +++ b/skyvern/library/skyvern_browser.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING + from playwright.async_api import BrowserContext, Page from skyvern.client import AsyncSkyvern from skyvern.library.skyvern_browser_page import SkyvernBrowserPage +if TYPE_CHECKING: + from skyvern.library.skyvern_sdk import SkyvernSdk + class SkyvernBrowser: """A browser context wrapper that creates Skyvern-enabled pages. @@ -33,16 +38,16 @@ class SkyvernBrowser: def __init__( self, + sdk: "SkyvernSdk", browser_context: BrowserContext, - client: AsyncSkyvern, *, browser_session_id: str | None = None, browser_address: str | None = None, ): + self._sdk = sdk self._browser_context = browser_context self._browser_session_id = browser_session_id self._browser_address = browser_address - self._client = client self.workflow_run_id: None | str = None @@ -56,7 +61,11 @@ class SkyvernBrowser: @property def client(self) -> AsyncSkyvern: - return self._client + return self._sdk.api + + @property + def sdk(self) -> "SkyvernSdk": + return self._sdk async def get_working_page(self) -> SkyvernBrowserPage: """Get the most recent page or create a new one if none exists. diff --git a/skyvern/library/skyvern_browser_page.py b/skyvern/library/skyvern_browser_page.py index 35667b2e..c92f0b33 100644 --- a/skyvern/library/skyvern_browser_page.py +++ b/skyvern/library/skyvern_browser_page.py @@ -7,7 +7,7 @@ from skyvern.client import GetRunResponse from skyvern.client.types.workflow_run_response import WorkflowRunResponse from skyvern.config import settings from skyvern.library.constants import DEFAULT_AGENT_HEARTBEAT_INTERVAL, DEFAULT_AGENT_TIMEOUT -from skyvern.library.SdkSkyvernPageAi import SdkSkyvernPageAi +from skyvern.library.skyvern_browser_page_ai import SdkSkyvernPageAi from skyvern.library.skyvern_locator import SkyvernLocator from skyvern.webeye.actions import handler_utils @@ -67,6 +67,7 @@ class SkyvernPageRun: TaskRunResponse containing the task execution results. """ + await self._browser.sdk.ensure_has_server() task_run = await self._browser.client.run_task( prompt=prompt, engine=engine, @@ -125,6 +126,7 @@ class SkyvernPageRun: WorkflowRunResponse containing the login workflow execution results. """ + await self._browser.sdk.ensure_has_server() workflow_run = await self._browser.client.login( credential_type=credential_type, url=url or self._get_page_url(), @@ -171,6 +173,8 @@ class SkyvernPageRun: Returns: WorkflowRunResponse containing the workflow execution results. """ + + await self._browser.sdk.ensure_has_server() workflow_run = await self._browser.client.run_workflow( workflow_id=workflow_id, parameters=parameters, diff --git a/skyvern/library/SdkSkyvernPageAi.py b/skyvern/library/skyvern_browser_page_ai.py similarity index 95% rename from skyvern/library/SdkSkyvernPageAi.py rename to skyvern/library/skyvern_browser_page_ai.py index faa8dc63..fce62005 100644 --- a/skyvern/library/SdkSkyvernPageAi.py +++ b/skyvern/library/skyvern_browser_page_ai.py @@ -30,6 +30,7 @@ class SdkSkyvernPageAi(SkyvernPageAi): ) -> str: """Click an element using AI via API call.""" + await self._browser.sdk.ensure_has_server() response = await self._browser.client.run_sdk_action( url=self._page.url, browser_session_id=self._browser.browser_session_id, @@ -57,6 +58,7 @@ class SdkSkyvernPageAi(SkyvernPageAi): ) -> str: """Input text into an element using AI via API call.""" + await self._browser.sdk.ensure_has_server() response = await self._browser.client.run_sdk_action( url=self._page.url, action=SdkAction_AiInputText( @@ -85,6 +87,7 @@ class SdkSkyvernPageAi(SkyvernPageAi): ) -> str: """Select an option from a dropdown using AI via API call.""" + await self._browser.sdk.ensure_has_server() response = await self._browser.client.run_sdk_action( url=self._page.url, action=SdkAction_AiSelectOption( @@ -121,6 +124,7 @@ class SdkSkyvernPageAi(SkyvernPageAi): ) -> dict[str, Any] | list | str | None: """Extract information from the page using AI via API call.""" + await self._browser.sdk.ensure_has_server() response = await self._browser.client.run_sdk_action( url=self._page.url, action=SdkAction_Extract( diff --git a/skyvern/library/skyvern_sdk.py b/skyvern/library/skyvern_sdk.py index fe7414b7..2928099e 100644 --- a/skyvern/library/skyvern_sdk.py +++ b/skyvern/library/skyvern_sdk.py @@ -6,6 +6,7 @@ from playwright.async_api import Playwright, async_playwright from skyvern.client import AsyncSkyvern, BrowserSessionResponse, SkyvernEnvironment from skyvern.library.constants import DEFAULT_CDP_PORT +from skyvern.library.local_server_runner import ensure_local_server_running from skyvern.library.skyvern_browser import SkyvernBrowser @@ -87,15 +88,17 @@ class SkyvernSdk: Exception: If no API key is provided and no .env file exists. """ + self._environment = environment + if api_key is None: if os.path.exists(".env"): load_dotenv(".env") + elif environment == SkyvernEnvironment.LOCAL: + raise ValueError("Please run `skyvern quickstart` to set up your local Skyvern environment") env_key = os.getenv("SKYVERN_API_KEY") if not env_key: - raise ValueError( - "SKYVERN_API_KEY is not set. Provide api_key or set SKYVERN_API_KEY in environment/.env." - ) + raise ValueError("SKYVERN_API_KEY is not set. Provide api_key or set SKYVERN_API_KEY in .env file.") self._api_key = env_key else: self._api_key = api_key @@ -110,6 +113,7 @@ class SkyvernSdk: ) self._playwright: Playwright | None = None + self._verified_has_server: bool = False @property def api(self) -> AsyncSkyvern: @@ -136,7 +140,7 @@ class SkyvernSdk: ) browser_address = f"http://localhost:{port}" browser_context = browser.contexts[0] if browser.contexts else await browser.new_context() - return SkyvernBrowser(browser_context, self._api, browser_address=browser_address) + return SkyvernBrowser(self, browser_context, browser_address=browser_address) async def connect_to_browser_over_cdp(self, cdp_url: str) -> SkyvernBrowser: """Connect to an existing browser instance via Chrome DevTools Protocol (CDP). @@ -153,7 +157,7 @@ class SkyvernSdk: playwright = await self._get_playwright() browser = await playwright.chromium.connect_over_cdp(cdp_url) browser_context = browser.contexts[0] if browser.contexts else await browser.new_context() - return SkyvernBrowser(browser_context, self._api, browser_address=cdp_url) + return SkyvernBrowser(self, browser_context, browser_address=cdp_url) async def connect_to_cloud_browser_session(self, browser_session_id: str) -> SkyvernBrowser: """Connect to an existing cloud-hosted browser session by ID. @@ -196,6 +200,15 @@ class SkyvernSdk: browser_session = await self._api.create_browser_session() return await self._connect_to_cloud_browser_session(browser_session) + async def ensure_has_server(self) -> None: + if self._verified_has_server: + return + + if self._environment == SkyvernEnvironment.LOCAL: + await ensure_local_server_running() + + self._verified_has_server = True + async def _connect_to_cloud_browser_session(self, browser_session: BrowserSessionResponse) -> SkyvernBrowser: if browser_session.browser_address is None: raise Exception(f"Browser address is missing for session {browser_session.browser_session_id}") @@ -205,7 +218,7 @@ class SkyvernSdk: browser_session.browser_address, headers={"x-api-key": self._api_key} ) browser_context = browser.contexts[0] if browser.contexts else await browser.new_context() - return SkyvernBrowser(browser_context, self._api, browser_session_id=browser_session.browser_session_id) + return SkyvernBrowser(self, browser_context, browser_session_id=browser_session.browser_session_id) async def _get_playwright(self) -> Playwright: if self._playwright is None: