SDK: auto-start server in local mode (#3869)
This commit is contained in:
committed by
GitHub
parent
9feb9e855c
commit
414366fff5
131
skyvern/library/local_server_runner.py
Normal file
131
skyvern/library/local_server_runner.py
Normal file
@@ -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")
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from playwright.async_api import BrowserContext, Page
|
from playwright.async_api import BrowserContext, Page
|
||||||
|
|
||||||
from skyvern.client import AsyncSkyvern
|
from skyvern.client import AsyncSkyvern
|
||||||
from skyvern.library.skyvern_browser_page import SkyvernBrowserPage
|
from skyvern.library.skyvern_browser_page import SkyvernBrowserPage
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from skyvern.library.skyvern_sdk import SkyvernSdk
|
||||||
|
|
||||||
|
|
||||||
class SkyvernBrowser:
|
class SkyvernBrowser:
|
||||||
"""A browser context wrapper that creates Skyvern-enabled pages.
|
"""A browser context wrapper that creates Skyvern-enabled pages.
|
||||||
@@ -33,16 +38,16 @@ class SkyvernBrowser:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
sdk: "SkyvernSdk",
|
||||||
browser_context: BrowserContext,
|
browser_context: BrowserContext,
|
||||||
client: AsyncSkyvern,
|
|
||||||
*,
|
*,
|
||||||
browser_session_id: str | None = None,
|
browser_session_id: str | None = None,
|
||||||
browser_address: str | None = None,
|
browser_address: str | None = None,
|
||||||
):
|
):
|
||||||
|
self._sdk = sdk
|
||||||
self._browser_context = browser_context
|
self._browser_context = browser_context
|
||||||
self._browser_session_id = browser_session_id
|
self._browser_session_id = browser_session_id
|
||||||
self._browser_address = browser_address
|
self._browser_address = browser_address
|
||||||
self._client = client
|
|
||||||
|
|
||||||
self.workflow_run_id: None | str = None
|
self.workflow_run_id: None | str = None
|
||||||
|
|
||||||
@@ -56,7 +61,11 @@ class SkyvernBrowser:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def client(self) -> AsyncSkyvern:
|
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:
|
async def get_working_page(self) -> SkyvernBrowserPage:
|
||||||
"""Get the most recent page or create a new one if none exists.
|
"""Get the most recent page or create a new one if none exists.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from skyvern.client import GetRunResponse
|
|||||||
from skyvern.client.types.workflow_run_response import WorkflowRunResponse
|
from skyvern.client.types.workflow_run_response import WorkflowRunResponse
|
||||||
from skyvern.config import settings
|
from skyvern.config import settings
|
||||||
from skyvern.library.constants import DEFAULT_AGENT_HEARTBEAT_INTERVAL, DEFAULT_AGENT_TIMEOUT
|
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.library.skyvern_locator import SkyvernLocator
|
||||||
from skyvern.webeye.actions import handler_utils
|
from skyvern.webeye.actions import handler_utils
|
||||||
|
|
||||||
@@ -67,6 +67,7 @@ class SkyvernPageRun:
|
|||||||
TaskRunResponse containing the task execution results.
|
TaskRunResponse containing the task execution results.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
await self._browser.sdk.ensure_has_server()
|
||||||
task_run = await self._browser.client.run_task(
|
task_run = await self._browser.client.run_task(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
engine=engine,
|
engine=engine,
|
||||||
@@ -125,6 +126,7 @@ class SkyvernPageRun:
|
|||||||
WorkflowRunResponse containing the login workflow execution results.
|
WorkflowRunResponse containing the login workflow execution results.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
await self._browser.sdk.ensure_has_server()
|
||||||
workflow_run = await self._browser.client.login(
|
workflow_run = await self._browser.client.login(
|
||||||
credential_type=credential_type,
|
credential_type=credential_type,
|
||||||
url=url or self._get_page_url(),
|
url=url or self._get_page_url(),
|
||||||
@@ -171,6 +173,8 @@ class SkyvernPageRun:
|
|||||||
Returns:
|
Returns:
|
||||||
WorkflowRunResponse containing the workflow execution results.
|
WorkflowRunResponse containing the workflow execution results.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
await self._browser.sdk.ensure_has_server()
|
||||||
workflow_run = await self._browser.client.run_workflow(
|
workflow_run = await self._browser.client.run_workflow(
|
||||||
workflow_id=workflow_id,
|
workflow_id=workflow_id,
|
||||||
parameters=parameters,
|
parameters=parameters,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class SdkSkyvernPageAi(SkyvernPageAi):
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Click an element using AI via API call."""
|
"""Click an element using AI via API call."""
|
||||||
|
|
||||||
|
await self._browser.sdk.ensure_has_server()
|
||||||
response = await self._browser.client.run_sdk_action(
|
response = await self._browser.client.run_sdk_action(
|
||||||
url=self._page.url,
|
url=self._page.url,
|
||||||
browser_session_id=self._browser.browser_session_id,
|
browser_session_id=self._browser.browser_session_id,
|
||||||
@@ -57,6 +58,7 @@ class SdkSkyvernPageAi(SkyvernPageAi):
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Input text into an element using AI via API call."""
|
"""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(
|
response = await self._browser.client.run_sdk_action(
|
||||||
url=self._page.url,
|
url=self._page.url,
|
||||||
action=SdkAction_AiInputText(
|
action=SdkAction_AiInputText(
|
||||||
@@ -85,6 +87,7 @@ class SdkSkyvernPageAi(SkyvernPageAi):
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Select an option from a dropdown using AI via API call."""
|
"""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(
|
response = await self._browser.client.run_sdk_action(
|
||||||
url=self._page.url,
|
url=self._page.url,
|
||||||
action=SdkAction_AiSelectOption(
|
action=SdkAction_AiSelectOption(
|
||||||
@@ -121,6 +124,7 @@ class SdkSkyvernPageAi(SkyvernPageAi):
|
|||||||
) -> dict[str, Any] | list | str | None:
|
) -> dict[str, Any] | list | str | None:
|
||||||
"""Extract information from the page using AI via API call."""
|
"""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(
|
response = await self._browser.client.run_sdk_action(
|
||||||
url=self._page.url,
|
url=self._page.url,
|
||||||
action=SdkAction_Extract(
|
action=SdkAction_Extract(
|
||||||
@@ -6,6 +6,7 @@ from playwright.async_api import Playwright, async_playwright
|
|||||||
|
|
||||||
from skyvern.client import AsyncSkyvern, BrowserSessionResponse, SkyvernEnvironment
|
from skyvern.client import AsyncSkyvern, BrowserSessionResponse, SkyvernEnvironment
|
||||||
from skyvern.library.constants import DEFAULT_CDP_PORT
|
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
|
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.
|
Exception: If no API key is provided and no .env file exists.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
self._environment = environment
|
||||||
|
|
||||||
if api_key is None:
|
if api_key is None:
|
||||||
if os.path.exists(".env"):
|
if os.path.exists(".env"):
|
||||||
load_dotenv(".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")
|
env_key = os.getenv("SKYVERN_API_KEY")
|
||||||
if not env_key:
|
if not env_key:
|
||||||
raise ValueError(
|
raise ValueError("SKYVERN_API_KEY is not set. Provide api_key or set SKYVERN_API_KEY in .env file.")
|
||||||
"SKYVERN_API_KEY is not set. Provide api_key or set SKYVERN_API_KEY in environment/.env."
|
|
||||||
)
|
|
||||||
self._api_key = env_key
|
self._api_key = env_key
|
||||||
else:
|
else:
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
@@ -110,6 +113,7 @@ class SkyvernSdk:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._playwright: Playwright | None = None
|
self._playwright: Playwright | None = None
|
||||||
|
self._verified_has_server: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api(self) -> AsyncSkyvern:
|
def api(self) -> AsyncSkyvern:
|
||||||
@@ -136,7 +140,7 @@ class SkyvernSdk:
|
|||||||
)
|
)
|
||||||
browser_address = f"http://localhost:{port}"
|
browser_address = f"http://localhost:{port}"
|
||||||
browser_context = browser.contexts[0] if browser.contexts else await browser.new_context()
|
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:
|
async def connect_to_browser_over_cdp(self, cdp_url: str) -> SkyvernBrowser:
|
||||||
"""Connect to an existing browser instance via Chrome DevTools Protocol (CDP).
|
"""Connect to an existing browser instance via Chrome DevTools Protocol (CDP).
|
||||||
@@ -153,7 +157,7 @@ class SkyvernSdk:
|
|||||||
playwright = await self._get_playwright()
|
playwright = await self._get_playwright()
|
||||||
browser = await playwright.chromium.connect_over_cdp(cdp_url)
|
browser = await playwright.chromium.connect_over_cdp(cdp_url)
|
||||||
browser_context = browser.contexts[0] if browser.contexts else await browser.new_context()
|
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:
|
async def connect_to_cloud_browser_session(self, browser_session_id: str) -> SkyvernBrowser:
|
||||||
"""Connect to an existing cloud-hosted browser session by ID.
|
"""Connect to an existing cloud-hosted browser session by ID.
|
||||||
@@ -196,6 +200,15 @@ class SkyvernSdk:
|
|||||||
browser_session = await self._api.create_browser_session()
|
browser_session = await self._api.create_browser_session()
|
||||||
return await self._connect_to_cloud_browser_session(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:
|
async def _connect_to_cloud_browser_session(self, browser_session: BrowserSessionResponse) -> SkyvernBrowser:
|
||||||
if browser_session.browser_address is None:
|
if browser_session.browser_address is None:
|
||||||
raise Exception(f"Browser address is missing for session {browser_session.browser_session_id}")
|
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_session.browser_address, headers={"x-api-key": self._api_key}
|
||||||
)
|
)
|
||||||
browser_context = browser.contexts[0] if browser.contexts else await browser.new_context()
|
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:
|
async def _get_playwright(self) -> Playwright:
|
||||||
if self._playwright is None:
|
if self._playwright is None:
|
||||||
|
|||||||
Reference in New Issue
Block a user