mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
848 lines
39 KiB
Python
848 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
|
|
import structlog
|
|
from playwright.async_api import async_playwright
|
|
|
|
from skyvern.exceptions import FailedToNavigateToUrl, MissingBrowserState
|
|
from skyvern.forge import app
|
|
from skyvern.forge.sdk.api.files import resolve_run_download_id
|
|
from skyvern.forge.sdk.core import skyvern_context
|
|
from skyvern.forge.sdk.routes.streaming.registries import set_deferred_close_params, stream_ref_active
|
|
from skyvern.forge.sdk.schemas.tasks import Task
|
|
from skyvern.forge.sdk.workflow.models.workflow import WorkflowRun
|
|
from skyvern.schemas.runs import ProxyLocation, ProxyLocationInput
|
|
from skyvern.webeye.browser_artifacts import VideoArtifact
|
|
from skyvern.webeye.browser_factory import BrowserContextFactory, rebind_download_dir
|
|
from skyvern.webeye.browser_manager import BrowserManager
|
|
from skyvern.webeye.browser_state import BrowserState
|
|
from skyvern.webeye.cdp_frame_publisher import (
|
|
CDPFramePublisher,
|
|
stream_key_for_task,
|
|
stream_key_for_workflow_run,
|
|
)
|
|
from skyvern.webeye.real_browser_state import RealBrowserState
|
|
from skyvern.webeye.session_cookies import persist_session_cookies
|
|
from skyvern.webeye.video_utils import finalize_webm
|
|
|
|
LOG = structlog.get_logger()
|
|
|
|
# Only driver/transport-level CDP drops trigger the cached-PBS evict + reconnect path.
|
|
# Playwright also surfaces page/context-only closes ("Target page, context or browser
|
|
# has been closed") with text that overlaps a transport drop; treating those as cached
|
|
# CDP drops would tear down a healthy PBS over a recoverable page-level state.
|
|
_CACHED_CDP_DROP_ERROR_SUBSTRINGS = ("Connection closed while reading from the driver",)
|
|
|
|
|
|
def _is_cached_cdp_drop_error(exc: FailedToNavigateToUrl) -> bool:
|
|
message = exc.error_message or ""
|
|
return any(needle in message for needle in _CACHED_CDP_DROP_ERROR_SUBSTRINGS)
|
|
|
|
|
|
async def _rebind_pbs_download_dir(
|
|
browser_state: BrowserState,
|
|
workflow_run: WorkflowRun,
|
|
browser_session_id: str,
|
|
) -> None:
|
|
browser_context = browser_state.browser_context
|
|
adopted_browser = browser_context.browser if browser_context else None
|
|
if adopted_browser is None:
|
|
return
|
|
try:
|
|
rebind_run_id = resolve_run_download_id(skyvern_context.current(), fallback_run_id=workflow_run.workflow_run_id)
|
|
await rebind_download_dir(adopted_browser, run_id=rebind_run_id)
|
|
except Exception:
|
|
LOG.warning(
|
|
"Failed to rebind download dir on adopted browser session",
|
|
browser_session_id=browser_session_id,
|
|
workflow_run_id=workflow_run.workflow_run_id,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
def _merge_proxy_session_headers(
|
|
extra_http_headers: dict[str, str] | None,
|
|
proxy_session_id: str | None,
|
|
) -> dict[str, str] | None:
|
|
if not proxy_session_id:
|
|
return extra_http_headers
|
|
return app.AGENT_FUNCTION.merge_proxy_session_extra_http_headers(extra_http_headers, proxy_session_id)
|
|
|
|
|
|
def _resolve_stream_key(*, workflow_run_id: str | None, task_id: str | None) -> str | None:
|
|
"""Pick the stream key that the API-side WebSocket polls for this entity.
|
|
|
|
Workflow-run streams always read ``{workflow_run_id}.png``; task streams use
|
|
that same key when the task belongs to a workflow run, and fall back to
|
|
``{task_id}.png`` only for standalone tasks. See
|
|
``skyvern/forge/sdk/routes/streaming/screenshot.py``.
|
|
"""
|
|
if workflow_run_id:
|
|
return stream_key_for_workflow_run(workflow_run_id)
|
|
if task_id:
|
|
return stream_key_for_task(task_id)
|
|
return None
|
|
|
|
|
|
class RealBrowserManager(BrowserManager):
|
|
def __init__(self) -> None:
|
|
self.pages: dict[str, BrowserState] = {}
|
|
# CDP frame publishers keyed by stream key (``{wr}.png`` / ``{task}.png``).
|
|
self._frame_publishers: dict[str, CDPFramePublisher] = {}
|
|
# Serializes the check/create/start/store/register sequence in
|
|
# ``_start_frame_publisher`` so concurrent attaches for one stream key
|
|
# cannot orphan a publisher loop.
|
|
self._publisher_lock = asyncio.Lock()
|
|
|
|
async def _start_frame_publisher(
|
|
self,
|
|
*,
|
|
browser_state: BrowserState,
|
|
workflow_run_id: str | None = None,
|
|
task_id: str | None = None,
|
|
organization_id: str | None = None,
|
|
) -> None:
|
|
"""Best-effort start a CDP frame publisher for this entity.
|
|
|
|
Gated on ``browser_state.browser_artifacts.needs_cdp_frame_publisher``,
|
|
which remote-CDP creators stamp. Local Playwright contexts leave it
|
|
False and skip publishing. Never raises.
|
|
"""
|
|
# Strict equality; MagicMock attributes are truthy by default.
|
|
if browser_state.browser_artifacts.needs_cdp_frame_publisher is not True:
|
|
return
|
|
stream_key = _resolve_stream_key(workflow_run_id=workflow_run_id, task_id=task_id)
|
|
if not stream_key or not organization_id:
|
|
return
|
|
async with self._publisher_lock:
|
|
if stream_key in self._frame_publishers:
|
|
return
|
|
try:
|
|
publisher = CDPFramePublisher(
|
|
browser_state=browser_state,
|
|
stream_key=stream_key,
|
|
organization_id=organization_id,
|
|
)
|
|
await publisher.start()
|
|
self._frame_publishers[stream_key] = publisher
|
|
except Exception:
|
|
LOG.warning(
|
|
"Failed to start CDP frame publisher; livestream may be unavailable",
|
|
stream_key=stream_key,
|
|
organization_id=organization_id,
|
|
exc_info=True,
|
|
)
|
|
return
|
|
# Tie publisher lifetime to BrowserState.close() so any close path
|
|
# stops it without needing to know about the publisher registry.
|
|
captured_stream_key = stream_key
|
|
|
|
async def _on_browser_state_close() -> None:
|
|
# Pop under the same lock that guards ``_start_frame_publisher``
|
|
# so a concurrent restart cannot slip past the registry check
|
|
# and orphan a second publisher. ``pub.stop()`` runs outside
|
|
# the lock — it awaits the task's exit and must not block
|
|
# other publishers from starting.
|
|
async with self._publisher_lock:
|
|
pub = self._frame_publishers.pop(captured_stream_key, None)
|
|
if pub is None:
|
|
return
|
|
try:
|
|
await pub.stop()
|
|
except Exception:
|
|
LOG.debug(
|
|
"CDP frame publisher stop raised during browser-state close; ignored",
|
|
stream_key=captured_stream_key,
|
|
exc_info=True,
|
|
)
|
|
|
|
browser_state.add_on_close(_on_browser_state_close)
|
|
|
|
async def _stop_frame_publisher(
|
|
self,
|
|
*,
|
|
workflow_run_id: str | None = None,
|
|
task_id: str | None = None,
|
|
) -> None:
|
|
"""Best-effort: stop the publisher matching this entity. Idempotent."""
|
|
stream_key = _resolve_stream_key(workflow_run_id=workflow_run_id, task_id=task_id)
|
|
if not stream_key:
|
|
return
|
|
publisher = self._frame_publishers.pop(stream_key, None)
|
|
if publisher is None:
|
|
return
|
|
try:
|
|
await publisher.stop()
|
|
except Exception:
|
|
LOG.debug(
|
|
"CDP frame publisher stop raised; ignored",
|
|
stream_key=stream_key,
|
|
exc_info=True,
|
|
)
|
|
|
|
@staticmethod
|
|
async def _create_browser_state(
|
|
proxy_location: ProxyLocationInput = None,
|
|
url: str | None = None,
|
|
task_id: str | None = None,
|
|
workflow_run_id: str | None = None,
|
|
workflow_permanent_id: str | None = None,
|
|
script_id: str | None = None,
|
|
organization_id: str | None = None,
|
|
extra_http_headers: dict[str, str] | None = None,
|
|
cdp_connect_headers: dict[str, str] | None = None,
|
|
browser_address: str | None = None,
|
|
browser_profile_id: str | None = None,
|
|
) -> BrowserState:
|
|
pw = await async_playwright().start()
|
|
(
|
|
browser_context,
|
|
browser_artifacts,
|
|
browser_cleanup,
|
|
) = await BrowserContextFactory.create_browser_context(
|
|
pw,
|
|
proxy_location=proxy_location,
|
|
url=url,
|
|
task_id=task_id,
|
|
workflow_run_id=workflow_run_id,
|
|
workflow_permanent_id=workflow_permanent_id,
|
|
script_id=script_id,
|
|
organization_id=organization_id,
|
|
extra_http_headers=extra_http_headers,
|
|
cdp_connect_headers=cdp_connect_headers,
|
|
browser_address=browser_address,
|
|
browser_profile_id=browser_profile_id,
|
|
)
|
|
return RealBrowserState(
|
|
pw=pw,
|
|
browser_context=browser_context,
|
|
page=None,
|
|
browser_artifacts=browser_artifacts,
|
|
browser_cleanup=browser_cleanup,
|
|
release_driver_on_close=browser_address is not None,
|
|
)
|
|
|
|
def evict_page(self, page_id: str) -> None:
|
|
self.pages.pop(page_id, None)
|
|
|
|
def get_for_task(self, task_id: str, workflow_run_id: str | None = None) -> BrowserState | None:
|
|
if task_id in self.pages:
|
|
return self.pages[task_id]
|
|
|
|
if workflow_run_id and workflow_run_id in self.pages:
|
|
LOG.info(
|
|
"Browser state for task not found. Using browser state for workflow run",
|
|
sampling=True,
|
|
task_id=task_id,
|
|
workflow_run_id=workflow_run_id,
|
|
)
|
|
self.pages[task_id] = self.pages[workflow_run_id]
|
|
return self.pages[task_id]
|
|
|
|
return None
|
|
|
|
async def get_or_create_for_task(
|
|
self,
|
|
task: Task,
|
|
browser_session_id: str | None = None,
|
|
) -> BrowserState:
|
|
browser_state = self.get_for_task(task_id=task.task_id, workflow_run_id=task.workflow_run_id)
|
|
if browser_state is not None:
|
|
return browser_state
|
|
|
|
if browser_session_id:
|
|
LOG.info(
|
|
"Getting browser state for task from persistent sessions manager",
|
|
browser_session_id=browser_session_id,
|
|
)
|
|
browser_state = await app.PERSISTENT_SESSIONS_MANAGER.get_browser_state(
|
|
browser_session_id, organization_id=task.organization_id
|
|
)
|
|
if browser_state is None:
|
|
LOG.warning(
|
|
"Browser state not found in persistent sessions manager",
|
|
browser_session_id=browser_session_id,
|
|
)
|
|
else:
|
|
if task.organization_id:
|
|
LOG.info("User to occupy browser session here", browser_session_id=browser_session_id)
|
|
else:
|
|
LOG.warning("Organization ID is not set for task", task_id=task.task_id)
|
|
page = await browser_state.get_working_page()
|
|
if page:
|
|
await browser_state.navigate_to_url(page=page, url=task.url)
|
|
else:
|
|
LOG.warning("Browser state has no page", workflow_run_id=task.workflow_run_id)
|
|
|
|
proxy_location = task.proxy_location
|
|
extra_http_headers = task.extra_http_headers
|
|
if browser_state is None:
|
|
LOG.info("Creating browser state for task", task_id=task.task_id)
|
|
if browser_session_id and task.organization_id:
|
|
session = await app.PERSISTENT_SESSIONS_MANAGER.get_session(browser_session_id, task.organization_id)
|
|
if session:
|
|
if session.proxy_location is not None:
|
|
proxy_location = session.proxy_location
|
|
extra_http_headers = _merge_proxy_session_headers(extra_http_headers, session.proxy_session_id)
|
|
browser_state = await self._create_browser_state(
|
|
proxy_location=proxy_location,
|
|
url=task.url,
|
|
task_id=task.task_id,
|
|
workflow_permanent_id=task.workflow_permanent_id,
|
|
organization_id=task.organization_id,
|
|
extra_http_headers=extra_http_headers,
|
|
cdp_connect_headers=task.cdp_connect_headers,
|
|
browser_address=task.browser_address,
|
|
)
|
|
|
|
if browser_session_id:
|
|
await app.PERSISTENT_SESSIONS_MANAGER.set_browser_state(
|
|
browser_session_id,
|
|
browser_state,
|
|
organization_id=task.organization_id,
|
|
)
|
|
|
|
self.pages[task.task_id] = browser_state
|
|
if task.workflow_run_id:
|
|
self.pages[task.workflow_run_id] = browser_state
|
|
|
|
# The URL here is only used when creating a new page, and not when using an existing page.
|
|
# This will make sure browser_state.page is not None.
|
|
await browser_state.get_or_create_page(
|
|
url=task.url,
|
|
proxy_location=proxy_location,
|
|
task_id=task.task_id,
|
|
workflow_permanent_id=task.workflow_permanent_id,
|
|
organization_id=task.organization_id,
|
|
extra_http_headers=extra_http_headers,
|
|
cdp_connect_headers=task.cdp_connect_headers,
|
|
browser_address=task.browser_address,
|
|
)
|
|
await self._start_frame_publisher(
|
|
browser_state=browser_state,
|
|
workflow_run_id=task.workflow_run_id,
|
|
task_id=task.task_id,
|
|
organization_id=task.organization_id,
|
|
)
|
|
return browser_state
|
|
|
|
async def get_or_create_for_workflow_run(
|
|
self,
|
|
workflow_run: WorkflowRun,
|
|
url: str | None = None,
|
|
browser_session_id: str | None = None,
|
|
browser_profile_id: str | None = None,
|
|
navigate: bool = True,
|
|
) -> BrowserState:
|
|
parent_workflow_run_id = workflow_run.parent_workflow_run_id
|
|
workflow_run_id = workflow_run.workflow_run_id
|
|
if browser_profile_id is None:
|
|
browser_profile_id = workflow_run.browser_profile_id
|
|
|
|
# Check own cache entry first so navigate_to_url is only called on the first step.
|
|
# Don't pass parent_workflow_run_id here — that lookup is deferred to the block
|
|
# below so PBS runs don't accidentally inherit the parent's browser.
|
|
browser_state = self.get_for_workflow_run(workflow_run_id=workflow_run_id)
|
|
if browser_state:
|
|
LOG.debug("Returning cached browser state for workflow run", workflow_run_id=workflow_run_id)
|
|
return browser_state
|
|
|
|
# When an explicit browser_session_id is provided (e.g. from a workflow
|
|
# trigger block), skip the parent workflow lookup so the child uses the
|
|
# specified persistent session instead of inheriting the parent's browser.
|
|
# Note: at this point workflow_run_id is guaranteed not in self.pages (caught above),
|
|
# so the call below can only match via parent_workflow_run_id.
|
|
if not browser_session_id:
|
|
browser_state = self.get_for_workflow_run(
|
|
workflow_run_id=workflow_run_id, parent_workflow_run_id=parent_workflow_run_id
|
|
)
|
|
if browser_state:
|
|
# always keep the browser state for the workflow run and the parent workflow run synced
|
|
self.pages[workflow_run_id] = browser_state
|
|
if parent_workflow_run_id:
|
|
self.pages[parent_workflow_run_id] = browser_state
|
|
# The workflow-run streaming endpoint reads ``{workflow_run_id}.png``, so the
|
|
# child needs its own publisher even when reusing the parent's browser state —
|
|
# the parent's publisher writes a different key.
|
|
await self._start_frame_publisher(
|
|
browser_state=browser_state,
|
|
workflow_run_id=workflow_run_id,
|
|
organization_id=workflow_run.organization_id,
|
|
)
|
|
return browser_state
|
|
|
|
if browser_session_id:
|
|
LOG.info(
|
|
"Getting browser state for workflow run from persistent sessions manager",
|
|
browser_session_id=browser_session_id,
|
|
)
|
|
browser_state = await app.PERSISTENT_SESSIONS_MANAGER.get_browser_state(
|
|
browser_session_id, organization_id=workflow_run.organization_id
|
|
)
|
|
if browser_state is None:
|
|
LOG.warning(
|
|
"Browser state not found in persistent sessions manager", browser_session_id=browser_session_id
|
|
)
|
|
else:
|
|
LOG.info("Used to occupy browser session here", browser_session_id=browser_session_id)
|
|
await _rebind_pbs_download_dir(browser_state, workflow_run, browser_session_id)
|
|
page = await browser_state.get_working_page()
|
|
if page:
|
|
if url and navigate:
|
|
try:
|
|
await browser_state.navigate_to_url(page=page, url=url)
|
|
except FailedToNavigateToUrl as nav_exc:
|
|
if not _is_cached_cdp_drop_error(nav_exc):
|
|
raise
|
|
if not app.PERSISTENT_SESSIONS_MANAGER.supports_evict_and_reconnect():
|
|
# Default OSS impl: ``get_browser_state`` is an in-memory
|
|
# dict lookup, so an evict would tear down the only cached
|
|
# BrowserState without any way to reconnect — and would
|
|
# break profile/video cleanup at ``close_session`` later.
|
|
# Re-raise the original navigation error untouched.
|
|
raise
|
|
LOG.warning(
|
|
"Cached browser CDP appears dead at first goto — evicting and reconnecting once",
|
|
browser_session_id=browser_session_id,
|
|
workflow_run_id=workflow_run.workflow_run_id,
|
|
error_message=nav_exc.error_message,
|
|
)
|
|
await app.PERSISTENT_SESSIONS_MANAGER.evict_cached_browser_state(
|
|
browser_session_id,
|
|
organization_id=workflow_run.organization_id,
|
|
expected=browser_state,
|
|
)
|
|
browser_state = await app.PERSISTENT_SESSIONS_MANAGER.get_browser_state(
|
|
browser_session_id,
|
|
organization_id=workflow_run.organization_id,
|
|
)
|
|
if browser_state is None:
|
|
raise
|
|
await _rebind_pbs_download_dir(browser_state, workflow_run, browser_session_id)
|
|
page = await browser_state.get_working_page()
|
|
if page is not None:
|
|
await browser_state.navigate_to_url(page=page, url=url)
|
|
else:
|
|
# The fresh CDP connection has no working page (e.g. the
|
|
# prior context closed its last tab during the dead-CDP
|
|
# window). The outer ``get_or_create_page`` below mirrors
|
|
# the normal-path behavior and will produce a page +
|
|
# navigate to ``url``, so don't fail a recoverable
|
|
# session here — fall through.
|
|
LOG.info(
|
|
"Recovered PBS reconnect has no working page — deferring to get_or_create_page",
|
|
browser_session_id=browser_session_id,
|
|
workflow_run_id=workflow_run.workflow_run_id,
|
|
)
|
|
else:
|
|
LOG.warning("Browser state has no page", workflow_run_id=workflow_run.workflow_run_id)
|
|
|
|
proxy_location = workflow_run.proxy_location
|
|
extra_http_headers = workflow_run.extra_http_headers
|
|
if browser_state is None:
|
|
LOG.info(
|
|
"Creating browser state for workflow run",
|
|
sampling=True,
|
|
workflow_run_id=workflow_run.workflow_run_id,
|
|
)
|
|
if browser_session_id and workflow_run.organization_id:
|
|
session = await app.PERSISTENT_SESSIONS_MANAGER.get_session(
|
|
browser_session_id, workflow_run.organization_id
|
|
)
|
|
if session:
|
|
if session.proxy_location is not None:
|
|
proxy_location = session.proxy_location
|
|
extra_http_headers = _merge_proxy_session_headers(extra_http_headers, session.proxy_session_id)
|
|
browser_state = await self._create_browser_state(
|
|
proxy_location=proxy_location,
|
|
url=url,
|
|
workflow_run_id=workflow_run.workflow_run_id,
|
|
workflow_permanent_id=workflow_run.workflow_permanent_id,
|
|
organization_id=workflow_run.organization_id,
|
|
extra_http_headers=extra_http_headers,
|
|
cdp_connect_headers=workflow_run.cdp_connect_headers,
|
|
browser_address=workflow_run.browser_address,
|
|
browser_profile_id=browser_profile_id,
|
|
)
|
|
|
|
if browser_session_id:
|
|
await app.PERSISTENT_SESSIONS_MANAGER.set_browser_state(
|
|
browser_session_id,
|
|
browser_state,
|
|
organization_id=workflow_run.organization_id,
|
|
)
|
|
|
|
self.pages[workflow_run_id] = browser_state
|
|
# Only sync the parent's entry when the child is sharing the parent's
|
|
# browser. When an explicit browser_session_id is provided the child
|
|
# has its own browser, and overwriting the parent's entry would break
|
|
# subsequent parent blocks.
|
|
if parent_workflow_run_id and not browser_session_id:
|
|
self.pages[parent_workflow_run_id] = browser_state
|
|
|
|
# The URL here is only used when creating a new page, and not when using an existing page.
|
|
# This will make sure browser_state.page is not None.
|
|
# When navigate is False, the URL has already been used for proxy selection in
|
|
# _create_browser_state above; we skip navigation so the caller (e.g. a generated
|
|
# script) performs the first goto itself, avoiding a redundant page load.
|
|
await browser_state.get_or_create_page(
|
|
url=url if navigate else None,
|
|
proxy_location=proxy_location,
|
|
workflow_run_id=workflow_run.workflow_run_id,
|
|
workflow_permanent_id=workflow_run.workflow_permanent_id,
|
|
organization_id=workflow_run.organization_id,
|
|
extra_http_headers=extra_http_headers,
|
|
cdp_connect_headers=workflow_run.cdp_connect_headers,
|
|
browser_address=workflow_run.browser_address,
|
|
browser_profile_id=browser_profile_id,
|
|
)
|
|
await self._start_frame_publisher(
|
|
browser_state=browser_state,
|
|
workflow_run_id=workflow_run.workflow_run_id,
|
|
organization_id=workflow_run.organization_id,
|
|
)
|
|
return browser_state
|
|
|
|
def get_for_workflow_run(
|
|
self, workflow_run_id: str, parent_workflow_run_id: str | None = None
|
|
) -> BrowserState | None:
|
|
# Priority: parent first, then own entry.
|
|
# Callers that need to avoid parent inheritance must omit parent_workflow_run_id.
|
|
# See get_or_create_for_workflow_run() for the two-phase lookup pattern.
|
|
if parent_workflow_run_id and parent_workflow_run_id in self.pages:
|
|
return self.pages[parent_workflow_run_id]
|
|
|
|
if workflow_run_id in self.pages:
|
|
return self.pages[workflow_run_id]
|
|
|
|
return None
|
|
|
|
def set_video_artifact_for_task(self, task: Task, artifacts: list[VideoArtifact]) -> None:
|
|
if task.workflow_run_id and task.workflow_run_id in self.pages:
|
|
self.pages[task.workflow_run_id].browser_artifacts.video_artifacts = artifacts
|
|
return
|
|
if task.task_id in self.pages:
|
|
self.pages[task.task_id].browser_artifacts.video_artifacts = artifacts
|
|
return
|
|
|
|
raise MissingBrowserState(task_id=task.task_id)
|
|
|
|
async def get_video_artifacts(
|
|
self,
|
|
browser_state: BrowserState,
|
|
task_id: str = "",
|
|
workflow_id: str = "",
|
|
workflow_run_id: str = "",
|
|
finalize: bool = True,
|
|
) -> list[VideoArtifact]:
|
|
if len(browser_state.browser_artifacts.video_artifacts) == 0:
|
|
LOG.warning(
|
|
"Video data not found for task",
|
|
task_id=task_id,
|
|
workflow_id=workflow_id,
|
|
workflow_run_id=workflow_run_id,
|
|
)
|
|
return []
|
|
|
|
for i, video_artifact in enumerate(browser_state.browser_artifacts.video_artifacts):
|
|
path = video_artifact.video_path
|
|
if path and os.path.exists(path=path):
|
|
# Only the local Playwright-launched recording path produces WebM
|
|
# that needs the remux fix-up. Other producers (e.g. fully formed
|
|
# MP4 downloaded from a remote source) are already container-valid
|
|
# and would be corrupted by ``finalize_webm`` — read those raw.
|
|
is_webm = path.lower().endswith(".webm")
|
|
if finalize and is_webm:
|
|
# Remux via ffmpeg so the WebM container has a valid Duration + Cues,
|
|
# even when browser_context.close() was killed mid-finalization.
|
|
browser_state.browser_artifacts.video_artifacts[i].video_data = await finalize_webm(path)
|
|
else:
|
|
# Per-step snapshot while recording is still open — skip ffmpeg: the file is
|
|
# partial, so remux would either fail or be thrown away by the final pass.
|
|
with open(path, "rb") as f:
|
|
browser_state.browser_artifacts.video_artifacts[i].video_data = f.read()
|
|
else:
|
|
LOG.debug(
|
|
"Video path not found",
|
|
task_id=task_id,
|
|
workflow_id=workflow_id,
|
|
workflow_run_id=workflow_run_id,
|
|
video_path=path,
|
|
)
|
|
|
|
return browser_state.browser_artifacts.video_artifacts
|
|
|
|
async def get_har_data(
|
|
self,
|
|
browser_state: BrowserState,
|
|
task_id: str = "",
|
|
workflow_id: str = "",
|
|
workflow_run_id: str = "",
|
|
) -> bytes:
|
|
if browser_state:
|
|
path = browser_state.browser_artifacts.har_path
|
|
if path and os.path.exists(path=path):
|
|
with open(path, "rb") as f:
|
|
return f.read()
|
|
LOG.warning(
|
|
"HAR data not found for task",
|
|
task_id=task_id,
|
|
workflow_id=workflow_id,
|
|
workflow_run_id=workflow_run_id,
|
|
)
|
|
return b""
|
|
|
|
async def get_browser_console_log(
|
|
self,
|
|
browser_state: BrowserState,
|
|
task_id: str = "",
|
|
workflow_id: str = "",
|
|
workflow_run_id: str = "",
|
|
) -> bytes:
|
|
if browser_state.browser_artifacts.browser_console_log_path is None:
|
|
LOG.warning(
|
|
"browser console log not found for task",
|
|
task_id=task_id,
|
|
workflow_id=workflow_id,
|
|
workflow_run_id=workflow_run_id,
|
|
)
|
|
return b""
|
|
|
|
return await browser_state.browser_artifacts.read_browser_console_log()
|
|
|
|
async def close(self) -> None:
|
|
LOG.info("Closing BrowserManager")
|
|
# Stop all streaming frame publishers before closing browsers so CDP
|
|
# sessions detach cleanly. Cancellation here is best-effort and must
|
|
# not block manager shutdown.
|
|
for stream_key in list(self._frame_publishers.keys()):
|
|
publisher = self._frame_publishers.pop(stream_key, None)
|
|
if publisher is None:
|
|
continue
|
|
try:
|
|
await publisher.stop()
|
|
except Exception:
|
|
LOG.debug(
|
|
"CDP frame publisher stop raised during manager close; ignored",
|
|
stream_key=stream_key,
|
|
exc_info=True,
|
|
)
|
|
for browser_state in self.pages.values():
|
|
await browser_state.close()
|
|
self.pages = dict()
|
|
LOG.info("BrowserManger is closed")
|
|
|
|
async def cleanup_for_task(
|
|
self,
|
|
task_id: str,
|
|
close_browser_on_completion: bool = True,
|
|
browser_session_id: str | None = None,
|
|
organization_id: str | None = None,
|
|
) -> BrowserState | None:
|
|
"""
|
|
Developer notes: handle errors here. Do not raise error from this function.
|
|
If error occurs, log it and address the cleanup error.
|
|
"""
|
|
LOG.info("Cleaning up for task")
|
|
browser_state_to_close = self.pages.pop(task_id, None)
|
|
if browser_state_to_close:
|
|
# Stop tracing before closing the browser if tracing is enabled
|
|
if browser_state_to_close.browser_context and browser_state_to_close.browser_artifacts.traces_dir:
|
|
trace_path = f"{browser_state_to_close.browser_artifacts.traces_dir}/{task_id}.zip"
|
|
await browser_state_to_close.browser_context.tracing.stop(path=trace_path)
|
|
LOG.info("Stopped tracing", trace_path=trace_path)
|
|
# Standalone-task only: a workflow-owned task's publisher is keyed
|
|
# by ``workflow_run_id`` (see ``_resolve_stream_key``) and is stopped
|
|
# by ``cleanup_for_workflow_run``. Passing ``task_id`` here is the
|
|
# honest signal — it hits ``{task_id}.png`` for standalone tasks
|
|
# and is a deliberate no-op for workflow tasks.
|
|
await self._stop_frame_publisher(task_id=task_id)
|
|
# A state backing a persistent session stays cached in the sessions
|
|
# manager for reuse; its driver is released when the session closes.
|
|
await browser_state_to_close.close(
|
|
close_browser_on_completion=close_browser_on_completion,
|
|
release_driver=False if browser_session_id else None,
|
|
)
|
|
LOG.info("Task is cleaned up")
|
|
|
|
if browser_session_id:
|
|
if organization_id:
|
|
await app.PERSISTENT_SESSIONS_MANAGER.release_browser_session(
|
|
browser_session_id, organization_id=organization_id
|
|
)
|
|
LOG.info("Released browser session", browser_session_id=browser_session_id)
|
|
else:
|
|
LOG.warning("Organization ID not specified, cannot release browser session", task_id=task_id)
|
|
|
|
return browser_state_to_close
|
|
|
|
async def cleanup_for_workflow_run(
|
|
self,
|
|
workflow_run_id: str,
|
|
task_ids: list[str],
|
|
close_browser_on_completion: bool = True,
|
|
browser_session_id: str | None = None,
|
|
organization_id: str | None = None,
|
|
child_workflow_run_ids: list[str] | None = None,
|
|
) -> BrowserState | None:
|
|
LOG.info("Cleaning up for workflow run", sampling=True)
|
|
browser_state_to_close = self.pages.get(workflow_run_id)
|
|
|
|
# Pop child workflow_run entries first — these are orphaned because child
|
|
# workflows skip clean_up_workflow. Must happen before the shared check
|
|
# so the task loop can correctly detect when the browser is no longer shared.
|
|
if child_workflow_run_ids:
|
|
for child_id in child_workflow_run_ids:
|
|
self.pages.pop(child_id, None)
|
|
# Child workflows skip their own cleanup, so the publishers
|
|
# started for inherited child runs would otherwise leak until
|
|
# process shutdown. Stop them here.
|
|
await self._stop_frame_publisher(workflow_run_id=child_id)
|
|
|
|
# Dual-stop is intentional and safe: both the explicit
|
|
# ``_stop_frame_publisher`` above and the ``add_on_close`` callback
|
|
# registered in ``_start_frame_publisher`` may fire for the same
|
|
# stream key. ``dict.pop(key, None)`` makes the second pop a no-op.
|
|
streams_active = stream_ref_active(workflow_run_id)
|
|
|
|
if browser_state_to_close:
|
|
# If another workflow run still references this browser state (e.g. a
|
|
# parent whose in-memory browser was shared via use_parent_browser_session),
|
|
# skip closing the browser so the parent can continue using it.
|
|
shared = any(bs is browser_state_to_close for bs in self.pages.values())
|
|
effective_close = close_browser_on_completion and not shared
|
|
if shared:
|
|
LOG.info(
|
|
"Browser state is shared with another workflow run, skipping browser close",
|
|
sampling=True,
|
|
workflow_run_id=workflow_run_id,
|
|
)
|
|
|
|
# Stop tracing before closing the browser if tracing is enabled.
|
|
# Skip when the browser is shared — Playwright supports only one active
|
|
# tracing session per context, so stopping here would kill the parent's trace.
|
|
if (
|
|
browser_state_to_close.browser_context
|
|
and browser_state_to_close.browser_artifacts.traces_dir
|
|
and not shared
|
|
):
|
|
trace_path = f"{browser_state_to_close.browser_artifacts.traces_dir}/{workflow_run_id}.zip"
|
|
await browser_state_to_close.browser_context.tracing.stop(path=trace_path)
|
|
LOG.info("Stopped tracing", trace_path=trace_path)
|
|
|
|
if streams_active:
|
|
# Defer close until the last stream disconnects. Persist session cookies first: the
|
|
# deferred close() runs after store_browser_session archives the dir, too late for it.
|
|
await persist_session_cookies(
|
|
browser_state_to_close.browser_context,
|
|
browser_state_to_close.browser_artifacts.browser_session_dir,
|
|
)
|
|
LOG.info(
|
|
"Deferring browser close — active CDP streams",
|
|
workflow_run_id=workflow_run_id,
|
|
)
|
|
set_deferred_close_params(workflow_run_id, close_browser_on_completion)
|
|
# Keep the publisher running while streams are attached. The
|
|
# eventual ``close(True)`` fires the on-close callback that
|
|
# stops it; ``close(False)`` is covered by the publisher's
|
|
# own disconnect-driven self-termination.
|
|
else:
|
|
# Detach the publisher's CDP session before the Playwright context
|
|
# closes; otherwise the stale session can race the teardown.
|
|
await self._stop_frame_publisher(workflow_run_id=workflow_run_id)
|
|
await browser_state_to_close.close(
|
|
close_browser_on_completion=effective_close,
|
|
release_driver=False if (shared or browser_session_id) else None,
|
|
)
|
|
|
|
if not streams_active:
|
|
self.pages.pop(workflow_run_id, None)
|
|
for task_id in task_ids:
|
|
task_browser_state = self.pages.pop(task_id, None)
|
|
if task_browser_state is None or streams_active:
|
|
continue
|
|
# Same shared-state check for task-level entries
|
|
shared = any(bs is task_browser_state for bs in self.pages.values())
|
|
effective_close = close_browser_on_completion and not shared
|
|
if shared:
|
|
LOG.info(
|
|
"Browser state is shared with another workflow run, skipping browser close",
|
|
sampling=True,
|
|
task_id=task_id,
|
|
workflow_run_id=workflow_run_id,
|
|
)
|
|
try:
|
|
await task_browser_state.close(
|
|
close_browser_on_completion=effective_close,
|
|
release_driver=False if (shared or browser_session_id) else None,
|
|
)
|
|
except Exception:
|
|
LOG.info(
|
|
"Failed to close the browser state from the task block, might because it's already closed.",
|
|
exc_info=True,
|
|
task_id=task_id,
|
|
workflow_run_id=workflow_run_id,
|
|
)
|
|
LOG.info("Workflow run is cleaned up", sampling=True)
|
|
|
|
if browser_session_id:
|
|
if organization_id:
|
|
await app.PERSISTENT_SESSIONS_MANAGER.release_browser_session(
|
|
browser_session_id, organization_id=organization_id
|
|
)
|
|
LOG.info("Released browser session", browser_session_id=browser_session_id)
|
|
else:
|
|
LOG.warning(
|
|
"Organization ID not specified, cannot release browser session", workflow_run_id=workflow_run_id
|
|
)
|
|
|
|
return browser_state_to_close
|
|
|
|
async def get_or_create_for_script(
|
|
self,
|
|
script_id: str | None = None,
|
|
browser_session_id: str | None = None,
|
|
) -> BrowserState:
|
|
browser_state = self.get_for_script(script_id=script_id)
|
|
if browser_state:
|
|
return browser_state
|
|
|
|
if browser_session_id:
|
|
LOG.info(
|
|
"Getting browser state for script",
|
|
browser_session_id=browser_session_id,
|
|
)
|
|
browser_state = await app.PERSISTENT_SESSIONS_MANAGER.get_browser_state(
|
|
browser_session_id, organization_id=script_id
|
|
)
|
|
if browser_state is None:
|
|
LOG.warning(
|
|
"Browser state not found in persistent sessions manager",
|
|
browser_session_id=browser_session_id,
|
|
)
|
|
else:
|
|
page = await browser_state.get_working_page()
|
|
if not page:
|
|
LOG.warning("Browser state has no page to run the script", script_id=script_id)
|
|
proxy_location = ProxyLocation.RESIDENTIAL
|
|
if not browser_state:
|
|
browser_state = await self._create_browser_state(
|
|
proxy_location=proxy_location,
|
|
script_id=script_id,
|
|
)
|
|
|
|
if script_id:
|
|
self.pages[script_id] = browser_state
|
|
await browser_state.get_or_create_page(
|
|
proxy_location=proxy_location,
|
|
script_id=script_id,
|
|
)
|
|
|
|
return browser_state
|
|
|
|
def get_for_script(self, script_id: str | None = None) -> BrowserState | None:
|
|
if script_id and script_id in self.pages:
|
|
return self.pages[script_id]
|
|
return None
|