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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Versions follow [Semantic Versioning](https://semver.org/).

- Reusable disk and full snapshot create, get, list, and delete operations,
plus sandbox restoration through `CreateSandboxRequest.snapshot_id`.
- Checkpoint create, list, restore, and delete operations scoped to
a running sandbox through `SandboxInstance.checkpoints`.
- Sandbox labels on create and response models, AND-matched label filtering in
`list_sandboxes()`, and label merging through `SandboxInstance.set_labels()`.

Expand All @@ -31,7 +33,7 @@ Versions follow [Semantic Versioning](https://semver.org/).

### Changed

- Set the next package version to `0.1.3` on the patch release line.
- Set the next package version to `0.1.4` on the patch release line.

### Added

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,28 @@ must be restored with their captured CPU, memory, and disk sizes. Snapshot
capture requires a running source sandbox with no attached external disks.
Each account may retain up to five snapshots across both types.

## Checkpoint a directory

Checkpoints keep local file history inside one running sandbox. Stop
writers before create or restore; processes are not paused automatically.

```python
from createos import CreateCheckpointRequest

checkpoint = sandbox.checkpoints.create(
CreateCheckpointRequest(
path="/workspace/project", name="before-update"
)
)
checkpoints = sandbox.checkpoints.list()
restored = sandbox.checkpoints.restore(checkpoint.id)
sandbox.checkpoints.delete(checkpoint.id)
```

Restore always uses the checkpoint's original path. Checkpoints remain on the
sandbox disk and disappear with it. See the
[runnable example](examples/checkpoints/).

## Delegate access to one sandbox

The owner can create one token for a sandbox. Creation and rotation return the
Expand Down
12 changes: 12 additions & 0 deletions examples/checkpoints/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Checkpoints

Capture a directory, modify it, restore its earlier contents, and remove the
checkpoint.

```sh
export CREATEOS_API_KEY="your-api-key"
python examples/checkpoints/main.py
```

The example creates live resources and cleans them up when it finishes. Stop
all writers before creating or restoring a checkpoint.
54 changes: 54 additions & 0 deletions examples/checkpoints/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Capture and restore local directory history inside one sandbox."""

import os

from createos import (
Client,
CreateCheckpointRequest,
CreateSandboxRequest,
RunCommandRequest,
)

CHECKPOINT_PATH = "/workspace/checkpoint-demo"


def command(sandbox, script: str):
"""Run a shell command inside the sandbox."""
return sandbox.run_command(
RunCommandRequest(command="sh", arguments=["-c", script])
)


def main() -> None:
"""Create, checkpoint, restore, and clean up live resources."""
with Client(api_key=os.environ["CREATEOS_API_KEY"]) as client:
sandbox = client.create_sandbox(
CreateSandboxRequest(shape="s-1vcpu-1gb", rootfs="devbox:1")
)
checkpoint = None
try:
command(
sandbox,
f"mkdir -p {CHECKPOINT_PATH} && "
f"printf 'version one' > {CHECKPOINT_PATH}/value",
)
checkpoint = sandbox.checkpoints.create(
CreateCheckpointRequest(
path=CHECKPOINT_PATH, name="before-update"
)
)
command(
sandbox,
f"printf 'version two' > {CHECKPOINT_PATH}/value",
)
sandbox.checkpoints.restore(checkpoint.id)
result = command(sandbox, f"cat {CHECKPOINT_PATH}/value")
print(f"restored {checkpoint.id}: {result.result.standard_output}")
finally:
if checkpoint is not None:
sandbox.checkpoints.delete(checkpoint.id)
sandbox.destroy()


if __name__ == "__main__":
main()
3 changes: 3 additions & 0 deletions src/createos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from .models import (
AttachDiskOptions,
BandwidthView,
Checkpoint,
CheckpointRestoreResponse,
CommandResult,
CommandStreamEvent,
ComputerButtonRequest,
Expand All @@ -39,6 +41,7 @@
ComputerWindowGeometry,
ComputerWindowMoveRequest,
ComputerWindowResizeRequest,
CreateCheckpointRequest,
CreateSandboxRequest,
CreateSandboxResponse,
CreateSnapshotRequest,
Expand Down
2 changes: 1 addition & 1 deletion src/createos/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.3"
__version__ = "0.1.4"
2 changes: 2 additions & 0 deletions src/createos/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
WaitOptions,
)
from .services import (
CheckpointsService,
ComputerService,
FilesService,
ProcessesService,
Expand All @@ -52,6 +53,7 @@ def __init__(self, transport, data: Sandbox) -> None:
self.files = FilesService(self)
self.processes = ProcessesService(self)
self.computer = ComputerService(self)
self.checkpoints = CheckpointsService(self)

@property
def id(self) -> str:
Expand Down
27 changes: 27 additions & 0 deletions src/createos/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,14 @@ class CreateSnapshotRequest(Model):
name: str = _json("name", omit_default=True, default="")


@dataclass(slots=True)
class CreateCheckpointRequest(Model):
"""Select a directory whose local file history should be captured."""

path: str
name: str | None = None


@dataclass(slots=True)
class ForkSandboxRequest(Model):
start_paused: bool = _json("start_paused", omit_default=True, default=False)
Expand Down Expand Up @@ -637,6 +645,25 @@ class SnapshotDeletedResponse(Model):
status: SnapshotStatus


@dataclass(slots=True)
class Checkpoint(Model):
"""Local file history retained inside one sandbox."""

id: str
path: str
created_at: datetime
name: str | None = None
parent_id: str | None = None


@dataclass(slots=True)
class CheckpointRestoreResponse(Model):
"""Identifies the restored checkpoint and its original directory path."""

checkpoint_id: str
path: str


@dataclass(slots=True)
class SandboxAccessTokenCreateResponse(Model):
"""Plaintext delegated token returned only on creation or rotation."""
Expand Down
43 changes: 43 additions & 0 deletions src/createos/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from ._streams import BinaryStream, ProcessStream, TemplateLogStream
from .errors import ProtocolError
from .models import (
Checkpoint,
CheckpointRestoreResponse,
ComputerButtonRequest,
ComputerClickRequest,
ComputerClipboard,
Expand All @@ -30,6 +32,7 @@
ComputerWindowGeometry,
ComputerWindowMoveRequest,
ComputerWindowResizeRequest,
CreateCheckpointRequest,
Disk,
DiskCreateRequest,
DiskCredentials,
Expand Down Expand Up @@ -370,6 +373,46 @@ def download(
return BinaryStream(response)


class CheckpointsService:
"""Manage local checkpoints inside one running sandbox."""

def __init__(self, instance: SandboxInstance) -> None:
self._instance = instance

def _path(self, checkpoint_id: str = "", suffix: str = "") -> str:
path = self._instance._path("/checkpoints")
if checkpoint_id:
path += f"/{_segment(checkpoint_id)}"
return path + suffix

def create(self, request: CreateCheckpointRequest) -> Checkpoint:
"""Capture an eligible absolute directory."""
return _one(
Checkpoint,
self._instance._transport.request(
"POST", self._path(), body=request
),
)

def list(self) -> list[Checkpoint]:
"""Return checkpoints oldest first."""
data = self._instance._transport.request("GET", self._path())
return _many(Checkpoint, data)

def restore(self, checkpoint_id: str) -> CheckpointRestoreResponse:
"""Restore a checkpoint at its original directory path."""
return _one(
CheckpointRestoreResponse,
self._instance._transport.request(
"POST", self._path(checkpoint_id, "/restore")
),
)

def delete(self, checkpoint_id: str) -> None:
"""Delete a checkpoint record while retaining referenced layers."""
self._instance._transport.request("DELETE", self._path(checkpoint_id))


class ProcessesService:
"""Manage persistent processes and PTYs in one sandbox."""

Expand Down
61 changes: 61 additions & 0 deletions tests/test_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
AuthenticationError,
Client,
CommandStreamEvent,
CreateCheckpointRequest,
CreateSandboxRequest,
CreateSnapshotRequest,
ExecStreamEventType,
Expand Down Expand Up @@ -558,3 +559,63 @@ def handler(request):
download_timeout = requests[2].extensions["timeout"]
assert set(upload_timeout.values()) == {300}
assert set(download_timeout.values()) == {600}


def test_directory_checkpoint_lifecycle_and_encoded_ids():
requests = []
responses = [
envelope({"id": "sb-1", "status": "running"}),
envelope(
{
"id": "cp_first",
"path": "/workspace/app",
"name": "before-update",
"created_at": "2026-11-05T12:00:00Z",
}
),
envelope(
[
{
"id": "cp_first",
"path": "/workspace/app",
"created_at": "2026-11-05T12:00:00Z",
}
]
),
envelope(
{
"checkpoint_id": "cp_first/segment",
"path": "/workspace/app",
}
),
envelope({}),
]

def handler(request):
requests.append(request)
return responses.pop(0)

client = Client(
api_key="key",
base_url="https://example.test",
http_client=mock_client(handler),
)
sandbox = client.get_sandbox("sb-1")
checkpoint = sandbox.checkpoints.create(
CreateCheckpointRequest(path="/workspace/app", name="before-update")
)
checkpoints = sandbox.checkpoints.list()
restored = sandbox.checkpoints.restore("cp_first/segment")
sandbox.checkpoints.delete("cp_first/segment")

assert checkpoint.id == "cp_first" and checkpoint.created_at.year == 2026
assert len(checkpoints) == 1
assert restored.path == "/workspace/app"
assert json.loads(requests[1].content) == {
"path": "/workspace/app",
"name": "before-update",
}
assert requests[3].url.raw_path.endswith(
b"/checkpoints/cp_first%2Fsegment/restore"
)
assert requests[4].url.raw_path.endswith(b"/checkpoints/cp_first%2Fsegment")
Loading