diff --git a/CHANGELOG.md b/CHANGELOG.md index 12639a7..b56a94e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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()`. @@ -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 diff --git a/README.md b/README.md index f050462..4d623ba 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/checkpoints/README.md b/examples/checkpoints/README.md new file mode 100644 index 0000000..51c5f87 --- /dev/null +++ b/examples/checkpoints/README.md @@ -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. diff --git a/examples/checkpoints/main.py b/examples/checkpoints/main.py new file mode 100644 index 0000000..9c29a72 --- /dev/null +++ b/examples/checkpoints/main.py @@ -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() diff --git a/src/createos/__init__.py b/src/createos/__init__.py index 8c86009..132979c 100644 --- a/src/createos/__init__.py +++ b/src/createos/__init__.py @@ -14,6 +14,8 @@ from .models import ( AttachDiskOptions, BandwidthView, + Checkpoint, + CheckpointRestoreResponse, CommandResult, CommandStreamEvent, ComputerButtonRequest, @@ -39,6 +41,7 @@ ComputerWindowGeometry, ComputerWindowMoveRequest, ComputerWindowResizeRequest, + CreateCheckpointRequest, CreateSandboxRequest, CreateSandboxResponse, CreateSnapshotRequest, diff --git a/src/createos/_version.py b/src/createos/_version.py index ae73625..bbab024 100644 --- a/src/createos/_version.py +++ b/src/createos/_version.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/src/createos/instance.py b/src/createos/instance.py index 6cb873a..08542ce 100644 --- a/src/createos/instance.py +++ b/src/createos/instance.py @@ -33,6 +33,7 @@ WaitOptions, ) from .services import ( + CheckpointsService, ComputerService, FilesService, ProcessesService, @@ -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: diff --git a/src/createos/models.py b/src/createos/models.py index 851f296..7c190c0 100644 --- a/src/createos/models.py +++ b/src/createos/models.py @@ -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) @@ -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.""" diff --git a/src/createos/services.py b/src/createos/services.py index 74fc0db..172efd3 100644 --- a/src/createos/services.py +++ b/src/createos/services.py @@ -9,6 +9,8 @@ from ._streams import BinaryStream, ProcessStream, TemplateLogStream from .errors import ProtocolError from .models import ( + Checkpoint, + CheckpointRestoreResponse, ComputerButtonRequest, ComputerClickRequest, ComputerClipboard, @@ -30,6 +32,7 @@ ComputerWindowGeometry, ComputerWindowMoveRequest, ComputerWindowResizeRequest, + CreateCheckpointRequest, Disk, DiskCreateRequest, DiskCredentials, @@ -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.""" diff --git a/tests/test_sdk.py b/tests/test_sdk.py index a52274a..073406f 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -10,6 +10,7 @@ AuthenticationError, Client, CommandStreamEvent, + CreateCheckpointRequest, CreateSandboxRequest, CreateSnapshotRequest, ExecStreamEventType, @@ -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")