mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
Add cached script deploy dry-run primitives (#6012)
This commit is contained in:
parent
ecd6018b63
commit
6f920c9ac1
50 changed files with 1840 additions and 100 deletions
|
|
@ -0,0 +1,33 @@
|
|||
"""add cdp connect headers
|
||||
|
||||
Revision ID: 5f28f1f478d5
|
||||
Revises: 43e7421f40f8
|
||||
Create Date: 2026-05-15 16:16:44.776674+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5f28f1f478d5"
|
||||
down_revision: Union[str, None] = "43e7421f40f8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("observer_cruises", sa.Column("cdp_connect_headers", sa.JSON(), nullable=True))
|
||||
op.add_column("tasks", sa.Column("cdp_connect_headers", sa.JSON(), nullable=True))
|
||||
op.add_column("workflow_runs", sa.Column("cdp_connect_headers", sa.JSON(), nullable=True))
|
||||
op.add_column("workflows", sa.Column("cdp_connect_headers", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("workflows", "cdp_connect_headers")
|
||||
op.drop_column("workflow_runs", "cdp_connect_headers")
|
||||
op.drop_column("tasks", "cdp_connect_headers")
|
||||
op.drop_column("observer_cruises", "cdp_connect_headers")
|
||||
|
|
@ -330,7 +330,6 @@ Content-Type: application/json
|
|||
**Expected response:**
|
||||
```json
|
||||
{
|
||||
"task_id": "tsk_123456",
|
||||
"verification_code": "123456"
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ type Props = {
|
|||
maxScreenshotScrolls: number | null;
|
||||
extraHttpHeaders: Record<string, string> | null;
|
||||
browserProfileId: string | null;
|
||||
cdpConnectHeaders: Record<string, string> | null;
|
||||
runWith: string | null;
|
||||
};
|
||||
};
|
||||
|
|
@ -191,6 +192,7 @@ type RunWorkflowRequestBody = {
|
|||
browser_profile_id?: string | null;
|
||||
max_screenshot_scrolls?: number | null;
|
||||
extra_http_headers?: Record<string, string> | null;
|
||||
cdp_connect_headers?: Record<string, string> | null;
|
||||
browser_address?: string | null;
|
||||
run_with?: "agent" | "code";
|
||||
ai_fallback?: boolean;
|
||||
|
|
@ -208,6 +210,7 @@ function getRunWorkflowRequestBody(
|
|||
cdpAddress,
|
||||
maxScreenshotScrolls,
|
||||
extraHttpHeaders,
|
||||
cdpConnectHeaders,
|
||||
runWith,
|
||||
aiFallback,
|
||||
...parameters
|
||||
|
|
@ -248,6 +251,16 @@ function getRunWorkflowRequestBody(
|
|||
}
|
||||
}
|
||||
|
||||
if (cdpConnectHeaders) {
|
||||
try {
|
||||
body.cdp_connect_headers = JSON.parse(cdpConnectHeaders);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Invalid CDP Connect Headers: value must be valid JSON (e.g., {"x-api-key": "..."}).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
|
|
@ -291,6 +304,7 @@ type RunWorkflowFormType = Record<string, unknown> & {
|
|||
cdpAddress: string | null;
|
||||
maxScreenshotScrolls: number | null;
|
||||
extraHttpHeaders: string | null;
|
||||
cdpConnectHeaders: string | null;
|
||||
runWith: "agent" | "code";
|
||||
aiFallback: boolean | null;
|
||||
};
|
||||
|
|
@ -336,6 +350,9 @@ function RunWorkflowForm({
|
|||
extraHttpHeaders: initialSettings.extraHttpHeaders
|
||||
? JSON.stringify(initialSettings.extraHttpHeaders)
|
||||
: null,
|
||||
cdpConnectHeaders: initialSettings.cdpConnectHeaders
|
||||
? JSON.stringify(initialSettings.cdpConnectHeaders)
|
||||
: null,
|
||||
runWith: deriveRunWith(workflow, initialSettings.runWith),
|
||||
aiFallback: workflow?.ai_fallback ?? true,
|
||||
},
|
||||
|
|
@ -445,6 +462,9 @@ function RunWorkflowForm({
|
|||
extraHttpHeaders: initialSettings.extraHttpHeaders
|
||||
? JSON.stringify(initialSettings.extraHttpHeaders)
|
||||
: null,
|
||||
cdpConnectHeaders: initialSettings.cdpConnectHeaders
|
||||
? JSON.stringify(initialSettings.cdpConnectHeaders)
|
||||
: null,
|
||||
runWith: deriveRunWith(workflow, initialSettings.runWith),
|
||||
aiFallback: workflow?.ai_fallback ?? true,
|
||||
});
|
||||
|
|
@ -477,6 +497,7 @@ function RunWorkflowForm({
|
|||
browserProfileId,
|
||||
maxScreenshotScrolls,
|
||||
extraHttpHeaders,
|
||||
cdpConnectHeaders,
|
||||
cdpAddress,
|
||||
runWith,
|
||||
aiFallback,
|
||||
|
|
@ -495,6 +516,7 @@ function RunWorkflowForm({
|
|||
browserProfileId,
|
||||
maxScreenshotScrolls,
|
||||
extraHttpHeaders,
|
||||
cdpConnectHeaders,
|
||||
cdpAddress,
|
||||
runWith,
|
||||
aiFallback,
|
||||
|
|
@ -509,6 +531,7 @@ function RunWorkflowForm({
|
|||
"browserProfileId",
|
||||
"maxScreenshotScrolls",
|
||||
"extraHttpHeaders",
|
||||
"cdpConnectHeaders",
|
||||
"cdpAddress",
|
||||
"runWith",
|
||||
]);
|
||||
|
|
@ -1129,6 +1152,42 @@ function RunWorkflowForm({
|
|||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
key="cdpConnectHeaders"
|
||||
control={form.control}
|
||||
name="cdpConnectHeaders"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex gap-16">
|
||||
<FormLabel>
|
||||
<div className="w-72">
|
||||
<div className="flex items-center gap-2 text-lg">
|
||||
CDP Connect Headers
|
||||
</div>
|
||||
<h2 className="text-sm text-slate-400">
|
||||
Headers attached only to the CDP WebSocket
|
||||
handshake when connecting to a remote browser
|
||||
(e.g. auth for the CDP endpoint). Not
|
||||
forwarded to target sites.
|
||||
</h2>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<div className="w-full space-y-2">
|
||||
<FormControl>
|
||||
<KeyValueInput
|
||||
value={field.value ?? ""}
|
||||
onChange={(val) => field.onChange(val)}
|
||||
addButtonText="Add Header"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
key="maxScreenshotScrolls"
|
||||
control={form.control}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { WorkflowApiResponse } from "./types/workflowTypes";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ProxyLocation } from "@/api/types";
|
||||
import { getInitialValues } from "./utils";
|
||||
import { isMaskedHeaders } from "@/util/secretHeaders";
|
||||
|
||||
function WorkflowRunParameters() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
|
@ -46,6 +47,14 @@ function WorkflowRunParameters() {
|
|||
const browserProfileId =
|
||||
(location.state?.browserProfileId as string | null | undefined) ?? null;
|
||||
|
||||
const cdpConnectHeaders = location.state
|
||||
? (location.state.cdpConnectHeaders as Record<string, string>)
|
||||
: null;
|
||||
|
||||
const storedCdpConnectHeaders = isMaskedHeaders(workflow?.cdp_connect_headers)
|
||||
? null
|
||||
: (workflow?.cdp_connect_headers ?? null);
|
||||
|
||||
const runWith = (location.state?.runWith as string) ?? undefined;
|
||||
|
||||
const initialValues = getInitialValues(location, workflowParameters ?? []);
|
||||
|
|
@ -84,6 +93,7 @@ function WorkflowRunParameters() {
|
|||
extraHttpHeaders ?? workflow.extra_http_headers ?? null,
|
||||
browserProfileId:
|
||||
browserProfileId ?? workflow.browser_profile_id ?? null,
|
||||
cdpConnectHeaders: cdpConnectHeaders ?? storedCdpConnectHeaders,
|
||||
cdpAddress: null,
|
||||
runWith,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ function getWorkflowElements(version: WorkflowVersion) {
|
|||
extraHttpHeaders: version.extra_http_headers
|
||||
? JSON.stringify(version.extra_http_headers)
|
||||
: null,
|
||||
cdpConnectHeaders: version.cdp_connect_headers
|
||||
? JSON.stringify(version.cdp_connect_headers)
|
||||
: null,
|
||||
runWith: version.run_with ?? "agent",
|
||||
codeVersion: version.code_version ?? null,
|
||||
scriptCacheKey: version.cache_key,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ function Debugger() {
|
|||
extraHttpHeaders: workflow.extra_http_headers
|
||||
? JSON.stringify(workflow.extra_http_headers)
|
||||
: null,
|
||||
cdpConnectHeaders: workflow.cdp_connect_headers
|
||||
? JSON.stringify(workflow.cdp_connect_headers)
|
||||
: null,
|
||||
runWith: workflow.run_with ?? "agent",
|
||||
codeVersion: workflow.code_version ?? null,
|
||||
scriptCacheKey: workflow.cache_key,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ function WorkflowEditor() {
|
|||
extraHttpHeaders: workflow.extra_http_headers
|
||||
? JSON.stringify(workflow.extra_http_headers)
|
||||
: null,
|
||||
cdpConnectHeaders: workflow.cdp_connect_headers
|
||||
? JSON.stringify(workflow.cdp_connect_headers)
|
||||
: null,
|
||||
runWith: workflow.run_with ?? "agent",
|
||||
codeVersion: workflow.code_version ?? null,
|
||||
scriptCacheKey: workflow.cache_key,
|
||||
|
|
|
|||
|
|
@ -1247,6 +1247,9 @@ function Workspace({
|
|||
extraHttpHeaders: workflowData.extra_http_headers
|
||||
? JSON.stringify(workflowData.extra_http_headers)
|
||||
: null,
|
||||
cdpConnectHeaders: workflowData.cdp_connect_headers
|
||||
? JSON.stringify(workflowData.cdp_connect_headers)
|
||||
: null,
|
||||
runWith: workflowData.run_with ?? "agent",
|
||||
codeVersion: workflowData.code_version ?? null,
|
||||
scriptCacheKey: workflowData.cache_key ?? null,
|
||||
|
|
@ -1308,6 +1311,9 @@ function Workspace({
|
|||
extraHttpHeaders: selectedVersion.extra_http_headers
|
||||
? JSON.stringify(selectedVersion.extra_http_headers)
|
||||
: null,
|
||||
cdpConnectHeaders: selectedVersion.cdp_connect_headers
|
||||
? JSON.stringify(selectedVersion.cdp_connect_headers)
|
||||
: null,
|
||||
runWith: selectedVersion.run_with ?? "agent",
|
||||
codeVersion: selectedVersion.code_version ?? null,
|
||||
scriptCacheKey: selectedVersion.cache_key,
|
||||
|
|
@ -2112,6 +2118,9 @@ function Workspace({
|
|||
extra_http_headers: saveData.settings.extraHttpHeaders
|
||||
? JSON.parse(saveData.settings.extraHttpHeaders)
|
||||
: null,
|
||||
cdp_connect_headers: saveData.settings.cdpConnectHeaders
|
||||
? JSON.parse(saveData.settings.cdpConnectHeaders)
|
||||
: null,
|
||||
persist_browser_session: saveData.settings.persistBrowserSession,
|
||||
model: saveData.settings.model,
|
||||
totp_verification_url: saveData.workflow.totp_verification_url,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type WorkflowStartNodeData = {
|
|||
model: WorkflowModel | null;
|
||||
maxScreenshotScrolls: number | null;
|
||||
extraHttpHeaders: string | Record<string, unknown> | null;
|
||||
cdpConnectHeaders: string | Record<string, unknown> | null;
|
||||
editable: boolean;
|
||||
runWith: string;
|
||||
codeVersion: number | null;
|
||||
|
|
|
|||
|
|
@ -144,6 +144,9 @@ function getWorkflowElements(version: WorkflowVersion) {
|
|||
extraHttpHeaders: version.extra_http_headers
|
||||
? JSON.stringify(version.extra_http_headers)
|
||||
: null,
|
||||
cdpConnectHeaders: version.cdp_connect_headers
|
||||
? JSON.stringify(version.cdp_connect_headers)
|
||||
: null,
|
||||
runWith: version.run_with ?? "agent",
|
||||
codeVersion: version.code_version ?? null,
|
||||
scriptCacheKey: version.cache_key,
|
||||
|
|
|
|||
|
|
@ -1678,6 +1678,7 @@ function getElements(
|
|||
model: settings.model,
|
||||
maxScreenshotScrolls: settings.maxScreenshotScrolls,
|
||||
extraHttpHeaders: settings.extraHttpHeaders,
|
||||
cdpConnectHeaders: settings.cdpConnectHeaders,
|
||||
editable,
|
||||
runWith: settings.runWith,
|
||||
codeVersion: settings.codeVersion,
|
||||
|
|
@ -3025,6 +3026,7 @@ function getWorkflowSettings(nodes: Array<AppNode>): WorkflowSettings {
|
|||
model: null,
|
||||
maxScreenshotScrolls: null,
|
||||
extraHttpHeaders: null,
|
||||
cdpConnectHeaders: null,
|
||||
runWith: "code",
|
||||
codeVersion: 2,
|
||||
scriptCacheKey: null,
|
||||
|
|
@ -3054,6 +3056,10 @@ function getWorkflowSettings(nodes: Array<AppNode>): WorkflowSettings {
|
|||
data.extraHttpHeaders && typeof data.extraHttpHeaders === "object"
|
||||
? JSON.stringify(data.extraHttpHeaders)
|
||||
: data.extraHttpHeaders,
|
||||
cdpConnectHeaders:
|
||||
data.cdpConnectHeaders && typeof data.cdpConnectHeaders === "object"
|
||||
? JSON.stringify(data.cdpConnectHeaders)
|
||||
: data.cdpConnectHeaders,
|
||||
runWith: data.runWith,
|
||||
codeVersion: data.codeVersion,
|
||||
scriptCacheKey: data.scriptCacheKey,
|
||||
|
|
|
|||
|
|
@ -635,6 +635,7 @@ export type WorkflowApiResponse = {
|
|||
proxy_location: ProxyLocation | null;
|
||||
webhook_callback_url: string | null;
|
||||
extra_http_headers: Record<string, string> | null;
|
||||
cdp_connect_headers: Record<string, string> | null;
|
||||
persist_browser_session: boolean;
|
||||
browser_profile_id?: string | null;
|
||||
model: WorkflowModel | null;
|
||||
|
|
@ -664,6 +665,7 @@ export type WorkflowSettings = {
|
|||
model: WorkflowModel | null;
|
||||
maxScreenshotScrolls: number | null;
|
||||
extraHttpHeaders: string | null;
|
||||
cdpConnectHeaders: string | null;
|
||||
runWith: string; // 'agent' or 'code'
|
||||
codeVersion: number | null;
|
||||
scriptCacheKey: string | null;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export type WorkflowCreateYAMLRequest = {
|
|||
is_saved_task?: boolean;
|
||||
max_screenshot_scrolls?: number | null;
|
||||
extra_http_headers?: Record<string, string> | null;
|
||||
cdp_connect_headers?: Record<string, string> | null;
|
||||
status?: string | null;
|
||||
run_with?: string | null;
|
||||
cache_key?: string | null;
|
||||
|
|
|
|||
|
|
@ -138,6 +138,38 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => {
|
|||
}
|
||||
}
|
||||
|
||||
let cdpConnectHeaders: Record<string, string> | null = null;
|
||||
if (saveData.settings.cdpConnectHeaders) {
|
||||
try {
|
||||
const parsedCdpHeaders = JSON.parse(
|
||||
saveData.settings.cdpConnectHeaders,
|
||||
);
|
||||
if (
|
||||
parsedCdpHeaders &&
|
||||
typeof parsedCdpHeaders === "object" &&
|
||||
!Array.isArray(parsedCdpHeaders)
|
||||
) {
|
||||
// Send the dict as-is, including any mask sentinels for unedited
|
||||
// entries. The backend resolves entries key-by-key so a newly added
|
||||
// key alongside a masked one is preserved (not wiped).
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsedCdpHeaders)) {
|
||||
if (key && typeof key === "string") {
|
||||
sanitized[key] = String(value);
|
||||
}
|
||||
}
|
||||
cdpConnectHeaders = sanitized;
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Invalid JSON format in cdp connect headers",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const scriptCacheKey = saveData.settings.scriptCacheKey ?? "";
|
||||
const normalizedKey =
|
||||
scriptCacheKey === "" ? "default" : saveData.settings.scriptCacheKey;
|
||||
|
|
@ -153,6 +185,7 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => {
|
|||
max_screenshot_scrolls: saveData.settings.maxScreenshotScrolls,
|
||||
totp_verification_url: saveData.workflow.totp_verification_url,
|
||||
extra_http_headers: extraHttpHeaders,
|
||||
cdp_connect_headers: cdpConnectHeaders,
|
||||
run_with: saveData.settings.runWith,
|
||||
cache_key: normalizedKey,
|
||||
ai_fallback: saveData.settings.aiFallback ?? true,
|
||||
|
|
|
|||
10
skyvern-frontend/src/util/secretHeaders.ts
Normal file
10
skyvern-frontend/src/util/secretHeaders.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export const SECRET_HEADER_MASK = "***";
|
||||
|
||||
export function isMaskedHeaders(
|
||||
headers: Record<string, unknown> | null | undefined,
|
||||
): boolean {
|
||||
if (!headers) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(headers).some((value) => value === SECRET_HEADER_MASK);
|
||||
}
|
||||
|
|
@ -777,6 +777,9 @@ def quickstart(
|
|||
return
|
||||
|
||||
if not _has_server_quickstart_extra():
|
||||
if not _is_interactive_input():
|
||||
_print_server_guidance()
|
||||
raise typer.Exit(0)
|
||||
if not _install_server_extra_for_quickstart() or not _has_server_quickstart_extra():
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
|
|
|||
341
skyvern/core/script_generations/script_block_extractor.py
Normal file
341
skyvern/core/script_generations/script_block_extractor.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import builtins
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterator
|
||||
|
||||
CACHEABLE_BLOCK_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"task",
|
||||
"task_v2",
|
||||
"action",
|
||||
"navigation",
|
||||
"extraction",
|
||||
"login",
|
||||
"file_download",
|
||||
"for_loop",
|
||||
"while_loop",
|
||||
}
|
||||
)
|
||||
|
||||
KNOWN_NON_CACHEABLE_BLOCK_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"validation",
|
||||
"wait",
|
||||
"conditional",
|
||||
"code",
|
||||
"goto_url",
|
||||
"send_email",
|
||||
"file_url_parser",
|
||||
"pdf_parser",
|
||||
"http_request",
|
||||
}
|
||||
)
|
||||
|
||||
RUNTIME_GLOBALS: frozenset[str] = frozenset({"skyvern", "__builtins__"})
|
||||
|
||||
|
||||
class ScriptBlockExtractionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class RunSignatureValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtractedScriptBlock:
|
||||
label: str
|
||||
primitive: str
|
||||
run_signature: str
|
||||
block_type: str | None
|
||||
is_cacheable: bool
|
||||
is_compound: bool
|
||||
missing_globals: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScriptBlockExtractionResult:
|
||||
blocks: tuple[ExtractedScriptBlock, ...]
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def cacheable_blocks(self) -> list[ExtractedScriptBlock]:
|
||||
return [block for block in self.blocks if block.is_cacheable]
|
||||
|
||||
|
||||
def _iter_workflow_blocks(blocks: list[Any]) -> Iterator[dict[str, Any]]:
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
yield block
|
||||
loop_blocks = block.get("loop_blocks")
|
||||
if isinstance(loop_blocks, list):
|
||||
yield from _iter_workflow_blocks(loop_blocks)
|
||||
|
||||
|
||||
def _label_to_block_type(workflow_definition: dict[str, Any]) -> dict[str, str]:
|
||||
labels: dict[str, str] = {}
|
||||
for block in _iter_workflow_blocks(workflow_definition.get("blocks") or []):
|
||||
label = block.get("label")
|
||||
block_type = block.get("block_type")
|
||||
if label and block_type:
|
||||
labels[label] = block_type
|
||||
return labels
|
||||
|
||||
|
||||
def _is_skyvern_call(call: ast.AST) -> bool:
|
||||
if not isinstance(call, ast.Call):
|
||||
return False
|
||||
func = call.func
|
||||
return isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name) and func.value.id == "skyvern"
|
||||
|
||||
|
||||
def _find_entry_function(tree: ast.Module) -> ast.AsyncFunctionDef | None:
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.AsyncFunctionDef):
|
||||
continue
|
||||
for decorator in node.decorator_list:
|
||||
if (
|
||||
isinstance(decorator, ast.Call)
|
||||
and isinstance(decorator.func, ast.Attribute)
|
||||
and isinstance(decorator.func.value, ast.Name)
|
||||
and decorator.func.value.id == "skyvern"
|
||||
and decorator.func.attr == "workflow"
|
||||
):
|
||||
return node
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.AsyncFunctionDef) and node.name == "run":
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _walk_entry_statements(nodes: list[ast.stmt], depth: int = 0) -> Iterator[tuple[str, ast.AST]]:
|
||||
if depth > 8:
|
||||
return
|
||||
|
||||
for node in nodes:
|
||||
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Await):
|
||||
yield ("await", node.value)
|
||||
elif isinstance(node, ast.Assign) and isinstance(node.value, ast.Await):
|
||||
yield ("await", node.value)
|
||||
elif isinstance(node, ast.AnnAssign) and isinstance(node.value, ast.Await):
|
||||
yield ("await", node.value)
|
||||
elif isinstance(node, ast.AugAssign) and isinstance(node.value, ast.Await):
|
||||
yield ("await", node.value)
|
||||
elif isinstance(node, ast.Return) and isinstance(node.value, ast.Await):
|
||||
yield ("await", node.value)
|
||||
elif isinstance(node, ast.AsyncFor) and _is_skyvern_call(node.iter):
|
||||
iter_call = node.iter
|
||||
if not isinstance(iter_call, ast.Call):
|
||||
continue
|
||||
iter_func = iter_call.func
|
||||
if not isinstance(iter_func, ast.Attribute):
|
||||
continue
|
||||
if iter_func.attr in ("loop", "while_loop"):
|
||||
yield ("async_for", node)
|
||||
yield from _walk_entry_statements(node.body, depth + 1)
|
||||
elif isinstance(node, ast.Try):
|
||||
yield from _walk_entry_statements(node.body, depth + 1)
|
||||
for handler in node.handlers:
|
||||
yield from _walk_entry_statements(handler.body, depth + 1)
|
||||
yield from _walk_entry_statements(node.orelse, depth + 1)
|
||||
yield from _walk_entry_statements(node.finalbody, depth + 1)
|
||||
elif hasattr(ast, "TryStar") and isinstance(node, ast.TryStar):
|
||||
yield from _walk_entry_statements(node.body, depth + 1)
|
||||
for handler in node.handlers:
|
||||
yield from _walk_entry_statements(handler.body, depth + 1)
|
||||
yield from _walk_entry_statements(node.orelse, depth + 1)
|
||||
yield from _walk_entry_statements(node.finalbody, depth + 1)
|
||||
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
||||
yield from _walk_entry_statements(node.body, depth + 1)
|
||||
elif isinstance(node, ast.If):
|
||||
yield from _walk_entry_statements(node.body, depth + 1)
|
||||
yield from _walk_entry_statements(node.orelse, depth + 1)
|
||||
|
||||
|
||||
def _node_source(source_lines: list[str], node: ast.AST) -> str:
|
||||
start = (getattr(node, "lineno", 1) or 1) - 1
|
||||
end = getattr(node, "end_lineno", None) or start + 1
|
||||
start_col = getattr(node, "col_offset", 0) or 0
|
||||
end_col = getattr(node, "end_col_offset", 0) or 0
|
||||
if start == end - 1:
|
||||
return source_lines[start][start_col:end_col]
|
||||
chunks = [source_lines[start][start_col:]]
|
||||
chunks.extend(source_lines[start + 1 : end - 1])
|
||||
chunks.append(source_lines[end - 1][:end_col])
|
||||
return "".join(chunks)
|
||||
|
||||
|
||||
def _extract_label_from_call(call: ast.Call) -> str | None:
|
||||
for keyword in call.keywords:
|
||||
if keyword.arg == "label" and isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, str):
|
||||
return keyword.value.value
|
||||
return None
|
||||
|
||||
|
||||
def _top_level_names(tree: ast.Module) -> set[str]:
|
||||
names: set[str] = set()
|
||||
|
||||
def _add_target(target: ast.AST) -> None:
|
||||
if isinstance(target, ast.Name):
|
||||
names.add(target.id)
|
||||
elif isinstance(target, (ast.Tuple, ast.List)):
|
||||
for elt in target.elts:
|
||||
_add_target(elt)
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
names.add(node.name)
|
||||
elif isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
_add_target(target)
|
||||
elif isinstance(node, ast.AnnAssign):
|
||||
_add_target(node.target)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
names.add(alias.asname or alias.name.split(".", 1)[0])
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
for alias in node.names:
|
||||
if alias.name == "*":
|
||||
raise RunSignatureValidationError("Wildcard imports are not supported for script deploy validation")
|
||||
names.add(alias.asname or alias.name)
|
||||
|
||||
return names
|
||||
|
||||
|
||||
def _entry_function_scope_names(entry_fn: ast.AsyncFunctionDef) -> set[str]:
|
||||
names: set[str] = set()
|
||||
args = entry_fn.args
|
||||
|
||||
for arg in [*args.posonlyargs, *args.args, *args.kwonlyargs]:
|
||||
names.add(arg.arg)
|
||||
if args.vararg:
|
||||
names.add(args.vararg.arg)
|
||||
if args.kwarg:
|
||||
names.add(args.kwarg.arg)
|
||||
|
||||
_loads, stores = _name_loads_and_stores(entry_fn)
|
||||
names.update(stores)
|
||||
return names
|
||||
|
||||
|
||||
def _name_loads_and_stores(tree: ast.AST) -> tuple[set[str], set[str]]:
|
||||
loads: set[str] = set()
|
||||
stores: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Name):
|
||||
if isinstance(node.ctx, ast.Load):
|
||||
loads.add(node.id)
|
||||
elif isinstance(node.ctx, (ast.Store, ast.Del)):
|
||||
stores.add(node.id)
|
||||
return loads, stores
|
||||
|
||||
|
||||
def validate_run_signature(run_signature: str, available_globals: set[str]) -> tuple[str, ...]:
|
||||
normalized_signature = textwrap.dedent(run_signature).strip()
|
||||
compound_prefixes = ("async for ", "for ", "if ", "while ", "with ", "async with ")
|
||||
if normalized_signature.startswith(compound_prefixes):
|
||||
wrapper_code = f"async def __run_signature_wrapper():\n{textwrap.indent(normalized_signature, ' ')}\n"
|
||||
else:
|
||||
wrapper_code = (
|
||||
"async def __run_signature_wrapper():\n"
|
||||
" return (\n"
|
||||
f"{textwrap.indent(normalized_signature, ' ')}\n"
|
||||
" )\n"
|
||||
)
|
||||
|
||||
try:
|
||||
wrapper_tree = ast.parse(wrapper_code)
|
||||
compile(wrapper_tree, "<run_signature>", "exec")
|
||||
except SyntaxError as exc:
|
||||
raise RunSignatureValidationError(str(exc)) from exc
|
||||
|
||||
loads, stores = _name_loads_and_stores(wrapper_tree)
|
||||
allowed = available_globals | RUNTIME_GLOBALS | set(dir(builtins)) | stores | {"__run_signature_wrapper"}
|
||||
return tuple(sorted(name for name in loads if name not in allowed))
|
||||
|
||||
|
||||
def extract_script_blocks(source: str, workflow_definition: dict[str, Any]) -> ScriptBlockExtractionResult:
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError as exc:
|
||||
raise ScriptBlockExtractionError(f"script source is not valid Python: {exc}") from exc
|
||||
|
||||
entry_fn = _find_entry_function(tree)
|
||||
if entry_fn is None:
|
||||
raise ScriptBlockExtractionError(
|
||||
"Could not find an @skyvern.workflow-decorated async function or top-level async def run(...)"
|
||||
)
|
||||
|
||||
source_lines = source.splitlines(keepends=True)
|
||||
label_to_block_type = _label_to_block_type(workflow_definition)
|
||||
available_globals = _top_level_names(tree) | _entry_function_scope_names(entry_fn)
|
||||
seen_labels: set[str] = set()
|
||||
blocks: list[ExtractedScriptBlock] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
for kind, node in _walk_entry_statements(entry_fn.body):
|
||||
if kind == "await":
|
||||
await_node = node
|
||||
if not isinstance(await_node, ast.Await) or not isinstance(await_node.value, ast.Call):
|
||||
continue
|
||||
call = await_node.value
|
||||
if not _is_skyvern_call(call) or not isinstance(call.func, ast.Attribute):
|
||||
continue
|
||||
primitive = call.func.attr
|
||||
if primitive == "setup":
|
||||
continue
|
||||
label = _extract_label_from_call(call)
|
||||
if not label:
|
||||
continue
|
||||
run_signature = "await " + textwrap.dedent(_node_source(source_lines, call)).strip()
|
||||
is_compound = False
|
||||
elif kind == "async_for":
|
||||
async_for_node = node
|
||||
if not isinstance(async_for_node, ast.AsyncFor):
|
||||
continue
|
||||
iter_call = async_for_node.iter
|
||||
if not isinstance(iter_call, ast.Call) or not isinstance(iter_call.func, ast.Attribute):
|
||||
continue
|
||||
primitive = iter_call.func.attr
|
||||
label = _extract_label_from_call(iter_call)
|
||||
if not label:
|
||||
continue
|
||||
run_signature = textwrap.dedent(_node_source(source_lines, async_for_node)).strip()
|
||||
is_compound = True
|
||||
else:
|
||||
continue
|
||||
|
||||
if label in seen_labels:
|
||||
warnings.append(f"Duplicate label {label!r}; only the first invocation is used")
|
||||
continue
|
||||
seen_labels.add(label)
|
||||
|
||||
block_type = label_to_block_type.get(label)
|
||||
if (
|
||||
block_type is not None
|
||||
and block_type not in CACHEABLE_BLOCK_TYPES
|
||||
and block_type not in KNOWN_NON_CACHEABLE_BLOCK_TYPES
|
||||
):
|
||||
warnings.append(
|
||||
f"Unknown workflow block type {block_type!r} for label {label!r}; treating as non-cacheable"
|
||||
)
|
||||
|
||||
missing_globals = validate_run_signature(run_signature, available_globals)
|
||||
blocks.append(
|
||||
ExtractedScriptBlock(
|
||||
label=label,
|
||||
primitive=primitive,
|
||||
run_signature=run_signature,
|
||||
block_type=block_type,
|
||||
is_cacheable=block_type in CACHEABLE_BLOCK_TYPES,
|
||||
is_compound=is_compound,
|
||||
missing_globals=missing_globals,
|
||||
)
|
||||
)
|
||||
|
||||
return ScriptBlockExtractionResult(blocks=tuple(blocks), warnings=tuple(warnings))
|
||||
|
|
@ -456,6 +456,7 @@ class ForgeAgent:
|
|||
model=task_block.model,
|
||||
max_screenshot_scrolling_times=workflow_run.max_screenshot_scrolls,
|
||||
extra_http_headers=workflow_run.extra_http_headers,
|
||||
cdp_connect_headers=workflow_run.cdp_connect_headers,
|
||||
browser_address=workflow_run.browser_address,
|
||||
browser_session_id=workflow_run.browser_session_id,
|
||||
download_timeout=task_block.download_timeout,
|
||||
|
|
@ -527,6 +528,7 @@ class ForgeAgent:
|
|||
model=task_request.model,
|
||||
max_screenshot_scrolling_times=task_request.max_screenshot_scrolls,
|
||||
extra_http_headers=task_request.extra_http_headers,
|
||||
cdp_connect_headers=task_request.cdp_connect_headers,
|
||||
browser_session_id=task_request.browser_session_id,
|
||||
browser_address=task_request.browser_address,
|
||||
include_extracted_text=task_request.include_extracted_text,
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ class TaskModel(Base):
|
|||
proxy_location = Column(String)
|
||||
extracted_information_schema = Column(JSON)
|
||||
extra_http_headers = Column(JSON, nullable=True)
|
||||
cdp_connect_headers = Column(JSON, nullable=True)
|
||||
workflow_run_id = Column(String, ForeignKey("workflow_runs.workflow_run_id"), index=True)
|
||||
order = Column(Integer, nullable=True)
|
||||
retry = Column(Integer, nullable=True)
|
||||
|
|
@ -326,6 +327,7 @@ class WorkflowModel(SoftDeleteMixin, Base):
|
|||
webhook_callback_url = Column(String)
|
||||
max_screenshot_scrolling_times = Column(Integer, nullable=True)
|
||||
extra_http_headers = Column(JSON, nullable=True)
|
||||
cdp_connect_headers = Column(JSON, nullable=True)
|
||||
totp_verification_url = Column(String)
|
||||
totp_identifier = Column(String)
|
||||
persist_browser_session = Column(Boolean, default=False, nullable=False)
|
||||
|
|
@ -444,6 +446,7 @@ class WorkflowRunModel(Base):
|
|||
totp_identifier = Column(String)
|
||||
max_screenshot_scrolling_times = Column(Integer, nullable=True)
|
||||
extra_http_headers = Column(JSON, nullable=True)
|
||||
cdp_connect_headers = Column(JSON, nullable=True)
|
||||
browser_address = Column(String, nullable=True, index=True)
|
||||
script_run = Column(JSON, nullable=True)
|
||||
job_id = Column(String, nullable=True, index=True)
|
||||
|
|
@ -913,6 +916,7 @@ class TaskV2Model(Base):
|
|||
max_steps = Column(Integer, nullable=True)
|
||||
max_screenshot_scrolling_times = Column(Integer, nullable=True)
|
||||
extra_http_headers = Column(JSON, nullable=True)
|
||||
cdp_connect_headers = Column(JSON, nullable=True)
|
||||
browser_address = Column(String, nullable=True)
|
||||
generate_script = Column(Boolean, default=False, nullable=False)
|
||||
run_with = Column(String, nullable=True) # 'agent' or 'code'
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ class ObserverRepository(BaseRepository):
|
|||
model: dict[str, Any] | None = None,
|
||||
max_screenshot_scrolling_times: int | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
browser_address: str | None = None,
|
||||
run_with: str | None = None,
|
||||
) -> TaskV2:
|
||||
|
|
@ -225,6 +226,7 @@ class ObserverRepository(BaseRepository):
|
|||
model=model,
|
||||
max_screenshot_scrolling_times=max_screenshot_scrolling_times,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
browser_address=browser_address,
|
||||
run_with=run_with,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, distinct, func, or_, select, update
|
||||
|
|
@ -38,6 +41,24 @@ from skyvern.schemas.scripts import (
|
|||
LOG = structlog.get_logger()
|
||||
|
||||
|
||||
class WorkflowScriptWriterIntent(StrEnum):
|
||||
deploy = "deploy"
|
||||
auto_regen = "auto_regen"
|
||||
ensure_static = "ensure_static"
|
||||
|
||||
|
||||
class WorkflowScriptUpsertStatus(StrEnum):
|
||||
created = "created"
|
||||
updated = "updated"
|
||||
blocked_by_pin = "blocked_by_pin"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowScriptUpsertResult:
|
||||
status: WorkflowScriptUpsertStatus
|
||||
workflow_script: WorkflowScript
|
||||
|
||||
|
||||
class ScriptsRepository(BaseRepository):
|
||||
"""Database operations for scripts, script files, script blocks, workflow scripts, and fallback episodes."""
|
||||
|
||||
|
|
@ -347,6 +368,69 @@ class ScriptsRepository(BaseRepository):
|
|||
await session.commit()
|
||||
return converted
|
||||
|
||||
@db_operation("upsert_script_block")
|
||||
async def upsert_script_block(
|
||||
self,
|
||||
script_revision_id: str,
|
||||
script_id: str,
|
||||
organization_id: str,
|
||||
script_block_label: str,
|
||||
script_file_id: str | None = None,
|
||||
run_signature: str | None = None,
|
||||
workflow_run_id: str | None = None,
|
||||
workflow_run_block_id: str | None = None,
|
||||
input_fields: list[str] | None = None,
|
||||
requires_agent: bool | None = None,
|
||||
) -> ScriptBlock:
|
||||
"""Insert or update a script block for a script revision.
|
||||
|
||||
``requires_agent=None`` preserves an existing row's value on conflict
|
||||
and inserts False for new rows. Callers that want a deploy-time
|
||||
preserve policy should resolve the prior revision's value before
|
||||
calling this method.
|
||||
"""
|
||||
async with self.Session() as session:
|
||||
now = datetime.now(timezone.utc)
|
||||
insert_values = {
|
||||
"script_block_id": generate_script_block_id(),
|
||||
"script_revision_id": script_revision_id,
|
||||
"script_id": script_id,
|
||||
"organization_id": organization_id,
|
||||
"script_block_label": script_block_label,
|
||||
"script_file_id": script_file_id,
|
||||
"run_signature": run_signature,
|
||||
"workflow_run_id": workflow_run_id,
|
||||
"workflow_run_block_id": workflow_run_block_id,
|
||||
"input_fields": input_fields,
|
||||
"requires_agent": requires_agent if requires_agent is not None else False,
|
||||
"created_at": now,
|
||||
"modified_at": now,
|
||||
}
|
||||
update_values: dict[str, Any] = {
|
||||
"script_file_id": script_file_id,
|
||||
"run_signature": run_signature,
|
||||
"workflow_run_id": workflow_run_id,
|
||||
"workflow_run_block_id": workflow_run_block_id,
|
||||
"input_fields": input_fields,
|
||||
"modified_at": now,
|
||||
}
|
||||
if requires_agent is not None:
|
||||
update_values["requires_agent"] = requires_agent
|
||||
|
||||
stmt = (
|
||||
insert(ScriptBlockModel)
|
||||
.values(**insert_values)
|
||||
.on_conflict_do_update(
|
||||
constraint="uc_script_revision_id_script_block_label",
|
||||
set_=update_values,
|
||||
)
|
||||
.returning(ScriptBlockModel)
|
||||
)
|
||||
script_block = (await session.scalars(stmt)).one()
|
||||
converted = convert_to_script_block(script_block)
|
||||
await session.commit()
|
||||
return converted
|
||||
|
||||
@db_operation("update_script_block")
|
||||
async def update_script_block(
|
||||
self,
|
||||
|
|
@ -565,6 +649,90 @@ class ScriptsRepository(BaseRepository):
|
|||
session.add(record)
|
||||
await session.commit()
|
||||
|
||||
@db_operation("upsert_workflow_script")
|
||||
async def upsert_workflow_script(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
script_id: str,
|
||||
workflow_permanent_id: str,
|
||||
cache_key: str,
|
||||
cache_key_value: str,
|
||||
workflow_id: str | None = None,
|
||||
workflow_run_id: str | None = None,
|
||||
status: ScriptStatus = ScriptStatus.published,
|
||||
is_pinned: bool = False,
|
||||
pinned_by: str | None = None,
|
||||
writer_intent: WorkflowScriptWriterIntent = WorkflowScriptWriterIntent.deploy,
|
||||
) -> WorkflowScriptUpsertResult:
|
||||
"""Create/update an active workflow-script mapping with sticky-pin semantics."""
|
||||
async with self.Session() as session:
|
||||
now = datetime.now(timezone.utc)
|
||||
status_value = status.value if isinstance(status, ScriptStatus) else status
|
||||
existing_rows = (
|
||||
await session.scalars(
|
||||
select(WorkflowScriptModel)
|
||||
.where(
|
||||
WorkflowScriptModel.organization_id == organization_id,
|
||||
WorkflowScriptModel.workflow_permanent_id == workflow_permanent_id,
|
||||
WorkflowScriptModel.cache_key_value == cache_key_value,
|
||||
WorkflowScriptModel.deleted_at.is_(None),
|
||||
WorkflowScriptModel.status == status_value,
|
||||
)
|
||||
.order_by(WorkflowScriptModel.created_at.desc())
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
|
||||
existing = existing_rows[0] if existing_rows else None
|
||||
if existing is None:
|
||||
record = WorkflowScriptModel(
|
||||
organization_id=organization_id,
|
||||
script_id=script_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
cache_key=cache_key,
|
||||
cache_key_value=cache_key_value,
|
||||
status=status_value,
|
||||
is_pinned=is_pinned,
|
||||
pinned_at=now if is_pinned else None,
|
||||
pinned_by=pinned_by if is_pinned else None,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return WorkflowScriptUpsertResult(
|
||||
status=WorkflowScriptUpsertStatus.created,
|
||||
workflow_script=WorkflowScript.model_validate(record),
|
||||
)
|
||||
|
||||
if existing.is_pinned and not is_pinned and writer_intent != WorkflowScriptWriterIntent.deploy:
|
||||
workflow_script = WorkflowScript.model_validate(existing)
|
||||
await session.commit()
|
||||
return WorkflowScriptUpsertResult(
|
||||
status=WorkflowScriptUpsertStatus.blocked_by_pin,
|
||||
workflow_script=workflow_script,
|
||||
)
|
||||
|
||||
existing.script_id = script_id
|
||||
existing.workflow_id = workflow_id
|
||||
existing.workflow_run_id = workflow_run_id
|
||||
existing.cache_key = cache_key
|
||||
existing.status = status_value
|
||||
existing.modified_at = now
|
||||
if is_pinned and not existing.is_pinned:
|
||||
existing.is_pinned = True
|
||||
existing.pinned_at = now
|
||||
existing.pinned_by = pinned_by
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return WorkflowScriptUpsertResult(
|
||||
status=WorkflowScriptUpsertStatus.updated,
|
||||
workflow_script=WorkflowScript.model_validate(existing),
|
||||
)
|
||||
|
||||
@db_operation("get_workflow_script")
|
||||
async def get_workflow_script(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class TasksRepository(BaseRepository):
|
|||
model: dict[str, Any] | None = None,
|
||||
max_screenshot_scrolling_times: int | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
browser_session_id: str | None = None,
|
||||
browser_address: str | None = None,
|
||||
download_timeout: float | None = None,
|
||||
|
|
@ -110,6 +111,7 @@ class TasksRepository(BaseRepository):
|
|||
model=model,
|
||||
max_screenshot_scrolling_times=max_screenshot_scrolling_times,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
browser_session_id=browser_session_id,
|
||||
browser_address=browser_address,
|
||||
download_timeout=download_timeout,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ class WorkflowRunsRepository(BaseRepository):
|
|||
parent_workflow_run_id: str | None = None,
|
||||
max_screenshot_scrolling_times: int | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
browser_address: str | None = None,
|
||||
sequential_key: str | None = None,
|
||||
run_with: str | None = None,
|
||||
|
|
@ -185,6 +186,7 @@ class WorkflowRunsRepository(BaseRepository):
|
|||
parent_workflow_run_id=parent_workflow_run_id,
|
||||
max_screenshot_scrolling_times=max_screenshot_scrolling_times,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
browser_address=browser_address,
|
||||
sequential_key=sequential_key,
|
||||
run_with=run_with,
|
||||
|
|
@ -224,6 +226,7 @@ class WorkflowRunsRepository(BaseRepository):
|
|||
browser_profile_id: str | None | object = _UNSET,
|
||||
browser_address: str | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
failure_category: list[dict[str, Any]] | None = None,
|
||||
started_at: datetime | None | object = _UNSET,
|
||||
queued_at: datetime | None | object = _UNSET,
|
||||
|
|
@ -269,6 +272,8 @@ class WorkflowRunsRepository(BaseRepository):
|
|||
workflow_run.browser_address = browser_address
|
||||
if extra_http_headers is not None:
|
||||
workflow_run.extra_http_headers = extra_http_headers
|
||||
if cdp_connect_headers is not None:
|
||||
workflow_run.cdp_connect_headers = cdp_connect_headers
|
||||
# 2FA verification code waiting state updates
|
||||
if waiting_for_verification_code is not None:
|
||||
workflow_run.waiting_for_verification_code = waiting_for_verification_code
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ class WorkflowsRepository(BaseRepository):
|
|||
webhook_callback_url: str | None = None,
|
||||
max_screenshot_scrolling_times: int | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
totp_verification_url: str | None = None,
|
||||
totp_identifier: str | None = None,
|
||||
persist_browser_session: bool = False,
|
||||
|
|
@ -115,6 +116,7 @@ class WorkflowsRepository(BaseRepository):
|
|||
totp_identifier=totp_identifier,
|
||||
max_screenshot_scrolling_times=max_screenshot_scrolling_times,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
persist_browser_session=persist_browser_session,
|
||||
browser_profile_id=browser_profile_id,
|
||||
model=model,
|
||||
|
|
@ -597,6 +599,7 @@ class WorkflowsRepository(BaseRepository):
|
|||
model: dict[str, Any] | None | object = _UNSET,
|
||||
max_screenshot_scrolling_times: int | None | object = _UNSET,
|
||||
extra_http_headers: dict[str, str] | None | object = _UNSET,
|
||||
cdp_connect_headers: dict[str, str] | None | object = _UNSET,
|
||||
ai_fallback: bool | None = None,
|
||||
run_sequentially: bool | None = None,
|
||||
sequential_key: str | None | object = _UNSET,
|
||||
|
|
@ -640,6 +643,8 @@ class WorkflowsRepository(BaseRepository):
|
|||
workflow.max_screenshot_scrolling_times = max_screenshot_scrolling_times
|
||||
if extra_http_headers is not _UNSET:
|
||||
workflow.extra_http_headers = extra_http_headers
|
||||
if cdp_connect_headers is not _UNSET:
|
||||
workflow.cdp_connect_headers = cdp_connect_headers
|
||||
if ai_fallback is not None:
|
||||
workflow.ai_fallback = ai_fallback
|
||||
if run_sequentially is not None:
|
||||
|
|
@ -688,6 +693,7 @@ class WorkflowsRepository(BaseRepository):
|
|||
model: dict[str, Any] | None | object = _UNSET,
|
||||
max_screenshot_scrolling_times: int | None | object = _UNSET,
|
||||
extra_http_headers: dict[str, str] | None | object = _UNSET,
|
||||
cdp_connect_headers: dict[str, str] | None | object = _UNSET,
|
||||
ai_fallback: bool | None = None,
|
||||
run_sequentially: bool | None = None,
|
||||
sequential_key: str | None | object = _UNSET,
|
||||
|
|
@ -767,6 +773,8 @@ class WorkflowsRepository(BaseRepository):
|
|||
workflow.max_screenshot_scrolling_times = max_screenshot_scrolling_times
|
||||
if extra_http_headers is not _UNSET:
|
||||
workflow.extra_http_headers = extra_http_headers
|
||||
if cdp_connect_headers is not _UNSET:
|
||||
workflow.cdp_connect_headers = cdp_connect_headers
|
||||
if ai_fallback is not None:
|
||||
workflow.ai_fallback = ai_fallback
|
||||
if run_sequentially is not None:
|
||||
|
|
|
|||
|
|
@ -289,6 +289,7 @@ def convert_to_task(task_obj: TaskModel, debug_enabled: bool = False, workflow_p
|
|||
proxy_location=deserialize_proxy_location(task_obj.proxy_location),
|
||||
extracted_information_schema=task_obj.extracted_information_schema,
|
||||
extra_http_headers=task_obj.extra_http_headers,
|
||||
cdp_connect_headers=task_obj.cdp_connect_headers,
|
||||
workflow_run_id=task_obj.workflow_run_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
order=task_obj.order,
|
||||
|
|
@ -486,6 +487,7 @@ def convert_to_workflow(
|
|||
deleted_at=workflow_model.deleted_at,
|
||||
status=WorkflowStatus(workflow_model.status),
|
||||
extra_http_headers=workflow_model.extra_http_headers,
|
||||
cdp_connect_headers=workflow_model.cdp_connect_headers,
|
||||
run_with=workflow_model.run_with,
|
||||
ai_fallback=workflow_model.ai_fallback,
|
||||
cache_key=workflow_model.cache_key,
|
||||
|
|
@ -534,6 +536,7 @@ def convert_to_workflow_run(
|
|||
workflow_title=workflow_title,
|
||||
max_screenshot_scrolls=workflow_run_model.max_screenshot_scrolling_times,
|
||||
extra_http_headers=workflow_run_model.extra_http_headers,
|
||||
cdp_connect_headers=workflow_run_model.cdp_connect_headers,
|
||||
browser_address=workflow_run_model.browser_address,
|
||||
job_id=workflow_run_model.job_id,
|
||||
depends_on_workflow_run_id=workflow_run_model.depends_on_workflow_run_id,
|
||||
|
|
|
|||
|
|
@ -263,6 +263,7 @@ async def run_task(
|
|||
model=run_request.model,
|
||||
max_screenshot_scrolls=run_request.max_screenshot_scrolls,
|
||||
extra_http_headers=run_request.extra_http_headers,
|
||||
cdp_connect_headers=run_request.cdp_connect_headers,
|
||||
browser_address=run_request.browser_address,
|
||||
)
|
||||
task_v1_response = await task_v1_service.run_task(
|
||||
|
|
@ -330,6 +331,7 @@ async def run_task(
|
|||
model=run_request.model,
|
||||
max_screenshot_scrolling_times=run_request.max_screenshot_scrolls,
|
||||
extra_http_headers=run_request.extra_http_headers,
|
||||
cdp_connect_headers=run_request.cdp_connect_headers,
|
||||
browser_session_id=run_request.browser_session_id,
|
||||
browser_address=run_request.browser_address,
|
||||
run_with=run_request.run_with,
|
||||
|
|
@ -439,6 +441,7 @@ async def run_workflow(
|
|||
browser_profile_id=workflow_run_request.browser_profile_id,
|
||||
max_screenshot_scrolls=workflow_run_request.max_screenshot_scrolls,
|
||||
extra_http_headers=workflow_run_request.extra_http_headers,
|
||||
cdp_connect_headers=workflow_run_request.cdp_connect_headers,
|
||||
browser_address=workflow_run_request.browser_address,
|
||||
run_with=workflow_run_request.run_with,
|
||||
ai_fallback=workflow_run_request.ai_fallback,
|
||||
|
|
@ -745,6 +748,7 @@ async def create_workflow_from_prompt(
|
|||
proxy_location=request.proxy_location,
|
||||
max_screenshot_scrolling_times=request.max_screenshot_scrolls,
|
||||
extra_http_headers=request.extra_http_headers,
|
||||
cdp_connect_headers=request.cdp_connect_headers,
|
||||
max_iterations=x_max_iterations_override,
|
||||
max_steps=x_max_steps_override,
|
||||
status=WorkflowStatus.published if request.publish_workflow else WorkflowStatus.auto_generated,
|
||||
|
|
@ -3541,6 +3545,7 @@ async def run_task_v2(
|
|||
max_screenshot_scrolling_times=data.max_screenshot_scrolls,
|
||||
browser_session_id=data.browser_session_id,
|
||||
extra_http_headers=data.extra_http_headers,
|
||||
cdp_connect_headers=data.cdp_connect_headers,
|
||||
browser_address=data.browser_address,
|
||||
trigger_type=legacy_v2_trigger_type,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ from skyvern.schemas.scripts import (
|
|||
ClearCacheResponse,
|
||||
CreateScriptRequest,
|
||||
CreateScriptResponse,
|
||||
DeployCachedScriptRequest,
|
||||
DeployCachedScriptResponse,
|
||||
DeployScriptRequest,
|
||||
FallbackEpisodeListResponse,
|
||||
PinScriptRequest,
|
||||
|
|
@ -40,7 +42,7 @@ from skyvern.schemas.scripts import (
|
|||
WorkflowScriptsListResponse,
|
||||
WorkflowScriptSummary,
|
||||
)
|
||||
from skyvern.services import script_service, workflow_script_service
|
||||
from skyvern.services import cached_script_deploy_service, script_service, workflow_script_service
|
||||
from skyvern.services.script_reviewer import ScriptReviewer, load_filtered_run_param_values, store_review_artifacts
|
||||
from skyvern.services.workflow_script_service import (
|
||||
create_script_version_from_review,
|
||||
|
|
@ -249,6 +251,24 @@ async def create_script(
|
|||
)
|
||||
|
||||
|
||||
@base_router.post(
|
||||
"/scripts/{workflow_permanent_id}/deploy_cached/",
|
||||
response_model=DeployCachedScriptResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def deploy_cached_script(
|
||||
data: DeployCachedScriptRequest,
|
||||
workflow_permanent_id: str = Path(..., description="The workflow permanent ID"),
|
||||
current_org: Organization = Depends(org_auth_service.get_current_org),
|
||||
) -> DeployCachedScriptResponse:
|
||||
"""Validate a cached script deploy plan without writing deployment state."""
|
||||
return await cached_script_deploy_service.dry_run_cached_script_deploy(
|
||||
organization_id=current_org.organization_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
request=data,
|
||||
)
|
||||
|
||||
|
||||
@base_router.get(
|
||||
"/scripts/{script_id}",
|
||||
response_model=Script,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ from datetime import datetime
|
|||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
||||
|
||||
from skyvern.forge.sdk.settings_manager import SettingsManager
|
||||
from skyvern.schemas.runs import GeoTarget, ProxyLocation, ProxyLocationInput
|
||||
from skyvern.utils.secret_headers import mask_header_values
|
||||
from skyvern.utils.url_validators import validate_url
|
||||
|
||||
DEFAULT_WORKFLOW_TITLE = "New Workflow"
|
||||
|
|
@ -53,6 +54,7 @@ class TaskV2(BaseModel):
|
|||
finished_at: datetime | None = None
|
||||
max_screenshot_scrolls: int | None = Field(default=None, alias="max_screenshot_scrolling_times")
|
||||
extra_http_headers: dict[str, str] | None = None
|
||||
cdp_connect_headers: dict[str, str] | None = None
|
||||
browser_address: str | None = None
|
||||
run_with: str | None = None
|
||||
failure_category: list[dict[str, Any]] | None = None
|
||||
|
|
@ -117,6 +119,10 @@ class TaskV2(BaseModel):
|
|||
def deserialize_proxy_location(cls, proxy_location: ProxyLocationInput | str) -> ProxyLocationInput:
|
||||
return cls._parse_proxy_location(proxy_location)
|
||||
|
||||
@field_serializer("cdp_connect_headers")
|
||||
def _mask_cdp_connect_headers(self, headers: dict[str, str] | None) -> dict[str, str] | None:
|
||||
return mask_header_values(headers)
|
||||
|
||||
|
||||
class ThoughtType(StrEnum):
|
||||
"""
|
||||
|
|
@ -207,6 +213,7 @@ class TaskV2Request(BaseModel):
|
|||
workflow_system_prompt: str | None = None
|
||||
max_screenshot_scrolls: int | None = None
|
||||
extra_http_headers: dict[str, str] | None = None
|
||||
cdp_connect_headers: dict[str, str] | None = None
|
||||
browser_address: str | None = None
|
||||
run_with: str | None = None
|
||||
ai_fallback: bool = False
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from enum import StrEnum
|
|||
from typing import Any
|
||||
|
||||
from fastapi import status
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from skyvern.exceptions import (
|
||||
|
|
@ -19,6 +19,7 @@ from skyvern.forge.sdk.schemas.files import FileInfo
|
|||
from skyvern.forge.sdk.settings_manager import SettingsManager
|
||||
from skyvern.schemas.docs.doc_strings import PROXY_LOCATION_DOC_STRING
|
||||
from skyvern.schemas.runs import ProxyLocationInput
|
||||
from skyvern.utils.secret_headers import mask_header_values
|
||||
from skyvern.utils.url_validators import validate_url
|
||||
|
||||
|
||||
|
|
@ -85,6 +86,14 @@ class TaskBase(BaseModel):
|
|||
extra_http_headers: dict[str, str] | None = Field(
|
||||
None, description="The extra HTTP headers for the requests in browser."
|
||||
)
|
||||
cdp_connect_headers: dict[str, str] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"HTTP headers attached ONLY to the CDP WebSocket handshake when connecting to "
|
||||
"a remote browser via browser_address. Use this for browser-provider auth. "
|
||||
"Never forwarded to target websites."
|
||||
),
|
||||
)
|
||||
complete_criterion: str | None = Field(
|
||||
default=None, description="Criterion to complete", examples=["Complete if 'hello world' shows up on the page"]
|
||||
)
|
||||
|
|
@ -128,6 +137,10 @@ class TaskBase(BaseModel):
|
|||
description="If False, omit the scraped page text dump from the extract-information prompt. ExtractionBlock opts out; everything else keeps the default.",
|
||||
)
|
||||
|
||||
@field_serializer("cdp_connect_headers")
|
||||
def _mask_cdp_connect_headers(self, headers: dict[str, str] | None) -> dict[str, str] | None:
|
||||
return mask_header_values(headers)
|
||||
|
||||
|
||||
class TaskRequest(TaskBase):
|
||||
url: str = Field(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from datetime import datetime
|
|||
from enum import StrEnum
|
||||
from typing import Any, List
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel, field_serializer, field_validator
|
||||
from typing_extensions import deprecated
|
||||
|
||||
from skyvern.forge.sdk.db.enums import WorkflowRunTriggerType
|
||||
|
|
@ -18,6 +18,7 @@ from skyvern.forge.sdk.workflow.models.parameter import PARAMETER_TYPE, OutputPa
|
|||
from skyvern.forge.sdk.workflow.models.validators import normalize_run_metadata, normalize_run_with
|
||||
from skyvern.schemas.runs import ProxyLocationInput, ScriptRunResponse
|
||||
from skyvern.schemas.workflows import WorkflowStatus
|
||||
from skyvern.utils.secret_headers import mask_header_values
|
||||
from skyvern.utils.url_validators import validate_url
|
||||
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ class WorkflowRequestBody(BaseModel):
|
|||
browser_profile_id: str | None = None
|
||||
max_screenshot_scrolls: int | None = None
|
||||
extra_http_headers: dict[str, str] | None = None
|
||||
cdp_connect_headers: dict[str, str] | None = None
|
||||
browser_address: str | None = None
|
||||
run_with: str | None = None
|
||||
ai_fallback: bool | None = None
|
||||
|
|
@ -112,6 +114,7 @@ class Workflow(BaseModel):
|
|||
status: WorkflowStatus = WorkflowStatus.published
|
||||
max_screenshot_scrolls: int | None = None
|
||||
extra_http_headers: dict[str, str] | None = None
|
||||
cdp_connect_headers: dict[str, str] | None = None
|
||||
run_with: str = "agent"
|
||||
ai_fallback: bool = True
|
||||
cache_key: str | None = None
|
||||
|
|
@ -130,6 +133,10 @@ class Workflow(BaseModel):
|
|||
def _normalize_run_with(cls, v: str | None) -> str:
|
||||
return normalize_run_with(v)
|
||||
|
||||
@field_serializer("cdp_connect_headers")
|
||||
def _mask_cdp_connect_headers(self, headers: dict[str, str] | None) -> dict[str, str] | None:
|
||||
return mask_header_values(headers)
|
||||
|
||||
created_at: datetime
|
||||
modified_at: datetime
|
||||
deleted_at: datetime | None = None
|
||||
|
|
@ -189,6 +196,7 @@ class WorkflowRun(BaseModel):
|
|||
debug_session_id: str | None = None
|
||||
status: WorkflowRunStatus
|
||||
extra_http_headers: dict[str, str] | None = None
|
||||
cdp_connect_headers: dict[str, str] | None = None
|
||||
proxy_location: ProxyLocationInput = None
|
||||
webhook_callback_url: str | None = None
|
||||
webhook_failure_reason: str | None = None
|
||||
|
|
@ -222,6 +230,10 @@ class WorkflowRun(BaseModel):
|
|||
return None
|
||||
return normalize_run_with(v)
|
||||
|
||||
@field_serializer("cdp_connect_headers")
|
||||
def _mask_cdp_connect_headers(self, headers: dict[str, str] | None) -> dict[str, str] | None:
|
||||
return mask_header_values(headers)
|
||||
|
||||
queued_at: datetime | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
|
@ -229,27 +241,44 @@ class WorkflowRun(BaseModel):
|
|||
modified_at: datetime
|
||||
|
||||
|
||||
def is_adaptive_caching(workflow: Workflow, workflow_run: WorkflowRun) -> bool:
|
||||
"""Compute effective adaptive caching mode from run-level override or workflow setting.
|
||||
def is_adaptive_caching_from_effective_state(
|
||||
*,
|
||||
workflow_run_with: str,
|
||||
run_run_with: str | None,
|
||||
code_version: int | None,
|
||||
adaptive_caching: bool,
|
||||
) -> bool:
|
||||
"""Compute adaptive caching from explicit workflow/run dispatch state.
|
||||
|
||||
Uses code_version >= 2 as the primary check. Falls back to the legacy
|
||||
adaptive_caching bool for rows that haven't been backfilled yet
|
||||
(code_version is None).
|
||||
|
||||
WorkflowRun.run_with is None when not explicitly set (inherits from workflow).
|
||||
Workflow.run_with is always "code" or "agent" after normalization.
|
||||
``run_run_with`` is None when the run inherits from the workflow. This
|
||||
helper is shared by runtime and deploy-time cache-key resolution so the
|
||||
``:v2`` suffix decision stays in one place.
|
||||
"""
|
||||
run_with = workflow_run.run_with or workflow.run_with
|
||||
run_with = normalize_run_with(run_run_with) if run_run_with is not None else normalize_run_with(workflow_run_with)
|
||||
if run_with == "agent":
|
||||
return False
|
||||
# run_with == "code": check code_version
|
||||
if run_with == "code":
|
||||
if workflow.code_version is not None:
|
||||
return workflow.code_version >= 2
|
||||
return workflow.adaptive_caching
|
||||
if code_version is not None:
|
||||
return code_version >= 2
|
||||
return adaptive_caching
|
||||
return False
|
||||
|
||||
|
||||
def is_adaptive_caching(workflow: Workflow, workflow_run: WorkflowRun) -> bool:
|
||||
"""Compute effective adaptive caching mode from run-level override or workflow setting."""
|
||||
return is_adaptive_caching_from_effective_state(
|
||||
workflow_run_with=workflow.run_with,
|
||||
run_run_with=workflow_run.run_with,
|
||||
code_version=workflow.code_version,
|
||||
adaptive_caching=workflow.adaptive_caching,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowRunParameter(BaseModel):
|
||||
workflow_run_id: str
|
||||
workflow_parameter_id: str
|
||||
|
|
@ -276,6 +305,7 @@ class WorkflowRunResponseBase(BaseModel):
|
|||
totp_verification_url: str | None = None
|
||||
totp_identifier: str | None = None
|
||||
extra_http_headers: dict[str, str] | None = None
|
||||
cdp_connect_headers: dict[str, str] | None = None
|
||||
queued_at: datetime | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
|
|
@ -305,6 +335,10 @@ class WorkflowRunResponseBase(BaseModel):
|
|||
def _normalize_run_with(cls, v: str | None) -> str:
|
||||
return normalize_run_with(v)
|
||||
|
||||
@field_serializer("cdp_connect_headers")
|
||||
def _mask_cdp_connect_headers(self, headers: dict[str, str] | None) -> dict[str, str] | None:
|
||||
return mask_header_values(headers)
|
||||
|
||||
|
||||
class WorkflowRunWithWorkflowResponse(WorkflowRunResponseBase):
|
||||
workflow: Workflow
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ from skyvern.schemas.workflows import (
|
|||
from skyvern.services import script_service, workflow_script_service
|
||||
from skyvern.services.webhook_delivery import deliver_webhook_with_retries
|
||||
from skyvern.utils.css_selector import build_action_summaries_with_timing # shared with script_service
|
||||
from skyvern.utils.secret_headers import merge_masked_headers
|
||||
from skyvern.utils.url_validators import validate_url as validate_url_with_blocked_host_check
|
||||
from skyvern.webeye.browser_state import BrowserState
|
||||
|
||||
|
|
@ -903,6 +904,13 @@ class WorkflowService:
|
|||
and workflow.browser_profile_id is not None
|
||||
):
|
||||
workflow_request.browser_profile_id = workflow.browser_profile_id
|
||||
if workflow_request.cdp_connect_headers is None:
|
||||
if workflow.cdp_connect_headers is not None:
|
||||
workflow_request.cdp_connect_headers = workflow.cdp_connect_headers
|
||||
else:
|
||||
workflow_request.cdp_connect_headers = merge_masked_headers(
|
||||
workflow_request.cdp_connect_headers, workflow.cdp_connect_headers
|
||||
)
|
||||
if workflow_request.run_with is None:
|
||||
workflow_request.run_with = workflow.run_with
|
||||
|
||||
|
|
@ -3483,6 +3491,7 @@ class WorkflowService:
|
|||
is_saved_task: bool = False,
|
||||
status: WorkflowStatus = WorkflowStatus.published,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
run_with: str | None = None,
|
||||
cache_key: str | None = None,
|
||||
ai_fallback: bool | None = None,
|
||||
|
|
@ -3514,6 +3523,7 @@ class WorkflowService:
|
|||
is_saved_task=is_saved_task,
|
||||
status=status,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
run_with=run_with,
|
||||
cache_key=cache_key,
|
||||
ai_fallback=True if ai_fallback is None else ai_fallback,
|
||||
|
|
@ -3541,6 +3551,7 @@ class WorkflowService:
|
|||
proxy_location: ProxyLocationInput = None,
|
||||
max_screenshot_scrolling_times: int | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
max_iterations: int | None = None,
|
||||
max_steps: int | None = None,
|
||||
status: WorkflowStatus = WorkflowStatus.auto_generated,
|
||||
|
|
@ -3681,6 +3692,7 @@ class WorkflowService:
|
|||
totp_identifier=totp_identifier,
|
||||
max_screenshot_scrolling_times=max_screenshot_scrolling_times,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
status=status,
|
||||
run_with=run_with,
|
||||
ai_fallback=ai_fallback,
|
||||
|
|
@ -3893,6 +3905,7 @@ class WorkflowService:
|
|||
model: dict[str, Any] | None | object = _UNSET,
|
||||
max_screenshot_scrolling_times: int | None | object = _UNSET,
|
||||
extra_http_headers: dict[str, str] | None | object = _UNSET,
|
||||
cdp_connect_headers: dict[str, str] | None | object = _UNSET,
|
||||
run_with: str | None = None,
|
||||
ai_fallback: bool | None = None,
|
||||
cache_key: str | None = None,
|
||||
|
|
@ -3915,6 +3928,7 @@ class WorkflowService:
|
|||
model=model,
|
||||
max_screenshot_scrolling_times=max_screenshot_scrolling_times,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
run_with=run_with,
|
||||
ai_fallback=ai_fallback,
|
||||
cache_key=cache_key,
|
||||
|
|
@ -3938,6 +3952,7 @@ class WorkflowService:
|
|||
model=model,
|
||||
max_screenshot_scrolling_times=max_screenshot_scrolling_times,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
run_with=run_with,
|
||||
ai_fallback=ai_fallback,
|
||||
cache_key=cache_key,
|
||||
|
|
@ -4287,6 +4302,7 @@ class WorkflowService:
|
|||
parent_workflow_run_id=parent_workflow_run_id,
|
||||
max_screenshot_scrolling_times=workflow_request.max_screenshot_scrolls,
|
||||
extra_http_headers=workflow_request.extra_http_headers,
|
||||
cdp_connect_headers=workflow_request.cdp_connect_headers,
|
||||
browser_address=workflow_request.browser_address,
|
||||
sequential_key=sequential_key,
|
||||
run_with=workflow_request.run_with,
|
||||
|
|
@ -5185,6 +5201,7 @@ class WorkflowService:
|
|||
totp_verification_url=workflow_run.totp_verification_url,
|
||||
totp_identifier=workflow_run.totp_identifier,
|
||||
extra_http_headers=workflow_run.extra_http_headers,
|
||||
cdp_connect_headers=workflow_run.cdp_connect_headers,
|
||||
queued_at=workflow_run.queued_at,
|
||||
started_at=workflow_run.started_at,
|
||||
finished_at=workflow_run.finished_at,
|
||||
|
|
@ -5675,6 +5692,17 @@ class WorkflowService:
|
|||
if existing_latest_workflow:
|
||||
existing_version = existing_latest_workflow.version
|
||||
|
||||
# Missing field inherits the stored dict; an explicit dict (possibly
|
||||
# with mask sentinels for unedited keys) is resolved entry-by-entry
|
||||
# against the stored value so newly-added keys aren't dropped.
|
||||
if request.cdp_connect_headers is None:
|
||||
effective_cdp_connect_headers = existing_latest_workflow.cdp_connect_headers
|
||||
else:
|
||||
effective_cdp_connect_headers = merge_masked_headers(
|
||||
request.cdp_connect_headers,
|
||||
existing_latest_workflow.cdp_connect_headers,
|
||||
)
|
||||
|
||||
# NOTE: it's only potential, as it may be immediately deleted!
|
||||
potential_workflow = await self.create_workflow(
|
||||
title=title,
|
||||
|
|
@ -5690,6 +5718,7 @@ class WorkflowService:
|
|||
model=request.model,
|
||||
max_screenshot_scrolling_times=request.max_screenshot_scrolls,
|
||||
extra_http_headers=request.extra_http_headers,
|
||||
cdp_connect_headers=effective_cdp_connect_headers,
|
||||
workflow_permanent_id=existing_latest_workflow.workflow_permanent_id,
|
||||
version=existing_version + 1,
|
||||
is_saved_task=request.is_saved_task,
|
||||
|
|
@ -5709,6 +5738,10 @@ class WorkflowService:
|
|||
edited_by=edited_by,
|
||||
)
|
||||
else:
|
||||
# No existing workflow to inherit from; merge_masked_headers drops
|
||||
# any keys whose value is the mask sentinel so we never persist the
|
||||
# literal "***" placeholder from a misbehaving client.
|
||||
new_cdp_connect_headers = merge_masked_headers(request.cdp_connect_headers, None)
|
||||
# NOTE: it's only potential, as it may be immediately deleted!
|
||||
potential_workflow = await self.create_workflow(
|
||||
title=title,
|
||||
|
|
@ -5724,6 +5757,7 @@ class WorkflowService:
|
|||
model=request.model,
|
||||
max_screenshot_scrolling_times=request.max_screenshot_scrolls,
|
||||
extra_http_headers=request.extra_http_headers,
|
||||
cdp_connect_headers=new_cdp_connect_headers,
|
||||
is_saved_task=request.is_saved_task,
|
||||
status=request.status,
|
||||
run_with=request.run_with,
|
||||
|
|
@ -5801,6 +5835,7 @@ class WorkflowService:
|
|||
proxy_location: ProxyLocationInput = None,
|
||||
max_screenshot_scrolling_times: int | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
run_with: str | None = None,
|
||||
status: WorkflowStatus = WorkflowStatus.published,
|
||||
) -> Workflow:
|
||||
|
|
@ -5818,6 +5853,7 @@ class WorkflowService:
|
|||
status=status,
|
||||
max_screenshot_scrolls=max_screenshot_scrolling_times,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
run_with=run_with,
|
||||
)
|
||||
return await app.WORKFLOW_SERVICE.create_workflow_from_request(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ class BaseRunBlockRequest(BaseModel):
|
|||
extra_http_headers: dict[str, str] | None = Field(
|
||||
default=None, description="Additional HTTP headers to include in requests"
|
||||
)
|
||||
cdp_connect_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"HTTP headers attached ONLY to the CDP WebSocket handshake when connecting "
|
||||
"to a remote browser via browser_address. Never forwarded to target websites."
|
||||
),
|
||||
)
|
||||
max_screenshot_scrolling_times: int | None = Field(
|
||||
default=None, description="Maximum number of times to scroll for screenshots"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator
|
||||
|
||||
from skyvern.forge.sdk.schemas.files import FileInfo
|
||||
from skyvern.forge.sdk.workflow.models.validators import normalize_run_metadata, normalize_run_with
|
||||
|
|
@ -45,6 +45,7 @@ from skyvern.schemas.run_enums import ( # noqa: F401
|
|||
RunStatus,
|
||||
RunType,
|
||||
)
|
||||
from skyvern.utils.secret_headers import mask_header_values
|
||||
from skyvern.utils.url_validators import validate_url
|
||||
|
||||
# Type checkers need string Literal values, while pydantic's discriminated
|
||||
|
|
@ -128,6 +129,15 @@ class TaskRunRequest(BaseModel):
|
|||
default=None,
|
||||
description="The extra HTTP headers for the requests in browser.",
|
||||
)
|
||||
cdp_connect_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"HTTP headers attached ONLY to the CDP WebSocket handshake when connecting to "
|
||||
"a remote browser via browser_address. Use this for browser-provider auth "
|
||||
"(e.g., x-api-key for Skyvern Cloud, Browserless, or similar). These headers "
|
||||
"are NEVER forwarded to target websites."
|
||||
),
|
||||
)
|
||||
publish_workflow: bool = Field(
|
||||
default=False,
|
||||
description="Whether to publish this task as a reusable workflow. Only available for skyvern-2.0.",
|
||||
|
|
@ -174,6 +184,10 @@ class TaskRunRequest(BaseModel):
|
|||
|
||||
return validate_url(url)
|
||||
|
||||
@field_serializer("cdp_connect_headers")
|
||||
def _mask_cdp_connect_headers(self, headers: dict[str, str] | None) -> dict[str, str] | None:
|
||||
return mask_header_values(headers)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _force_v2_for_publish_workflow(self) -> TaskRunRequest:
|
||||
if self.publish_workflow and self.engine != RunEngine.skyvern_v2:
|
||||
|
|
@ -222,6 +236,15 @@ class WorkflowRunRequest(BaseModel):
|
|||
default=None,
|
||||
description="The extra HTTP headers for the requests in browser.",
|
||||
)
|
||||
cdp_connect_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"HTTP headers attached ONLY to the CDP WebSocket handshake when connecting to "
|
||||
"a remote browser via browser_address. Use this for browser-provider auth "
|
||||
"(e.g., x-api-key for Skyvern Cloud, Browserless, or similar). These headers "
|
||||
"are NEVER forwarded to target websites."
|
||||
),
|
||||
)
|
||||
browser_address: str | None = Field(
|
||||
default=None,
|
||||
description="The CDP address for the workflow run.",
|
||||
|
|
@ -260,6 +283,10 @@ class WorkflowRunRequest(BaseModel):
|
|||
return url
|
||||
return validate_url(url)
|
||||
|
||||
@field_serializer("cdp_connect_headers")
|
||||
def _mask_cdp_connect_headers(self, headers: dict[str, str] | None) -> dict[str, str] | None:
|
||||
return mask_header_values(headers)
|
||||
|
||||
|
||||
class BlockRunRequest(WorkflowRunRequest):
|
||||
block_labels: list[str] = Field(
|
||||
|
|
|
|||
|
|
@ -102,6 +102,62 @@ class DeployScriptRequest(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class DeployCachedScriptCacheContext(BaseModel):
|
||||
parameters: dict[str, str | int | float | bool | dict | list | None] = Field(
|
||||
default_factory=dict,
|
||||
description="Workflow-run parameter values used to resolve cache key and block URLs in strict deploy mode",
|
||||
)
|
||||
adaptive_caching: bool = Field(..., description="Whether deploy should apply Code 2.0 :v2 cache suffix")
|
||||
domain_override: str | None = Field(
|
||||
default=None,
|
||||
description="Explicit domain for default/empty cache keys when it cannot be derived from workflow block URLs",
|
||||
)
|
||||
|
||||
|
||||
class DeployCachedScriptRequest(BaseModel):
|
||||
workflow_id: str = Field(..., description="Expected latest workflow version id")
|
||||
workflow_version: int = Field(..., description="Expected latest workflow version number")
|
||||
cache_key: str | None = Field(
|
||||
default=None,
|
||||
description="Optional cache key template override; omitted uses the workflow cache_key",
|
||||
)
|
||||
cache_context: DeployCachedScriptCacheContext
|
||||
resolved_cache_key_value: str | None = Field(
|
||||
default=None,
|
||||
description="Optional operator assertion for the exact runtime cache key value",
|
||||
)
|
||||
files: list[ScriptFileCreate] = Field(..., description="Script files to validate; must include main.py")
|
||||
source_workflow_run_id: str | None = Field(default=None, description="Optional provenance run id")
|
||||
requires_agent_overrides: dict[str, bool] = Field(
|
||||
default_factory=dict,
|
||||
description="Explicit per-label requires_agent values for future commit mode",
|
||||
)
|
||||
dry_run: bool = Field(default=True, description="Dry-run must be true in the initial internal endpoint")
|
||||
|
||||
|
||||
class DeployCachedScriptBlockPlan(BaseModel):
|
||||
label: str
|
||||
primitive: str
|
||||
block_type: str | None = None
|
||||
is_cacheable: bool
|
||||
is_compound: bool
|
||||
missing_globals: list[str] = Field(default_factory=list)
|
||||
requires_agent: bool = False
|
||||
|
||||
|
||||
class DeployCachedScriptResponse(BaseModel):
|
||||
workflow_id: str
|
||||
workflow_version: int
|
||||
cache_key: str | None = None
|
||||
cache_key_value: str
|
||||
dry_run: bool
|
||||
would_create_script: bool
|
||||
cacheable_block_count: int
|
||||
skipped_block_labels: list[str] = Field(default_factory=list)
|
||||
blocks: list[DeployCachedScriptBlockPlan] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreateScriptResponse(BaseModel):
|
||||
script_id: str = Field(..., description="Unique script identifier", examples=["s_abc123"])
|
||||
version: int = Field(..., description="Script version number", examples=[1])
|
||||
|
|
|
|||
|
|
@ -1174,6 +1174,7 @@ class WorkflowCreateYAMLRequest(BaseModel):
|
|||
is_saved_task: bool = False
|
||||
max_screenshot_scrolls: int | None = None
|
||||
extra_http_headers: dict[str, str] | None = None
|
||||
cdp_connect_headers: dict[str, str] | None = None
|
||||
status: WorkflowStatus = WorkflowStatus.published
|
||||
run_with: str = "agent"
|
||||
ai_fallback: bool = True
|
||||
|
|
|
|||
132
skyvern/services/cached_script_deploy_service.py
Normal file
132
skyvern/services/cached_script_deploy_service.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from skyvern.core.script_generations.script_block_extractor import (
|
||||
RunSignatureValidationError,
|
||||
ScriptBlockExtractionError,
|
||||
extract_script_blocks,
|
||||
)
|
||||
from skyvern.forge import app
|
||||
from skyvern.forge.sdk.workflow.models.workflow import Workflow
|
||||
from skyvern.schemas.scripts import (
|
||||
DeployCachedScriptBlockPlan,
|
||||
DeployCachedScriptRequest,
|
||||
DeployCachedScriptResponse,
|
||||
FileEncoding,
|
||||
ScriptFileCreate,
|
||||
)
|
||||
from skyvern.services.workflow_script_service import CacheKeyResolutionError, resolve_cache_key_value
|
||||
|
||||
|
||||
def _decode_script_file(file: ScriptFileCreate) -> str:
|
||||
if file.encoding == FileEncoding.BASE64:
|
||||
try:
|
||||
return base64.b64decode(file.content).decode("utf-8")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"File {file.path!r} is not valid base64 UTF-8") from exc
|
||||
return file.content
|
||||
|
||||
|
||||
def _main_py(files: list[ScriptFileCreate]) -> str:
|
||||
for file in files:
|
||||
if file.path == "main.py":
|
||||
return _decode_script_file(file)
|
||||
raise HTTPException(status_code=400, detail="Cached script deploy requires a main.py file")
|
||||
|
||||
|
||||
def _workflow_with_cache_key(workflow: Workflow, cache_key: str | None) -> Workflow:
|
||||
if cache_key is None or cache_key == workflow.cache_key:
|
||||
return workflow
|
||||
return workflow.model_copy(update={"cache_key": cache_key})
|
||||
|
||||
|
||||
async def dry_run_cached_script_deploy(
|
||||
*,
|
||||
organization_id: str,
|
||||
workflow_permanent_id: str,
|
||||
request: DeployCachedScriptRequest,
|
||||
) -> DeployCachedScriptResponse:
|
||||
if not request.dry_run:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Commit mode is not enabled for deploy_cached yet; use dry_run=true"
|
||||
)
|
||||
|
||||
workflow = await app.DATABASE.workflows.get_workflow_by_permanent_id(
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
if workflow is None:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
if workflow.workflow_id != request.workflow_id or workflow.version != request.workflow_version:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Workflow version is stale: expected "
|
||||
f"{request.workflow_id} v{request.workflow_version}, found {workflow.workflow_id} v{workflow.version}"
|
||||
),
|
||||
)
|
||||
|
||||
proposed_workflow = _workflow_with_cache_key(workflow, request.cache_key)
|
||||
main_py = _main_py(request.files)
|
||||
|
||||
try:
|
||||
extraction = extract_script_blocks(main_py, proposed_workflow.workflow_definition.model_dump(mode="json"))
|
||||
except (ScriptBlockExtractionError, RunSignatureValidationError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
cacheable_blocks = extraction.cacheable_blocks
|
||||
if not cacheable_blocks:
|
||||
raise HTTPException(status_code=400, detail="Cached script deploy found zero cacheable script blocks")
|
||||
|
||||
missing_globals = {block.label: list(block.missing_globals) for block in cacheable_blocks if block.missing_globals}
|
||||
if missing_globals:
|
||||
raise HTTPException(status_code=400, detail={"missing_globals": missing_globals})
|
||||
|
||||
try:
|
||||
cache_key_value = resolve_cache_key_value(
|
||||
proposed_workflow,
|
||||
request.cache_context.parameters,
|
||||
adaptive_caching=request.cache_context.adaptive_caching,
|
||||
strict=True,
|
||||
domain_override=request.cache_context.domain_override,
|
||||
)
|
||||
except CacheKeyResolutionError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Could not resolve cache key value: {exc}") from exc
|
||||
|
||||
if request.resolved_cache_key_value and request.resolved_cache_key_value != cache_key_value:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Resolved cache key value mismatch: "
|
||||
f"expected {request.resolved_cache_key_value!r}, resolved {cache_key_value!r}"
|
||||
),
|
||||
)
|
||||
|
||||
block_plans = [
|
||||
DeployCachedScriptBlockPlan(
|
||||
label=block.label,
|
||||
primitive=block.primitive,
|
||||
block_type=block.block_type,
|
||||
is_cacheable=block.is_cacheable,
|
||||
is_compound=block.is_compound,
|
||||
missing_globals=list(block.missing_globals),
|
||||
requires_agent=request.requires_agent_overrides.get(block.label, False),
|
||||
)
|
||||
for block in extraction.blocks
|
||||
]
|
||||
|
||||
return DeployCachedScriptResponse(
|
||||
workflow_id=workflow.workflow_id,
|
||||
workflow_version=workflow.version,
|
||||
cache_key=proposed_workflow.cache_key,
|
||||
cache_key_value=cache_key_value,
|
||||
dry_run=True,
|
||||
would_create_script=True,
|
||||
cacheable_block_count=len(cacheable_blocks),
|
||||
skipped_block_labels=[block.label for block in extraction.blocks if not block.is_cacheable],
|
||||
blocks=block_plans,
|
||||
warnings=extraction.warnings,
|
||||
)
|
||||
|
|
@ -260,6 +260,7 @@ async def initialize_task_v2(
|
|||
max_screenshot_scrolling_times: int | None = None,
|
||||
browser_session_id: str | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
browser_address: str | None = None,
|
||||
run_with: str | None = None,
|
||||
trigger_type: WorkflowRunTriggerType | None = None,
|
||||
|
|
@ -278,6 +279,7 @@ async def initialize_task_v2(
|
|||
model=model,
|
||||
max_screenshot_scrolling_times=max_screenshot_scrolling_times,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
browser_address=browser_address,
|
||||
run_with=run_with,
|
||||
)
|
||||
|
|
@ -299,6 +301,7 @@ async def initialize_task_v2(
|
|||
status=workflow_status,
|
||||
max_screenshot_scrolling_times=max_screenshot_scrolling_times,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
run_with=run_with,
|
||||
)
|
||||
workflow_run = await app.WORKFLOW_SERVICE.setup_workflow_run(
|
||||
|
|
@ -307,6 +310,7 @@ async def initialize_task_v2(
|
|||
max_screenshot_scrolls=max_screenshot_scrolling_times,
|
||||
browser_session_id=browser_session_id,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
browser_address=browser_address,
|
||||
run_with=run_with,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from typing import Any, NamedTuple
|
|||
|
||||
import structlog
|
||||
from cachetools import TTLCache
|
||||
from jinja2 import StrictUndefined
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from skyvern.config import settings
|
||||
|
|
@ -27,6 +28,7 @@ from skyvern.utils.url_validators import prepend_scheme_and_validate_url
|
|||
|
||||
LOG = structlog.get_logger()
|
||||
jinja_sandbox_env = SandboxedEnvironment()
|
||||
strict_jinja_sandbox_env = SandboxedEnvironment(undefined=StrictUndefined)
|
||||
|
||||
# Shared regex for parsing @skyvern.cached decorator lines in main.py source.
|
||||
_CACHED_DECORATOR_RE = re.compile(
|
||||
|
|
@ -113,9 +115,19 @@ def _jinja_domain_filter(url: str) -> str:
|
|||
|
||||
|
||||
jinja_sandbox_env.filters["domain"] = _jinja_domain_filter
|
||||
strict_jinja_sandbox_env.filters["domain"] = _jinja_domain_filter
|
||||
|
||||
|
||||
def _resolve_block_url_for_cache_key(url_template: str, parameters: dict[str, Any]) -> str:
|
||||
class CacheKeyResolutionError(ValueError):
|
||||
"""Raised when deploy-time strict cache-key resolution cannot prove runtime parity."""
|
||||
|
||||
|
||||
def _render_cache_template(template: str, parameters: dict[str, Any], *, strict: bool) -> str:
|
||||
env = strict_jinja_sandbox_env if strict else jinja_sandbox_env
|
||||
return env.from_string(template).render(parameters)
|
||||
|
||||
|
||||
def _resolve_block_url_for_cache_key(url_template: str, parameters: dict[str, Any], *, strict: bool = False) -> str:
|
||||
"""Resolve a block ``url`` the same way runtime does: param-key swap,
|
||||
Jinja render, prepend-scheme validate. Must stay in sync with
|
||||
``BaseTaskBlock.execute`` / ``format_potential_template_parameters``.
|
||||
|
|
@ -125,35 +137,100 @@ def _resolve_block_url_for_cache_key(url_template: str, parameters: dict[str, An
|
|||
value = parameters[url_template]
|
||||
if value:
|
||||
candidate = str(value)
|
||||
rendered = jinja_sandbox_env.from_string(candidate).render(parameters)
|
||||
rendered = _render_cache_template(candidate, parameters, strict=strict)
|
||||
if not rendered:
|
||||
return ""
|
||||
return prepend_scheme_and_validate_url(rendered)
|
||||
|
||||
|
||||
def _extract_first_block_domain(workflow: Workflow, parameters: dict[str, Any]) -> str:
|
||||
def _extract_first_block_domain(workflow: Workflow, parameters: dict[str, Any], *, strict: bool = False) -> str:
|
||||
"""Extract the domain from the first block's URL for cache-key enrichment.
|
||||
|
||||
Calls ``_resolve_block_url_for_cache_key`` on each block's ``url`` and
|
||||
returns the first non-empty domain. The helper mirrors runtime's URL
|
||||
resolution — see its docstring for the pipeline.
|
||||
"""
|
||||
try:
|
||||
|
||||
def _resolve() -> str:
|
||||
blocks = get_all_blocks(workflow.workflow_definition.blocks)
|
||||
for block in blocks:
|
||||
url_template = getattr(block, "url", None)
|
||||
if not url_template:
|
||||
continue
|
||||
rendered_url = _resolve_block_url_for_cache_key(str(url_template), parameters)
|
||||
rendered_url = _resolve_block_url_for_cache_key(str(url_template), parameters, strict=strict)
|
||||
if rendered_url:
|
||||
domain = _jinja_domain_filter(rendered_url)
|
||||
if domain:
|
||||
return domain
|
||||
return ""
|
||||
|
||||
if strict:
|
||||
return _resolve()
|
||||
|
||||
try:
|
||||
return _resolve()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_cache_key_value(
|
||||
workflow: Workflow,
|
||||
parameters: dict[str, Any],
|
||||
*,
|
||||
adaptive_caching: bool,
|
||||
strict: bool = False,
|
||||
domain_override: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve a workflow script cache key without performing the DB lookup.
|
||||
|
||||
Runtime calls this in tolerant mode after loading a workflow run's
|
||||
parameters. Deploy flows call it in strict mode with explicit cache context,
|
||||
so missing template values fail before publishing a script.
|
||||
"""
|
||||
cache_key = workflow.cache_key or ""
|
||||
|
||||
try:
|
||||
rendered_cache_key_value = _render_cache_template(cache_key, parameters, strict=strict)
|
||||
|
||||
if rendered_cache_key_value in ("default", ""):
|
||||
domain = domain_override or _extract_first_block_domain(workflow, parameters, strict=strict)
|
||||
if not domain and strict:
|
||||
raise CacheKeyResolutionError(
|
||||
"Unable to resolve default cache key: provide cache_context.parameters or domain_override"
|
||||
)
|
||||
if domain:
|
||||
ats_platform = app.AGENT_FUNCTION.detect_ats_platform(domain)
|
||||
if ats_platform:
|
||||
LOG.info(
|
||||
"Code 2.0: platform detected, using platform-level cache key",
|
||||
ats_platform=ats_platform,
|
||||
original_domain=domain,
|
||||
workflow_permanent_id=workflow.workflow_permanent_id,
|
||||
)
|
||||
cache_segment = ats_platform if ats_platform else domain
|
||||
rendered_cache_key_value = (
|
||||
f"{rendered_cache_key_value}:{cache_segment}" if rendered_cache_key_value else cache_segment
|
||||
)
|
||||
|
||||
if adaptive_caching:
|
||||
rendered_cache_key_value = f"{rendered_cache_key_value}:v2" if rendered_cache_key_value else "v2"
|
||||
|
||||
return rendered_cache_key_value
|
||||
except CacheKeyResolutionError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if strict:
|
||||
raise CacheKeyResolutionError(str(exc)) from exc
|
||||
LOG.warning(
|
||||
"Failed to resolve workflow script cache key",
|
||||
workflow_id=workflow.workflow_id,
|
||||
workflow_permanent_id=workflow.workflow_permanent_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def workflow_has_conditionals(workflow: Workflow) -> bool:
|
||||
"""
|
||||
Check if a workflow contains any conditional blocks.
|
||||
|
|
@ -327,7 +404,6 @@ async def get_workflow_script(
|
|||
Check if there's a related workflow script that should be used instead of running the workflow.
|
||||
Returns the tuple of (script, rendered_cache_key_value, is_pinned).
|
||||
"""
|
||||
cache_key = workflow.cache_key or ""
|
||||
rendered_cache_key_value = ""
|
||||
|
||||
try:
|
||||
|
|
@ -336,33 +412,11 @@ async def get_workflow_script(
|
|||
)
|
||||
parameters = {wf_param.key: run_param.value for wf_param, run_param in parameter_tuples}
|
||||
|
||||
rendered_cache_key_value = jinja_sandbox_env.from_string(cache_key).render(parameters)
|
||||
|
||||
# Auto-enrich with domain when using the default cache key.
|
||||
# This ensures the same workflow running against different sites gets
|
||||
# separate cached scripts. For known platform patterns, a platform-level
|
||||
# key is used instead of the domain so all employers on the same platform
|
||||
# share one cached script.
|
||||
if rendered_cache_key_value in ("default", ""):
|
||||
domain = _extract_first_block_domain(workflow, parameters)
|
||||
if domain:
|
||||
ats_platform = app.AGENT_FUNCTION.detect_ats_platform(domain)
|
||||
if ats_platform:
|
||||
LOG.info(
|
||||
"Code 2.0: platform detected, using platform-level cache key",
|
||||
ats_platform=ats_platform,
|
||||
original_domain=domain,
|
||||
workflow_permanent_id=workflow.workflow_permanent_id,
|
||||
)
|
||||
cache_segment = ats_platform if ats_platform else domain
|
||||
rendered_cache_key_value = (
|
||||
f"{rendered_cache_key_value}:{cache_segment}" if rendered_cache_key_value else cache_segment
|
||||
)
|
||||
|
||||
# Namespace adaptive caching (Code 2.0) scripts with :v2 suffix so they
|
||||
# don't collide with traditional (Code 1.0) cached scripts.
|
||||
if is_adaptive_caching(workflow, workflow_run):
|
||||
rendered_cache_key_value = f"{rendered_cache_key_value}:v2" if rendered_cache_key_value else "v2"
|
||||
rendered_cache_key_value = resolve_cache_key_value(
|
||||
workflow=workflow,
|
||||
parameters=parameters,
|
||||
adaptive_caching=is_adaptive_caching(workflow, workflow_run),
|
||||
)
|
||||
|
||||
LOG.info(
|
||||
"Resolved cache key for workflow script lookup",
|
||||
|
|
|
|||
39
skyvern/utils/secret_headers.py
Normal file
39
skyvern/utils/secret_headers.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
SECRET_HEADER_MASK = "***"
|
||||
|
||||
|
||||
def mask_header_values(headers: dict[str, str] | None) -> dict[str, str] | None:
|
||||
"""Replace every value in a header dict with ``SECRET_HEADER_MASK``.
|
||||
|
||||
Used by Pydantic ``field_serializer`` hooks on ``cdp_connect_headers`` so that
|
||||
API responses never echo browser-provider credentials (e.g. ``x-api-key``)
|
||||
back to clients polling status endpoints. The serializer fires on
|
||||
``model_dump``/JSON serialization only, so internal attribute access still
|
||||
returns the raw value used for the CDP handshake.
|
||||
"""
|
||||
if not headers:
|
||||
return headers
|
||||
return {k: SECRET_HEADER_MASK for k in headers}
|
||||
|
||||
|
||||
def merge_masked_headers(
|
||||
new_headers: dict[str, str] | None,
|
||||
stored_headers: dict[str, str] | None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Resolve ``SECRET_HEADER_MASK`` entries in ``new_headers`` against ``stored_headers`` per-key.
|
||||
|
||||
Masked entries fall back to the stored value (preserving credentials on round-trip)
|
||||
or are dropped when no stored value exists; non-masked entries pass through verbatim.
|
||||
Returns ``None`` only when ``new_headers`` is ``None`` so callers can distinguish
|
||||
"field omitted" from an explicit empty/cleared dict.
|
||||
"""
|
||||
if new_headers is None:
|
||||
return None
|
||||
stored = stored_headers or {}
|
||||
merged: dict[str, str] = {}
|
||||
for key, value in new_headers.items():
|
||||
if value == SECRET_HEADER_MASK:
|
||||
if key in stored:
|
||||
merged[key] = stored[key]
|
||||
continue
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
|
@ -517,6 +517,7 @@ async def _create_headless_chromium(
|
|||
playwright: Playwright,
|
||||
proxy_location: ProxyLocation | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
**kwargs: dict,
|
||||
) -> tuple[BrowserContext, BrowserArtifacts, BrowserCleanupFunc]:
|
||||
if browser_address := kwargs.get("browser_address"):
|
||||
|
|
@ -524,6 +525,7 @@ async def _create_headless_chromium(
|
|||
playwright,
|
||||
remote_browser_url=str(browser_address),
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
apply_download_behaviour=True,
|
||||
)
|
||||
|
||||
|
|
@ -606,6 +608,7 @@ async def _create_headful_chromium(
|
|||
playwright: Playwright,
|
||||
proxy_location: ProxyLocation | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
**kwargs: dict,
|
||||
) -> tuple[BrowserContext, BrowserArtifacts, BrowserCleanupFunc]:
|
||||
if browser_address := kwargs.get("browser_address"):
|
||||
|
|
@ -613,6 +616,7 @@ async def _create_headful_chromium(
|
|||
playwright,
|
||||
remote_browser_url=str(browser_address),
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
apply_download_behaviour=True,
|
||||
)
|
||||
|
||||
|
|
@ -723,6 +727,7 @@ async def _create_cdp_connection_browser(
|
|||
playwright: Playwright,
|
||||
proxy_location: ProxyLocation | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
**kwargs: dict,
|
||||
) -> tuple[BrowserContext, BrowserArtifacts, BrowserCleanupFunc]:
|
||||
if browser_address := kwargs.get("browser_address"):
|
||||
|
|
@ -730,6 +735,7 @@ async def _create_cdp_connection_browser(
|
|||
playwright,
|
||||
remote_browser_url=str(browser_address),
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
apply_download_behaviour=True,
|
||||
)
|
||||
|
||||
|
|
@ -787,6 +793,7 @@ async def _connect_to_cdp_browser(
|
|||
playwright: Playwright,
|
||||
remote_browser_url: str,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
apply_download_behaviour: bool = False,
|
||||
) -> tuple[BrowserContext, BrowserArtifacts, BrowserCleanupFunc]:
|
||||
parsed_headers = parse_extra_headers(extra_http_headers)
|
||||
|
|
@ -800,10 +807,20 @@ async def _connect_to_cdp_browser(
|
|||
)
|
||||
|
||||
LOG.info("Connecting browser CDP connection", remote_browser_url=remote_browser_url)
|
||||
cdp_headers = build_cdp_connect_headers(settings.BROWSER_REMOTE_DEBUGGING_HOST_HEADER) or {}
|
||||
if cdp_connect_headers:
|
||||
# HTTP header names are case-insensitive, so a plain ``{**user, **server}``
|
||||
# would still leak a user-supplied ``host`` alongside our managed ``Host``.
|
||||
# Drop any user key that collides with a server key on a lowercased compare.
|
||||
reserved_keys = {key.lower() for key in cdp_headers}
|
||||
filtered_user_headers = {
|
||||
key: value for key, value in cdp_connect_headers.items() if key.lower() not in reserved_keys
|
||||
}
|
||||
cdp_headers = {**filtered_user_headers, **cdp_headers}
|
||||
browser = await _connect_over_cdp_with_diagnostics(
|
||||
playwright,
|
||||
remote_browser_url,
|
||||
headers=build_cdp_connect_headers(settings.BROWSER_REMOTE_DEBUGGING_HOST_HEADER),
|
||||
headers=cdp_headers or None,
|
||||
timeout_ms=settings.BROWSER_CDP_CONNECT_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class BrowserState(Protocol):
|
|||
script_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
browser_address: str | None = None,
|
||||
browser_profile_id: str | None = None,
|
||||
) -> None: ...
|
||||
|
|
@ -56,6 +57,7 @@ class BrowserState(Protocol):
|
|||
script_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
browser_address: str | None = None,
|
||||
browser_profile_id: str | None = None,
|
||||
) -> Page: ...
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class RealBrowserManager(BrowserManager):
|
|||
script_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
browser_address: str | None = None,
|
||||
browser_profile_id: str | None = None,
|
||||
) -> BrowserState:
|
||||
|
|
@ -52,6 +53,7 @@ class RealBrowserManager(BrowserManager):
|
|||
script_id=script_id,
|
||||
organization_id=organization_id,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
browser_address=browser_address,
|
||||
browser_profile_id=browser_profile_id,
|
||||
)
|
||||
|
|
@ -123,6 +125,7 @@ class RealBrowserManager(BrowserManager):
|
|||
workflow_permanent_id=task.workflow_permanent_id,
|
||||
organization_id=task.organization_id,
|
||||
extra_http_headers=task.extra_http_headers,
|
||||
cdp_connect_headers=task.cdp_connect_headers,
|
||||
browser_address=task.browser_address,
|
||||
)
|
||||
|
||||
|
|
@ -145,6 +148,7 @@ class RealBrowserManager(BrowserManager):
|
|||
workflow_permanent_id=task.workflow_permanent_id,
|
||||
organization_id=task.organization_id,
|
||||
extra_http_headers=task.extra_http_headers,
|
||||
cdp_connect_headers=task.cdp_connect_headers,
|
||||
browser_address=task.browser_address,
|
||||
)
|
||||
return browser_state
|
||||
|
|
@ -218,6 +222,7 @@ class RealBrowserManager(BrowserManager):
|
|||
workflow_permanent_id=workflow_run.workflow_permanent_id,
|
||||
organization_id=workflow_run.organization_id,
|
||||
extra_http_headers=workflow_run.extra_http_headers,
|
||||
cdp_connect_headers=workflow_run.cdp_connect_headers,
|
||||
browser_address=workflow_run.browser_address,
|
||||
browser_profile_id=browser_profile_id,
|
||||
)
|
||||
|
|
@ -245,6 +250,7 @@ class RealBrowserManager(BrowserManager):
|
|||
workflow_permanent_id=workflow_run.workflow_permanent_id,
|
||||
organization_id=workflow_run.organization_id,
|
||||
extra_http_headers=workflow_run.extra_http_headers,
|
||||
cdp_connect_headers=workflow_run.cdp_connect_headers,
|
||||
browser_address=workflow_run.browser_address,
|
||||
browser_profile_id=browser_profile_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ class RealBrowserState(BrowserState):
|
|||
script_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
browser_address: str | None = None,
|
||||
browser_profile_id: str | None = None,
|
||||
) -> None:
|
||||
|
|
@ -101,6 +102,7 @@ class RealBrowserState(BrowserState):
|
|||
script_id=script_id,
|
||||
organization_id=organization_id,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
browser_address=browser_address,
|
||||
browser_profile_id=browser_profile_id,
|
||||
)
|
||||
|
|
@ -274,6 +276,7 @@ class RealBrowserState(BrowserState):
|
|||
script_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
extra_http_headers: dict[str, str] | None = None,
|
||||
cdp_connect_headers: dict[str, str] | None = None,
|
||||
browser_address: str | None = None,
|
||||
browser_profile_id: str | None = None,
|
||||
) -> Page:
|
||||
|
|
@ -291,6 +294,7 @@ class RealBrowserState(BrowserState):
|
|||
script_id=script_id,
|
||||
organization_id=organization_id,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
browser_address=browser_address,
|
||||
browser_profile_id=browser_profile_id,
|
||||
)
|
||||
|
|
@ -312,6 +316,7 @@ class RealBrowserState(BrowserState):
|
|||
script_id=script_id,
|
||||
organization_id=organization_id,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
browser_address=browser_address,
|
||||
browser_profile_id=browser_profile_id,
|
||||
)
|
||||
|
|
@ -330,6 +335,7 @@ class RealBrowserState(BrowserState):
|
|||
script_id=script_id,
|
||||
organization_id=organization_id,
|
||||
extra_http_headers=extra_http_headers,
|
||||
cdp_connect_headers=cdp_connect_headers,
|
||||
browser_address=browser_address,
|
||||
browser_profile_id=browser_profile_id,
|
||||
)
|
||||
|
|
|
|||
230
tests/unit/test_cached_script_deploy_service.py
Normal file
230
tests/unit/test_cached_script_deploy_service.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import base64
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from skyvern.forge.sdk.workflow.models.workflow import Workflow
|
||||
from skyvern.schemas.scripts import (
|
||||
DeployCachedScriptCacheContext,
|
||||
DeployCachedScriptRequest,
|
||||
FileEncoding,
|
||||
ScriptFileCreate,
|
||||
)
|
||||
from skyvern.services import cached_script_deploy_service
|
||||
|
||||
|
||||
def _b64(text: str) -> str:
|
||||
return base64.b64encode(text.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def _workflow(*, cache_key: str | None = "default", version: int = 3) -> Workflow:
|
||||
return Workflow(
|
||||
workflow_id="wf_latest",
|
||||
organization_id="org_test",
|
||||
workflow_permanent_id="wpid_test",
|
||||
title="test",
|
||||
version=version,
|
||||
is_saved_task=False,
|
||||
workflow_definition={
|
||||
"parameters": [],
|
||||
"blocks": [
|
||||
{
|
||||
"block_type": "navigation",
|
||||
"label": "step_a",
|
||||
"url": "https://example.com/login",
|
||||
"navigation_goal": "Open",
|
||||
"output_parameter": {
|
||||
"parameter_type": "output",
|
||||
"key": "step_a_output",
|
||||
"output_parameter_id": "op_test",
|
||||
"workflow_id": "wf_latest",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"modified_at": datetime.now(timezone.utc),
|
||||
},
|
||||
},
|
||||
{
|
||||
"block_type": "validation",
|
||||
"label": "check",
|
||||
"complete_criterion": "done",
|
||||
"terminate_criterion": "stop",
|
||||
"output_parameter": {
|
||||
"parameter_type": "output",
|
||||
"key": "check_output",
|
||||
"output_parameter_id": "op_check",
|
||||
"workflow_id": "wf_latest",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"modified_at": datetime.now(timezone.utc),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
run_with="code",
|
||||
cache_key=cache_key,
|
||||
code_version=2,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
modified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _request(
|
||||
source: str,
|
||||
*,
|
||||
resolved_cache_key_value: str | None = None,
|
||||
dry_run: bool = True,
|
||||
cache_key: str | None = "default",
|
||||
) -> DeployCachedScriptRequest:
|
||||
return DeployCachedScriptRequest(
|
||||
workflow_id="wf_latest",
|
||||
workflow_version=3,
|
||||
cache_key=cache_key,
|
||||
cache_context=DeployCachedScriptCacheContext(parameters={}, adaptive_caching=True),
|
||||
resolved_cache_key_value=resolved_cache_key_value,
|
||||
dry_run=dry_run,
|
||||
files=[
|
||||
ScriptFileCreate(
|
||||
path="main.py",
|
||||
content=_b64(source),
|
||||
encoding=FileEncoding.BASE64,
|
||||
mime_type="text/x-python",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_app(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
workflow = _workflow()
|
||||
|
||||
class Workflows:
|
||||
async def get_workflow_by_permanent_id(self, **_: object) -> Workflow:
|
||||
return workflow
|
||||
|
||||
monkeypatch.setattr(
|
||||
cached_script_deploy_service.app,
|
||||
"DATABASE",
|
||||
SimpleNamespace(workflows=Workflows()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cached_script_deploy_service.app,
|
||||
"AGENT_FUNCTION",
|
||||
SimpleNamespace(detect_ats_platform=lambda domain: None),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_returns_cache_key_and_block_plan() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
await skyvern.run_task(prompt="...", label="step_a", cache_key="step_a")
|
||||
await skyvern.validate(complete_criterion="done", terminate_criterion="stop", label="check")
|
||||
"""
|
||||
|
||||
response = await cached_script_deploy_service.dry_run_cached_script_deploy(
|
||||
organization_id="org_test",
|
||||
workflow_permanent_id="wpid_test",
|
||||
request=_request(source, resolved_cache_key_value="default:example.com:v2"),
|
||||
)
|
||||
|
||||
assert response.dry_run is True
|
||||
assert response.would_create_script is True
|
||||
assert response.cache_key_value == "default:example.com:v2"
|
||||
assert response.cacheable_block_count == 1
|
||||
assert response.skipped_block_labels == ["check"]
|
||||
assert [block.label for block in response.blocks] == ["step_a", "check"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_uses_workflow_cache_key_when_override_is_omitted() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
await skyvern.run_task(prompt="...", label="step_a", cache_key="step_a")
|
||||
"""
|
||||
|
||||
response = await cached_script_deploy_service.dry_run_cached_script_deploy(
|
||||
organization_id="org_test",
|
||||
workflow_permanent_id="wpid_test",
|
||||
request=_request(source, resolved_cache_key_value="default:example.com:v2", cache_key=None),
|
||||
)
|
||||
|
||||
assert response.cache_key == "default"
|
||||
assert response.cache_key_value == "default:example.com:v2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_rejects_commit_mode_until_writes_are_implemented() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await cached_script_deploy_service.dry_run_cached_script_deploy(
|
||||
organization_id="org_test",
|
||||
workflow_permanent_id="wpid_test",
|
||||
request=_request("import skyvern\nasync def run(parameters):\n pass\n", dry_run=False),
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "Commit mode is not enabled" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_rejects_missing_globals() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
await skyvern.run_task(url=LOGIN_URL, prompt="...", label="step_a", cache_key="step_a")
|
||||
"""
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await cached_script_deploy_service.dry_run_cached_script_deploy(
|
||||
organization_id="org_test",
|
||||
workflow_permanent_id="wpid_test",
|
||||
request=_request(source),
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == {"missing_globals": {"step_a": ["LOGIN_URL"]}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_returns_400_for_run_signature_validation_errors() -> None:
|
||||
source = """
|
||||
from constants import *
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
await skyvern.run_task(prompt="...", label="step_a", cache_key="step_a")
|
||||
"""
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await cached_script_deploy_service.dry_run_cached_script_deploy(
|
||||
organization_id="org_test",
|
||||
workflow_permanent_id="wpid_test",
|
||||
request=_request(source),
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "Wildcard imports" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_rejects_cache_key_assertion_mismatch() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
await skyvern.run_task(prompt="...", label="step_a", cache_key="step_a")
|
||||
"""
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await cached_script_deploy_service.dry_run_cached_script_deploy(
|
||||
organization_id="org_test",
|
||||
workflow_permanent_id="wpid_test",
|
||||
request=_request(source, resolved_cache_key_value="wrong:v2"),
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "Resolved cache key value mismatch" in str(exc.value.detail)
|
||||
|
|
@ -471,11 +471,10 @@ def test_quickstart_explicit_local_choice_without_local_extra_prints_install_ste
|
|||
def test_quickstart_server_flags_select_server_path_without_server_extra(monkeypatch) -> None:
|
||||
install_calls = []
|
||||
server_calls = []
|
||||
server_extra_checks = iter([False, False, True])
|
||||
|
||||
monkeypatch.setattr(quickstart_module, "check_docker_compose_file", lambda: False)
|
||||
monkeypatch.setattr(quickstart_module, "_has_local_quickstart_extra", lambda: True)
|
||||
monkeypatch.setattr(quickstart_module, "_has_server_quickstart_extra", lambda: next(server_extra_checks))
|
||||
monkeypatch.setattr(quickstart_module, "_has_server_quickstart_extra", lambda: False)
|
||||
monkeypatch.setattr(quickstart_module, "_is_interactive_input", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
quickstart_module,
|
||||
|
|
@ -492,15 +491,9 @@ def test_quickstart_server_flags_select_server_path_without_server_extra(monkeyp
|
|||
assert result.exit_code == 0
|
||||
assert "Embedded local Python SDK" not in result.output
|
||||
assert "Cloud/API SDK usage" not in result.output
|
||||
assert install_calls == ["install"]
|
||||
assert server_calls == [
|
||||
{
|
||||
"no_postgres": False,
|
||||
"database_string": "postgresql+psycopg://user/db",
|
||||
"skip_browser_install": False,
|
||||
"server_only": False,
|
||||
}
|
||||
]
|
||||
assert 'Install: pip install "skyvern[server]"' in result.output
|
||||
assert install_calls == []
|
||||
assert server_calls == []
|
||||
|
||||
|
||||
def test_quickstart_explicit_install_type_overrides_server_flags(monkeypatch) -> None:
|
||||
|
|
@ -542,6 +535,36 @@ def test_quickstart_interactive_server_choice_without_server_extra_prints_instal
|
|||
assert "Wheel installs run the backend only" in result.output
|
||||
|
||||
|
||||
def test_quickstart_interactive_server_choice_can_install_server_extra(monkeypatch) -> None:
|
||||
install_calls = []
|
||||
server_calls = []
|
||||
server_extra_checks = iter([False, False, True])
|
||||
|
||||
monkeypatch.setattr(quickstart_module, "check_docker_compose_file", lambda: False)
|
||||
monkeypatch.setattr(quickstart_module, "_has_local_quickstart_extra", lambda: False)
|
||||
monkeypatch.setattr(quickstart_module, "_has_server_quickstart_extra", lambda: next(server_extra_checks))
|
||||
monkeypatch.setattr(quickstart_module, "_is_interactive_input", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
quickstart_module,
|
||||
"_install_server_extra_for_quickstart",
|
||||
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 == ["install"]
|
||||
assert server_calls == [
|
||||
{
|
||||
"no_postgres": False,
|
||||
"database_string": "",
|
||||
"skip_browser_install": False,
|
||||
"server_only": False,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_quickstart_interactive_choice_accepts_alias(monkeypatch) -> None:
|
||||
monkeypatch.setattr(quickstart_module, "check_docker_compose_file", lambda: False)
|
||||
monkeypatch.setattr(quickstart_module, "_has_local_quickstart_extra", lambda: False)
|
||||
|
|
@ -592,12 +615,11 @@ def test_quickstart_server_flags_skip_docker_compose_offer_without_server_extra(
|
|||
calls = []
|
||||
install_calls = []
|
||||
server_calls = []
|
||||
server_extra_checks = iter([False, False, True])
|
||||
|
||||
monkeypatch.setattr(quickstart_module, "check_docker", lambda: True)
|
||||
monkeypatch.setattr(quickstart_module, "check_docker_compose_file", lambda: True)
|
||||
monkeypatch.setattr(quickstart_module, "_has_local_quickstart_extra", lambda: False)
|
||||
monkeypatch.setattr(quickstart_module, "_has_server_quickstart_extra", lambda: next(server_extra_checks))
|
||||
monkeypatch.setattr(quickstart_module, "_has_server_quickstart_extra", lambda: False)
|
||||
monkeypatch.setattr(quickstart_module, "_is_interactive_input", lambda: False)
|
||||
monkeypatch.setattr(quickstart_module, "run_docker_compose_setup", lambda: calls.append("docker"))
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -611,15 +633,9 @@ def test_quickstart_server_flags_skip_docker_compose_offer_without_server_extra(
|
|||
|
||||
assert result.exit_code == 0
|
||||
assert "Docker Compose file detected" not in result.output
|
||||
assert install_calls == ["install"]
|
||||
assert server_calls == [
|
||||
{
|
||||
"no_postgres": False,
|
||||
"database_string": "",
|
||||
"skip_browser_install": False,
|
||||
"server_only": True,
|
||||
}
|
||||
]
|
||||
assert 'Install: pip install "skyvern[server]"' in result.output
|
||||
assert install_calls == []
|
||||
assert server_calls == []
|
||||
assert calls == []
|
||||
|
||||
|
||||
|
|
|
|||
162
tests/unit/test_script_block_extractor.py
Normal file
162
tests/unit/test_script_block_extractor.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import pytest
|
||||
|
||||
from skyvern.core.script_generations.script_block_extractor import (
|
||||
RunSignatureValidationError,
|
||||
ScriptBlockExtractionError,
|
||||
extract_script_blocks,
|
||||
)
|
||||
|
||||
|
||||
def test_extracts_try_else_block() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
@skyvern.workflow(title="t")
|
||||
async def run(parameters):
|
||||
page, context = await skyvern.setup(parameters, dict)
|
||||
try:
|
||||
await page.wait_for_load_state()
|
||||
except TimeoutError:
|
||||
pass
|
||||
else:
|
||||
await skyvern.run_task(prompt="...", label="step_a", cache_key="step_a")
|
||||
"""
|
||||
workflow_definition = {"blocks": [{"label": "step_a", "block_type": "navigation"}]}
|
||||
|
||||
result = extract_script_blocks(source, workflow_definition)
|
||||
|
||||
assert [block.label for block in result.blocks] == ["step_a"]
|
||||
assert result.blocks[0].is_cacheable is True
|
||||
assert result.blocks[0].run_signature.startswith("await skyvern.run_task")
|
||||
|
||||
|
||||
def test_extracts_nested_loop_child_block_type() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
page, context = await skyvern.setup(parameters, dict)
|
||||
async for current_value in skyvern.loop(label="outer", cache_key="outer", loop_over="items"):
|
||||
await skyvern.run_task(prompt="...", label="inner_a", cache_key="inner_a")
|
||||
"""
|
||||
workflow_definition = {
|
||||
"blocks": [
|
||||
{
|
||||
"label": "outer",
|
||||
"block_type": "for_loop",
|
||||
"loop_blocks": [{"label": "inner_a", "block_type": "navigation"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = extract_script_blocks(source, workflow_definition)
|
||||
|
||||
assert [block.label for block in result.blocks] == ["outer", "inner_a"]
|
||||
outer = result.blocks[0]
|
||||
inner = result.blocks[1]
|
||||
assert outer.is_compound is True
|
||||
assert outer.is_cacheable is True
|
||||
assert outer.run_signature.startswith("async for ")
|
||||
assert inner.block_type == "navigation"
|
||||
assert inner.is_cacheable is True
|
||||
|
||||
|
||||
def test_missing_global_is_reported_for_signature() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
await skyvern.run_task(url=LOGIN_URL, prompt="...", label="login", cache_key="login")
|
||||
"""
|
||||
workflow_definition = {"blocks": [{"label": "login", "block_type": "login"}]}
|
||||
|
||||
result = extract_script_blocks(source, workflow_definition)
|
||||
|
||||
assert result.blocks[0].missing_globals == ("LOGIN_URL",)
|
||||
|
||||
|
||||
def test_defined_global_is_allowed_for_signature() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
LOGIN_URL = "https://example.com"
|
||||
|
||||
async def run(parameters):
|
||||
await skyvern.run_task(url=LOGIN_URL, prompt="...", label="login", cache_key="login")
|
||||
"""
|
||||
workflow_definition = {"blocks": [{"label": "login", "block_type": "login"}]}
|
||||
|
||||
result = extract_script_blocks(source, workflow_definition)
|
||||
|
||||
assert result.blocks[0].missing_globals == ()
|
||||
|
||||
|
||||
def test_entry_function_parameters_and_locals_are_allowed_for_signature() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
page, context = await skyvern.setup(parameters, dict)
|
||||
await skyvern.run_task(
|
||||
url=parameters["url"],
|
||||
prompt=context.prompt,
|
||||
label="login",
|
||||
cache_key="login",
|
||||
)
|
||||
"""
|
||||
workflow_definition = {"blocks": [{"label": "login", "block_type": "login"}]}
|
||||
|
||||
result = extract_script_blocks(source, workflow_definition)
|
||||
|
||||
assert result.blocks[0].missing_globals == ()
|
||||
|
||||
|
||||
def test_wildcard_imports_are_rejected() -> None:
|
||||
source = """
|
||||
from constants import *
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
await skyvern.run_task(url=LOGIN_URL, prompt="...", label="login", cache_key="login")
|
||||
"""
|
||||
workflow_definition = {"blocks": [{"label": "login", "block_type": "login"}]}
|
||||
|
||||
with pytest.raises(RunSignatureValidationError, match="Wildcard imports"):
|
||||
extract_script_blocks(source, workflow_definition)
|
||||
|
||||
|
||||
def test_requires_entry_function() -> None:
|
||||
with pytest.raises(ScriptBlockExtractionError, match="Could not find"):
|
||||
extract_script_blocks("import skyvern\n", {"blocks": []})
|
||||
|
||||
|
||||
def test_known_non_cacheable_block_is_extracted_but_not_cacheable() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
await skyvern.validate(complete_criterion="done", terminate_criterion="stop", label="check")
|
||||
"""
|
||||
workflow_definition = {"blocks": [{"label": "check", "block_type": "validation"}]}
|
||||
|
||||
result = extract_script_blocks(source, workflow_definition)
|
||||
|
||||
assert result.blocks[0].label == "check"
|
||||
assert result.blocks[0].is_cacheable is False
|
||||
|
||||
|
||||
def test_unknown_block_type_warns_and_is_not_cacheable() -> None:
|
||||
source = """
|
||||
import skyvern
|
||||
|
||||
async def run(parameters):
|
||||
await skyvern.run_task(prompt="...", label="future", cache_key="future")
|
||||
"""
|
||||
workflow_definition = {"blocks": [{"label": "future", "block_type": "future_block"}]}
|
||||
|
||||
result = extract_script_blocks(source, workflow_definition)
|
||||
|
||||
assert result.blocks[0].is_cacheable is False
|
||||
assert result.warnings == (
|
||||
"Unknown workflow block type 'future_block' for label 'future'; treating as non-cacheable",
|
||||
)
|
||||
|
|
@ -77,6 +77,7 @@ def _make_workflow_stub(browser_profile_id: str | None) -> SimpleNamespace:
|
|||
proxy_location=None,
|
||||
webhook_callback_url=None,
|
||||
extra_http_headers=None,
|
||||
cdp_connect_headers=None,
|
||||
browser_profile_id=browser_profile_id,
|
||||
run_with="agent",
|
||||
code_version=None,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ def _make_service_with_mocks(
|
|||
proxy_location=None,
|
||||
webhook_callback_url=None,
|
||||
extra_http_headers=None,
|
||||
cdp_connect_headers=None,
|
||||
browser_profile_id=None,
|
||||
run_with="agent",
|
||||
code_version=None,
|
||||
|
|
|
|||
151
tests/unit/test_workflow_script_cache_key_resolution.py
Normal file
151
tests/unit/test_workflow_script_cache_key_resolution.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from jinja2 import UndefinedError
|
||||
|
||||
from skyvern.forge.sdk.workflow.models.workflow import Workflow, is_adaptive_caching_from_effective_state
|
||||
from skyvern.services import workflow_script_service
|
||||
from skyvern.services.workflow_script_service import CacheKeyResolutionError, resolve_cache_key_value
|
||||
|
||||
|
||||
def _workflow(cache_key: str | None = "default", url: str | None = "https://example.com/login") -> Workflow:
|
||||
blocks = []
|
||||
if url is not None:
|
||||
blocks.append(
|
||||
{
|
||||
"block_type": "navigation",
|
||||
"label": "open_site",
|
||||
"url": url,
|
||||
"navigation_goal": "Open the site",
|
||||
"output_parameter": {
|
||||
"parameter_type": "output",
|
||||
"key": "open_site_output",
|
||||
"output_parameter_id": "op_test",
|
||||
"workflow_id": "wf_test",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"modified_at": datetime.now(timezone.utc),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return Workflow(
|
||||
workflow_id="wf_test",
|
||||
organization_id="org_test",
|
||||
workflow_permanent_id="wpid_test",
|
||||
title="test",
|
||||
version=1,
|
||||
is_saved_task=False,
|
||||
workflow_definition={"parameters": [], "blocks": blocks},
|
||||
run_with="code",
|
||||
cache_key=cache_key,
|
||||
code_version=2,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
modified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_platform_detection(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
workflow_script_service.app,
|
||||
"AGENT_FUNCTION",
|
||||
SimpleNamespace(detect_ats_platform=lambda domain: "known_platform" if domain == "ats.example.com" else None),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_default_cache_key_enriches_with_block_domain() -> None:
|
||||
workflow = _workflow(cache_key="default", url="https://essentials.example.com/login")
|
||||
|
||||
assert resolve_cache_key_value(workflow, {}, adaptive_caching=False) == "default:essentials.example.com"
|
||||
|
||||
|
||||
def test_resolve_empty_cache_key_uses_platform_when_detected() -> None:
|
||||
workflow = _workflow(cache_key="", url="https://ats.example.com/login")
|
||||
|
||||
assert resolve_cache_key_value(workflow, {}, adaptive_caching=False) == "known_platform"
|
||||
|
||||
|
||||
def test_resolve_appends_v2_when_adaptive_caching_enabled() -> None:
|
||||
workflow = _workflow(cache_key="custom", url=None)
|
||||
|
||||
assert resolve_cache_key_value(workflow, {}, adaptive_caching=True) == "custom:v2"
|
||||
|
||||
|
||||
def test_strict_mode_errors_on_missing_cache_key_variable() -> None:
|
||||
workflow = _workflow(cache_key="{{ payer }}:custom", url=None)
|
||||
|
||||
with pytest.raises(CacheKeyResolutionError):
|
||||
resolve_cache_key_value(workflow, {}, adaptive_caching=False, strict=True)
|
||||
|
||||
|
||||
def test_strict_mode_errors_on_missing_block_url_variable_for_default_key() -> None:
|
||||
workflow = _workflow(cache_key="default", url="{{ host }}/login")
|
||||
|
||||
with pytest.raises(CacheKeyResolutionError):
|
||||
resolve_cache_key_value(workflow, {}, adaptive_caching=False, strict=True)
|
||||
|
||||
|
||||
def test_tolerant_domain_resolution_preserves_existing_swallow_behavior() -> None:
|
||||
workflow = _workflow(cache_key="default", url="{{ host }}/login")
|
||||
|
||||
assert resolve_cache_key_value(workflow, {}, adaptive_caching=False, strict=False) == "default:https:///login"
|
||||
|
||||
|
||||
def test_tolerant_resolution_swallows_unexpected_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
workflow = _workflow(cache_key="custom", url=None)
|
||||
|
||||
def _raise(*_: object, **__: object) -> str:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(workflow_script_service, "_render_cache_template", _raise)
|
||||
|
||||
assert resolve_cache_key_value(workflow, {}, adaptive_caching=False, strict=False) == ""
|
||||
|
||||
|
||||
def test_strict_block_url_helper_surfaces_template_errors() -> None:
|
||||
with pytest.raises(UndefinedError):
|
||||
workflow_script_service._resolve_block_url_for_cache_key("{{ host }}/login", {}, strict=True)
|
||||
|
||||
|
||||
def test_domain_override_allows_strict_default_key_without_block_url_context() -> None:
|
||||
workflow = _workflow(cache_key="default", url="{{ host }}/login")
|
||||
|
||||
assert (
|
||||
resolve_cache_key_value(
|
||||
workflow,
|
||||
{},
|
||||
adaptive_caching=True,
|
||||
strict=True,
|
||||
domain_override="essentials.example.com",
|
||||
)
|
||||
== "default:essentials.example.com:v2"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("workflow_run_with", "run_run_with", "code_version", "adaptive_caching", "expected"),
|
||||
[
|
||||
("code", None, 2, False, True),
|
||||
("code", None, 1, True, False),
|
||||
("code", None, None, True, True),
|
||||
("agent", "code", 2, False, True),
|
||||
("code", "agent", 2, False, False),
|
||||
],
|
||||
)
|
||||
def test_adaptive_caching_from_effective_state(
|
||||
workflow_run_with: str,
|
||||
run_run_with: str | None,
|
||||
code_version: int | None,
|
||||
adaptive_caching: bool,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
assert (
|
||||
is_adaptive_caching_from_effective_state(
|
||||
workflow_run_with=workflow_run_with,
|
||||
run_run_with=run_run_with,
|
||||
code_version=code_version,
|
||||
adaptive_caching=adaptive_caching,
|
||||
)
|
||||
is expected
|
||||
)
|
||||
60
uv.lock
generated
60
uv.lock
generated
|
|
@ -425,15 +425,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.7.1"
|
||||
version = "1.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "joserfc" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/f2/e05664d5275ce811fd4e9df0a2b3f0086ee19a8a80358d95499fa82fd50c/authlib-1.7.1.tar.gz", hash = "sha256:8c09b0f9d080c823e594b52316af70f79a1fa4eed64d0363a076233c04ef063a", size = 175884, upload-time = "2026-05-04T08:11:25.033Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/82/730650ee5e5b598b7bfdc291b784bc2f6fe02a5671695485403365101088/authlib-1.7.1-py2.py3-none-any.whl", hash = "sha256:8470f4aa6b5590ac41bd81d6e6ee12448ce36a0da0af19bbed69fb53fb4e8ad9", size = 258826, upload-time = "2026-05-04T08:11:23.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -945,7 +945,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "cyclopts"
|
||||
version = "4.11.0"
|
||||
version = "4.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
|
|
@ -953,9 +953,9 @@ dependencies = [
|
|||
{ name = "rich" },
|
||||
{ name = "rich-rst" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/fa/eff8f1abae783bade9b5e9bafafd0040d4dbf51988f9384bfdc0326ba1fc/cyclopts-4.11.0.tar.gz", hash = "sha256:1ffcb9990dbd56b90da19980d31596de9e99019980a215a5d76cf88fe452e94d", size = 170690, upload-time = "2026-04-23T00:23:36.858Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/2c/fced34890f6e5a93a4b7afb2c71e8eee2a0719fb26193a0abf159ecb714d/cyclopts-4.10.2.tar.gz", hash = "sha256:d7b950457ef2563596d56331f80cbbbf86a2772535fb8b315c4f03bc7e6127f1", size = 166664, upload-time = "2026-04-08T23:57:45.805Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/37/197db187c260d24d4be1f09d427f59f3fb9a89bcf1354e23865c7bff7607/cyclopts-4.11.0-py3-none-any.whl", hash = "sha256:34318e3823b44b5baa754a5e37ec70a5c17dc81c65e4295ed70e17bc1aeae50d", size = 208494, upload-time = "2026-04-23T00:23:34.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/bd/05055d8360cef0757d79367157f3b15c0a0715e81e08f86a04018ec045f0/cyclopts-4.10.2-py3-none-any.whl", hash = "sha256:a1f2d6f8f7afac9456b48f75a40b36658778ddc9c6d406b520d017ae32c990fe", size = 204314, upload-time = "2026-04-08T23:57:46.969Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1428,7 +1428,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "google-cloud-aiplatform"
|
||||
version = "1.149.0"
|
||||
version = "1.151.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docstring-parser" },
|
||||
|
|
@ -1444,9 +1444,9 @@ dependencies = [
|
|||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/2c/fba4adc56f74c0ee0fbd91a39d414ca2c3588dd8b71f9be8a507015ca886/google_cloud_aiplatform-1.149.0.tar.gz", hash = "sha256:a4d73485bf1d727a9e1bbbd13d08d7031490686bbf7d125eb905c1a6c1559a35", size = 10451466, upload-time = "2026-04-27T23:11:54.513Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/f6/e2fbe175a011f5080da8c1f7d9169a6875a00ea2c7bee4193d952b097400/google_cloud_aiplatform-1.151.0.tar.gz", hash = "sha256:2f29b1853f790a7371a746c747bf1f664380b534254682441acd4b5ee26fafd2", size = 10617421, upload-time = "2026-05-07T21:56:52.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/a0/27719ba23967ef62e52a1d54e013e0fc174bdab8dd84fb300bab9bf0d4a3/google_cloud_aiplatform-1.149.0-py2.py3-none-any.whl", hash = "sha256:e6b5299fa5d303e971cb29a19f03fdbb7b1e3b9d2faa3a788ca933341fba2f2e", size = 8570410, upload-time = "2026-04-27T23:11:50.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/4a/cd35f8ba622d563b1335222284d2838aa789b953b40516b1b997e50fe5b6/google_cloud_aiplatform-1.151.0-py2.py3-none-any.whl", hash = "sha256:61372bb0923b14b8027f45b83393452df3a85bf4ea86fa48e08844fb5ec50049", size = 8732627, upload-time = "2026-05-07T21:56:49.014Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3336,7 +3336,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.33.0"
|
||||
version = "2.32.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -3348,9 +3348,9 @@ dependencies = [
|
|||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3900,11 +3900,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.0"
|
||||
version = "1.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/17/9c3094b822982b9f1ea666d8580ce59000f61f87c1663556fb72031ad9ec/pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080", size = 133918, upload-time = "2026-04-23T01:46:22.298Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5967,15 +5967,15 @@ mypy = [
|
|||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.4.1"
|
||||
version = "3.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/9a/f35932a8c0eb6b2287b66fa65a0321df8c84e4e355a659c1841a37c39fdb/sse_starlette-3.4.1.tar.gz", hash = "sha256:f780bebcf6c8997fe514e3bd8e8c648d8284976b391c8bed0bcb1f611632b555", size = 35127, upload-time = "2026-04-26T13:32:32.292Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/82/10cdfab4ab663a6b6bd624d33f55b2cfa41af5105be033a6d5d135a92c5f/sse_starlette-3.4.2.tar.gz", hash = "sha256:2f9a7f51ed84395a0427fb9f66cb1ec11f7899d977a72cbc9070b962a2e14489", size = 35236, upload-time = "2026-05-06T19:42:13.727Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/07/45c21ed03d708c477367305726b89919b020a3a2a01f72aaf5ad941caf35/sse_starlette-3.4.1-py3-none-any.whl", hash = "sha256:6b43cf21f1d574d582a6e1b0cfbde1c94dc86a32a701a7168c99c4475c6bd1d0", size = 16487, upload-time = "2026-04-26T13:32:30.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/27/351c71e803c56090d8d3bf9520422debeb8ed938871fd4f7ef519805a6c5/sse_starlette-3.4.2-py3-none-any.whl", hash = "sha256:6ea5d35b7ce979a3de5a0db5f77fe886b1616e4b3e1ad93fba502bd9b5fb662f", size = 16516, upload-time = "2026-05-06T19:42:12.201Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6019,15 +6019,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "stripe"
|
||||
version = "15.0.1"
|
||||
version = "15.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/b4/bd4baf56cfb9ec7ee66b0d33611e196afaabd12ddcc2322f29449649ec5f/stripe-15.0.1.tar.gz", hash = "sha256:3c04e8cc9ec8d9542f9dd998e2a4361dbe3d0b6150ca17d3f352546cf22c5565", size = 1490326, upload-time = "2026-04-01T23:52:21.218Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/26/5d6f5f5beae6f1ff78213e2e6f4fbd431518dcd98733cdd39fb4ba0d01d3/stripe-15.1.0.tar.gz", hash = "sha256:24bd3b6bd0969a4841bd4d7681556a9e35e46c414a07c8590a225fbd5a878450", size = 1501673, upload-time = "2026-04-24T00:18:58.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ee/4537ba64d6c2f8bc58667f4381429c804bbc2e854b5857337cbc39add620/stripe-15.0.1-py3-none-any.whl", hash = "sha256:0a525e4550b5bf7fb44ff682266b9d46cfb38dd6f1f97a85d361c452f69e6b4b", size = 2132160, upload-time = "2026-04-01T23:52:18.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/4e/fd9cb74ddf1e61fb6241e2f6799a81ef99bf6cf2e94f8812ee1cd5458e5d/stripe-15.1.0-py3-none-any.whl", hash = "sha256:bdfb556be08662a41833e6403607ebf12e0062cae4f9b93e2b89b6ba926d7c82", size = 2143199, upload-time = "2026-04-24T00:18:56.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6285,16 +6285,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "types-boto3"
|
||||
version = "1.42.95"
|
||||
version = "1.42.94"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore-stubs" },
|
||||
{ name = "types-s3transfer" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/e6/edde18693736fd6d3c200ff03394e8b3eec3cedcae36eb3ae3e2a39428af/types_boto3-1.42.95.tar.gz", hash = "sha256:bce21bb72d21d80e59d5ce18a1eecb57f23fdca49e516190e81f305b3bf60401", size = 102975, upload-time = "2026-04-23T21:37:21.239Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/02/81e1a6f5869db25a3f6e3a1c570cc8ff609869883310b2fe9929629953f2/types_boto3-1.42.94.tar.gz", hash = "sha256:0d47b32e7e76ec520a80e891706ec939434e2665ba172ef3950476d02f3ffc1b", size = 102979, upload-time = "2026-04-22T21:30:54.897Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/8c/f5e43d8a19e370f0a3f111084c65f12dc8042162049749d75dd572c17fb4/types_boto3-1.42.95-py3-none-any.whl", hash = "sha256:ad5c026ac3378fc4d74ef3bbe4b63fab6ae821cf4e5718a035cfa80224d1f3fe", size = 70560, upload-time = "2026-04-23T21:37:16.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/81/55020b7b414751a9752c923ca27ab211e6b1cd4909bd1fa599413e716198/types_boto3-1.42.94-py3-none-any.whl", hash = "sha256:f6c9acd1c9c4e0fdd66a13fd6d206b30d8166c439b2e08a685ab5558ad55c721", size = 70565, upload-time = "2026-04-22T21:30:51.439Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -6304,14 +6304,14 @@ full = [
|
|||
|
||||
[[package]]
|
||||
name = "types-boto3-full"
|
||||
version = "1.42.94"
|
||||
version = "1.42.93"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/7e/2f301b5f868396524b01d79a997b7a1354ca134b75b517b7b85d6109e216/types_boto3_full-1.42.94.tar.gz", hash = "sha256:e665376c15117f9cbcc21c89a21c2449bd3f4e6f4fdba2d7c9759d3a8f99f4ee", size = 8692455, upload-time = "2026-04-23T02:04:07.677Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/bf/8c3513a4a9397715ab936d62bd1878c56835d47b308a32ea6d1130abc103/types_boto3_full-1.42.93.tar.gz", hash = "sha256:b0b18872ea7b82c8f680300bcbf4fa827c7173f3774331cae30094e0895f5312", size = 8682422, upload-time = "2026-04-22T01:59:59.757Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/08/633dce6de18f5e0428ec46d376e771655fa3ed56518ebb25c6f4858c76ef/types_boto3_full-1.42.94-py3-none-any.whl", hash = "sha256:77afb5d04e678e7298a1a488b54c9b95a3d81f73e6ebe4d832f322fd8a46d160", size = 13192128, upload-time = "2026-04-23T02:04:04.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1a/dde920b45253d2451889b0a2ddc07d461c2ac86362631404be523ac95805/types_boto3_full-1.42.93-py3-none-any.whl", hash = "sha256:f14f999744fa5bb06661846cb6ec164b4249b09f2dde4a2d3dbea3aa8030bb28", size = 13179511, upload-time = "2026-04-22T01:59:56.121Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6403,15 +6403,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.44.0"
|
||||
version = "0.45.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue