feat(sandbox): draft storage

This commit is contained in:
Harry
2026-01-20 18:44:00 +08:00
parent ceb410fb5c
commit 1c76ed2c40
18 changed files with 333 additions and 208 deletions

View File

@ -33,6 +33,8 @@ class SandboxConfigValidationError(ValueError):
class CommandExecutionError(Exception):
"""Raised when a command execution fails."""
result: CommandResult
def __init__(self, message: str, result: CommandResult):
super().__init__(message)
self.result = result
@ -44,3 +46,19 @@ class CommandExecutionError(Exception):
@property
def stderr(self) -> bytes:
return self.result.stderr
class PipelineExecutionError(CommandExecutionError):
"""Raised when a pipeline command fails in strict mode."""
index: int
command: list[str]
results: list[CommandResult]
def __init__(
self, message: str, result: CommandResult, *, index: int, command: list[str], results: list[CommandResult]
):
super().__init__(message, result)
self.index = index
self.command = command
self.results = results

View File

@ -1,15 +1,19 @@
from __future__ import annotations
import contextlib
import shlex
from collections.abc import Generator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass, field
from functools import partial
from core.virtual_environment.__base.command_future import CommandFuture
from core.virtual_environment.__base.entities import CommandResult, ConnectionHandle
from core.virtual_environment.__base.exec import CommandExecutionError
from core.virtual_environment.__base.exec import CommandExecutionError, PipelineExecutionError
from core.virtual_environment.__base.virtual_environment import VirtualEnvironment
_PIPE_SENTINEL = "__DIFY_PIPE__"
@contextmanager
def with_connection(env: VirtualEnvironment) -> Generator[ConnectionHandle, None, None]:
@ -147,3 +151,127 @@ def try_execute(
with with_connection(env) as conn:
return _execute_with_connection(env, conn, command, timeout, cwd)
@dataclass(frozen=True)
class _PipelineStep:
argv: list[str]
error_message: str = "Command failed"
@dataclass
class CommandPipeline:
"""Batch multiple commands into a single shell execution (Redis pipeline style).
Example:
results = pipeline(env).add(["echo", "hi"]).add(["ls"]).execute()
# Strict mode: raise on first failure
pipeline(env).add(["mkdir", "/x"], error_message="mkdir failed").execute(raise_on_error=True)
"""
env: VirtualEnvironment
connection: ConnectionHandle | None = None
cwd: str | None = None
environments: Mapping[str, str] | None = None
_steps: list[_PipelineStep] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
def add(self, command: list[str], *, error_message: str = "Command failed") -> CommandPipeline:
self._steps.append(_PipelineStep(argv=command, error_message=error_message))
return self
def execute(self, *, timeout: float | None = 30, raise_on_error: bool = False) -> list[CommandResult]:
if not self._steps:
return []
script = self._build_script(fail_fast=raise_on_error)
batch_cmd = ["sh", "-lc", script]
if self.connection is not None:
batch_result = try_execute(self.env, batch_cmd, timeout=timeout, cwd=self.cwd, connection=self.connection)
else:
with with_connection(self.env) as conn:
batch_result = try_execute(self.env, batch_cmd, timeout=timeout, cwd=self.cwd, connection=conn)
results = self._parse_results(batch_result.stdout, batch_result.pid)
if raise_on_error:
for i, r in enumerate(iterable=results):
if r.is_error:
step = self._steps[i]
raise PipelineExecutionError(
f"{step.error_message}: {r.error_message}",
r,
index=i,
command=step.argv,
results=results,
)
return results
def _build_script(self, *, fail_fast: bool = False) -> str:
lines = [
"run() {",
' i="$1"; shift',
' out="$(mktemp)"; err="$(mktemp)"',
' ("$@") >"$out" 2>"$err"; ec="$?"',
' os="$(wc -c <"$out" | tr -d \' \')"',
' es="$(wc -c <"$err" | tr -d \' \')"',
f' printf \'{_PIPE_SENTINEL} %s %s %s %s\\n\' "$i" "$ec" "$os" "$es"',
' cat "$out"',
' cat "$err"',
' rm -f "$out" "$err"',
' return "$ec"',
"}",
]
suffix = " || exit $?" if fail_fast else ""
for i, step in enumerate(self._steps):
quoted = " ".join(shlex.quote(arg) for arg in step.argv)
lines.append(f"run {i} {quoted}{suffix}")
return "\n".join(lines)
@staticmethod
def _parse_results(stdout: bytes, pid: str) -> list[CommandResult]:
results: list[CommandResult] = []
pos = 0
sentinel = _PIPE_SENTINEL.encode() + b" "
while pos < len(stdout):
nl = stdout.find(b"\n", pos)
if nl == -1:
break
header = stdout[pos : nl + 1]
pos = nl + 1
if not header.startswith(sentinel):
raise ValueError("Malformed pipeline output: missing sentinel")
parts = header.decode().strip().split(" ")
_, idx, ec, os_len, es_len = parts
out_len, err_len = int(os_len), int(es_len)
out_bytes = stdout[pos : pos + out_len]
pos += out_len
err_bytes = stdout[pos : pos + err_len]
pos += err_len
results.append(
CommandResult(
stdout=out_bytes,
stderr=err_bytes,
exit_code=int(ec),
pid=f"{pid}:{idx}",
)
)
return results
def pipeline(
env: VirtualEnvironment,
connection: ConnectionHandle | None = None,
*,
cwd: str | None = None,
environments: Mapping[str, str] | None = None,
) -> CommandPipeline:
return CommandPipeline(env=env, connection=connection, cwd=cwd, environments=environments)