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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +22 -0
- data/.ruby-version +1 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +190 -0
- data/README.md +184 -0
- data/Rakefile +12 -0
- data/lib/nightona/code_interpreter.rb +359 -0
- data/lib/nightona/common/charts.rb +124 -0
- data/lib/nightona/common/code_interpreter.rb +56 -0
- data/lib/nightona/common/code_language.rb +14 -0
- data/lib/nightona/common/file_system.rb +26 -0
- data/lib/nightona/common/git.rb +19 -0
- data/lib/nightona/common/image.rb +500 -0
- data/lib/nightona/common/nightona.rb +230 -0
- data/lib/nightona/common/process.rb +149 -0
- data/lib/nightona/common/pty.rb +309 -0
- data/lib/nightona/common/resources.rb +39 -0
- data/lib/nightona/common/response.rb +83 -0
- data/lib/nightona/common/snapshot.rb +124 -0
- data/lib/nightona/computer_use.rb +919 -0
- data/lib/nightona/config.rb +116 -0
- data/lib/nightona/file_system.rb +451 -0
- data/lib/nightona/file_transfer.rb +383 -0
- data/lib/nightona/git.rb +334 -0
- data/lib/nightona/lsp_server.rb +139 -0
- data/lib/nightona/nightona.rb +336 -0
- data/lib/nightona/object_storage.rb +172 -0
- data/lib/nightona/otel.rb +183 -0
- data/lib/nightona/process.rb +550 -0
- data/lib/nightona/sandbox.rb +751 -0
- data/lib/nightona/sdk/version.rb +10 -0
- data/lib/nightona/sdk.rb +56 -0
- data/lib/nightona/snapshot_service.rb +238 -0
- data/lib/nightona/util.rb +80 -0
- data/lib/nightona/volume.rb +46 -0
- data/lib/nightona/volume_service.rb +61 -0
- data/lib/nightona.rb +10 -0
- data/project.json +100 -0
- data/scripts/generate-docs.rb +402 -0
- data/sig/nightona/sdk.rbs +6 -0
- 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
|