fix(SKY-9917): use bounded lock wait for cdp migration (#6122)

This commit is contained in:
Andrew Neilson 2026-05-22 17:53:13 -04:00 committed by GitHub
parent aa5664f215
commit 22612366d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 736 additions and 880 deletions

View file

@ -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)

View file

@ -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",

View file

@ -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<HTMLInputElement | null>;
}
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<HTMLInputElement>) => 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<string | null>(
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<HTMLInputElement>(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 (
<div
className={cn(
"flex h-full w-full justify-between rounded-xl bg-slate-elevation2 px-6 py-5",
"flex h-full w-full items-center justify-between rounded-xl bg-slate-elevation2 px-4 py-5 xl:px-6",
)}
>
<div className="flex h-full items-center">
<div className="mr-2 flex h-full min-w-0 flex-1 items-center xl:mr-4">
<EditableNodeTitle
editable={!isRecording}
onChange={(newTitle) => {
@ -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"
/>
</div>
<div className="flex h-full items-center justify-end gap-4">
{shouldShowCacheControls && (
<>
{debugStore.isDebugMode && (
<Button
className="pl-2 pr-3"
size="lg"
variant={!showAllCode ? "tertiary" : "default"}
onClick={handleShowAllCode}
>
<CodeIcon className="mr-2 h-6 w-6" />
Show Code
</Button>
)}
<div
tabIndex={1}
className="flex max-w-[10rem] items-center justify-center gap-1 rounded-md border border-input pr-1 focus-within:ring-1 focus-within:ring-ring"
>
<Input
ref={dom.input}
className="focus-visible:transparent focus-visible:none h-[2.75rem] text-ellipsis whitespace-nowrap border-none focus-visible:outline-none focus-visible:ring-0"
onChange={(e) => {
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 ? (
<ChevronUpIcon
className="h-6 w-6 cursor-pointer"
onClick={onCacheKeyValuesClick}
/>
) : (
<ChevronDownIcon
className="h-6 w-6 cursor-pointer"
onClick={() => {
dom.input.current?.focus();
onCacheKeyValuesClick();
}}
/>
)}
</div>
</>
)}
<div className="flex h-full shrink-0 items-center justify-end gap-2 xl:gap-4">
{isGeneratingCode && (
<Button
className="size-10 min-w-[6rem]"
@ -400,73 +308,113 @@ function WorkflowHeader({
<TooltipContent>Save</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
disabled={
isRecording || templateMutation.isPending || saving
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="tertiary"
className="size-10 min-w-[2.5rem]"
disabled={isRecording}
aria-label="More actions"
>
<DotsHorizontalIcon className="size-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{shouldShowCacheControls && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<CodeIcon className="mr-2 size-4" />
Cache key: {cacheKeyValue || "default"}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-72 overflow-y-auto">
<DropdownMenuRadioGroup
value={cacheKeyValue ?? ""}
onValueChange={(v) => onCacheKeyValueAccept(v || null)}
>
<DropdownMenuRadioItem value="">
Default (no cache key)
</DropdownMenuRadioItem>
{cacheKeyValues?.values?.map((value) => (
<DropdownMenuRadioItem key={value} value={value}>
{value}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
{(cacheKeyValues?.values?.length ?? 0) === 0 && (
<div className="px-2 py-1.5 text-xs text-slate-400">
No cache keys yet
</div>
)}
{onBrowseCacheKeys && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onBrowseCacheKeys}>
Browse all cache keys
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{shouldShowCacheControls && debugStore.isDebugMode && (
<DropdownMenuItem onSelect={handleShowAllCode}>
<CodeIcon className="mr-2 size-4" />
{showAllCode ? "Hide Code" : "Show Code"}
</DropdownMenuItem>
)}
{shouldShowCacheControls && <DropdownMenuSeparator />}
<DropdownMenuItem
disabled={isRecording || templateMutation.isPending || saving}
onSelect={() => {
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 ? (
<ReloadIcon className="mr-2 size-4 animate-spin" />
) : isTemplate ? (
<BookmarkFilledIcon className="mr-2 size-4" />
) : (
<BookmarkIcon className="mr-2 size-4" />
)}
{templateMutation.isPending
? "Saving…"
: isTemplate
? "Remove from Templates"
: "Save as Template"}
</DropdownMenuItem>
{!workflowRunIsRunningOrQueued && (
<DropdownMenuItem
disabled={isRecording}
onSelect={() => {
onHistory?.();
}}
>
{templateMutation.isPending ? (
<ReloadIcon className="size-6 animate-spin" />
) : isTemplate ? (
<BookmarkFilledIcon className="size-6" />
) : (
<BookmarkIcon className="size-6" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isTemplate ? "Remove from Templates" : "Save as Template"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!workflowRunIsRunningOrQueued && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
disabled={isRecording}
size="icon"
variant="tertiary"
className="size-10 min-w-[2.5rem]"
onClick={() => {
onHistory?.();
}}
>
<VersionHistoryIcon size={24} />
</Button>
</TooltipTrigger>
<TooltipContent>History</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
disabled={isRecording}
variant="tertiary"
size="lg"
onClick={onScheduleClick}
>
<ClockIcon className="mr-2 h-5 w-5" />
<span className="mr-2">Schedule</span>
{schedulesPanelOpen ? (
<ChevronUpIcon className="h-6 w-6" />
) : (
<ChevronDownIcon className="h-6 w-6" />
)}
</Button>
<VersionHistoryIcon size={16} className="mr-2" />
History
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={isRecording}
onSelect={onScheduleClick}
>
<CalendarIcon className="mr-2 size-4" />
Schedule
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
navigate(`/workflows/${workflowPermanentId}/runs`);
}}
>
<CounterClockwiseClockIcon className="mr-2 size-4" />
Run history
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
disabled={isRecording}
variant="tertiary"

View file

@ -14,6 +14,7 @@ import {
ChevronRightIcon,
ChevronLeftIcon,
CopyIcon,
Cross2Icon,
GlobeIcon,
PlayIcon,
ReloadIcon,
@ -694,6 +695,17 @@ function Workspace({
};
});
// When the cache-key panel closes, drop any pending filter so the next
// surface (submenu or reopened panel) sees the full unfiltered set.
const cacheKeyPanelOpen =
workflowPanelState.active &&
workflowPanelState.content === "cacheKeyValues";
useEffect(() => {
if (!cacheKeyPanelOpen) {
setCacheKeyValueFilter("");
}
}, [cacheKeyPanelOpen]);
// Add window resize listener to trigger NoVNC canvas resize
// invalidate block scripts (so we always fetch latest on mount)
useEffect(() => {
@ -1135,24 +1147,6 @@ function Workspace({
doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
}
function openCacheKeyValuesPanel() {
setWorkflowPanelState({
active: true,
content: "cacheKeyValues",
});
}
function toggleCacheKeyValuesPanel() {
if (
workflowPanelState.active &&
workflowPanelState.content === "cacheKeyValues"
) {
closeWorkflowPanel();
} else {
openCacheKeyValuesPanel();
}
}
function toggleHistoryPanel() {
// Capture current state before making changes
const wasInComparisonMode = workflowPanelState.data?.showComparison;
@ -1418,42 +1412,20 @@ function Workspace({
isGeneratingCode={isGeneratingCode}
isTemplate={workflow?.is_template}
saving={workflowChangesStore.saveIsPending}
cacheKeyValuesPanelOpen={
workflowPanelState.active &&
workflowPanelState.content === "cacheKeyValues"
}
parametersPanelOpen={
workflowPanelState.active &&
workflowPanelState.content === "parameters"
}
schedulesPanelOpen={
workflowPanelState.active &&
workflowPanelState.content === "schedules"
}
showAllCode={showAllCode}
onCacheKeyValueAccept={(v) => {
setExplicitCacheKeyValue(v ?? "");
setCacheKeyValueFilter("");
closeWorkflowPanel();
}}
onCacheKeyValuesBlurred={(v) => {
setExplicitCacheKeyValue(v ?? "");
}}
onCacheKeyValuesKeydown={(e) => {
if (e.key === "Enter") {
toggleCacheKeyValuesPanel();
return;
}
if (e.key !== "Tab") {
openCacheKeyValuesPanel();
}
}}
onCacheKeyValuesFilter={(v) => {
setCacheKeyValueFilter(v);
}}
onCacheKeyValuesClick={() => {
toggleCacheKeyValuesPanel();
onBrowseCacheKeys={() => {
setWorkflowPanelState({
active: true,
content: "cacheKeyValues",
});
}}
onParametersClick={() => {
if (
@ -1531,14 +1503,17 @@ function Workspace({
{workflowPanelState.content === "cacheKeyValues" && (
<WorkflowCacheKeyValuesPanel
cacheKeyValues={cacheKeyValues}
filter={cacheKeyValueFilter ?? ""}
pending={cacheKeyValuesLoading}
scriptKey={workflow.cache_key ?? "default"}
onClose={closeWorkflowPanel}
onDelete={(cacheKeyValue) => {
deleteCacheKeyValue.mutate({
workflowPermanentId: workflowPermanentId!,
cacheKeyValue,
});
}}
onFilterChange={(v) => setCacheKeyValueFilter(v)}
onPaginate={(page) => {
setPage(page);
}}
@ -1556,13 +1531,14 @@ function Workspace({
)}
{workflowPanelState.content === "schedules" && (
<div className="z-30">
<WorkflowSchedulePanel />
<WorkflowSchedulePanel onClose={closeWorkflowPanel} />
</div>
)}
{workflowPanelState.content === "history" && (
<div className="pointer-events-auto relative right-0 top-[3.5rem] z-30 h-[calc(100vh-14rem)]">
<WorkflowHistoryPanel
workflowPermanentId={workflowPermanentId!}
onClose={closeWorkflowPanel}
onCompare={handleCompareVersions}
/>
</div>
@ -1611,14 +1587,17 @@ function Workspace({
{workflowPanelState.content === "cacheKeyValues" && (
<WorkflowCacheKeyValuesPanel
cacheKeyValues={cacheKeyValues}
filter={cacheKeyValueFilter ?? ""}
pending={cacheKeyValuesLoading}
scriptKey={workflow.cache_key ?? "default"}
onClose={closeWorkflowPanel}
onDelete={(cacheKeyValue) => {
deleteCacheKeyValue.mutate({
workflowPermanentId: workflowPermanentId!,
cacheKeyValue,
});
}}
onFilterChange={(v) => setCacheKeyValueFilter(v)}
onPaginate={(page) => {
setPage(page);
}}
@ -1636,13 +1615,14 @@ function Workspace({
)}
{workflowPanelState.content === "schedules" && (
<div className="z-30">
<WorkflowSchedulePanel />
<WorkflowSchedulePanel onClose={closeWorkflowPanel} />
</div>
)}
{workflowPanelState.content === "history" && (
<div className="pointer-events-auto relative right-0 top-[3.5rem] z-30 h-[calc(100vh-14rem)]">
<WorkflowHistoryPanel
workflowPermanentId={workflowPermanentId!}
onClose={closeWorkflowPanel}
onCompare={handleCompareVersions}
/>
</div>
@ -1672,14 +1652,17 @@ function Workspace({
{workflowPanelState.content === "cacheKeyValues" && (
<WorkflowCacheKeyValuesPanel
cacheKeyValues={cacheKeyValues}
filter={cacheKeyValueFilter ?? ""}
pending={cacheKeyValuesLoading}
scriptKey={workflow.cache_key ?? "default"}
onClose={closeWorkflowPanel}
onDelete={(cacheKeyValue) => {
deleteCacheKeyValue.mutate({
workflowPermanentId: workflowPermanentId!,
cacheKeyValue,
});
}}
onFilterChange={(v) => setCacheKeyValueFilter(v)}
onPaginate={(page) => {
setPage(page);
}}
@ -1694,12 +1677,13 @@ function Workspace({
<WorkflowParametersPanel />
)}
{workflowPanelState.content === "schedules" && (
<WorkflowSchedulePanel />
<WorkflowSchedulePanel onClose={closeWorkflowPanel} />
)}
{workflowPanelState.content === "history" && (
<div className="h-[calc(100vh-14rem)]">
<WorkflowHistoryPanel
workflowPermanentId={workflowPermanentId!}
onClose={closeWorkflowPanel}
onCompare={handleCompareVersions}
/>
</div>
@ -1740,8 +1724,17 @@ function Workspace({
})}
>
<div className="relative mt-[8.5rem] w-full p-6 pr-5 pt-0">
<div className="absolute right-[2rem] top-[0.75rem] z-20">
<div className="absolute right-[2rem] top-[0.75rem] z-20 flex items-center gap-2">
<CopyAndExplainCode code={code} />
<Button
size="icon"
variant="ghost"
aria-label="Close code view"
className="size-7"
onClick={() => setShowAllCode(false)}
>
<Cross2Icon className="size-4" />
</Button>
</div>
<CodeEditor
className={cn("w-full overflow-y-scroll", {

View file

@ -1,4 +1,9 @@
import { CrossCircledIcon, ReloadIcon } from "@radix-ui/react-icons";
import {
Cross2Icon,
CrossCircledIcon,
ReloadIcon,
} from "@radix-ui/react-icons";
import { useEffect, useState } from "react";
import {
Pagination,
PaginationContent,
@ -7,14 +12,18 @@ import {
PaginationPrevious,
} from "@/components/ui/pagination";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { CacheKeyValuesResponse } from "@/routes/workflows/types/scriptTypes";
import { cn } from "@/util/utils";
interface Props {
cacheKeyValues: CacheKeyValuesResponse | undefined;
filter?: string;
pending: boolean;
scriptKey: string;
onClose?: () => 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?.()}
>
<div className="space-y-4">
<header>
<h1 className="text-lg">Code Cache</h1>
<span className="text-sm text-slate-400">
Given your code key,{" "}
<code className="font-mono text-xs text-slate-200">
{scriptKey}
</code>
, search for saved code using a code key value. For this code key
there {totalCount === 1 ? "is" : "are"}{" "}
<span className="font-bold text-slate-200">{totalCount}</span> code
key {totalCount === 1 ? "value" : "values"}
{filteredCount !== totalCount && (
<>
{" "}
(
<span className="font-bold text-slate-200">
{filteredCount}
</span>{" "}
filtered)
</>
)}
.
</span>
<header className="flex items-start justify-between gap-3">
<div className="flex-1">
<h1 className="text-lg">Code Cache</h1>
<span className="text-sm text-slate-400">
Given your code key,{" "}
<code className="font-mono text-xs text-slate-200">
{scriptKey}
</code>
, search for saved code using a code key value. For this code key
there {totalCount === 1 ? "is" : "are"}{" "}
<span className="font-bold text-slate-200">{totalCount}</span>{" "}
code key {totalCount === 1 ? "value" : "values"}
{filteredCount !== totalCount && (
<>
{" "}
(
<span className="font-bold text-slate-200">
{filteredCount}
</span>{" "}
filtered)
</>
)}
.
</span>
</div>
{onClose && (
<Button
size="icon"
variant="ghost"
aria-label="Close"
className="size-7 shrink-0"
onClick={onClose}
>
<Cross2Icon className="size-4" />
</Button>
)}
</header>
{onFilterChange && (
<Input
placeholder="Search or type a new cache key value, press Enter"
value={draftFilter}
onChange={(e) => {
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);
}}
/>
)}
<div className="h-[10rem] w-full overflow-hidden overflow-y-auto border-b border-slate-700 p-1">
{values.length ? (
<div className="grid w-full grid-cols-[3rem_1fr_3rem] text-sm">

View file

@ -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) {
<div className="flex-shrink-0 p-4 pb-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Workflow History</h2>
<div className="text-sm text-muted-foreground">
{selectedVersions.size}/2 selected
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground">
{selectedVersions.size}/2 selected
</div>
{onClose && (
<Button
size="icon"
variant="ghost"
aria-label="Close"
className="size-7"
onClick={onClose}
>
<Cross2Icon className="size-4" />
</Button>
)}
</div>
</div>
<p className="mt-1 text-sm text-muted-foreground">

View file

@ -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})` : ""}
</h3>
<CreateScheduleDialog
workflowParameters={workflowParameters}
onSubmit={handleCreate}
isPending={createSchedule.isPending}
/>
<div className="flex items-center gap-2">
<CreateScheduleDialog
workflowParameters={workflowParameters}
onSubmit={handleCreate}
isPending={createSchedule.isPending}
/>
{onClose && (
<Button
size="icon"
variant="ghost"
aria-label="Close"
className="size-7"
onClick={onClose}
>
<Cross2Icon className="size-4" />
</Button>
)}
</div>
</div>
<ScrollArea>

View file

@ -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:

View file

@ -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:

View file

@ -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,

View file

@ -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,
)

View file

@ -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",

View file

@ -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 }}
```
```

View file

@ -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,

View file

@ -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.

View file

@ -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]

View file

@ -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='<button id="day-1">01</button><button>Today</button>',
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

View file

@ -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"]]

View file

@ -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)
),
)

View file

@ -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,
}
]

View file

@ -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

8
uv.lock generated
View file

@ -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]]