Skip to content

Commit 303dd10

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/confined-workspace
# Conflicts: # CHANGELOG.md
2 parents fa164cf + 051e634 commit 303dd10

7 files changed

Lines changed: 296 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **`SandboxdWorkspace(session_name=...)` and `DaytonaWorkspace(sandbox_name=...)`.**
13+
The workspace is named by whoever configures it rather than by the provider: a
14+
run with no ref opens it under the name, or attaches when it exists, so an
15+
application can key a session on its own record - a conversation, a user -
16+
without storing a ref first. `DockerWorkspace(container_name=...)` did this for
17+
Docker in 0.2.31. Two clients opening one name at once end up in one session.
18+
A ref is still attach-only, so a run that knows the session existed hears it is
19+
gone instead of starting over in an empty one, and a ref naming anything else
20+
is left to another capability.
21+
1222
- **`ConfinedWorkspace`.** Pydantic AI's local workspace confines nothing, so moving from
1323
`LocalBackend(root_dir=...)` to it let the file tools - which usually run without
1424
approval - write anywhere the process can. `ConfinedWorkspace` wraps any workspace and

‎docs/concepts/daytona.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ attaches to it, starting it when Daytona stopped or archived it; one that is des
3737
gone fails with `WorkspaceUnavailableError`. `destroy(ref)` deletes it. Daytona's own
3838
auto-stop and auto-delete still apply.
3939

40+
`sandbox_name` names the sandbox instead: a run with no ref creates it under that name, or
41+
attaches to the sandbox that already has it, so an application can key a sandbox on its
42+
own record without storing a ref first. Its ref carries the name. Two clients creating
43+
the same name at once end up in one sandbox. A ref is still attach-only, so a sandbox
44+
deleted meanwhile is `WorkspaceUnavailableError` rather than a new one.
45+
46+
```python
47+
sandboxes = DaytonaWorkspace(config=config, sandbox_name=f"conv-{conversation_id}")
48+
```
49+
4050
## How a command runs
4151

4252
As a synchronous session command, the one Daytona API that reports stdout and stderr apart

‎docs/concepts/remote.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,27 @@ The service generates session ids, so they are unguessable; an application namin
337337
its own with `reuse` should derive them from a UUID it generated, never from a
338338
sequential id or user input, and keep them inside 64 characters.
339339

340+
### A session named by you
341+
342+
`SandboxdWorkspace(session_name=...)` is that from the client: a run with no
343+
ref opens the session under the name, or attaches when it is open or its files
344+
are kept, so the application can key the session on its own record - a
345+
conversation, a user - without storing a ref first:
346+
347+
```python
348+
sandbox = SandboxdWorkspace(
349+
service_url="http://sandboxd:8080",
350+
token=token,
351+
session_name=f"conv-{conversation_id}",
352+
)
353+
```
354+
355+
Two runs opening the same name at once both end up in one session: the service
356+
answers the second with `409` while the first is still opening it, and the
357+
client asks again. A ref is still attach-only, so a run continuing a
358+
conversation whose session was purged gets `WorkspaceUnavailableError` rather
359+
than an empty directory - hand it the ref when you know the session existed.
360+
340361
## Making it fast on a small host
341362

342363
Four settings, and on a 4 GB box each one is worth more than it sounds.

‎src/pydantic_ai_backends/workspaces/_daytona.py‎

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ class DaytonaWorkspaceBackend(WorkspaceBackend, SupportsCommands):
9898
env: Variables every command gets, under any a call passes.
9999
client: An `AsyncDaytona` to share, owned and closed by the caller;
100100
without one each operation opens and closes its own.
101+
sandbox_name: A sandbox name chosen by whoever configures the workspace
102+
rather than by Daytona: with no ref, the first operation creates the
103+
sandbox under it, or attaches to the one that already has it. A ref
104+
is still attach-only, so a caller that knows the sandbox existed
105+
learns that it is gone instead of starting over in a new one.
101106
"""
102107

103108
def __init__(
@@ -108,9 +113,11 @@ def __init__(
108113
ref: WorkspaceRef | None = None,
109114
env: Mapping[str, str] | None = None,
110115
client: AsyncDaytona | None = None,
116+
sandbox_name: str | None = None,
111117
) -> None:
112118
if ref is not None and ref.provider != DAYTONA_PROVIDER:
113119
raise ValueError(f"expected a {DAYTONA_PROVIDER!r} workspace ref, got {ref.provider!r}")
120+
self._sandbox_name = sandbox_name
114121
self._config = config
115122
self._create_params = create_params
116123
self._ref = ref
@@ -137,33 +144,44 @@ async def _daytona(self) -> AsyncIterator[AsyncDaytona]:
137144

138145
async def _sandbox(self, client: AsyncDaytona) -> AsyncSandbox:
139146
"""The sandbox, created or attached under the lock, with a session open in it."""
140-
daytona = load("daytona", purpose="DaytonaWorkspace")
141147
async with self._lock:
142-
if self._ref is None:
148+
if self._ref is not None:
149+
sandbox = await _attach(client, self._ref.id)
150+
elif self._sandbox_name is not None:
151+
sandbox = await self._open_named(client, self._sandbox_name)
152+
else:
143153
# Shielded so a caller cancelled mid-create still leaves the ref
144154
# of a sandbox Daytona has already made.
145155
with anyio.CancelScope(shield=True):
146156
sandbox = await client.create(self._create_params)
147157
self._ref = WorkspaceRef(provider=DAYTONA_PROVIDER, id=sandbox.id)
148-
else:
149-
try:
150-
sandbox = await client.get(self._ref.id)
151-
except daytona.DaytonaNotFoundError as error:
152-
raise WorkspaceUnavailableError(
153-
f"Daytona sandbox {self._ref.id!r} no longer exists"
154-
) from error
155-
state = _state(sandbox)
156-
if state in GONE_STATES:
157-
raise WorkspaceUnavailableError(f"Daytona sandbox {self._ref.id!r} is {state}")
158-
if state in RESUMABLE_STATES:
159-
await sandbox.start()
160158
if self._working_dir is None:
161159
self._working_dir = await sandbox.get_work_dir()
162160
if not self._session_open:
163161
await sandbox.process.create_session(self._session_id)
164162
self._session_open = True
165163
return sandbox
166164

165+
async def _open_named(self, client: AsyncDaytona, name: str) -> AsyncSandbox:
166+
"""The sandbox called `name`: the existing one, or a new one created under it."""
167+
daytona = load("daytona", purpose="DaytonaWorkspace")
168+
with anyio.CancelScope(shield=True):
169+
try:
170+
sandbox = await _attach(client, name)
171+
except WorkspaceUnavailableError as gone:
172+
if not isinstance(gone.__cause__, daytona.DaytonaNotFoundError):
173+
raise
174+
params = self._create_params or daytona.CreateSandboxFromSnapshotParams()
175+
try:
176+
sandbox = await client.create(params.model_copy(update={"name": name}))
177+
except daytona.DaytonaConflictError:
178+
# Another client created it between our lookup and our create.
179+
sandbox = await _attach(client, name)
180+
# The name, not the id: it is what a later client knows to look for,
181+
# and `get` takes either.
182+
self._ref = WorkspaceRef(provider=DAYTONA_PROVIDER, id=name)
183+
return sandbox
184+
167185
async def working_dir(self) -> str:
168186
"""The sandbox's working directory, as Daytona reports it."""
169187
async with self._daytona() as client:
@@ -264,6 +282,27 @@ async def purge(self) -> None:
264282
await client.delete(sandbox)
265283

266284

285+
async def _attach(client: AsyncDaytona, id_or_name: str) -> AsyncSandbox:
286+
"""The existing sandbox `id_or_name` names, started when it was stopped or archived.
287+
288+
Raises:
289+
WorkspaceUnavailableError: It does not exist, or is gone for good.
290+
"""
291+
daytona = load("daytona", purpose="DaytonaWorkspace")
292+
try:
293+
sandbox = await client.get(id_or_name)
294+
except daytona.DaytonaNotFoundError as error:
295+
raise WorkspaceUnavailableError(
296+
f"Daytona sandbox {id_or_name!r} no longer exists"
297+
) from error
298+
state = _state(sandbox)
299+
if state in GONE_STATES:
300+
raise WorkspaceUnavailableError(f"Daytona sandbox {id_or_name!r} is {state}")
301+
if state in RESUMABLE_STATES:
302+
await sandbox.start()
303+
return sandbox
304+
305+
267306
@dataclass(kw_only=True)
268307
class DaytonaWorkspace(AbstractCapability[object]):
269308
"""Supply a Daytona sandbox as the run's workspace.
@@ -300,6 +339,14 @@ class DaytonaWorkspace(AbstractCapability[object]):
300339
client: AsyncDaytona | None = field(default=None, repr=False, compare=False)
301340
"""An `AsyncDaytona` to share across runs, owned and closed by the caller."""
302341

342+
sandbox_name: str | None = None
343+
"""One sandbox for every run, named by you: created on first use, attached after.
344+
345+
Without it each run without a ref gets a new sandbox and only the ref leads
346+
back to it. With it a later process finds the same sandbox by name. A ref
347+
naming another sandbox is left to another capability.
348+
"""
349+
303350
def __post_init__(self) -> None:
304351
if self.defer_loading:
305352
raise UserError(
@@ -315,6 +362,7 @@ def backend(self, ref: WorkspaceRef | None = None) -> DaytonaWorkspaceBackend:
315362
ref=ref,
316363
env=self.env,
317364
client=self.client,
365+
sandbox_name=self.sandbox_name,
318366
)
319367

320368
def get_workspace(
@@ -324,6 +372,8 @@ def get_workspace(
324372
del ctx
325373
if ref is not None and ref.provider != DAYTONA_PROVIDER:
326374
return None
375+
if ref is not None and self.sandbox_name is not None and ref.id != self.sandbox_name:
376+
return None
327377
return self.backend(ref)
328378

329379
async def destroy(self, ref: WorkspaceRef) -> None:

‎src/pydantic_ai_backends/workspaces/_sandboxd.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import contextlib
6+
import re
67
import uuid
78
from collections.abc import AsyncIterator, Mapping
89
from dataclasses import dataclass, field
@@ -45,6 +46,9 @@
4546
STOP_GRACE_SECONDS = 5.0
4647
"""Longest a cancelled command's stop request may hold up the cancellation."""
4748

49+
OPENING_RETRY_SECONDS = 0.2
50+
"""Pause before asking again for a named session another client is opening."""
51+
4852
_GONE = frozenset({404, 410})
4953
"""Statuses meaning the session or its sandbox no longer exists."""
5054

@@ -84,6 +88,12 @@ class SandboxdWorkspaceBackend(WorkspaceBackend, SupportsCommands):
8488
client: An `httpx.AsyncClient` to share. Owned by the caller, who
8589
closes it; without one each request uses a client of its own.
8690
request_timeout: Seconds for a request that runs no command.
91+
session_name: A session id chosen by whoever configures the workspace
92+
rather than by the service: with no ref, the first operation opens
93+
the session under it, or attaches when it is open or its files are
94+
kept. A ref is still attach-only, so a caller that knows the
95+
session existed learns that it is gone instead of starting over in
96+
an empty one.
8797
"""
8898

8999
def __init__(
@@ -98,9 +108,14 @@ def __init__(
98108
env: Mapping[str, str] | None = None,
99109
client: httpx.AsyncClient | None = None,
100110
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
111+
session_name: str | None = None,
101112
) -> None:
102113
if ref is not None and ref.provider != provider:
103114
raise ValueError(f"expected a {provider!r} workspace ref, got {ref.provider!r}")
115+
if session_name is not None and not re.fullmatch(wire.SESSION_ID_PATTERN, session_name):
116+
raise ValueError(
117+
f"session_name {session_name!r} is not a session id the service accepts"
118+
)
104119
self._service_url = service_url.rstrip("/")
105120
self._token = token
106121
self._ref = ref
@@ -110,6 +125,7 @@ def __init__(
110125
self._env = dict(env) if env else None
111126
self._client = client
112127
self._request_timeout = request_timeout
128+
self._session_name = session_name
113129
self._session: _Session | None = None
114130
self._working_dir: str | None = None
115131
self._lock = anyio.Lock()
@@ -145,15 +161,26 @@ async def _post(
145161

146162
async def _open(self) -> _Session:
147163
"""Open or attach to the session, then learn the service's command ceiling."""
164+
named = self._ref is None and self._session_name is not None
148165
request = wire.CreateSessionRequest(
149-
session_id=None if self._ref is None else self._ref.id,
166+
session_id=self._session_name if self._ref is None else self._ref.id,
150167
runtime=self._runtime,
151168
tenant=self._tenant,
169+
reuse=named,
152170
attach=self._ref is not None,
153171
)
154172
response = await self._post(
155173
"/sessions", request, token=self._token, timeout=self._request_timeout
156174
)
175+
# With `reuse` a 409 means another client is opening the same name right
176+
# now - two first runs of one conversation, say. It is theirs a moment
177+
# later, so ask again rather than fail one of them.
178+
give_up = anyio.current_time() + self._request_timeout
179+
while named and response.status_code == 409 and anyio.current_time() < give_up:
180+
await anyio.sleep(OPENING_RETRY_SECONDS)
181+
response = await self._post(
182+
"/sessions", request, token=self._token, timeout=self._request_timeout
183+
)
157184
if self._ref is not None and response.status_code == 404:
158185
raise WorkspaceUnavailableError(f"sandboxd session {self._ref.id!r} no longer exists")
159186
response.raise_for_status()
@@ -350,6 +377,15 @@ class SandboxdWorkspace(AbstractCapability[object]):
350377
client: httpx.AsyncClient | None = field(default=None, repr=False, compare=False)
351378
"""An `httpx.AsyncClient` to share across runs, owned and closed by the caller."""
352379

380+
session_name: str | None = None
381+
"""One session for every run, named by you: opened on first use, attached after.
382+
383+
Without it each run without a ref gets a new session and only the ref leads
384+
back to it. With it a later process finds the same session - or the files the
385+
service kept after reaping it - by name. A ref naming another session is left
386+
to another capability.
387+
"""
388+
353389
def __post_init__(self) -> None:
354390
if self.defer_loading:
355391
raise UserError(
@@ -368,6 +404,7 @@ def backend(self, ref: WorkspaceRef | None = None) -> SandboxdWorkspaceBackend:
368404
tenant=self.tenant,
369405
env=self.env,
370406
client=self.client,
407+
session_name=self.session_name,
371408
)
372409

373410
def get_workspace(
@@ -377,6 +414,8 @@ def get_workspace(
377414
del ctx
378415
if ref is not None and ref.provider != self.provider:
379416
return None
417+
if ref is not None and self.session_name is not None and ref.id != self.session_name:
418+
return None
380419
return self.backend(ref)
381420

382421
async def destroy(self, ref: WorkspaceRef) -> None:

0 commit comments

Comments
 (0)