mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
fix(SKY-9917): use bounded lock wait for cdp migration (#6122)
This commit is contained in:
parent
aa5664f215
commit
22612366d6
22 changed files with 736 additions and 880 deletions
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
109
tests/unit/test_date_dropdown_emerging_selection.py
Normal file
109
tests/unit/test_date_dropdown_emerging_selection.py
Normal 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
|
||||
|
|
@ -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"]]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
8
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue