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