daytona 0.196.0 → 0.198.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/lib/daytona/common/daytona.rb +19 -5
- data/lib/daytona/common/event_dispatcher.rb +326 -0
- data/lib/daytona/common/event_subscription_manager.rb +144 -0
- data/lib/daytona/common/pty.rb +22 -7
- data/lib/daytona/common/socketio_client.rb +289 -0
- data/lib/daytona/config.rb +21 -0
- data/lib/daytona/daytona.rb +66 -6
- data/lib/daytona/file_system.rb +1 -1
- data/lib/daytona/lsp_server.rb +1 -1
- data/lib/daytona/process.rb +34 -18
- data/lib/daytona/sandbox.rb +544 -114
- data/lib/daytona/sdk/version.rb +1 -1
- data/lib/daytona/sdk.rb +6 -2
- data/project.json +4 -3
- metadata +22 -5
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright 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, source: nil, sdk_version: nil, on_event: nil,
|
|
43
|
+
on_disconnect: nil, connect_timeout: 5)
|
|
44
|
+
@api_url = api_url
|
|
45
|
+
@token = token
|
|
46
|
+
@organization_id = organization_id
|
|
47
|
+
@source = source
|
|
48
|
+
@sdk_version = sdk_version
|
|
49
|
+
@on_event = on_event
|
|
50
|
+
@on_disconnect = on_disconnect
|
|
51
|
+
@connect_timeout = connect_timeout
|
|
52
|
+
@connected = false
|
|
53
|
+
@mutex = Mutex.new
|
|
54
|
+
@write_mutex = Mutex.new
|
|
55
|
+
@health_thread = nil
|
|
56
|
+
@ping_interval = 25
|
|
57
|
+
@ping_timeout = 20
|
|
58
|
+
@last_server_activity = Time.now
|
|
59
|
+
@ws = nil
|
|
60
|
+
@close_requested = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Establish the WebSocket connection and perform Socket.IO handshake.
|
|
64
|
+
# @return [Boolean] true if connection succeeded
|
|
65
|
+
# @raise [StandardError] on connection failure
|
|
66
|
+
def connect
|
|
67
|
+
ws_url = build_ws_url
|
|
68
|
+
connected_queue = Queue.new
|
|
69
|
+
|
|
70
|
+
# Capture self because websocket-client-simple uses instance_exec for callbacks
|
|
71
|
+
client = self
|
|
72
|
+
|
|
73
|
+
# Register callbacks inside the connect block so they are installed before the
|
|
74
|
+
# socket dials — otherwise the Engine.IO handshake can arrive before :message
|
|
75
|
+
# is bound, stalling the connection into a polling fallback/timeout.
|
|
76
|
+
@ws = WebSocket::Client::Simple.connect(ws_url) do |ws|
|
|
77
|
+
ws.on :message do |msg|
|
|
78
|
+
client.send(:handle_raw_message, msg.data.to_s, connected_queue)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
ws.on :error do |_e|
|
|
82
|
+
was_connected = client.instance_variable_get(:@mutex).synchronize do
|
|
83
|
+
prev = client.instance_variable_get(:@connected)
|
|
84
|
+
client.instance_variable_set(:@connected, false)
|
|
85
|
+
prev
|
|
86
|
+
end
|
|
87
|
+
connected_queue.push(:error) unless was_connected
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
ws.on :close do
|
|
91
|
+
mutex = client.instance_variable_get(:@mutex)
|
|
92
|
+
was_connected = mutex.synchronize do
|
|
93
|
+
prev = client.instance_variable_get(:@connected)
|
|
94
|
+
client.instance_variable_set(:@connected, false)
|
|
95
|
+
prev
|
|
96
|
+
end
|
|
97
|
+
on_disconnect = client.instance_variable_get(:@on_disconnect)
|
|
98
|
+
close_requested = client.instance_variable_get(:@close_requested)
|
|
99
|
+
begin
|
|
100
|
+
on_disconnect&.call if was_connected && !close_requested
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Wait for connection with timeout
|
|
108
|
+
result = nil
|
|
109
|
+
begin
|
|
110
|
+
Timeout.timeout(@connect_timeout) { result = connected_queue.pop }
|
|
111
|
+
rescue Timeout::Error
|
|
112
|
+
close
|
|
113
|
+
raise "WebSocket connection timed out after #{@connect_timeout}s"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if result != :connected
|
|
117
|
+
close
|
|
118
|
+
raise "WebSocket connection failed: #{result}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
@mutex.synchronize { @connected }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Boolean]
|
|
125
|
+
def connected?
|
|
126
|
+
@mutex.synchronize { @connected }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Gracefully close the connection.
|
|
130
|
+
def close
|
|
131
|
+
@close_requested = true
|
|
132
|
+
@health_thread&.kill
|
|
133
|
+
@health_thread = nil
|
|
134
|
+
|
|
135
|
+
send_raw(EIO_CLOSE) if @ws
|
|
136
|
+
@ws&.close
|
|
137
|
+
@mutex.synchronize { @connected = false }
|
|
138
|
+
rescue StandardError
|
|
139
|
+
# Ignore errors during close
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def build_ws_url
|
|
145
|
+
parsed = URI.parse(@api_url)
|
|
146
|
+
ws_scheme = parsed.scheme == 'https' ? 'wss' : 'ws'
|
|
147
|
+
host = parsed.host
|
|
148
|
+
port = parsed.port
|
|
149
|
+
|
|
150
|
+
query_parts = ['EIO=4', 'transport=websocket']
|
|
151
|
+
query_parts << "organizationId=#{URI.encode_www_form_component(@organization_id)}" if @organization_id
|
|
152
|
+
query_parts << "source=#{URI.encode_www_form_component(@source)}" if @source
|
|
153
|
+
query_parts << "sdkVersion=#{URI.encode_www_form_component(@sdk_version)}" if @sdk_version
|
|
154
|
+
|
|
155
|
+
port_str = (parsed.scheme == 'https' && port == 443) || (parsed.scheme == 'http' && port == 80) ? '' : ":#{port}"
|
|
156
|
+
base_path = parsed.path.to_s.chomp('/')
|
|
157
|
+
"#{ws_scheme}://#{host}#{port_str}#{base_path}/socket.io/?#{query_parts.join('&')}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def handle_raw_message(raw, connected_queue)
|
|
161
|
+
return if raw.nil? || raw.empty?
|
|
162
|
+
|
|
163
|
+
# Track all server activity for health monitoring.
|
|
164
|
+
# If the server stops sending ANY data (pings, events, etc.)
|
|
165
|
+
# the health monitor will detect the dead connection.
|
|
166
|
+
@last_server_activity = Time.now
|
|
167
|
+
|
|
168
|
+
case raw[0]
|
|
169
|
+
when EIO_OPEN
|
|
170
|
+
# Parse open payload for ping interval
|
|
171
|
+
begin
|
|
172
|
+
payload = JSON.parse(raw[1..])
|
|
173
|
+
@ping_interval = (payload['pingInterval'] || 25_000) / 1000.0
|
|
174
|
+
@ping_timeout = (payload['pingTimeout'] || 20_000) / 1000.0
|
|
175
|
+
rescue JSON::ParserError
|
|
176
|
+
# Use default ping interval
|
|
177
|
+
end
|
|
178
|
+
# Send Socket.IO CONNECT with auth
|
|
179
|
+
auth = JSON.generate({ token: @token })
|
|
180
|
+
send_raw("#{EIO_MESSAGE}#{SIO_CONNECT}#{auth}")
|
|
181
|
+
|
|
182
|
+
when EIO_PING
|
|
183
|
+
# Server heartbeat — respond immediately with PONG
|
|
184
|
+
send_raw(EIO_PONG)
|
|
185
|
+
|
|
186
|
+
when EIO_PONG
|
|
187
|
+
# Unexpected in EIO v4 (server doesn't respond to client pings),
|
|
188
|
+
# but handle gracefully — activity already tracked above.
|
|
189
|
+
nil
|
|
190
|
+
|
|
191
|
+
when EIO_MESSAGE
|
|
192
|
+
handle_socketio_packet(raw[1..], connected_queue)
|
|
193
|
+
|
|
194
|
+
when EIO_CLOSE
|
|
195
|
+
@mutex.synchronize { @connected = false }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def handle_socketio_packet(data, connected_queue)
|
|
200
|
+
return if data.nil? || data.empty?
|
|
201
|
+
|
|
202
|
+
case data[0]
|
|
203
|
+
when SIO_CONNECT
|
|
204
|
+
# Connection acknowledged
|
|
205
|
+
@mutex.synchronize { @connected = true }
|
|
206
|
+
start_health_monitor
|
|
207
|
+
connected_queue&.push(:connected)
|
|
208
|
+
|
|
209
|
+
when SIO_CONNECT_ERROR
|
|
210
|
+
# Connection rejected
|
|
211
|
+
error_msg = begin
|
|
212
|
+
payload = JSON.parse(data[1..])
|
|
213
|
+
payload['message'] || 'Unknown error'
|
|
214
|
+
rescue JSON::ParserError
|
|
215
|
+
data[1..]
|
|
216
|
+
end
|
|
217
|
+
@mutex.synchronize { @connected = false }
|
|
218
|
+
connected_queue&.push("Auth rejected: #{error_msg}")
|
|
219
|
+
|
|
220
|
+
when SIO_EVENT
|
|
221
|
+
handle_event(data[1..])
|
|
222
|
+
|
|
223
|
+
when SIO_DISCONNECT
|
|
224
|
+
was_connected = @mutex.synchronize do
|
|
225
|
+
prev = @connected
|
|
226
|
+
@connected = false
|
|
227
|
+
prev
|
|
228
|
+
end
|
|
229
|
+
@on_disconnect&.call if was_connected && !@close_requested
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def handle_event(json_str)
|
|
234
|
+
return unless @on_event
|
|
235
|
+
|
|
236
|
+
# Skip namespace prefix if present (e.g., "/ns,")
|
|
237
|
+
if json_str&.start_with?('/')
|
|
238
|
+
comma_idx = json_str.index(',')
|
|
239
|
+
json_str = json_str[(comma_idx + 1)..] if comma_idx
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
event_array = JSON.parse(json_str)
|
|
243
|
+
return unless event_array.is_a?(Array) && event_array.length >= 1
|
|
244
|
+
|
|
245
|
+
event_name = event_array[0]
|
|
246
|
+
event_data = event_array[1]
|
|
247
|
+
|
|
248
|
+
@on_event.call(event_name, event_data)
|
|
249
|
+
rescue JSON::ParserError
|
|
250
|
+
# Malformed event, ignore
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Monitors connection health by checking for server activity.
|
|
254
|
+
# In Engine.IO v4, the server sends PING every pingInterval ms.
|
|
255
|
+
# If no server activity (pings, events, any data) is seen within
|
|
256
|
+
# pingInterval + pingTimeout, the connection is considered dead.
|
|
257
|
+
HEALTH_CHECK_INTERVAL = 5 # seconds — check frequently for fast detection
|
|
258
|
+
|
|
259
|
+
def start_health_monitor
|
|
260
|
+
@health_thread&.kill
|
|
261
|
+
@last_server_activity = Time.now
|
|
262
|
+
@health_thread = Thread.new do
|
|
263
|
+
loop do
|
|
264
|
+
sleep(HEALTH_CHECK_INTERVAL)
|
|
265
|
+
break unless connected?
|
|
266
|
+
|
|
267
|
+
if Time.now - @last_server_activity > @ping_interval + @ping_timeout
|
|
268
|
+
# No server activity within expected window — connection is dead
|
|
269
|
+
@mutex.synchronize { @connected = false }
|
|
270
|
+
@on_disconnect&.call unless @close_requested
|
|
271
|
+
# Force-close the dead WebSocket to ensure cleanup
|
|
272
|
+
@ws&.close rescue nil # rubocop:disable Style/RescueModifier
|
|
273
|
+
break
|
|
274
|
+
end
|
|
275
|
+
rescue StandardError
|
|
276
|
+
break
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def send_raw(msg)
|
|
282
|
+
@write_mutex.synchronize do
|
|
283
|
+
@ws&.send(msg)
|
|
284
|
+
end
|
|
285
|
+
rescue StandardError
|
|
286
|
+
# Ignore write errors
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
data/lib/daytona/config.rb
CHANGED
|
@@ -39,6 +39,16 @@ module Daytona
|
|
|
39
39
|
# @return [Boolean, nil]
|
|
40
40
|
attr_accessor :otel_enabled
|
|
41
41
|
|
|
42
|
+
# Observe sandbox state by legacy polling instead of WebSocket event streaming.
|
|
43
|
+
# Defaults to false (event streaming). Can also be enabled via the
|
|
44
|
+
# DAYTONA_USE_DEPRECATED_POLLING environment variable.
|
|
45
|
+
#
|
|
46
|
+
# @deprecated Polling-only mode will be removed in a future release;
|
|
47
|
+
# event streaming is the default and falls back to polling automatically
|
|
48
|
+
# when WebSockets are unavailable.
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
attr_accessor :use_deprecated_polling
|
|
51
|
+
|
|
42
52
|
# Experimental configuration options
|
|
43
53
|
#
|
|
44
54
|
# @return [Hash, nil] Experimental configuration hash
|
|
@@ -52,6 +62,9 @@ module Daytona
|
|
|
52
62
|
# @param organization_id [String, nil] Daytona organization ID. Defaults to ENV['DAYTONA_ORGANIZATION_ID'].
|
|
53
63
|
# @param target [String, nil] Daytona target. Defaults to ENV['DAYTONA_TARGET'].
|
|
54
64
|
# @param otel_enabled [Boolean, nil] Enable OpenTelemetry tracing for SDK operations.
|
|
65
|
+
# @param use_deprecated_polling [Boolean, nil] Observe sandbox state by legacy polling instead of
|
|
66
|
+
# WebSocket event streaming. Defaults to false (event streaming). Can also be enabled via the
|
|
67
|
+
# DAYTONA_USE_DEPRECATED_POLLING environment variable.
|
|
55
68
|
# @param _experimental [Hash, nil] Experimental configuration options.
|
|
56
69
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
57
70
|
api_key: nil,
|
|
@@ -60,6 +73,7 @@ module Daytona
|
|
|
60
73
|
organization_id: nil,
|
|
61
74
|
target: nil,
|
|
62
75
|
otel_enabled: nil,
|
|
76
|
+
use_deprecated_polling: nil,
|
|
63
77
|
_experimental: nil
|
|
64
78
|
)
|
|
65
79
|
@env_reader = daytona_env_reader
|
|
@@ -71,6 +85,7 @@ module Daytona
|
|
|
71
85
|
@organization_id = organization_id || @env_reader.call('DAYTONA_ORGANIZATION_ID')
|
|
72
86
|
@otel_enabled = otel_enabled
|
|
73
87
|
@_experimental = _experimental
|
|
88
|
+
@use_deprecated_polling = resolve_use_deprecated_polling(use_deprecated_polling)
|
|
74
89
|
end
|
|
75
90
|
|
|
76
91
|
# Reads a DAYTONA_-prefixed environment variable using the same precedence
|
|
@@ -105,5 +120,11 @@ module Daytona
|
|
|
105
120
|
def daytona_filter(env_hash)
|
|
106
121
|
env_hash.select { |k, _| k.start_with?('DAYTONA_') }
|
|
107
122
|
end
|
|
123
|
+
|
|
124
|
+
def resolve_use_deprecated_polling(use_deprecated_polling)
|
|
125
|
+
return use_deprecated_polling unless use_deprecated_polling.nil?
|
|
126
|
+
|
|
127
|
+
@env_reader.call('DAYTONA_USE_DEPRECATED_POLLING') == 'true'
|
|
128
|
+
end
|
|
108
129
|
end
|
|
109
130
|
end
|
data/lib/daytona/daytona.rb
CHANGED
|
@@ -48,18 +48,49 @@ module Daytona
|
|
|
48
48
|
@api_client = build_api_client
|
|
49
49
|
@sandbox_api = DaytonaApiClient::SandboxApi.new(api_client)
|
|
50
50
|
@config_api = DaytonaApiClient::ConfigApi.new(api_client)
|
|
51
|
+
@analytics_api_url = nil
|
|
52
|
+
@analytics_api_url_fetched = false
|
|
53
|
+
@analytics_api_url_mutex = Mutex.new
|
|
51
54
|
@volume = VolumeService.new(DaytonaApiClient::VolumesApi.new(api_client), otel_state:)
|
|
52
55
|
@secret = SecretService.new(DaytonaApiClient::SecretApi.new(api_client), otel_state:)
|
|
53
56
|
@object_storage_api = DaytonaApiClient::ObjectStorageApi.new(api_client)
|
|
54
57
|
@snapshots_api = DaytonaApiClient::SnapshotsApi.new(api_client)
|
|
55
|
-
@snapshot = SnapshotService.new(
|
|
56
|
-
|
|
58
|
+
@snapshot = SnapshotService.new(
|
|
59
|
+
snapshots_api:,
|
|
60
|
+
object_storage_api:,
|
|
61
|
+
default_region_id: config.target,
|
|
62
|
+
otel_state:
|
|
63
|
+
)
|
|
64
|
+
token = config.api_key || config.jwt_token
|
|
65
|
+
|
|
66
|
+
if config.use_deprecated_polling
|
|
67
|
+
warn(
|
|
68
|
+
'[DEPRECATION] Polling-only mode (use_deprecated_polling / DAYTONA_USE_DEPRECATED_POLLING) ' \
|
|
69
|
+
'is deprecated and will be removed in a future release.',
|
|
70
|
+
uplevel: 1
|
|
71
|
+
)
|
|
72
|
+
else
|
|
73
|
+
@event_dispatcher = EventDispatcher.new(
|
|
74
|
+
api_url: config.api_url,
|
|
75
|
+
token: token,
|
|
76
|
+
organization_id: config.organization_id,
|
|
77
|
+
source: SOURCE_RUBY,
|
|
78
|
+
sdk_version: Sdk::VERSION
|
|
79
|
+
)
|
|
80
|
+
@event_dispatcher.ensure_connected
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@subscription_manager = EventSubscriptionManager.new(@event_dispatcher)
|
|
57
84
|
end
|
|
58
85
|
|
|
59
86
|
# Shuts down OTel providers, flushing any pending telemetry data.
|
|
60
87
|
#
|
|
61
88
|
# @return [void]
|
|
62
89
|
def close
|
|
90
|
+
@subscription_manager&.shutdown
|
|
91
|
+
@subscription_manager = nil
|
|
92
|
+
@event_dispatcher&.disconnect
|
|
93
|
+
@event_dispatcher = nil
|
|
63
94
|
::Daytona.shutdown_otel(@otel_state)
|
|
64
95
|
@otel_state = nil
|
|
65
96
|
end
|
|
@@ -68,7 +99,8 @@ module Daytona
|
|
|
68
99
|
#
|
|
69
100
|
# @param params [Daytona::CreateSandboxFromSnapshotParams, Daytona::CreateSandboxFromImageParams, Nil] Sandbox creation parameters
|
|
70
101
|
# @return [Daytona::Sandbox] The created sandbox
|
|
71
|
-
# @raise [Daytona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
|
|
102
|
+
# @raise [Daytona::Sdk::Error] If auto_stop_interval, auto_pause_interval, or auto_archive_interval is negative,
|
|
103
|
+
# or if auto_stop_interval and auto_pause_interval are both non-zero
|
|
72
104
|
def create(params = nil, on_snapshot_create_logs: nil)
|
|
73
105
|
if params.nil?
|
|
74
106
|
params = CreateSandboxFromSnapshotParams.new(language: CodeLanguage::PYTHON)
|
|
@@ -87,8 +119,9 @@ module Daytona
|
|
|
87
119
|
# Deletes a Sandbox.
|
|
88
120
|
#
|
|
89
121
|
# @param sandbox [Daytona::Sandbox]
|
|
122
|
+
# @param wait [Boolean] When +true+, block until the Sandbox is destroyed.
|
|
90
123
|
# @return [void]
|
|
91
|
-
def delete(sandbox) = sandbox.delete
|
|
124
|
+
def delete(sandbox, wait: false) = sandbox.delete(wait:)
|
|
92
125
|
|
|
93
126
|
# Gets a Sandbox by its ID.
|
|
94
127
|
#
|
|
@@ -148,6 +181,18 @@ module Daytona
|
|
|
148
181
|
# @return [Daytona::OtelState, nil]
|
|
149
182
|
attr_reader :otel_state
|
|
150
183
|
|
|
184
|
+
# Resolves the deployment's Analytics API URL via /config, cached (including a nil
|
|
185
|
+
# result) for the client's lifetime. Errors are not cached, so failed lookups retry.
|
|
186
|
+
def analytics_api_url
|
|
187
|
+
@analytics_api_url_mutex.synchronize do
|
|
188
|
+
unless @analytics_api_url_fetched
|
|
189
|
+
@analytics_api_url = @config_api.config_controller_get_config.analytics_api_url
|
|
190
|
+
@analytics_api_url_fetched = true
|
|
191
|
+
end
|
|
192
|
+
@analytics_api_url
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
151
196
|
# Fetches a single page of sandboxes. Each call produces one OTel span
|
|
152
197
|
# ("Daytona.list_fetch_page") so that paginated iteration emits N spans
|
|
153
198
|
# for N pages.
|
|
@@ -192,7 +237,8 @@ module Daytona
|
|
|
192
237
|
# @param timeout [Numeric] Maximum wait time in seconds (defaults to 60 s).
|
|
193
238
|
# @param on_snapshot_create_logs [Proc]
|
|
194
239
|
# @return [Daytona::Sandbox] The created sandbox
|
|
195
|
-
# @raise [Daytona::Sdk::Error] If auto_stop_interval or auto_archive_interval is negative
|
|
240
|
+
# @raise [Daytona::Sdk::Error] If auto_stop_interval, auto_pause_interval, or auto_archive_interval is negative,
|
|
241
|
+
# or if auto_stop_interval and auto_pause_interval are both non-zero
|
|
196
242
|
def _create(params, timeout: 60, on_snapshot_create_logs: nil)
|
|
197
243
|
raise Sdk::Error, 'Timeout must be a non-negative number' if timeout.negative?
|
|
198
244
|
|
|
@@ -200,6 +246,17 @@ module Daytona
|
|
|
200
246
|
|
|
201
247
|
raise Sdk::Error, 'auto_stop_interval must be a non-negative integer' if params.auto_stop_interval&.negative?
|
|
202
248
|
|
|
249
|
+
raise Sdk::Error, 'auto_pause_interval must be a non-negative integer' if params.auto_pause_interval&.negative?
|
|
250
|
+
|
|
251
|
+
if params.auto_stop_interval&.positive? && params.auto_pause_interval&.positive?
|
|
252
|
+
raise Sdk::Error,
|
|
253
|
+
'auto_stop_interval and auto_pause_interval are mutually exclusive. Set at most one of them to a non-zero value'
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
if params.auto_pause_interval&.positive? && params.auto_delete_interval&.zero?
|
|
257
|
+
raise Sdk::Error, 'Ephemeral sandboxes cannot have auto-pause enabled. Set auto_pause_interval to 0'
|
|
258
|
+
end
|
|
259
|
+
|
|
203
260
|
if params.auto_archive_interval&.negative?
|
|
204
261
|
raise Sdk::Error, 'auto_archive_interval must be a non-negative integer'
|
|
205
262
|
end
|
|
@@ -214,6 +271,7 @@ module Daytona
|
|
|
214
271
|
public: params.public,
|
|
215
272
|
target: config.target,
|
|
216
273
|
auto_stop_interval: params.auto_stop_interval,
|
|
274
|
+
auto_pause_interval: params.auto_pause_interval,
|
|
217
275
|
auto_archive_interval: params.auto_archive_interval,
|
|
218
276
|
auto_delete_interval: params.auto_delete_interval,
|
|
219
277
|
volumes: params.volumes,
|
|
@@ -322,7 +380,9 @@ module Daytona
|
|
|
322
380
|
sandbox_dto:,
|
|
323
381
|
config:,
|
|
324
382
|
sandbox_api:,
|
|
325
|
-
otel_state: @otel_state
|
|
383
|
+
otel_state: @otel_state,
|
|
384
|
+
analytics_api_url_provider: method(:analytics_api_url),
|
|
385
|
+
subscription_manager: @subscription_manager
|
|
326
386
|
)
|
|
327
387
|
end
|
|
328
388
|
|
data/lib/daytona/file_system.rb
CHANGED
|
@@ -402,7 +402,7 @@ module Daytona
|
|
|
402
402
|
# end
|
|
403
403
|
# end
|
|
404
404
|
def replace_in_files(files:, pattern:, new_value:)
|
|
405
|
-
replace_request =
|
|
405
|
+
replace_request = DaytonaToolboxApiClient::ReplaceRequest.new(
|
|
406
406
|
files: files,
|
|
407
407
|
pattern: pattern,
|
|
408
408
|
new_value: new_value
|
data/lib/daytona/lsp_server.rb
CHANGED
|
@@ -56,7 +56,7 @@ module Daytona
|
|
|
56
56
|
language_id:,
|
|
57
57
|
path_to_project:,
|
|
58
58
|
uri: uri(path),
|
|
59
|
-
position:
|
|
59
|
+
position: DaytonaToolboxApiClient::LspPosition.new(line: position.line, character: position.character)
|
|
60
60
|
)
|
|
61
61
|
)
|
|
62
62
|
end
|
data/lib/daytona/process.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
# frozen_string_literal: true
|
|
5
5
|
|
|
6
|
+
require 'base64'
|
|
7
|
+
require 'json'
|
|
6
8
|
require 'uri'
|
|
7
9
|
|
|
8
10
|
module Daytona
|
|
@@ -410,19 +412,32 @@ module Daytona
|
|
|
410
412
|
# pty_handle.disconnect
|
|
411
413
|
#
|
|
412
414
|
# @raise [Daytona::Sdk::Error] If the PTY session creation fails or the session ID is already in use.
|
|
413
|
-
def create_pty_session(id:, cwd: nil, envs: nil, pty_size: nil) # rubocop:disable Metrics/MethodLength
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
lazy_start: true
|
|
422
|
-
)
|
|
423
|
-
)
|
|
415
|
+
def create_pty_session(id:, cwd: nil, envs: nil, pty_size: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
416
|
+
cols = pty_size&.cols || 80
|
|
417
|
+
rows = pty_size&.rows || 24
|
|
418
|
+
|
|
419
|
+
preview_link = get_preview_link.call(WS_PORT)
|
|
420
|
+
url = URI.parse(preview_link.url)
|
|
421
|
+
url.scheme = url.scheme == 'https' ? 'wss' : 'ws'
|
|
422
|
+
url.path = '/process/pty/create-connect'
|
|
424
423
|
|
|
425
|
-
|
|
424
|
+
params = { id:, cols:, rows: }
|
|
425
|
+
params[:cwd] = cwd if cwd
|
|
426
|
+
url.query = URI.encode_www_form(params)
|
|
427
|
+
|
|
428
|
+
headers = toolbox_api.api_client.default_headers.dup.merge(
|
|
429
|
+
'X-Daytona-Preview-Token' => preview_link.token
|
|
430
|
+
)
|
|
431
|
+
protocols = [headers['Sec-WebSocket-Protocol'], PTY_EXIT_CONTROL_SUBPROTOCOL].compact
|
|
432
|
+
protocols << "X-Daytona-Pty-Envs~#{Base64.urlsafe_encode64(envs.to_json, padding: false)}" if envs
|
|
433
|
+
headers['Sec-WebSocket-Protocol'] = protocols.join(', ')
|
|
434
|
+
|
|
435
|
+
PtyHandle.new(
|
|
436
|
+
WebSocket::Client::Simple.connect(url.to_s, headers:),
|
|
437
|
+
session_id: id,
|
|
438
|
+
handle_resize: ->(pty_size_arg) { resize_pty_session(id, pty_size_arg) },
|
|
439
|
+
handle_kill: -> { delete_pty_session(id) }
|
|
440
|
+
).tap(&:wait_for_connection)
|
|
426
441
|
end
|
|
427
442
|
|
|
428
443
|
# Connects to an existing PTY session in the Sandbox.
|
|
@@ -448,13 +463,14 @@ module Daytona
|
|
|
448
463
|
url.scheme = url.scheme == 'https' ? 'wss' : 'ws'
|
|
449
464
|
url.path = "/process/pty/#{session_id}/connect"
|
|
450
465
|
|
|
466
|
+
headers = toolbox_api.api_client.default_headers.dup.merge(
|
|
467
|
+
'X-Daytona-Preview-Token' => preview_link.token
|
|
468
|
+
)
|
|
469
|
+
headers['Sec-WebSocket-Protocol'] =
|
|
470
|
+
[headers['Sec-WebSocket-Protocol'], PTY_EXIT_CONTROL_SUBPROTOCOL].compact.join(', ')
|
|
471
|
+
|
|
451
472
|
handle = nil
|
|
452
|
-
WebSocket::Client::Simple.connect(
|
|
453
|
-
url.to_s,
|
|
454
|
-
headers: toolbox_api.api_client.default_headers.dup.merge(
|
|
455
|
-
'X-Daytona-Preview-Token' => preview_link.token
|
|
456
|
-
)
|
|
457
|
-
) do |client|
|
|
473
|
+
WebSocket::Client::Simple.connect(url.to_s, headers:) do |client|
|
|
458
474
|
handle = PtyHandle.new(
|
|
459
475
|
client,
|
|
460
476
|
session_id:,
|