diff --git a/src/runloop_api_client/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py index de809b855..dbd02be20 100644 --- a/src/runloop_api_client/resources/devboxes/devboxes.py +++ b/src/runloop_api_client/resources/devboxes/devboxes.py @@ -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, @@ -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) @@ -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( @@ -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, @@ -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, @@ -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, @@ -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) @@ -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( @@ -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, @@ -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. """ diff --git a/tests/test_command_id.py b/tests/test_command_id.py new file mode 100644 index 000000000..92089d28a --- /dev/null +++ b/tests/test_command_id.py @@ -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"