daytona 0.170.0 → 0.171.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7515dcf7e9ce6366233315925a09418cfe7714473f55ccf2062010f3d5ac692c
4
- data.tar.gz: 85148ff08b50b7a9d647ac53ae8b73622e5e3d9d01f9a211229a1ec071f49979
3
+ metadata.gz: 7b07a148abb7ece5ebaef51d75a606301119c2e4b8bda891406e71acbe60dc9a
4
+ data.tar.gz: 66186d6b161ab68bf34df1c4860cc2e7b59c3d8983171efa2c3dbaddc36ef1a6
5
5
  SHA512:
6
- metadata.gz: ccd9ad77b30a91e4aa4ca503d87d3d9a976978b553bf3c0a1ba6e80c912c804bfab99b520377edc71ab17abd4eb22790edac7ae683bf2f6a58bfe6419a08e390
7
- data.tar.gz: 071d6d27256509829400a640d70b386c7a627e44731b07b0a2ba04559c3837bf31ec4f037c76f9f4bdbe5073af58d9660551d14ccac9e6fff5dd653712c0b04a
6
+ metadata.gz: 392310a933e9c7555db1787abe9247462fa1fffc421a4cdbe8714c620704346ac8416f595a18bc8534999aaed02a4e64569516a53aee41f08d8bef748c66c445
7
+ data.tar.gz: 64201581db64d5df476bed8e55cb22fbeb7d1dc8b8cd9f5b6d0232fc1bdd898303dd76cd9cd7a94c0db3b4b7da8feb6765f74779a2e14ecdc21c2aec6bed0b76
@@ -103,15 +103,11 @@ module Daytona
103
103
  'Accept' => 'application/json'
104
104
  )
105
105
 
106
- # Use queue for synchronization
107
106
  completion_queue = Queue.new
108
- interpreter = self # Capture self for use in blocks
109
- last_message_time = Time.now
110
- message_mutex = Mutex.new
107
+ interpreter = self
111
108
 
112
109
  puts "[DEBUG] Connecting to WebSocket: #{ws_url}" if ENV['DEBUG']
113
110
 
114
- # Connect to WebSocket and execute
115
111
  ws = WebSocket::Client::Simple.connect(ws_url, headers:)
116
112
 
117
113
  ws.on :open do
@@ -120,8 +116,6 @@ module Daytona
120
116
  end
121
117
 
122
118
  ws.on :message do |msg|
123
- message_mutex.synchronize { last_message_time = Time.now }
124
-
125
119
  puts "[DEBUG] Received message (length=#{msg.data.length}): #{msg.data.inspect[0..200]}" if ENV['DEBUG']
126
120
 
127
121
  interpreter.send(:handle_message, msg.data, result, on_stdout, on_stderr, on_error, completion_queue)
@@ -146,72 +140,59 @@ module Daytona
146
140
  end
147
141
  end
148
142
 
149
- # Wait for completion signal with idle timeout
150
- # If timeout is specified, wait longer to detect actual timeout errors
151
- # Otherwise use short idle timeout for normal completion
152
- idle_timeout = timeout ? (timeout + 2.0) : 1.0
153
- max_wait = (timeout || 300) + 3 # Add buffer to configured timeout
143
+ no_timeout = timeout.is_a?(Numeric) && timeout <= 0
144
+ max_wait = no_timeout ? nil : (timeout || 600) + 3
154
145
  start_time = Time.now
155
146
  completion_reason = nil
156
147
 
157
- # Wait for completion or close event
158
148
  loop do
159
- begin
160
- completion = completion_queue.pop(true) # non-blocking
161
- puts "[DEBUG] Got completion signal: #{completion[:type]}" if ENV['DEBUG']
162
-
163
- # Control message (completed/interrupted) = normal completion
164
- if completion[:type] == :completed
165
- completion_reason = :completed
166
- break
167
- # If it's an error from close event (like timeout), raise it
168
- elsif completion[:type] == :error_from_close
169
- error_msg = completion[:error]
170
- # Raise TimeoutError for timeout cases, regular Error for others
171
- if error_msg.include?('timed out') || error_msg.include?('Execution timed out')
172
- raise Sdk::TimeoutError, error_msg
173
- end
174
-
175
- raise Sdk::Error, error_msg
176
-
177
- # Close event during execution (before control message) = likely timeout or error
178
- elsif completion[:type] == :close
179
- elapsed = Time.now - start_time
180
- # If we got close near the timeout, it's likely a timeout
181
- if timeout && elapsed >= timeout && elapsed < (timeout + 2)
182
- raise Sdk::TimeoutError,
183
- 'Execution timed out: operation exceeded the configured `timeout`. Provide a larger value if needed.'
184
- end
185
- # Otherwise normal close
186
- completion_reason = :close
187
- break
188
- # WebSocket errors
189
- elsif completion[:type] == :error && !completion[:error].message.include?('stream closed')
190
- raise Sdk::Error, "WebSocket error: #{completion[:error].message}"
149
+ if max_wait
150
+ remaining = max_wait - (Time.now - start_time)
151
+ if remaining <= 0
152
+ ws.close
153
+ raise Sdk::TimeoutError,
154
+ 'Execution timed out: operation exceeded the configured `timeout`. Provide a larger value if needed.'
191
155
  end
192
- rescue ThreadError
193
- # Queue is empty, check idle timeout
194
156
  end
195
157
 
196
- # Check idle timeout (no messages for N seconds = completion)
197
- time_since_last_message = message_mutex.synchronize { Time.now - last_message_time }
198
- if time_since_last_message > idle_timeout
199
- puts "[DEBUG] Idle timeout reached (#{idle_timeout}s), assuming completion" if ENV['DEBUG']
200
- completion_reason = :idle_complete
201
- break
202
- end
158
+ completion = completion_queue.pop(timeout: max_wait ? remaining : nil)
203
159
 
204
- # Check for absolute timeout (safety net)
205
- if Time.now - start_time > max_wait
160
+ if completion.nil?
206
161
  ws.close
207
162
  raise Sdk::TimeoutError,
208
163
  'Execution timed out: operation exceeded the configured `timeout`. Provide a larger value if needed.'
209
164
  end
210
165
 
211
- sleep 0.05 # Check every 50ms
166
+ puts "[DEBUG] Got completion signal: #{completion[:type]}" if ENV['DEBUG']
167
+
168
+ if completion[:type] == :completed
169
+ completion_reason = :completed
170
+ break
171
+ elsif completion[:type] == :error_from_close
172
+ error_msg = completion[:error]
173
+ if error_msg.include?('timed out') || error_msg.include?('Execution timed out')
174
+ raise Sdk::TimeoutError, error_msg
175
+ end
176
+
177
+ raise Sdk::Error, error_msg
178
+ elsif completion[:type] == :close
179
+ elapsed = Time.now - start_time
180
+ if timeout && timeout > 0 && elapsed >= timeout && elapsed < (timeout + 2)
181
+ raise Sdk::TimeoutError,
182
+ 'Execution timed out: operation exceeded the configured `timeout`. Provide a larger value if needed.'
183
+ end
184
+ completion_reason = :close
185
+ break
186
+ elsif completion[:type] == :error
187
+ unless completion[:error].message.include?('stream closed')
188
+ raise Sdk::Error, "WebSocket error: #{completion[:error].message}"
189
+ end
190
+
191
+ completion_reason = :close
192
+ break
193
+ end
212
194
  end
213
195
 
214
- # Close WebSocket if not already closed
215
196
  ws.close if completion_reason != :close
216
197
  sleep 0.05
217
198
 
@@ -91,7 +91,7 @@ module Daytona
91
91
  #
92
92
  # @example
93
93
  # result = sandbox.computer_use.mouse.drag(start_x: 50, start_y: 50, end_x: 150, end_y: 150)
94
- # puts "Dragged from #{result.from_x},#{result.from_y} to #{result.to_x},#{result.to_y}"
94
+ # puts "Drag ended at #{result.x}, #{result.y}"
95
95
  def drag(start_x:, start_y:, end_x:, end_y:, button: 'left')
96
96
  request = DaytonaToolboxApiClient::MouseDragRequest.new(start_x:, start_y:, end_x:, end_y:, button:)
97
97
  toolbox_api.drag(request)
@@ -224,8 +224,16 @@ module Daytona
224
224
  def ensure_access_token_defined
225
225
  return if config.api_key
226
226
 
227
- raise Sdk::Error, 'API key or JWT token is required' unless config.jwt_token
228
- raise Sdk::Error, 'Organization ID is required when using JWT token' unless config.organization_id
227
+ unless config.jwt_token
228
+ raise Sdk::Error,
229
+ 'Authentication credentials not found. Set DAYTONA_API_KEY, or both DAYTONA_JWT_TOKEN and ' \
230
+ 'DAYTONA_ORGANIZATION_ID. These can also be provided via Daytona::Config.'
231
+ end
232
+ return if config.organization_id
233
+
234
+ raise Sdk::Error,
235
+ 'DAYTONA_ORGANIZATION_ID is required when authenticating with DAYTONA_JWT_TOKEN. ' \
236
+ 'It can also be provided via Daytona::Config.'
229
237
  end
230
238
 
231
239
  # @return [DaytonaApiClient::ApiClient]
@@ -5,9 +5,10 @@
5
5
 
6
6
  require 'tempfile'
7
7
  require 'fileutils'
8
+ require_relative 'file_transfer'
8
9
 
9
10
  module Daytona
10
- class FileSystem
11
+ class FileSystem # rubocop:disable Metrics/ClassLength
11
12
  include Instrumentation
12
13
 
13
14
  # @return [String] The Sandbox ID
@@ -153,6 +154,37 @@ module Daytona
153
154
  raise Sdk::Error, "Failed to download file: #{e.message}"
154
155
  end
155
156
 
157
+ # Downloads a single file from the Sandbox as a stream without buffering the entire
158
+ # file into memory. Yields file content in chunks to the given block, or returns an
159
+ # Enumerator if no block is given.
160
+ #
161
+ # @param remote_path [String] Path to the file in the Sandbox. Relative paths are resolved
162
+ # based on the sandbox working directory.
163
+ # @param timeout [Integer] Timeout for the download operation in seconds. 0 means no timeout.
164
+ # Default is 30 minutes.
165
+ # @yield [chunk] Yields each chunk of file content as it arrives
166
+ # @yieldparam chunk [String] A binary string chunk of file content
167
+ # @return [Enumerator, nil] An Enumerator yielding chunks if no block given, nil otherwise
168
+ # @raise [Daytona::Sdk::Error] If the file does not exist or the operation fails
169
+ #
170
+ # @example Stream to a local file without loading into memory
171
+ # File.open("local_copy.bin", "wb") do |f|
172
+ # sandbox.fs.download_file_stream("workspace/large-file.bin") { |chunk| f.write(chunk) }
173
+ # end
174
+ #
175
+ # @example Collect chunks with an Enumerator
176
+ # content = sandbox.fs.download_file_stream("workspace/data.json").reduce(:+)
177
+ # puts content
178
+ def download_file_stream(remote_path, timeout: 30 * 60, &)
179
+ return enum_for(__method__, remote_path, timeout:) unless block_given?
180
+
181
+ FileTransfer.stream_download(api_client: toolbox_api.api_client, remote_path: remote_path,
182
+ timeout: timeout, &)
183
+ nil
184
+ rescue StandardError => e
185
+ raise Sdk::Error, "Failed to download file: #{e.message}"
186
+ end
187
+
156
188
  # Uploads a file to the specified path in the Sandbox. If a file already exists at
157
189
  # the destination path, it will be overwritten.
158
190
  #
@@ -364,8 +396,8 @@ module Daytona
364
396
  end
365
397
 
366
398
  instrument :create_folder, :delete_file, :get_file_info, :list_files, :download_file,
367
- :upload_file, :upload_files, :find_files, :search_files, :move_files,
368
- :replace_in_files, :set_file_permissions,
399
+ :download_file_stream, :upload_file, :upload_files, :find_files,
400
+ :search_files, :move_files, :replace_in_files, :set_file_permissions,
369
401
  component: 'FileSystem'
370
402
 
371
403
  private
@@ -0,0 +1,184 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # frozen_string_literal: true
5
+
6
+ require 'json'
7
+ require 'typhoeus'
8
+
9
+ module Daytona
10
+ class MultipartDownloadStreamParser
11
+ attr_reader :error_message
12
+ attr_writer :boundary_token
13
+
14
+ def initialize(&on_file_chunk)
15
+ @on_file_chunk = on_file_chunk
16
+ @boundary_token = nil
17
+ @buffer = String.new.b
18
+ @state = :preamble
19
+ @part_name = nil
20
+ @error_buffer = String.new.b
21
+ end
22
+
23
+ def <<(chunk)
24
+ @buffer << chunk.b
25
+ process!
26
+ end
27
+
28
+ def finish!
29
+ process!
30
+
31
+ return if @state == :done || @buffer.empty?
32
+
33
+ emit(@buffer)
34
+ finalize_part!
35
+ @buffer = String.new.b
36
+ @state = :done
37
+ end
38
+
39
+ private
40
+
41
+ def process!
42
+ loop do
43
+ advanced = case @state
44
+ when :preamble then consume_preamble?
45
+ when :headers then consume_headers?
46
+ when :body then consume_body?
47
+ else false
48
+ end
49
+
50
+ break unless advanced
51
+ end
52
+ end
53
+
54
+ def consume_preamble?
55
+ start_marker = "#{boundary}\r\n".b
56
+ index = @buffer.index(start_marker)
57
+ return retain_tail?(start_marker.bytesize - 1) unless index
58
+
59
+ @buffer = remaining_bytes(index + start_marker.bytesize)
60
+ @state = :headers
61
+ true
62
+ end
63
+
64
+ def consume_headers?
65
+ separator = "\r\n\r\n".b
66
+ index = @buffer.index(separator)
67
+ return false unless index
68
+
69
+ headers = @buffer.byteslice(0, index)
70
+ @buffer = remaining_bytes(index + separator.bytesize)
71
+ @part_name = headers[/Content-Disposition:\s*[^\r\n]*\bname="([^"]+)"/i, 1]
72
+ raise Sdk::Error, 'Invalid multipart response' if @part_name.nil?
73
+
74
+ @state = :body
75
+ true
76
+ end
77
+
78
+ def consume_body? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
79
+ marker = "\r\n#{boundary}".b
80
+ index = @buffer.index(marker)
81
+
82
+ if index
83
+ emit(@buffer.byteslice(0, index))
84
+ @buffer = remaining_bytes(index + marker.bytesize)
85
+ finalize_part!
86
+ @state = :done
87
+ return true
88
+ end
89
+
90
+ flushable = @buffer.bytesize - marker.bytesize + 1
91
+ return false if flushable <= 0
92
+
93
+ emit(@buffer.byteslice(0, flushable))
94
+ @buffer = remaining_bytes(flushable)
95
+ false
96
+ end
97
+
98
+ def emit(data)
99
+ return if data.nil? || data.empty?
100
+
101
+ case @part_name
102
+ when 'file'
103
+ @on_file_chunk.call(data)
104
+ when 'error'
105
+ @error_buffer << data
106
+ end
107
+ end
108
+
109
+ def finalize_part!
110
+ return unless @part_name == 'error'
111
+
112
+ @error_message = extract_error_message(@error_buffer)
113
+ end
114
+
115
+ def extract_error_message(payload)
116
+ parsed = JSON.parse(payload)
117
+ parsed['message'] || parsed['error'] || payload
118
+ rescue JSON::ParserError
119
+ payload
120
+ end
121
+
122
+ def retain_tail?(size)
123
+ @buffer = @buffer.byteslice(-size, size) || String.new.b if size.positive? && @buffer.bytesize > size
124
+ false
125
+ end
126
+
127
+ def remaining_bytes(offset)
128
+ @buffer.byteslice(offset, @buffer.bytesize - offset) || String.new.b
129
+ end
130
+
131
+ def boundary
132
+ "--#{@boundary_token}".b
133
+ end
134
+ end
135
+
136
+ module FileTransfer
137
+ def self.extract_multipart_boundary(content_type)
138
+ match = content_type&.match(/boundary=(?:"([^"]+)"|([^;]+))/i)
139
+ return unless match
140
+
141
+ match.captures.compact.first
142
+ end
143
+
144
+ def self.stream_download(api_client:, remote_path:, timeout:, &) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
145
+ config = api_client.config
146
+ parser = MultipartDownloadStreamParser.new(&)
147
+ response = nil
148
+
149
+ request = Typhoeus::Request.new(
150
+ "#{config.base_url}/files/bulk-download",
151
+ method: :post,
152
+ headers: api_client.default_headers.dup.merge(
153
+ 'Accept' => 'multipart/form-data',
154
+ 'Content-Type' => 'application/json'
155
+ ),
156
+ body: JSON.generate(paths: [remote_path]),
157
+ timeout: timeout,
158
+ ssl_verifypeer: config.verify_ssl,
159
+ ssl_verifyhost: config.verify_ssl_host ? 2 : 0
160
+ )
161
+
162
+ request.on_headers do |stream_response|
163
+ boundary = extract_multipart_boundary(stream_response.headers['Content-Type'])
164
+ raise Sdk::Error, 'Missing multipart boundary in download response' unless boundary
165
+
166
+ parser.boundary_token = boundary
167
+ end
168
+
169
+ request.on_body do |chunk|
170
+ parser << chunk
171
+ end
172
+
173
+ request.on_complete do |completed_response|
174
+ response = completed_response
175
+ parser.finish!
176
+ end
177
+
178
+ request.run
179
+
180
+ raise Sdk::Error, parser.error_message if parser.error_message
181
+ raise Sdk::Error, "HTTP #{response.code}" if response && !response.success?
182
+ end
183
+ end
184
+ end
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Daytona
7
7
  module Sdk
8
- VERSION = '0.170.0'
8
+ VERSION = '0.171.0'
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: daytona
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.170.0
4
+ version: 0.171.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daytona Platforms Inc.
@@ -85,28 +85,28 @@ dependencies:
85
85
  requirements:
86
86
  - - '='
87
87
  - !ruby/object:Gem::Version
88
- version: 0.170.0
88
+ version: 0.171.0
89
89
  type: :runtime
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
93
  - - '='
94
94
  - !ruby/object:Gem::Version
95
- version: 0.170.0
95
+ version: 0.171.0
96
96
  - !ruby/object:Gem::Dependency
97
97
  name: daytona_toolbox_api_client
98
98
  requirement: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - '='
101
101
  - !ruby/object:Gem::Version
102
- version: 0.170.0
102
+ version: 0.171.0
103
103
  type: :runtime
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - '='
108
108
  - !ruby/object:Gem::Version
109
- version: 0.170.0
109
+ version: 0.171.0
110
110
  - !ruby/object:Gem::Dependency
111
111
  name: dotenv
112
112
  requirement: !ruby/object:Gem::Requirement
@@ -196,6 +196,7 @@ files:
196
196
  - lib/daytona/config.rb
197
197
  - lib/daytona/daytona.rb
198
198
  - lib/daytona/file_system.rb
199
+ - lib/daytona/file_transfer.rb
199
200
  - lib/daytona/git.rb
200
201
  - lib/daytona/lsp_server.rb
201
202
  - lib/daytona/object_storage.rb