daytona 0.126.0.pre.alpha.5 → 0.134.0.alpha.1
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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/README.md +2 -1
- data/lib/daytona/code_interpreter.rb +365 -0
- data/lib/daytona/code_toolbox/sandbox_js_code_toolbox.rb +19 -0
- data/lib/daytona/common/code_interpreter.rb +53 -0
- data/lib/daytona/common/snapshot.rb +7 -1
- data/lib/daytona/computer_use.rb +3 -3
- data/lib/daytona/config.rb +46 -10
- data/lib/daytona/daytona.rb +30 -9
- data/lib/daytona/git.rb +10 -10
- data/lib/daytona/lsp_server.rb +7 -7
- data/lib/daytona/object_storage.rb +2 -2
- data/lib/daytona/process.rb +26 -12
- data/lib/daytona/sandbox.rb +115 -1
- data/lib/daytona/sdk/version.rb +1 -1
- data/lib/daytona/sdk.rb +4 -2
- data/lib/daytona/snapshot_service.rb +33 -10
- data/lib/daytona.rb +0 -1
- data/project.json +13 -2
- data/scripts/generate-docs.rb +395 -0
- metadata +9 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7ccf2ea96861ef33410f2dc49e0e85243aabf6608d9d1b10e657507f4030d02
|
|
4
|
+
data.tar.gz: 9816f06a64493152d37bd202d00a8180212e6b9925af3c9d3b8deb11cad2d8f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ccafd445c1c0f927eb9991cc051b2ad0d063adcd960615b1f21d6a154e2b9d5d3fbd424b7ffbd10aedcb7ec1dc2c2e30202dcd2182305f251f50c093baa03303
|
|
7
|
+
data.tar.gz: 6ee5305671de7d7c351a28b29f1a5210f08bae5fe6d992dffb888ca93e0795e21f55dd5f845b97c853b31323c0daf557c4e651ea9de65f9763854e7747cbda41
|
data/.rubocop.yml
CHANGED
data/README.md
CHANGED
|
@@ -86,13 +86,14 @@ From the repository root:
|
|
|
86
86
|
```bash
|
|
87
87
|
# Set your RubyGems API key and version
|
|
88
88
|
export RUBYGEMS_API_KEY="your-rubygems-api-key"
|
|
89
|
-
export RUBYGEMS_PKG_VERSION="X.Y.Z" # pre-release format example: "X.Y.Z
|
|
89
|
+
export RUBYGEMS_PKG_VERSION="X.Y.Z" # pre-release format example: "X.Y.Z.alpha.1"
|
|
90
90
|
|
|
91
91
|
# Publish (builds and publishes all Ruby gems)
|
|
92
92
|
yarn nx publish sdk-ruby
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
This will automatically:
|
|
96
|
+
|
|
96
97
|
- Set the version for all Ruby gems (api-client, toolbox-api-client, sdk)
|
|
97
98
|
- Build all gems in the correct dependency order
|
|
98
99
|
- Publish to RubyGems
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'websocket-client-simple'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
module Daytona
|
|
8
|
+
# Handles code interpretation and execution within a Sandbox. Currently supports only Python.
|
|
9
|
+
#
|
|
10
|
+
# This class provides methods to execute code in isolated interpreter contexts,
|
|
11
|
+
# manage contexts, and stream execution output via callbacks. If subsequent code executions
|
|
12
|
+
# are performed in the same context, the variables, imports, and functions defined in
|
|
13
|
+
# the previous execution will be available.
|
|
14
|
+
#
|
|
15
|
+
# For other languages, use the `code_run` method from the `Process` interface,
|
|
16
|
+
# or execute the appropriate command directly in the sandbox terminal.
|
|
17
|
+
class CodeInterpreter
|
|
18
|
+
WEBSOCKET_TIMEOUT_CODE = 4008
|
|
19
|
+
WS_PORT = 2280
|
|
20
|
+
private_constant :WS_PORT
|
|
21
|
+
|
|
22
|
+
# @param sandbox_id [String]
|
|
23
|
+
# @param toolbox_api [DaytonaToolboxApiClient::InterpreterApi]
|
|
24
|
+
# @param get_preview_link [Proc]
|
|
25
|
+
def initialize(sandbox_id:, toolbox_api:, get_preview_link:)
|
|
26
|
+
@sandbox_id = sandbox_id
|
|
27
|
+
@toolbox_api = toolbox_api
|
|
28
|
+
@get_preview_link = get_preview_link
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Execute Python code in the sandbox.
|
|
32
|
+
#
|
|
33
|
+
# By default, code runs in the default shared context which persists variables,
|
|
34
|
+
# imports, and functions across executions. To run in an isolated context,
|
|
35
|
+
# create a new context with `create_context` and pass it as the `context` argument.
|
|
36
|
+
#
|
|
37
|
+
# @param code [String] Code to execute
|
|
38
|
+
# @param context [DaytonaToolboxApiClient::InterpreterContext, nil] Context to run code in
|
|
39
|
+
# @param on_stdout [Proc, nil] Callback for stdout messages (receives OutputMessage)
|
|
40
|
+
# @param on_stderr [Proc, nil] Callback for stderr messages (receives OutputMessage)
|
|
41
|
+
# @param on_error [Proc, nil] Callback for execution errors (receives ExecutionError)
|
|
42
|
+
# @param envs [Hash<String, String>, nil] Environment variables for this execution
|
|
43
|
+
# @param timeout [Integer, nil] Timeout in seconds. 0 means no timeout. Default is 10 minutes.
|
|
44
|
+
# @return [Daytona::ExecutionResult]
|
|
45
|
+
# @raise [Daytona::Sdk::Error]
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
# def handle_stdout(msg)
|
|
49
|
+
# print "STDOUT: #{msg.output}"
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# def handle_stderr(msg)
|
|
53
|
+
# print "STDERR: #{msg.output}"
|
|
54
|
+
# end
|
|
55
|
+
#
|
|
56
|
+
# def handle_error(err)
|
|
57
|
+
# puts "ERROR: #{err.name}: #{err.value}"
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# code = <<~PYTHON
|
|
61
|
+
# import sys
|
|
62
|
+
# import time
|
|
63
|
+
# for i in range(5):
|
|
64
|
+
# print(i)
|
|
65
|
+
# time.sleep(1)
|
|
66
|
+
# sys.stderr.write("Counting done!")
|
|
67
|
+
# PYTHON
|
|
68
|
+
#
|
|
69
|
+
# result = sandbox.code_interpreter.run_code(
|
|
70
|
+
# code,
|
|
71
|
+
# on_stdout: method(:handle_stdout),
|
|
72
|
+
# on_stderr: method(:handle_stderr),
|
|
73
|
+
# on_error: method(:handle_error),
|
|
74
|
+
# timeout: 10
|
|
75
|
+
# )
|
|
76
|
+
def run_code(code, context: nil, on_stdout: nil, on_stderr: nil, on_error: nil, envs: nil, timeout: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
|
|
77
|
+
# Get WebSocket URL via preview link
|
|
78
|
+
preview_link = @get_preview_link.call(WS_PORT)
|
|
79
|
+
url = URI.parse(preview_link.url)
|
|
80
|
+
url.scheme = url.scheme == 'https' ? 'wss' : 'ws'
|
|
81
|
+
url.path = '/process/interpreter/execute'
|
|
82
|
+
ws_url = url.to_s
|
|
83
|
+
|
|
84
|
+
result = ExecutionResult.new
|
|
85
|
+
|
|
86
|
+
# Create request payload
|
|
87
|
+
request = { code: }
|
|
88
|
+
request[:contextId] = context.id if context
|
|
89
|
+
request[:envs] = envs if envs
|
|
90
|
+
request[:timeout] = timeout if timeout
|
|
91
|
+
|
|
92
|
+
# Build headers with preview token
|
|
93
|
+
headers = @toolbox_api.api_client.default_headers.dup.merge(
|
|
94
|
+
'X-Daytona-Preview-Token' => preview_link.token,
|
|
95
|
+
'Content-Type' => 'application/json',
|
|
96
|
+
'Accept' => 'application/json'
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Use queue for synchronization
|
|
100
|
+
completion_queue = Queue.new
|
|
101
|
+
interpreter = self # Capture self for use in blocks
|
|
102
|
+
last_message_time = Time.now
|
|
103
|
+
message_mutex = Mutex.new
|
|
104
|
+
|
|
105
|
+
puts "[DEBUG] Connecting to WebSocket: #{ws_url}" if ENV['DEBUG']
|
|
106
|
+
|
|
107
|
+
# Connect to WebSocket and execute
|
|
108
|
+
ws = WebSocket::Client::Simple.connect(ws_url, headers:)
|
|
109
|
+
|
|
110
|
+
ws.on :open do
|
|
111
|
+
puts '[DEBUG] WebSocket opened, sending request' if ENV['DEBUG']
|
|
112
|
+
ws.send(JSON.dump(request))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
ws.on :message do |msg|
|
|
116
|
+
message_mutex.synchronize { last_message_time = Time.now }
|
|
117
|
+
|
|
118
|
+
puts "[DEBUG] Received message (length=#{msg.data.length}): #{msg.data.inspect[0..200]}" if ENV['DEBUG']
|
|
119
|
+
|
|
120
|
+
interpreter.send(:handle_message, msg.data, result, on_stdout, on_stderr, on_error, completion_queue)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
ws.on :error do |e|
|
|
124
|
+
puts "[DEBUG] WebSocket error: #{e.message}" if ENV['DEBUG']
|
|
125
|
+
completion_queue.push({ type: :error, error: e })
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
ws.on :close do |e|
|
|
129
|
+
if ENV['DEBUG']
|
|
130
|
+
code = e&.code || 'nil'
|
|
131
|
+
reason = e&.reason || 'nil'
|
|
132
|
+
puts "[DEBUG] WebSocket closed: code=#{code}, reason=#{reason}"
|
|
133
|
+
end
|
|
134
|
+
error_info = interpreter.send(:handle_close, e)
|
|
135
|
+
if error_info
|
|
136
|
+
completion_queue.push({ type: :error_from_close, error: error_info })
|
|
137
|
+
else
|
|
138
|
+
completion_queue.push({ type: :close })
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Wait for completion signal with idle timeout
|
|
143
|
+
# If timeout is specified, wait longer to detect actual timeout errors
|
|
144
|
+
# Otherwise use short idle timeout for normal completion
|
|
145
|
+
idle_timeout = timeout ? (timeout + 2.0) : 1.0
|
|
146
|
+
max_wait = (timeout || 300) + 3 # Add buffer to configured timeout
|
|
147
|
+
start_time = Time.now
|
|
148
|
+
completion_reason = nil
|
|
149
|
+
|
|
150
|
+
# Wait for completion or close event
|
|
151
|
+
loop do
|
|
152
|
+
begin
|
|
153
|
+
completion = completion_queue.pop(true) # non-blocking
|
|
154
|
+
puts "[DEBUG] Got completion signal: #{completion[:type]}" if ENV['DEBUG']
|
|
155
|
+
|
|
156
|
+
# Control message (completed/interrupted) = normal completion
|
|
157
|
+
if completion[:type] == :completed
|
|
158
|
+
completion_reason = :completed
|
|
159
|
+
break
|
|
160
|
+
# If it's an error from close event (like timeout), raise it
|
|
161
|
+
elsif completion[:type] == :error_from_close
|
|
162
|
+
error_msg = completion[:error]
|
|
163
|
+
# Raise TimeoutError for timeout cases, regular Error for others
|
|
164
|
+
if error_msg.include?('timed out') || error_msg.include?('Execution timed out')
|
|
165
|
+
raise Sdk::TimeoutError, error_msg
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
raise Sdk::Error, error_msg
|
|
169
|
+
|
|
170
|
+
# Close event during execution (before control message) = likely timeout or error
|
|
171
|
+
elsif completion[:type] == :close
|
|
172
|
+
elapsed = Time.now - start_time
|
|
173
|
+
# If we got close near the timeout, it's likely a timeout
|
|
174
|
+
if timeout && elapsed >= timeout && elapsed < (timeout + 2)
|
|
175
|
+
raise Sdk::TimeoutError,
|
|
176
|
+
'Execution timed out: operation exceeded the configured `timeout`. Provide a larger value if needed.'
|
|
177
|
+
end
|
|
178
|
+
# Otherwise normal close
|
|
179
|
+
completion_reason = :close
|
|
180
|
+
break
|
|
181
|
+
# WebSocket errors
|
|
182
|
+
elsif completion[:type] == :error && !completion[:error].message.include?('stream closed')
|
|
183
|
+
raise Sdk::Error, "WebSocket error: #{completion[:error].message}"
|
|
184
|
+
end
|
|
185
|
+
rescue ThreadError
|
|
186
|
+
# Queue is empty, check idle timeout
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Check idle timeout (no messages for N seconds = completion)
|
|
190
|
+
time_since_last_message = message_mutex.synchronize { Time.now - last_message_time }
|
|
191
|
+
if time_since_last_message > idle_timeout
|
|
192
|
+
puts "[DEBUG] Idle timeout reached (#{idle_timeout}s), assuming completion" if ENV['DEBUG']
|
|
193
|
+
completion_reason = :idle_complete
|
|
194
|
+
break
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Check for absolute timeout (safety net)
|
|
198
|
+
if Time.now - start_time > max_wait
|
|
199
|
+
ws.close
|
|
200
|
+
raise Sdk::TimeoutError,
|
|
201
|
+
'Execution timed out: operation exceeded the configured `timeout`. Provide a larger value if needed.'
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
sleep 0.05 # Check every 50ms
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Close WebSocket if not already closed
|
|
208
|
+
ws.close if completion_reason != :close
|
|
209
|
+
sleep 0.05
|
|
210
|
+
|
|
211
|
+
result
|
|
212
|
+
rescue Sdk::Error
|
|
213
|
+
# Re-raise SDK errors as-is
|
|
214
|
+
raise
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
# Wrap unexpected errors
|
|
217
|
+
raise Sdk::Error, "Failed to run code: #{e.message}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Create a new isolated interpreter context.
|
|
221
|
+
#
|
|
222
|
+
# Contexts provide isolated execution environments with their own global namespace.
|
|
223
|
+
# Variables, imports, and functions defined in one context don't affect others.
|
|
224
|
+
#
|
|
225
|
+
# @param cwd [String, nil] Working directory for the context
|
|
226
|
+
# @return [DaytonaToolboxApiClient::InterpreterContext]
|
|
227
|
+
# @raise [Daytona::Sdk::Error]
|
|
228
|
+
#
|
|
229
|
+
# @example
|
|
230
|
+
# # Create isolated context
|
|
231
|
+
# ctx = sandbox.code_interpreter.create_context
|
|
232
|
+
#
|
|
233
|
+
# # Execute code in this context
|
|
234
|
+
# sandbox.code_interpreter.run_code("x = 100", context: ctx)
|
|
235
|
+
#
|
|
236
|
+
# # Variable only exists in this context
|
|
237
|
+
# result = sandbox.code_interpreter.run_code("print(x)", context: ctx) # OK
|
|
238
|
+
#
|
|
239
|
+
# # Won't see the variable in default context
|
|
240
|
+
# result = sandbox.code_interpreter.run_code("print(x)") # NameError
|
|
241
|
+
#
|
|
242
|
+
# # Clean up
|
|
243
|
+
# sandbox.code_interpreter.delete_context(ctx)
|
|
244
|
+
def create_context(cwd: nil)
|
|
245
|
+
request = DaytonaToolboxApiClient::CreateContextRequest.new(cwd:)
|
|
246
|
+
@toolbox_api.create_interpreter_context(request)
|
|
247
|
+
rescue StandardError => e
|
|
248
|
+
raise Sdk::Error, "Failed to create interpreter context: #{e.message}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# List all user-created interpreter contexts.
|
|
252
|
+
#
|
|
253
|
+
# The default context is not included in this list. Only contexts created
|
|
254
|
+
# via `create_context` are returned.
|
|
255
|
+
#
|
|
256
|
+
# @return [Array<DaytonaToolboxApiClient::InterpreterContext>]
|
|
257
|
+
# @raise [Daytona::Sdk::Error]
|
|
258
|
+
#
|
|
259
|
+
# @example
|
|
260
|
+
# contexts = sandbox.code_interpreter.list_contexts
|
|
261
|
+
# contexts.each do |ctx|
|
|
262
|
+
# puts "Context #{ctx.id}: #{ctx.language} at #{ctx.cwd}"
|
|
263
|
+
# end
|
|
264
|
+
def list_contexts
|
|
265
|
+
response = @toolbox_api.list_interpreter_contexts
|
|
266
|
+
response.contexts || []
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
raise Sdk::Error, "Failed to list interpreter contexts: #{e.message}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Delete an interpreter context and shut down all associated processes.
|
|
272
|
+
#
|
|
273
|
+
# This permanently removes the context and all its state (variables, imports, etc.).
|
|
274
|
+
# The default context cannot be deleted.
|
|
275
|
+
#
|
|
276
|
+
# @param context [DaytonaToolboxApiClient::InterpreterContext]
|
|
277
|
+
# @return [void]
|
|
278
|
+
# @raise [Daytona::Sdk::Error]
|
|
279
|
+
#
|
|
280
|
+
# @example
|
|
281
|
+
# ctx = sandbox.code_interpreter.create_context
|
|
282
|
+
# # ... use context ...
|
|
283
|
+
# sandbox.code_interpreter.delete_context(ctx)
|
|
284
|
+
def delete_context(context)
|
|
285
|
+
@toolbox_api.delete_interpreter_context(context.id)
|
|
286
|
+
nil
|
|
287
|
+
rescue StandardError => e
|
|
288
|
+
raise Sdk::Error, "Failed to delete interpreter context: #{e.message}"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private
|
|
292
|
+
|
|
293
|
+
# @return [Hash<String, String>]
|
|
294
|
+
def build_headers
|
|
295
|
+
headers = {}
|
|
296
|
+
@toolbox_api.api_client.update_params_for_auth!(headers, nil, ['bearer'])
|
|
297
|
+
headers
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# @param data [String]
|
|
301
|
+
# @param result [Daytona::ExecutionResult]
|
|
302
|
+
# @param on_stdout [Proc, nil]
|
|
303
|
+
# @param on_stderr [Proc, nil]
|
|
304
|
+
# @param on_error [Proc, nil]
|
|
305
|
+
# @param completion_queue [Queue, nil] Queue to signal completion
|
|
306
|
+
# @return [void]
|
|
307
|
+
def handle_message(data, result, on_stdout, on_stderr, on_error, completion_queue = nil) # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
|
|
308
|
+
# Empty messages are just keepalives or noise, ignore them
|
|
309
|
+
if data.nil? || data.empty?
|
|
310
|
+
puts '[DEBUG] Received empty message, ignoring' if ENV['DEBUG']
|
|
311
|
+
return
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
chunk = JSON.parse(data)
|
|
315
|
+
chunk_type = chunk['type']
|
|
316
|
+
|
|
317
|
+
case chunk_type
|
|
318
|
+
when 'stdout'
|
|
319
|
+
stdout = chunk['text'] || ''
|
|
320
|
+
result.stdout += stdout
|
|
321
|
+
on_stdout&.call(OutputMessage.new(output: stdout))
|
|
322
|
+
when 'stderr'
|
|
323
|
+
stderr = chunk['text'] || ''
|
|
324
|
+
result.stderr += stderr
|
|
325
|
+
on_stderr&.call(OutputMessage.new(output: stderr))
|
|
326
|
+
when 'error'
|
|
327
|
+
error = ExecutionError.new(
|
|
328
|
+
name: chunk['name'] || '',
|
|
329
|
+
value: chunk['value'] || '',
|
|
330
|
+
traceback: chunk['traceback'] || ''
|
|
331
|
+
)
|
|
332
|
+
result.error = error
|
|
333
|
+
on_error&.call(error)
|
|
334
|
+
when 'control'
|
|
335
|
+
control_text = chunk['text'] || ''
|
|
336
|
+
if %w[completed interrupted].include?(control_text)
|
|
337
|
+
puts "[DEBUG] Received control message: #{control_text}" if ENV['DEBUG']
|
|
338
|
+
completion_queue&.push({ type: :completed })
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
rescue JSON::ParserError => e
|
|
342
|
+
# Skip malformed messages
|
|
343
|
+
warn "Warning: Failed to parse message: #{e.message}" if ENV['DEBUG']
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# @param event [Object]
|
|
347
|
+
# @return [void]
|
|
348
|
+
def handle_close(event)
|
|
349
|
+
return nil unless event # Skip if event is nil (manual close)
|
|
350
|
+
|
|
351
|
+
code = event.respond_to?(:code) ? event.code : nil
|
|
352
|
+
reason = event.respond_to?(:reason) ? event.reason : nil
|
|
353
|
+
|
|
354
|
+
if code == WEBSOCKET_TIMEOUT_CODE
|
|
355
|
+
return 'Execution timed out: operation exceeded the configured `timeout`. Provide a larger value if needed.'
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
return nil if code == 1000 || code.nil? # Normal closure or no code
|
|
359
|
+
|
|
360
|
+
detail = reason.to_s.empty? ? 'WebSocket connection closed unexpectedly' : reason.to_s
|
|
361
|
+
detail = "#{detail} (close code #{code})" if code
|
|
362
|
+
detail
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Daytona
|
|
6
|
+
class SandboxJsCodeToolbox
|
|
7
|
+
def get_run_command(code, params = nil)
|
|
8
|
+
# Encode the provided code in base64
|
|
9
|
+
base64_code = Base64.strict_encode64(code)
|
|
10
|
+
|
|
11
|
+
# Build command-line arguments string
|
|
12
|
+
argv = ''
|
|
13
|
+
argv = params.argv.join(' ') if params&.argv && !params.argv.empty?
|
|
14
|
+
|
|
15
|
+
# Combine everything into the final command for JavaScript
|
|
16
|
+
" sh -c 'echo #{base64_code} | base64 --decode | node -e \"$(cat)\" #{argv} 2>&1 | grep -vE \"npm notice\"' "
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Daytona
|
|
4
|
+
# Represents stdout or stderr output from code execution
|
|
5
|
+
class OutputMessage
|
|
6
|
+
# @return [String] The output content
|
|
7
|
+
attr_reader :output
|
|
8
|
+
|
|
9
|
+
# @param output [String]
|
|
10
|
+
def initialize(output:)
|
|
11
|
+
@output = output
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Represents an error that occurred during code execution
|
|
16
|
+
class ExecutionError
|
|
17
|
+
# @return [String] The error type/class name (e.g., "ValueError", "SyntaxError")
|
|
18
|
+
attr_reader :name
|
|
19
|
+
|
|
20
|
+
# @return [String] The error value
|
|
21
|
+
attr_reader :value
|
|
22
|
+
|
|
23
|
+
# @return [String] Full traceback of the error
|
|
24
|
+
attr_reader :traceback
|
|
25
|
+
|
|
26
|
+
# @param name [String]
|
|
27
|
+
# @param value [String]
|
|
28
|
+
# @param traceback [String]
|
|
29
|
+
def initialize(name:, value:, traceback: '')
|
|
30
|
+
@name = name
|
|
31
|
+
@value = value
|
|
32
|
+
@traceback = traceback
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Result of code execution
|
|
37
|
+
class ExecutionResult
|
|
38
|
+
# @return [String] Standard output from the code execution
|
|
39
|
+
attr_accessor :stdout
|
|
40
|
+
|
|
41
|
+
# @return [String] Standard error output from the code execution
|
|
42
|
+
attr_accessor :stderr
|
|
43
|
+
|
|
44
|
+
# @return [ExecutionError, nil] Error details if execution failed, nil otherwise
|
|
45
|
+
attr_accessor :error
|
|
46
|
+
|
|
47
|
+
def initialize(stdout: '', stderr: '', error: nil)
|
|
48
|
+
@stdout = stdout
|
|
49
|
+
@stderr = stderr
|
|
50
|
+
@error = error
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -16,15 +16,21 @@ module Daytona
|
|
|
16
16
|
# @return [Array<String>, nil] Entrypoint of the snapshot
|
|
17
17
|
attr_reader :entrypoint
|
|
18
18
|
|
|
19
|
+
# @return [String, nil] ID of the region where the snapshot will be available.
|
|
20
|
+
# Defaults to organization default region if not specified.
|
|
21
|
+
attr_reader :region_id
|
|
22
|
+
|
|
19
23
|
# @param name [String] Name of the snapshot
|
|
20
24
|
# @param image [String, Daytona::Image] Image of the snapshot
|
|
21
25
|
# @param resources [Daytona::Resources, nil] Resources of the snapshot
|
|
22
26
|
# @param entrypoint [Array<String>, nil] Entrypoint of the snapshot
|
|
23
|
-
|
|
27
|
+
# @param region_id [String, nil] ID of the region where the snapshot will be available
|
|
28
|
+
def initialize(name:, image:, resources: nil, entrypoint: nil, region_id: nil)
|
|
24
29
|
@name = name
|
|
25
30
|
@image = image
|
|
26
31
|
@resources = resources
|
|
27
32
|
@entrypoint = entrypoint
|
|
33
|
+
@region_id = region_id
|
|
28
34
|
end
|
|
29
35
|
end
|
|
30
36
|
|
data/lib/daytona/computer_use.rb
CHANGED
|
@@ -67,7 +67,7 @@ module Daytona
|
|
|
67
67
|
# right_click = sandbox.computer_use.mouse.click(x: 100, y: 200, button: 'right')
|
|
68
68
|
def click(x:, y:, button: 'left', double: false) # rubocop:disable Naming/MethodParameterName
|
|
69
69
|
request = DaytonaToolboxApiClient::MouseClickRequest.new(x:, y:, button:, double:)
|
|
70
|
-
toolbox_api.
|
|
70
|
+
toolbox_api.click(request)
|
|
71
71
|
rescue StandardError => e
|
|
72
72
|
raise Sdk::Error, "Failed to click mouse: #{e.message}"
|
|
73
73
|
end
|
|
@@ -87,7 +87,7 @@ module Daytona
|
|
|
87
87
|
# puts "Dragged from #{result.from_x},#{result.from_y} to #{result.to_x},#{result.to_y}"
|
|
88
88
|
def drag(start_x:, start_y:, end_x:, end_y:, button: 'left')
|
|
89
89
|
request = DaytonaToolboxApiClient::MouseDragRequest.new(start_x:, start_y:, end_x:, end_y:, button:)
|
|
90
|
-
toolbox_api.
|
|
90
|
+
toolbox_api.drag(request)
|
|
91
91
|
rescue StandardError => e
|
|
92
92
|
raise Sdk::Error, "Failed to drag mouse: #{e.message}"
|
|
93
93
|
end
|
|
@@ -109,7 +109,7 @@ module Daytona
|
|
|
109
109
|
# scroll_down = sandbox.computer_use.mouse.scroll(x: 100, y: 200, direction: 'down', amount: 5)
|
|
110
110
|
def scroll(x:, y:, direction:, amount: 1) # rubocop:disable Naming/MethodParameterName
|
|
111
111
|
request = DaytonaToolboxApiClient::MouseScrollRequest.new(x:, y:, direction:, amount:)
|
|
112
|
-
toolbox_api.
|
|
112
|
+
toolbox_api.scroll(request)
|
|
113
113
|
true
|
|
114
114
|
rescue StandardError => e
|
|
115
115
|
raise Sdk::Error, "Failed to scroll mouse: #{e.message}"
|
data/lib/daytona/config.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'dotenv'
|
|
4
|
+
|
|
3
5
|
module Daytona
|
|
4
6
|
class Config
|
|
5
7
|
API_URL = 'https://app.daytona.io/api'
|
|
@@ -37,17 +39,51 @@ module Daytona
|
|
|
37
39
|
# @param organization_id [String, nil] Daytona organization ID. Defaults to ENV['DAYTONA_ORGANIZATION_ID'].
|
|
38
40
|
# @param target [String, nil] Daytona target. Defaults to ENV['DAYTONA_TARGET'].
|
|
39
41
|
def initialize(
|
|
40
|
-
api_key:
|
|
41
|
-
jwt_token:
|
|
42
|
-
api_url:
|
|
43
|
-
organization_id:
|
|
44
|
-
target:
|
|
42
|
+
api_key: nil,
|
|
43
|
+
jwt_token: nil,
|
|
44
|
+
api_url: nil,
|
|
45
|
+
organization_id: nil,
|
|
46
|
+
target: nil
|
|
45
47
|
)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@
|
|
48
|
+
# Load environment variables from .env and .env.local files
|
|
49
|
+
# Files are loaded from the current working directory (where the code is executed)
|
|
50
|
+
load_env_files
|
|
51
|
+
|
|
52
|
+
@api_key = api_key || ENV.fetch('DAYTONA_API_KEY', nil)
|
|
53
|
+
@jwt_token = jwt_token || ENV.fetch('DAYTONA_JWT_TOKEN', nil)
|
|
54
|
+
@api_url = api_url || ENV.fetch('DAYTONA_API_URL', API_URL)
|
|
55
|
+
@target = target || ENV.fetch('DAYTONA_TARGET', nil)
|
|
56
|
+
@organization_id = organization_id || ENV.fetch('DAYTONA_ORGANIZATION_ID', nil)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Load only Daytona-specific environment variables from .env and .env.local files
|
|
62
|
+
# Only loads variables that are not already set in the runtime environment
|
|
63
|
+
# .env.local overrides .env
|
|
64
|
+
# Files are loaded from the current working directory
|
|
65
|
+
def load_env_files
|
|
66
|
+
# Daytona-specific variables we want to load
|
|
67
|
+
daytona_vars = %w[
|
|
68
|
+
DAYTONA_API_KEY
|
|
69
|
+
DAYTONA_API_URL
|
|
70
|
+
DAYTONA_TARGET
|
|
71
|
+
DAYTONA_JWT_TOKEN
|
|
72
|
+
DAYTONA_ORGANIZATION_ID
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
env_file = File.join(Dir.pwd, '.env')
|
|
76
|
+
env_local_file = File.join(Dir.pwd, '.env.local')
|
|
77
|
+
|
|
78
|
+
# Parse .env files using dotenv (doesn't set ENV automatically)
|
|
79
|
+
env_from_file = {}
|
|
80
|
+
env_from_file.merge!(Dotenv.parse(env_file)) if File.exist?(env_file)
|
|
81
|
+
env_from_file.merge!(Dotenv.parse(env_local_file)) if File.exist?(env_local_file)
|
|
82
|
+
|
|
83
|
+
# Only set Daytona-specific variables that aren't already in runtime
|
|
84
|
+
daytona_vars.each do |var|
|
|
85
|
+
ENV[var] = env_from_file[var] if env_from_file.key?(var) && !ENV.key?(var)
|
|
86
|
+
end
|
|
51
87
|
end
|
|
52
88
|
end
|
|
53
89
|
end
|
data/lib/daytona/daytona.rb
CHANGED
|
@@ -36,8 +36,9 @@ module Daytona
|
|
|
36
36
|
@volume = VolumeService.new(DaytonaApiClient::VolumesApi.new(api_client))
|
|
37
37
|
@object_storage_api = DaytonaApiClient::ObjectStorageApi.new(api_client)
|
|
38
38
|
@snapshots_api = DaytonaApiClient::SnapshotsApi.new(api_client)
|
|
39
|
-
@snapshot = SnapshotService.new(snapshots_api:, object_storage_api:)
|
|
40
|
-
@
|
|
39
|
+
@snapshot = SnapshotService.new(snapshots_api:, object_storage_api:, default_region_id: config.target)
|
|
40
|
+
@proxy_toolbox_url_cache = {}
|
|
41
|
+
@proxy_toolbox_url_mutex = Mutex.new
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
# Creates a sandbox with the specified parameters
|
|
@@ -180,9 +181,15 @@ module Daytona
|
|
|
180
181
|
response = sandbox_api.create_sandbox(create_sandbox)
|
|
181
182
|
|
|
182
183
|
if response.state == DaytonaApiClient::SandboxState::PENDING_BUILD && on_snapshot_create_logs
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
# Wait for state to change from PENDING_BUILD before fetching logs
|
|
185
|
+
while response.state == DaytonaApiClient::SandboxState::PENDING_BUILD
|
|
186
|
+
sleep(1)
|
|
187
|
+
response = sandbox_api.get_sandbox(response.id)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Get build logs URL from API
|
|
191
|
+
build_logs_response = sandbox_api.get_build_logs_url(response.id)
|
|
192
|
+
uri = URI.parse("#{build_logs_response.url}?follow=true")
|
|
186
193
|
|
|
187
194
|
headers = {}
|
|
188
195
|
sandbox_api.api_client.update_params_for_auth!(headers, nil, ['bearer'])
|
|
@@ -238,15 +245,27 @@ module Daytona
|
|
|
238
245
|
config:,
|
|
239
246
|
sandbox_api:,
|
|
240
247
|
code_toolbox:,
|
|
241
|
-
get_proxy_toolbox_url:
|
|
248
|
+
get_proxy_toolbox_url: proc { |sandbox_id, region_id| proxy_toolbox_url(sandbox_id, region_id) }
|
|
242
249
|
)
|
|
243
250
|
end
|
|
244
251
|
|
|
245
|
-
# Gets the proxy toolbox URL from the
|
|
252
|
+
# Gets the proxy toolbox URL from the sandbox API (cached per region)
|
|
246
253
|
#
|
|
254
|
+
# @param sandbox_id [String] The sandbox ID
|
|
255
|
+
# @param region_id [String] The region ID
|
|
247
256
|
# @return [String] The proxy toolbox URL
|
|
248
|
-
def proxy_toolbox_url
|
|
249
|
-
|
|
257
|
+
def proxy_toolbox_url(sandbox_id, region_id)
|
|
258
|
+
# Return cached URL if available
|
|
259
|
+
return @proxy_toolbox_url_cache[region_id] if @proxy_toolbox_url_cache.key?(region_id)
|
|
260
|
+
|
|
261
|
+
# Use mutex to ensure thread-safe caching
|
|
262
|
+
@proxy_toolbox_url_mutex.synchronize do
|
|
263
|
+
# Double-check after acquiring lock
|
|
264
|
+
return @proxy_toolbox_url_cache[region_id] if @proxy_toolbox_url_cache.key?(region_id)
|
|
265
|
+
|
|
266
|
+
# Fetch and cache the URL
|
|
267
|
+
@proxy_toolbox_url_cache[region_id] = sandbox_api.get_toolbox_proxy_url(sandbox_id).url
|
|
268
|
+
end
|
|
250
269
|
end
|
|
251
270
|
|
|
252
271
|
# Converts a language to a code toolbox
|
|
@@ -260,6 +279,8 @@ module Daytona
|
|
|
260
279
|
SandboxPythonCodeToolbox.new
|
|
261
280
|
when SandboxTsCodeToolbox, CodeLanguage::TYPESCRIPT
|
|
262
281
|
SandboxTsCodeToolbox.new
|
|
282
|
+
when CodeLanguage::JAVASCRIPT
|
|
283
|
+
SandboxJsCodeToolbox.new
|
|
263
284
|
else
|
|
264
285
|
raise Sdk::Error, "Unsupported language: #{language}"
|
|
265
286
|
end
|