Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion rock/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
from rock.sdk.envs import make

__all__ = ["make"]
from ._codes import codes
from .sdk.common.exceptions import (
BadRequestRockError,
CommandRockError,
InternalServerRockError,
RockException,
raise_for_code,
)

__all__ = [
"make",
"codes",
"RockException",
"BadRequestRockError",
"InternalServerRockError",
"CommandRockError",
"raise_for_code",
]
160 changes: 160 additions & 0 deletions rock/_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
from __future__ import annotations

from enum import IntEnum

__all__ = ["codes"]


class codes(IntEnum):
"""
ROCK status codes enumeration.

This class extends IntEnum to provide status codes with associated phrase descriptions.
Each enum member has both an integer value and a phrase attribute for human-readable descriptions.

The class also provides utility methods to categorize codes and retrieve phrases.
"""

_ignore_ = ["phrase"]
phrase: str = ""

def __new__(cls, value: int, phrase: str = "") -> codes:
"""
Create a new codes enum member with both value and phrase.

Args:
value: The integer status code value
phrase: Human-readable description of the status

Returns:
A new codes enum member with the phrase attribute set
"""
obj = int.__new__(cls, value)
obj._value_ = value
obj.phrase = phrase # Add phrase as an instance attribute
return obj

def __str__(self) -> str:
"""Return string representation of the status code value."""
return str(self.value)

@classmethod
def get_reason_phrase(cls, value: int) -> str:
"""
Get the reason phrase for a given status code value.

Args:
value: The integer status code value to look up

Returns:
The reason phrase string, or empty string if code not found

Example:
>>> codes.get_reason_phrase(2000)
'OK'
>>> codes.get_reason_phrase(9999)
''
"""
try:
return codes(value).phrase
except ValueError:
return ""

@classmethod
def is_success(cls, value: int) -> bool:
"""
Check if a status code indicates success (2xxx range).

Args:
value: The status code to check

Returns:
True if the code is in the 2000-2999 range, False otherwise
"""
return 2000 <= value <= 2999

@classmethod
def is_client_error(cls, value: int) -> bool:
"""
Check if a status code indicates a client error (4xxx range).

Args:
value: The status code to check

Returns:
True if the code is in the 4000-4999 range, False otherwise
"""
return 4000 <= value <= 4999

@classmethod
def is_server_error(cls, value: int) -> bool:
"""
Check if a status code indicates a server error (5xxx range).

Args:
value: The status code to check

Returns:
True if the code is in the 5000-5999 range, False otherwise
"""
return 5000 <= value <= 5999

@classmethod
def is_command_error(cls, value: int) -> bool:
"""
Check if a status code indicates a command error (6xxx range).

Args:
value: The status code to check

Returns:
True if the code is in the 6000-6999 range, False otherwise
"""
return 6000 <= value <= 6999

@classmethod
def is_error(cls, value: int) -> bool:
"""
Check if a status code indicates any kind of error (4xxx or 5xxx range).

Args:
value: The status code to check

Returns:
True if the code is in the 4000-5999 range, False otherwise
"""
return 4000 <= value <= 6999

OK = 2000, "OK"
"""
Success codes (2xxx)
"""

BAD_REQUEST = 4000, "Bad Request"
"""
Client error codes (4xxx):

These errors indicate issues with the client request,
SDK will raise Exceptions for these errors.
"""

INTERNAL_SERVER_ERROR = 5000, "Internal Server Error"
"""
Server error codes (5xxx):

These errors indicate issues on the server side,
SDK will raise Exceptions for these errors.
"""

COMMAND_ERROR = 6000, "Command Error"
"""
Command/execution error codes (6xxx):

These errors are related to command execution and should be handled by the model,
SDK will NOT raise Exceptions for these errors.
"""


# Include lower-case styles for `requests` compatibility.
for code in codes:
setattr(codes, code._name_.lower(), int(code))
54 changes: 54 additions & 0 deletions rock/sdk/common/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import rock
from rock.actions.response import RockResponse
from rock.utils.deprecated import deprecated


class RockException(Exception):
_code: rock.codes = None

def __init__(self, message, code: rock.codes = None):
super().__init__(message)
self._code = code

@property
def code(self):
return self._code


@deprecated("This exception is deprecated")
class InvalidParameterRockException(RockException):
def __init__(self, message):
super().__init__(message)


class BadRequestRockError(RockException):
def __init__(self, message, code: rock.codes = rock.codes.BAD_REQUEST):
super().__init__(message, code)


class InternalServerRockError(RockException):
def __init__(self, message, code: rock.codes = rock.codes.INTERNAL_SERVER_ERROR):
super().__init__(message, code)


class CommandRockError(RockException):
def __init__(self, message, code: rock.codes = rock.codes.COMMAND_ERROR):
super().__init__(message, code)


def raise_for_code(code: rock.codes, message: str):
if code is None or rock.codes.is_success(code):
return

if rock.codes.is_client_error(code):
raise BadRequestRockError(message)
if rock.codes.is_server_error(code):
raise InternalServerRockError(message)
if rock.codes.is_command_error(code):
raise CommandRockError(message)

raise RockException(message, code=code)


def from_rock_exception(e: RockException) -> RockResponse:
return RockResponse(code=e.code, failure_reason=str(e))
75 changes: 65 additions & 10 deletions rock/sdk/sandbox/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import uuid
import warnings
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path

import oss2
Expand Down Expand Up @@ -37,6 +38,7 @@
WriteFileResponse,
)
from rock.sdk.common.constants import PID_PREFIX, PID_SUFFIX, RunModeType
from rock.sdk.common.exceptions import InvalidParameterRockException
from rock.sdk.sandbox.agent.base import Agent
from rock.sdk.sandbox.config import SandboxConfig, SandboxGroupConfig
from rock.sdk.sandbox.remote_user import LinuxRemoteUser, RemoteUser
Expand All @@ -45,6 +47,11 @@
logger = logging.getLogger(__name__)


class RunMode(str, Enum):
NORMAL = "normal"
NOHUP = "nohup"


class Sandbox(AbstractSandbox):
config: SandboxConfig
_url: str
Expand Down Expand Up @@ -143,7 +150,7 @@ async def start(self):
except Exception as e:
logging.warning(f"Failed to get status, {str(e)}")
await asyncio.sleep(3)
raise Exception(f"Failed to start sandbox within {self.config.startup_timeout}s")
raise Exception(f"Failed to start sandbox within {self.config.startup_timeout}s, sandbox: {str(self)}")

async def is_alive(self) -> IsAliveResponse:
try:
Expand Down Expand Up @@ -173,7 +180,7 @@ async def execute(self, command: Command) -> CommandResponse:
"sandbox_id": self.sandbox_id,
"timeout": command.timeout,
"cwd": command.cwd,
"env": command.env
"env": command.env,
}
try:
response = await HttpUtils.post(url, headers, data)
Expand Down Expand Up @@ -315,19 +322,69 @@ async def arun(
session: str = None,
wait_timeout=300,
wait_interval=10,
mode: RunModeType = "normal",
mode: RunModeType = RunMode.NORMAL,
response_limited_bytes_in_nohup: int | None = None,
ignore_output: bool = False,
) -> Observation:
if mode == "nohup":
"""
Asynchronously run a command in the sandbox environment.
This method supports two execution modes:
- NORMAL: Execute command synchronously and wait for completion
- NOHUP: Execute command in background using nohup, suitable for long-running tasks
Args:
cmd (str): The command to execute in the sandbox
session (str, optional): The session identifier to run the command in.
If None, a temporary session will be created for nohup mode. Defaults to None.
wait_timeout (int, optional): Maximum time in seconds to wait for nohup command completion.
Defaults to 300.
wait_interval (int, optional): Interval in seconds between process completion checks for nohup mode.
Minimum value is 5 seconds. Defaults to 10.
mode (RunModeType, optional): Execution mode - either "normal" or "nohup".
Defaults to RunMode.NORMAL.
response_limited_bytes_in_nohup (int | None, optional): Maximum bytes to read from nohup output file.
If None, reads entire output. Only applies to nohup mode. Defaults to None.
nohup_command_timeout (int, optional): Timeout in seconds for the nohup command submission itself.
Defaults to 60.
Returns:
Observation: Command execution result containing output, exit code, and failure reason if any.
- For normal mode: Returns immediate execution result
- For nohup mode: Returns result after process completion or timeout
Raises:
InvalidParameterRockException: If an unsupported run mode is provided
ReadTimeout: If command execution times out (nohup mode)
Exception: For other execution failures in nohup mode
Examples:
# Normal synchronous execution
result = await sandbox.arun("ls -la")
# Background execution with nohup
result = await sandbox.arun(
"python long_running_script.py",
mode="nohup",
wait_timeout=600
)
# Limited output reading in nohup mode
result = await sandbox.arun(
"generate_large_output.sh",
mode="nohup",
response_limited_bytes_in_nohup=1024
)
"""
if mode not in (RunMode.NORMAL, RunMode.NOHUP):
raise InvalidParameterRockException(f"Unsupported arun mode: {mode}")

if mode == RunMode.NORMAL:
return await self._run_in_session(action=Action(command=cmd, session=session))
if mode == RunMode.NOHUP:
try:
timestamp = str(time.time_ns())
if session is None:
temp_session = f"bash-{timestamp}"
await self.create_session(CreateBashSessionRequest(session=temp_session))
session = temp_session
tmp_file = f"/tmp/tmp_{timestamp}.out"
nohup_command = f"nohup {cmd} < /dev/null > {tmp_file} 2>&1 & echo {PID_PREFIX}${{!}}{PID_SUFFIX};disown"
nohup_command = (
f"nohup {cmd} < /dev/null > {tmp_file} 2>&1 & echo {PID_PREFIX}${{!}}{PID_SUFFIX};disown"
)
# todo:
# Theoretically, the nohup command should return in a very short time, but the total time online is longer,
# so time_out is set larger to avoid affecting online usage. It will be reduced after optimizing the read cluster time.
Expand All @@ -354,7 +411,9 @@ async def arun(
file_size = None
try:
size_result: Observation = await self._run_in_session(
BashAction(session=session, command=f"stat -c %s {tmp_file} 2>/dev/null || stat -f %z {tmp_file}")
BashAction(
session=session, command=f"stat -c %s {tmp_file} 2>/dev/null || stat -f %z {tmp_file}"
)
)
if size_result.exit_code == 0 and size_result.output.strip().isdigit():
file_size = int(size_result.output.strip())
Expand Down Expand Up @@ -382,10 +441,6 @@ async def arun(
except Exception as e:
error_msg = f"Failed to execute nohup command '{cmd}': {str(e)}"
return Observation(output=error_msg, exit_code=1, failure_reason=error_msg)
elif mode == "normal":
return await self._run_in_session(action=BashAction(command=cmd, session=session))
else:
return Observation(output="", exit_code=1, failure_reason="Unsupported arun mode")

async def write_file(self, request: WriteFileRequest) -> WriteFileResponse:
content = request.content
Expand Down
Loading
Loading