mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
Allow uncapped workflow runtime configuration (#SKY-10265) (#6567)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
parent
d0e0982dcd
commit
cdacadb95d
15 changed files with 290 additions and 106 deletions
|
|
@ -19621,7 +19621,7 @@
|
|||
"max_iterations": {
|
||||
"type": "integer",
|
||||
"title": "Max Iterations",
|
||||
"default": 10
|
||||
"default": 50
|
||||
},
|
||||
"max_steps": {
|
||||
"type": "integer",
|
||||
|
|
@ -19729,7 +19729,7 @@
|
|||
"max_iterations": {
|
||||
"type": "integer",
|
||||
"title": "Max Iterations",
|
||||
"default": 10
|
||||
"default": 50
|
||||
},
|
||||
"max_steps": {
|
||||
"type": "integer",
|
||||
|
|
@ -22466,7 +22466,7 @@
|
|||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 240.0,
|
||||
"maximum": 480.0,
|
||||
"minimum": 1.0
|
||||
},
|
||||
{
|
||||
|
|
@ -24245,7 +24245,7 @@
|
|||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 240.0,
|
||||
"maximum": 480.0,
|
||||
"minimum": 1.0
|
||||
},
|
||||
{
|
||||
|
|
@ -24253,7 +24253,7 @@
|
|||
}
|
||||
],
|
||||
"title": "Max Elapsed Time Minutes",
|
||||
"description": "Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours."
|
||||
"description": "Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes."
|
||||
},
|
||||
"extra_http_headers": {
|
||||
"anyOf": [
|
||||
|
|
@ -24490,7 +24490,7 @@
|
|||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 240.0,
|
||||
"maximum": 480.0,
|
||||
"minimum": 1.0
|
||||
},
|
||||
{
|
||||
|
|
@ -24498,7 +24498,7 @@
|
|||
}
|
||||
],
|
||||
"title": "Max Elapsed Time Minutes",
|
||||
"description": "Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours."
|
||||
"description": "Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes."
|
||||
},
|
||||
"extra_http_headers": {
|
||||
"anyOf": [
|
||||
|
|
|
|||
|
|
@ -1989,12 +1989,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Timeline not available for this run type"
|
||||
},
|
||||
"404": {
|
||||
"description": "Run not found"
|
||||
},
|
||||
"400": {
|
||||
"description": "Timeline not available for this run type"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
|
|
@ -3000,12 +3000,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Unauthorized - Invalid or missing authentication"
|
||||
},
|
||||
"404": {
|
||||
"description": "Browser session not found"
|
||||
},
|
||||
"403": {
|
||||
"description": "Unauthorized - Invalid or missing authentication"
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict - browser session has already ended and can no longer be updated"
|
||||
},
|
||||
|
|
@ -3074,12 +3074,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Unauthorized - Invalid or missing authentication"
|
||||
},
|
||||
"404": {
|
||||
"description": "Browser session not found"
|
||||
},
|
||||
"403": {
|
||||
"description": "Unauthorized - Invalid or missing authentication"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
|
|
@ -4521,12 +4521,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Folder not found"
|
||||
},
|
||||
"404": {
|
||||
"description": "Workflow not found"
|
||||
},
|
||||
"400": {
|
||||
"description": "Folder not found"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
|
|
@ -5481,6 +5481,9 @@
|
|||
"404": {
|
||||
"description": "Workflow not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Storage deletion failed; retry"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
|
|
@ -5490,9 +5493,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Storage deletion failed; retry"
|
||||
}
|
||||
},
|
||||
"x-hidden": true
|
||||
|
|
@ -8692,7 +8692,7 @@
|
|||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"description": "Timeout in milliseconds",
|
||||
"default": 10000
|
||||
"default": 5000
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -9267,8 +9267,8 @@
|
|||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 1440,
|
||||
"minimum": 5
|
||||
"maximum": 1440.0,
|
||||
"minimum": 5.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
|
|
@ -14134,7 +14134,7 @@
|
|||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"description": "Timeout in milliseconds",
|
||||
"default": 10000
|
||||
"default": 5000
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -17132,7 +17132,7 @@
|
|||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"description": "Timeout in milliseconds",
|
||||
"default": 10000
|
||||
"default": 5000
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -20390,7 +20390,7 @@
|
|||
"type": "number",
|
||||
"title": "Timeout",
|
||||
"description": "Timeout in milliseconds",
|
||||
"default": 10000
|
||||
"default": 5000
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -20944,8 +20944,8 @@
|
|||
},
|
||||
"confidence_float": {
|
||||
"type": "number",
|
||||
"maximum": 1,
|
||||
"minimum": 0,
|
||||
"maximum": 1.0,
|
||||
"minimum": 0.0,
|
||||
"title": "Confidence Float"
|
||||
},
|
||||
"error_type": {
|
||||
|
|
@ -22466,8 +22466,8 @@
|
|||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 240,
|
||||
"minimum": 1
|
||||
"maximum": 480.0,
|
||||
"minimum": 1.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
|
|
@ -22538,8 +22538,8 @@
|
|||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 2,
|
||||
"minimum": 1
|
||||
"maximum": 2.0,
|
||||
"minimum": 1.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
|
|
@ -24245,15 +24245,15 @@
|
|||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 240,
|
||||
"minimum": 1
|
||||
"maximum": 480.0,
|
||||
"minimum": 1.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Max Elapsed Time Minutes",
|
||||
"description": "Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours."
|
||||
"description": "Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes."
|
||||
},
|
||||
"extra_http_headers": {
|
||||
"anyOf": [
|
||||
|
|
@ -24490,15 +24490,15 @@
|
|||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 240,
|
||||
"minimum": 1
|
||||
"maximum": 480.0,
|
||||
"minimum": 1.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Max Elapsed Time Minutes",
|
||||
"description": "Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours."
|
||||
"description": "Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes."
|
||||
},
|
||||
"extra_http_headers": {
|
||||
"anyOf": [
|
||||
|
|
@ -25561,4 +25561,4 @@
|
|||
"x-fern-server-name": "Local"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ const PREVENT_OVERLAPPING_RUNS_TOOLTIP =
|
|||
const SEQUENTIAL_KEY_TOOLTIP =
|
||||
"Scope the run queue. Runs with the same key are queued together; runs with different keys can still execute in parallel. Templated against agent inputs, for example {{ account_id }} to serialize per account.";
|
||||
|
||||
const WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES = 4 * 60;
|
||||
const WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES = 8 * 60;
|
||||
|
||||
function WorkflowSettingsEditor({ blockId }: { blockId: string }) {
|
||||
// Subscribe to the start node's data slice. The sidebar mount lives
|
||||
// outside the per-node renderer, and the body also subscribes to
|
||||
|
|
@ -396,14 +399,14 @@ function WorkflowSettingsEditorBody({
|
|||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Max Run Time (minutes)</Label>
|
||||
<HelpTooltip content="Times out this workflow after the configured elapsed runtime. Maximum runtime is 4 hours (240 minutes)." />
|
||||
<HelpTooltip content="Times out this workflow after the configured elapsed runtime. Leave blank to use the platform default of 4 hours. Maximum is 8 hours." />
|
||||
</div>
|
||||
<Input
|
||||
value={data.maxElapsedTimeMinutes ?? ""}
|
||||
placeholder="Default: 4 hours"
|
||||
placeholder={`Default: ${WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES} minutes`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={240}
|
||||
max={WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES}
|
||||
step={1}
|
||||
onChange={(event) => {
|
||||
const rawValue = event.target.value;
|
||||
|
|
@ -411,7 +414,10 @@ function WorkflowSettingsEditorBody({
|
|||
const value =
|
||||
rawValue === "" || !Number.isFinite(parsedValue)
|
||||
? null
|
||||
: Math.min(240, Math.max(1, Math.trunc(parsedValue)));
|
||||
: Math.min(
|
||||
WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
Math.max(1, Math.trunc(parsedValue)),
|
||||
);
|
||||
update({ maxElapsedTimeMinutes: value });
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -431,7 +431,7 @@ class Skyvern:
|
|||
The maximum number of scrolls for the post action screenshot. When it's None or 0, it takes the current viewpoint screenshot.
|
||||
|
||||
max_elapsed_time_minutes : typing.Optional[int]
|
||||
Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours.
|
||||
Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes.
|
||||
|
||||
extra_http_headers : typing.Optional[typing.Dict[str, typing.Optional[str]]]
|
||||
The extra HTTP headers for the requests in browser.
|
||||
|
|
@ -3177,7 +3177,7 @@ class AsyncSkyvern:
|
|||
The maximum number of scrolls for the post action screenshot. When it's None or 0, it takes the current viewpoint screenshot.
|
||||
|
||||
max_elapsed_time_minutes : typing.Optional[int]
|
||||
Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours.
|
||||
Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes.
|
||||
|
||||
extra_http_headers : typing.Optional[typing.Dict[str, typing.Optional[str]]]
|
||||
The extra HTTP headers for the requests in browser.
|
||||
|
|
|
|||
|
|
@ -389,7 +389,7 @@ class RawSkyvern:
|
|||
The maximum number of scrolls for the post action screenshot. When it's None or 0, it takes the current viewpoint screenshot.
|
||||
|
||||
max_elapsed_time_minutes : typing.Optional[int]
|
||||
Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours.
|
||||
Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes.
|
||||
|
||||
extra_http_headers : typing.Optional[typing.Dict[str, typing.Optional[str]]]
|
||||
The extra HTTP headers for the requests in browser.
|
||||
|
|
@ -4181,7 +4181,7 @@ class AsyncRawSkyvern:
|
|||
The maximum number of scrolls for the post action screenshot. When it's None or 0, it takes the current viewpoint screenshot.
|
||||
|
||||
max_elapsed_time_minutes : typing.Optional[int]
|
||||
Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours.
|
||||
Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes.
|
||||
|
||||
extra_http_headers : typing.Optional[typing.Dict[str, typing.Optional[str]]]
|
||||
The extra HTTP headers for the requests in browser.
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class WorkflowRunRequestOutput(UniversalBaseModel):
|
|||
|
||||
max_elapsed_time_minutes: typing.Optional[int] = pydantic.Field(default=None)
|
||||
"""
|
||||
Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours.
|
||||
Timeout this workflow run after the configured elapsed runtime in minutes. When omitted, the platform default is 240 minutes. The maximum configurable value is 480 minutes.
|
||||
"""
|
||||
|
||||
extra_http_headers: typing.Optional[typing.Dict[str, typing.Optional[str]]] = pydantic.Field(default=None)
|
||||
|
|
|
|||
|
|
@ -802,6 +802,7 @@ class AgentFunction:
|
|||
timezone: str,
|
||||
enabled: bool,
|
||||
parameters: dict[str, Any] | None = None,
|
||||
max_elapsed_time_minutes: int | None = None,
|
||||
) -> None:
|
||||
"""Upsert a recurring schedule with the execution backend (e.g. Temporal).
|
||||
|
||||
|
|
|
|||
|
|
@ -326,6 +326,7 @@ async def create_workflow_schedule(
|
|||
timezone=body.timezone,
|
||||
enabled=body.enabled,
|
||||
parameters=stored_parameters,
|
||||
max_elapsed_time_minutes=workflow.max_elapsed_time_minutes,
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
|
|
@ -492,6 +493,7 @@ async def update_workflow_schedule(
|
|||
timezone=body.timezone,
|
||||
enabled=body.enabled,
|
||||
parameters=stored_parameters,
|
||||
max_elapsed_time_minutes=workflow.max_elapsed_time_minutes,
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES = 4 * 60
|
||||
WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES = 4 * 60
|
||||
WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES = 8 * 60
|
||||
|
||||
|
||||
def reject_bool_max_elapsed_time_minutes(max_elapsed_time_minutes: object) -> object:
|
||||
|
|
@ -8,9 +9,10 @@ def reject_bool_max_elapsed_time_minutes(max_elapsed_time_minutes: object) -> ob
|
|||
|
||||
|
||||
def get_effective_workflow_run_max_elapsed_time_minutes(max_elapsed_time_minutes: int | None) -> int:
|
||||
if max_elapsed_time_minutes is None:
|
||||
return WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES
|
||||
if not isinstance(max_elapsed_time_minutes, int) or isinstance(max_elapsed_time_minutes, bool):
|
||||
return DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES
|
||||
return WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES
|
||||
if max_elapsed_time_minutes <= 0:
|
||||
return DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES
|
||||
# Product hard ceiling: explicit workflow/run settings cannot extend past the default 4 hour cap.
|
||||
return min(max_elapsed_time_minutes, DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES)
|
||||
return WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES
|
||||
return min(max_elapsed_time_minutes, WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from skyvern.forge.sdk.workflow.exceptions import (
|
|||
from skyvern.forge.sdk.workflow.models.block import BlockTypeVar, ForLoopBlock, WhileLoopBlock, get_all_blocks
|
||||
from skyvern.forge.sdk.workflow.models.parameter import PARAMETER_TYPE, OutputParameter
|
||||
from skyvern.forge.sdk.workflow.models.run_limits import (
|
||||
DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
reject_bool_max_elapsed_time_minutes,
|
||||
)
|
||||
from skyvern.forge.sdk.workflow.models.validators import normalize_run_metadata, normalize_run_with
|
||||
|
|
@ -36,7 +36,7 @@ class WorkflowRequestBody(BaseModel):
|
|||
browser_session_id: str | None = None
|
||||
browser_profile_id: str | None = None
|
||||
max_screenshot_scrolls: int | None = None
|
||||
max_elapsed_time_minutes: int | None = Field(default=None, ge=1, le=DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES)
|
||||
max_elapsed_time_minutes: int | None = Field(default=None, ge=1, le=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES)
|
||||
extra_http_headers: dict[str, str] | None = None
|
||||
cdp_connect_headers: dict[str, str] | None = None
|
||||
browser_address: str | None = None
|
||||
|
|
|
|||
|
|
@ -198,6 +198,13 @@ def _format_workflow_run_elapsed_timeout_failure_reason(effective_minutes: int)
|
|||
return f"Workflow run exceeded max elapsed runtime limit of {effective_minutes} {minute_label}."
|
||||
|
||||
|
||||
def _require_elapsed_timeout_failure_reason(timeout_failure_reason: str | None) -> str:
|
||||
if timeout_failure_reason is None:
|
||||
LOG.error("timeout_failure_reason missing when workflow elapsed timeout is set")
|
||||
return "Workflow run exceeded max elapsed runtime limit."
|
||||
return timeout_failure_reason
|
||||
|
||||
|
||||
def _get_workflow_webhook_delivery_status(
|
||||
*,
|
||||
webhook_callback_url: str | None,
|
||||
|
|
@ -1782,43 +1789,48 @@ class WorkflowService:
|
|||
started_at=workflow_run.started_at,
|
||||
)
|
||||
blocks_to_update: set[str] = set()
|
||||
|
||||
async def execute_workflow_blocks() -> None:
|
||||
nonlocal workflow_run, blocks_to_update
|
||||
# Check if there's a related workflow script that should be used instead.
|
||||
workflow_script, _, script_is_pinned = await workflow_script_service.get_workflow_script(
|
||||
workflow, workflow_run, block_labels
|
||||
)
|
||||
current_context = skyvern_context.current()
|
||||
if current_context:
|
||||
if workflow_script:
|
||||
current_context.generate_script = False
|
||||
if workflow_run.code_gen:
|
||||
current_context.generate_script = True
|
||||
workflow_run, blocks_to_update = await self._execute_workflow_blocks(
|
||||
workflow=workflow,
|
||||
workflow_run=workflow_run,
|
||||
organization=organization,
|
||||
browser_session_id=browser_session_id,
|
||||
browser_profile_id=browser_profile_id,
|
||||
block_labels=block_labels,
|
||||
block_outputs=block_outputs,
|
||||
script=workflow_script,
|
||||
script_is_pinned=script_is_pinned,
|
||||
)
|
||||
|
||||
if max_elapsed_timeout_seconds <= 0:
|
||||
workflow_run = await self.mark_workflow_run_as_timed_out(
|
||||
workflow_run_id=workflow_run_id,
|
||||
failure_reason=timeout_failure_reason,
|
||||
failure_reason=_require_elapsed_timeout_failure_reason(timeout_failure_reason),
|
||||
)
|
||||
return workflow_run
|
||||
else:
|
||||
timeout_context = asyncio.timeout(max_elapsed_timeout_seconds)
|
||||
try:
|
||||
async with timeout_context:
|
||||
# Check if there's a related workflow script that should be used instead.
|
||||
workflow_script, _, script_is_pinned = await workflow_script_service.get_workflow_script(
|
||||
workflow, workflow_run, block_labels
|
||||
)
|
||||
current_context = skyvern_context.current()
|
||||
if current_context:
|
||||
if workflow_script:
|
||||
current_context.generate_script = False
|
||||
if workflow_run.code_gen:
|
||||
current_context.generate_script = True
|
||||
workflow_run, blocks_to_update = await self._execute_workflow_blocks(
|
||||
workflow=workflow,
|
||||
workflow_run=workflow_run,
|
||||
organization=organization,
|
||||
browser_session_id=browser_session_id,
|
||||
browser_profile_id=browser_profile_id,
|
||||
block_labels=block_labels,
|
||||
block_outputs=block_outputs,
|
||||
script=workflow_script,
|
||||
script_is_pinned=script_is_pinned,
|
||||
)
|
||||
await execute_workflow_blocks()
|
||||
except TimeoutError:
|
||||
if not timeout_context.expired():
|
||||
raise
|
||||
workflow_run = await self.mark_workflow_run_as_timed_out(
|
||||
workflow_run_id=workflow_run_id,
|
||||
failure_reason=timeout_failure_reason,
|
||||
failure_reason=_require_elapsed_timeout_failure_reason(timeout_failure_reason),
|
||||
)
|
||||
return workflow_run
|
||||
|
||||
|
|
@ -1834,7 +1846,7 @@ class WorkflowService:
|
|||
workflow_run=workflow_run,
|
||||
pre_finally_status=pre_finally_status,
|
||||
pre_finally_failure_reason=pre_finally_failure_reason,
|
||||
timeout_failure_reason=timeout_failure_reason,
|
||||
timeout_failure_reason=_require_elapsed_timeout_failure_reason(timeout_failure_reason),
|
||||
)
|
||||
return workflow_run
|
||||
|
||||
|
|
@ -1962,7 +1974,7 @@ class WorkflowService:
|
|||
workflow_run=workflow_run,
|
||||
pre_finally_status=pre_finally_status,
|
||||
pre_finally_failure_reason=pre_finally_failure_reason,
|
||||
timeout_failure_reason=timeout_failure_reason,
|
||||
timeout_failure_reason=_require_elapsed_timeout_failure_reason(timeout_failure_reason),
|
||||
)
|
||||
finally:
|
||||
# Shielded finalize runs even when the try body was cancelled
|
||||
|
|
@ -6276,6 +6288,8 @@ class WorkflowService:
|
|||
title=title,
|
||||
)
|
||||
new_workflow_id: str | None = None
|
||||
refresh_schedule_runtime_limits = False
|
||||
effective_max_elapsed_time_minutes: int | None = None
|
||||
|
||||
if workflow_permanent_id:
|
||||
# Would return 404: WorkflowNotFound to the client if wpid does not match the organization
|
||||
|
|
@ -6306,6 +6320,9 @@ class WorkflowService:
|
|||
if "max_elapsed_time_minutes" in request.model_fields_set
|
||||
else existing_latest_workflow.max_elapsed_time_minutes
|
||||
)
|
||||
refresh_schedule_runtime_limits = (
|
||||
effective_max_elapsed_time_minutes != existing_latest_workflow.max_elapsed_time_minutes
|
||||
)
|
||||
|
||||
# NOTE: it's only potential, as it may be immediately deleted!
|
||||
potential_workflow = await self.create_workflow(
|
||||
|
|
@ -6408,6 +6425,13 @@ class WorkflowService:
|
|||
delete_script=delete_script,
|
||||
)
|
||||
|
||||
if refresh_schedule_runtime_limits:
|
||||
await self._refresh_workflow_schedule_runtime_limits(
|
||||
workflow_permanent_id=updated_workflow.workflow_permanent_id,
|
||||
organization_id=organization_id,
|
||||
max_elapsed_time_minutes=effective_max_elapsed_time_minutes,
|
||||
)
|
||||
|
||||
return updated_workflow
|
||||
except SkyvernHTTPException:
|
||||
# Bubble up well-formed client errors (e.g. WorkflowNotFound 404)
|
||||
|
|
@ -6426,6 +6450,39 @@ class WorkflowService:
|
|||
LOG.exception(f"Failed to create workflow from request, title: {title}")
|
||||
raise e
|
||||
|
||||
async def _refresh_workflow_schedule_runtime_limits(
|
||||
self,
|
||||
*,
|
||||
workflow_permanent_id: str,
|
||||
organization_id: str,
|
||||
max_elapsed_time_minutes: int | None,
|
||||
) -> None:
|
||||
schedules = await app.DATABASE.schedules.get_workflow_schedules(
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
for schedule in schedules:
|
||||
if not schedule.backend_schedule_id:
|
||||
continue
|
||||
try:
|
||||
await app.AGENT_FUNCTION.upsert_workflow_schedule(
|
||||
backend_schedule_id=schedule.backend_schedule_id,
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
workflow_schedule_id=schedule.workflow_schedule_id,
|
||||
cron_expression=schedule.cron_expression,
|
||||
timezone=schedule.timezone,
|
||||
enabled=schedule.enabled,
|
||||
parameters=schedule.parameters,
|
||||
max_elapsed_time_minutes=max_elapsed_time_minutes,
|
||||
)
|
||||
except Exception:
|
||||
LOG.exception(
|
||||
"Failed to refresh workflow schedule runtime limit",
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
workflow_schedule_id=schedule.workflow_schedule_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def create_output_parameter_for_block(workflow_id: str, block_yaml: BLOCK_YAML_TYPES) -> OutputParameter:
|
||||
output_parameter_key = f"{block_yaml.label}_output"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ from pydantic import (
|
|||
|
||||
from skyvern.forge.sdk.schemas.files import FileInfo
|
||||
from skyvern.forge.sdk.workflow.models.run_limits import (
|
||||
DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES,
|
||||
WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
reject_bool_max_elapsed_time_minutes,
|
||||
)
|
||||
from skyvern.forge.sdk.workflow.models.validators import normalize_run_metadata, normalize_run_with
|
||||
|
|
@ -263,8 +264,12 @@ class WorkflowRunRequest(BaseModel):
|
|||
max_elapsed_time_minutes: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
description="Timeout this workflow run after the configured elapsed runtime in minutes. Maximum runtime is 4 hours.",
|
||||
le=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
description=(
|
||||
"Timeout this workflow run after the configured elapsed runtime in minutes. "
|
||||
f"When omitted, the platform default is {WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES} minutes. "
|
||||
f"The maximum configurable value is {WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES} minutes."
|
||||
),
|
||||
)
|
||||
extra_http_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from skyvern.forge.sdk.api.llm.config_registry import LLMConfigRegistry
|
|||
from skyvern.forge.sdk.settings_manager import SettingsManager
|
||||
from skyvern.forge.sdk.workflow.models.parameter import OutputParameter, ParameterType, WorkflowParameterType
|
||||
from skyvern.forge.sdk.workflow.models.run_limits import (
|
||||
DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
reject_bool_max_elapsed_time_minutes,
|
||||
)
|
||||
from skyvern.forge.sdk.workflow.models.validators import normalize_run_with
|
||||
|
|
@ -1219,7 +1219,7 @@ class WorkflowCreateYAMLRequest(BaseModel):
|
|||
workflow_definition: WorkflowDefinitionYAML
|
||||
is_saved_task: bool = False
|
||||
max_screenshot_scrolls: int | None = None
|
||||
max_elapsed_time_minutes: int | None = Field(default=None, ge=1, le=DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES)
|
||||
max_elapsed_time_minutes: int | None = Field(default=None, ge=1, le=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES)
|
||||
extra_http_headers: dict[str, str] | None = None
|
||||
cdp_connect_headers: dict[str, str] | None = None
|
||||
status: WorkflowStatus = WorkflowStatus.published
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ async def test_create_workflow_from_request_preserves_existing_max_elapsed_time_
|
|||
create_workflow_mock.assert_awaited_once()
|
||||
assert create_workflow_mock.await_args is not None
|
||||
assert create_workflow_mock.await_args.kwargs["max_elapsed_time_minutes"] == 90
|
||||
refresh_schedules_mock = service._refresh_workflow_schedule_runtime_limits
|
||||
assert isinstance(refresh_schedules_mock, AsyncMock)
|
||||
refresh_schedules_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -116,6 +119,62 @@ async def test_create_workflow_from_request_allows_explicit_null_to_clear_existi
|
|||
create_workflow_mock.assert_awaited_once()
|
||||
assert create_workflow_mock.await_args is not None
|
||||
assert create_workflow_mock.await_args.kwargs["max_elapsed_time_minutes"] is None
|
||||
refresh_schedules_mock = service._refresh_workflow_schedule_runtime_limits
|
||||
assert isinstance(refresh_schedules_mock, AsyncMock)
|
||||
refresh_schedules_mock.assert_awaited_once_with(
|
||||
workflow_permanent_id="wpid_test",
|
||||
organization_id="org_1",
|
||||
max_elapsed_time_minutes=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_workflow_schedule_runtime_limits_reupserts_backend_schedules() -> None:
|
||||
service = WorkflowService()
|
||||
schedule_with_backend = SimpleNamespace(
|
||||
backend_schedule_id="temporal_1",
|
||||
workflow_schedule_id="wfs_1",
|
||||
cron_expression="0 */6 * * *",
|
||||
timezone="UTC",
|
||||
enabled=True,
|
||||
parameters={"url": "https://example.com"},
|
||||
)
|
||||
schedule_without_backend = SimpleNamespace(
|
||||
backend_schedule_id=None,
|
||||
workflow_schedule_id="wfs_local",
|
||||
cron_expression="0 */12 * * *",
|
||||
timezone="UTC",
|
||||
enabled=False,
|
||||
parameters=None,
|
||||
)
|
||||
|
||||
with patch("skyvern.forge.sdk.workflow.service.app") as mock_app:
|
||||
mock_app.DATABASE.schedules.get_workflow_schedules = AsyncMock(
|
||||
return_value=[schedule_with_backend, schedule_without_backend]
|
||||
)
|
||||
mock_app.AGENT_FUNCTION.upsert_workflow_schedule = AsyncMock()
|
||||
|
||||
await service._refresh_workflow_schedule_runtime_limits(
|
||||
workflow_permanent_id="wpid_test",
|
||||
organization_id="org_1",
|
||||
max_elapsed_time_minutes=360,
|
||||
)
|
||||
|
||||
mock_app.DATABASE.schedules.get_workflow_schedules.assert_awaited_once_with(
|
||||
workflow_permanent_id="wpid_test",
|
||||
organization_id="org_1",
|
||||
)
|
||||
mock_app.AGENT_FUNCTION.upsert_workflow_schedule.assert_awaited_once_with(
|
||||
backend_schedule_id="temporal_1",
|
||||
organization_id="org_1",
|
||||
workflow_permanent_id="wpid_test",
|
||||
workflow_schedule_id="wfs_1",
|
||||
cron_expression="0 */6 * * *",
|
||||
timezone="UTC",
|
||||
enabled=True,
|
||||
parameters={"url": "https://example.com"},
|
||||
max_elapsed_time_minutes=360,
|
||||
)
|
||||
|
||||
|
||||
def _make_workflow_update_service(
|
||||
|
|
@ -131,7 +190,7 @@ def _make_workflow_update_service(
|
|||
max_elapsed_time_minutes=existing_max_elapsed_time_minutes,
|
||||
)
|
||||
potential_workflow = SimpleNamespace(workflow_id="wf_new")
|
||||
updated_workflow = SimpleNamespace(workflow_id="wf_new")
|
||||
updated_workflow = SimpleNamespace(workflow_id="wf_new", workflow_permanent_id="wpid_test")
|
||||
|
||||
service.get_workflow_by_permanent_id = AsyncMock(return_value=existing_workflow) # type: ignore[method-assign]
|
||||
service.create_workflow = AsyncMock(return_value=potential_workflow) # type: ignore[method-assign]
|
||||
|
|
@ -142,6 +201,7 @@ def _make_workflow_update_service(
|
|||
service._validate_payload_templates = Mock() # type: ignore[method-assign]
|
||||
service.update_workflow_definition = AsyncMock(return_value=updated_workflow) # type: ignore[method-assign]
|
||||
service.maybe_delete_cached_code = AsyncMock() # type: ignore[method-assign]
|
||||
service._refresh_workflow_schedule_runtime_limits = AsyncMock() # type: ignore[method-assign]
|
||||
|
||||
return service, updated_workflow
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ from pydantic import ValidationError
|
|||
from skyvern.forge.sdk.routes.agent_protocol import _workflow_run_request_to_legacy_request
|
||||
from skyvern.forge.sdk.workflow import service as service_module
|
||||
from skyvern.forge.sdk.workflow.models.run_limits import (
|
||||
DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES,
|
||||
WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES,
|
||||
get_effective_workflow_run_max_elapsed_time_minutes,
|
||||
)
|
||||
from skyvern.forge.sdk.workflow.models.workflow import (
|
||||
|
|
@ -25,7 +26,12 @@ from skyvern.schemas.runs import WorkflowRunRequest
|
|||
from skyvern.schemas.workflows import WorkflowCreateYAMLRequest, WorkflowDefinitionYAML
|
||||
|
||||
|
||||
def _workflow_run(status: WorkflowRunStatus, *, started_at: datetime) -> SimpleNamespace:
|
||||
def _workflow_run(
|
||||
status: WorkflowRunStatus,
|
||||
*,
|
||||
started_at: datetime,
|
||||
max_elapsed_time_minutes: int | None = 1,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
workflow_run_id="wr_1",
|
||||
workflow_id="wf_1",
|
||||
|
|
@ -38,7 +44,7 @@ def _workflow_run(status: WorkflowRunStatus, *, started_at: datetime) -> SimpleN
|
|||
ignore_inherited_workflow_system_prompt=False,
|
||||
parent_workflow_run_id=None,
|
||||
proxy_location=None,
|
||||
max_elapsed_time_minutes=1,
|
||||
max_elapsed_time_minutes=max_elapsed_time_minutes,
|
||||
started_at=started_at,
|
||||
created_at=started_at,
|
||||
code_gen=False,
|
||||
|
|
@ -46,14 +52,42 @@ def _workflow_run(status: WorkflowRunStatus, *, started_at: datetime) -> SimpleN
|
|||
)
|
||||
|
||||
|
||||
def test_workflow_request_rejects_max_elapsed_time_above_cap() -> None:
|
||||
with pytest.warns(DeprecationWarning), pytest.raises(ValidationError):
|
||||
WorkflowRequestBody(max_elapsed_time_minutes=DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1)
|
||||
def test_workflow_request_accepts_max_elapsed_time_above_legacy_four_hour_runtime() -> None:
|
||||
with pytest.warns(DeprecationWarning):
|
||||
request = WorkflowRequestBody(max_elapsed_time_minutes=300)
|
||||
|
||||
assert (
|
||||
get_effective_workflow_run_max_elapsed_time_minutes(DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1)
|
||||
== DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES
|
||||
assert request.max_elapsed_time_minutes == 300
|
||||
assert get_effective_workflow_run_max_elapsed_time_minutes(None) == WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES
|
||||
assert get_effective_workflow_run_max_elapsed_time_minutes(0) == WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES
|
||||
assert get_effective_workflow_run_max_elapsed_time_minutes(-1) == WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES
|
||||
assert get_effective_workflow_run_max_elapsed_time_minutes(cast(int, "bad")) == (
|
||||
WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES
|
||||
)
|
||||
assert get_effective_workflow_run_max_elapsed_time_minutes(300) == 300
|
||||
assert get_effective_workflow_run_max_elapsed_time_minutes(600) == WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES
|
||||
|
||||
|
||||
def test_elapsed_timeout_failure_reason_falls_back_when_invariant_is_missing() -> None:
|
||||
assert (
|
||||
service_module._require_elapsed_timeout_failure_reason(None)
|
||||
== "Workflow run exceeded max elapsed runtime limit."
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_run_elapsed_timeout_uses_platform_default_when_max_elapsed_time_is_none() -> None:
|
||||
workflow_run = cast(
|
||||
WorkflowRun,
|
||||
_workflow_run(
|
||||
WorkflowRunStatus.running,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
max_elapsed_time_minutes=None,
|
||||
),
|
||||
)
|
||||
|
||||
timeout_seconds = service_module._get_workflow_run_max_elapsed_timeout_seconds(workflow_run)
|
||||
assert timeout_seconds is not None
|
||||
assert (WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES * 60) - 1 <= timeout_seconds
|
||||
assert timeout_seconds <= WORKFLOW_RUN_DEFAULT_MAX_ELAPSED_TIME_MINUTES * 60
|
||||
|
||||
|
||||
def test_workflow_request_rejects_bool_max_elapsed_time() -> None:
|
||||
|
|
@ -61,22 +95,30 @@ def test_workflow_request_rejects_bool_max_elapsed_time() -> None:
|
|||
WorkflowRequestBody(max_elapsed_time_minutes=True)
|
||||
|
||||
|
||||
def test_workflow_run_request_accepts_max_elapsed_time() -> None:
|
||||
request = WorkflowRunRequest(workflow_id="wpid_test", max_elapsed_time_minutes=10)
|
||||
def test_workflow_request_rejects_max_elapsed_time_above_platform_cap() -> None:
|
||||
with pytest.warns(DeprecationWarning), pytest.raises(ValidationError):
|
||||
WorkflowRequestBody(max_elapsed_time_minutes=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1)
|
||||
|
||||
assert request.max_elapsed_time_minutes == 10
|
||||
|
||||
def test_workflow_run_request_accepts_max_elapsed_time() -> None:
|
||||
request = WorkflowRunRequest(workflow_id="wpid_test", max_elapsed_time_minutes=300)
|
||||
|
||||
assert request.max_elapsed_time_minutes == 300
|
||||
|
||||
|
||||
def test_workflow_run_request_rejects_invalid_max_elapsed_time() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
WorkflowRunRequest(
|
||||
workflow_id="wpid_test",
|
||||
max_elapsed_time_minutes=DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1,
|
||||
)
|
||||
WorkflowRunRequest(workflow_id="wpid_test", max_elapsed_time_minutes=0)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
WorkflowRunRequest(workflow_id="wpid_test", max_elapsed_time_minutes=True)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
WorkflowRunRequest(
|
||||
workflow_id="wpid_test",
|
||||
max_elapsed_time_minutes=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1,
|
||||
)
|
||||
|
||||
|
||||
def test_public_workflow_run_request_preserves_max_elapsed_time_for_legacy_runner() -> None:
|
||||
request = WorkflowRunRequest(workflow_id="wpid_test", max_elapsed_time_minutes=10)
|
||||
|
|
@ -96,9 +138,18 @@ def test_workflow_create_yaml_request_rejects_bool_max_elapsed_time() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_workflow_domain_models_accept_db_values_above_current_elapsed_cap() -> None:
|
||||
def test_workflow_create_yaml_request_rejects_max_elapsed_time_above_platform_cap() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
WorkflowCreateYAMLRequest(
|
||||
title="test",
|
||||
workflow_definition=WorkflowDefinitionYAML(parameters=[], blocks=[]),
|
||||
max_elapsed_time_minutes=WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1,
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_domain_models_accept_long_elapsed_timeout_values() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
max_elapsed_time_minutes = DEFAULT_WORKFLOW_RUN_MAX_ELAPSED_TIME_MINUTES + 1
|
||||
max_elapsed_time_minutes = 300
|
||||
|
||||
workflow = Workflow(
|
||||
workflow_id="wf_1",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue