@veris-ai/daytona-opencode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.opencode/plugin/daytona/core/logger.d.ts +15 -0
  2. package/.opencode/plugin/daytona/core/logger.js +65 -0
  3. package/.opencode/plugin/daytona/core/logger.js.map +1 -0
  4. package/.opencode/plugin/daytona/core/logger.ts +71 -0
  5. package/.opencode/plugin/daytona/core/project-data-storage.d.ts +63 -0
  6. package/.opencode/plugin/daytona/core/project-data-storage.js +213 -0
  7. package/.opencode/plugin/daytona/core/project-data-storage.js.map +1 -0
  8. package/.opencode/plugin/daytona/core/project-data-storage.ts +234 -0
  9. package/.opencode/plugin/daytona/core/session-manager.d.ts +74 -0
  10. package/.opencode/plugin/daytona/core/session-manager.js +441 -0
  11. package/.opencode/plugin/daytona/core/session-manager.js.map +1 -0
  12. package/.opencode/plugin/daytona/core/session-manager.ts +471 -0
  13. package/.opencode/plugin/daytona/core/toast.d.ts +47 -0
  14. package/.opencode/plugin/daytona/core/toast.js +70 -0
  15. package/.opencode/plugin/daytona/core/toast.js.map +1 -0
  16. package/.opencode/plugin/daytona/core/toast.ts +96 -0
  17. package/.opencode/plugin/daytona/core/types.d.ts +53 -0
  18. package/.opencode/plugin/daytona/core/types.js +12 -0
  19. package/.opencode/plugin/daytona/core/types.js.map +1 -0
  20. package/.opencode/plugin/daytona/core/types.ts +72 -0
  21. package/.opencode/plugin/daytona/git/host-git-manager.d.ts +58 -0
  22. package/.opencode/plugin/daytona/git/host-git-manager.js +342 -0
  23. package/.opencode/plugin/daytona/git/host-git-manager.js.map +1 -0
  24. package/.opencode/plugin/daytona/git/host-git-manager.ts +372 -0
  25. package/.opencode/plugin/daytona/git/index.d.ts +5 -0
  26. package/.opencode/plugin/daytona/git/index.js +6 -0
  27. package/.opencode/plugin/daytona/git/index.js.map +1 -0
  28. package/.opencode/plugin/daytona/git/index.ts +6 -0
  29. package/.opencode/plugin/daytona/git/sandbox-git-manager.d.ts +22 -0
  30. package/.opencode/plugin/daytona/git/sandbox-git-manager.js +80 -0
  31. package/.opencode/plugin/daytona/git/sandbox-git-manager.js.map +1 -0
  32. package/.opencode/plugin/daytona/git/sandbox-git-manager.ts +92 -0
  33. package/.opencode/plugin/daytona/git/session-git-manager.d.ts +54 -0
  34. package/.opencode/plugin/daytona/git/session-git-manager.js +184 -0
  35. package/.opencode/plugin/daytona/git/session-git-manager.js.map +1 -0
  36. package/.opencode/plugin/daytona/git/session-git-manager.ts +203 -0
  37. package/.opencode/plugin/daytona/index.d.ts +125 -0
  38. package/.opencode/plugin/daytona/index.js +60 -0
  39. package/.opencode/plugin/daytona/index.js.map +1 -0
  40. package/.opencode/plugin/daytona/index.ts +73 -0
  41. package/.opencode/plugin/daytona/plugins/custom-tools.d.ts +122 -0
  42. package/.opencode/plugin/daytona/plugins/custom-tools.js +18 -0
  43. package/.opencode/plugin/daytona/plugins/custom-tools.js.map +1 -0
  44. package/.opencode/plugin/daytona/plugins/custom-tools.ts +21 -0
  45. package/.opencode/plugin/daytona/plugins/session-events.d.ts +10 -0
  46. package/.opencode/plugin/daytona/plugins/session-events.js +63 -0
  47. package/.opencode/plugin/daytona/plugins/session-events.js.map +1 -0
  48. package/.opencode/plugin/daytona/plugins/session-events.ts +61 -0
  49. package/.opencode/plugin/daytona/plugins/system-transform.d.ts +10 -0
  50. package/.opencode/plugin/daytona/plugins/system-transform.js +38 -0
  51. package/.opencode/plugin/daytona/plugins/system-transform.js.map +1 -0
  52. package/.opencode/plugin/daytona/plugins/system-transform.ts +43 -0
  53. package/.opencode/plugin/daytona/tools/bash.d.ts +19 -0
  54. package/.opencode/plugin/daytona/tools/bash.js +42 -0
  55. package/.opencode/plugin/daytona/tools/bash.js.map +1 -0
  56. package/.opencode/plugin/daytona/tools/bash.ts +51 -0
  57. package/.opencode/plugin/daytona/tools/edit.d.ts +21 -0
  58. package/.opencode/plugin/daytona/tools/edit.js +33 -0
  59. package/.opencode/plugin/daytona/tools/edit.js.map +1 -0
  60. package/.opencode/plugin/daytona/tools/edit.ts +44 -0
  61. package/.opencode/plugin/daytona/tools/get-preview-url.d.ts +17 -0
  62. package/.opencode/plugin/daytona/tools/get-preview-url.js +17 -0
  63. package/.opencode/plugin/daytona/tools/get-preview-url.js.map +1 -0
  64. package/.opencode/plugin/daytona/tools/get-preview-url.ts +26 -0
  65. package/.opencode/plugin/daytona/tools/git-sync.d.ts +12 -0
  66. package/.opencode/plugin/daytona/tools/git-sync.js +26 -0
  67. package/.opencode/plugin/daytona/tools/git-sync.js.map +1 -0
  68. package/.opencode/plugin/daytona/tools/git-sync.ts +36 -0
  69. package/.opencode/plugin/daytona/tools/glob.d.ts +17 -0
  70. package/.opencode/plugin/daytona/tools/glob.js +21 -0
  71. package/.opencode/plugin/daytona/tools/glob.js.map +1 -0
  72. package/.opencode/plugin/daytona/tools/glob.ts +30 -0
  73. package/.opencode/plugin/daytona/tools/grep.d.ts +17 -0
  74. package/.opencode/plugin/daytona/tools/grep.js +29 -0
  75. package/.opencode/plugin/daytona/tools/grep.js.map +1 -0
  76. package/.opencode/plugin/daytona/tools/grep.ts +39 -0
  77. package/.opencode/plugin/daytona/tools/ls.d.ts +17 -0
  78. package/.opencode/plugin/daytona/tools/ls.js +22 -0
  79. package/.opencode/plugin/daytona/tools/ls.js.map +1 -0
  80. package/.opencode/plugin/daytona/tools/ls.ts +32 -0
  81. package/.opencode/plugin/daytona/tools/multiedit.d.ts +25 -0
  82. package/.opencode/plugin/daytona/tools/multiedit.js +37 -0
  83. package/.opencode/plugin/daytona/tools/multiedit.js.map +1 -0
  84. package/.opencode/plugin/daytona/tools/multiedit.ts +54 -0
  85. package/.opencode/plugin/daytona/tools/read.d.ts +17 -0
  86. package/.opencode/plugin/daytona/tools/read.js +18 -0
  87. package/.opencode/plugin/daytona/tools/read.js.map +1 -0
  88. package/.opencode/plugin/daytona/tools/read.ts +27 -0
  89. package/.opencode/plugin/daytona/tools/veris-receipt.d.ts +26 -0
  90. package/.opencode/plugin/daytona/tools/veris-receipt.js +76 -0
  91. package/.opencode/plugin/daytona/tools/veris-receipt.js.map +1 -0
  92. package/.opencode/plugin/daytona/tools/veris-receipt.ts +103 -0
  93. package/.opencode/plugin/daytona/tools/write.d.ts +19 -0
  94. package/.opencode/plugin/daytona/tools/write.js +18 -0
  95. package/.opencode/plugin/daytona/tools/write.js.map +1 -0
  96. package/.opencode/plugin/daytona/tools/write.ts +27 -0
  97. package/.opencode/plugin/daytona/tools.d.ts +119 -0
  98. package/.opencode/plugin/daytona/tools.js +35 -0
  99. package/.opencode/plugin/daytona/tools.js.map +1 -0
  100. package/.opencode/plugin/daytona/tools.ts +45 -0
  101. package/.opencode/plugin/index.d.ts +9 -0
  102. package/.opencode/plugin/index.js +10 -0
  103. package/.opencode/plugin/index.js.map +1 -0
  104. package/.opencode/plugin/index.ts +11 -0
  105. package/README.md +87 -0
  106. package/package.json +52 -0
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Manages Daytona sandbox sessions and persists session-sandbox mappings
8
+ * Stores data per-project in ~/.local/share/opencode/storage/daytona/{projectId}.json
9
+ */
10
+
11
+ // The Veris fork's entire diff to this file is the module named below.
12
+ //
13
+ // @veris-ai/daytona re-exports all of @daytona/sdk and overrides only
14
+ // `Daytona`, so every `new Daytona({ apiKey })` in this file now returns a
15
+ // client whose create() also registers the Veris snapshot, provisions a twin,
16
+ // sets the domain allowlist, and waits for veris-proxy to bind — and whose
17
+ // get() rehydrates that surface on reconnect. The sandboxes handed back carry
18
+ // `.veris` for receipts, and their delete() removes the twin as well.
19
+ //
20
+ // DaytonaNotFoundError and DaytonaValidationError below are the SAME class
21
+ // objects @daytona/sdk exports (it is a peer dependency of @veris-ai/daytona),
22
+ // so the `instanceof` checks further down keep working — including against
23
+ // errors raised in tools/bash.ts, which still imports from '@daytona/sdk'.
24
+ import {
25
+ Daytona,
26
+ DaytonaNotFoundError,
27
+ DaytonaValidationError,
28
+ type CreateSandboxFromSnapshotParams,
29
+ type Sandbox,
30
+ } from '@veris-ai/daytona'
31
+ import { logger } from './logger'
32
+ import type { SessionSandboxMap, SandboxInfo, SessionInfo } from './types'
33
+ import { SessionGitManager } from '../git/session-git-manager'
34
+ import { DaytonaSandboxGitManager } from '../git/sandbox-git-manager'
35
+ import { ProjectDataStorage } from './project-data-storage'
36
+ import type { PluginInput } from '@opencode-ai/plugin'
37
+ import { toast } from './toast'
38
+
39
+ export class DaytonaSessionManager {
40
+ private readonly apiKey: string
41
+ private readonly dataStorage: ProjectDataStorage
42
+ private sessionSandboxes: SessionSandboxMap
43
+ // Sessions whose sandbox teardown has begun. getSandbox creates sandboxes on demand,
44
+ // so without this tombstone a sync queued behind a deletion would resurrect a fresh
45
+ // sandbox for a session that no longer exists (invisible, billed, never cleaned up).
46
+ private readonly deletingSessions = new Set<string>()
47
+ private readonly deletionPromises = new Map<string, Promise<boolean>>()
48
+ private currentProjectId?: string
49
+ public readonly repoPath: string
50
+ /** Snapshot new sandboxes are created from; undefined uses Daytona's default snapshot. */
51
+ public readonly snapshot?: string
52
+
53
+ constructor(apiKey: string, storageDir: string, repoPath: string, snapshot?: string) {
54
+ this.apiKey = apiKey
55
+ this.dataStorage = new ProjectDataStorage(storageDir)
56
+ this.repoPath = repoPath
57
+ this.snapshot = snapshot?.trim() || undefined
58
+ this.sessionSandboxes = new Map()
59
+ }
60
+
61
+ /**
62
+ * Check if a sandbox is fully initialized (has process property)
63
+ */
64
+ private isFullyInitialized(sandbox: Sandbox | SandboxInfo | undefined): sandbox is Sandbox {
65
+ return sandbox !== undefined && 'process' in sandbox
66
+ }
67
+
68
+ /**
69
+ * Check if a sandbox is partially initialized (has id but not process)
70
+ */
71
+ private isPartiallyInitialized(sandbox: Sandbox | SandboxInfo | undefined): sandbox is SandboxInfo {
72
+ return sandbox !== undefined && 'id' in sandbox && !('process' in sandbox)
73
+ }
74
+
75
+ /**
76
+ * Load sessions for a specific project into memory
77
+ */
78
+ private loadProjectSessions(projectId: string): void {
79
+ const projectData = this.dataStorage.load(projectId)
80
+ if (projectData) {
81
+ for (const [sessionId, sessionInfo] of Object.entries(projectData.sessions)) {
82
+ this.sessionSandboxes.set(sessionId, { id: sessionInfo.sandboxId })
83
+ }
84
+ logger.info(`Loaded ${Object.keys(projectData.sessions).length} sessions for project ${projectId}`)
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Set the current project context
90
+ */
91
+ setProjectContext(projectId: string): void {
92
+ if (this.currentProjectId !== projectId) {
93
+ this.currentProjectId = projectId
94
+ this.sessionSandboxes.clear()
95
+ this.loadProjectSessions(projectId)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Get branch number for a sandbox
101
+ */
102
+ getBranchNumberForSandbox(projectId: string, sandboxId: string): number | undefined {
103
+ return this.dataStorage.getBranchNumberForSandbox(projectId, sandboxId)
104
+ }
105
+
106
+ /**
107
+ * Get or create a sandbox for the given session ID
108
+ */
109
+ async getSandbox(sessionId: string, projectId: string, worktree: string, pluginCtx?: PluginInput): Promise<Sandbox> {
110
+ if (pluginCtx?.client?.tui) {
111
+ toast.initialize(pluginCtx.client.tui)
112
+ }
113
+ if (this.deletingSessions.has(sessionId)) {
114
+ throw new Error(`Session ${sessionId} is deleted; not creating a new sandbox for it.`)
115
+ }
116
+ if (!this.apiKey) {
117
+ logger.error('DAYTONA_API_KEY is not set. Cannot create or retrieve sandbox.')
118
+ toast.show({
119
+ title: 'Sandbox error',
120
+ message: 'DAYTONA_API_KEY is not set. Please set the environment variable to use Daytona sandboxes.',
121
+ variant: 'error',
122
+ })
123
+ throw new Error('DAYTONA_API_KEY is not set. Please set the environment variable to use Daytona sandboxes.')
124
+ }
125
+ // Veris coordinates get the same treatment as the Daytona key, and for the
126
+ // same reason: this is the first tool call of a session, and a legible
127
+ // message here is the difference between "set one env var" and a stack
128
+ // trace out of sandbox creation.
129
+ //
130
+ // Deliberately fatal rather than degrading to a plain Daytona sandbox. A
131
+ // sandbox without a twin reaches the REAL vendor, and an agent cannot tell
132
+ // the difference — so an unset key has to stop the session, not quietly
133
+ // change what it is doing.
134
+ for (const [name, hint] of [
135
+ ['VERIS_API_KEY', 'Get one at https://studio.veris.ai'],
136
+ ['VERIS_ENVIRONMENT_ID', 'A Veris environment decides which vendor services your twin gets'],
137
+ ] as const) {
138
+ if (process.env[name]) continue
139
+ const message = `${name} is not set, so this sandbox would reach real vendor APIs. ${hint}.`
140
+ logger.error(message)
141
+ toast.show({ title: 'Veris not configured', message, variant: 'error' })
142
+ throw new Error(message)
143
+ }
144
+
145
+ // Load project sessions if needed
146
+ this.setProjectContext(projectId)
147
+
148
+ const existing = this.sessionSandboxes.get(sessionId)
149
+
150
+ // If we have a fully initialized sandbox, reuse it
151
+ if (this.isFullyInitialized(existing)) {
152
+ // Refresh sandbox state and ensure it's running
153
+ await existing.refreshData()
154
+ if (existing.state !== 'started') {
155
+ logger.info(`Starting sandbox ${existing.id} (current state: ${existing.state})`)
156
+ await existing.start()
157
+ }
158
+ this.ensureNotDeleted(sessionId)
159
+ this.dataStorage.updateSession(projectId, worktree, sessionId, existing.id)
160
+ return existing
161
+ }
162
+
163
+ // If we have a sandboxId but not a full sandbox object, reconnect to it
164
+ if (this.isPartiallyInitialized(existing)) {
165
+ try {
166
+ logger.info(`Reconnecting to existing sandbox: ${existing.id}`)
167
+ const daytona = new Daytona({ apiKey: this.apiKey })
168
+ const reconnectStart = Date.now()
169
+ logger.info(`Daytona get begin sandboxId=${existing.id}`)
170
+ const sandbox = await daytona.get(existing.id)
171
+ logger.info(`Daytona get done sandboxId=${existing.id} in ${Date.now() - reconnectStart}ms`)
172
+ logger.info(`Starting sandbox begin sandboxId=${sandbox.id}`)
173
+ await sandbox.start()
174
+ logger.info(`Starting sandbox done sandboxId=${sandbox.id} in ${Date.now() - reconnectStart}ms`)
175
+ this.ensureNotDeleted(sessionId)
176
+ this.sessionSandboxes.set(sessionId, sandbox)
177
+ // Preserve branch number if it exists for this sandbox
178
+ let branchNumber = this.dataStorage.getBranchNumberForSandbox(projectId, sandbox.id)
179
+ if (!branchNumber) {
180
+ try {
181
+ branchNumber = SessionGitManager.allocateAndReserveBranchNumber(worktree)
182
+ } catch {
183
+ // No local git repo (or git unavailable) shouldn't block sandbox usage.
184
+ branchNumber = undefined
185
+ }
186
+ }
187
+ this.dataStorage.updateSession(projectId, worktree, sessionId, sandbox.id, branchNumber)
188
+ toast.show({
189
+ title: 'Sandbox connected',
190
+ message: `Connected to existing sandbox.`,
191
+ variant: 'info',
192
+ })
193
+
194
+ // Even if git syncing is disabled, ensure the project directory exists in the sandbox.
195
+ if (!branchNumber) {
196
+ try {
197
+ await new DaytonaSandboxGitManager(sandbox, this.repoPath).ensureDirectory()
198
+ } catch (err) {
199
+ logger.warn(`Failed to ensure sandbox project directory exists: ${err}`)
200
+ }
201
+ }
202
+
203
+ return sandbox
204
+ } catch (err) {
205
+ // Only treat 404 as "sandbox is confirmed gone" — clear the mapping and fall through
206
+ // to create a replacement. For transient errors (network, auth, timeout, provisioning),
207
+ // preserve the mapping and propagate so the caller can retry later without losing the
208
+ // session→sandbox binding and its branchNumber.
209
+ if (err instanceof DaytonaNotFoundError) {
210
+ logger.error(`Sandbox ${existing.id} no longer exists; creating a replacement.`)
211
+ this.sessionSandboxes.delete(sessionId)
212
+ this.dataStorage.removeSession(projectId, worktree, sessionId)
213
+ } else {
214
+ logger.error(`Failed to reconnect to sandbox ${existing.id}: ${err}`)
215
+ throw err
216
+ }
217
+ }
218
+ }
219
+
220
+ // If not in cache/storage for this project, try to recover from other projects and migrate.
221
+ if (!existing) {
222
+ const migrated = this.dataStorage.getSession(projectId, worktree, sessionId)
223
+ if (migrated?.sandboxId) {
224
+ logger.info(`Recovered session ${sessionId} for project ${projectId} (migrated from another project)`)
225
+ this.sessionSandboxes.set(sessionId, { id: migrated.sandboxId })
226
+ // Re-run getSandbox to go through the normal reconnect path.
227
+ return this.getSandbox(sessionId, projectId, worktree, pluginCtx)
228
+ }
229
+ }
230
+
231
+ // Otherwise, create a new sandbox
232
+ logger.info(`Creating new sandbox for session: ${sessionId} in project: ${projectId}`)
233
+ const daytona = new Daytona({ apiKey: this.apiKey })
234
+ // Omit the key entirely when unset so Daytona applies its default snapshot.
235
+ const createParams: CreateSandboxFromSnapshotParams = this.snapshot ? { snapshot: this.snapshot } : {}
236
+ const createStart = Date.now()
237
+ logger.info(`Daytona create begin sessionId=${sessionId} snapshot=${this.snapshot ?? '(default)'}`)
238
+ const waitingLog = setTimeout(() => {
239
+ logger.warn(`Daytona create still waiting after ${Date.now() - createStart}ms (sessionId=${sessionId})`)
240
+ }, 15_000)
241
+ const sandbox = await daytona
242
+ .create(createParams)
243
+ .catch((err: unknown) => {
244
+ // Only blame the snapshot when the request itself was rejected. The create
245
+ // params contain nothing but the snapshot name, so a validation error means
246
+ // Daytona refused that name (empirically: "Snapshot <name> not found" is a
247
+ // DaytonaValidationError). Auth, network, quota, and timeout failures are
248
+ // unrelated to DAYTONA_SNAPSHOT and propagate unattributed, as before.
249
+ if (this.snapshot && err instanceof DaytonaValidationError) {
250
+ logger.error(`Failed to create sandbox from snapshot '${this.snapshot}': ${err}`)
251
+ toast.show({
252
+ title: 'Sandbox error',
253
+ message: `Verify DAYTONA_SNAPSHOT names an available snapshot: ${err.message}`,
254
+ variant: 'error',
255
+ })
256
+ }
257
+ throw err
258
+ })
259
+ .finally(() => clearTimeout(waitingLog))
260
+ logger.info(`Daytona create done sessionId=${sessionId} sandboxId=${sandbox.id} in ${Date.now() - createStart}ms`)
261
+ if (this.deletingSessions.has(sessionId)) {
262
+ // The session was deleted while creation was in flight. The fresh sandbox is not
263
+ // registered anywhere, so nothing else will ever clean it up - discard it here.
264
+ logger.warn(`Session ${sessionId} was deleted during sandbox creation; discarding sandbox ${sandbox.id}`)
265
+ try {
266
+ await sandbox.delete()
267
+ } catch (err) {
268
+ logger.error(`Failed to discard sandbox ${sandbox.id} for deleted session ${sessionId}: ${err}`)
269
+ throw new Error(
270
+ `Session ${sessionId} is deleted and discarding newly created sandbox ${sandbox.id} failed; if it still exists, delete it from the Daytona dashboard.`,
271
+ )
272
+ }
273
+ throw new Error(`Session ${sessionId} is deleted; the newly created sandbox was discarded.`)
274
+ }
275
+ this.sessionSandboxes.set(sessionId, sandbox)
276
+
277
+ // Get or assign branch number for this sandbox
278
+ let branchNumber = this.dataStorage.getBranchNumberForSandbox(projectId, sandbox.id)
279
+
280
+ if (!branchNumber) {
281
+ try {
282
+ branchNumber = SessionGitManager.allocateAndReserveBranchNumber(worktree)
283
+ } catch (err: any) {
284
+ logger.warn(`allocateAndReserveBranchNumber failed sessionId=${sessionId}: ${err}`)
285
+ // No local git repo (or git unavailable) shouldn't block sandbox usage.
286
+ branchNumber = undefined
287
+ }
288
+ }
289
+
290
+ this.dataStorage.updateSession(projectId, worktree, sessionId, sandbox.id, branchNumber)
291
+ logger.info(
292
+ `Sandbox created successfully: ${sandbox.id}${branchNumber ? ` with branch number ${branchNumber}` : ''}`,
293
+ )
294
+
295
+ // Initialize git repo in the sandbox and sync with host
296
+ try {
297
+ if (branchNumber) {
298
+ const sessionGit = new SessionGitManager(sandbox, this.repoPath, worktree, branchNumber)
299
+ await sessionGit.initializeAndSync(pluginCtx)
300
+ } else {
301
+ // Git disabled; still ensure the directory exists so tools can operate.
302
+ await new DaytonaSandboxGitManager(sandbox, this.repoPath).ensureDirectory()
303
+ }
304
+ } catch (err: any) {
305
+ logger.error(`Failed to initialize git repo or push local changes in sandbox: ${err}`)
306
+ toast.show({
307
+ title: 'Git error',
308
+ message: err?.message || 'Failed to initialize git repo in sandbox.',
309
+ variant: 'error',
310
+ })
311
+ }
312
+ // Deletion may have raced the initialization awaits above; the mapping was already
313
+ // registered, so the delete flow owns (and removes) the sandbox itself - returning
314
+ // it would hand callers a destroyed sandbox that fails confusingly on first use.
315
+ this.ensureNotDeleted(sessionId)
316
+ toast.show({
317
+ title: 'Sandbox created',
318
+ message: `Created new sandbox for session.`,
319
+ variant: 'success',
320
+ })
321
+ return sandbox
322
+ }
323
+
324
+ /**
325
+ * Delete the sandbox associated with the given session ID
326
+ */
327
+ async deleteSandbox(sessionId: string, projectId: string): Promise<boolean> {
328
+ // Concurrent deletes share one promise: a second teardown racing the first would
329
+ // observe the already-deleted sandbox, throw, and wrongly clear the tombstone.
330
+ const inFlight = this.deletionPromises.get(sessionId)
331
+ if (inFlight) return inFlight
332
+
333
+ // Tombstone first, removed again on failure: while set, no code path may create or
334
+ // reconnect a sandbox for this session. Kept after success on purpose - the session
335
+ // is gone, and any late event for it must no-op instead of resurrecting a sandbox.
336
+ this.deletingSessions.add(sessionId)
337
+ const run = (async () => {
338
+ try {
339
+ return await this.deleteSandboxInner(sessionId, projectId)
340
+ } catch (err) {
341
+ this.deletingSessions.delete(sessionId)
342
+ throw err
343
+ } finally {
344
+ this.deletionPromises.delete(sessionId)
345
+ }
346
+ })()
347
+ this.deletionPromises.set(sessionId, run)
348
+ return run
349
+ }
350
+
351
+ private async deleteSandboxInner(sessionId: string, projectId: string): Promise<boolean> {
352
+ await SessionGitManager.waitForPendingSync(sessionId)
353
+
354
+ let sandbox = this.sessionSandboxes.get(sessionId)
355
+
356
+ // Read-only lookup so deleting never migrates sessions or rewrites project metadata.
357
+ const stored = this.dataStorage.findSession(sessionId)
358
+
359
+ let sandboxGone = false
360
+
361
+ // If not in cache, try to load from storage and reconnect
362
+ if (!sandbox || this.isPartiallyInitialized(sandbox)) {
363
+ if (stored?.session.sandboxId) {
364
+ const daytona = new Daytona({ apiKey: this.apiKey })
365
+ try {
366
+ sandbox = await daytona.get(stored.session.sandboxId)
367
+ this.sessionSandboxes.set(sessionId, sandbox)
368
+ } catch (err) {
369
+ if (err instanceof DaytonaNotFoundError) {
370
+ sandboxGone = true
371
+ logger.warn(`Sandbox ${stored.session.sandboxId} no longer exists; clearing stale mapping.`)
372
+ } else {
373
+ // Non-404: we cannot confirm the sandbox is gone. Surface the error so the
374
+ // event handler shows a "Delete failed" toast instead of silently reporting
375
+ // success while leaving a running sandbox on the Daytona account.
376
+ logger.error(`Failed to reconnect to sandbox ${stored.session.sandboxId}: ${err}`)
377
+ throw err
378
+ }
379
+ }
380
+ } else {
381
+ sandboxGone = true
382
+ }
383
+ }
384
+
385
+ // Delete the sandbox if we have a fully initialized one
386
+ let deleted = false
387
+ if (this.isFullyInitialized(sandbox)) {
388
+ // Final sync and deletion run as ONE queue entry, so a sync enqueued between the
389
+ // wait above and this point is drained first, and nothing can slot in between
390
+ // pulling the last changes and destroying the sandbox.
391
+ const target = sandbox
392
+ await SessionGitManager.enqueueSessionSync(sessionId, async () => {
393
+ await this.syncBeforeDelete(target, stored)
394
+ logger.info(`Removing sandbox for session: ${sessionId}`)
395
+ await target.delete()
396
+ })
397
+ deleted = true
398
+ sandboxGone = true
399
+ logger.info(`Sandbox deleted successfully.`)
400
+ } else {
401
+ logger.warn(`No sandbox found for session: ${sessionId}`)
402
+ }
403
+
404
+ // Clear the local mapping when the sandbox is gone (deleted or already absent) so a
405
+ // stale entry can't cause repeated reconnect failures. Preserve it on transient errors.
406
+ if (sandboxGone) {
407
+ this.sessionSandboxes.delete(sessionId)
408
+ const cleanupProjectId = stored?.projectId ?? projectId
409
+ const cleanupWorktree = stored?.worktree ?? this.dataStorage.load(projectId)?.worktree ?? ''
410
+ this.dataStorage.removeSession(cleanupProjectId, cleanupWorktree, sessionId)
411
+ }
412
+
413
+ return deleted
414
+ }
415
+
416
+ /**
417
+ * Pull not-yet-synced sandbox changes into the local repo before the sandbox is
418
+ * destroyed. Throws — aborting deletion so the sandbox is preserved — when unsynced
419
+ * changes cannot be pulled, including when the local repository itself is no longer
420
+ * accessible (a silent skip there would destroy the only copy of the work). A sandbox
421
+ * that is not running is deleted without being started: anything in it was either
422
+ * synced while it ran or is abandoned by the explicit delete.
423
+ *
424
+ * Runs inside the session's sync queue; it must NOT enqueue (that would deadlock).
425
+ */
426
+ private async syncBeforeDelete(sandbox: Sandbox, stored: { worktree: string; session: SessionInfo } | undefined) {
427
+ const branchNumber = stored?.session.branchNumber
428
+ if (!branchNumber || !stored?.worktree) return
429
+ await sandbox.refreshData()
430
+ if (sandbox.state !== 'started') return
431
+ const sessionGit = new SessionGitManager(sandbox, this.repoPath, stored.worktree, branchNumber)
432
+ if (!sessionGit.hasLocalRepo()) {
433
+ throw new Error(
434
+ `Local repository at ${stored.worktree} is not accessible, so unsynced sandbox changes cannot be pulled; the sandbox was not deleted. Restore the repository or delete the sandbox from the Daytona dashboard.`,
435
+ )
436
+ }
437
+ try {
438
+ await sessionGit.autoCommitAndPull()
439
+ } catch (err: any) {
440
+ throw new Error(
441
+ `Sandbox has changes that could not be synced to the local repository; the sandbox was not deleted. ${err?.message ?? err}`,
442
+ )
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Read-only check for a session→sandbox mapping (memory or storage); never creates,
448
+ * migrates, or connects.
449
+ */
450
+ hasSandbox(sessionId: string, projectId: string): boolean {
451
+ if (this.deletingSessions.has(sessionId)) return false
452
+ this.setProjectContext(projectId)
453
+ if (this.sessionSandboxes.has(sessionId)) return true
454
+ return this.dataStorage.findSession(sessionId) !== undefined
455
+ }
456
+
457
+ isSessionDeleting(sessionId: string): boolean {
458
+ return this.deletingSessions.has(sessionId)
459
+ }
460
+
461
+ /**
462
+ * Guard for registration points that follow an await: deletion may have started (and
463
+ * finished) while a sandbox was being refreshed or reconnected, and persisting the
464
+ * mapping afterwards would resurrect state for a session that no longer exists.
465
+ */
466
+ private ensureNotDeleted(sessionId: string): void {
467
+ if (this.deletingSessions.has(sessionId)) {
468
+ throw new Error(`Session ${sessionId} was deleted while its sandbox was being prepared.`)
469
+ }
470
+ }
471
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ /**
6
+ * Global toast notification singleton
7
+ * Queues toasts to prevent showing multiple at the same time
8
+ */
9
+ type ToastVariant = 'success' | 'error' | 'warning' | 'info';
10
+ interface ToastOptions {
11
+ title: string;
12
+ message: string;
13
+ variant?: ToastVariant;
14
+ }
15
+ interface TuiShowToast {
16
+ showToast: (options: {
17
+ body: {
18
+ title: string;
19
+ message: string;
20
+ variant: ToastVariant;
21
+ };
22
+ }) => void;
23
+ }
24
+ declare class ToastManager {
25
+ private tui;
26
+ private queue;
27
+ private isShowing;
28
+ /**
29
+ * Initialize the toast manager with the TUI instance
30
+ */
31
+ initialize(tui: TuiShowToast | null | undefined): void;
32
+ /**
33
+ * Show a toast notification
34
+ * If a toast is currently showing, this will be queued
35
+ */
36
+ show(options: ToastOptions): void;
37
+ /**
38
+ * Process the toast queue, showing one toast at a time
39
+ */
40
+ private processQueue;
41
+ /**
42
+ * Clear all pending toasts
43
+ */
44
+ clear(): void;
45
+ }
46
+ export declare const toast: ToastManager;
47
+ export {};
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ class ToastManager {
6
+ tui = null;
7
+ queue = [];
8
+ isShowing = false;
9
+ /**
10
+ * Initialize the toast manager with the TUI instance
11
+ */
12
+ initialize(tui) {
13
+ this.tui = tui || null;
14
+ }
15
+ /**
16
+ * Show a toast notification
17
+ * If a toast is currently showing, this will be queued
18
+ */
19
+ show(options) {
20
+ const toast = {
21
+ variant: 'info',
22
+ ...options,
23
+ };
24
+ this.queue.push(toast);
25
+ this.processQueue();
26
+ }
27
+ /**
28
+ * Process the toast queue, showing one toast at a time
29
+ */
30
+ processQueue() {
31
+ if (this.isShowing || this.queue.length === 0) {
32
+ return;
33
+ }
34
+ if (!this.tui) {
35
+ // If TUI is not available, clear the queue
36
+ this.queue = [];
37
+ return;
38
+ }
39
+ this.isShowing = true;
40
+ const toast = this.queue.shift();
41
+ try {
42
+ this.tui.showToast({
43
+ body: {
44
+ title: toast.title,
45
+ message: toast.message,
46
+ variant: toast.variant || 'info',
47
+ },
48
+ });
49
+ }
50
+ catch (err) {
51
+ // If showing fails, continue with next toast
52
+ console.error('Failed to show toast:', err);
53
+ }
54
+ // Wait a bit before showing the next toast to avoid overlap
55
+ // Most toasts are visible for 2-3 seconds, so we wait 2.5 seconds
56
+ setTimeout(() => {
57
+ this.isShowing = false;
58
+ this.processQueue();
59
+ }, 2500);
60
+ }
61
+ /**
62
+ * Clear all pending toasts
63
+ */
64
+ clear() {
65
+ this.queue = [];
66
+ }
67
+ }
68
+ // Export singleton instance
69
+ export const toast = new ToastManager();
70
+ //# sourceMappingURL=toast.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"toast.js","sourceRoot":"","sources":["toast.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAmBH,MAAM,YAAY;IACR,GAAG,GAAwB,IAAI,CAAA;IAC/B,KAAK,GAAmB,EAAE,CAAA;IAC1B,SAAS,GAAG,KAAK,CAAA;IAEzB;;OAEG;IACH,UAAU,CAAC,GAAoC;QAC7C,IAAI,CAAC,GAAG,GAAG,GAAG,IAAI,IAAI,CAAA;IACxB,CAAC;IAED;;;OAGG;IACH,IAAI,CAAC,OAAqB;QACxB,MAAM,KAAK,GAAiB;YAC1B,OAAO,EAAE,MAAM;YACf,GAAG,OAAO;SACX,CAAA;QAED,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACtB,IAAI,CAAC,YAAY,EAAE,CAAA;IACrB,CAAC;IAED;;OAEG;IACK,YAAY;QAClB,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC9C,OAAM;QACR,CAAC;QAED,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;YACd,2CAA2C;YAC3C,IAAI,CAAC,KAAK,GAAG,EAAE,CAAA;YACf,OAAM;QACR,CAAC;QAED,IAAI,CAAC,SAAS,GAAG,IAAI,CAAA;QACrB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,EAAG,CAAA;QAEjC,IAAI,CAAC;YACH,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC;gBACjB,IAAI,EAAE;oBACJ,KAAK,EAAE,KAAK,CAAC,KAAK;oBAClB,OAAO,EAAE,KAAK,CAAC,OAAO;oBACtB,OAAO,EAAE,KAAK,CAAC,OAAO,IAAI,MAAM;iBACjC;aACF,CAAC,CAAA;QACJ,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,6CAA6C;YAC7C,OAAO,CAAC,KAAK,CAAC,uBAAuB,EAAE,GAAG,CAAC,CAAA;QAC7C,CAAC;QAED,4DAA4D;QAC5D,kEAAkE;QAClE,UAAU,CAAC,GAAG,EAAE;YACd,IAAI,CAAC,SAAS,GAAG,KAAK,CAAA;YACtB,IAAI,CAAC,YAAY,EAAE,CAAA;QACrB,CAAC,EAAE,IAAI,CAAC,CAAA;IACV,CAAC;IAED;;OAEG;IACH,KAAK;QACH,IAAI,CAAC,KAAK,GAAG,EAAE,CAAA;IACjB,CAAC;CACF;AAED,4BAA4B;AAC5B,MAAM,CAAC,MAAM,KAAK,GAAG,IAAI,YAAY,EAAE,CAAA"}
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Global toast notification singleton
8
+ * Queues toasts to prevent showing multiple at the same time
9
+ */
10
+
11
+ type ToastVariant = 'success' | 'error' | 'warning' | 'info'
12
+
13
+ interface ToastOptions {
14
+ title: string
15
+ message: string
16
+ variant?: ToastVariant
17
+ }
18
+
19
+ interface TuiShowToast {
20
+ showToast: (options: { body: { title: string; message: string; variant: ToastVariant } }) => void
21
+ }
22
+
23
+ class ToastManager {
24
+ private tui: TuiShowToast | null = null
25
+ private queue: ToastOptions[] = []
26
+ private isShowing = false
27
+
28
+ /**
29
+ * Initialize the toast manager with the TUI instance
30
+ */
31
+ initialize(tui: TuiShowToast | null | undefined): void {
32
+ this.tui = tui || null
33
+ }
34
+
35
+ /**
36
+ * Show a toast notification
37
+ * If a toast is currently showing, this will be queued
38
+ */
39
+ show(options: ToastOptions): void {
40
+ const toast: ToastOptions = {
41
+ variant: 'info',
42
+ ...options,
43
+ }
44
+
45
+ this.queue.push(toast)
46
+ this.processQueue()
47
+ }
48
+
49
+ /**
50
+ * Process the toast queue, showing one toast at a time
51
+ */
52
+ private processQueue(): void {
53
+ if (this.isShowing || this.queue.length === 0) {
54
+ return
55
+ }
56
+
57
+ if (!this.tui) {
58
+ // If TUI is not available, clear the queue
59
+ this.queue = []
60
+ return
61
+ }
62
+
63
+ this.isShowing = true
64
+ const toast = this.queue.shift()!
65
+
66
+ try {
67
+ this.tui.showToast({
68
+ body: {
69
+ title: toast.title,
70
+ message: toast.message,
71
+ variant: toast.variant || 'info',
72
+ },
73
+ })
74
+ } catch (err) {
75
+ // If showing fails, continue with next toast
76
+ console.error('Failed to show toast:', err)
77
+ }
78
+
79
+ // Wait a bit before showing the next toast to avoid overlap
80
+ // Most toasts are visible for 2-3 seconds, so we wait 2.5 seconds
81
+ setTimeout(() => {
82
+ this.isShowing = false
83
+ this.processQueue()
84
+ }, 2500)
85
+ }
86
+
87
+ /**
88
+ * Clear all pending toasts
89
+ */
90
+ clear(): void {
91
+ this.queue = []
92
+ }
93
+ }
94
+
95
+ // Export singleton instance
96
+ export const toast = new ToastManager()