daytona 0.173.0 → 0.176.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 +4 -4
- data/.rubocop.yml +6 -0
- data/lib/daytona/computer_use.rb +137 -0
- data/lib/daytona/file_system.rb +42 -4
- data/lib/daytona/file_transfer.rb +183 -19
- data/lib/daytona/git.rb +34 -0
- data/lib/daytona/sdk/version.rb +1 -1
- data/lib/daytona/sdk.rb +7 -0
- data/scripts/generate-docs.rb +6 -1
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7535ad9431345bb3c463fe8bfbeb3e0f296fb5aefd6e8a2208376aa71893fb77
|
|
4
|
+
data.tar.gz: 74f70acd3c6cb193bc8d471e437345ea6ed68bf65d6b8735fc1d0826306c8a03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e80742f74b83913585be67091015a0b39c6bbd4acb1c7d0743e73bde841940fe9cdce49d4a466cead18bd54b550888ba873925b80cb034995ae4dc0f7bb986d3
|
|
7
|
+
data.tar.gz: b123fc8cfd3c45d122f750777194f493982256550bb93525c5cb981b795d0aaa8e2f5d707ce0e00bb4ca2c76fabbfcc464bb3370787c6243e184ea1c21fbf61e
|
data/.rubocop.yml
CHANGED
|
@@ -14,3 +14,9 @@ Style/Documentation:
|
|
|
14
14
|
|
|
15
15
|
Style/AccessorGrouping:
|
|
16
16
|
EnforcedStyle: separated
|
|
17
|
+
|
|
18
|
+
# RSpec describe / context blocks naturally span the whole file; the
|
|
19
|
+
# stock 25-line cap forces noise instead of catching real complexity.
|
|
20
|
+
Metrics/BlockLength:
|
|
21
|
+
Exclude:
|
|
22
|
+
- 'spec/**/*'
|
data/lib/daytona/computer_use.rb
CHANGED
|
@@ -409,6 +409,139 @@ module Daytona
|
|
|
409
409
|
attr_reader :otel_state
|
|
410
410
|
end
|
|
411
411
|
|
|
412
|
+
# Accessibility operations for computer use functionality.
|
|
413
|
+
class Accessibility
|
|
414
|
+
include Instrumentation
|
|
415
|
+
|
|
416
|
+
# @return [String] The ID of the sandbox
|
|
417
|
+
attr_reader :sandbox_id
|
|
418
|
+
|
|
419
|
+
# @return [DaytonaToolboxApiClient::ComputerUseApi] API client for sandbox operations
|
|
420
|
+
attr_reader :toolbox_api
|
|
421
|
+
|
|
422
|
+
# @param sandbox_id [String] The ID of the sandbox
|
|
423
|
+
# @param toolbox_api [DaytonaToolboxApiClient::ComputerUseApi] API client for sandbox operations
|
|
424
|
+
# @param otel_state [Daytona::OtelState, nil]
|
|
425
|
+
def initialize(sandbox_id:, toolbox_api:, otel_state: nil)
|
|
426
|
+
@sandbox_id = sandbox_id
|
|
427
|
+
@toolbox_api = toolbox_api
|
|
428
|
+
@otel_state = otel_state
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Fetches the AT-SPI accessibility tree.
|
|
432
|
+
#
|
|
433
|
+
# @param scope [String, nil] Tree scope to inspect: "focused", "pid", or "all"
|
|
434
|
+
# @param pid [Integer, nil] Process ID when scope is "pid"
|
|
435
|
+
# @param max_depth [Integer, nil] Maximum depth to descend; 0 returns only the root
|
|
436
|
+
# @return [DaytonaToolboxApiClient::AccessibilityTreeResponse] Accessibility tree response
|
|
437
|
+
# @raise [Daytona::Sdk::Error] If the operation fails
|
|
438
|
+
#
|
|
439
|
+
# @example
|
|
440
|
+
# tree = sandbox.computer_use.accessibility.get_tree(scope: "all", max_depth: 3)
|
|
441
|
+
# puts tree.root.name
|
|
442
|
+
def get_tree(scope: nil, pid: nil, max_depth: nil)
|
|
443
|
+
opts = {}
|
|
444
|
+
opts[:scope] = scope unless scope.nil?
|
|
445
|
+
opts[:pid] = pid unless pid.nil?
|
|
446
|
+
opts[:max_depth] = max_depth unless max_depth.nil?
|
|
447
|
+
|
|
448
|
+
toolbox_api.get_accessibility_tree(opts)
|
|
449
|
+
rescue StandardError => e
|
|
450
|
+
raise Sdk::Error, "Failed to get accessibility tree: #{e.message}"
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Finds AT-SPI accessibility nodes matching the provided filters.
|
|
454
|
+
#
|
|
455
|
+
# @param scope [String, nil] Search scope: "focused", "pid", or "all"
|
|
456
|
+
# @param pid [Integer, nil] Process ID when scope is "pid"
|
|
457
|
+
# @param role [String, nil] Accessibility role to match, such as "button"
|
|
458
|
+
# @param name [String, nil] Accessible name to match
|
|
459
|
+
# @param name_match [String, nil] Name match mode, such as "exact" or "substring"
|
|
460
|
+
# @param states [Array<String>, nil] Required accessibility states
|
|
461
|
+
# @param limit [Integer, nil] Maximum number of matches
|
|
462
|
+
# @return [DaytonaToolboxApiClient::AccessibilityNodesResponse] Matching accessibility nodes
|
|
463
|
+
# @raise [Daytona::Sdk::Error] If the operation fails
|
|
464
|
+
#
|
|
465
|
+
# @example
|
|
466
|
+
# buttons = sandbox.computer_use.accessibility.find_nodes(
|
|
467
|
+
# scope: "all",
|
|
468
|
+
# role: "button",
|
|
469
|
+
# name: "Submit",
|
|
470
|
+
# name_match: "substring"
|
|
471
|
+
# )
|
|
472
|
+
# puts buttons.matches.length
|
|
473
|
+
def find_nodes(scope: nil, pid: nil, role: nil, name: nil, name_match: nil, states: nil, limit: nil)
|
|
474
|
+
attrs = {}
|
|
475
|
+
attrs[:scope] = scope unless scope.nil?
|
|
476
|
+
attrs[:pid] = pid unless pid.nil?
|
|
477
|
+
attrs[:role] = role unless role.nil?
|
|
478
|
+
attrs[:name] = name unless name.nil?
|
|
479
|
+
attrs[:name_match] = name_match unless name_match.nil?
|
|
480
|
+
attrs[:states] = states unless states.nil?
|
|
481
|
+
attrs[:limit] = limit unless limit.nil?
|
|
482
|
+
|
|
483
|
+
request = DaytonaToolboxApiClient::FindAccessibilityNodesRequest.new(attrs)
|
|
484
|
+
toolbox_api.find_accessibility_nodes(request)
|
|
485
|
+
rescue StandardError => e
|
|
486
|
+
raise Sdk::Error, "Failed to find accessibility nodes: #{e.message}"
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Focuses an AT-SPI accessibility node.
|
|
490
|
+
#
|
|
491
|
+
# @param id [String] Accessibility node ID returned by get_tree or find_nodes
|
|
492
|
+
# @raise [Daytona::Sdk::Error] If the operation fails
|
|
493
|
+
#
|
|
494
|
+
# @example
|
|
495
|
+
# sandbox.computer_use.accessibility.focus_node(id: node.id)
|
|
496
|
+
def focus_node(id:)
|
|
497
|
+
request = DaytonaToolboxApiClient::AccessibilityNodeRequest.new(id:)
|
|
498
|
+
toolbox_api.focus_accessibility_node(request)
|
|
499
|
+
rescue StandardError => e
|
|
500
|
+
raise Sdk::Error, "Failed to focus accessibility node: #{e.message}"
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Invokes an AT-SPI accessibility node action.
|
|
504
|
+
#
|
|
505
|
+
# @param id [String] Accessibility node ID returned by get_tree or find_nodes
|
|
506
|
+
# @param action [String, nil] Action name to invoke, or nil for the primary action
|
|
507
|
+
# @raise [Daytona::Sdk::Error] If the operation fails
|
|
508
|
+
#
|
|
509
|
+
# @example
|
|
510
|
+
# sandbox.computer_use.accessibility.invoke_node(id: node.id, action: "click")
|
|
511
|
+
def invoke_node(id:, action: nil)
|
|
512
|
+
attrs = { id: }
|
|
513
|
+
attrs[:action] = action unless action.nil?
|
|
514
|
+
|
|
515
|
+
request = DaytonaToolboxApiClient::AccessibilityInvokeRequest.new(attrs)
|
|
516
|
+
toolbox_api.invoke_accessibility_node(request)
|
|
517
|
+
rescue StandardError => e
|
|
518
|
+
raise Sdk::Error, "Failed to invoke accessibility node: #{e.message}"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Sets an AT-SPI accessibility node value.
|
|
522
|
+
#
|
|
523
|
+
# @param id [String] Accessibility node ID returned by get_tree or find_nodes
|
|
524
|
+
# @param value [String] Value to write to the node
|
|
525
|
+
# @raise [Daytona::Sdk::Error] If the operation fails
|
|
526
|
+
#
|
|
527
|
+
# @example
|
|
528
|
+
# sandbox.computer_use.accessibility.set_node_value(id: node.id, value: "hello")
|
|
529
|
+
def set_node_value(id:, value:)
|
|
530
|
+
request = DaytonaToolboxApiClient::AccessibilitySetValueRequest.new(id:, value:)
|
|
531
|
+
toolbox_api.set_accessibility_node_value(request)
|
|
532
|
+
rescue StandardError => e
|
|
533
|
+
raise Sdk::Error, "Failed to set accessibility node value: #{e.message}"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
instrument :get_tree, :find_nodes, :focus_node, :invoke_node, :set_node_value,
|
|
537
|
+
component: 'Accessibility'
|
|
538
|
+
|
|
539
|
+
private
|
|
540
|
+
|
|
541
|
+
# @return [Daytona::OtelState, nil]
|
|
542
|
+
attr_reader :otel_state
|
|
543
|
+
end
|
|
544
|
+
|
|
412
545
|
# Region coordinates for screenshot operations.
|
|
413
546
|
class ScreenshotRegion
|
|
414
547
|
# @return [Integer] X coordinate of the region
|
|
@@ -652,6 +785,9 @@ module Daytona
|
|
|
652
785
|
# @return [Recording] Screen recording operations interface
|
|
653
786
|
attr_reader :recording
|
|
654
787
|
|
|
788
|
+
# @return [Accessibility] Accessibility operations interface
|
|
789
|
+
attr_reader :accessibility
|
|
790
|
+
|
|
655
791
|
# Initialize a new ComputerUse instance.
|
|
656
792
|
#
|
|
657
793
|
# @param sandbox_id [String] The ID of the sandbox
|
|
@@ -666,6 +802,7 @@ module Daytona
|
|
|
666
802
|
@screenshot = Screenshot.new(sandbox_id:, toolbox_api:, otel_state:)
|
|
667
803
|
@display = Display.new(sandbox_id:, toolbox_api:, otel_state:)
|
|
668
804
|
@recording = Recording.new(sandbox_id:, toolbox_api:, otel_state:)
|
|
805
|
+
@accessibility = Accessibility.new(sandbox_id:, toolbox_api:, otel_state:)
|
|
669
806
|
end
|
|
670
807
|
|
|
671
808
|
# Starts all computer use processes (Xvfb, xfce4, x11vnc, novnc).
|
data/lib/daytona/file_system.rb
CHANGED
|
@@ -162,10 +162,17 @@ module Daytona
|
|
|
162
162
|
# based on the sandbox working directory.
|
|
163
163
|
# @param timeout [Integer] Timeout for the download operation in seconds. 0 means no timeout.
|
|
164
164
|
# Default is 30 minutes.
|
|
165
|
+
# @param on_progress [Proc, nil] Optional callback invoked with a Daytona::DownloadProgress
|
|
166
|
+
# struct containing bytes_received (Integer) and total_bytes (Integer or nil).
|
|
167
|
+
# @param cancel_event [#set?, nil] Optional cancellation token (anything responding to +set?+;
|
|
168
|
+
# the standard library's +Concurrent::Event+ or a small ad-hoc object both work). When set
|
|
169
|
+
# during streaming, the next chunk raises Daytona::Sdk::Error and the underlying HTTP
|
|
170
|
+
# connection is torn down.
|
|
165
171
|
# @yield [chunk] Yields each chunk of file content as it arrives
|
|
166
172
|
# @yieldparam chunk [String] A binary string chunk of file content
|
|
167
173
|
# @return [Enumerator, nil] An Enumerator yielding chunks if no block given, nil otherwise
|
|
168
|
-
# @raise [Daytona::Sdk::Error] If the file does not exist
|
|
174
|
+
# @raise [Daytona::Sdk::Error] If the file does not exist, the operation fails, or
|
|
175
|
+
# +cancel_event+ is set during streaming
|
|
169
176
|
#
|
|
170
177
|
# @example Stream to a local file without loading into memory
|
|
171
178
|
# File.open("local_copy.bin", "wb") do |f|
|
|
@@ -175,11 +182,12 @@ module Daytona
|
|
|
175
182
|
# @example Collect chunks with an Enumerator
|
|
176
183
|
# content = sandbox.fs.download_file_stream("workspace/data.json").reduce(:+)
|
|
177
184
|
# puts content
|
|
178
|
-
def download_file_stream(remote_path, timeout: 30 * 60, &)
|
|
179
|
-
return enum_for(__method__, remote_path, timeout:) unless block_given?
|
|
185
|
+
def download_file_stream(remote_path, timeout: 30 * 60, on_progress: nil, cancel_event: nil, &)
|
|
186
|
+
return enum_for(__method__, remote_path, timeout:, on_progress:, cancel_event:) unless block_given?
|
|
180
187
|
|
|
181
188
|
FileTransfer.stream_download(api_client: toolbox_api.api_client, remote_path: remote_path,
|
|
182
|
-
timeout: timeout,
|
|
189
|
+
timeout: timeout, on_progress: on_progress,
|
|
190
|
+
cancel_event: cancel_event, &)
|
|
183
191
|
nil
|
|
184
192
|
rescue StandardError => e
|
|
185
193
|
raise Sdk::Error, "Failed to download file: #{e.message}"
|
|
@@ -225,6 +233,36 @@ module Daytona
|
|
|
225
233
|
raise Sdk::Error, "Failed to upload file: #{e.message}"
|
|
226
234
|
end
|
|
227
235
|
|
|
236
|
+
# Streams +source+ to the Sandbox without buffering its contents in memory, with
|
|
237
|
+
# optional progress reporting.
|
|
238
|
+
#
|
|
239
|
+
# @param source [String, IO] A local file path or any IO-like object responding to
|
|
240
|
+
# +read(n)+. Strings that don't reference an existing file are uploaded as their
|
|
241
|
+
# raw bytes (still streamed, just from memory).
|
|
242
|
+
# @param remote_path [String] Destination path in the Sandbox.
|
|
243
|
+
# @param timeout [Integer] Timeout in seconds. 0 means no timeout. Default 30 minutes.
|
|
244
|
+
# @param on_progress [Proc, nil] Optional callback invoked with a
|
|
245
|
+
# +Daytona::UploadProgress+ struct as libcurl reports bytes actually uploaded.
|
|
246
|
+
# @param cancel_event [#set?, nil] Optional cancellation token. When set while
|
|
247
|
+
# staging a non-file source or during the libcurl upload, the operation raises
|
|
248
|
+
# Daytona::Sdk::Error and the in-progress upload is aborted (no destination file
|
|
249
|
+
# is left on the sandbox thanks to the daemon's atomic-rename behaviour).
|
|
250
|
+
# @return [void]
|
|
251
|
+
# @raise [Daytona::Sdk::Error] If the operation fails or +cancel_event+ is set.
|
|
252
|
+
#
|
|
253
|
+
# @example
|
|
254
|
+
# File.open("large.bin", "rb") do |f|
|
|
255
|
+
# sandbox.fs.upload_file_stream(f, "tmp/large.bin",
|
|
256
|
+
# on_progress: ->(p) { puts "#{p.bytes_sent} bytes sent" })
|
|
257
|
+
# end
|
|
258
|
+
def upload_file_stream(source, remote_path, timeout: 30 * 60, on_progress: nil, cancel_event: nil)
|
|
259
|
+
FileTransfer.stream_upload(api_client: toolbox_api.api_client, remote_path: remote_path,
|
|
260
|
+
source: source, timeout: timeout, on_progress: on_progress,
|
|
261
|
+
cancel_event: cancel_event)
|
|
262
|
+
rescue StandardError => e
|
|
263
|
+
raise Sdk::Error, "Failed to upload file: #{e.message}"
|
|
264
|
+
end
|
|
265
|
+
|
|
228
266
|
# Uploads multiple files to the Sandbox. If files already exist at the destination paths,
|
|
229
267
|
# they will be overwritten.
|
|
230
268
|
#
|
|
@@ -4,11 +4,20 @@
|
|
|
4
4
|
# frozen_string_literal: true
|
|
5
5
|
|
|
6
6
|
require 'json'
|
|
7
|
+
require 'stringio'
|
|
8
|
+
require 'tempfile'
|
|
7
9
|
require 'typhoeus'
|
|
8
10
|
|
|
9
11
|
module Daytona
|
|
12
|
+
# Progress information for a streaming download.
|
|
13
|
+
DownloadProgress = Struct.new(:bytes_received, :total_bytes, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# Progress information for a streaming upload.
|
|
16
|
+
UploadProgress = Struct.new(:bytes_sent, keyword_init: true)
|
|
17
|
+
|
|
10
18
|
class MultipartDownloadStreamParser
|
|
11
19
|
attr_reader :error_message
|
|
20
|
+
attr_reader :part_total_bytes
|
|
12
21
|
attr_writer :boundary_token
|
|
13
22
|
|
|
14
23
|
def initialize(&on_file_chunk)
|
|
@@ -17,6 +26,7 @@ module Daytona
|
|
|
17
26
|
@buffer = String.new.b
|
|
18
27
|
@state = :preamble
|
|
19
28
|
@part_name = nil
|
|
29
|
+
@part_total_bytes = nil
|
|
20
30
|
@error_buffer = String.new.b
|
|
21
31
|
end
|
|
22
32
|
|
|
@@ -62,14 +72,14 @@ module Daytona
|
|
|
62
72
|
end
|
|
63
73
|
|
|
64
74
|
def consume_headers?
|
|
65
|
-
|
|
66
|
-
index = @buffer.index(separator)
|
|
75
|
+
index = @buffer.index("\r\n\r\n".b)
|
|
67
76
|
return false unless index
|
|
68
77
|
|
|
69
78
|
headers = @buffer.byteslice(0, index)
|
|
70
|
-
@buffer = remaining_bytes(index +
|
|
71
|
-
@part_name = headers[/Content-Disposition:\s*[^\r\n]*\bname="([^"]+)"/i, 1]
|
|
72
|
-
|
|
79
|
+
@buffer = remaining_bytes(index + 4)
|
|
80
|
+
@part_name = headers[/Content-Disposition:\s*[^\r\n]*\bname="([^"]+)"/i, 1] ||
|
|
81
|
+
raise(Sdk::Error, 'Invalid multipart response')
|
|
82
|
+
@part_total_bytes = headers[/Content-Length:\s*(\d+)/i, 1]&.to_i
|
|
73
83
|
|
|
74
84
|
@state = :body
|
|
75
85
|
true
|
|
@@ -124,16 +134,11 @@ module Daytona
|
|
|
124
134
|
false
|
|
125
135
|
end
|
|
126
136
|
|
|
127
|
-
def remaining_bytes(offset)
|
|
128
|
-
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def boundary
|
|
132
|
-
"--#{@boundary_token}".b
|
|
133
|
-
end
|
|
137
|
+
def remaining_bytes(offset) = @buffer.byteslice(offset, @buffer.bytesize - offset) || String.new.b
|
|
138
|
+
def boundary = "--#{@boundary_token}".b
|
|
134
139
|
end
|
|
135
140
|
|
|
136
|
-
module FileTransfer
|
|
141
|
+
module FileTransfer # rubocop:disable Metrics/ModuleLength
|
|
137
142
|
def self.extract_multipart_boundary(content_type)
|
|
138
143
|
match = content_type&.match(/boundary=(?:"([^"]+)"|([^;]+))/i)
|
|
139
144
|
return unless match
|
|
@@ -141,9 +146,31 @@ module Daytona
|
|
|
141
146
|
match.captures.compact.first
|
|
142
147
|
end
|
|
143
148
|
|
|
144
|
-
def self.
|
|
149
|
+
def self.assign_download_boundary(parser, content_type)
|
|
150
|
+
boundary = extract_multipart_boundary(content_type)
|
|
151
|
+
raise Sdk::Error, 'Missing multipart boundary in download response' unless boundary
|
|
152
|
+
|
|
153
|
+
parser.boundary_token = boundary
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
157
|
+
def self.stream_download(api_client:, remote_path:, timeout:, on_progress: nil, cancel_event: nil, &block)
|
|
145
158
|
config = api_client.config
|
|
146
|
-
|
|
159
|
+
bytes_received = 0
|
|
160
|
+
parser = nil
|
|
161
|
+
wrapped_block = proc do |chunk|
|
|
162
|
+
raise Sdk::Error, "Download cancelled: #{remote_path}" if cancel_event&.set?
|
|
163
|
+
|
|
164
|
+
if on_progress
|
|
165
|
+
bytes_received += chunk.bytesize
|
|
166
|
+
on_progress.call(DownloadProgress.new(
|
|
167
|
+
bytes_received: bytes_received,
|
|
168
|
+
total_bytes: parser&.part_total_bytes
|
|
169
|
+
))
|
|
170
|
+
end
|
|
171
|
+
block.call(chunk)
|
|
172
|
+
end
|
|
173
|
+
parser = MultipartDownloadStreamParser.new(&wrapped_block)
|
|
147
174
|
response = nil
|
|
148
175
|
|
|
149
176
|
request = Typhoeus::Request.new(
|
|
@@ -160,13 +187,15 @@ module Daytona
|
|
|
160
187
|
)
|
|
161
188
|
|
|
162
189
|
request.on_headers do |stream_response|
|
|
163
|
-
|
|
164
|
-
raise Sdk::Error, 'Missing multipart boundary in download response' unless boundary
|
|
165
|
-
|
|
166
|
-
parser.boundary_token = boundary
|
|
190
|
+
assign_download_boundary(parser, stream_response.headers['Content-Type'])
|
|
167
191
|
end
|
|
168
192
|
|
|
193
|
+
# Returning +:abort+ from the on_body callback tells libcurl to tear down the
|
|
194
|
+
# connection immediately, which is how cancellation actually severs the
|
|
195
|
+
# transfer rather than just stopping our own bookkeeping.
|
|
169
196
|
request.on_body do |chunk|
|
|
197
|
+
next :abort if cancel_event&.set?
|
|
198
|
+
|
|
170
199
|
parser << chunk
|
|
171
200
|
end
|
|
172
201
|
|
|
@@ -177,8 +206,143 @@ module Daytona
|
|
|
177
206
|
|
|
178
207
|
request.run
|
|
179
208
|
|
|
209
|
+
raise Sdk::Error, "Download cancelled: #{remote_path}" if cancel_event&.set?
|
|
180
210
|
raise Sdk::Error, parser.error_message if parser.error_message
|
|
181
211
|
raise Sdk::Error, "HTTP #{response.code}" if response && !response.success?
|
|
182
212
|
end
|
|
213
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
214
|
+
|
|
215
|
+
# Uploads +source+ to /files/bulk-upload via Typhoeus (libcurl), which streams the
|
|
216
|
+
# request body straight from disk without buffering it in memory. Local file paths
|
|
217
|
+
# are uploaded directly; in-memory IOs/bytes are first drained to a tempfile so we
|
|
218
|
+
# have a stable file handle for libcurl.
|
|
219
|
+
#
|
|
220
|
+
# The daemon owns atomicity (writes to a sibling tempfile then renames), so a
|
|
221
|
+
# client-side abort just leaves no destination file at all.
|
|
222
|
+
#
|
|
223
|
+
# @param api_client The OpenAPI-generated toolbox API client (auth/base-url only).
|
|
224
|
+
# @param remote_path [String] Destination path in the sandbox.
|
|
225
|
+
# @param source [String, IO] Local file path or any IO-like object responding to +read(n)+.
|
|
226
|
+
# @param timeout [Integer] Typhoeus timeout in seconds (0 disables).
|
|
227
|
+
# @param on_progress [Proc, nil] Optional callback invoked with +Daytona::UploadProgress+
|
|
228
|
+
# as libcurl reports real network upload progress.
|
|
229
|
+
# @param cancel_event [#set?, nil] Optional cancellation token. Checked while staging
|
|
230
|
+
# non-file sources and during the libcurl transfer itself.
|
|
231
|
+
# rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
232
|
+
def self.stream_upload(api_client:, remote_path:, source:, timeout:, on_progress: nil, cancel_event: nil)
|
|
233
|
+
with_upload_file(source, cancel_event, remote_path) do |upload_path|
|
|
234
|
+
config = api_client.config
|
|
235
|
+
progress_callback = upload_progress_callback(on_progress, cancel_event)
|
|
236
|
+
response = with_open_upload_file(upload_path) do |file|
|
|
237
|
+
upload_request(
|
|
238
|
+
api_client: api_client,
|
|
239
|
+
config: config,
|
|
240
|
+
remote_path: remote_path,
|
|
241
|
+
file: file,
|
|
242
|
+
timeout: timeout,
|
|
243
|
+
progress_callback: progress_callback
|
|
244
|
+
).run
|
|
245
|
+
end
|
|
246
|
+
raise_upload_error(response, cancel_event, remote_path)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
# rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
|
|
250
|
+
|
|
251
|
+
# Yields a path on disk that holds the source's bytes, ready for libcurl to stream.
|
|
252
|
+
# Local files are passed through unchanged; everything else is drained into a
|
|
253
|
+
# tempfile that gets unlinked when we return.
|
|
254
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
255
|
+
def self.with_upload_file(source, cancel_event, remote_path)
|
|
256
|
+
raise Sdk::Error, "Upload cancelled: #{remote_path}" if cancel_event&.set?
|
|
257
|
+
|
|
258
|
+
return yield(source) if source.is_a?(String) && File.exist?(source)
|
|
259
|
+
|
|
260
|
+
tmp = Tempfile.new(['daytona-upload-', File.extname(remote_path).to_s])
|
|
261
|
+
tmp.binmode
|
|
262
|
+
begin
|
|
263
|
+
drain_source_to(source, tmp, cancel_event, remote_path)
|
|
264
|
+
tmp.flush
|
|
265
|
+
tmp.close
|
|
266
|
+
yield(tmp.path)
|
|
267
|
+
ensure
|
|
268
|
+
tmp.close unless tmp.closed?
|
|
269
|
+
begin
|
|
270
|
+
tmp.unlink
|
|
271
|
+
rescue StandardError
|
|
272
|
+
# tempfile already gone, nothing to do
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
277
|
+
|
|
278
|
+
def self.drain_source_to(source, sink, cancel_event, remote_path)
|
|
279
|
+
io, owns_io = open_drain_source(source)
|
|
280
|
+
begin
|
|
281
|
+
while (chunk = io.read(64 * 1024))
|
|
282
|
+
break if chunk.empty?
|
|
283
|
+
raise Sdk::Error, "Upload cancelled: #{remote_path}" if cancel_event&.set?
|
|
284
|
+
|
|
285
|
+
sink.write(chunk)
|
|
286
|
+
end
|
|
287
|
+
ensure
|
|
288
|
+
io.close if owns_io && io.respond_to?(:close)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def self.with_open_upload_file(upload_path)
|
|
293
|
+
file = File.open(upload_path, 'rb')
|
|
294
|
+
yield(file)
|
|
295
|
+
ensure
|
|
296
|
+
file.close if file && !file.closed?
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
300
|
+
def self.upload_request(api_client:, config:, remote_path:, file:, timeout:, progress_callback:)
|
|
301
|
+
Typhoeus::Request.new(
|
|
302
|
+
"#{config.base_url}/files/bulk-upload",
|
|
303
|
+
method: :post,
|
|
304
|
+
headers: api_client.default_headers.dup.tap { |h| h.delete('Content-Type') },
|
|
305
|
+
body: {
|
|
306
|
+
'files[0].path' => remote_path,
|
|
307
|
+
'files[0].file' => file
|
|
308
|
+
},
|
|
309
|
+
timeout: timeout,
|
|
310
|
+
ssl_verifypeer: config.verify_ssl,
|
|
311
|
+
ssl_verifyhost: config.verify_ssl_host ? 2 : 0,
|
|
312
|
+
noprogress: false,
|
|
313
|
+
progressfunction: progress_callback,
|
|
314
|
+
xferinfofunction: progress_callback
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
# rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
|
|
318
|
+
|
|
319
|
+
def self.upload_progress_callback(on_progress, cancel_event)
|
|
320
|
+
last_bytes_sent = -1
|
|
321
|
+
|
|
322
|
+
proc do |_clientp, _dltotal, _dlnow, _ultotal, ulnow|
|
|
323
|
+
next 1 if cancel_event&.set?
|
|
324
|
+
|
|
325
|
+
bytes_sent = ulnow.to_i
|
|
326
|
+
if on_progress && bytes_sent > last_bytes_sent
|
|
327
|
+
last_bytes_sent = bytes_sent
|
|
328
|
+
on_progress.call(UploadProgress.new(bytes_sent: bytes_sent))
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
0
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def self.raise_upload_error(response, _cancel_event, remote_path)
|
|
336
|
+
raise Sdk::Error, "Upload timed out: #{remote_path}" if response.timed_out?
|
|
337
|
+
raise Sdk::Error, "Upload cancelled: #{remote_path}" if response.return_code == :aborted_by_callback
|
|
338
|
+
raise Sdk::Error, "HTTP #{response.code}: #{response.body}" unless response.success?
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def self.open_drain_source(source)
|
|
342
|
+
return [source, false] if source.respond_to?(:read)
|
|
343
|
+
return [StringIO.new(source.b), true] if source.is_a?(String)
|
|
344
|
+
|
|
345
|
+
raise Sdk::Error, "Unsupported upload source: #{source.class}"
|
|
346
|
+
end
|
|
183
347
|
end
|
|
184
348
|
end
|
data/lib/daytona/git.rb
CHANGED
|
@@ -45,6 +45,8 @@ module Daytona
|
|
|
45
45
|
# ])
|
|
46
46
|
def add(path, files)
|
|
47
47
|
toolbox_api.add_files(DaytonaToolboxApiClient::GitAddRequest.new(path:, files:))
|
|
48
|
+
rescue DaytonaToolboxApiClient::ApiError => e
|
|
49
|
+
raise map_api_error(e, 'Failed to add files')
|
|
48
50
|
rescue StandardError => e
|
|
49
51
|
raise Sdk::Error, "Failed to add files: #{e.message}"
|
|
50
52
|
end
|
|
@@ -61,6 +63,8 @@ module Daytona
|
|
|
61
63
|
# puts "Branches: #{response.branches}"
|
|
62
64
|
def branches(path)
|
|
63
65
|
toolbox_api.list_branches(path)
|
|
66
|
+
rescue DaytonaToolboxApiClient::ApiError => e
|
|
67
|
+
raise map_api_error(e, 'Failed to list branches')
|
|
64
68
|
rescue StandardError => e
|
|
65
69
|
raise Sdk::Error, "Failed to list branches: #{e.message}"
|
|
66
70
|
end
|
|
@@ -114,6 +118,8 @@ module Daytona
|
|
|
114
118
|
commit_id: commit_id
|
|
115
119
|
)
|
|
116
120
|
)
|
|
121
|
+
rescue DaytonaToolboxApiClient::ApiError => e
|
|
122
|
+
raise map_api_error(e, 'Failed to clone repository')
|
|
117
123
|
rescue StandardError => e
|
|
118
124
|
raise Sdk::Error, "Failed to clone repository: #{e.message}"
|
|
119
125
|
end
|
|
@@ -146,6 +152,8 @@ module Daytona
|
|
|
146
152
|
DaytonaToolboxApiClient::GitCommitRequest.new(path:, message:, author:, email:, allow_empty:)
|
|
147
153
|
)
|
|
148
154
|
GitCommitResponse.new(sha: response._hash)
|
|
155
|
+
rescue DaytonaToolboxApiClient::ApiError => e
|
|
156
|
+
raise map_api_error(e, 'Failed to commit changes')
|
|
149
157
|
rescue StandardError => e
|
|
150
158
|
raise Sdk::Error, "Failed to commit changes: #{e.message}"
|
|
151
159
|
end
|
|
@@ -175,6 +183,8 @@ module Daytona
|
|
|
175
183
|
toolbox_api.push_changes(
|
|
176
184
|
DaytonaToolboxApiClient::GitRepoRequest.new(path:, username:, password:)
|
|
177
185
|
)
|
|
186
|
+
rescue DaytonaToolboxApiClient::ApiError => e
|
|
187
|
+
raise map_api_error(e, 'Failed to push changes')
|
|
178
188
|
rescue StandardError => e
|
|
179
189
|
raise Sdk::Error, "Failed to push changes: #{e.message}"
|
|
180
190
|
end
|
|
@@ -204,6 +214,8 @@ module Daytona
|
|
|
204
214
|
toolbox_api.pull_changes(
|
|
205
215
|
DaytonaToolboxApiClient::GitRepoRequest.new(path:, username:, password:)
|
|
206
216
|
)
|
|
217
|
+
rescue DaytonaToolboxApiClient::ApiError => e
|
|
218
|
+
raise map_api_error(e, 'Failed to pull changes')
|
|
207
219
|
rescue StandardError => e
|
|
208
220
|
raise Sdk::Error, "Failed to pull changes: #{e.message}"
|
|
209
221
|
end
|
|
@@ -222,6 +234,8 @@ module Daytona
|
|
|
222
234
|
# puts "Commits behind: #{status.behind}"
|
|
223
235
|
def status(path)
|
|
224
236
|
toolbox_api.get_status(path)
|
|
237
|
+
rescue DaytonaToolboxApiClient::ApiError => e
|
|
238
|
+
raise map_api_error(e, 'Failed to get status')
|
|
225
239
|
rescue StandardError => e
|
|
226
240
|
raise Sdk::Error, "Failed to get status: #{e.message}"
|
|
227
241
|
end
|
|
@@ -241,6 +255,8 @@ module Daytona
|
|
|
241
255
|
toolbox_api.checkout_branch(
|
|
242
256
|
DaytonaToolboxApiClient::GitCheckoutRequest.new(path:, branch:)
|
|
243
257
|
)
|
|
258
|
+
rescue DaytonaToolboxApiClient::ApiError => e
|
|
259
|
+
raise map_api_error(e, 'Failed to checkout branch')
|
|
244
260
|
rescue StandardError => e
|
|
245
261
|
raise Sdk::Error, "Failed to checkout branch: #{e.message}"
|
|
246
262
|
end
|
|
@@ -261,6 +277,8 @@ module Daytona
|
|
|
261
277
|
toolbox_api.create_branch(
|
|
262
278
|
DaytonaToolboxApiClient::GitBranchRequest.new(path:, name:)
|
|
263
279
|
)
|
|
280
|
+
rescue DaytonaToolboxApiClient::ApiError => e
|
|
281
|
+
raise map_api_error(e, 'Failed to create branch')
|
|
264
282
|
rescue StandardError => e
|
|
265
283
|
raise Sdk::Error, "Failed to create branch: #{e.message}"
|
|
266
284
|
end
|
|
@@ -280,6 +298,8 @@ module Daytona
|
|
|
280
298
|
toolbox_api.delete_branch(
|
|
281
299
|
DaytonaToolboxApiClient::GitDeleteBranchRequest.new(path:, name:)
|
|
282
300
|
)
|
|
301
|
+
rescue DaytonaToolboxApiClient::ApiError => e
|
|
302
|
+
raise map_api_error(e, 'Failed to delete branch')
|
|
283
303
|
rescue StandardError => e
|
|
284
304
|
raise Sdk::Error, "Failed to delete branch: #{e.message}"
|
|
285
305
|
end
|
|
@@ -292,5 +312,19 @@ module Daytona
|
|
|
292
312
|
|
|
293
313
|
# @return [Daytona::OtelState, nil]
|
|
294
314
|
attr_reader :otel_state
|
|
315
|
+
|
|
316
|
+
def map_api_error(api_error, prefix)
|
|
317
|
+
msg = "#{prefix}: #{api_error.message}"
|
|
318
|
+
case api_error.code
|
|
319
|
+
when 400 then Sdk::ValidationError.new(msg)
|
|
320
|
+
when 401 then Sdk::AuthenticationError.new(msg)
|
|
321
|
+
when 403 then Sdk::ForbiddenError.new(msg)
|
|
322
|
+
when 404 then Sdk::NotFoundError.new(msg)
|
|
323
|
+
when 409 then Sdk::ConflictError.new(msg)
|
|
324
|
+
when 429 then Sdk::RateLimitError.new(msg)
|
|
325
|
+
when 500..599 then Sdk::ServerError.new(msg)
|
|
326
|
+
else Sdk::Error.new(msg)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
295
329
|
end
|
|
296
330
|
end
|
data/lib/daytona/sdk/version.rb
CHANGED
data/lib/daytona/sdk.rb
CHANGED
|
@@ -43,6 +43,13 @@ module Daytona
|
|
|
43
43
|
module Sdk
|
|
44
44
|
class Error < StandardError; end
|
|
45
45
|
class TimeoutError < Error; end
|
|
46
|
+
class AuthenticationError < Error; end
|
|
47
|
+
class ForbiddenError < Error; end
|
|
48
|
+
class NotFoundError < Error; end
|
|
49
|
+
class ConflictError < Error; end
|
|
50
|
+
class ValidationError < Error; end
|
|
51
|
+
class RateLimitError < Error; end
|
|
52
|
+
class ServerError < Error; end
|
|
46
53
|
|
|
47
54
|
def self.logger = @logger ||= Logger.new($stdout, level: Logger::INFO)
|
|
48
55
|
end
|
data/scripts/generate-docs.rb
CHANGED
|
@@ -23,6 +23,7 @@ CLASSES_TO_DOCUMENT = [
|
|
|
23
23
|
['volume.rb', 'volume.mdx', 'Daytona::Volume'],
|
|
24
24
|
['object_storage.rb', 'object-storage.mdx', 'Daytona::ObjectStorage'],
|
|
25
25
|
['computer_use.rb', 'computer-use.mdx', 'Daytona::ComputerUse'],
|
|
26
|
+
['computer_use.rb', 'computer-use.mdx', 'Daytona::ComputerUse::Accessibility'],
|
|
26
27
|
['snapshot_service.rb', 'snapshot.mdx', 'Daytona::SnapshotService'],
|
|
27
28
|
['volume_service.rb', 'volume-service.mdx', 'Daytona::VolumeService'],
|
|
28
29
|
['common/charts.rb', 'charts.mdx', 'Daytona::Chart'],
|
|
@@ -371,7 +372,11 @@ def generate_docs_for_class(file_path, output_filename, class_name)
|
|
|
371
372
|
|
|
372
373
|
# Write to output file
|
|
373
374
|
output_path = File.join(DOCS_OUTPUT_DIR, output_filename)
|
|
374
|
-
File.
|
|
375
|
+
if File.exist?(output_path)
|
|
376
|
+
File.write(output_path, "#{File.read(output_path).rstrip}\n\n#{markdown_content}")
|
|
377
|
+
else
|
|
378
|
+
File.write(output_path, final_content)
|
|
379
|
+
end
|
|
375
380
|
|
|
376
381
|
puts "✅ Generated: #{output_filename}"
|
|
377
382
|
rescue StandardError => e
|
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.
|
|
4
|
+
version: 0.176.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.
|
|
88
|
+
version: 0.176.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.
|
|
95
|
+
version: 0.176.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.
|
|
102
|
+
version: 0.176.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.
|
|
109
|
+
version: 0.176.0
|
|
110
110
|
- !ruby/object:Gem::Dependency
|
|
111
111
|
name: dotenv
|
|
112
112
|
requirement: !ruby/object:Gem::Requirement
|