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
24 changes: 16 additions & 8 deletions src/runloop_api_client/resources/devboxes/devboxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,7 @@ def execute(
id: str,
*,
command: str,
command_id: str = str(uuid7()),
command_id: str | None = None,
last_n: str | Omit = omit,
optimistic_timeout: Optional[int] | Omit = omit,
shell_name: Optional[str] | Omit = omit,
Expand All @@ -892,7 +892,8 @@ def execute(
specified the command is run from the directory based on the recent state of the
persistent shell.

command_id: The command ID in UUIDv7 string format for idempotency and tracking
command_id: The command ID in UUIDv7 string format for idempotency and tracking.
A fresh UUID is generated per call if not provided.

last_n: Last n lines of standard error / standard out to return (default: 100)

Expand All @@ -915,6 +916,8 @@ def execute(
"""
if not id:
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
if command_id is None:
command_id = str(uuid7())
if not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT:
timeout = 600
return self._post(
Expand Down Expand Up @@ -944,7 +947,7 @@ def execute_and_await_completion(
devbox_id: str,
*,
command: str,
command_id: str = str(uuid7()),
command_id: str | None = None,
last_n: str | Omit = omit,
optimistic_timeout: Optional[int] | Omit = omit,
shell_name: Optional[str] | Omit = omit,
Expand All @@ -963,9 +966,11 @@ def execute_and_await_completion(
return the result within the initial request's timeout. If the execution is not yet
complete, it switches to using wait_for_command to minimize latency while waiting.

A command_id (UUIDv7) is automatically generated for idempotency and tracking.
A command_id (UUIDv7) is automatically generated per call for idempotency and tracking.
You can provide your own command_id to enable custom retry logic or external tracking.
"""
if command_id is None:
command_id = str(uuid7())
execution = self.execute(
devbox_id,
command=command,
Expand Down Expand Up @@ -2543,7 +2548,7 @@ async def execute(
id: str,
*,
command: str,
command_id: str = str(uuid7()),
command_id: str | None = None,
last_n: str | Omit = omit,
optimistic_timeout: Optional[int] | Omit = omit,
shell_name: Optional[str] | Omit = omit,
Expand All @@ -2568,7 +2573,8 @@ async def execute(
specified the command is run from the directory based on the recent state of the
persistent shell.

command_id: The command ID in UUIDv7 string format for idempotency and tracking
command_id: The command ID in UUIDv7 string format for idempotency and tracking.
A fresh UUID is generated per call if not provided.

last_n: Last n lines of standard error / standard out to return (default: 100)

Expand All @@ -2591,6 +2597,8 @@ async def execute(
"""
if not id:
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
if command_id is None:
command_id = str(uuid7())
if not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT:
timeout = 600
return await self._post(
Expand Down Expand Up @@ -2620,7 +2628,7 @@ async def execute_and_await_completion(
devbox_id: str,
*,
command: str,
command_id: str = str(uuid7()),
command_id: str | None = None,
last_n: str | Omit = omit,
optimistic_timeout: Optional[int] | Omit = omit,
shell_name: Optional[str] | Omit = omit,
Expand All @@ -2639,7 +2647,7 @@ async def execute_and_await_completion(
return the result within the initial request's timeout. If the execution is not yet
complete, it switches to using wait_for_command to minimize latency while waiting.

A command_id (UUIDv7) is automatically generated for idempotency and tracking.
A command_id (UUIDv7) is automatically generated per call for idempotency and tracking.
You can provide your own command_id to enable custom retry logic or external tracking.
"""

Expand Down
94 changes: 94 additions & 0 deletions tests/test_command_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Tests for command_id default generation in execute / execute_and_await_completion.

Verifies that each call generates a fresh UUIDv7 rather than reusing a frozen
default (the bug fixed in this change).
"""

from __future__ import annotations

import json
from typing import cast

import httpx
import pytest
from respx import Route, MockRouter

from runloop_api_client import Runloop, AsyncRunloop

base_url = "http://127.0.0.1:4010"
EXECUTE_PATH = "/v1/devboxes/dbx_test/execute"

STUB_RESPONSE = {
"execution_id": "exec_1",
"command_id": "ignored",
"devbox_id": "dbx_test",
"status": "completed",
"exit_status": 0,
"stdout": "",
"stderr": "",
}


def _get_command_ids(route: Route) -> list[str]:
return [
json.loads(cast(bytes, call.request.content))["command_id"] # type: ignore[union-attr]
for call in route.calls # type: ignore[union-attr]
]


def _get_request_body(route: Route, index: int = 0) -> dict[str, object]:
return json.loads(cast(bytes, route.calls[index].request.content)) # type: ignore[union-attr]


class TestCommandIdGeneration:
"""command_id must be a fresh UUIDv7 per call when not explicitly provided."""

@pytest.mark.respx(base_url=base_url)
def test_execute_generates_unique_command_ids(self, respx_mock: MockRouter) -> None:
route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
client = Runloop(base_url=base_url, bearer_token="test")

for _ in range(5):
client.devboxes.execute(id="dbx_test", command="echo hi")

assert route.call_count == 5
ids = _get_command_ids(route)
assert len(set(ids)) == 5, f"command_ids should all be unique, got: {ids}"

@pytest.mark.respx(base_url=base_url)
def test_execute_preserves_explicit_command_id(self, respx_mock: MockRouter) -> None:
route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
client = Runloop(base_url=base_url, bearer_token="test")

client.devboxes.execute(id="dbx_test", command="echo hi", command_id="my-custom-id")

body = _get_request_body(route)
assert body["command_id"] == "my-custom-id"


class TestAsyncCommandIdGeneration:
"""Async variant: command_id must be a fresh UUIDv7 per call when not explicitly provided."""

@pytest.mark.respx(base_url=base_url)
@pytest.mark.asyncio
async def test_execute_generates_unique_command_ids(self, respx_mock: MockRouter) -> None:
route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
client = AsyncRunloop(base_url=base_url, bearer_token="test")

for _ in range(5):
await client.devboxes.execute(id="dbx_test", command="echo hi")

assert route.call_count == 5
ids = _get_command_ids(route)
assert len(set(ids)) == 5, f"command_ids should all be unique, got: {ids}"

@pytest.mark.respx(base_url=base_url)
@pytest.mark.asyncio
async def test_execute_preserves_explicit_command_id(self, respx_mock: MockRouter) -> None:
route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
client = AsyncRunloop(base_url=base_url, bearer_token="test")

await client.devboxes.execute(id="dbx_test", command="echo hi", command_id="my-custom-id")

body = _get_request_body(route)
assert body["command_id"] == "my-custom-id"