From 22612366d652be511ee6b6f931de4b250a7d8500 Mon Sep 17 00:00:00 2001 From: Andrew Neilson Date: Fri, 22 May 2026 17:53:13 -0400 Subject: [PATCH] fix(SKY-9917): use bounded lock wait for cdp migration (#6122) --- ...59-0b0bd1875c6e_add_cdp_connect_headers.py | 109 +++++++ pyproject.toml | 2 - .../workflows/editor/WorkflowHeader.tsx | 306 ++++++++---------- .../src/routes/workflows/editor/Workspace.tsx | 91 +++--- .../panels/WorkflowCacheKeyValuesPanel.tsx | 102 ++++-- .../editor/panels/WorkflowHistoryPanel.tsx | 25 +- .../schedulePanel/WorkflowSchedulePanel.tsx | 31 +- skyvern/cli/browser.py | 18 +- skyvern/cli/init_command.py | 117 ++----- skyvern/cli/mcp.py | 183 +---------- skyvern/cli/quickstart.py | 120 +------ skyvern/exceptions.py | 5 +- .../forge/prompts/skyvern/custom-select.j2 | 9 +- .../forge/sdk/api/llm/api_handler_factory.py | 62 ++-- skyvern/webeye/actions/handler.py | 73 ++++- .../test_cdp_connect_headers_migration.py | 63 +++- .../test_date_dropdown_emerging_selection.py | 109 +++++++ tests/unit/test_init_command.py | 58 +--- tests/unit/test_init_mcp.py | 2 +- tests/unit/test_quickstart_command.py | 20 +- tests/unit/test_quickstart_server_extra.py | 103 ------ uv.lock | 8 +- 22 files changed, 736 insertions(+), 880 deletions(-) create mode 100644 alembic/versions/2026_05_22_2059-0b0bd1875c6e_add_cdp_connect_headers.py create mode 100644 tests/unit/test_date_dropdown_emerging_selection.py delete mode 100644 tests/unit/test_quickstart_server_extra.py diff --git a/alembic/versions/2026_05_22_2059-0b0bd1875c6e_add_cdp_connect_headers.py b/alembic/versions/2026_05_22_2059-0b0bd1875c6e_add_cdp_connect_headers.py new file mode 100644 index 000000000..bb9ea5c66 --- /dev/null +++ b/alembic/versions/2026_05_22_2059-0b0bd1875c6e_add_cdp_connect_headers.py @@ -0,0 +1,109 @@ +"""add cdp_connect_headers + +Revision ID: 0b0bd1875c6e +Revises: 9f512f2da31e +Create Date: 2026-05-22T20:59:01.986004+00:00 + +ACCESS EXCLUSIVE on the target tables conflicts with every other lock +mode, so a queued ALTER can block concurrent queries behind it. Each +ALTER first tries to take an ACCESS EXCLUSIVE lock with a short +lock_timeout inside its own transaction; failed attempts roll back +immediately and retry outside the lock queue. +""" + +import random +import time +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.exc import DBAPIError + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0b0bd1875c6e" +down_revision: Union[str, None] = "9f512f2da31e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLES = ("observer_cruises", "tasks", "workflow_runs", "workflows") +_COLUMN = "cdp_connect_headers" +_STATEMENT_TIMEOUT = "5s" +_LOCK_TIMEOUT = "250ms" +_LOCK_NOT_AVAILABLE_SQLSTATE = "55P03" +_MIGRATION_RETRY_SECONDS = 20 * 60 +_BACKOFF_BASE_SECONDS = 0.25 +_BACKOFF_JITTER_SECONDS = 0.75 + + +def _is_lock_not_available(exc: DBAPIError) -> bool: + orig = getattr(exc, "orig", None) + return ( + getattr(orig, "sqlstate", None) == _LOCK_NOT_AVAILABLE_SQLSTATE + or getattr(orig, "pgcode", None) == _LOCK_NOT_AVAILABLE_SQLSTATE + ) + + +def _column_exists(table: str) -> bool: + result = op.get_bind().execute( + sa.text( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = :table_name + AND column_name = :column_name + """ + ), + {"table_name": table, "column_name": _COLUMN}, + ) + return result.first() is not None + + +def _execute_transactional_schema_change(table: str, statement: str) -> None: + try: + op.execute("BEGIN") + op.execute(f"SET LOCAL statement_timeout = '{_STATEMENT_TIMEOUT}'") + op.execute(f"SET LOCAL lock_timeout = '{_LOCK_TIMEOUT}'") + op.execute(f'LOCK TABLE "{table}" IN ACCESS EXCLUSIVE MODE') + op.execute(statement) + op.execute("COMMIT") + except Exception as exc: + try: + op.execute("ROLLBACK") + except Exception as rollback_exc: + raise RuntimeError( + f"rollback failed after schema change error; aborting migration: {rollback_exc}" + ) from exc + raise + + +def _execute_with_retry(table: str, statement: str, deadline: float) -> None: + while True: + try: + _execute_transactional_schema_change(table, statement) + return + except DBAPIError as exc: + if not _is_lock_not_available(exc) or time.monotonic() >= deadline: + raise + time.sleep(_BACKOFF_BASE_SECONDS + random.random() * _BACKOFF_JITTER_SECONDS) + + +def upgrade() -> None: + deadline = time.monotonic() + _MIGRATION_RETRY_SECONDS + with op.get_context().autocommit_block(): + for table in reversed(_TABLES): + if not _column_exists(table): + _execute_with_retry( + table, + f'ALTER TABLE "{table}" ADD COLUMN IF NOT EXISTS {_COLUMN} JSON', + deadline, + ) + + +def downgrade() -> None: + deadline = time.monotonic() + _MIGRATION_RETRY_SECONDS + with op.get_context().autocommit_block(): + for table in _TABLES: + if _column_exists(table): + _execute_with_retry(table, f'ALTER TABLE "{table}" DROP COLUMN IF EXISTS {_COLUMN}', deadline) diff --git a/pyproject.toml b/pyproject.toml index 13baea8ec..f14ec8f33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -257,11 +257,9 @@ constraint-dependencies = [ "flask>=3.1.3", "idna>=3.15", "joserfc>=1.6.3", - "jupyter-server>=2.18.0", "mako>=1.3.12", "mistune>=3.2.1", - "pyasn1>=0.6.3", "tornado>=6.5.5", "werkzeug>=3.1.6", diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx index af7bcce36..84850931a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx @@ -1,28 +1,41 @@ import { BookmarkFilledIcon, BookmarkIcon, + CalendarIcon, ChevronDownIcon, ChevronUpIcon, - ClockIcon, CodeIcon, CopyIcon, + CounterClockwiseClockIcon, + DotsHorizontalIcon, PlayIcon, ReloadIcon, ResetIcon, } from "@radix-ui/react-icons"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { SaveIcon } from "@/components/icons/SaveIcon"; import { BrowserIcon } from "@/components/icons/BrowserIcon"; import { VersionHistoryIcon } from "@/components/icons/VersionHistoryIcon"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Input } from "@/components/ui/input"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery"; import { getClient } from "@/api/AxiosClient"; @@ -42,30 +55,21 @@ import { isMacPlatform } from "@/util/platform"; import { cn } from "@/util/utils"; import { CacheKeyValuesResponse } from "@/routes/workflows/types/scriptTypes"; -interface Dom { - input: React.MutableRefObject; -} - type Props = { cacheKeyValue: string | null; cacheKeyValues: CacheKeyValuesResponse | undefined; - cacheKeyValuesPanelOpen: boolean; canUndo: boolean; canRedo: boolean; isGeneratingCode?: boolean; isTemplate?: boolean; parametersPanelOpen: boolean; - schedulesPanelOpen: boolean; saving: boolean; showAllCode: boolean; onCacheKeyValueAccept: (cacheKeyValue: string | null) => void; - onCacheKeyValuesBlurred: (cacheKeyValue: string | null) => void; - onCacheKeyValuesFilter: (cacheKeyValue: string) => void; - onCacheKeyValuesKeydown: (e: React.KeyboardEvent) => void; + onBrowseCacheKeys?: () => void; onParametersClick: () => void; onScheduleClick: () => void; onShowAllCodeClick?: () => void; - onCacheKeyValuesClick: () => void; onSave: () => void; onUndo: () => void; onRedo: () => void; @@ -76,23 +80,18 @@ type Props = { function WorkflowHeader({ cacheKeyValue, cacheKeyValues, - cacheKeyValuesPanelOpen, canUndo, canRedo, isGeneratingCode, isTemplate, parametersPanelOpen, - schedulesPanelOpen, saving, showAllCode, onCacheKeyValueAccept, - onCacheKeyValuesBlurred, - onCacheKeyValuesFilter, - onCacheKeyValuesKeydown, + onBrowseCacheKeys, onParametersClick, onScheduleClick, onShowAllCodeClick, - onCacheKeyValuesClick, onSave, onUndo, onRedo, @@ -110,16 +109,9 @@ function WorkflowHeader({ const recordingStore = useRecordingStore(); const workflowRunIsRunningOrQueued = workflowRun && statusIsRunningOrQueued(workflowRun); - const [chosenCacheKeyValue, setChosenCacheKeyValue] = useState( - cacheKeyValue ?? null, - ); const credentialGetter = useCredentialGetter(); const queryClient = useQueryClient(); - // Keyboard shortcut labels shown in tooltips - keep them platform-aware - // so Windows/Linux users don't see the Mac Command glyph. Memoized so - // we don't re-sniff the platform on every render (it's stable for the - // session). const { undoShortcutLabel, redoShortcutLabel } = useMemo(() => { const mac = isMacPlatform(); return { @@ -160,23 +152,10 @@ function WorkflowHeader({ }, }); - const dom: Dom = { - input: useRef(null), - }; - const handleShowAllCode = () => { onShowAllCodeClick?.(); }; - useEffect(() => { - if (cacheKeyValue === chosenCacheKeyValue) { - return; - } - - setChosenCacheKeyValue(cacheKeyValue ?? null); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cacheKeyValue]); - const isRecording = recordingStore.isRecording; const shouldShowCacheControls = @@ -193,10 +172,10 @@ function WorkflowHeader({ return (
-
+
{ @@ -204,82 +183,11 @@ function WorkflowHeader({ workflowChangesStore.setHasChanges(true); }} value={title} - titleClassName="text-3xl" - inputClassName="text-3xl" + titleClassName="text-2xl xl:text-3xl" + inputClassName="text-2xl xl:text-3xl" />
-
- {shouldShowCacheControls && ( - <> - {debugStore.isDebugMode && ( - - )} -
- { - setChosenCacheKeyValue(e.target.value); - onCacheKeyValuesFilter(e.target.value); - }} - onMouseDown={() => { - if (!cacheKeyValuesPanelOpen) { - onCacheKeyValuesClick(); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - const numFiltered = cacheKeyValues?.values?.length ?? 0; - - if (numFiltered === 1) { - const first = cacheKeyValues?.values?.[0]; - if (first) { - setChosenCacheKeyValue(first); - onCacheKeyValueAccept(first); - } - return; - } - - setChosenCacheKeyValue(chosenCacheKeyValue); - onCacheKeyValueAccept(chosenCacheKeyValue); - } - onCacheKeyValuesKeydown(e); - }} - placeholder="Code Key Value" - value={chosenCacheKeyValue ?? undefined} - onBlur={(e) => { - onCacheKeyValuesBlurred(e.target.value); - setChosenCacheKeyValue(e.target.value); - }} - /> - {cacheKeyValuesPanelOpen ? ( - - ) : ( - { - dom.input.current?.focus(); - onCacheKeyValuesClick(); - }} - /> - )} -
- - )} +
{isGeneratingCode && ( + + + {shouldShowCacheControls && ( + + + + Cache key: {cacheKeyValue || "default"} + + + onCacheKeyValueAccept(v || null)} + > + + Default (no cache key) + + {cacheKeyValues?.values?.map((value) => ( + + {value} + + ))} + + {(cacheKeyValues?.values?.length ?? 0) === 0 && ( +
+ No cache keys yet +
+ )} + {onBrowseCacheKeys && ( + <> + + + Browse all cache keys… + + + )} +
+
+ )} + {shouldShowCacheControls && debugStore.isDebugMode && ( + + + {showAllCode ? "Hide Code" : "Show Code"} + + )} + {shouldShowCacheControls && } + { + const newIsTemplate = !isTemplate; + if (newIsTemplate) { + onSave(); } - size="icon" - variant={isTemplate ? "default" : "tertiary"} - className="size-10 min-w-[2.5rem]" - onClick={() => { - const newIsTemplate = !isTemplate; - if (newIsTemplate) { - // When saving AS template, save the workflow first - onSave(); - } - templateMutation.mutate(newIsTemplate); + templateMutation.mutate(newIsTemplate); + }} + > + {templateMutation.isPending ? ( + + ) : isTemplate ? ( + + ) : ( + + )} + {templateMutation.isPending + ? "Saving…" + : isTemplate + ? "Remove from Templates" + : "Save as Template"} + + {!workflowRunIsRunningOrQueued && ( + { + onHistory?.(); }} > - {templateMutation.isPending ? ( - - ) : isTemplate ? ( - - ) : ( - - )} - - - - {isTemplate ? "Remove from Templates" : "Save as Template"} - - - - {!workflowRunIsRunningOrQueued && ( - - - - - - History - - - )} - + + History + + )} + + + Schedule… + + { + navigate(`/workflows/${workflowPermanentId}/runs`); + }} + > + + Run history + +
+
void; onDelete: (cacheKeyValue: string) => void; + onFilterChange?: (filter: string) => void; onMouseDownCapture?: () => void; onPaginate: (page: number) => void; onSelect: (cacheKeyValue: string) => void; @@ -22,13 +31,20 @@ interface Props { function WorkflowCacheKeyValuesPanel({ cacheKeyValues, + filter, pending, scriptKey, + onClose, onDelete, + onFilterChange, onMouseDownCapture, onPaginate, onSelect, }: Props) { + const [draftFilter, setDraftFilter] = useState(filter ?? ""); + useEffect(() => { + setDraftFilter(filter ?? ""); + }, [filter]); const values = cacheKeyValues?.values ?? []; const page = cacheKeyValues?.page ?? 0; const pageSize = cacheKeyValues?.page_size ?? 0; @@ -43,30 +59,68 @@ function WorkflowCacheKeyValuesPanel({ onMouseDownCapture={() => onMouseDownCapture?.()} >
-
-

Code Cache

- - Given your code key,{" "} - - {scriptKey} - - , search for saved code using a code key value. For this code key - there {totalCount === 1 ? "is" : "are"}{" "} - {totalCount} code - key {totalCount === 1 ? "value" : "values"} - {filteredCount !== totalCount && ( - <> - {" "} - ( - - {filteredCount} - {" "} - filtered) - - )} - . - +
+
+

Code Cache

+ + Given your code key,{" "} + + {scriptKey} + + , search for saved code using a code key value. For this code key + there {totalCount === 1 ? "is" : "are"}{" "} + {totalCount}{" "} + code key {totalCount === 1 ? "value" : "values"} + {filteredCount !== totalCount && ( + <> + {" "} + ( + + {filteredCount} + {" "} + filtered) + + )} + . + +
+ {onClose && ( + + )}
+ {onFilterChange && ( + { + setDraftFilter(e.target.value); + onFilterChange(e.target.value); + }} + onKeyDown={(e) => { + if (e.key !== "Enter") { + return; + } + const typed = draftFilter.trim(); + if (!typed) { + return; + } + const exact = cacheKeyValues?.values?.find((v) => v === typed); + const onlyMatch = + cacheKeyValues?.values?.length === 1 + ? cacheKeyValues.values[0] + : undefined; + onSelect(exact ?? onlyMatch ?? typed); + }} + /> + )}
{values.length ? (
diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowHistoryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowHistoryPanel.tsx index f4e0ad475..4753edfc3 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowHistoryPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowHistoryPanel.tsx @@ -1,3 +1,4 @@ +import { Cross2Icon } from "@radix-ui/react-icons"; import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -13,6 +14,7 @@ import { type Props = { workflowPermanentId: string; + onClose?: () => void; onCompare?: ( version1: WorkflowVersion, version2: WorkflowVersion, @@ -20,7 +22,11 @@ type Props = { ) => void; }; -function WorkflowHistoryPanel({ workflowPermanentId, onCompare }: Props) { +function WorkflowHistoryPanel({ + workflowPermanentId, + onClose, + onCompare, +}: Props) { const { data: versions, isLoading } = useWorkflowVersionsQuery({ workflowPermanentId, }); @@ -95,8 +101,21 @@ function WorkflowHistoryPanel({ workflowPermanentId, onCompare }: Props) {

Workflow History

-
- {selectedVersions.size}/2 selected +
+
+ {selectedVersions.size}/2 selected +
+ {onClose && ( + + )}

diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx index f3678137c..fcbe21ba0 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/schedulePanel/WorkflowSchedulePanel.tsx @@ -17,11 +17,15 @@ import { import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery"; import { ScheduleCard } from "./ScheduleCard"; import { CreateScheduleDialog } from "./CreateScheduleDialog"; -import { ReloadIcon } from "@radix-ui/react-icons"; +import { Cross2Icon, ReloadIcon } from "@radix-ui/react-icons"; import { useState } from "react"; import { useParams } from "react-router-dom"; -function WorkflowSchedulePanel() { +type Props = { + onClose?: () => void; +}; + +function WorkflowSchedulePanel({ onClose }: Props) { const { data: schedules, isLoading, @@ -82,11 +86,24 @@ function WorkflowSchedulePanel() { Schedules {schedules && schedules.length > 0 ? ` (${schedules.length})` : ""} - +

+ + {onClose && ( + + )} +
diff --git a/skyvern/cli/browser.py b/skyvern/cli/browser.py index 7c6387803..3cd64a424 100644 --- a/skyvern/cli/browser.py +++ b/skyvern/cli/browser.py @@ -4,10 +4,16 @@ import time from typing import Any, Optional from urllib.parse import urlparse +import requests # type: ignore from rich.panel import Panel from rich.prompt import Confirm, Prompt from .console import console +from .core.browser_launcher import ( + SKYVERN_DATA_DIR, + clone_local_chrome_profile, + get_local_chrome_profile_dir, +) # Ports to scan when auto-discovering a CDP debugging server _CDP_SCAN_PORTS = [9222, 9223, 9224, 9225, 9226, 9229] @@ -66,8 +72,6 @@ def _discover_cdp_server() -> Optional[tuple[str, dict | None]]: - "ws://127.0.0.1:{port}/devtools/browser" if only WS is available version_info is cached to avoid a redundant probe in _print_cdp_info. """ - import requests # type: ignore - for port in _CDP_SCAN_PORTS: http_url = f"http://127.0.0.1:{port}" # Try HTTP first (standard --remote-debugging-port) @@ -104,8 +108,6 @@ def _print_cdp_info(url: str, cached_info: dict | None = None) -> None: return # Standard HTTP CDP server - import requests # type: ignore - info = cached_info if not info: try: @@ -169,12 +171,6 @@ def _print_classic_cdp_instructions() -> None: def _setup_local_browser_clone() -> tuple[str, Optional[str], Optional[str]]: """Set up a new browser with the user's Chrome profile cloned.""" - from .core.browser_launcher import ( # noqa: PLC0415 - SKYVERN_DATA_DIR, - clone_local_chrome_profile, - get_local_chrome_profile_dir, - ) - chrome_profile_dir = get_local_chrome_profile_dir() if not chrome_profile_dir.is_dir(): console.print( @@ -299,8 +295,6 @@ def _setup_local_browser_actual() -> tuple[str, Optional[str], Optional[str]]: ) # Verify the manual URL - import requests # type: ignore - try: response = requests.get(f"{manual_url}/json/version", timeout=2) if response.status_code == 200: diff --git a/skyvern/cli/init_command.py b/skyvern/cli/init_command.py index 5613cba2d..4dca63769 100644 --- a/skyvern/cli/init_command.py +++ b/skyvern/cli/init_command.py @@ -4,7 +4,7 @@ import os import subprocess import sys import uuid -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass, field from pathlib import Path from typing import Any, Literal, TypeVar, overload @@ -25,6 +25,7 @@ from skyvern.utils.env_paths import ( resolve_backend_env_path, ) +from .browser import setup_browser_config from .console import console from .database import setup_postgresql from .llm_setup import setup_llm_providers, update_or_add_env_var @@ -129,12 +130,7 @@ def _ensure_playwright_chromium(browser_type: str | None, skip_browser_install: ) as progress: progress.add_task("[bold blue]Downloading Chromium, this may take a moment...", total=None) try: - subprocess.run( - [sys.executable, "-m", "playwright", "install", "chromium"], - check=True, - capture_output=True, - text=True, - ) + subprocess.run(["playwright", "install", "chromium"], check=True, capture_output=True, text=True) capture_setup_event("playwright-install-complete", success=True) console.print("✅ [green]Chromium installation complete.[/green]") return BrowserInstallStatus(required=True, ready=True, attempted=True) @@ -297,10 +293,7 @@ def _install_server_extra_for_missing_dependency(exc: ImportError) -> None: _SERVER_EXTRA_INSTALL_ATTEMPTED = True console.print(f"📦 [bold blue]Installing {escape(target)}...[/bold blue]") try: - subprocess.run( - [sys.executable, "-m", "pip", "install", "--retries", "5", "--timeout", "60", target], - check=True, - ) + subprocess.run([sys.executable, "-m", "pip", "install", target], check=True) except subprocess.CalledProcessError as install_error: console.print(f"[bold red]Failed to install {target}: {install_error}[/bold red]") _print_local_server_dependency_hint(missing_module, target) @@ -315,27 +308,11 @@ def _run_with_server_dependency_install(action: Callable[[], _T]) -> _T: _install_server_extra_for_missing_dependency(exc) -async def _setup_local_organization_from_database() -> str: - """Seed the local org/API key without constructing the full server runtime.""" - from skyvern.config import settings # noqa: PLC0415 - - from .mcp import setup_local_organization_from_database_string # noqa: PLC0415 - - return await setup_local_organization_from_database_string(settings.DATABASE_STRING) - - @overload def init_env( no_postgres: bool = False, database_string: str = "", skip_browser_install: bool = False, - mode: Literal["local", "cloud"] | None = None, - skip_llm_setup: bool = False, - configure_mcp: bool | None = None, - browser_type: Literal["chromium-headful", "chromium-headless", "cdp-connect"] | None = None, - browser_location: str | None = None, - remote_debugging_url: str | None = None, - analytics_id: str | None = None, env_scope: str | None = None, env_path: Path | str | None = None, return_result: Literal[False] = False, @@ -347,13 +324,6 @@ def init_env( no_postgres: bool = False, database_string: str = "", skip_browser_install: bool = False, - mode: Literal["local", "cloud"] | None = None, - skip_llm_setup: bool = False, - configure_mcp: bool | None = None, - browser_type: Literal["chromium-headful", "chromium-headless", "cdp-connect"] | None = None, - browser_location: str | None = None, - remote_debugging_url: str | None = None, - analytics_id: str | None = None, env_scope: str | None = None, env_path: Path | str | None = None, return_result: Literal[True] = True, @@ -364,13 +334,6 @@ def init_env( no_postgres: bool = False, database_string: str = "", skip_browser_install: bool = False, - mode: Literal["local", "cloud"] | None = None, - skip_llm_setup: bool = False, - configure_mcp: bool | None = None, - browser_type: Literal["chromium-headful", "chromium-headless", "cdp-connect"] | None = None, - browser_location: str | None = None, - remote_debugging_url: str | None = None, - analytics_id: str | None = None, env_scope: str | None = None, env_path: Path | str | None = None, return_result: bool = False, @@ -385,7 +348,7 @@ def init_env( ) console.print("[italic]This wizard will help you set up Skyvern.[/italic]") - infra_choice = mode or Prompt.ask( + infra_choice = Prompt.ask( "Would you like to run Skyvern [bold blue]local[/bold blue]ly or in the [bold purple]cloud[/bold purple]?", choices=["local", "cloud"], ) @@ -422,16 +385,27 @@ def init_env( console.print("✅ [green]Database migration complete.[/green]") console.print("🔑 [bold blue]Generating local organization API key...[/bold blue]") - api_key = _run_with_server_dependency_install(lambda: asyncio.run(_setup_local_organization_from_database())) + + def _load_start_forge_app() -> Callable[[], object]: + from skyvern.forge.forge_app_initializer import start_forge_app # noqa: PLC0415 + + return start_forge_app + + def _load_setup_local_organization() -> Callable[[], Coroutine[Any, Any, str]]: + from .mcp import setup_local_organization # noqa: PLC0415 + + return setup_local_organization + + start_forge_app = _run_with_server_dependency_install(_load_start_forge_app) + setup_local_organization = _run_with_server_dependency_install(_load_setup_local_organization) + _run_with_server_dependency_install(start_forge_app) + api_key = _run_with_server_dependency_install(lambda: asyncio.run(setup_local_organization())) if api_key: console.print("✅ [green]Local organization API key generated.[/green]") else: console.print("[red]Failed to generate local organization API key. Please check server logs.[/red]") - if skip_llm_setup: - console.print("[yellow]Skipping LLM setup as requested.[/yellow]") - set_env_var("ENV", "local") - elif backend_env_path.exists(): + if backend_env_path.exists(): console.print(f"💡 [{backend_env_path}] file already exists.", style="yellow", markup=False) redo_llm_setup = Confirm.ask( "Do you want to go through [bold yellow]LLM provider setup again[/bold yellow]?", @@ -447,29 +421,13 @@ def init_env( setup_llm_providers(env_path=backend_env_path) console.print("\n[bold blue]Configuring browser settings...[/bold blue]") - selected_browser_type: str - selected_browser_location: str | None - selected_remote_debugging_url: str | None - if browser_type: - selected_browser_type = browser_type - selected_browser_location = browser_location - selected_remote_debugging_url = remote_debugging_url - capture_setup_event( - "browser-config-select", - success=True, - extra_data={"type": selected_browser_type, "source": "cli-option"}, - ) - else: - from .browser import setup_browser_config # noqa: PLC0415 - - selected_browser_type, selected_browser_location, selected_remote_debugging_url = setup_browser_config() - - result.browser_type = selected_browser_type - set_env_var("BROWSER_TYPE", selected_browser_type) - if selected_browser_location: - set_env_var("CHROME_EXECUTABLE_PATH", selected_browser_location) - if selected_remote_debugging_url: - set_env_var("BROWSER_REMOTE_DEBUGGING_URL", selected_remote_debugging_url) + browser_type, browser_location, remote_debugging_url = setup_browser_config() + result.browser_type = browser_type + set_env_var("BROWSER_TYPE", browser_type) + if browser_location: + set_env_var("CHROME_EXECUTABLE_PATH", browser_location) + if remote_debugging_url: + set_env_var("BROWSER_REMOTE_DEBUGGING_URL", remote_debugging_url) set_env_var("BROWSER_STREAMING_MODE", "cdp") console.print("✅ [green]Browser configuration complete.[/green]") @@ -530,11 +488,9 @@ def init_env( console.print(" This starts Chrome on your machine and creates a tunnel so Skyvern Cloud can control it.") console.print(" Learn more: [link]https://www.skyvern.com/docs/optimization/browser-tunneling[/link]") - resolved_analytics_id = analytics_id - if resolved_analytics_id is None: - analytics_id_input = Prompt.ask("Please enter your email for analytics (press enter to skip)", default="") - resolved_analytics_id = analytics_id_input if analytics_id_input else str(uuid.uuid4()) - set_env_var("ANALYTICS_ID", resolved_analytics_id) + analytics_id_input = Prompt.ask("Please enter your email for analytics (press enter to skip)", default="") + analytics_id = analytics_id_input if analytics_id_input else str(uuid.uuid4()) + set_env_var("ANALYTICS_ID", analytics_id) if api_key: set_env_var("SKYVERN_API_KEY", api_key) console.print(f"✅ [green]{backend_env_path} file has been initialized.[/green]") @@ -543,14 +499,7 @@ def init_env( _mcp_browser_type = os.environ.get("BROWSER_TYPE") if run_local else None _mcp_browser_url = os.environ.get("BROWSER_REMOTE_DEBUGGING_URL") if run_local else None - should_configure_mcp = configure_mcp - if should_configure_mcp is None: - should_configure_mcp = Confirm.ask( - "\nWould you like to [bold yellow]configure the MCP server[/bold yellow]?", - default=True, - ) - - if should_configure_mcp: + if Confirm.ask("\nWould you like to [bold yellow]configure the MCP server[/bold yellow]?", default=True): from .mcp import setup_mcp # noqa: PLC0415 setup_mcp( @@ -618,8 +567,6 @@ def init_browser() -> None: """Initialize only the browser configuration and install Chromium.""" console.print("\n[bold blue]Configuring browser settings...[/bold blue]") capture_setup_event("browser-config-start") - from .browser import setup_browser_config # noqa: PLC0415 - browser_type, browser_location, remote_debugging_url = setup_browser_config() update_or_add_env_var("BROWSER_TYPE", browser_type) if browser_location: diff --git a/skyvern/cli/mcp.py b/skyvern/cli/mcp.py index 2784173cc..0c0b1fa57 100644 --- a/skyvern/cli/mcp.py +++ b/skyvern/cli/mcp.py @@ -1,67 +1,31 @@ -from __future__ import annotations - -import base64 -import hashlib -import hmac -import json -from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any - from rich.panel import Panel from rich.prompt import Confirm from skyvern.forge import app +from skyvern.forge.sdk.core import security from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType +from skyvern.forge.sdk.schemas.organizations import Organization +from skyvern.forge.sdk.services.local_org_auth_token_service import SKYVERN_LOCAL_DOMAIN, SKYVERN_LOCAL_ORG +from skyvern.forge.sdk.services.org_auth_token_service import API_KEY_LIFETIME from .console import console from .setup_commands import setup_claude, setup_claude_code, setup_cursor, setup_windsurf -if TYPE_CHECKING: - from skyvern.forge.sdk.schemas.organizations import Organization -API_KEY_LIFETIME = timedelta(weeks=5200) -SKYVERN_LOCAL_ORG = "Skyvern-local" -SKYVERN_LOCAL_DOMAIN = "skyvern.local" - - -def _base64url_encode(data: bytes) -> str: - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") - - -def _create_local_access_token(subject: str, expires_delta: timedelta) -> str: - from skyvern.config import settings # noqa: PLC0415 - - if settings.SIGNATURE_ALGORITHM != "HS256": - raise RuntimeError("Local quickstart token bootstrap only supports HS256 SIGNATURE_ALGORITHM") - - expire = datetime.utcnow() + expires_delta - header = {"alg": "HS256", "typ": "JWT"} - payload = {"exp": int(expire.timestamp()), "sub": str(subject)} - signing_input = ".".join( - [ - _base64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8")), - _base64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")), - ] - ) - signature = hmac.new(settings.SECRET_KEY.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256).digest() - return f"{signing_input}.{_base64url_encode(signature)}" - - -async def get_or_create_local_organization(database: Any | None = None) -> Organization: - database = database or app.DATABASE - organization = await database.organizations.get_organization_by_domain(SKYVERN_LOCAL_DOMAIN) +async def get_or_create_local_organization() -> Organization: + organization = await app.DATABASE.organizations.get_organization_by_domain(SKYVERN_LOCAL_DOMAIN) if not organization: - organization = await database.organizations.create_organization( + organization = await app.DATABASE.organizations.create_organization( organization_name=SKYVERN_LOCAL_ORG, domain=SKYVERN_LOCAL_DOMAIN, max_steps_per_run=10, max_retries_per_step=3, ) - api_key = _create_local_access_token( + api_key = security.create_access_token( organization.organization_id, expires_delta=API_KEY_LIFETIME, ) - await database.organizations.create_org_auth_token( + await app.DATABASE.organizations.create_org_auth_token( organization_id=organization.organization_id, token=api_key, token_type=OrganizationAuthTokenType.api, @@ -69,138 +33,15 @@ async def get_or_create_local_organization(database: Any | None = None) -> Organ return organization -async def setup_local_organization(database: Any | None = None) -> str: - database = database or app.DATABASE - organization = await get_or_create_local_organization(database=database) - org_auth_token = await database.organizations.get_valid_org_auth_token( +async def setup_local_organization() -> str: + organization = await get_or_create_local_organization() + org_auth_token = await app.DATABASE.organizations.get_valid_org_auth_token( organization_id=organization.organization_id, token_type=OrganizationAuthTokenType.api.value, ) return org_auth_token.token if org_auth_token else "" -async def setup_local_organization_from_database_string(database_string: str) -> str: - """Seed the local org/API key without importing the full repository graph.""" - from sqlalchemy import text # noqa: PLC0415 - from sqlalchemy.ext.asyncio import create_async_engine # noqa: PLC0415 - - from skyvern.forge.sdk.db.id import generate_org_id, generate_organization_auth_token_id # noqa: PLC0415 - - connect_args = {"prepare_threshold": None} if "postgresql+psycopg" in database_string else {} - engine = create_async_engine(database_string, connect_args=connect_args, pool_pre_ping=True) - try: - async with engine.begin() as connection: - organization_id = ( - await connection.execute( - text("SELECT organization_id FROM organizations WHERE domain = :domain LIMIT 1"), - {"domain": SKYVERN_LOCAL_DOMAIN}, - ) - ).scalar_one_or_none() - - if not organization_id: - organization_id = generate_org_id() - now = datetime.utcnow() - await connection.execute( - text( - """ - INSERT INTO organizations ( - organization_id, - organization_name, - domain, - max_steps_per_run, - max_retries_per_step, - created_at, - modified_at - ) - VALUES ( - :organization_id, - :organization_name, - :domain, - :max_steps_per_run, - :max_retries_per_step, - :created_at, - :modified_at - ) - """ - ), - { - "organization_id": organization_id, - "organization_name": SKYVERN_LOCAL_ORG, - "domain": SKYVERN_LOCAL_DOMAIN, - "max_steps_per_run": 10, - "max_retries_per_step": 3, - "created_at": now, - "modified_at": now, - }, - ) - api_key = _create_local_access_token( - organization_id, - expires_delta=API_KEY_LIFETIME, - ) - await connection.execute( - text( - """ - INSERT INTO organization_auth_tokens ( - id, - organization_id, - token_type, - token, - encrypted_token, - encrypted_method, - valid, - created_at, - modified_at - ) - VALUES ( - :id, - :organization_id, - :token_type, - :token, - :encrypted_token, - :encrypted_method, - :valid, - :created_at, - :modified_at - ) - """ - ), - { - "id": generate_organization_auth_token_id(), - "organization_id": organization_id, - "token_type": OrganizationAuthTokenType.api.value, - "token": api_key, - "encrypted_token": "", - "encrypted_method": "", - "valid": True, - "created_at": now, - "modified_at": now, - }, - ) - - token = ( - await connection.execute( - text( - """ - SELECT token - FROM organization_auth_tokens - WHERE organization_id = :organization_id - AND token_type = :token_type - AND valid = true - ORDER BY created_at DESC - LIMIT 1 - """ - ), - { - "organization_id": organization_id, - "token_type": OrganizationAuthTokenType.api.value, - }, - ) - ).scalar_one_or_none() - return token or "" - finally: - await engine.dispose() - - def setup_mcp( *, local: bool = False, diff --git a/skyvern/cli/quickstart.py b/skyvern/cli/quickstart.py index 66df19d4f..aeefe2178 100644 --- a/skyvern/cli/quickstart.py +++ b/skyvern/cli/quickstart.py @@ -7,7 +7,7 @@ import subprocess import sys from enum import Enum from pathlib import Path -from typing import Any, Literal +from typing import Any import typer from rich.markup import escape @@ -36,7 +36,7 @@ def _server_extra_install_target() -> str: return f"skyvern[server]=={version}" -def _install_server_extra_for_quickstart(*, assume_yes: bool = False) -> bool: +def _install_server_extra_for_quickstart() -> bool: target = _server_extra_install_target() console.print( Panel( @@ -46,16 +46,13 @@ def _install_server_extra_for_quickstart(*, assume_yes: bool = False) -> bool: border_style="yellow", ) ) - if not assume_yes and not Confirm.ask("Install the missing server dependencies now?", default=True): + if not Confirm.ask("Install the missing server dependencies now?", default=True): _print_server_guidance() return False console.print(f"📦 [bold blue]Installing {escape(target)}...[/bold blue]") try: - subprocess.run( - [sys.executable, "-m", "pip", "install", "--retries", "5", "--timeout", "60", target], - check=True, - ) + subprocess.run([sys.executable, "-m", "pip", "install", target], check=True) except subprocess.CalledProcessError as install_error: console.print(f"[bold red]Failed to install {target}: {install_error}[/bold red]") _print_server_guidance() @@ -288,13 +285,6 @@ def _run_server_quickstart( database_string: str, skip_browser_install: bool, server_only: bool, - skip_llm_setup: bool = False, - configure_mcp: bool | None = None, - browser_type: Literal["chromium-headful", "chromium-headless", "cdp-connect"] | None = None, - browser_location: str | None = None, - remote_debugging_url: str | None = None, - analytics_id: str | None = None, - start_services_now: bool | None = None, ) -> None: try: from skyvern.cli.init_command import init_env # noqa: PLC0415 @@ -306,13 +296,6 @@ def _run_server_quickstart( no_postgres=no_postgres, database_string=database_string, skip_browser_install=skip_browser_install, - mode="local", - skip_llm_setup=skip_llm_setup, - configure_mcp=configure_mcp, - browser_type=browser_type, - browser_location=browser_location, - remote_debugging_url=remote_debugging_url, - analytics_id=analytics_id, return_result=True, ) run_local = bool(init_result) @@ -338,11 +321,7 @@ def _run_server_quickstart( ) return - start_now = ( - start_services_now - if start_services_now is not None - else typer.confirm("\nDo you want to start Skyvern services now?", default=True) - ) + start_now = typer.confirm("\nDo you want to start Skyvern services now?", default=True) if start_now: console.print("\n[bold blue]Starting Skyvern services...[/bold blue]") asyncio.run(start_services(server_only=server_only)) @@ -689,78 +668,12 @@ def quickstart( "--env-scope", help="Backend env location for setup writes: legacy/current, project, or global.", ), - yes: bool = typer.Option( - False, - "--yes", - "-y", - help="Automatically approve installing missing Skyvern server dependencies.", - ), - non_interactive: bool = typer.Option( - False, - "--non-interactive", - help=( - "Run the self-hosted server quickstart path without prompts. Installs missing server dependencies, " - "skips LLM/MCP prompts, uses headless Chromium, anonymous analytics, and does not start services." - ), - ), - skip_llm_setup: bool = typer.Option( - False, - "--skip-llm-setup", - help="Skip interactive LLM provider setup and keep/default environment values.", - ), - skip_mcp: bool = typer.Option(False, "--skip-mcp", help="Skip interactive MCP server configuration."), - browser_type: Literal["chromium-headful", "chromium-headless", "cdp-connect"] | None = typer.Option( - None, - "--browser-type", - help="Browser type to write without prompting.", - ), - browser_location: str | None = typer.Option( - None, - "--browser-location", - help="Chrome executable path to write when using a custom browser location.", - ), - remote_debugging_url: str | None = typer.Option( - None, - "--remote-debugging-url", - help="CDP URL to write when using --browser-type cdp-connect.", - ), - analytics_id: str | None = typer.Option( - None, - "--analytics-id", - help="Analytics identifier to write without prompting. Use 'anonymous' for agent tests.", - ), - start_services_now: bool | None = typer.Option( - None, - "--start/--no-start", - help="Start Skyvern services after setup. Omit to prompt.", - ), ) -> None: """Quickstart command to set up and run Skyvern with one command.""" # Run initialization console.print(Panel("[bold green]🚀 Starting Skyvern Quickstart[/bold green]", border_style="green")) install_type_value = install_type if isinstance(install_type, str) else None env_scope_value = env_scope if isinstance(env_scope, str) else None - if non_interactive: - install_type_value = install_type_value or QuickstartPath.SERVER.value - yes = True - skip_llm_setup = True - skip_mcp = True - browser_type = browser_type or "chromium-headless" - analytics_id = analytics_id or "anonymous" - if start_services_now is None: - start_services_now = False - if not database_string: - no_postgres = True - console.print( - Panel( - "[bold yellow]Non-interactive quickstart will not prompt to start PostgreSQL.[/bold yellow]\n\n" - "No [cyan]--database-string[/cyan] was provided, so Skyvern will use any existing " - "[cyan]DATABASE_STRING[/cyan] from your environment or .env file.\n\n" - "For unattended agent tests, pass " - "[cyan]--database-string postgresql+psycopg://user:pass@host:5432/dbname[/cyan].", - border_style="yellow", - ) - ) has_server_extra = _has_server_quickstart_extra() # The server extra is a superset of the local embedded runtime dependencies. @@ -772,16 +685,6 @@ def quickstart( server_only=server_only, ) if docker_compose: - if non_interactive: - console.print( - Panel( - "[bold red]Non-interactive Docker Compose quickstart is not supported yet.[/bold red]\n" - "Use [cyan]--install-type server --database-string postgresql+psycopg://...[/cyan] " - "for unattended agent tests.", - border_style="red", - ) - ) - raise typer.Exit(1) selected_path = ( QuickstartPath.SERVER if install_type_value is None else _parse_quickstart_path(install_type_value) ) @@ -858,7 +761,7 @@ def quickstart( return # If Docker Compose file exists, offer the choice - if not non_interactive and docker_compose_available and check_docker() and not server_flags_requested: + if docker_compose_available and check_docker() and not server_flags_requested: console.print("\n[bold blue]Setup Method[/bold blue]") console.print("Docker Compose file detected. Choose your setup method:\n") console.print(" [cyan]1.[/cyan] [green]Docker Compose (Recommended)[/green] - Full containerized setup") @@ -874,10 +777,10 @@ def quickstart( return if not _has_server_quickstart_extra(): - if not yes and not _is_interactive_input(): + if not _is_interactive_input(): _print_server_guidance() raise typer.Exit(0) - if not _install_server_extra_for_quickstart(assume_yes=yes) or not _has_server_quickstart_extra(): + if not _install_server_extra_for_quickstart() or not _has_server_quickstart_extra(): raise typer.Exit(1) _run_server_quickstart( @@ -885,11 +788,4 @@ def quickstart( database_string=database_string, skip_browser_install=skip_browser_install, server_only=server_only, - skip_llm_setup=skip_llm_setup, - configure_mcp=False if skip_mcp else None, - browser_type=browser_type, - browser_location=browser_location, - remote_debugging_url=remote_debugging_url, - analytics_id=analytics_id, - start_services_now=start_services_now, ) diff --git a/skyvern/exceptions.py b/skyvern/exceptions.py index 131672cfc..b3ab55085 100644 --- a/skyvern/exceptions.py +++ b/skyvern/exceptions.py @@ -8,13 +8,10 @@ from typing import NoReturn # the heavy modules users commonly have partially installed. _LOCAL_EXTRA_SENTINELS = ( "fastapi", - "fuzzysearch", "jinja2", "libcst", "litellm", - "openai", "playwright", - "psycopg", "sqlalchemy", "starlette", "starlette_context", @@ -25,7 +22,7 @@ _LOCAL_EXTRA_SENTINELS = ( # server sentinels local-compatible so those imports continue to work in # skyvern[local]. Full server entrypoints pass server-only module_names such as # "uvicorn" when they need to fail for local-only installs. -_SERVER_EXTRA_SENTINELS = tuple(dict.fromkeys((*_LOCAL_EXTRA_SENTINELS, "alembic", "anthropic"))) +_SERVER_EXTRA_SENTINELS = tuple(_LOCAL_EXTRA_SENTINELS) _EXTRA_SUPPORT_LABELS = { "local": "local embedded/browser support", diff --git a/skyvern/forge/prompts/skyvern/custom-select.j2 b/skyvern/forge/prompts/skyvern/custom-select.j2 index d35fdf7fc..f1aa13846 100644 --- a/skyvern/forge/prompts/skyvern/custom-select.j2 +++ b/skyvern/forge/prompts/skyvern/custom-select.j2 @@ -7,9 +7,10 @@ You can identify the matching element based on the following guidelines: - If a field is required, do not leave it blank. - If a field is required, do not select a placeholder value, such as "Please select", "-", or "Select...". - Exclude loading indicators like "loading more results" as valid options.{% if new_elements_ids %} - - The matching element can only be in the emerging elements.{% endif %}{% if select_history %} - - The selection history displays the previously selected values for the multi-level selection. Continue to complete the entire selection process.{% if is_date_related %} - - Date picker might be triggered, you goal is to set the correct start date and end date.{% endif %}{% endif %} + - The matching element can only be in the emerging elements.{% endif %}{% if is_date_related %} + - A date picker might be triggered. Use the target value when provided, the user goal, and visible date controls to set the requested date range. + - Do not choose "Today" unless the target date is today.{% endif %}{% if select_history %} + - The selection history displays the previously selected values for the multi-level selection. Continue to complete the entire selection process.{% endif %} MAKE SURE YOU OUTPUT VALID JSON. No text before or after JSON, no trailing commas, no comments (//), no unnecessary quotes, etc. Each interactable element is tagged with an ID. @@ -63,4 +64,4 @@ Select History: Current datetime, ISO format: ``` {{ local_datetime }} -``` \ No newline at end of file +``` diff --git a/skyvern/forge/sdk/api/llm/api_handler_factory.py b/skyvern/forge/sdk/api/llm/api_handler_factory.py index cdc25c245..a8db373bb 100644 --- a/skyvern/forge/sdk/api/llm/api_handler_factory.py +++ b/skyvern/forge/sdk/api/llm/api_handler_factory.py @@ -988,11 +988,13 @@ class LLMAPIHandlerFactory: model_used = main_model_group llm_request_json = "" + llm_duration_seconds = 0.0 async def _call_primary_with_vertex_cache( cache_name: str, cache_variant_name: str | None, ) -> tuple[ModelResponse, str, str]: + nonlocal llm_duration_seconds if primary_model_dict is None: raise ValueError("Primary router model missing configuration") litellm_params = copy.deepcopy(primary_model_dict.get("litellm_params") or {}) @@ -1031,23 +1033,32 @@ class LLMAPIHandlerFactory: cache_variant=cache_variant_name, ) request_payload_json = await _log_llm_request_artifact(request_model, True) - response = await litellm.acompletion( - model=request_model, - messages=active_messages, - drop_params=True, - **active_params, - ) + _llm_call_start = time.perf_counter() + try: + response = await litellm.acompletion( + model=request_model, + messages=active_messages, + drop_params=True, + **active_params, + ) + finally: + llm_duration_seconds += time.perf_counter() - _llm_call_start return response, request_model, request_payload_json async def _call_router_without_cache() -> tuple[ModelResponse, str]: + nonlocal llm_duration_seconds request_payload_json = await _log_llm_request_artifact(llm_key, False) - response = await router.acompletion( - model=main_model_group, - messages=messages, - timeout=settings.LLM_CONFIG_TIMEOUT, - drop_params=True, - **parameters, - ) + _llm_call_start = time.perf_counter() + try: + response = await router.acompletion( + model=main_model_group, + messages=messages, + timeout=settings.LLM_CONFIG_TIMEOUT, + drop_params=True, + **parameters, + ) + finally: + llm_duration_seconds += time.perf_counter() - _llm_call_start return response, request_payload_json try: @@ -1103,13 +1114,17 @@ class LLMAPIHandlerFactory: fallback_params = { k: v for k, v in parameters.items() if k not in ("max_completion_tokens", "max_tokens") } - response = await router.acompletion( - model=fallback_model, - messages=messages, - timeout=settings.LLM_CONFIG_TIMEOUT, - drop_params=True, - **fallback_params, - ) + _llm_call_start = time.perf_counter() + try: + response = await router.acompletion( + model=fallback_model, + messages=messages, + timeout=settings.LLM_CONFIG_TIMEOUT, + drop_params=True, + **fallback_params, + ) + finally: + llm_duration_seconds += time.perf_counter() - _llm_call_start model_used = response.model or fallback_model if is_truncated_response(response): _fb_usage = response.usage if hasattr(response, "usage") and response.usage else None @@ -1328,6 +1343,7 @@ class LLMAPIHandlerFactory: model=model_used, prompt_name=prompt_name, duration_seconds=duration_seconds, + llm_duration_seconds=llm_duration_seconds, step_id=step.step_id if step else None, thought_id=thought.observer_thought_id if thought else None, organization_id=organization_id, @@ -1669,6 +1685,7 @@ class LLMAPIHandlerFactory: LOG.warning("Could not find static prompt to strip from cached request") t_llm_request = time.perf_counter() + llm_duration_seconds = 0.0 try: # TODO (kerem): add a retry mechanism to this call (acompletion_with_retries) # TODO (kerem): use litellm fallbacks? https://litellm.vercel.app/docs/tutorials/fallbacks#how-does-completion_with_fallbacks-work @@ -1678,6 +1695,7 @@ class LLMAPIHandlerFactory: drop_params=True, # Drop unsupported parameters gracefully **active_parameters, ) + llm_duration_seconds = time.perf_counter() - t_llm_request # Error paths only set status=error, not token/cost attrs via # _enrich_llm_span — no response object exists so there's nothing to report. except litellm.exceptions.APIError as e: @@ -1866,6 +1884,7 @@ class LLMAPIHandlerFactory: prompt_name=prompt_name, model=llm_config.model_name, duration_seconds=duration_seconds, + llm_duration_seconds=llm_duration_seconds, step_id=step.step_id if step else None, thought_id=thought.observer_thought_id if thought else None, organization_id=organization_id, @@ -2226,6 +2245,7 @@ class LLMCaller: ) t_llm_request = time.perf_counter() + llm_duration_seconds = 0.0 try: # `timeout` may already live in active_parameters via litellm_params (flex configs # carry their own); passing it explicitly too collides on kwarg unpacking. @@ -2234,6 +2254,7 @@ class LLMCaller: tools=tools, **active_parameters, ) + llm_duration_seconds = time.perf_counter() - t_llm_request if use_message_history: # only update message_history when the request is successful self.message_history = messages @@ -2337,6 +2358,7 @@ class LLMCaller: prompt_name=prompt_name, model=self.llm_config.model_name, duration_seconds=duration_seconds, + llm_duration_seconds=llm_duration_seconds, step_id=step.step_id if step else None, thought_id=thought.observer_thought_id if thought else None, organization_id=organization_id, diff --git a/skyvern/webeye/actions/handler.py b/skyvern/webeye/actions/handler.py index e01efc067..e90f026b4 100644 --- a/skyvern/webeye/actions/handler.py +++ b/skyvern/webeye/actions/handler.py @@ -1427,13 +1427,25 @@ async def handle_sequential_click_for_dropdown( step=step, ) + options = CustomSelectPromptOptions( + field_information=dropdown_select_context.intention + if dropdown_select_context.intention + else dropdown_select_context.field, + is_date_related=dropdown_select_context.is_date_related, + required_field=dropdown_select_context.is_required, + ) + if dropdown_select_context.is_date_related: - LOG.info( - "The dropdown is date related, exiting the sequential click logic and skipping the remaining actions", + return await _select_date_from_emerging_elements_or_skip( + current_element_id=anchor_element.get_id(), + options=options, + page=page, + scraped_page=scraped_page, + step=step, + task=task, + scraped_page_after_open=scraped_page_after_open, + new_interactable_element_ids=new_interactable_element_ids, ) - result = ActionSuccess() - result.skip_remaining_actions = True - return result LOG.info( "Found the dropdown menu element after clicking, triggering the sequential click logic", @@ -1442,13 +1454,7 @@ async def handle_sequential_click_for_dropdown( return await select_from_emerging_elements( current_element_id=anchor_element.get_id(), - options=CustomSelectPromptOptions( - field_information=dropdown_select_context.intention - if dropdown_select_context.intention - else dropdown_select_context.field, - is_date_related=dropdown_select_context.is_date_related, - required_field=dropdown_select_context.is_required, - ), + options=options, page=page, scraped_page=scraped_page, step=step, @@ -4118,6 +4124,49 @@ class CustomSelectPromptOptions(BaseModel): target_value: str | None = None +async def _select_date_from_emerging_elements_or_skip( + current_element_id: str, + options: CustomSelectPromptOptions, + page: Page, + scraped_page: ScrapedPage, + step: Step, + task: Task, + scraped_page_after_open: ScrapedPage, + new_interactable_element_ids: list[str], +) -> ActionResult: + try: + result = await select_from_emerging_elements( + current_element_id=current_element_id, + options=options, + page=page, + scraped_page=scraped_page, + step=step, + task=task, + scraped_page_after_open=scraped_page_after_open, + new_interactable_element_ids=new_interactable_element_ids, + ) + except Exception: + LOG.warning( + "Date-related emerging element selection failed, preserving skip behavior", + current_element_id=current_element_id, + exc_info=True, + ) + result = ActionSuccess() + + if not result.success: + LOG.warning( + "Date-related emerging element selection returned failure, preserving skip behavior", + current_element_id=current_element_id, + selection_exception_type=result.exception_type, + selection_exception_message=result.exception_message, + selection_data=result.data, + ) + result = ActionSuccess() + + result.skip_remaining_actions = True + return result + + def _extract_new_subtrees(elements: list[dict], new_ids: set[str]) -> list[dict]: """Walk *elements* and return the minimal set of subtrees rooted at new IDs. diff --git a/tests/unit/test_cdp_connect_headers_migration.py b/tests/unit/test_cdp_connect_headers_migration.py index 991972f74..5fbd2d798 100644 --- a/tests/unit/test_cdp_connect_headers_migration.py +++ b/tests/unit/test_cdp_connect_headers_migration.py @@ -11,7 +11,7 @@ def _find_migration_path() -> Path: paths = [ path for path in sorted(MIGRATION_DIR.glob("*_add_cdp_connect_headers.py")) - if "_execute_with_retry" in path.read_text() + if "_LOCK_TIMEOUT" in path.read_text() ] assert len(paths) == 1 return paths[0] @@ -52,7 +52,7 @@ def _load_migration_module(): return module -def test_retry_uses_nowait_lock_and_rolls_back_failed_attempts(monkeypatch: pytest.MonkeyPatch) -> None: +def test_retry_uses_bounded_lock_wait_and_rolls_back_failed_attempts(monkeypatch: pytest.MonkeyPatch) -> None: migration = _load_migration_module() fake_op = _FakeOp(lock_failures=2) sleeps: list[int] = [] @@ -60,6 +60,7 @@ def test_retry_uses_nowait_lock_and_rolls_back_failed_attempts(monkeypatch: pyte monkeypatch.setattr(migration, "op", fake_op) monkeypatch.setattr(migration.time, "sleep", sleeps.append) monkeypatch.setattr(migration.time, "monotonic", lambda: 0.0) + monkeypatch.setattr(migration.random, "random", lambda: 0.5) migration._execute_with_retry( "tasks", @@ -67,19 +68,22 @@ def test_retry_uses_nowait_lock_and_rolls_back_failed_attempts(monkeypatch: pyte deadline=100.0, ) - assert sleeps == [5, 5] + assert sleeps == [0.625, 0.625] assert fake_op.statements == [ "BEGIN", "SET LOCAL statement_timeout = '5s'", - 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE NOWAIT', + "SET LOCAL lock_timeout = '250ms'", + 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE', "ROLLBACK", "BEGIN", "SET LOCAL statement_timeout = '5s'", - 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE NOWAIT', + "SET LOCAL lock_timeout = '250ms'", + 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE', "ROLLBACK", "BEGIN", "SET LOCAL statement_timeout = '5s'", - 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE NOWAIT', + "SET LOCAL lock_timeout = '250ms'", + 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE', 'ALTER TABLE "tasks" ADD COLUMN IF NOT EXISTS cdp_connect_headers JSON', "COMMIT", ] @@ -93,6 +97,7 @@ def test_retry_stops_when_deadline_is_exhausted(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr(migration, "op", fake_op) monkeypatch.setattr(migration.time, "sleep", sleeps.append) monkeypatch.setattr(migration.time, "monotonic", lambda: 101.0) + monkeypatch.setattr(migration.random, "random", lambda: 0.5) with pytest.raises(DBAPIError): migration._execute_with_retry( @@ -105,7 +110,8 @@ def test_retry_stops_when_deadline_is_exhausted(monkeypatch: pytest.MonkeyPatch) assert fake_op.statements == [ "BEGIN", "SET LOCAL statement_timeout = '5s'", - 'LOCK TABLE "observer_cruises" IN ACCESS EXCLUSIVE MODE NOWAIT', + "SET LOCAL lock_timeout = '250ms'", + 'LOCK TABLE "observer_cruises" IN ACCESS EXCLUSIVE MODE', "ROLLBACK", ] @@ -118,6 +124,7 @@ def test_retry_recognizes_pgcode_lock_errors(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(migration, "op", fake_op) monkeypatch.setattr(migration.time, "sleep", sleeps.append) monkeypatch.setattr(migration.time, "monotonic", lambda: 0.0) + monkeypatch.setattr(migration.random, "random", lambda: 1.0) migration._execute_with_retry( "tasks", @@ -125,15 +132,17 @@ def test_retry_recognizes_pgcode_lock_errors(monkeypatch: pytest.MonkeyPatch) -> deadline=100.0, ) - assert sleeps == [5] + assert sleeps == [1.0] assert fake_op.statements == [ "BEGIN", "SET LOCAL statement_timeout = '5s'", - 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE NOWAIT', + "SET LOCAL lock_timeout = '250ms'", + 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE', "ROLLBACK", "BEGIN", "SET LOCAL statement_timeout = '5s'", - 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE NOWAIT', + "SET LOCAL lock_timeout = '250ms'", + 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE', 'ALTER TABLE "tasks" ADD COLUMN IF NOT EXISTS cdp_connect_headers JSON', "COMMIT", ] @@ -147,6 +156,7 @@ def test_retry_aborts_when_rollback_fails(monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setattr(migration, "op", fake_op) monkeypatch.setattr(migration.time, "sleep", sleeps.append) monkeypatch.setattr(migration.time, "monotonic", lambda: 0.0) + monkeypatch.setattr(migration.random, "random", lambda: 0.5) with pytest.raises(RuntimeError, match="rollback failed"): migration._execute_with_retry( @@ -159,12 +169,39 @@ def test_retry_aborts_when_rollback_fails(monkeypatch: pytest.MonkeyPatch) -> No assert fake_op.statements == [ "BEGIN", "SET LOCAL statement_timeout = '5s'", - 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE NOWAIT', + "SET LOCAL lock_timeout = '250ms'", + 'LOCK TABLE "tasks" IN ACCESS EXCLUSIVE MODE', "ROLLBACK", ] -def test_retry_budget_stays_under_ten_minutes() -> None: +def test_retry_budget_is_twenty_minutes() -> None: migration = _load_migration_module() - assert migration._MIGRATION_RETRY_SECONDS == 10 * 60 + assert migration._MIGRATION_RETRY_SECONDS == 20 * 60 + + +def test_lock_wait_budget_is_bounded_to_250ms() -> None: + migration = _load_migration_module() + + assert migration._LOCK_TIMEOUT == "250ms" + + +def test_retry_backoff_samples_sub_second_jitter(monkeypatch: pytest.MonkeyPatch) -> None: + migration = _load_migration_module() + fake_op = _FakeOp(lock_failures=2) + sleeps: list[float] = [] + draws = iter([0.0, 1.0]) + + monkeypatch.setattr(migration, "op", fake_op) + monkeypatch.setattr(migration.time, "sleep", sleeps.append) + monkeypatch.setattr(migration.time, "monotonic", lambda: 0.0) + monkeypatch.setattr(migration.random, "random", lambda: next(draws)) + + migration._execute_with_retry( + "tasks", + 'ALTER TABLE "tasks" ADD COLUMN IF NOT EXISTS cdp_connect_headers JSON', + deadline=100.0, + ) + + assert sleeps == [0.25, 1.0] diff --git a/tests/unit/test_date_dropdown_emerging_selection.py b/tests/unit/test_date_dropdown_emerging_selection.py new file mode 100644 index 000000000..daaade388 --- /dev/null +++ b/tests/unit/test_date_dropdown_emerging_selection.py @@ -0,0 +1,109 @@ +from pathlib import Path +from typing import Any + +import pytest +from jinja2 import Template + +from skyvern.webeye.actions import handler +from skyvern.webeye.actions.handler import CustomSelectPromptOptions +from skyvern.webeye.actions.responses import ActionFailure, ActionSuccess + + +@pytest.mark.asyncio +async def test_date_dropdown_tries_emerging_element_selection(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + async def fake_select_from_emerging_elements(**kwargs: Any) -> ActionSuccess: + captured.update(kwargs) + return ActionSuccess(data={"selected": "01.01.2026"}) + + monkeypatch.setattr(handler, "select_from_emerging_elements", fake_select_from_emerging_elements) + options = CustomSelectPromptOptions( + field_information="From date", + is_date_related=True, + required_field=True, + ) + + result = await handler._select_date_from_emerging_elements_or_skip( + current_element_id="date-anchor", + options=options, + page=object(), + scraped_page=object(), + step=object(), + task=object(), + scraped_page_after_open=object(), + new_interactable_element_ids=["day-1"], + ) + + assert result.success is True + assert result.skip_remaining_actions is True + assert captured["current_element_id"] == "date-anchor" + assert captured["options"] is options + assert captured["new_interactable_element_ids"] == ["day-1"] + + +@pytest.mark.asyncio +async def test_date_dropdown_preserves_skip_behavior_when_selection_fails(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_select_from_emerging_elements(**kwargs: Any) -> ActionSuccess: + raise RuntimeError("no exact date option") + + monkeypatch.setattr(handler, "select_from_emerging_elements", fake_select_from_emerging_elements) + + result = await handler._select_date_from_emerging_elements_or_skip( + current_element_id="date-anchor", + options=CustomSelectPromptOptions(field_information="From date", is_date_related=True), + page=object(), + scraped_page=object(), + step=object(), + task=object(), + scraped_page_after_open=object(), + new_interactable_element_ids=["day-1"], + ) + + assert result.success is True + assert result.skip_remaining_actions is True + + +@pytest.mark.asyncio +async def test_date_dropdown_preserves_skip_behavior_when_selection_returns_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_select_from_emerging_elements(**kwargs: Any) -> ActionFailure: + return ActionFailure(exception=RuntimeError("date option was not actionable")) + + monkeypatch.setattr(handler, "select_from_emerging_elements", fake_select_from_emerging_elements) + + result = await handler._select_date_from_emerging_elements_or_skip( + current_element_id="date-anchor", + options=CustomSelectPromptOptions(field_information="From date", is_date_related=True), + page=object(), + scraped_page=object(), + step=object(), + task=object(), + scraped_page_after_open=object(), + new_interactable_element_ids=["day-1"], + ) + + assert result.success is True + assert result.skip_remaining_actions is True + + +def test_custom_select_date_guidance_renders_without_select_history() -> None: + template_path = Path("skyvern/forge/prompts/skyvern/custom-select.j2") + prompt = Template(template_path.read_text()).render( + field_information="From date", + required_field=True, + is_date_related=True, + navigation_goal="Set the from date to 01.01.2026.", + navigation_payload_str="{}", + elements='', + new_elements_ids=["day-1"], + select_history="", + target_value="01.01.2026", + support_complete_action=False, + local_datetime="2026-05-21T12:00:00", + ) + + assert "A date picker might be triggered." in prompt + assert 'Do not choose "Today" unless the target date is today.' in prompt + assert "Select History:" not in prompt diff --git a/tests/unit/test_init_command.py b/tests/unit/test_init_command.py index a6aac409e..4fad7634b 100644 --- a/tests/unit/test_init_command.py +++ b/tests/unit/test_init_command.py @@ -1,9 +1,7 @@ from __future__ import annotations import subprocess -from collections.abc import Callable from pathlib import Path -from typing import Any import pytest @@ -48,58 +46,4 @@ def test_run_with_server_dependency_install_installs_and_retries(monkeypatch: py assert init_command._run_with_server_dependency_install(action) == "ok" assert calls == ["action", "action"] - assert install_calls == [ - [ - "/usr/bin/python", - "-m", - "pip", - "install", - "--retries", - "5", - "--timeout", - "60", - "skyvern[server]==1.2.3", - ] - ] - - -def test_init_env_wraps_local_org_setup_with_server_dependency_install( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - wrapped_results: list[Any] = [] - - def fake_run_with_server_dependency_install(action: Callable[[], Any]) -> Any: - result = action() - wrapped_results.append(result) - return result - - async def fake_setup_local_organization() -> str: - return "skyvern-api-key" - - env_updates: dict[str, str] = {} - - def fake_update_or_add_env_var(key: str, value: str, **_kwargs: Any) -> None: - env_updates[key] = value - - monkeypatch.setenv(init_command.BACKEND_ENV_FILE_ENV_VAR, "") - monkeypatch.setattr(init_command, "setup_postgresql", lambda *args, **kwargs: None) - monkeypatch.setattr("skyvern.utils.migrate_db", lambda: None) - monkeypatch.setattr(init_command, "_setup_local_organization_from_database", fake_setup_local_organization) - monkeypatch.setattr(init_command, "_run_with_server_dependency_install", fake_run_with_server_dependency_install) - monkeypatch.setattr(init_command, "update_or_add_env_var", fake_update_or_add_env_var) - - result = init_command.init_env( - no_postgres=True, - skip_browser_install=True, - mode="local", - skip_llm_setup=True, - configure_mcp=False, - browser_type="cdp-connect", - analytics_id="anonymous", - env_path=tmp_path / ".env", - return_result=True, - ) - - assert result.run_local is True - assert wrapped_results == [None, "skyvern-api-key"] - assert env_updates["SKYVERN_API_KEY"] == "skyvern-api-key" + assert install_calls == [["/usr/bin/python", "-m", "pip", "install", "skyvern[server]==1.2.3"]] diff --git a/tests/unit/test_init_mcp.py b/tests/unit/test_init_mcp.py index 0054db577..056152d11 100644 --- a/tests/unit/test_init_mcp.py +++ b/tests/unit/test_init_mcp.py @@ -40,7 +40,7 @@ def test_init_callback_passes_plain_database_string(monkeypatch) -> None: monkeypatch.setattr( "skyvern.cli.init_command.init_env", - lambda no_postgres=False, database_string="", env_scope=None, **kwargs: calls.append( + lambda no_postgres=False, database_string="", env_scope=None: calls.append( (no_postgres, database_string, env_scope) ), ) diff --git a/tests/unit/test_quickstart_command.py b/tests/unit/test_quickstart_command.py index a032ae1d5..3c019787e 100644 --- a/tests/unit/test_quickstart_command.py +++ b/tests/unit/test_quickstart_command.py @@ -479,7 +479,7 @@ def test_quickstart_server_flags_select_server_path_without_server_extra(monkeyp monkeypatch.setattr( quickstart_module, "_install_server_extra_for_quickstart", - lambda **kwargs: install_calls.append(kwargs) or True, + lambda: install_calls.append("install") or True, ) monkeypatch.setattr(quickstart_module, "_run_server_quickstart", lambda **kwargs: server_calls.append(kwargs)) @@ -547,27 +547,20 @@ def test_quickstart_interactive_server_choice_can_install_server_extra(monkeypat monkeypatch.setattr( quickstart_module, "_install_server_extra_for_quickstart", - lambda **kwargs: install_calls.append(kwargs) or True, + lambda: install_calls.append("install") or True, ) monkeypatch.setattr(quickstart_module, "_run_server_quickstart", lambda **kwargs: server_calls.append(kwargs)) result = CliRunner().invoke(quickstart_module.quickstart_app, [], input="3\n") assert result.exit_code == 0 - assert install_calls == [{"assume_yes": False}] + assert install_calls == ["install"] assert server_calls == [ { "no_postgres": False, "database_string": "", "skip_browser_install": False, "server_only": False, - "skip_llm_setup": False, - "configure_mcp": None, - "browser_type": None, - "browser_location": None, - "remote_debugging_url": None, - "analytics_id": None, - "start_services_now": None, } ] @@ -728,13 +721,6 @@ def test_quickstart_with_server_extra_preserves_existing_flow(monkeypatch) -> No "database_string": "postgresql+psycopg://user/db", "skip_browser_install": True, "server_only": True, - "skip_llm_setup": False, - "configure_mcp": None, - "browser_type": None, - "browser_location": None, - "remote_debugging_url": None, - "analytics_id": None, - "start_services_now": None, } ] diff --git a/tests/unit/test_quickstart_server_extra.py b/tests/unit/test_quickstart_server_extra.py deleted file mode 100644 index a23b87dde..000000000 --- a/tests/unit/test_quickstart_server_extra.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -import subprocess - -from typer.testing import CliRunner - -from skyvern.cli import quickstart as quickstart_module -from skyvern.cli.quickstart import quickstart_app - - -def test_install_server_extra_assume_yes_skips_prompt(monkeypatch) -> None: - commands: list[list[str]] = [] - - monkeypatch.setattr(quickstart_module, "_server_extra_install_target", lambda: "skyvern[server]==1.2.3") - monkeypatch.setattr( - quickstart_module.Confirm, - "ask", - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not prompt")), - ) - monkeypatch.setattr( - quickstart_module.subprocess, - "run", - lambda command, **kwargs: commands.append(command) or subprocess.CompletedProcess(command, returncode=0), - ) - - assert quickstart_module._install_server_extra_for_quickstart(assume_yes=True) is True - assert commands == [ - [ - quickstart_module.sys.executable, - "-m", - "pip", - "install", - "--retries", - "5", - "--timeout", - "60", - "skyvern[server]==1.2.3", - ] - ] - - -def test_quickstart_non_interactive_server_options_skip_prompts(monkeypatch) -> None: - calls: list[dict] = [] - - def fake_init_env(**kwargs): - calls.append(kwargs) - return False - - monkeypatch.setattr("skyvern.cli.quickstart._has_server_quickstart_extra", lambda: True) - monkeypatch.setattr("skyvern.cli.quickstart.check_docker_compose_file", lambda: False) - monkeypatch.setattr("skyvern.cli.init_command.init_env", fake_init_env) - monkeypatch.setattr( - "skyvern.cli.quickstart.Confirm.ask", - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not prompt")), - ) - - result = CliRunner().invoke( - quickstart_app, - [ - "--non-interactive", - "--skip-browser-install", - "--database-string", - "postgresql+psycopg://skyvern:skyvern@postgres:5432/skyvern", - ], - ) - - assert result.exit_code == 0 - assert calls - call = calls[0] - assert call["no_postgres"] is False - assert call["mode"] == "local" - assert call["skip_llm_setup"] is True - assert call["configure_mcp"] is False - assert call["browser_type"] == "chromium-headless" - assert call["analytics_id"] == "anonymous" - assert call["return_result"] is True - - -def test_quickstart_non_interactive_without_database_string_skips_postgres_prompt(monkeypatch) -> None: - calls: list[dict] = [] - - monkeypatch.setattr("skyvern.cli.quickstart._has_server_quickstart_extra", lambda: True) - monkeypatch.setattr("skyvern.cli.quickstart.check_docker_compose_file", lambda: False) - monkeypatch.setattr("skyvern.cli.quickstart._run_server_quickstart", lambda **kwargs: calls.append(kwargs)) - monkeypatch.setattr( - "skyvern.cli.quickstart.Confirm.ask", - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not prompt")), - ) - - result = CliRunner().invoke(quickstart_app, ["--non-interactive"]) - - assert result.exit_code == 0 - assert "Non-interactive quickstart will not prompt to start PostgreSQL" in result.output - assert "--database-string" in result.output - assert calls - call = calls[0] - assert call["no_postgres"] is True - assert call["database_string"] == "" - assert call["skip_llm_setup"] is True - assert call["configure_mcp"] is False - assert call["browser_type"] == "chromium-headless" - assert call["analytics_id"] == "anonymous" - assert call["start_services_now"] is False diff --git a/uv.lock b/uv.lock index 3e01a49a3..077708e48 100644 --- a/uv.lock +++ b/uv.lock @@ -19,11 +19,9 @@ constraints = [ { name = "flask", specifier = ">=3.1.3" }, { name = "idna", specifier = ">=3.15" }, { name = "joserfc", specifier = ">=1.6.3" }, - { name = "jupyter-server", specifier = ">=2.18.0" }, { name = "mako", specifier = ">=1.3.12" }, { name = "mistune", specifier = ">=3.2.1" }, - { name = "pyasn1", specifier = ">=0.6.3" }, { name = "tornado", specifier = ">=6.5.5" }, { name = "werkzeug", specifier = ">=3.1.6" }, @@ -6212,15 +6210,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, ] [[package]]