daytona 0.148.0 → 0.149.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/lib/daytona/common/event_subscriber.rb +220 -0
- data/lib/daytona/common/socketio_client.rb +266 -0
- data/lib/daytona/daytona.rb +17 -1
- data/lib/daytona/sandbox.rb +159 -43
- data/lib/daytona/sdk/version.rb +1 -1
- data/lib/daytona/sdk.rb +2 -0
- metadata +9 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0111eaa3f2e1edf3951f4fb4c4ab90039a9bd264602f1f0da5c88b89982d6376
|
|
4
|
+
data.tar.gz: 29fb26a2e6c4f55e3263e5e6f774d0e8625d4bca444d23aae3bf9e3e53731eaf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2b03e16b5d7d31005cae3d297525415335143717d624b1fd842dfde1ddacebb267189a52077070ef0dd887c3e9d8814a54d2176be154dafb2b4e2050fff3446
|
|
7
|
+
data.tar.gz: da5ddce3d882d5f7fff7ad2d6f8e0b105fee37401742656c675743c8a41e51fb69c6c6f53815a29a9271818dc181685384c874e30c91ed309710cfc4a75a7cd3
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright 2025 Daytona Platforms Inc.
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require_relative 'socketio_client'
|
|
7
|
+
|
|
8
|
+
module Daytona
|
|
9
|
+
# Manages a Socket.IO connection and dispatches events to per-resource handlers.
|
|
10
|
+
# Generic — works for sandboxes, volumes, snapshots, runners, etc.
|
|
11
|
+
class EventSubscriber
|
|
12
|
+
# @param api_url [String]
|
|
13
|
+
# @param token [String]
|
|
14
|
+
# @param organization_id [String, nil]
|
|
15
|
+
def initialize(api_url:, token:, organization_id: nil)
|
|
16
|
+
@api_url = api_url
|
|
17
|
+
@token = token
|
|
18
|
+
@organization_id = organization_id
|
|
19
|
+
@client = nil
|
|
20
|
+
@connected = false
|
|
21
|
+
@failed = false
|
|
22
|
+
@fail_error = nil
|
|
23
|
+
@listeners = {}
|
|
24
|
+
@registered_events = Set.new
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@disconnect_timer = nil
|
|
27
|
+
@last_event_at = Time.now
|
|
28
|
+
@reconnecting = false
|
|
29
|
+
@close_requested = false
|
|
30
|
+
@max_reconnects = 10
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Idempotent: ensure a connection attempt is in progress or already established.
|
|
34
|
+
# Non-blocking. Starts a background Thread to connect if not already connected
|
|
35
|
+
# and no attempt is currently running.
|
|
36
|
+
# @return [void]
|
|
37
|
+
def ensure_connected
|
|
38
|
+
return if @connected
|
|
39
|
+
return if @connect_thread&.alive?
|
|
40
|
+
|
|
41
|
+
@connect_thread = Thread.new do
|
|
42
|
+
connect
|
|
43
|
+
rescue StandardError
|
|
44
|
+
# Callers check connected? when they need it
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Establish the Socket.IO connection.
|
|
49
|
+
# @return [void]
|
|
50
|
+
# @raise [StandardError] on connection failure
|
|
51
|
+
def connect
|
|
52
|
+
return if @connected
|
|
53
|
+
|
|
54
|
+
# Close any existing stale connection before creating a fresh one
|
|
55
|
+
@client&.close rescue nil # rubocop:disable Style/RescueModifier
|
|
56
|
+
|
|
57
|
+
@client = SocketIOClient.new(
|
|
58
|
+
api_url: @api_url,
|
|
59
|
+
token: @token,
|
|
60
|
+
organization_id: @organization_id,
|
|
61
|
+
on_event: method(:handle_event),
|
|
62
|
+
on_disconnect: method(:handle_disconnect)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@close_requested = false
|
|
66
|
+
@client.connect
|
|
67
|
+
@connected = true
|
|
68
|
+
@failed = false
|
|
69
|
+
@fail_error = nil
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
@failed = true
|
|
72
|
+
@fail_error = "WebSocket connection failed: #{e.message}"
|
|
73
|
+
raise
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Subscribe to specific events for a resource.
|
|
77
|
+
# @param resource_id [String] The ID of the resource (e.g. sandbox ID, volume ID).
|
|
78
|
+
# @param events [Array<String>] List of Socket.IO event names to listen for.
|
|
79
|
+
# @yield [event_name, data] Called with raw event name and data hash.
|
|
80
|
+
# @return [Proc] Unsubscribe function.
|
|
81
|
+
DISCONNECT_DELAY = 30
|
|
82
|
+
|
|
83
|
+
def subscribe(resource_id, events:, &handler)
|
|
84
|
+
# Cancel any pending delayed disconnect
|
|
85
|
+
@disconnect_timer&.kill
|
|
86
|
+
@disconnect_timer = nil
|
|
87
|
+
|
|
88
|
+
# Register any new events with the Socket.IO client (idempotent)
|
|
89
|
+
register_events(events)
|
|
90
|
+
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
@listeners[resource_id] ||= []
|
|
93
|
+
@listeners[resource_id] << handler
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
lambda {
|
|
97
|
+
should_schedule = false
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@listeners[resource_id]&.delete(handler)
|
|
100
|
+
@listeners.delete(resource_id) if @listeners[resource_id] && @listeners[resource_id].empty?
|
|
101
|
+
should_schedule = @listeners.empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Schedule delayed disconnect when no resources are listening anymore
|
|
105
|
+
if should_schedule
|
|
106
|
+
@disconnect_timer = Thread.new do
|
|
107
|
+
sleep(DISCONNECT_DELAY)
|
|
108
|
+
empty = @mutex.synchronize { @listeners.empty? }
|
|
109
|
+
disconnect if empty
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def connected?
|
|
117
|
+
@connected
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def failed?
|
|
122
|
+
@failed
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [String, nil]
|
|
126
|
+
attr_reader :fail_error
|
|
127
|
+
|
|
128
|
+
# Disconnect and clean up.
|
|
129
|
+
def disconnect
|
|
130
|
+
@close_requested = true
|
|
131
|
+
@client&.close
|
|
132
|
+
@connected = false
|
|
133
|
+
@mutex.synchronize { @listeners.clear }
|
|
134
|
+
@registered_events.clear
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Register Socket.IO event handlers (idempotent - each event is registered once).
|
|
140
|
+
# The SocketIOClient dispatches all events via the on_event callback, so we just
|
|
141
|
+
# need to track which events we care about for filtering in handle_event.
|
|
142
|
+
def register_events(events)
|
|
143
|
+
events.each { |evt| @registered_events.add(evt) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def handle_event(event_name, data)
|
|
147
|
+
@last_event_at = Time.now
|
|
148
|
+
|
|
149
|
+
# Only dispatch events that have been registered
|
|
150
|
+
return unless @registered_events.include?(event_name)
|
|
151
|
+
|
|
152
|
+
resource_id = extract_id_from_event(data)
|
|
153
|
+
return unless resource_id
|
|
154
|
+
|
|
155
|
+
dispatch(resource_id, event_name, data)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Extract resource ID from an event payload.
|
|
159
|
+
# Handles two payload shapes:
|
|
160
|
+
# - Wrapper: {sandbox: {id: ...}, ...} -> nested resource ID
|
|
161
|
+
# - Direct: {id: ...} -> top-level ID
|
|
162
|
+
def extract_id_from_event(data)
|
|
163
|
+
return nil unless data.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
%w[sandbox volume snapshot runner].each do |key|
|
|
166
|
+
nested = data[key]
|
|
167
|
+
next unless nested.is_a?(Hash)
|
|
168
|
+
|
|
169
|
+
sid = nested['id']
|
|
170
|
+
return sid if sid.is_a?(String)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
top_id = data['id']
|
|
174
|
+
return top_id if top_id.is_a?(String)
|
|
175
|
+
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def dispatch(resource_id, event_name, data)
|
|
180
|
+
handlers = @mutex.synchronize { @listeners[resource_id]&.dup || [] }
|
|
181
|
+
handlers.each do |handler|
|
|
182
|
+
handler.call(event_name, data)
|
|
183
|
+
rescue StandardError
|
|
184
|
+
# Don't let handler errors break other handlers
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def handle_disconnect
|
|
189
|
+
@connected = false
|
|
190
|
+
return if @close_requested
|
|
191
|
+
|
|
192
|
+
Thread.new { reconnect_loop }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def reconnect_loop
|
|
196
|
+
return if @reconnecting
|
|
197
|
+
|
|
198
|
+
@reconnecting = true
|
|
199
|
+
|
|
200
|
+
@max_reconnects.times do |attempt|
|
|
201
|
+
return if @close_requested
|
|
202
|
+
|
|
203
|
+
delay = [2**attempt, 30].min
|
|
204
|
+
sleep(delay)
|
|
205
|
+
return if @close_requested
|
|
206
|
+
|
|
207
|
+
connect
|
|
208
|
+
@reconnecting = false
|
|
209
|
+
return
|
|
210
|
+
rescue StandardError
|
|
211
|
+
# Continue retrying
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# All attempts failed
|
|
215
|
+
@failed = true
|
|
216
|
+
@fail_error = "WebSocket reconnection failed after #{@max_reconnects} attempts"
|
|
217
|
+
@reconnecting = false
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright 2025 Daytona Platforms Inc.
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
require 'websocket-client-simple'
|
|
9
|
+
require 'json'
|
|
10
|
+
require 'uri'
|
|
11
|
+
|
|
12
|
+
module Daytona
|
|
13
|
+
# Minimal Engine.IO/Socket.IO v4 client over raw WebSocket.
|
|
14
|
+
# Supports connect with auth, heartbeat, and event reception.
|
|
15
|
+
#
|
|
16
|
+
# Engine.IO v4 heartbeat protocol (WebSocket transport):
|
|
17
|
+
# - Server sends PING (type 2) every pingInterval ms
|
|
18
|
+
# - Client must respond with PONG (type 3) within pingTimeout ms
|
|
19
|
+
# - Client monitors for missing server PINGs to detect dead connections
|
|
20
|
+
class SocketIOClient
|
|
21
|
+
# Engine.IO v4 packet types
|
|
22
|
+
EIO_OPEN = '0'
|
|
23
|
+
EIO_CLOSE = '1'
|
|
24
|
+
EIO_PING = '2'
|
|
25
|
+
EIO_PONG = '3'
|
|
26
|
+
EIO_MESSAGE = '4'
|
|
27
|
+
|
|
28
|
+
# Socket.IO v4 packet types (inside Engine.IO messages)
|
|
29
|
+
SIO_CONNECT = '0'
|
|
30
|
+
SIO_DISCONNECT = '1'
|
|
31
|
+
SIO_EVENT = '2'
|
|
32
|
+
SIO_CONNECT_ERROR = '4'
|
|
33
|
+
|
|
34
|
+
attr_reader :connected
|
|
35
|
+
|
|
36
|
+
# @param api_url [String] The API URL (e.g., "https://app.daytona.io/api")
|
|
37
|
+
# @param token [String] Auth token (API key or JWT)
|
|
38
|
+
# @param organization_id [String, nil] Organization ID for room joining
|
|
39
|
+
# @param on_event [Proc] Called with (event_name, data_hash) for each Socket.IO event
|
|
40
|
+
# @param on_disconnect [Proc] Called when the connection is lost
|
|
41
|
+
# @param connect_timeout [Numeric] Connection timeout in seconds
|
|
42
|
+
def initialize(api_url:, token:, organization_id: nil, on_event: nil, on_disconnect: nil, connect_timeout: 5)
|
|
43
|
+
@api_url = api_url
|
|
44
|
+
@token = token
|
|
45
|
+
@organization_id = organization_id
|
|
46
|
+
@on_event = on_event
|
|
47
|
+
@on_disconnect = on_disconnect
|
|
48
|
+
@connect_timeout = connect_timeout
|
|
49
|
+
@connected = false
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
@write_mutex = Mutex.new
|
|
52
|
+
@health_thread = nil
|
|
53
|
+
@ping_interval = 25
|
|
54
|
+
@ping_timeout = 20
|
|
55
|
+
@last_server_activity = Time.now
|
|
56
|
+
@ws = nil
|
|
57
|
+
@close_requested = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Establish the WebSocket connection and perform Socket.IO handshake.
|
|
61
|
+
# @return [Boolean] true if connection succeeded
|
|
62
|
+
# @raise [StandardError] on connection failure
|
|
63
|
+
def connect
|
|
64
|
+
ws_url = build_ws_url
|
|
65
|
+
connected_queue = Queue.new
|
|
66
|
+
|
|
67
|
+
# Capture self because websocket-client-simple uses instance_exec for callbacks
|
|
68
|
+
client = self
|
|
69
|
+
|
|
70
|
+
@ws = WebSocket::Client::Simple.connect(ws_url)
|
|
71
|
+
|
|
72
|
+
@ws.on :message do |msg|
|
|
73
|
+
client.send(:handle_raw_message, msg.data.to_s, connected_queue)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@ws.on :error do |_e|
|
|
77
|
+
client.instance_variable_get(:@mutex).synchronize do
|
|
78
|
+
client.instance_variable_set(:@connected, false)
|
|
79
|
+
end
|
|
80
|
+
connected_queue.push(:error) unless client.connected?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@ws.on :close do
|
|
84
|
+
mutex = client.instance_variable_get(:@mutex)
|
|
85
|
+
was_connected = mutex.synchronize do
|
|
86
|
+
prev = client.instance_variable_get(:@connected)
|
|
87
|
+
client.instance_variable_set(:@connected, false)
|
|
88
|
+
prev
|
|
89
|
+
end
|
|
90
|
+
on_disconnect = client.instance_variable_get(:@on_disconnect)
|
|
91
|
+
close_requested = client.instance_variable_get(:@close_requested)
|
|
92
|
+
on_disconnect&.call if was_connected && !close_requested
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Wait for connection with timeout
|
|
96
|
+
result = nil
|
|
97
|
+
begin
|
|
98
|
+
Timeout.timeout(@connect_timeout) { result = connected_queue.pop }
|
|
99
|
+
rescue Timeout::Error
|
|
100
|
+
close
|
|
101
|
+
raise "WebSocket connection timed out after #{@connect_timeout}s"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
raise "WebSocket connection failed: #{result}" if result != :connected
|
|
105
|
+
|
|
106
|
+
@mutex.synchronize { @connected }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Boolean]
|
|
110
|
+
def connected?
|
|
111
|
+
@mutex.synchronize { @connected }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Gracefully close the connection.
|
|
115
|
+
def close
|
|
116
|
+
@close_requested = true
|
|
117
|
+
@health_thread&.kill
|
|
118
|
+
@health_thread = nil
|
|
119
|
+
|
|
120
|
+
send_raw(EIO_CLOSE) if @ws
|
|
121
|
+
@ws&.close
|
|
122
|
+
@mutex.synchronize { @connected = false }
|
|
123
|
+
rescue StandardError
|
|
124
|
+
# Ignore errors during close
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def build_ws_url
|
|
130
|
+
parsed = URI.parse(@api_url)
|
|
131
|
+
ws_scheme = parsed.scheme == 'https' ? 'wss' : 'ws'
|
|
132
|
+
host = parsed.host
|
|
133
|
+
port = parsed.port
|
|
134
|
+
|
|
135
|
+
query_parts = ['EIO=4', 'transport=websocket']
|
|
136
|
+
query_parts << "organizationId=#{URI.encode_www_form_component(@organization_id)}" if @organization_id
|
|
137
|
+
|
|
138
|
+
port_str = (parsed.scheme == 'https' && port == 443) || (parsed.scheme == 'http' && port == 80) ? '' : ":#{port}"
|
|
139
|
+
"#{ws_scheme}://#{host}#{port_str}/api/socket.io/?#{query_parts.join('&')}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def handle_raw_message(raw, connected_queue)
|
|
143
|
+
return if raw.nil? || raw.empty?
|
|
144
|
+
|
|
145
|
+
# Track all server activity for health monitoring.
|
|
146
|
+
# If the server stops sending ANY data (pings, events, etc.)
|
|
147
|
+
# the health monitor will detect the dead connection.
|
|
148
|
+
@last_server_activity = Time.now
|
|
149
|
+
|
|
150
|
+
case raw[0]
|
|
151
|
+
when EIO_OPEN
|
|
152
|
+
# Parse open payload for ping interval
|
|
153
|
+
begin
|
|
154
|
+
payload = JSON.parse(raw[1..])
|
|
155
|
+
@ping_interval = (payload['pingInterval'] || 25_000) / 1000.0
|
|
156
|
+
@ping_timeout = (payload['pingTimeout'] || 20_000) / 1000.0
|
|
157
|
+
rescue JSON::ParserError
|
|
158
|
+
# Use default ping interval
|
|
159
|
+
end
|
|
160
|
+
# Send Socket.IO CONNECT with auth
|
|
161
|
+
auth = JSON.generate({ token: @token })
|
|
162
|
+
send_raw("#{EIO_MESSAGE}#{SIO_CONNECT}#{auth}")
|
|
163
|
+
|
|
164
|
+
when EIO_PING
|
|
165
|
+
# Server heartbeat — respond immediately with PONG
|
|
166
|
+
send_raw(EIO_PONG)
|
|
167
|
+
|
|
168
|
+
when EIO_PONG
|
|
169
|
+
# Unexpected in EIO v4 (server doesn't respond to client pings),
|
|
170
|
+
# but handle gracefully — activity already tracked above.
|
|
171
|
+
nil
|
|
172
|
+
|
|
173
|
+
when EIO_MESSAGE
|
|
174
|
+
handle_socketio_packet(raw[1..], connected_queue)
|
|
175
|
+
|
|
176
|
+
when EIO_CLOSE
|
|
177
|
+
@mutex.synchronize { @connected = false }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def handle_socketio_packet(data, connected_queue)
|
|
182
|
+
return if data.nil? || data.empty?
|
|
183
|
+
|
|
184
|
+
case data[0]
|
|
185
|
+
when SIO_CONNECT
|
|
186
|
+
# Connection acknowledged
|
|
187
|
+
@mutex.synchronize { @connected = true }
|
|
188
|
+
start_health_monitor
|
|
189
|
+
connected_queue&.push(:connected)
|
|
190
|
+
|
|
191
|
+
when SIO_CONNECT_ERROR
|
|
192
|
+
# Connection rejected
|
|
193
|
+
error_msg = begin
|
|
194
|
+
payload = JSON.parse(data[1..])
|
|
195
|
+
payload['message'] || 'Unknown error'
|
|
196
|
+
rescue JSON::ParserError
|
|
197
|
+
data[1..]
|
|
198
|
+
end
|
|
199
|
+
@mutex.synchronize { @connected = false }
|
|
200
|
+
connected_queue&.push("Auth rejected: #{error_msg}")
|
|
201
|
+
|
|
202
|
+
when SIO_EVENT
|
|
203
|
+
handle_event(data[1..])
|
|
204
|
+
|
|
205
|
+
when SIO_DISCONNECT
|
|
206
|
+
@mutex.synchronize { @connected = false }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def handle_event(json_str)
|
|
211
|
+
return unless @on_event
|
|
212
|
+
|
|
213
|
+
# Skip namespace prefix if present (e.g., "/ns,")
|
|
214
|
+
if json_str&.start_with?('/')
|
|
215
|
+
comma_idx = json_str.index(',')
|
|
216
|
+
json_str = json_str[(comma_idx + 1)..] if comma_idx
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
event_array = JSON.parse(json_str)
|
|
220
|
+
return unless event_array.is_a?(Array) && event_array.length >= 1
|
|
221
|
+
|
|
222
|
+
event_name = event_array[0]
|
|
223
|
+
event_data = event_array[1]
|
|
224
|
+
|
|
225
|
+
@on_event.call(event_name, event_data)
|
|
226
|
+
rescue JSON::ParserError
|
|
227
|
+
# Malformed event, ignore
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Monitors connection health by checking for server activity.
|
|
231
|
+
# In Engine.IO v4, the server sends PING every pingInterval ms.
|
|
232
|
+
# If no server activity (pings, events, any data) is seen within
|
|
233
|
+
# pingInterval + pingTimeout, the connection is considered dead.
|
|
234
|
+
HEALTH_CHECK_INTERVAL = 5 # seconds — check frequently for fast detection
|
|
235
|
+
|
|
236
|
+
def start_health_monitor
|
|
237
|
+
@health_thread&.kill
|
|
238
|
+
@last_server_activity = Time.now
|
|
239
|
+
@health_thread = Thread.new do
|
|
240
|
+
loop do
|
|
241
|
+
sleep(HEALTH_CHECK_INTERVAL)
|
|
242
|
+
break unless connected?
|
|
243
|
+
|
|
244
|
+
if Time.now - @last_server_activity > @ping_interval + @ping_timeout
|
|
245
|
+
# No server activity within expected window — connection is dead
|
|
246
|
+
@mutex.synchronize { @connected = false }
|
|
247
|
+
@on_disconnect&.call unless @close_requested
|
|
248
|
+
# Force-close the dead WebSocket to ensure cleanup
|
|
249
|
+
@ws&.close rescue nil # rubocop:disable Style/RescueModifier
|
|
250
|
+
break
|
|
251
|
+
end
|
|
252
|
+
rescue StandardError
|
|
253
|
+
break
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def send_raw(msg)
|
|
259
|
+
@write_mutex.synchronize do
|
|
260
|
+
@ws&.send(msg)
|
|
261
|
+
end
|
|
262
|
+
rescue StandardError
|
|
263
|
+
# Ignore write errors
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
data/lib/daytona/daytona.rb
CHANGED
|
@@ -44,12 +44,27 @@ module Daytona
|
|
|
44
44
|
@snapshots_api = DaytonaApiClient::SnapshotsApi.new(api_client)
|
|
45
45
|
@snapshot = SnapshotService.new(snapshots_api:, object_storage_api:, default_region_id: config.target,
|
|
46
46
|
otel_state:)
|
|
47
|
+
# Event subscriber for real-time sandbox updates
|
|
48
|
+
@event_subscriber = nil
|
|
49
|
+
|
|
50
|
+
# Create and start WebSocket event subscriber connection in the background (non-blocking).
|
|
51
|
+
token = config.api_key || config.jwt_token
|
|
52
|
+
return unless token
|
|
53
|
+
|
|
54
|
+
@event_subscriber = EventSubscriber.new(
|
|
55
|
+
api_url: config.api_url,
|
|
56
|
+
token: token,
|
|
57
|
+
organization_id: config.organization_id
|
|
58
|
+
)
|
|
59
|
+
@event_subscriber.ensure_connected
|
|
47
60
|
end
|
|
48
61
|
|
|
49
62
|
# Shuts down OTel providers, flushing any pending telemetry data.
|
|
50
63
|
#
|
|
51
64
|
# @return [void]
|
|
52
65
|
def close
|
|
66
|
+
@event_subscriber&.disconnect
|
|
67
|
+
@event_subscriber = nil
|
|
53
68
|
::Daytona.shutdown_otel(@otel_state)
|
|
54
69
|
@otel_state = nil
|
|
55
70
|
end
|
|
@@ -265,7 +280,8 @@ module Daytona
|
|
|
265
280
|
config:,
|
|
266
281
|
sandbox_api:,
|
|
267
282
|
code_toolbox:,
|
|
268
|
-
otel_state: @otel_state
|
|
283
|
+
otel_state: @otel_state,
|
|
284
|
+
event_subscriber: @event_subscriber
|
|
269
285
|
)
|
|
270
286
|
end
|
|
271
287
|
|
data/lib/daytona/sandbox.rb
CHANGED
|
@@ -119,12 +119,13 @@ module Daytona
|
|
|
119
119
|
# @params sandbox_api [DaytonaApiClient::SandboxApi]
|
|
120
120
|
# @params sandbox_dto [DaytonaApiClient::Sandbox]
|
|
121
121
|
# @params otel_state [Daytona::OtelState, nil]
|
|
122
|
-
def initialize(code_toolbox:, sandbox_dto:, config:, sandbox_api:, otel_state: nil) # rubocop:disable Metrics/MethodLength
|
|
122
|
+
def initialize(code_toolbox:, sandbox_dto:, config:, sandbox_api:, otel_state: nil, event_subscriber: nil) # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
|
|
123
123
|
process_response(sandbox_dto)
|
|
124
124
|
@code_toolbox = code_toolbox
|
|
125
125
|
@config = config
|
|
126
126
|
@sandbox_api = sandbox_api
|
|
127
127
|
@otel_state = otel_state
|
|
128
|
+
@event_subscriber = event_subscriber
|
|
128
129
|
|
|
129
130
|
# Create toolbox API clients with dynamic configuration
|
|
130
131
|
toolbox_api_config = build_toolbox_api_config
|
|
@@ -165,6 +166,9 @@ module Daytona
|
|
|
165
166
|
)
|
|
166
167
|
@lsp_api = lsp_api
|
|
167
168
|
@info_api = info_api
|
|
169
|
+
|
|
170
|
+
# Subscribe to real-time events for this sandbox
|
|
171
|
+
subscribe_to_events
|
|
168
172
|
end
|
|
169
173
|
|
|
170
174
|
# Archives the sandbox, making it inactive and preserving its state. When sandboxes are
|
|
@@ -224,14 +228,26 @@ module Daytona
|
|
|
224
228
|
# @return [DaytonaApiClient::SshAccessDto]
|
|
225
229
|
def create_ssh_access(expires_in_minutes) = sandbox_api.create_ssh_access(id, { expires_in_minutes: })
|
|
226
230
|
|
|
231
|
+
# Deletes the Sandbox and waits for it to reach the 'destroyed' state.
|
|
232
|
+
#
|
|
233
|
+
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
|
|
227
234
|
# @return [void]
|
|
228
|
-
def delete
|
|
229
|
-
sandbox_api.delete_sandbox(id)
|
|
230
|
-
refresh
|
|
231
|
-
rescue DaytonaApiClient::ApiError => e
|
|
232
|
-
raise unless e.code == 404
|
|
235
|
+
def delete(timeout = DEFAULT_TIMEOUT)
|
|
236
|
+
process_response(sandbox_api.delete_sandbox(id))
|
|
233
237
|
|
|
234
|
-
|
|
238
|
+
return if state.to_s == DaytonaApiClient::SandboxState::DESTROYED.to_s
|
|
239
|
+
|
|
240
|
+
with_timeout(
|
|
241
|
+
timeout:,
|
|
242
|
+
message: "Sandbox #{id} failed to be destroyed within the #{timeout} seconds timeout period",
|
|
243
|
+
setup: nil
|
|
244
|
+
) do
|
|
245
|
+
wait_for_state(
|
|
246
|
+
target_states: [DaytonaApiClient::SandboxState::DESTROYED],
|
|
247
|
+
error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED],
|
|
248
|
+
safe_refresh: true
|
|
249
|
+
)
|
|
250
|
+
end
|
|
235
251
|
end
|
|
236
252
|
|
|
237
253
|
# Gets the user's home directory path for the logged in user inside the Sandbox.
|
|
@@ -340,7 +356,12 @@ module Daytona
|
|
|
340
356
|
timeout:,
|
|
341
357
|
message: "Sandbox #{id} failed to become ready within the #{timeout} seconds timeout period",
|
|
342
358
|
setup: proc { process_response(sandbox_api.start_sandbox(id)) }
|
|
343
|
-
)
|
|
359
|
+
) do
|
|
360
|
+
wait_for_state(
|
|
361
|
+
target_states: [DaytonaApiClient::SandboxState::STARTED],
|
|
362
|
+
error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
|
|
363
|
+
)
|
|
364
|
+
end
|
|
344
365
|
end
|
|
345
366
|
|
|
346
367
|
# Recovers the Sandbox from a recoverable error and waits for it to be ready.
|
|
@@ -357,7 +378,12 @@ module Daytona
|
|
|
357
378
|
timeout:,
|
|
358
379
|
message: "Sandbox #{id} failed to recover within the #{timeout} seconds timeout period",
|
|
359
380
|
setup: proc { process_response(sandbox_api.recover_sandbox(id)) }
|
|
360
|
-
)
|
|
381
|
+
) do
|
|
382
|
+
wait_for_state(
|
|
383
|
+
target_states: [DaytonaApiClient::SandboxState::STARTED],
|
|
384
|
+
error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
|
|
385
|
+
)
|
|
386
|
+
end
|
|
361
387
|
rescue StandardError => e
|
|
362
388
|
raise Sdk::Error, "Failed to recover sandbox: #{e.message}"
|
|
363
389
|
end
|
|
@@ -375,9 +401,9 @@ module Daytona
|
|
|
375
401
|
refresh
|
|
376
402
|
}
|
|
377
403
|
) do
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
404
|
+
wait_for_state(
|
|
405
|
+
target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED],
|
|
406
|
+
error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
|
|
381
407
|
)
|
|
382
408
|
end
|
|
383
409
|
end
|
|
@@ -420,9 +446,17 @@ module Daytona
|
|
|
420
446
|
#
|
|
421
447
|
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s)
|
|
422
448
|
# @return [void]
|
|
423
|
-
def wait_for_resize_complete(
|
|
424
|
-
|
|
425
|
-
|
|
449
|
+
def wait_for_resize_complete(timeout = DEFAULT_TIMEOUT)
|
|
450
|
+
with_timeout(
|
|
451
|
+
timeout:,
|
|
452
|
+
message: "Sandbox #{id} resize did not complete within the #{timeout} seconds timeout period",
|
|
453
|
+
setup: nil
|
|
454
|
+
) do
|
|
455
|
+
wait_for_state(
|
|
456
|
+
target_states: [DaytonaApiClient::SandboxState::STARTED, DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::ARCHIVED],
|
|
457
|
+
error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
|
|
458
|
+
)
|
|
459
|
+
end
|
|
426
460
|
end
|
|
427
461
|
|
|
428
462
|
# Creates a new Language Server Protocol (LSP) server instance.
|
|
@@ -448,8 +482,17 @@ module Daytona
|
|
|
448
482
|
#
|
|
449
483
|
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
|
|
450
484
|
# @return [void]
|
|
451
|
-
def wait_for_sandbox_start(
|
|
452
|
-
|
|
485
|
+
def wait_for_sandbox_start(timeout = DEFAULT_TIMEOUT)
|
|
486
|
+
with_timeout(
|
|
487
|
+
timeout:,
|
|
488
|
+
message: "Sandbox #{id} failed to start within the #{timeout} seconds timeout period",
|
|
489
|
+
setup: nil
|
|
490
|
+
) do
|
|
491
|
+
wait_for_state(
|
|
492
|
+
target_states: [DaytonaApiClient::SandboxState::STARTED],
|
|
493
|
+
error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
|
|
494
|
+
)
|
|
495
|
+
end
|
|
453
496
|
end
|
|
454
497
|
|
|
455
498
|
# Waits for the Sandbox to reach the 'stopped' state. Polls the Sandbox status until it
|
|
@@ -458,9 +501,17 @@ module Daytona
|
|
|
458
501
|
#
|
|
459
502
|
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
|
|
460
503
|
# @return [void]
|
|
461
|
-
def wait_for_sandbox_stop(
|
|
462
|
-
|
|
463
|
-
|
|
504
|
+
def wait_for_sandbox_stop(timeout = DEFAULT_TIMEOUT)
|
|
505
|
+
with_timeout(
|
|
506
|
+
timeout:,
|
|
507
|
+
message: "Sandbox #{id} failed to stop within the #{timeout} seconds timeout period",
|
|
508
|
+
setup: nil
|
|
509
|
+
) do
|
|
510
|
+
wait_for_state(
|
|
511
|
+
target_states: [DaytonaApiClient::SandboxState::STOPPED, DaytonaApiClient::SandboxState::DESTROYED],
|
|
512
|
+
error_states: [DaytonaApiClient::SandboxState::ERROR, DaytonaApiClient::SandboxState::BUILD_FAILED]
|
|
513
|
+
)
|
|
514
|
+
end
|
|
464
515
|
end
|
|
465
516
|
|
|
466
517
|
instrument :archive, :auto_archive_interval=, :auto_delete_interval=, :auto_stop_interval=,
|
|
@@ -545,40 +596,105 @@ module Daytona
|
|
|
545
596
|
)
|
|
546
597
|
end
|
|
547
598
|
|
|
548
|
-
# Waits for the Sandbox to reach the one of the target states
|
|
549
|
-
#
|
|
550
|
-
# for the Sandbox to reach one of the target states.
|
|
599
|
+
# Waits for the Sandbox to reach the one of the target states via WebSocket events
|
|
600
|
+
# with periodic polling safety net.
|
|
551
601
|
#
|
|
552
|
-
# @param
|
|
553
|
-
# @param
|
|
602
|
+
# @param target_states [Array<DaytonaApiClient::SandboxState>] States that indicate success.
|
|
603
|
+
# @param error_states [Array<DaytonaApiClient::SandboxState>] States that indicate failure.
|
|
604
|
+
# @param safe_refresh [Boolean] If true, wrap refresh in rescue for delete operations (404s).
|
|
554
605
|
# @return [void]
|
|
555
606
|
# @raise [Daytona::Sdk::Error]
|
|
556
|
-
def
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
607
|
+
def wait_for_state(target_states:, error_states:, safe_refresh: false) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
608
|
+
target_strings = target_states.map(&:to_s)
|
|
609
|
+
error_strings = error_states.map(&:to_s)
|
|
610
|
+
|
|
611
|
+
return if target_strings.include?(state.to_s)
|
|
612
|
+
|
|
613
|
+
if error_strings.include?(state.to_s)
|
|
614
|
+
raise Sdk::Error, "Sandbox #{id} is in error state: #{state}, error reason: #{error_reason}"
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
mutex = Mutex.new
|
|
618
|
+
state_changed = ConditionVariable.new
|
|
619
|
+
result_state = nil
|
|
620
|
+
|
|
621
|
+
unsubscribe = @event_subscriber&.subscribe(id, events: ['sandbox.state.updated']) do |_event_name, data|
|
|
622
|
+
next unless data.is_a?(Hash)
|
|
623
|
+
next if result_state # Already resolved
|
|
624
|
+
|
|
625
|
+
new_state = data['newState']
|
|
626
|
+
next unless new_state
|
|
627
|
+
|
|
628
|
+
if target_strings.include?(new_state) || error_strings.include?(new_state)
|
|
629
|
+
mutex.synchronize do
|
|
630
|
+
result_state = new_state
|
|
631
|
+
state_changed.signal
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
begin
|
|
637
|
+
mutex.synchronize do
|
|
638
|
+
until result_state
|
|
639
|
+
# Wait 1s for WebSocket event, then poll as safety net
|
|
640
|
+
state_changed.wait(mutex, POLL_SAFETY_INTERVAL)
|
|
641
|
+
|
|
642
|
+
break if result_state
|
|
643
|
+
|
|
644
|
+
# Poll: refresh data and check state
|
|
645
|
+
|
|
646
|
+
if safe_refresh
|
|
647
|
+
begin
|
|
648
|
+
refresh
|
|
649
|
+
rescue DaytonaApiClient::ApiError => e
|
|
650
|
+
@state = DaytonaApiClient::SandboxState::DESTROYED if e.code == 404
|
|
651
|
+
rescue StandardError
|
|
652
|
+
nil # ignore other refresh errors
|
|
653
|
+
end
|
|
654
|
+
else
|
|
655
|
+
refresh rescue nil # rubocop:disable Style/RescueModifier
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
return if target_strings.include?(state.to_s)
|
|
659
|
+
|
|
660
|
+
if error_strings.include?(state.to_s)
|
|
661
|
+
raise Sdk::Error,
|
|
662
|
+
"Sandbox #{id} is in error state: #{state}, error reason: #{error_reason}"
|
|
663
|
+
end
|
|
664
|
+
end
|
|
562
665
|
end
|
|
563
666
|
|
|
564
|
-
|
|
565
|
-
|
|
667
|
+
if result_state && error_strings.include?(result_state)
|
|
668
|
+
raise Sdk::Error,
|
|
669
|
+
"Sandbox #{id} entered error state: #{result_state}, error reason: #{error_reason}"
|
|
670
|
+
end
|
|
671
|
+
ensure
|
|
672
|
+
unsubscribe&.call
|
|
566
673
|
end
|
|
567
674
|
end
|
|
568
675
|
|
|
569
|
-
|
|
570
|
-
|
|
676
|
+
def subscribe_to_events
|
|
677
|
+
return unless @event_subscriber
|
|
571
678
|
|
|
572
|
-
|
|
573
|
-
|
|
679
|
+
@event_subscriber.ensure_connected
|
|
680
|
+
|
|
681
|
+
@event_subscriber.subscribe(
|
|
682
|
+
id,
|
|
683
|
+
events: ['sandbox.state.updated', 'sandbox.desired-state.updated', 'sandbox.created']
|
|
684
|
+
) do |_event_name, data|
|
|
685
|
+
next unless data.is_a?(Hash)
|
|
574
686
|
|
|
575
|
-
|
|
576
|
-
|
|
687
|
+
raw = data['sandbox'] || data
|
|
688
|
+
process_response(DaytonaApiClient::Sandbox.build_from_hash(raw)) if raw.is_a?(Hash)
|
|
689
|
+
rescue StandardError
|
|
690
|
+
nil # Event payload may be incomplete
|
|
691
|
+
end
|
|
692
|
+
end
|
|
577
693
|
|
|
578
|
-
|
|
579
|
-
private_constant :
|
|
694
|
+
POLL_SAFETY_INTERVAL = 1
|
|
695
|
+
private_constant :POLL_SAFETY_INTERVAL
|
|
580
696
|
|
|
581
|
-
|
|
582
|
-
private_constant :
|
|
697
|
+
NO_TIMEOUT = 0
|
|
698
|
+
private_constant :NO_TIMEOUT
|
|
583
699
|
end
|
|
584
700
|
end
|
data/lib/daytona/sdk/version.rb
CHANGED
data/lib/daytona/sdk.rb
CHANGED
|
@@ -27,6 +27,8 @@ require_relative 'computer_use'
|
|
|
27
27
|
require_relative 'code_toolbox/sandbox_python_code_toolbox'
|
|
28
28
|
require_relative 'code_toolbox/sandbox_ts_code_toolbox'
|
|
29
29
|
require_relative 'code_toolbox/sandbox_js_code_toolbox'
|
|
30
|
+
require_relative 'common/socketio_client'
|
|
31
|
+
require_relative 'common/event_subscriber'
|
|
30
32
|
require_relative 'daytona'
|
|
31
33
|
require_relative 'file_system'
|
|
32
34
|
require_relative 'git'
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: daytona
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.149.0.alpha.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daytona Platforms Inc.
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: opentelemetry-exporter-otlp
|
|
@@ -86,28 +85,28 @@ dependencies:
|
|
|
86
85
|
requirements:
|
|
87
86
|
- - '='
|
|
88
87
|
- !ruby/object:Gem::Version
|
|
89
|
-
version: 0.
|
|
88
|
+
version: 0.149.0.alpha.1
|
|
90
89
|
type: :runtime
|
|
91
90
|
prerelease: false
|
|
92
91
|
version_requirements: !ruby/object:Gem::Requirement
|
|
93
92
|
requirements:
|
|
94
93
|
- - '='
|
|
95
94
|
- !ruby/object:Gem::Version
|
|
96
|
-
version: 0.
|
|
95
|
+
version: 0.149.0.alpha.1
|
|
97
96
|
- !ruby/object:Gem::Dependency
|
|
98
97
|
name: daytona_toolbox_api_client
|
|
99
98
|
requirement: !ruby/object:Gem::Requirement
|
|
100
99
|
requirements:
|
|
101
100
|
- - '='
|
|
102
101
|
- !ruby/object:Gem::Version
|
|
103
|
-
version: 0.
|
|
102
|
+
version: 0.149.0.alpha.1
|
|
104
103
|
type: :runtime
|
|
105
104
|
prerelease: false
|
|
106
105
|
version_requirements: !ruby/object:Gem::Requirement
|
|
107
106
|
requirements:
|
|
108
107
|
- - '='
|
|
109
108
|
- !ruby/object:Gem::Version
|
|
110
|
-
version: 0.
|
|
109
|
+
version: 0.149.0.alpha.1
|
|
111
110
|
- !ruby/object:Gem::Dependency
|
|
112
111
|
name: dotenv
|
|
113
112
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -172,6 +171,7 @@ files:
|
|
|
172
171
|
- lib/daytona/common/code_interpreter.rb
|
|
173
172
|
- lib/daytona/common/code_language.rb
|
|
174
173
|
- lib/daytona/common/daytona.rb
|
|
174
|
+
- lib/daytona/common/event_subscriber.rb
|
|
175
175
|
- lib/daytona/common/file_system.rb
|
|
176
176
|
- lib/daytona/common/git.rb
|
|
177
177
|
- lib/daytona/common/image.rb
|
|
@@ -180,6 +180,7 @@ files:
|
|
|
180
180
|
- lib/daytona/common/resources.rb
|
|
181
181
|
- lib/daytona/common/response.rb
|
|
182
182
|
- lib/daytona/common/snapshot.rb
|
|
183
|
+
- lib/daytona/common/socketio_client.rb
|
|
183
184
|
- lib/daytona/computer_use.rb
|
|
184
185
|
- lib/daytona/config.rb
|
|
185
186
|
- lib/daytona/daytona.rb
|
|
@@ -207,7 +208,6 @@ metadata:
|
|
|
207
208
|
source_code_uri: https://github.com/daytonaio/daytona
|
|
208
209
|
changelog_uri: https://github.com/daytonaio/daytona/releases
|
|
209
210
|
rubygems_mfa_required: 'true'
|
|
210
|
-
post_install_message:
|
|
211
211
|
rdoc_options: []
|
|
212
212
|
require_paths:
|
|
213
213
|
- lib
|
|
@@ -222,8 +222,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
222
222
|
- !ruby/object:Gem::Version
|
|
223
223
|
version: '0'
|
|
224
224
|
requirements: []
|
|
225
|
-
rubygems_version: 3.
|
|
226
|
-
signing_key:
|
|
225
|
+
rubygems_version: 3.6.9
|
|
227
226
|
specification_version: 4
|
|
228
227
|
summary: Ruby SDK for Daytona
|
|
229
228
|
test_files: []
|