@@ -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 )
268307class 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 :
0 commit comments