nightona 0.191.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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +22 -0
  4. data/.ruby-version +1 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE +190 -0
  7. data/README.md +184 -0
  8. data/Rakefile +12 -0
  9. data/lib/nightona/code_interpreter.rb +359 -0
  10. data/lib/nightona/common/charts.rb +124 -0
  11. data/lib/nightona/common/code_interpreter.rb +56 -0
  12. data/lib/nightona/common/code_language.rb +14 -0
  13. data/lib/nightona/common/file_system.rb +26 -0
  14. data/lib/nightona/common/git.rb +19 -0
  15. data/lib/nightona/common/image.rb +500 -0
  16. data/lib/nightona/common/nightona.rb +230 -0
  17. data/lib/nightona/common/process.rb +149 -0
  18. data/lib/nightona/common/pty.rb +309 -0
  19. data/lib/nightona/common/resources.rb +39 -0
  20. data/lib/nightona/common/response.rb +83 -0
  21. data/lib/nightona/common/snapshot.rb +124 -0
  22. data/lib/nightona/computer_use.rb +919 -0
  23. data/lib/nightona/config.rb +116 -0
  24. data/lib/nightona/file_system.rb +451 -0
  25. data/lib/nightona/file_transfer.rb +383 -0
  26. data/lib/nightona/git.rb +334 -0
  27. data/lib/nightona/lsp_server.rb +139 -0
  28. data/lib/nightona/nightona.rb +336 -0
  29. data/lib/nightona/object_storage.rb +172 -0
  30. data/lib/nightona/otel.rb +183 -0
  31. data/lib/nightona/process.rb +550 -0
  32. data/lib/nightona/sandbox.rb +751 -0
  33. data/lib/nightona/sdk/version.rb +10 -0
  34. data/lib/nightona/sdk.rb +56 -0
  35. data/lib/nightona/snapshot_service.rb +238 -0
  36. data/lib/nightona/util.rb +80 -0
  37. data/lib/nightona/volume.rb +46 -0
  38. data/lib/nightona/volume_service.rb +61 -0
  39. data/lib/nightona.rb +10 -0
  40. data/project.json +100 -0
  41. data/scripts/generate-docs.rb +402 -0
  42. data/sig/nightona/sdk.rbs +6 -0
  43. metadata +242 -0
@@ -0,0 +1,383 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # frozen_string_literal: true
5
+
6
+ require 'json'
7
+ require 'stringio'
8
+ require 'tempfile'
9
+ require 'typhoeus'
10
+
11
+ module Nightona
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
+
18
+ class MultipartDownloadStreamParser
19
+ attr_reader :error_message
20
+ attr_reader :part_total_bytes
21
+ attr_reader :part_bytes_emitted
22
+ attr_writer :boundary_token
23
+
24
+ def initialize(&on_file_chunk)
25
+ @on_file_chunk = on_file_chunk
26
+ @boundary_token = nil
27
+ @buffer = String.new.b
28
+ @state = :preamble
29
+ @part_name = nil
30
+ @part_total_bytes = nil
31
+ @part_bytes_emitted = 0
32
+ @error_buffer = String.new.b
33
+ end
34
+
35
+ def <<(chunk)
36
+ @buffer << chunk.b
37
+ process!
38
+ end
39
+
40
+ # Raises if the response ended before the closing multipart boundary, so
41
+ # truncations surface as typed errors instead of silently short downloads.
42
+ def finish!
43
+ process!
44
+ return if @state == :done
45
+
46
+ raise Sdk::Error, "Truncated multipart response: closing boundary not received (state=#{@state})"
47
+ end
48
+
49
+ private
50
+
51
+ def process!
52
+ loop do
53
+ advanced = case @state
54
+ when :preamble then consume_preamble?
55
+ when :headers then consume_headers?
56
+ when :body then consume_body?
57
+ else false
58
+ end
59
+
60
+ break unless advanced
61
+ end
62
+ end
63
+
64
+ def consume_preamble?
65
+ start_marker = "#{boundary}\r\n".b
66
+ index = @buffer.index(start_marker)
67
+ return retain_tail?(start_marker.bytesize - 1) unless index
68
+
69
+ @buffer = remaining_bytes(index + start_marker.bytesize)
70
+ @state = :headers
71
+ true
72
+ end
73
+
74
+ def consume_headers?
75
+ index = @buffer.index("\r\n\r\n".b)
76
+ return false unless index
77
+
78
+ headers = @buffer.byteslice(0, index)
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
83
+
84
+ @state = :body
85
+ true
86
+ end
87
+
88
+ def consume_body? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
89
+ marker = "\r\n#{boundary}".b
90
+ index = @buffer.index(marker)
91
+
92
+ if index
93
+ emit(@buffer.byteslice(0, index))
94
+ @buffer = remaining_bytes(index + marker.bytesize)
95
+ finalize_part!
96
+ @state = :done
97
+ return true
98
+ end
99
+
100
+ flushable = @buffer.bytesize - marker.bytesize + 1
101
+ return false if flushable <= 0
102
+
103
+ emit(@buffer.byteslice(0, flushable))
104
+ @buffer = remaining_bytes(flushable)
105
+ false
106
+ end
107
+
108
+ def emit(data)
109
+ return if data.nil? || data.empty?
110
+
111
+ case @part_name
112
+ when 'file'
113
+ @part_bytes_emitted += data.bytesize
114
+ @on_file_chunk.call(data)
115
+ when 'error'
116
+ @error_buffer << data
117
+ end
118
+ end
119
+
120
+ def finalize_part!
121
+ return unless @part_name == 'error'
122
+
123
+ @error_message = extract_error_message(@error_buffer)
124
+ end
125
+
126
+ def extract_error_message(payload)
127
+ parsed = JSON.parse(payload)
128
+ parsed['message'] || parsed['error'] || payload
129
+ rescue JSON::ParserError
130
+ payload
131
+ end
132
+
133
+ def retain_tail?(size)
134
+ @buffer = @buffer.byteslice(-size, size) || String.new.b if size.positive? && @buffer.bytesize > size
135
+ false
136
+ end
137
+
138
+ def remaining_bytes(offset) = @buffer.byteslice(offset, @buffer.bytesize - offset) || String.new.b
139
+ def boundary = "--#{@boundary_token}".b
140
+ end
141
+
142
+ module FileTransfer # rubocop:disable Metrics/ModuleLength
143
+ def self.extract_multipart_boundary(content_type)
144
+ match = content_type&.match(/boundary=(?:"([^"]+)"|([^;]+))/i)
145
+ return unless match
146
+
147
+ match.captures.compact.first
148
+ end
149
+
150
+ def self.assign_download_boundary(parser, content_type)
151
+ boundary = extract_multipart_boundary(content_type)
152
+ raise Sdk::Error, 'Missing multipart boundary in download response' unless boundary
153
+
154
+ parser.boundary_token = boundary
155
+ end
156
+
157
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
158
+ def self.stream_download(api_client:, remote_path:, timeout:, on_progress: nil, cancel_event: nil, &block)
159
+ config = api_client.config
160
+ bytes_received = 0
161
+ parser = nil
162
+ wrapped_block = proc do |chunk|
163
+ raise Sdk::Error, "Download cancelled: #{remote_path}" if cancel_event&.set?
164
+
165
+ if on_progress
166
+ bytes_received += chunk.bytesize
167
+ on_progress.call(DownloadProgress.new(
168
+ bytes_received: bytes_received,
169
+ total_bytes: parser&.part_total_bytes
170
+ ))
171
+ end
172
+ block.call(chunk)
173
+ end
174
+ parser = MultipartDownloadStreamParser.new(&wrapped_block)
175
+ response = nil
176
+
177
+ request = Typhoeus::Request.new(
178
+ "#{config.base_url}/files/bulk-download",
179
+ method: :post,
180
+ headers: api_client.default_headers.dup.merge(
181
+ 'Accept' => 'multipart/form-data',
182
+ 'Content-Type' => 'application/json'
183
+ ),
184
+ body: JSON.generate(paths: [remote_path]),
185
+ timeout: timeout,
186
+ ssl_verifypeer: config.verify_ssl,
187
+ ssl_verifyhost: config.verify_ssl_host ? 2 : 0
188
+ )
189
+
190
+ request.on_headers do |stream_response|
191
+ assign_download_boundary(parser, stream_response.headers['Content-Type'])
192
+ end
193
+
194
+ # Returning +:abort+ from the on_body callback tells libcurl to tear down the
195
+ # connection immediately, which is how cancellation actually severs the
196
+ # transfer rather than just stopping our own bookkeeping.
197
+ request.on_body do |chunk|
198
+ next :abort if cancel_event&.set?
199
+
200
+ parser << chunk
201
+ end
202
+
203
+ request.on_complete do |completed_response|
204
+ response = completed_response
205
+ parser.finish! unless cancel_event&.set?
206
+ end
207
+
208
+ request.run
209
+
210
+ raise Sdk::Error, "Download cancelled: #{remote_path}" if cancel_event&.set?
211
+ raise Sdk::Error, parser.error_message if parser.error_message
212
+ raise Sdk::Error, "HTTP #{response.code}" if response && !response.success?
213
+
214
+ assert_download_length!(parser, remote_path)
215
+ end
216
+
217
+ def self.assert_download_length!(parser, remote_path)
218
+ return unless parser.part_total_bytes && parser.part_bytes_emitted != parser.part_total_bytes
219
+
220
+ raise Sdk::Error,
221
+ "Multipart response length mismatch for #{remote_path}: " \
222
+ "got #{parser.part_bytes_emitted} bytes, expected #{parser.part_total_bytes}"
223
+ end
224
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
225
+
226
+ # Uploads +source+ to /files/bulk-upload via Typhoeus (libcurl), which streams the
227
+ # request body straight from disk without buffering it in memory. Local file paths
228
+ # are uploaded directly; in-memory IOs/bytes are first drained to a tempfile so we
229
+ # have a stable file handle for libcurl.
230
+ #
231
+ # The daemon owns atomicity (writes to a sibling tempfile then renames), so a
232
+ # client-side abort just leaves no destination file at all.
233
+ #
234
+ # @param api_client The OpenAPI-generated toolbox API client (auth/base-url only).
235
+ # @param remote_path [String] Destination path in the sandbox.
236
+ # @param source [String, IO] Local file path or any IO-like object responding to +read(n)+.
237
+ # @param timeout [Integer] Typhoeus timeout in seconds (0 disables).
238
+ # @param on_progress [Proc, nil] Optional callback invoked with +Nightona::UploadProgress+
239
+ # as libcurl reports real network upload progress.
240
+ # @param cancel_event [#set?, nil] Optional cancellation token. Checked while staging
241
+ # non-file sources and during the libcurl transfer itself.
242
+ # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
243
+ def self.stream_upload(api_client:, remote_path:, source:, timeout:, on_progress: nil, cancel_event: nil)
244
+ with_upload_file(source, cancel_event, remote_path) do |upload_path|
245
+ config = api_client.config
246
+ expected_bytes = File.size(upload_path)
247
+ progress_callback = upload_progress_callback(on_progress, cancel_event)
248
+ response = with_open_upload_file(upload_path) do |file|
249
+ upload_request(
250
+ api_client: api_client,
251
+ config: config,
252
+ remote_path: remote_path,
253
+ file: file,
254
+ timeout: timeout,
255
+ progress_callback: progress_callback
256
+ ).run
257
+ end
258
+ raise_upload_error(response, cancel_event, remote_path)
259
+ verify_upload_response(response, remote_path, expected_bytes)
260
+ end
261
+ end
262
+ # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
263
+
264
+ # Yields a path on disk that holds the source's bytes, ready for libcurl to stream.
265
+ # Local files are passed through unchanged; everything else is drained into a
266
+ # tempfile that gets unlinked when we return.
267
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
268
+ def self.with_upload_file(source, cancel_event, remote_path)
269
+ raise Sdk::Error, "Upload cancelled: #{remote_path}" if cancel_event&.set?
270
+
271
+ return yield(source) if source.is_a?(String) && File.exist?(source)
272
+
273
+ tmp = Tempfile.new(['nightona-upload-', File.extname(remote_path).to_s])
274
+ tmp.binmode
275
+ begin
276
+ drain_source_to(source, tmp, cancel_event, remote_path)
277
+ tmp.flush
278
+ tmp.close
279
+ yield(tmp.path)
280
+ ensure
281
+ tmp.close unless tmp.closed?
282
+ begin
283
+ tmp.unlink
284
+ rescue StandardError
285
+ # tempfile already gone, nothing to do
286
+ end
287
+ end
288
+ end
289
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
290
+
291
+ def self.drain_source_to(source, sink, cancel_event, remote_path)
292
+ io, owns_io = open_drain_source(source)
293
+ begin
294
+ while (chunk = io.read(64 * 1024))
295
+ break if chunk.empty?
296
+ raise Sdk::Error, "Upload cancelled: #{remote_path}" if cancel_event&.set?
297
+
298
+ sink.write(chunk)
299
+ end
300
+ ensure
301
+ io.close if owns_io && io.respond_to?(:close)
302
+ end
303
+ end
304
+
305
+ def self.with_open_upload_file(upload_path)
306
+ file = File.open(upload_path, 'rb')
307
+ yield(file)
308
+ ensure
309
+ file.close if file && !file.closed?
310
+ end
311
+
312
+ # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
313
+ def self.upload_request(api_client:, config:, remote_path:, file:, timeout:, progress_callback:)
314
+ Typhoeus::Request.new(
315
+ "#{config.base_url}/files/bulk-upload",
316
+ method: :post,
317
+ headers: api_client.default_headers.dup.tap { |h| h.delete('Content-Type') },
318
+ body: {
319
+ 'files[0].path' => remote_path,
320
+ 'files[0].file' => file
321
+ },
322
+ timeout: timeout,
323
+ ssl_verifypeer: config.verify_ssl,
324
+ ssl_verifyhost: config.verify_ssl_host ? 2 : 0,
325
+ noprogress: false,
326
+ progressfunction: progress_callback,
327
+ xferinfofunction: progress_callback
328
+ )
329
+ end
330
+ # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists
331
+
332
+ def self.upload_progress_callback(on_progress, cancel_event)
333
+ last_bytes_sent = -1
334
+
335
+ proc do |_clientp, _dltotal, _dlnow, _ultotal, ulnow|
336
+ next 1 if cancel_event&.set?
337
+
338
+ bytes_sent = ulnow.to_i
339
+ if on_progress && bytes_sent > last_bytes_sent
340
+ last_bytes_sent = bytes_sent
341
+ on_progress.call(UploadProgress.new(bytes_sent: bytes_sent))
342
+ end
343
+
344
+ 0
345
+ end
346
+ end
347
+
348
+ def self.raise_upload_error(response, _cancel_event, remote_path)
349
+ raise Sdk::Error, "Upload timed out: #{remote_path}" if response.timed_out?
350
+ raise Sdk::Error, "Upload cancelled: #{remote_path}" if response.return_code == :aborted_by_callback
351
+ raise Sdk::Error, "HTTP #{response.code}: #{response.body}" unless response.success?
352
+ end
353
+
354
+ # Compares the daemon's reported bytes-written against what the SDK sent.
355
+ # Catches server-side miscounts (or extra-byte injection) at the upload
356
+ # call site instead of surfacing later as a download mismatch.
357
+ def self.verify_upload_response(response, remote_path, expected_bytes)
358
+ recorded = recorded_upload_bytes(response.body, remote_path)
359
+ return if recorded.nil? || recorded == expected_bytes
360
+
361
+ raise Sdk::Error,
362
+ "Upload size mismatch for #{remote_path}: sent #{expected_bytes} bytes, " \
363
+ "daemon recorded #{recorded}"
364
+ end
365
+
366
+ def self.recorded_upload_bytes(body, remote_path)
367
+ parsed = JSON.parse(body) rescue nil # rubocop:disable Style/RescueModifier
368
+ return nil unless parsed.is_a?(Hash)
369
+
370
+ files = Array(parsed['files'])
371
+ match = files.find { |f| f.is_a?(Hash) && f['path'] == remote_path }
372
+ bytes = match&.dig('bytes')
373
+ bytes.is_a?(Integer) ? bytes : nil
374
+ end
375
+
376
+ def self.open_drain_source(source)
377
+ return [source, false] if source.respond_to?(:read)
378
+ return [StringIO.new(source.b), true] if source.is_a?(String)
379
+
380
+ raise Sdk::Error, "Unsupported upload source: #{source.class}"
381
+ end
382
+ end
383
+ end