Skip to content

feat(sandbox): add fork() and snapshot() helpers to SandboxInstance - #201

Merged
drappier-charles merged 2 commits into
mainfrom
cdrappier/devin/sandbox-fork-snapshot-helpers
Jul 24, 2026
Merged

feat(sandbox): add fork() and snapshot() helpers to SandboxInstance#201
drappier-charles merged 2 commits into
mainfrom
cdrappier/devin/sandbox-fork-snapshot-helpers

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Fixes ENG-4160

Summary

Python counterpart to the sdk-typescript helpers. Adds public fork() / snapshot() / list_snapshots() / delete_snapshot() methods to both the async SandboxInstance and the SyncSandboxInstance, wrapping the generated fork_sandbox / *_sandbox_snapshot API functions (generated client/api and client/models untouched).

sbx = await SandboxInstance.get("my-sandbox")

snapshot = await sbx.snapshot("before-migration")
await sbx.list_snapshots()
await sbx.delete_snapshot("snap_abc123")

# fork into a new sandbox (default) or an application
fork = await sbx.fork("my-sandbox-copy")
await sbx.fork("my-app", target_type="application", traffic=100, port=8080)

# create a sandbox from a snapshot id
await sbx.fork("my-sandbox-copy", target_type="sandbox", snapshot_id="snap_abc123")

The generated asyncio/sync functions return Union[Error, T] | None; a shared _unwrap_response() helper raises SandboxAPIError on an Error/None result and otherwise returns the payload. Optional fork fields (port, traffic, custom_domain, prefix, snapshot_id) are only set on SandboxForkRequest when provided.

Adds unit tests in tests/core/test_sandbox.py (mock the generated functions, assert request bodies and error handling) for both async and sync paths.

Companion PRs: sdk-typescript#396 and docs update (blaxel-ai/docs#624).

Link to Devin session: https://app.devin.ai/sessions/13e4e4ca02fe48a4ab4101a997e085a9
Requested by: @drappier-charles


Note

Adds fork(), snapshot(), list_snapshots(), and delete_snapshot() methods to both async and sync sandbox instances, with a shared _unwrap_response() helper that now correctly handles 204 No Content via allow_none=True.

Written by Mendral for commit 812a705.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@drappier-charles drappier-charles self-assigned this Jul 24, 2026
@drappier-charles
drappier-charles self-requested a review July 24, 2026 04:41
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@mendral-app

mendral-app Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🧪 Testing Guide

What this PR addresses

Adds public fork(), snapshot(), list_snapshots(), and delete_snapshot() helper methods to both the async SandboxInstance and sync SyncSandboxInstance classes. This is the Python counterpart to existing sdk-typescript helpers, wrapping the generated control-plane API functions without modifying the generated client code.

Steps to exercise the new behavior

  1. Run the new unit tests:

    pytest tests/core/test_sandbox.py -k "fork or snapshot" -v

    All 7 new tests should pass (async fork, fork with options, snapshot with name, delete snapshot 204 handling, error response handling, and sync variants).

  2. Run the full sandbox test suite to check for regressions:

    pytest tests/core/test_sandbox.py -v
  3. Review the _unwrap_response helper (src/blaxel/core/sandbox/default/sandbox.py):

    • Confirm it correctly raises SandboxAPIError when the response is an Error instance.
    • Confirm it raises when response is None (unless allow_none=True for void/204 endpoints like delete_snapshot).
  4. Integration validation (if you have a Blaxel workspace configured):

    from blaxel.core.sandbox import SandboxInstance
    
    sbx = await SandboxInstance.get("my-sandbox")
    
    # Snapshot lifecycle
    snap = await sbx.snapshot("test-snap")
    snaps = await sbx.list_snapshots()
    await sbx.delete_snapshot(snap.id)
    
    # Fork from live state
    fork = await sbx.fork("my-sandbox-copy")
    
    # Fork from snapshot
    fork = await sbx.fork("my-app", target_type="application", snapshot_id="snap_id", port=8080)

What to verify (expected behavior)

  • All existing sandbox tests continue to pass (no regressions).
  • New methods are available on both SandboxInstance (async) and SyncSandboxInstance (sync) with matching signatures.
  • fork() defaults target_type to "sandbox" and only sets optional fields (port, traffic, custom_domain, prefix, snapshot_id) when explicitly provided.
  • snapshot() passes the name argument only when provided; omits it otherwise.
  • delete_snapshot() gracefully handles None responses (HTTP 204 No Content).
  • Error responses from the API are properly converted to SandboxAPIError exceptions with status code and message preserved.
  • The generated client/api and client/models modules are untouched — this PR only adds wrapper methods.

Note

Posted by PR Testing Guide · Tag @mendral-app with feedback.

@mendral-app

mendral-app Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🔀 Interaction Flow Diagram

Here's how the new fork() and snapshot() helpers interact with the existing components:

sequenceDiagram
    participant User as User Code
    participant Async as SandboxInstance (async)
    participant Sync as SyncSandboxInstance
    participant Helper as _unwrap_response()
    participant API as Generated Client API
    participant CP as Control Plane

    Note over User,CP: Snapshot Flow
    User->>Async: await sbx.snapshot("before-migration")
    Async->>API: create_sandbox_snapshot(name, SandboxSnapshotRequest)
    API->>CP: POST /sandboxes/{name}/snapshots
    CP-->>API: Response (SandboxSnapshot | ErrorResponse)
    API-->>Async: raw response
    Async->>Helper: _unwrap_response(response, SandboxSnapshot)
    Helper-->>Async: SandboxSnapshot (unwrapped)
    Async-->>User: SandboxSnapshot

    Note over User,CP: List Snapshots Flow
    User->>Async: await sbx.list_snapshots()
    Async->>API: list_sandbox_snapshots(name)
    API->>CP: GET /sandboxes/{name}/snapshots
    CP-->>API: Response
    API-->>Async: List[SandboxSnapshot]
    Async-->>User: List[SandboxSnapshot]

    Note over User,CP: Delete Snapshot Flow
    User->>Async: await sbx.delete_snapshot("snap_abc")
    Async->>API: delete_sandbox_snapshot(name, snapshot_id)
    API->>CP: DELETE /sandboxes/{name}/snapshots/{id}
    CP-->>API: Response (None on 204 | ErrorResponse)
    API-->>Async: raw response
    Async->>Helper: _unwrap_response(response, None)
    Helper-->>Async: None
    Async-->>User: None

    Note over User,CP: Fork Flow
    User->>Async: await sbx.fork(target="sandbox")
    Async->>API: fork_sandbox(name, SandboxForkRequest)
    API->>CP: POST /sandboxes/{name}/fork
    CP-->>API: Response (SandboxForkResponse | ErrorResponse)
    API-->>Async: raw response
    Async->>Helper: _unwrap_response(response, SandboxForkResponse)
    Helper-->>Async: SandboxForkResponse
    Async-->>User: SandboxForkResponse

    Note over User,Sync: Sync Wrappers
    User->>Sync: sbx.snapshot() / fork() / etc.
    Sync->>Async: Delegates (same logic, no await)
    Sync->>Helper: _unwrap_response (imported from default module)
    Sync-->>User: Same return types
Loading

Summary

This PR adds four public methods (snapshot(), list_snapshots(), delete_snapshot(), fork()) to both SandboxInstance (async) and SyncSandboxInstance. The key design pattern:

  1. User calls a high-level method on the sandbox instance
  2. Instance delegates to the generated client API functions (untouched by this PR)
  3. API calls the Control Plane endpoints
  4. _unwrap_response() (new helper) handles error checking and payload extraction from raw API responses
  5. Sync variant reuses the same _unwrap_response helper, imported from the async module

Note

Posted by PR Sequence Diagram · Tag @mendral-app with feedback.

@mendral-app

mendral-app Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

📋 Created Linear issue ENG-4160 — status: In Progress

  • Assignee: Charles Drappier (reviewer — bot PR)
  • Labels: enhancement, Sandbox, SDK
  • Estimate: M (280 additions, 3 files)
  • PR linked: ✅ Issue will auto-close when this PR merges

Auto-created because no Linear reference was found in the PR title, description, or branch name.

Note

Posted by Linear Issue Enforcer · Tag @mendral-app with feedback.

mendral-app[bot]

This comment was marked as outdated.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@mendral-app mendral-app Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

All three previous issues (the delete_snapshot 204-handling bug) are fixed in 812a705. The _unwrap_response helper now accepts allow_none=True, both variants pass it for delete, and a dedicated test verifies the behavior. No new issues found.

Tag @mendral-app with feedback or questions. View session

@drappier-charles
drappier-charles marked this pull request as ready for review July 24, 2026 06:15
@drappier-charles
drappier-charles merged commit 2c405e2 into main Jul 24, 2026
18 of 21 checks passed
@drappier-charles
drappier-charles deleted the cdrappier/devin/sandbox-fork-snapshot-helpers branch July 24, 2026 06:15

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +839 to +945
@pytest.mark.asyncio
async def test_fork_defaults_to_sandbox_target():
sandbox = sandbox_instance("my-sandbox")

with patch(
"blaxel.core.sandbox.default.sandbox.fork_sandbox", new_callable=AsyncMock
) as mock_fork:
mock_fork.return_value = MagicMock(name="my-sandbox-copy", type="sandbox")

await sandbox.fork("my-sandbox-copy")

assert mock_fork.call_args.args[0] == "my-sandbox"
body = mock_fork.call_args.kwargs["body"]
assert body.target_name == "my-sandbox-copy"
assert body.target_type == "sandbox"


@pytest.mark.asyncio
async def test_fork_forwards_application_options_and_snapshot():
sandbox = sandbox_instance("my-sandbox")

with patch(
"blaxel.core.sandbox.default.sandbox.fork_sandbox", new_callable=AsyncMock
) as mock_fork:
mock_fork.return_value = MagicMock()

await sandbox.fork(
"my-app",
target_type="application",
traffic=100,
port=8080,
custom_domain="app.example.com",
snapshot_id="snap_abc123",
)

body = mock_fork.call_args.kwargs["body"]
assert body.target_name == "my-app"
assert body.target_type == "application"
assert body.traffic == 100
assert body.port == 8080
assert body.custom_domain == "app.example.com"
assert body.snapshot_id == "snap_abc123"


@pytest.mark.asyncio
async def test_snapshot_sends_optional_name():
sandbox = sandbox_instance("my-sandbox")

with patch(
"blaxel.core.sandbox.default.sandbox.create_sandbox_snapshot", new_callable=AsyncMock
) as mock_snapshot:
mock_snapshot.return_value = MagicMock()

await sandbox.snapshot("before")

assert mock_snapshot.call_args.args[0] == "my-sandbox"
assert mock_snapshot.call_args.kwargs["body"].name == "before"


@pytest.mark.asyncio
async def test_delete_snapshot_accepts_none_204_response():
sandbox = sandbox_instance("my-sandbox")

with patch(
"blaxel.core.sandbox.default.sandbox.delete_sandbox_snapshot", new_callable=AsyncMock
) as mock_delete:
# Generated client returns None for a successful 204 No Content.
mock_delete.return_value = None

await sandbox.delete_snapshot("snap_abc123")

assert mock_delete.call_args.args == ("my-sandbox", "snap_abc123")


@pytest.mark.asyncio
async def test_fork_raises_on_error_response():
from blaxel.core.client.models.error import Error

sandbox = sandbox_instance("my-sandbox")

with patch(
"blaxel.core.sandbox.default.sandbox.fork_sandbox", new_callable=AsyncMock
) as mock_fork:
mock_fork.return_value = Error(error="boom", code=400)

with pytest.raises(SandboxAPIError):
await sandbox.fork("my-sandbox-copy")


def test_sync_fork_and_snapshot_helpers():
sandbox = sandbox_instance("my-sandbox", cls=SyncSandboxInstance)

with (
patch("blaxel.core.sandbox.sync.sandbox.fork_sandbox") as mock_fork,
patch("blaxel.core.sandbox.sync.sandbox.create_sandbox_snapshot") as mock_snapshot,
):
mock_fork.return_value = MagicMock()
mock_snapshot.return_value = MagicMock()

sandbox.fork("my-sandbox-copy", snapshot_id="snap_abc123")
sandbox.snapshot("before")

fork_body = mock_fork.call_args.kwargs["body"]
assert fork_body.target_name == "my-sandbox-copy"
assert fork_body.target_type == "sandbox"
assert fork_body.snapshot_id == "snap_abc123"
assert mock_snapshot.call_args.kwargs["body"].name == "before"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New sandbox fork/snapshot feature ships without required integration tests

The new fork/snapshot capability is added with only mocked unit tests (tests/core/test_sandbox.py:839-945) and no integration test exercising it against the real API, which AGENTS.md requires for every feature.
Impact: The feature is merged without any real-API test coverage, so real endpoint regressions can go undetected.

Why this violates the repository rule

AGENTS.md states under Testing: "Every feature must ship with an integration test exercising it against the real API". Existing sandbox features each have an integration test under tests/integration/core/sandbox/ (e.g. test_sandbox_crud.py, test_lifecycle.py). The new fork(), snapshot(), list_snapshots(), and delete_snapshot() methods added in src/blaxel/core/sandbox/default/sandbox.py and src/blaxel/core/sandbox/sync/sandbox.py only receive mocked unit tests in tests/core/test_sandbox.py; a grep of tests/integration/core/sandbox/ finds no fork/snapshot integration test (the only matches concern the unrelated snapshot_enabled config).

Prompt for agents
AGENTS.md requires that every feature ship with an integration test exercising it against the real API, and existing sandbox features each have one under tests/integration/core/sandbox/. This PR adds fork()/snapshot()/list_snapshots()/delete_snapshot() to both SandboxInstance and SyncSandboxInstance but only adds mocked unit tests in tests/core/test_sandbox.py. Add an integration test (e.g. tests/integration/core/sandbox/test_fork_snapshot.py) that creates a sandbox, snapshots it, lists snapshots, forks it, and deletes the snapshot against the real API, using helpers from tests/helpers.py (unique_name, default_labels, default_image) and cleaning up resources in a class-level cleanup fixture. If the endpoint is WIP/slow, gate it behind a dedicated environment variable as described in AGENTS.md so it is skipped by default.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

SystemSculpt added a commit that referenced this pull request Jul 28, 2026
…G-4244) (#206)

* fix(sandbox): propagate control-plane errors from update_*/delete (ENG-4244)

Generated control-plane API functions return `Union[Error, T] | None`, so an
error status is a return value rather than an exception. The sandbox update_*
and delete helpers passed that response straight into `cls(response)`, which
does not type-check, so a 403/404/500 produced an instance wrapping an `Error`.

Callers that ignore the return value -- the common shape -- saw a failed write
as a success: the TTL was never applied, or the sandbox was never deleted and
kept billing. Callers that used the result hit `AttributeError: 'Error' object
has no attribute 'metadata'` far from the real cause.

Route the responses through `_unwrap_response()`, already present in the same
module and already imported by the sync tree; it was added in #201 for the
fork/snapshot helpers and never applied to the update/delete paths. Volume
delete had the identical gap and uses that file's existing guard shape.

Reported by Mathis Joffre. TypeScript (`throwOnError: true`) and Go are already
correct; Python was the only affected SDK.

Note: delete on a 404 now raises `SandboxAPIError` instead of silently
returning an `Error`, and the empty case raises `SandboxAPIError` instead of
`ValueError`.

* fix(volume): raise on empty delete responses too

Mendral review on #206: the volume delete helpers guarded Error but not
None, while the sandbox path routes through _unwrap_response, which raises
on both. delete_volume returns Union[Error, Volume] | None, so a None
response returned silently despite the -> Volume annotation -- the same
silent-failure class this PR removes for sandboxes.

* test(lifecycle): stop asserting a lifecycle change that never applied

test_update_lifecycle_with_different_policy_types_preserves_files built a
lifecycle with ttl-idle listed twice. The control plane rejects duplicate
policy types with a 400, and update_lifecycle used to swallow that 400, so
the update silently never applied. The test then asserted the file survived
a change that had not happened, and passed for the wrong reason.

Now that update_lifecycle raises, the 400 surfaces and the copy-paste
duplicate is visible. Drop the duplicate entry so the test exercises the
policy-type change it claims to.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant