daytona-sdk 0.125.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.
@@ -0,0 +1,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Daytona
8
+ class Process # rubocop:disable Metrics/ClassLength
9
+ # @return [Daytona::SandboxPythonCodeToolbox,
10
+ attr_reader :code_toolbox
11
+
12
+ # @return [String] The ID of the Sandbox
13
+ attr_reader :sandbox_id
14
+
15
+ # @return [DaytonaApiClient::ToolboxApi] API client for Sandbox operations
16
+ attr_reader :toolbox_api
17
+
18
+ # @return [Proc] Function to get preview link for a port
19
+ attr_reader :get_preview_link
20
+
21
+ # Initialize a new Process instance
22
+ #
23
+ # @param code_toolbox [Daytona::SandboxPythonCodeToolbox, Daytona::SandboxTsCodeToolbox]
24
+ # @param sandbox_id [String] The ID of the Sandbox
25
+ # @param toolbox_api [DaytonaApiClient::ToolboxApi] API client for Sandbox operations
26
+ # @param get_preview_link [Proc] Function to get preview link for a port
27
+ def initialize(code_toolbox:, sandbox_id:, toolbox_api:, get_preview_link:)
28
+ @code_toolbox = code_toolbox
29
+ @sandbox_id = sandbox_id
30
+ @toolbox_api = toolbox_api
31
+ @get_preview_link = get_preview_link
32
+ end
33
+
34
+ # Execute a shell command in the Sandbox
35
+ #
36
+ # @param command [String] Shell command to execute
37
+ # @param cwd [String, nil] Working directory for command execution. If not specified, uses the sandbox working directory
38
+ # @param env [Hash<String, String>, nil] Environment variables to set for the command
39
+ # @param timeout [Integer, nil] Maximum time in seconds to wait for the command to complete. 0 means wait indefinitely
40
+ # @return [ExecuteResponse] Command execution results containing exit_code, result, and artifacts
41
+ #
42
+ # @example
43
+ # # Simple command
44
+ # response = sandbox.process.exec("echo 'Hello'")
45
+ # puts response.artifacts.stdout
46
+ # => "Hello\n"
47
+ #
48
+ # # Command with working directory
49
+ # result = sandbox.process.exec("ls", cwd: "workspace/src")
50
+ #
51
+ # # Command with timeout
52
+ # result = sandbox.process.exec("sleep 10", timeout: 5)
53
+ def exec(command:, cwd: nil, env: nil, timeout: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
54
+ command = "echo '#{Base64.encode64(command)}' | base64 -d | sh"
55
+
56
+ if env && !env.empty?
57
+ safe_env_exports = env.map do |key, value|
58
+ "export #{key}=$(echo '#{Base64.encode64(value)}' | base64 -d)"
59
+ end.join(';')
60
+ command = "#{safe_env_exports}; #{command}"
61
+ end
62
+
63
+ command = "sh -c \"#{command}\""
64
+
65
+ response = toolbox_api.execute_command(sandbox_id, DaytonaApiClient::ExecuteRequest.new(command:, cwd:, timeout:))
66
+ # Post-process the output to extract ExecutionArtifacts
67
+ artifacts = parse_output(response.result.split("\n"))
68
+
69
+ # Create new response with processed output and charts
70
+ ExecuteResponse.new(
71
+ exit_code: response.exit_code,
72
+ result: artifacts.stdout,
73
+ artifacts: artifacts
74
+ )
75
+ end
76
+
77
+ # Execute code in the Sandbox using the appropriate language runtime
78
+ #
79
+ # @param code [String] Code to execute
80
+ # @param params [CodeRunParams, nil] Parameters for code execution
81
+ # @param timeout [Integer, nil] Maximum time in seconds to wait for the code to complete. 0 means wait indefinitely
82
+ # @return [ExecuteResponse] Code execution result containing exit_code, result, and artifacts
83
+ #
84
+ # @example
85
+ # # Run Python code
86
+ # response = sandbox.process.code_run(<<~CODE)
87
+ # x = 10
88
+ # y = 20
89
+ # print(f"Sum: {x + y}")
90
+ # CODE
91
+ # puts response.artifacts.stdout # Prints: Sum: 30
92
+ def code_run(code:, params: nil, timeout: nil)
93
+ exec(command: code_toolbox.get_run_command(code, params), env: params&.env, timeout:)
94
+ end
95
+
96
+ # Creates a new long-running background session in the Sandbox
97
+ #
98
+ # Sessions are background processes that maintain state between commands, making them ideal for
99
+ # scenarios requiring multiple related commands or persistent environment setup.
100
+ #
101
+ # @param session_id [String] Unique identifier for the new session
102
+ # @return [void]
103
+ #
104
+ # @example
105
+ # # Create a new session
106
+ # session_id = "my-session"
107
+ # sandbox.process.create_session(session_id)
108
+ # session = sandbox.process.get_session(session_id)
109
+ # # Do work...
110
+ # sandbox.process.delete_session(session_id)
111
+ def create_session(session_id)
112
+ toolbox_api.create_session(sandbox_id, DaytonaApiClient::CreateSessionRequest.new(session_id:))
113
+ end
114
+
115
+ # Gets a session in the Sandbox
116
+ #
117
+ # @param session_id [String] Unique identifier of the session to retrieve
118
+ # @return [DaytonaApiClient::Session] Session information including session_id and commands
119
+ #
120
+ # @example
121
+ # session = sandbox.process.get_session("my-session")
122
+ # session.commands.each do |cmd|
123
+ # puts "Command: #{cmd.command}"
124
+ # end
125
+ def get_session(session_id) = toolbox_api.get_session(sandbox_id, session_id)
126
+
127
+ # Gets information about a specific command executed in a session
128
+ #
129
+ # @param session_id [String] Unique identifier of the session
130
+ # @param command_id [String] Unique identifier of the command
131
+ # @return [DaytonaApiClient::Command] Command information including id, command, and exit_code
132
+ #
133
+ # @example
134
+ # cmd = sandbox.process.get_session_command(session_id: "my-session", command_id: "cmd-123")
135
+ # if cmd.exit_code == 0
136
+ # puts "Command #{cmd.command} completed successfully"
137
+ # end
138
+ def get_session_command(session_id:, command_id:)
139
+ toolbox_api.get_session_command(sandbox_id, session_id, command_id)
140
+ end
141
+
142
+ # Executes a command in the session
143
+ #
144
+ # @param session_id [String] Unique identifier of the session to use
145
+ # @param req [Daytona::SessionExecuteRequest] Command execution request containing command and run_async
146
+ # @return [Daytona::SessionExecuteResponse] Command execution results containing cmd_id, output, stdout, stderr, and exit_code
147
+ #
148
+ # @example
149
+ # # Execute commands in sequence, maintaining state
150
+ # session_id = "my-session"
151
+ #
152
+ # # Change directory
153
+ # req = Daytona::SessionExecuteRequest.new(command: "cd /workspace")
154
+ # sandbox.process.execute_session_command(session_id:, req:)
155
+ #
156
+ # # Create a file
157
+ # req = Daytona::SessionExecuteRequest.new(command: "echo 'Hello' > test.txt")
158
+ # sandbox.process.execute_session_command(session_id:, req:)
159
+ #
160
+ # # Read the file
161
+ # req = Daytona::SessionExecuteRequest.new(command: "cat test.txt")
162
+ # result = sandbox.process.execute_session_command(session_id:, req:)
163
+ # puts "Command stdout: #{result.stdout}"
164
+ # puts "Command stderr: #{result.stderr}"
165
+ def execute_session_command(session_id:, req:) # rubocop:disable Metrics/MethodLength
166
+ response = toolbox_api.execute_session_command(
167
+ sandbox_id,
168
+ session_id,
169
+ DaytonaApiClient::SessionExecuteRequest.new(command: req.command, run_async: req.run_async)
170
+ )
171
+
172
+ stdout, stderr = Util.demux(response.output || '')
173
+
174
+ SessionExecuteResponse.new(
175
+ cmd_id: response.cmd_id,
176
+ output: response.output,
177
+ stdout:,
178
+ stderr:,
179
+ exit_code: response.exit_code,
180
+ # TODO: DaytonaApiClient::SessionExecuteResponse doesn't have additional_properties attribute
181
+ additional_properties: {}
182
+ )
183
+ end
184
+
185
+ # Get the logs for a command executed in a session
186
+ #
187
+ # @param session_id [String] Unique identifier of the session
188
+ # @param command_id [String] Unique identifier of the command
189
+ # @return [Daytona::SessionCommandLogsResponse] Command logs including output, stdout, and stderr
190
+ #
191
+ # @example
192
+ # logs = sandbox.process.get_session_command_logs(session_id: "my-session", command_id: "cmd-123")
193
+ # puts "Command stdout: #{logs.stdout}"
194
+ # puts "Command stderr: #{logs.stderr}"
195
+ def get_session_command_logs(session_id:, command_id:)
196
+ parse_session_command_logs(
197
+ toolbox_api.get_session_command_logs(
198
+ sandbox_id,
199
+ session_id,
200
+ command_id
201
+ )
202
+ )
203
+ end
204
+
205
+ # Asynchronously retrieves and processes the logs for a command executed in a session as they become available
206
+ #
207
+ # @param session_id [String] Unique identifier of the session
208
+ # @param command_id [String] Unique identifier of the command
209
+ # @param on_stdout [Proc] Callback function to handle stdout log chunks as they arrive
210
+ # @param on_stderr [Proc] Callback function to handle stderr log chunks as they arrive
211
+ # @return [WebSocket::Client::Simple::Client]
212
+ #
213
+ # @example
214
+ # sandbox.process.get_session_command_logs_async(
215
+ # session_id: "my-session",
216
+ # command_id: "cmd-123",
217
+ # on_stdout: ->(log) { puts "[STDOUT]: #{log}" },
218
+ # on_stderr: ->(log) { puts "[STDERR]: #{log}" }
219
+ # )
220
+ def get_session_command_logs_async(session_id:, command_id:, on_stdout:, on_stderr:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
221
+ preview_link = get_preview_link.call(WS_PORT)
222
+ url = URI.parse(preview_link.url)
223
+ url.scheme = url.scheme == 'https' ? 'wss' : 'ws'
224
+ url.path = "/process/session/#{session_id}/command/#{command_id}/logs"
225
+ url.query = 'follow=true'
226
+
227
+ WebSocket::Client::Simple.connect(
228
+ url.to_s,
229
+ headers: toolbox_api.api_client.default_headers.dup.merge(
230
+ 'X-Daytona-Preview-Token' => preview_link.token,
231
+ 'Content-Type' => 'text/plain',
232
+ 'Accept' => 'text/plain'
233
+ )
234
+ ) do |ws|
235
+ ws.on(:message) do |message|
236
+ if message.type == :close
237
+ ws.close
238
+ next
239
+ else
240
+ stdout, stderr = Util.demux(message.data.to_s)
241
+
242
+ on_stdout.call(stdout) unless stdout.empty?
243
+ on_stderr.call(stderr) unless stderr.empty?
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ #
250
+ # @return [Array<DaytonaApiClient::Session>] List of all sessions in the Sandbox
251
+ #
252
+ # @example
253
+ # sessions = sandbox.process.list_sessions
254
+ # sessions.each do |session|
255
+ # puts "Session #{session.session_id}:"
256
+ # puts " Commands: #{session.commands.length}"
257
+ # end
258
+ def list_sessions = toolbox_api.list_sessions(sandbox_id)
259
+
260
+ # Terminates and removes a session from the Sandbox, cleaning up any resources associated with it
261
+ #
262
+ # @param session_id [String] Unique identifier of the session to delete
263
+ #
264
+ # @example
265
+ # # Create and use a session
266
+ # sandbox.process.create_session("temp-session")
267
+ # # ... use the session ...
268
+ #
269
+ # # Clean up when done
270
+ # sandbox.process.delete_session("temp-session")
271
+ def delete_session(session_id) = toolbox_api.delete_session(sandbox_id, session_id)
272
+
273
+ # Creates a new PTY (pseudo-terminal) session in the Sandbox.
274
+ #
275
+ # Creates an interactive terminal session that can execute commands and handle user input.
276
+ # The PTY session behaves like a real terminal, supporting features like command history.
277
+ #
278
+ # @param id [String] Unique identifier for the PTY session. Must be unique within the Sandbox.
279
+ # @param cwd [String, nil] Working directory for the PTY session. Defaults to the sandbox's working directory.
280
+ # @param envs [Hash<String, String>, nil] Environment variables to set in the PTY session. These will be merged with
281
+ # the Sandbox's default environment variables.
282
+ # @param pty_size [PtySize, nil] Terminal size configuration. Defaults to 80x24 if not specified.
283
+ # @return [PtyHandle] Handle for managing the created PTY session. Use this to send input,
284
+ # receive output, resize the terminal, and manage the session lifecycle.
285
+ #
286
+ # @example
287
+ # # Create a basic PTY session
288
+ # pty_handle = sandbox.process.create_pty_session(id: "my-pty")
289
+ #
290
+ # # Create a PTY session with specific size and environment
291
+ # pty_size = Daytona::PtySize.new(rows: 30, cols: 120)
292
+ # pty_handle = sandbox.process.create_pty_session(
293
+ # id: "my-pty",
294
+ # cwd: "/workspace",
295
+ # envs: {"NODE_ENV" => "development"},
296
+ # pty_size: pty_size
297
+ # )
298
+ #
299
+ # # Use the PTY session
300
+ # pty_handle.wait_for_connection
301
+ # pty_handle.send_input("ls -la\n")
302
+ # result = pty_handle.wait
303
+ # pty_handle.disconnect
304
+ #
305
+ # @raise [Daytona::Sdk::Error] If the PTY session creation fails or the session ID is already in use.
306
+ def create_pty_session(id:, cwd: nil, envs: nil, pty_size: nil) # rubocop:disable Metrics/MethodLength
307
+ response = toolbox_api.create_pty_session(
308
+ sandbox_id,
309
+ DaytonaApiClient::PtyCreateRequest.new(
310
+ id:,
311
+ cwd:,
312
+ envs:,
313
+ cols: pty_size&.cols,
314
+ rows: pty_size&.rows,
315
+ lazy_start: true
316
+ )
317
+ )
318
+
319
+ connect_pty_session(response.session_id)
320
+ end
321
+
322
+ # Connects to an existing PTY session in the Sandbox.
323
+ #
324
+ # Establishes a WebSocket connection to an existing PTY session, allowing you to
325
+ # interact with a previously created terminal session.
326
+ #
327
+ # @param session_id [String] Unique identifier of the PTY session to connect to.
328
+ # @return [PtyHandle] Handle for managing the connected PTY session.
329
+ #
330
+ # @example
331
+ # # Connect to an existing PTY session
332
+ # pty_handle = sandbox.process.connect_pty_session("my-pty-session")
333
+ # pty_handle.wait_for_connection
334
+ # pty_handle.send_input("echo 'Hello World'\n")
335
+ # result = pty_handle.wait
336
+ # pty_handle.disconnect
337
+ #
338
+ # @raise [Daytona::Sdk::Error] If the PTY session doesn't exist or connection fails.
339
+ def connect_pty_session(session_id) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
340
+ preview_link = get_preview_link.call(WS_PORT)
341
+ url = URI.parse(preview_link.url)
342
+ url.scheme = url.scheme == 'https' ? 'wss' : 'ws'
343
+ url.path = "/process/pty/#{session_id}/connect"
344
+
345
+ PtyHandle.new(
346
+ WebSocket::Client::Simple.connect(
347
+ url.to_s,
348
+ headers: toolbox_api.api_client.default_headers.dup.merge(
349
+ 'X-Daytona-Preview-Token' => preview_link.token
350
+ )
351
+ ),
352
+ session_id:,
353
+
354
+ handle_resize: ->(pty_size) { resize_pty_session(session_id, pty_size) },
355
+ handle_kill: -> { delete_pty_session(session_id) }
356
+ ).tap(&:wait_for_connection)
357
+ end
358
+
359
+ # Resizes a PTY session to the specified dimensions
360
+ #
361
+ # @param session_id [String] Unique identifier of the PTY session
362
+ # @param pty_size [PtySize] New terminal size
363
+ # @return [DaytonaApiClient::PtySessionInfo] Updated PTY session information
364
+ #
365
+ # @example
366
+ # pty_size = Daytona::PtySize.new(rows: 30, cols: 120)
367
+ # session_info = sandbox.process.resize_pty_session("my-pty", pty_size)
368
+ # puts "PTY resized to #{session_info.cols}x#{session_info.rows}"
369
+ def resize_pty_session(session_id, pty_size)
370
+ toolbox_api.resize_pty_session(
371
+ sandbox_id,
372
+ session_id,
373
+ DaytonaApiClient::PtyResizeRequest.new(
374
+ cols: pty_size.cols,
375
+ rows: pty_size.rows
376
+ )
377
+ )
378
+ end
379
+
380
+ # Deletes a PTY session, terminating the associated process
381
+ #
382
+ # @param session_id [String] Unique identifier of the PTY session to delete
383
+ # @return [void]
384
+ #
385
+ # @example
386
+ # sandbox.process.delete_pty_session("my-pty")
387
+ def delete_pty_session(session_id)
388
+ toolbox_api.delete_pty_session(sandbox_id, session_id)
389
+ end
390
+
391
+ # Lists all PTY sessions in the Sandbox
392
+ #
393
+ # @return [Array<DaytonaApiClient::PtySessionInfo>] List of PTY session information
394
+ #
395
+ # @example
396
+ # sessions = sandbox.process.list_pty_sessions
397
+ # sessions.each do |session|
398
+ # puts "PTY Session #{session.id}: #{session.cols}x#{session.rows}"
399
+ # end
400
+ def list_pty_sessions
401
+ toolbox_api.list_pty_sessions(sandbox_id)
402
+ end
403
+
404
+ # Gets detailed information about a specific PTY session
405
+ #
406
+ # Retrieves comprehensive information about a PTY session including its current state,
407
+ # configuration, and metadata.
408
+ #
409
+ # @param session_id [String] Unique identifier of the PTY session to retrieve information for
410
+ # @return [DaytonaApiClient::PtySessionInfo] Detailed information about the PTY session including ID, state,
411
+ # creation time, working directory, environment variables, and more
412
+ #
413
+ # @example
414
+ # # Get details about a specific PTY session
415
+ # session_info = sandbox.process.get_pty_session_info("my-session")
416
+ # puts "Session ID: #{session_info.id}"
417
+ # puts "Active: #{session_info.active}"
418
+ # puts "Working Directory: #{session_info.cwd}"
419
+ # puts "Terminal Size: #{session_info.cols}x#{session_info.rows}"
420
+ def get_pty_session_info(session_id)
421
+ toolbox_api.get_pty_session(sandbox_id, session_id)
422
+ end
423
+
424
+ private
425
+
426
+ # Parse the output of a command to extract ExecutionArtifacts
427
+ #
428
+ # @param lines [Array<String>] A list of lines of output from a command
429
+ # @return [Daytona::ExecutionArtifacts] The artifacts from the command execution
430
+ def parse_output(lines)
431
+ artifacts = ExecutionArtifacts.new('', [])
432
+
433
+ lines.each do |line|
434
+ if line.start_with?(ARTIFACT_PREFIX)
435
+ parse_json_line(line:, artifacts:)
436
+ else
437
+ artifacts.stdout += "#{line}\n"
438
+ end
439
+ end
440
+
441
+ artifacts
442
+ end
443
+
444
+ # Parse a JSON line to extract artifacts
445
+ #
446
+ # @param line [String] The line to parse
447
+ # @param artifacts [Daytona::ExecutionArtifacts] The artifacts to add to
448
+ # @return [void]
449
+ def parse_json_line(line:, artifacts:)
450
+ data = JSON.parse(line.sub(ARTIFACT_PREFIX, '').strip, symbolize_names: true)
451
+
452
+ case data.fetch(:type, nil)
453
+ when ArtifactType::CHART
454
+ artifacts.charts.append(Charts.parse(data.fetch(:value, {})))
455
+ end
456
+ end
457
+
458
+ # Parse combined stdout/stderr output into separate streams
459
+ #
460
+ # @param data [String] Combined log string with STDOUT_PREFIX and STDERR_PREFIX markers
461
+ # @return [SessionCommandLogsResponse] Response with separated stdout and stderr
462
+ def parse_session_command_logs(data)
463
+ stdout, stderr = Util.demux(data)
464
+
465
+ SessionCommandLogsResponse.new(
466
+ output: data,
467
+ stdout:,
468
+ stderr:
469
+ )
470
+ end
471
+
472
+ ARTIFACT_PREFIX = 'dtn_artifact_k39fd2:'
473
+ private_constant :ARTIFACT_PREFIX
474
+
475
+ WS_PORT = 2280
476
+ private_constant :WS_PORT
477
+
478
+ module ArtifactType
479
+ ALL = [
480
+ CHART = 'chart'
481
+ ].freeze
482
+ end
483
+ end
484
+ end