mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
536 lines
22 KiB
Python
536 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
from dataclasses import dataclass
|
|
|
|
import structlog
|
|
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.db.exceptions import NotFoundError
|
|
from skyvern.forge.sdk.db.repositories.scripts import WorkflowScriptUpsertStatus, WorkflowScriptWriterIntent
|
|
from skyvern.forge.sdk.workflow.models.workflow import Workflow
|
|
from skyvern.schemas.scripts import (
|
|
DeployCachedScriptBlockPlan,
|
|
DeployCachedScriptRequest,
|
|
DeployCachedScriptResponse,
|
|
FileEncoding,
|
|
ScriptFileCreate,
|
|
ScriptStatus,
|
|
WorkflowScript,
|
|
)
|
|
from skyvern.services.workflow_script_service import CacheKeyResolutionError, resolve_cache_key_value
|
|
from skyvern.utils.script_file_paths import SCRIPT_FILE_PATH_ERROR, normalize_script_file_path
|
|
|
|
_CODE_VERSION_STATIC = 1
|
|
_CODE_VERSION_ADAPTIVE = 2
|
|
_MAX_SCRIPT_FILE_BYTES = 10 * 1024 * 1024
|
|
|
|
LOG = structlog.get_logger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _CachedScriptDeployPlan:
|
|
workflow: Workflow
|
|
proposed_workflow: Workflow
|
|
cache_key_value: str
|
|
block_plans: list[DeployCachedScriptBlockPlan]
|
|
cacheable_block_count: int
|
|
skipped_block_labels: list[str]
|
|
warnings: list[str]
|
|
validated_files: list[_ValidatedScriptFile]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ValidatedScriptFile:
|
|
file: ScriptFileCreate
|
|
content_bytes: bytes
|
|
|
|
|
|
@dataclass
|
|
class _CachedScriptDeployUndoState:
|
|
script_revision_id: str | None = None
|
|
workflow_script_created_snapshot: WorkflowScript | None = None
|
|
workflow_script_written_snapshot: WorkflowScript | None = None
|
|
workflow_script_restore_snapshot: WorkflowScript | None = None
|
|
workflow_dispatch_updated: bool = False
|
|
workflow_dispatch_restore_run_with: str | None = None
|
|
workflow_dispatch_restore_cache_key: str | None = None
|
|
workflow_dispatch_restore_code_version: int | None = None
|
|
workflow_dispatch_written_run_with: str | None = None
|
|
workflow_dispatch_written_cache_key: str | None = None
|
|
workflow_dispatch_written_code_version: int | None = None
|
|
|
|
@property
|
|
def has_writes(self) -> bool:
|
|
return (
|
|
self.script_revision_id is not None
|
|
or self.workflow_script_created_snapshot is not None
|
|
or self.workflow_script_written_snapshot is not None
|
|
or self.workflow_script_restore_snapshot is not None
|
|
or self.workflow_dispatch_updated
|
|
)
|
|
|
|
|
|
def _decode_script_file_bytes(file: ScriptFileCreate) -> bytes:
|
|
if file.encoding == FileEncoding.BASE64:
|
|
try:
|
|
return base64.b64decode(file.content, validate=True)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=400, detail=f"File {file.path!r} is not valid base64") from exc
|
|
return file.content.encode("utf-8")
|
|
|
|
|
|
def _validate_script_file_path(file_path: str) -> None:
|
|
try:
|
|
normalize_script_file_path(file_path)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"File path {file_path!r} is invalid: {SCRIPT_FILE_PATH_ERROR}",
|
|
) from exc
|
|
|
|
|
|
def _validate_script_files(files: list[ScriptFileCreate]) -> list[_ValidatedScriptFile]:
|
|
seen_paths: set[str] = set()
|
|
validated_files: list[_ValidatedScriptFile] = []
|
|
for file in files:
|
|
_validate_script_file_path(file.path)
|
|
if file.path in seen_paths:
|
|
raise HTTPException(status_code=400, detail=f"Duplicate script file path {file.path!r}")
|
|
seen_paths.add(file.path)
|
|
content_bytes = _decode_script_file_bytes(file)
|
|
if len(content_bytes) > _MAX_SCRIPT_FILE_BYTES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"File {file.path!r} exceeds maximum size of {_MAX_SCRIPT_FILE_BYTES} bytes",
|
|
)
|
|
validated_files.append(_ValidatedScriptFile(file=file, content_bytes=content_bytes))
|
|
return validated_files
|
|
|
|
|
|
def _main_py(files: list[_ValidatedScriptFile]) -> str:
|
|
for validated_file in files:
|
|
if validated_file.file.path == "main.py":
|
|
try:
|
|
return validated_file.content_bytes.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise HTTPException(status_code=400, detail="File 'main.py' is not valid UTF-8") from exc
|
|
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})
|
|
|
|
|
|
def _code_version_for_cache_context(request: DeployCachedScriptRequest) -> int:
|
|
return _CODE_VERSION_ADAPTIVE if request.cache_context.adaptive_caching else _CODE_VERSION_STATIC
|
|
|
|
|
|
def _require_cache_key(plan: _CachedScriptDeployPlan) -> str:
|
|
if plan.proposed_workflow.cache_key is None:
|
|
raise HTTPException(status_code=400, detail="Cached script deploy requires a non-null workflow cache_key")
|
|
return plan.proposed_workflow.cache_key
|
|
|
|
|
|
async def _persist_script_files(
|
|
*,
|
|
files: list[_ValidatedScriptFile],
|
|
organization_id: str,
|
|
script_id: str,
|
|
script_version: int,
|
|
script_revision_id: str,
|
|
) -> None:
|
|
for validated_file in files:
|
|
file = validated_file.file
|
|
content_bytes = validated_file.content_bytes
|
|
content_hash = f"sha256:{hashlib.sha256(content_bytes).hexdigest()}"
|
|
artifact_id = await app.ARTIFACT_MANAGER.create_script_file_artifact(
|
|
organization_id=organization_id,
|
|
script_id=script_id,
|
|
script_version=script_version,
|
|
file_path=file.path,
|
|
data=content_bytes,
|
|
)
|
|
await app.DATABASE.scripts.create_script_file(
|
|
script_revision_id=script_revision_id,
|
|
script_id=script_id,
|
|
organization_id=organization_id,
|
|
file_path=file.path,
|
|
file_name=file.path.split("/")[-1],
|
|
file_type="file",
|
|
content_hash=content_hash,
|
|
file_size=len(content_bytes),
|
|
mime_type=file.mime_type,
|
|
encoding=file.encoding.value,
|
|
artifact_id=artifact_id,
|
|
)
|
|
|
|
|
|
async def _build_cached_script_deploy_plan(
|
|
*,
|
|
organization_id: str,
|
|
workflow_permanent_id: str,
|
|
request: DeployCachedScriptRequest,
|
|
) -> _CachedScriptDeployPlan:
|
|
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)
|
|
validated_files = _validate_script_files(request.files)
|
|
main_py = _main_py(validated_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,
|
|
run_signature=block.run_signature,
|
|
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, not block.is_cacheable),
|
|
)
|
|
for block in extraction.blocks
|
|
]
|
|
|
|
return _CachedScriptDeployPlan(
|
|
workflow=workflow,
|
|
proposed_workflow=proposed_workflow,
|
|
cache_key_value=cache_key_value,
|
|
block_plans=block_plans,
|
|
cacheable_block_count=len(cacheable_blocks),
|
|
skipped_block_labels=[block.label for block in extraction.blocks if not block.is_cacheable],
|
|
warnings=list(extraction.warnings),
|
|
validated_files=validated_files,
|
|
)
|
|
|
|
|
|
def _response_from_plan(
|
|
*,
|
|
plan: _CachedScriptDeployPlan,
|
|
dry_run: bool,
|
|
script_id: str | None = None,
|
|
script_revision_id: str | None = None,
|
|
script_version: int | None = None,
|
|
workflow_script_id: str | None = None,
|
|
workflow_script_upsert_status: str | None = None,
|
|
script_was_created: bool = False,
|
|
) -> DeployCachedScriptResponse:
|
|
return DeployCachedScriptResponse(
|
|
workflow_id=plan.workflow.workflow_id,
|
|
workflow_version=plan.workflow.version,
|
|
cache_key=plan.proposed_workflow.cache_key,
|
|
cache_key_value=plan.cache_key_value,
|
|
dry_run=dry_run,
|
|
would_create_script=True,
|
|
script_was_created=script_was_created,
|
|
script_id=script_id,
|
|
script_revision_id=script_revision_id,
|
|
script_version=script_version,
|
|
workflow_script_id=workflow_script_id,
|
|
workflow_script_upsert_status=workflow_script_upsert_status,
|
|
cacheable_block_count=plan.cacheable_block_count,
|
|
skipped_block_labels=plan.skipped_block_labels,
|
|
blocks=plan.block_plans,
|
|
warnings=plan.warnings,
|
|
)
|
|
|
|
|
|
async def _cleanup_failed_cached_script_deploy(
|
|
*,
|
|
organization_id: str,
|
|
plan: _CachedScriptDeployPlan,
|
|
undo_state: _CachedScriptDeployUndoState,
|
|
) -> None:
|
|
cleanup_errors: list[str] = []
|
|
if undo_state.workflow_dispatch_updated:
|
|
try:
|
|
restored_workflow = await app.DATABASE.workflows.restore_workflow_script_dispatch_if_matches(
|
|
workflow_id=plan.workflow.workflow_id,
|
|
organization_id=organization_id,
|
|
run_with=undo_state.workflow_dispatch_restore_run_with,
|
|
cache_key=undo_state.workflow_dispatch_restore_cache_key,
|
|
code_version=undo_state.workflow_dispatch_restore_code_version,
|
|
current_run_with=undo_state.workflow_dispatch_written_run_with,
|
|
current_cache_key=undo_state.workflow_dispatch_written_cache_key,
|
|
current_code_version=undo_state.workflow_dispatch_written_code_version,
|
|
)
|
|
if restored_workflow is None:
|
|
LOG.warning(
|
|
"Skipped restoring workflow dispatch after cached script deploy failure because it changed",
|
|
workflow_id=plan.workflow.workflow_id,
|
|
organization_id=organization_id,
|
|
)
|
|
except Exception:
|
|
cleanup_errors.append("workflow_dispatch")
|
|
LOG.warning(
|
|
"Failed to restore workflow dispatch state after cached script deploy failure",
|
|
workflow_id=plan.workflow.workflow_id,
|
|
organization_id=organization_id,
|
|
exc_info=True,
|
|
)
|
|
|
|
if (
|
|
undo_state.workflow_script_restore_snapshot is not None
|
|
and undo_state.workflow_script_written_snapshot is not None
|
|
):
|
|
try:
|
|
restored_workflow_script = await app.DATABASE.scripts.restore_workflow_script_if_matches(
|
|
current_workflow_script=undo_state.workflow_script_written_snapshot,
|
|
restore_workflow_script=undo_state.workflow_script_restore_snapshot,
|
|
)
|
|
if not restored_workflow_script:
|
|
LOG.warning(
|
|
"Skipped restoring workflow script after cached script deploy failure because it changed",
|
|
workflow_script_id=undo_state.workflow_script_written_snapshot.workflow_script_id,
|
|
organization_id=organization_id,
|
|
)
|
|
except Exception:
|
|
cleanup_errors.append("workflow_script_restore")
|
|
LOG.warning(
|
|
"Failed to restore workflow script after cached script deploy failure",
|
|
workflow_script_id=undo_state.workflow_script_restore_snapshot.workflow_script_id,
|
|
organization_id=organization_id,
|
|
exc_info=True,
|
|
)
|
|
elif undo_state.workflow_script_created_snapshot is not None:
|
|
try:
|
|
deleted_workflow_script = await app.DATABASE.scripts.soft_delete_workflow_script_if_matches(
|
|
workflow_script=undo_state.workflow_script_created_snapshot,
|
|
)
|
|
if not deleted_workflow_script:
|
|
LOG.warning(
|
|
"Skipped soft-deleting workflow script after cached script deploy failure because it changed",
|
|
workflow_script_id=undo_state.workflow_script_created_snapshot.workflow_script_id,
|
|
organization_id=organization_id,
|
|
)
|
|
except Exception:
|
|
cleanup_errors.append("workflow_script")
|
|
LOG.warning(
|
|
"Failed to soft-delete workflow script after cached script deploy failure",
|
|
workflow_script_id=undo_state.workflow_script_created_snapshot.workflow_script_id,
|
|
organization_id=organization_id,
|
|
exc_info=True,
|
|
)
|
|
|
|
if undo_state.script_revision_id is not None:
|
|
try:
|
|
await app.DATABASE.scripts.soft_delete_script_by_revision(
|
|
script_revision_id=undo_state.script_revision_id,
|
|
organization_id=organization_id,
|
|
)
|
|
except Exception:
|
|
cleanup_errors.append("script_revision")
|
|
LOG.warning(
|
|
"Failed to soft-delete script revision after cached script deploy failure",
|
|
script_revision_id=undo_state.script_revision_id,
|
|
organization_id=organization_id,
|
|
exc_info=True,
|
|
)
|
|
|
|
if cleanup_errors:
|
|
LOG.error(
|
|
"Cached script deploy failed and cleanup was incomplete",
|
|
workflow_id=plan.workflow.workflow_id,
|
|
organization_id=organization_id,
|
|
cleanup_errors=cleanup_errors,
|
|
)
|
|
|
|
|
|
async def deploy_cached_script(
|
|
*,
|
|
organization_id: str,
|
|
workflow_permanent_id: str,
|
|
request: DeployCachedScriptRequest,
|
|
) -> DeployCachedScriptResponse:
|
|
plan = await _build_cached_script_deploy_plan(
|
|
organization_id=organization_id,
|
|
workflow_permanent_id=workflow_permanent_id,
|
|
request=request,
|
|
)
|
|
cache_key = _require_cache_key(plan)
|
|
if request.dry_run:
|
|
return _response_from_plan(plan=plan, dry_run=True)
|
|
|
|
script = None
|
|
commit_stage = "create_script"
|
|
undo_state = _CachedScriptDeployUndoState()
|
|
try:
|
|
script = await app.DATABASE.scripts.create_script(
|
|
organization_id=organization_id,
|
|
run_id=request.source_workflow_run_id,
|
|
)
|
|
undo_state.script_revision_id = script.script_revision_id
|
|
commit_stage = "persist_script_files"
|
|
await _persist_script_files(
|
|
files=plan.validated_files,
|
|
organization_id=organization_id,
|
|
script_id=script.script_id,
|
|
script_version=script.version,
|
|
script_revision_id=script.script_revision_id,
|
|
)
|
|
|
|
commit_stage = "upsert_script_blocks"
|
|
for block in plan.block_plans:
|
|
await app.DATABASE.scripts.upsert_script_block(
|
|
script_revision_id=script.script_revision_id,
|
|
script_id=script.script_id,
|
|
organization_id=organization_id,
|
|
script_block_label=block.label,
|
|
run_signature=block.run_signature,
|
|
requires_agent=block.requires_agent,
|
|
)
|
|
|
|
commit_stage = "upsert_workflow_script"
|
|
workflow_script_result = await app.DATABASE.scripts.upsert_workflow_script(
|
|
organization_id=organization_id,
|
|
script_id=script.script_id,
|
|
workflow_permanent_id=workflow_permanent_id,
|
|
workflow_id=plan.workflow.workflow_id,
|
|
workflow_run_id=request.source_workflow_run_id,
|
|
cache_key=cache_key,
|
|
cache_key_value=plan.cache_key_value,
|
|
status=ScriptStatus.published,
|
|
is_pinned=True,
|
|
writer_intent=WorkflowScriptWriterIntent.deploy,
|
|
)
|
|
if workflow_script_result.status == WorkflowScriptUpsertStatus.created:
|
|
undo_state.workflow_script_created_snapshot = workflow_script_result.workflow_script
|
|
elif workflow_script_result.status == WorkflowScriptUpsertStatus.updated:
|
|
undo_state.workflow_script_written_snapshot = workflow_script_result.workflow_script
|
|
undo_state.workflow_script_restore_snapshot = workflow_script_result.previous_workflow_script
|
|
if undo_state.workflow_script_restore_snapshot is None:
|
|
LOG.warning(
|
|
"Cached script deploy updated workflow script without a previous snapshot",
|
|
workflow_script_id=workflow_script_result.workflow_script.workflow_script_id,
|
|
organization_id=organization_id,
|
|
workflow_permanent_id=workflow_permanent_id,
|
|
cache_key_value=plan.cache_key_value,
|
|
)
|
|
|
|
commit_stage = "update_workflow_dispatch_state"
|
|
code_version = _code_version_for_cache_context(request)
|
|
dispatch_update_result = await app.DATABASE.workflows.update_workflow_dispatch_state_if_latest_with_previous(
|
|
workflow_id=plan.workflow.workflow_id,
|
|
workflow_permanent_id=workflow_permanent_id,
|
|
organization_id=organization_id,
|
|
expected_version=plan.workflow.version,
|
|
run_with="code",
|
|
cache_key=cache_key,
|
|
code_version=code_version,
|
|
)
|
|
previous_dispatch_state = dispatch_update_result.previous_dispatch_state
|
|
undo_state.workflow_dispatch_updated = True
|
|
undo_state.workflow_dispatch_restore_run_with = previous_dispatch_state.run_with
|
|
undo_state.workflow_dispatch_restore_cache_key = previous_dispatch_state.cache_key
|
|
undo_state.workflow_dispatch_restore_code_version = previous_dispatch_state.code_version
|
|
undo_state.workflow_dispatch_written_run_with = "code"
|
|
undo_state.workflow_dispatch_written_cache_key = cache_key
|
|
undo_state.workflow_dispatch_written_code_version = code_version
|
|
|
|
return _response_from_plan(
|
|
plan=plan,
|
|
dry_run=False,
|
|
script_id=script.script_id,
|
|
script_revision_id=script.script_revision_id,
|
|
script_version=script.version,
|
|
workflow_script_id=workflow_script_result.workflow_script.workflow_script_id,
|
|
workflow_script_upsert_status=workflow_script_result.status.value,
|
|
script_was_created=True,
|
|
)
|
|
except NotFoundError as exc:
|
|
LOG.exception(
|
|
"cached_script_deploy_commit_failed",
|
|
organization_id=organization_id,
|
|
workflow_permanent_id=workflow_permanent_id,
|
|
workflow_id=plan.workflow.workflow_id,
|
|
workflow_version=plan.workflow.version,
|
|
cache_key_value=plan.cache_key_value,
|
|
script_id=script.script_id if script else None,
|
|
commit_stage=commit_stage,
|
|
)
|
|
if undo_state.has_writes:
|
|
await _cleanup_failed_cached_script_deploy(
|
|
organization_id=organization_id,
|
|
plan=plan,
|
|
undo_state=undo_state,
|
|
)
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
"Workflow version became stale before deploy commit: expected "
|
|
f"{plan.workflow.workflow_id} v{plan.workflow.version}"
|
|
),
|
|
) from exc
|
|
except Exception:
|
|
LOG.exception(
|
|
"cached_script_deploy_commit_failed",
|
|
organization_id=organization_id,
|
|
workflow_permanent_id=workflow_permanent_id,
|
|
workflow_id=plan.workflow.workflow_id,
|
|
workflow_version=plan.workflow.version,
|
|
cache_key_value=plan.cache_key_value,
|
|
script_id=script.script_id if script else None,
|
|
commit_stage=commit_stage,
|
|
)
|
|
if undo_state.has_writes:
|
|
await _cleanup_failed_cached_script_deploy(
|
|
organization_id=organization_id,
|
|
plan=plan,
|
|
undo_state=undo_state,
|
|
)
|
|
raise
|