projects -> scripts (#3123)

This commit is contained in:
Shuchang Zheng
2025-08-06 22:23:38 -07:00
committed by GitHub
parent 75eadef0e1
commit 1a4bf1df1a
17 changed files with 660 additions and 538 deletions

View File

@@ -56,11 +56,6 @@ class TaskNotFound(SkyvernHTTPException):
super().__init__(f"Task {task_id} not found", status_code=status.HTTP_404_NOT_FOUND)
class ScriptNotFound(SkyvernException):
def __init__(self, script_name: str | None = None):
super().__init__(f"Script {script_name} not found. Has the script been registered?")
class MissingElement(SkyvernException):
def __init__(self, selector: str | None = None, element_id: str | None = None):
super().__init__(
@@ -746,6 +741,6 @@ class ElementOutOfCurrentViewport(SkyvernException):
super().__init__(f"Element {element_id} is out of current viewport")
class ProjectNotFound(SkyvernHTTPException):
def __init__(self, project_id: str) -> None:
super().__init__(f"Project {project_id} not found")
class ScriptNotFound(SkyvernHTTPException):
def __init__(self, script_id: str) -> None:
super().__init__(f"Script {script_id} not found")

View File

@@ -238,38 +238,38 @@ class ArtifactManager:
path=path,
)
async def create_project_file_artifact(
async def create_script_file_artifact(
self,
*,
organization_id: str,
project_id: str,
project_version: int,
script_id: str,
script_version: int,
file_path: str,
data: bytes,
) -> str:
"""Create an artifact for a project file.
"""Create an artifact for a script file.
Args:
organization_id: The organization ID
project_id: The project ID
project_version: The project version
file_path: The file path relative to project root
script_id: The script ID
script_version: The script version
file_path: The file path relative to script root
data: The file content as bytes
Returns:
The artifact ID
"""
artifact_id = generate_artifact_id()
uri = app.STORAGE.build_project_file_uri(
uri = app.STORAGE.build_script_file_uri(
organization_id=organization_id,
project_id=project_id,
project_version=project_version,
script_id=script_id,
script_version=script_version,
file_path=file_path,
)
return await self._create_artifact(
aio_task_primary_key=f"{project_id}_{project_version}",
aio_task_primary_key=f"{script_id}_{script_version}",
artifact_id=artifact_id,
artifact_type=ArtifactType.PROJECT_FILE,
artifact_type=ArtifactType.SCRIPT_FILE,
uri=uri,
organization_id=organization_id,
data=data,

View File

@@ -49,8 +49,8 @@ class ArtifactType(StrEnum):
TRACE = "trace"
HAR = "har"
# Project files
PROJECT_FILE = "project_file"
# Script files
SCRIPT_FILE = "script_file"
class Artifact(BaseModel):

View File

@@ -82,8 +82,8 @@ class BaseStorage(ABC):
pass
@abstractmethod
def build_project_file_uri(
self, *, organization_id: str, project_id: str, project_version: int, file_path: str
def build_script_file_uri(
self, *, organization_id: str, script_id: str, script_version: int, file_path: str
) -> str:
pass

View File

@@ -78,10 +78,10 @@ class LocalStorage(BaseStorage):
file_ext = FILE_EXTENTSION_MAP[artifact_type]
return f"file://{self.artifact_path}/{settings.ENV}/{organization_id}/ai_suggestions/{ai_suggestion.ai_suggestion_id}/{datetime.utcnow().isoformat()}_{artifact_id}_{artifact_type}.{file_ext}"
def build_project_file_uri(
self, *, organization_id: str, project_id: str, project_version: int, file_path: str
def build_script_file_uri(
self, *, organization_id: str, script_id: str, script_version: int, file_path: str
) -> str:
return f"file://{self.artifact_path}/{settings.ENV}/{organization_id}/projects/{project_id}/{project_version}/{file_path}"
return f"file://{self.artifact_path}/{settings.ENV}/{organization_id}/scripts/{script_id}/{script_version}/{file_path}"
async def store_artifact(self, artifact: Artifact, data: bytes) -> None:
file_path = None

View File

@@ -84,21 +84,21 @@ class S3Storage(BaseStorage):
file_ext = FILE_EXTENTSION_MAP[artifact_type]
return f"{self._build_base_uri(organization_id)}/ai_suggestions/{ai_suggestion.ai_suggestion_id}/{datetime.utcnow().isoformat()}_{artifact_id}_{artifact_type}.{file_ext}"
def build_project_file_uri(
self, *, organization_id: str, project_id: str, project_version: int, file_path: str
def build_script_file_uri(
self, *, organization_id: str, script_id: str, script_version: int, file_path: str
) -> str:
"""Build the S3 URI for a project file.
"""Build the S3 URI for a script file.
Args:
organization_id: The organization ID
project_id: The project ID
project_version: The project version
file_path: The file path relative to project root
script_id: The script ID
script_version: The script version
file_path: The file path relative to script root
Returns:
The S3 URI for the project file
The S3 URI for the script file
"""
return f"{self._build_base_uri(organization_id)}/projects/{project_id}/{project_version}/{file_path}"
return f"{self._build_base_uri(organization_id)}/scripts/{script_id}/{script_version}/{file_path}"
async def store_artifact(self, artifact: Artifact, data: bytes) -> None:
sc = await self._get_storage_class_for_org(artifact.organization_id)

View File

@@ -29,8 +29,8 @@ from skyvern.forge.sdk.db.models import (
OrganizationModel,
OutputParameterModel,
PersistentBrowserSessionModel,
ProjectFileModel,
ProjectModel,
ScriptFileModel,
ScriptModel,
StepModel,
TaskGenerationModel,
TaskModel,
@@ -54,8 +54,8 @@ from skyvern.forge.sdk.db.utils import (
convert_to_organization,
convert_to_organization_auth_token,
convert_to_output_parameter,
convert_to_project,
convert_to_project_file,
convert_to_script,
convert_to_script_file,
convert_to_step,
convert_to_task,
convert_to_workflow,
@@ -102,8 +102,8 @@ from skyvern.forge.sdk.workflow.models.workflow import (
WorkflowRunStatus,
WorkflowStatus,
)
from skyvern.schemas.projects import Project, ProjectFile
from skyvern.schemas.runs import ProxyLocation, RunEngine, RunType
from skyvern.schemas.scripts import Script, ScriptFile
from skyvern.webeye.actions.actions import Action
from skyvern.webeye.actions.models import AgentStepOutput
@@ -3546,134 +3546,134 @@ class AgentDB:
return DebugSession.model_validate(debug_session)
async def create_project(
async def create_script(
self,
organization_id: str,
run_id: str | None = None,
project_id: str | None = None,
script_id: str | None = None,
version: int | None = None,
) -> Project:
) -> Script:
try:
async with self.Session() as session:
project = ProjectModel(
script = ScriptModel(
organization_id=organization_id,
run_id=run_id,
)
if project_id:
project.project_id = project_id
if script_id:
script.script_id = script_id
if version:
project.version = version
session.add(project)
script.version = version
session.add(script)
await session.commit()
await session.refresh(project)
return convert_to_project(project)
await session.refresh(script)
return convert_to_script(script)
except SQLAlchemyError:
LOG.error("SQLAlchemyError", exc_info=True)
raise
async def update_project(
async def update_script(
self,
project_revision_id: str,
script_revision_id: str,
organization_id: str,
artifact_id: str | None = None,
run_id: str | None = None,
version: int | None = None,
) -> Project:
) -> Script:
try:
async with self.Session() as session:
get_project_query = (
select(ProjectModel)
get_script_query = (
select(ScriptModel)
.filter_by(organization_id=organization_id)
.filter_by(project_revision_id=project_revision_id)
.filter_by(script_revision_id=script_revision_id)
)
if project := (await session.scalars(get_project_query)).first():
if script := (await session.scalars(get_script_query)).first():
if artifact_id:
project.artifact_id = artifact_id
script.artifact_id = artifact_id
if run_id:
project.run_id = run_id
script.run_id = run_id
if version:
project.version = version
script.version = version
await session.commit()
await session.refresh(project)
return convert_to_project(project)
await session.refresh(script)
return convert_to_script(script)
else:
raise NotFoundError("Project not found")
raise NotFoundError("Script not found")
except SQLAlchemyError:
LOG.error("SQLAlchemyError", exc_info=True)
raise
except NotFoundError:
LOG.error("No project found to update", project_revision_id=project_revision_id)
LOG.error("No script found to update", script_revision_id=script_revision_id)
raise
except Exception:
LOG.error("UnexpectedError", exc_info=True)
raise
async def get_projects(
async def get_scripts(
self,
organization_id: str,
page: int = 1,
page_size: int = 10,
) -> list[Project]:
) -> list[Script]:
try:
async with self.Session() as session:
# Calculate offset for pagination
offset = (page - 1) * page_size
# Subquery to get the latest version of each project
# Subquery to get the latest version of each script
latest_versions_subquery = (
select(ProjectModel.project_id, func.max(ProjectModel.version).label("latest_version"))
select(ScriptModel.script_id, func.max(ScriptModel.version).label("latest_version"))
.filter_by(organization_id=organization_id)
.filter(ProjectModel.deleted_at.is_(None))
.group_by(ProjectModel.project_id)
.filter(ScriptModel.deleted_at.is_(None))
.group_by(ScriptModel.script_id)
.subquery()
)
# Main query to get projects with their latest versions
get_projects_query = (
select(ProjectModel)
# Main query to get scripts with their latest versions
get_scripts_query = (
select(ScriptModel)
.join(
latest_versions_subquery,
and_(
ProjectModel.project_id == latest_versions_subquery.c.project_id,
ProjectModel.version == latest_versions_subquery.c.latest_version,
ScriptModel.script_id == latest_versions_subquery.c.script_id,
ScriptModel.version == latest_versions_subquery.c.latest_version,
),
)
.filter_by(organization_id=organization_id)
.filter(ProjectModel.deleted_at.is_(None))
.order_by(ProjectModel.created_at.desc())
.filter(ScriptModel.deleted_at.is_(None))
.order_by(ScriptModel.created_at.desc())
.limit(page_size)
.offset(offset)
)
projects = (await session.scalars(get_projects_query)).all()
return [convert_to_project(project) for project in projects]
scripts = (await session.scalars(get_scripts_query)).all()
return [convert_to_script(script) for script in scripts]
except SQLAlchemyError:
LOG.error("SQLAlchemyError", exc_info=True)
raise
async def get_project(
async def get_script(
self,
project_id: str,
script_id: str,
organization_id: str,
version: int | None = None,
) -> Project | None:
"""Get a specific project by ID and optionally by version."""
) -> Script | None:
"""Get a specific script by ID and optionally by version."""
try:
async with self.Session() as session:
get_project_query = (
select(ProjectModel)
.filter_by(project_id=project_id)
get_script_query = (
select(ScriptModel)
.filter_by(script_id=script_id)
.filter_by(organization_id=organization_id)
.filter(ProjectModel.deleted_at.is_(None))
.filter(ScriptModel.deleted_at.is_(None))
)
if version is not None:
get_project_query = get_project_query.filter_by(version=version)
get_script_query = get_script_query.filter_by(version=version)
else:
# Get the latest version
get_project_query = get_project_query.order_by(ProjectModel.version.desc()).limit(1)
get_script_query = get_script_query.order_by(ScriptModel.version.desc()).limit(1)
if project := (await session.scalars(get_project_query)).first():
return convert_to_project(project)
if script := (await session.scalars(get_script_query)).first():
return convert_to_script(script)
return None
except SQLAlchemyError:
LOG.error("SQLAlchemyError", exc_info=True)
@@ -3682,21 +3682,21 @@ class AgentDB:
LOG.error("UnexpectedError", exc_info=True)
raise
async def get_project_revision(self, project_revision_id: str, organization_id: str) -> Project | None:
async def get_script_revision(self, script_revision_id: str, organization_id: str) -> Script | None:
async with self.Session() as session:
project = (
script = (
await session.scalars(
select(ProjectModel)
.filter_by(project_revision_id=project_revision_id)
select(ScriptModel)
.filter_by(script_revision_id=script_revision_id)
.filter_by(organization_id=organization_id)
)
).first()
return convert_to_project(project) if project else None
return convert_to_script(script) if script else None
async def create_project_file(
async def create_script_file(
self,
project_revision_id: str,
project_id: str,
script_revision_id: str,
script_id: str,
organization_id: str,
file_path: str,
file_name: str,
@@ -3707,12 +3707,12 @@ class AgentDB:
encoding: str = "utf-8",
artifact_id: str | None = None,
) -> None:
"""Create a project file record."""
"""Create a script file record."""
try:
async with self.Session() as session:
project_file = ProjectFileModel(
project_revision_id=project_revision_id,
project_id=project_id,
script_file = ScriptFileModel(
script_revision_id=script_revision_id,
script_id=script_id,
organization_id=organization_id,
file_path=file_path,
file_name=file_name,
@@ -3723,7 +3723,7 @@ class AgentDB:
encoding=encoding,
artifact_id=artifact_id,
)
session.add(project_file)
session.add(script_file)
await session.commit()
except SQLAlchemyError:
LOG.error("SQLAlchemyError", exc_info=True)
@@ -3732,13 +3732,13 @@ class AgentDB:
LOG.error("UnexpectedError", exc_info=True)
raise
async def get_project_files(self, project_revision_id: str, organization_id: str) -> list[ProjectFile]:
async def get_script_files(self, script_revision_id: str, organization_id: str) -> list[ScriptFile]:
async with self.Session() as session:
project_files = (
script_files = (
await session.scalars(
select(ProjectFileModel)
.filter_by(project_revision_id=project_revision_id)
select(ScriptFileModel)
.filter_by(script_revision_id=script_revision_id)
.filter_by(organization_id=organization_id)
)
).all()
return [convert_to_project_file(project_file) for project_file in project_files]
return [convert_to_script_file(script_file) for script_file in script_files]

View File

@@ -45,9 +45,9 @@ ORGANIZATION_AUTH_TOKEN_PREFIX = "oat"
ORG_PREFIX = "o"
OUTPUT_PARAMETER_PREFIX = "op"
PERSISTENT_BROWSER_SESSION_ID = "pbs"
PROJECT_FILE_PREFIX = "pf"
PROJECT_REVISION_PREFIX = "pv"
PROJECT_PREFIX = "p"
SCRIPT_FILE_PREFIX = "sf"
SCRIPT_REVISION_PREFIX = "sr"
SCRIPT_PREFIX = "s"
STEP_PREFIX = "stp"
TASK_GENERATION_PREFIX = "tg"
TASK_PREFIX = "tsk"
@@ -212,19 +212,19 @@ def generate_organization_bitwarden_collection_id() -> str:
return f"{ORGANIZATION_BITWARDEN_COLLECTION_PREFIX}_{int_id}"
def generate_project_id() -> str:
def generate_script_id() -> str:
int_id = generate_id()
return f"{PROJECT_PREFIX}_{int_id}"
return f"{SCRIPT_PREFIX}_{int_id}"
def generate_project_revision_id() -> str:
def generate_script_revision_id() -> str:
int_id = generate_id()
return f"{PROJECT_REVISION_PREFIX}_{int_id}"
return f"{SCRIPT_REVISION_PREFIX}_{int_id}"
def generate_project_file_id() -> str:
def generate_script_file_id() -> str:
int_id = generate_id()
return f"{PROJECT_FILE_PREFIX}_{int_id}"
return f"{SCRIPT_FILE_PREFIX}_{int_id}"
############# Helper functions below ##############

View File

@@ -35,9 +35,9 @@ from skyvern.forge.sdk.db.id import (
generate_organization_bitwarden_collection_id,
generate_output_parameter_id,
generate_persistent_browser_session_id,
generate_project_file_id,
generate_project_id,
generate_project_revision_id,
generate_script_file_id,
generate_script_id,
generate_script_revision_id,
generate_step_id,
generate_task_generation_id,
generate_task_id,
@@ -778,18 +778,18 @@ class DebugSessionModel(Base):
status = Column(String, nullable=False, default="created")
class ProjectModel(Base):
__tablename__ = "projects"
class ScriptModel(Base):
__tablename__ = "scripts"
__table_args__ = (
Index("project_org_created_at_index", "organization_id", "created_at"),
Index("project_org_run_id_index", "organization_id", "run_id"),
UniqueConstraint("organization_id", "project_id", "version", name="uc_org_project_version"),
Index("script_org_created_at_index", "organization_id", "created_at"),
Index("script_org_run_id_index", "organization_id", "run_id"),
UniqueConstraint("organization_id", "script_id", "version", name="uc_org_script_version"),
)
project_revision_id = Column(String, primary_key=True, default=generate_project_revision_id)
project_id = Column(String, default=generate_project_id, nullable=False) # User-facing, consistent across versions
script_revision_id = Column(String, primary_key=True, default=generate_script_revision_id)
script_id = Column(String, default=generate_script_id, nullable=False) # User-facing, consistent across versions
organization_id = Column(String, nullable=False)
# The workflow run or task run id that this project is generated
# The workflow run or task run id that this script is generated
run_id = Column(String, nullable=True)
version = Column(Integer, default=1, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
@@ -802,16 +802,16 @@ class ProjectModel(Base):
deleted_at = Column(DateTime, nullable=True)
class ProjectFileModel(Base):
__tablename__ = "project_files"
class ScriptFileModel(Base):
__tablename__ = "script_files"
__table_args__ = (
Index("file_project_path_index", "project_revision_id", "file_path"),
UniqueConstraint("project_revision_id", "file_path", name="unique_project_file_path"),
Index("file_script_path_index", "script_revision_id", "file_path"),
UniqueConstraint("script_revision_id", "file_path", name="unique_script_file_path"),
)
file_id = Column(String, primary_key=True, default=generate_project_file_id)
project_revision_id = Column(String, nullable=False)
project_id = Column(String, nullable=False)
file_id = Column(String, primary_key=True, default=generate_script_file_id)
script_revision_id = Column(String, nullable=False)
script_id = Column(String, nullable=False)
organization_id = Column(String, nullable=False)
file_path = Column(String, nullable=False) # e.g., "src/utils.py"

View File

@@ -15,8 +15,8 @@ from skyvern.forge.sdk.db.models import (
OrganizationAuthTokenModel,
OrganizationModel,
OutputParameterModel,
ProjectFileModel,
ProjectModel,
ScriptFileModel,
ScriptModel,
StepModel,
TaskModel,
WorkflowModel,
@@ -50,8 +50,8 @@ from skyvern.forge.sdk.workflow.models.workflow import (
WorkflowRunStatus,
WorkflowStatus,
)
from skyvern.schemas.projects import Project, ProjectFile
from skyvern.schemas.runs import ProxyLocation
from skyvern.schemas.scripts import Script, ScriptFile
from skyvern.webeye.actions.actions import (
Action,
ActionType,
@@ -506,35 +506,35 @@ def convert_to_workflow_run_block(
return block
def convert_to_project(project_model: ProjectModel) -> Project:
return Project(
project_revision_id=project_model.project_revision_id,
project_id=project_model.project_id,
organization_id=project_model.organization_id,
run_id=project_model.run_id,
version=project_model.version,
created_at=project_model.created_at,
modified_at=project_model.modified_at,
deleted_at=project_model.deleted_at,
def convert_to_script(script_model: ScriptModel) -> Script:
return Script(
script_revision_id=script_model.script_revision_id,
script_id=script_model.script_id,
organization_id=script_model.organization_id,
run_id=script_model.run_id,
version=script_model.version,
created_at=script_model.created_at,
modified_at=script_model.modified_at,
deleted_at=script_model.deleted_at,
)
def convert_to_project_file(project_file_model: ProjectFileModel) -> ProjectFile:
return ProjectFile(
file_id=project_file_model.file_id,
project_revision_id=project_file_model.project_revision_id,
project_id=project_file_model.project_id,
organization_id=project_file_model.organization_id,
file_path=project_file_model.file_path,
file_name=project_file_model.file_name,
file_type=project_file_model.file_type,
content_hash=project_file_model.content_hash,
file_size=project_file_model.file_size,
mime_type=project_file_model.mime_type,
encoding=project_file_model.encoding,
artifact_id=project_file_model.artifact_id,
created_at=project_file_model.created_at,
modified_at=project_file_model.modified_at,
def convert_to_script_file(script_file_model: ScriptFileModel) -> ScriptFile:
return ScriptFile(
file_id=script_file_model.file_id,
script_revision_id=script_file_model.script_revision_id,
script_id=script_file_model.script_id,
organization_id=script_file_model.organization_id,
file_path=script_file_model.file_path,
file_name=script_file_model.file_name,
file_type=script_file_model.file_type,
content_hash=script_file_model.content_hash,
file_size=script_file_model.file_size,
mime_type=script_file_model.mime_type,
encoding=script_file_model.encoding,
artifact_id=script_file_model.artifact_id,
created_at=script_file_model.created_at,
modified_at=script_file_model.modified_at,
)

View File

@@ -12,7 +12,7 @@ from skyvern.forge.sdk.schemas.task_v2 import TaskV2Status
from skyvern.forge.sdk.schemas.tasks import TaskStatus
from skyvern.forge.sdk.workflow.models.workflow import WorkflowRunStatus
from skyvern.schemas.runs import RunEngine, RunType
from skyvern.services import project_service, task_v2_service
from skyvern.services import script_service, task_v2_service
from skyvern.utils.files import initialize_skyvern_state_file
LOG = structlog.get_logger()
@@ -62,10 +62,10 @@ class AsyncExecutor(abc.ABC):
pass
@abc.abstractmethod
async def execute_project(
async def execute_script(
self,
request: Request | None,
project_id: str,
script_id: str,
organization_id: str,
background_tasks: BackgroundTasks | None,
**kwargs: dict,
@@ -209,18 +209,18 @@ class BackgroundTaskExecutor(AsyncExecutor):
browser_session_id=browser_session_id,
)
async def execute_project(
async def execute_script(
self,
request: Request | None,
project_id: str,
script_id: str,
organization_id: str,
background_tasks: BackgroundTasks | None,
**kwargs: dict,
) -> None:
if background_tasks:
background_tasks.add_task(
project_service.execute_project,
project_id=project_id,
script_service.execute_script,
script_id=script_id,
organization_id=organization_id,
background_tasks=background_tasks,
)

View File

@@ -1,9 +1,9 @@
from skyvern.forge.sdk.routes import agent_protocol # noqa: F401
from skyvern.forge.sdk.routes import browser_sessions # noqa: F401
from skyvern.forge.sdk.routes import credentials # noqa: F401
from skyvern.forge.sdk.routes import projects # noqa: F401
from skyvern.forge.sdk.routes import pylon # noqa: F401
from skyvern.forge.sdk.routes import run_blocks # noqa: F401
from skyvern.forge.sdk.routes import scripts # noqa: F401
from skyvern.forge.sdk.routes import streaming # noqa: F401
from skyvern.forge.sdk.routes import streaming_commands # noqa: F401
from skyvern.forge.sdk.routes import streaming_vnc # noqa: F401

View File

@@ -1,295 +0,0 @@
import base64
import hashlib
import structlog
from fastapi import BackgroundTasks, Depends, HTTPException, Path, Query, Request
from skyvern.forge import app
from skyvern.forge.sdk.executor.factory import AsyncExecutorFactory
from skyvern.forge.sdk.routes.routers import base_router
from skyvern.forge.sdk.schemas.organizations import Organization
from skyvern.forge.sdk.services import org_auth_service
from skyvern.schemas.projects import CreateProjectRequest, CreateProjectResponse, DeployProjectRequest, Project
from skyvern.services import project_service
LOG = structlog.get_logger()
@base_router.post(
"/projects",
response_model=CreateProjectResponse,
summary="Create project",
description="Create a new project with optional files and metadata",
tags=["Projects"],
openapi_extra={
"x-fern-sdk-method-name": "create_project",
},
)
@base_router.post(
"/projects/",
response_model=CreateProjectResponse,
include_in_schema=False,
)
async def create_project(
data: CreateProjectRequest,
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> CreateProjectResponse:
"""Create a new project with optional files and metadata."""
organization_id = current_org.organization_id
LOG.info(
"Creating project",
organization_id=organization_id,
file_count=len(data.files) if data.files else 0,
)
if data.run_id:
if not await app.DATABASE.get_run(run_id=data.run_id, organization_id=organization_id):
raise HTTPException(status_code=404, detail=f"Run_id {data.run_id} not found")
try:
# Create the project in the database
project = await app.DATABASE.create_project(
organization_id=organization_id,
run_id=data.run_id,
)
# Process files if provided
file_tree = {}
file_count = 0
if data.files:
file_tree = await project_service.build_file_tree(
data.files,
organization_id=organization_id,
project_id=project.project_id,
project_version=project.version,
project_revision_id=project.project_revision_id,
)
file_count = len(data.files)
return CreateProjectResponse(
project_id=project.project_id,
version=project.version,
run_id=project.run_id,
file_count=file_count,
created_at=project.created_at,
file_tree=file_tree,
)
except Exception as e:
LOG.error("Failed to create project", error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail="Failed to create project")
@base_router.get(
"/projects/{project_id}",
response_model=Project,
summary="Get project by ID",
description="Retrieves a specific project by its ID",
tags=["Projects"],
openapi_extra={
"x-fern-sdk-method-name": "get_project",
},
)
@base_router.get(
"/projects/{project_id}/",
response_model=Project,
include_in_schema=False,
)
async def get_project(
project_id: str = Path(
...,
description="The unique identifier of the project",
examples=["proj_abc123"],
),
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> Project:
"""Get a project by its ID."""
LOG.info(
"Getting project",
organization_id=current_org.organization_id,
project_id=project_id,
)
project = await app.DATABASE.get_project(
project_id=project_id,
organization_id=current_org.organization_id,
)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@base_router.get(
"/projects",
response_model=list[Project],
summary="Get all projects",
description="Retrieves a paginated list of projects for the current organization",
tags=["Projects"],
openapi_extra={
"x-fern-sdk-method-name": "get_projects",
},
)
@base_router.get(
"/projects/",
response_model=list[Project],
include_in_schema=False,
)
async def get_projects(
current_org: Organization = Depends(org_auth_service.get_current_org),
page: int = Query(
1,
ge=1,
description="Page number for pagination",
examples=[1],
),
page_size: int = Query(
10,
ge=1,
description="Number of items per page",
examples=[10],
),
) -> list[Project]:
"""Get all projects for the current organization."""
LOG.info(
"Getting projects",
organization_id=current_org.organization_id,
page=page,
page_size=page_size,
)
projects = await app.DATABASE.get_projects(
organization_id=current_org.organization_id,
page=page,
page_size=page_size,
)
return projects
@base_router.post(
"/projects/{project_id}/deploy",
response_model=CreateProjectResponse,
summary="Deploy project",
description="Deploy a project with updated files, creating a new version",
tags=["Projects"],
openapi_extra={
"x-fern-sdk-method-name": "deploy_project",
},
)
@base_router.post(
"/projects/{project_id}/deploy/",
response_model=CreateProjectResponse,
include_in_schema=False,
)
async def deploy_project(
data: DeployProjectRequest,
project_id: str = Path(
...,
description="The unique identifier of the project",
examples=["proj_abc123"],
),
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> CreateProjectResponse:
"""Deploy a project with updated files, creating a new version."""
LOG.info(
"Deploying project",
organization_id=current_org.organization_id,
project_id=project_id,
file_count=len(data.files) if data.files else 0,
)
try:
# Get the latest version of the project
latest_project = await app.DATABASE.get_project(
project_id=project_id,
organization_id=current_org.organization_id,
)
if not latest_project:
raise HTTPException(status_code=404, detail="Project not found")
# Create a new version of the project
new_version = latest_project.version + 1
new_project_revision = await app.DATABASE.create_project(
organization_id=current_org.organization_id,
run_id=latest_project.run_id,
project_id=project_id, # Use the same project_id for versioning
version=new_version,
)
# Process files if provided
file_tree = {}
file_count = 0
if data.files:
file_tree = await project_service.build_file_tree(
data.files,
organization_id=current_org.organization_id,
project_id=new_project_revision.project_id,
project_version=new_project_revision.version,
project_revision_id=new_project_revision.project_revision_id,
)
file_count = len(data.files)
# Create project file records
for file in data.files:
content_bytes = base64.b64decode(file.content)
content_hash = hashlib.sha256(content_bytes).hexdigest()
file_size = len(content_bytes)
# Extract file name from path
file_name = file.path.split("/")[-1]
await app.DATABASE.create_project_file(
project_revision_id=new_project_revision.project_revision_id,
project_id=new_project_revision.project_id,
organization_id=new_project_revision.organization_id,
file_path=file.path,
file_name=file_name,
file_type="file",
content_hash=f"sha256:{content_hash}",
file_size=file_size,
mime_type=file.mime_type,
encoding=file.encoding,
)
return CreateProjectResponse(
project_id=new_project_revision.project_id,
version=new_project_revision.version,
run_id=new_project_revision.run_id,
file_count=file_count,
created_at=new_project_revision.created_at,
file_tree=file_tree,
)
except HTTPException:
raise
except Exception as e:
LOG.error("Failed to deploy project", error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail="Failed to deploy project")
@base_router.post(
"/projects/{project_id}/run",
summary="Run project",
description="Run a project",
tags=["Projects"],
)
async def run_project(
request: Request,
background_tasks: BackgroundTasks,
project_id: str = Path(
...,
description="The unique identifier of the project",
examples=["proj_abc123"],
),
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> None:
"""Run a project."""
# await project_service.execute_project(
# project_id=project_id,
# organization_id=current_org.organization_id,
# background_tasks=background_tasks,
# )
await AsyncExecutorFactory.get_executor().execute_project(
request=request,
project_id=project_id,
organization_id=current_org.organization_id,
background_tasks=background_tasks,
)

View File

@@ -0,0 +1,295 @@
import base64
import hashlib
import structlog
from fastapi import BackgroundTasks, Depends, HTTPException, Path, Query, Request
from skyvern.forge import app
from skyvern.forge.sdk.executor.factory import AsyncExecutorFactory
from skyvern.forge.sdk.routes.routers import base_router
from skyvern.forge.sdk.schemas.organizations import Organization
from skyvern.forge.sdk.services import org_auth_service
from skyvern.schemas.scripts import CreateScriptRequest, CreateScriptResponse, DeployScriptRequest, Script
from skyvern.services import script_service
LOG = structlog.get_logger()
@base_router.post(
"/scripts",
response_model=CreateScriptResponse,
summary="Create script",
description="Create a new script with optional files and metadata",
tags=["Scripts"],
openapi_extra={
"x-fern-sdk-method-name": "create_script",
},
)
@base_router.post(
"/scripts/",
response_model=CreateScriptResponse,
include_in_schema=False,
)
async def create_script(
data: CreateScriptRequest,
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> CreateScriptResponse:
"""Create a new script with optional files and metadata."""
organization_id = current_org.organization_id
LOG.info(
"Creating script",
organization_id=organization_id,
file_count=len(data.files) if data.files else 0,
)
if data.run_id:
if not await app.DATABASE.get_run(run_id=data.run_id, organization_id=organization_id):
raise HTTPException(status_code=404, detail=f"Run_id {data.run_id} not found")
try:
# Create the script in the database
script = await app.DATABASE.create_script(
organization_id=organization_id,
run_id=data.run_id,
)
# Process files if provided
file_tree = {}
file_count = 0
if data.files:
file_tree = await script_service.build_file_tree(
data.files,
organization_id=organization_id,
script_id=script.script_id,
script_version=script.version,
script_revision_id=script.script_revision_id,
)
file_count = len(data.files)
return CreateScriptResponse(
script_id=script.script_id,
version=script.version,
run_id=script.run_id,
file_count=file_count,
created_at=script.created_at,
file_tree=file_tree,
)
except Exception as e:
LOG.error("Failed to create script", error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail="Failed to create script")
@base_router.get(
"/scripts/{script_id}",
response_model=Script,
summary="Get script by ID",
description="Retrieves a specific script by its ID",
tags=["Scripts"],
openapi_extra={
"x-fern-sdk-method-name": "get_script",
},
)
@base_router.get(
"/scripts/{script_id}/",
response_model=Script,
include_in_schema=False,
)
async def get_script(
script_id: str = Path(
...,
description="The unique identifier of the script",
examples=["s_abc123"],
),
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> Script:
"""Get a script by its ID."""
LOG.info(
"Getting script",
organization_id=current_org.organization_id,
script_id=script_id,
)
script = await app.DATABASE.get_script(
script_id=script_id,
organization_id=current_org.organization_id,
)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
return script
@base_router.get(
"/scripts",
response_model=list[Script],
summary="Get all scripts",
description="Retrieves a paginated list of scripts for the current organization",
tags=["Scripts"],
openapi_extra={
"x-fern-sdk-method-name": "get_scripts",
},
)
@base_router.get(
"/scripts/",
response_model=list[Script],
include_in_schema=False,
)
async def get_scripts(
current_org: Organization = Depends(org_auth_service.get_current_org),
page: int = Query(
1,
ge=1,
description="Page number for pagination",
examples=[1],
),
page_size: int = Query(
10,
ge=1,
description="Number of items per page",
examples=[10],
),
) -> list[Script]:
"""Get all scripts for the current organization."""
LOG.info(
"Getting scripts",
organization_id=current_org.organization_id,
page=page,
page_size=page_size,
)
scripts = await app.DATABASE.get_scripts(
organization_id=current_org.organization_id,
page=page,
page_size=page_size,
)
return scripts
@base_router.post(
"/scripts/{script_id}/deploy",
response_model=CreateScriptResponse,
summary="Deploy script",
description="Deploy a script with updated files, creating a new version",
tags=["Scripts"],
openapi_extra={
"x-fern-sdk-method-name": "deploy_script",
},
)
@base_router.post(
"/scripts/{script_id}/deploy/",
response_model=CreateScriptResponse,
include_in_schema=False,
)
async def deploy_script(
data: DeployScriptRequest,
script_id: str = Path(
...,
description="The unique identifier of the script",
examples=["s_abc123"],
),
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> CreateScriptResponse:
"""Deploy a script with updated files, creating a new version."""
LOG.info(
"Deploying script",
organization_id=current_org.organization_id,
script_id=script_id,
file_count=len(data.files) if data.files else 0,
)
try:
# Get the latest version of the script
latest_script = await app.DATABASE.get_script(
script_id=script_id,
organization_id=current_org.organization_id,
)
if not latest_script:
raise HTTPException(status_code=404, detail="Script not found")
# Create a new version of the script
new_version = latest_script.version + 1
new_script_revision = await app.DATABASE.create_script(
organization_id=current_org.organization_id,
run_id=latest_script.run_id,
script_id=script_id, # Use the same script_id for versioning
version=new_version,
)
# Process files if provided
file_tree = {}
file_count = 0
if data.files:
file_tree = await script_service.build_file_tree(
data.files,
organization_id=current_org.organization_id,
script_id=new_script_revision.script_id,
script_version=new_script_revision.version,
script_revision_id=new_script_revision.script_revision_id,
)
file_count = len(data.files)
# Create script file records
for file in data.files:
content_bytes = base64.b64decode(file.content)
content_hash = hashlib.sha256(content_bytes).hexdigest()
file_size = len(content_bytes)
# Extract file name from path
file_name = file.path.split("/")[-1]
await app.DATABASE.create_script_file(
script_revision_id=new_script_revision.script_revision_id,
script_id=new_script_revision.script_id,
organization_id=new_script_revision.organization_id,
file_path=file.path,
file_name=file_name,
file_type="file",
content_hash=f"sha256:{content_hash}",
file_size=file_size,
mime_type=file.mime_type,
encoding=file.encoding,
)
return CreateScriptResponse(
script_id=new_script_revision.script_id,
version=new_script_revision.version,
run_id=new_script_revision.run_id,
file_count=file_count,
created_at=new_script_revision.created_at,
file_tree=file_tree,
)
except HTTPException:
raise
except Exception as e:
LOG.error("Failed to deploy script", error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail="Failed to deploy script")
@base_router.post(
"/scripts/{script_id}/run",
summary="Run script",
description="Run a script",
tags=["Scripts"],
)
async def run_script(
request: Request,
background_tasks: BackgroundTasks,
script_id: str = Path(
...,
description="The unique identifier of the script",
examples=["s_abc123"],
),
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> None:
"""Run a script."""
# await script_service.execute_script(
# script_id=script_id,
# organization_id=current_org.organization_id,
# background_tasks=background_tasks,
# )
await AsyncExecutorFactory.get_executor().execute_script(
request=request,
script_id=script_id,
organization_id=current_org.organization_id,
background_tasks=background_tasks,
)

View File

@@ -14,10 +14,10 @@ class FileEncoding(StrEnum):
UTF8 = "utf-8"
class ProjectFile(BaseModel):
class ScriptFile(BaseModel):
file_id: str
project_revision_id: str
project_id: str
script_revision_id: str
script_id: str
organization_id: str
file_path: str # e.g., "src/utils.py"
@@ -41,21 +41,21 @@ class ProjectFile(BaseModel):
return self.content
class ProjectFileCreate(BaseModel):
"""Model representing a file in a project."""
class ScriptFileCreate(BaseModel):
"""Model representing a file in a script."""
path: str = Field(..., description="File path relative to project root", examples=["src/main.py"])
path: str = Field(..., description="File path relative to script root", examples=["src/main.py"])
content: str = Field(..., description="Base64 encoded file content")
encoding: FileEncoding = Field(default=FileEncoding.BASE64, description="Content encoding")
mime_type: str | None = Field(default=None, description="MIME type (auto-detected if not provided)")
class CreateProjectRequest(BaseModel):
class CreateScriptRequest(BaseModel):
workflow_id: str | None = Field(default=None, description="Associated workflow ID")
run_id: str | None = Field(default=None, description="Associated run ID")
files: list[ProjectFileCreate] | None = Field(
files: list[ScriptFileCreate] | None = Field(
default=None,
description="Array of files to include in the project",
description="Array of files to include in the script",
examples=[
{
"path": "main.py",
@@ -84,12 +84,12 @@ class FileNode(BaseModel):
children: dict[str, FileNode] | None = Field(default=None, description="Child nodes for directories")
class DeployProjectRequest(BaseModel):
"""Request model for deploying a project with updated files."""
class DeployScriptRequest(BaseModel):
"""Request model for deploying a script with updated files."""
files: list[ProjectFileCreate] = Field(
files: list[ScriptFileCreate] = Field(
...,
description="Array of files to include in the project",
description="Array of files to include in the script",
examples=[
{
"path": "src/main.py",
@@ -101,27 +101,27 @@ class DeployProjectRequest(BaseModel):
)
class CreateProjectResponse(BaseModel):
project_id: str = Field(..., description="Unique project identifier", examples=["proj_abc123"])
version: int = Field(..., description="Project version number", examples=[1])
class CreateScriptResponse(BaseModel):
script_id: str = Field(..., description="Unique script identifier", examples=["s_abc123"])
version: int = Field(..., description="Script version number", examples=[1])
run_id: str | None = Field(
default=None, description="ID of the workflow run or task run that generated this project"
default=None, description="ID of the workflow run or task run that generated this script"
)
file_count: int = Field(..., description="Total number of files in the project")
file_count: int = Field(..., description="Total number of files in the script")
file_tree: dict[str, FileNode] = Field(..., description="Hierarchical file tree structure")
created_at: datetime = Field(..., description="Timestamp when the project was created")
created_at: datetime = Field(..., description="Timestamp when the script was created")
class Project(BaseModel):
class Script(BaseModel):
model_config = ConfigDict(from_attributes=True)
project_revision_id: str = Field(description="Unique identifier for this specific project revision")
project_id: str = Field(description="User-facing project identifier, consistent across versions")
organization_id: str = Field(description="ID of the organization that owns this project")
script_revision_id: str = Field(description="Unique identifier for this specific script revision")
script_id: str = Field(description="User-facing script identifier, consistent across versions")
organization_id: str = Field(description="ID of the organization that owns this script")
run_id: str | None = Field(
default=None, description="ID of the workflow run or task run that generated this project"
default=None, description="ID of the workflow run or task run that generated this script"
)
version: int = Field(description="Version number of the project")
created_at: datetime = Field(description="Timestamp when the project was created")
modified_at: datetime = Field(description="Timestamp when the project was last modified")
deleted_at: datetime | None = Field(default=None, description="Timestamp when the project was soft deleted")
version: int = Field(description="Version number of the script")
created_at: datetime = Field(description="Timestamp when the script was created")
modified_at: datetime = Field(description="Timestamp when the script was last modified")
deleted_at: datetime | None = Field(default=None, description="Timestamp when the script was soft deleted")

View File

@@ -7,19 +7,19 @@ from datetime import datetime
import structlog
from fastapi import BackgroundTasks
from skyvern.exceptions import ProjectNotFound
from skyvern.exceptions import ScriptNotFound
from skyvern.forge import app
from skyvern.schemas.projects import FileNode, ProjectFileCreate
from skyvern.schemas.scripts import FileNode, ScriptFileCreate
LOG = structlog.get_logger(__name__)
async def build_file_tree(
files: list[ProjectFileCreate],
files: list[ScriptFileCreate],
organization_id: str,
project_id: str,
project_version: int,
project_revision_id: str,
script_id: str,
script_version: int,
script_revision_id: str,
) -> dict[str, FileNode]:
"""Build a hierarchical file tree from a list of files and upload the files to s3 with the same tree structure."""
file_tree: dict[str, FileNode] = {}
@@ -32,24 +32,24 @@ async def build_file_tree(
# Create artifact and upload to S3
try:
artifact_id = await app.ARTIFACT_MANAGER.create_project_file_artifact(
artifact_id = await app.ARTIFACT_MANAGER.create_script_file_artifact(
organization_id=organization_id,
project_id=project_id,
project_version=project_version,
script_id=script_id,
script_version=script_version,
file_path=file.path,
data=content_bytes,
)
LOG.debug(
"Created project file artifact",
"Created script file artifact",
artifact_id=artifact_id,
file_path=file.path,
project_id=project_id,
project_version=project_version,
script_id=script_id,
script_version=script_version,
)
# create a project file record
await app.DATABASE.create_project_file(
project_revision_id=project_revision_id,
project_id=project_id,
# create a script file record
await app.DATABASE.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],
@@ -61,11 +61,11 @@ async def build_file_tree(
)
except Exception:
LOG.exception(
"Failed to create project file artifact",
"Failed to create script file artifact",
file_path=file.path,
project_id=project_id,
project_version=project_version,
project_revision_id=project_revision_id,
script_id=script_id,
script_version=script_version,
script_revision_id=script_revision_id,
)
raise
@@ -96,43 +96,43 @@ async def build_file_tree(
return file_tree
async def execute_project(
project_id: str,
async def execute_script(
script_id: str,
organization_id: str,
background_tasks: BackgroundTasks | None = None,
) -> None:
# TODO: assume the project only has one ProjectFile called main.py
# step 1: get the project revision
# step 2: get the project files
# step 3: copy the project files to the local directory
# step 4: execute the project
# TODO: assume the script only has one ScriptFile called main.py
# step 1: get the script revision
# step 2: get the script files
# step 3: copy the script files to the local directory
# step 4: execute the script
# step 1: get the project revision
project = await app.DATABASE.get_project(
project_id=project_id,
# step 1: get the script revision
script = await app.DATABASE.get_script(
script_id=script_id,
organization_id=organization_id,
)
if not project:
raise ProjectNotFound(project_id=project_id)
if not script:
raise ScriptNotFound(script_id=script_id)
# step 2: get the project files
project_files = await app.DATABASE.get_project_files(
project_revision_id=project.project_revision_id, organization_id=organization_id
# step 2: get the script files
script_files = await app.DATABASE.get_script_files(
script_revision_id=script.script_revision_id, organization_id=organization_id
)
# step 3: copy the project files to the local directory
for file in project_files:
# step 3: copy the script files to the local directory
for file in script_files:
# retrieve the artifact
if not file.artifact_id:
continue
artifact = await app.DATABASE.get_artifact_by_id(file.artifact_id, organization_id)
if not artifact:
LOG.error("Artifact not found", artifact_id=file.artifact_id, project_id=project_id)
LOG.error("Artifact not found", artifact_id=file.artifact_id, script_id=script_id)
continue
file_content = await app.ARTIFACT_MANAGER.retrieve_artifact(artifact)
if not file_content:
continue
file_path = os.path.join(project.project_id, file.file_path)
file_path = os.path.join(script.script_id, file.file_path)
# create the directory if it doesn't exist
os.makedirs(os.path.dirname(file_path), exist_ok=True)
@@ -154,7 +154,7 @@ async def execute_project(
with open(file_path, "wb") as f:
f.write(file_content)
# step 4: execute the project
# step 4: execute the script
if background_tasks:
background_tasks.add_task(subprocess.run, ["python", f"{project.project_id}/main.py"])
LOG.info("Project executed successfully", project_id=project_id)
background_tasks.add_task(subprocess.run, ["python", f"{script.script_id}/main.py"])
LOG.info("Script executed successfully", script_id=script_id)