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
data/lib/cogbox/git.rb ADDED
@@ -0,0 +1,335 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # Copyright Cognifyi
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # frozen_string_literal: true
6
+
7
+ module Cogbox
8
+ class Git
9
+ include Instrumentation
10
+
11
+ # @return [String] The Sandbox ID
12
+ attr_reader :sandbox_id
13
+
14
+ # @return [CogboxToolboxApiClient::GitApi] API client for Sandbox operations
15
+ attr_reader :toolbox_api
16
+
17
+ # Initializes a new Git handler instance.
18
+ #
19
+ # @param sandbox_id [String] The Sandbox ID.
20
+ # @param toolbox_api [CogboxToolboxApiClient::GitApi] API client for Sandbox operations.
21
+ # @param otel_state [Cogbox::OtelState, nil]
22
+ def initialize(sandbox_id:, toolbox_api:, otel_state: nil)
23
+ @sandbox_id = sandbox_id
24
+ @toolbox_api = toolbox_api
25
+ @otel_state = otel_state
26
+ end
27
+
28
+ # Stages the specified files for the next commit, similar to
29
+ # running 'git add' on the command line.
30
+ #
31
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
32
+ # the sandbox working directory.
33
+ # @param files [Array<String>] List of file paths or directories to stage, relative to the repository root.
34
+ # @return [void]
35
+ # @raise [Cogbox::Sdk::Error] if adding files fails
36
+ #
37
+ # @example
38
+ # # Stage a single file
39
+ # sandbox.git.add("workspace/repo", ["file.txt"])
40
+ #
41
+ # # Stage multiple files
42
+ # sandbox.git.add("workspace/repo", [
43
+ # "src/main.rb",
44
+ # "spec/main_spec.rb",
45
+ # "README.md"
46
+ # ])
47
+ def add(path, files)
48
+ toolbox_api.add_files(CogboxToolboxApiClient::GitAddRequest.new(path:, files:))
49
+ rescue CogboxToolboxApiClient::ApiError => e
50
+ raise map_api_error(e, 'Failed to add files')
51
+ rescue StandardError => e
52
+ raise Sdk::Error, "Failed to add files: #{e.message}"
53
+ end
54
+
55
+ # Lists branches in the repository.
56
+ #
57
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
58
+ # the sandbox working directory.
59
+ # @return [CogboxApiClient::ListBranchResponse] List of branches in the repository.
60
+ # @raise [Cogbox::Sdk::Error] if listing branches fails
61
+ #
62
+ # @example
63
+ # response = sandbox.git.branches("workspace/repo")
64
+ # puts "Branches: #{response.branches}"
65
+ def branches(path)
66
+ toolbox_api.list_branches(path)
67
+ rescue CogboxToolboxApiClient::ApiError => e
68
+ raise map_api_error(e, 'Failed to list branches')
69
+ rescue StandardError => e
70
+ raise Sdk::Error, "Failed to list branches: #{e.message}"
71
+ end
72
+
73
+ # Clones a Git repository into the specified path. It supports
74
+ # cloning specific branches or commits, and can authenticate with the remote
75
+ # repository if credentials are provided.
76
+ #
77
+ # @param url [String] Repository URL to clone from.
78
+ # @param path [String] Path where the repository should be cloned. Relative paths are resolved
79
+ # based on the sandbox working directory.
80
+ # @param branch [String, nil] Specific branch to clone. If not specified,
81
+ # clones the default branch.
82
+ # @param commit_id [String, nil] Specific commit to clone. If specified,
83
+ # the repository will be left in a detached HEAD state at this commit.
84
+ # @param username [String, nil] Git username for authentication.
85
+ # @param password [String, nil] Git password or token for authentication.
86
+ # @param insecure_skip_tls [Boolean, nil] Skip TLS certificate verification (insecure).
87
+ # Use only for trusted internal Git servers with self-signed or private-CA certs;
88
+ # credentials, if supplied, are transmitted over an unverified TLS connection.
89
+ # @return [void]
90
+ # @raise [Cogbox::Sdk::Error] if cloning repository fails
91
+ #
92
+ # @example
93
+ # # Clone the default branch
94
+ # sandbox.git.clone(
95
+ # url: "https://github.com/user/repo.git",
96
+ # path: "workspace/repo"
97
+ # )
98
+ #
99
+ # # Clone a specific branch with authentication
100
+ # sandbox.git.clone(
101
+ # url: "https://github.com/user/private-repo.git",
102
+ # path: "workspace/private",
103
+ # branch: "develop",
104
+ # username: "user",
105
+ # password: "token"
106
+ # )
107
+ #
108
+ # # Clone a specific commit
109
+ # sandbox.git.clone(
110
+ # url: "https://github.com/user/repo.git",
111
+ # path: "workspace/repo-old",
112
+ # commit_id: "abc123"
113
+ # )
114
+ def clone(url:, path:, branch: nil, commit_id: nil, username: nil, password: nil, insecure_skip_tls: nil) # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
115
+ toolbox_api.clone_repository(
116
+ CogboxToolboxApiClient::GitCloneRequest.new(
117
+ url: url,
118
+ branch: branch,
119
+ path: path,
120
+ username: username,
121
+ password: password,
122
+ commit_id: commit_id,
123
+ insecure_skip_tls: insecure_skip_tls
124
+ )
125
+ )
126
+ rescue CogboxToolboxApiClient::ApiError => e
127
+ raise map_api_error(e, 'Failed to clone repository')
128
+ rescue StandardError => e
129
+ raise Sdk::Error, "Failed to clone repository: #{e.message}"
130
+ end
131
+
132
+ # Creates a new commit with the staged changes. Make sure to stage
133
+ # changes using the add() method before committing.
134
+ #
135
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
136
+ # the sandbox working directory.
137
+ # @param message [String] Commit message describing the changes.
138
+ # @param author [String] Name of the commit author.
139
+ # @param email [String] Email address of the commit author.
140
+ # @param allow_empty [Boolean] Allow creating an empty commit when no changes are staged. Defaults to false.
141
+ # @return [GitCommitResponse] Response containing the commit SHA.
142
+ # @raise [Cogbox::Sdk::Error] if committing changes fails
143
+ #
144
+ # @example
145
+ # # Stage and commit changes
146
+ # sandbox.git.add("workspace/repo", ["README.md"])
147
+ # commit_response = sandbox.git.commit(
148
+ # path: "workspace/repo",
149
+ # message: "Update documentation",
150
+ # author: "John Doe",
151
+ # email: "john@example.com",
152
+ # allow_empty: true
153
+ # )
154
+ # puts "Commit SHA: #{commit_response.sha}"
155
+ def commit(path:, message:, author:, email:, allow_empty: false)
156
+ response = toolbox_api.commit_changes(
157
+ CogboxToolboxApiClient::GitCommitRequest.new(path:, message:, author:, email:, allow_empty:)
158
+ )
159
+ GitCommitResponse.new(sha: response._hash)
160
+ rescue CogboxToolboxApiClient::ApiError => e
161
+ raise map_api_error(e, 'Failed to commit changes')
162
+ rescue StandardError => e
163
+ raise Sdk::Error, "Failed to commit changes: #{e.message}"
164
+ end
165
+
166
+ # Pushes all local commits on the current branch to the remote
167
+ # repository. If the remote repository requires authentication, provide
168
+ # username and password/token.
169
+ #
170
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
171
+ # the sandbox working directory.
172
+ # @param username [String, nil] Git username for authentication.
173
+ # @param password [String, nil] Git password or token for authentication.
174
+ # @return [void]
175
+ # @raise [Cogbox::Sdk::Error] if pushing changes fails
176
+ #
177
+ # @example
178
+ # # Push without authentication (for public repos or SSH)
179
+ # sandbox.git.push("workspace/repo")
180
+ #
181
+ # # Push with authentication
182
+ # sandbox.git.push(
183
+ # path: "workspace/repo",
184
+ # username: "user",
185
+ # password: "github_token"
186
+ # )
187
+ def push(path:, username: nil, password: nil)
188
+ toolbox_api.push_changes(
189
+ CogboxToolboxApiClient::GitRepoRequest.new(path:, username:, password:)
190
+ )
191
+ rescue CogboxToolboxApiClient::ApiError => e
192
+ raise map_api_error(e, 'Failed to push changes')
193
+ rescue StandardError => e
194
+ raise Sdk::Error, "Failed to push changes: #{e.message}"
195
+ end
196
+
197
+ # Pulls changes from the remote repository. If the remote repository requires authentication,
198
+ # provide username and password/token.
199
+ #
200
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
201
+ # the sandbox working directory.
202
+ # @param username [String, nil] Git username for authentication.
203
+ # @param password [String, nil] Git password or token for authentication.
204
+ # @return [void]
205
+ # @raise [Cogbox::Sdk::Error] if pulling changes fails
206
+ #
207
+ # @example
208
+ # # Pull without authentication
209
+ # sandbox.git.pull("workspace/repo")
210
+ #
211
+ # # Pull with authentication
212
+ # sandbox.git.pull(
213
+ # path: "workspace/repo",
214
+ # username: "user",
215
+ # password: "github_token"
216
+ # )
217
+ #
218
+ def pull(path:, username: nil, password: nil)
219
+ toolbox_api.pull_changes(
220
+ CogboxToolboxApiClient::GitRepoRequest.new(path:, username:, password:)
221
+ )
222
+ rescue CogboxToolboxApiClient::ApiError => e
223
+ raise map_api_error(e, 'Failed to pull changes')
224
+ rescue StandardError => e
225
+ raise Sdk::Error, "Failed to pull changes: #{e.message}"
226
+ end
227
+
228
+ # Gets the current Git repository status.
229
+ #
230
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
231
+ # the sandbox working directory.
232
+ # @return [CogboxToolboxApiClient::GitStatus] Repository status information including:
233
+ # @raise [Cogbox::Sdk::Error] if getting status fails
234
+ #
235
+ # @example
236
+ # status = sandbox.git.status("workspace/repo")
237
+ # puts "On branch: #{status.current_branch}"
238
+ # puts "Commits ahead: #{status.ahead}"
239
+ # puts "Commits behind: #{status.behind}"
240
+ def status(path)
241
+ toolbox_api.get_status(path)
242
+ rescue CogboxToolboxApiClient::ApiError => e
243
+ raise map_api_error(e, 'Failed to get status')
244
+ rescue StandardError => e
245
+ raise Sdk::Error, "Failed to get status: #{e.message}"
246
+ end
247
+
248
+ # Checkout branch in the repository.
249
+ #
250
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
251
+ # the sandbox working directory.
252
+ # @param branch [String] Name of the branch to checkout
253
+ # @return [void]
254
+ # @raise [Cogbox::Sdk::Error] if checking out branch fails
255
+ #
256
+ # @example
257
+ # # Checkout a branch
258
+ # sandbox.git.checkout_branch("workspace/repo", "feature-branch")
259
+ def checkout_branch(path, branch)
260
+ toolbox_api.checkout_branch(
261
+ CogboxToolboxApiClient::GitCheckoutRequest.new(path:, branch:)
262
+ )
263
+ rescue CogboxToolboxApiClient::ApiError => e
264
+ raise map_api_error(e, 'Failed to checkout branch')
265
+ rescue StandardError => e
266
+ raise Sdk::Error, "Failed to checkout branch: #{e.message}"
267
+ end
268
+
269
+ # Create branch in the repository.
270
+ #
271
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
272
+ # the sandbox working directory.
273
+ # @param name [String] Name of the new branch to create
274
+ # @return [void]
275
+ # @raise [Cogbox::Sdk::Error] if creating branch fails
276
+ #
277
+ # @example
278
+ # # Create a new branch
279
+ # sandbox.git.create_branch("workspace/repo", "new-feature")
280
+ #
281
+ def create_branch(path, name)
282
+ toolbox_api.create_branch(
283
+ CogboxToolboxApiClient::GitBranchRequest.new(path:, name:)
284
+ )
285
+ rescue CogboxToolboxApiClient::ApiError => e
286
+ raise map_api_error(e, 'Failed to create branch')
287
+ rescue StandardError => e
288
+ raise Sdk::Error, "Failed to create branch: #{e.message}"
289
+ end
290
+
291
+ # Delete branch in the repository.
292
+ #
293
+ # @param path [String] Path to the Git repository root. Relative paths are resolved based on
294
+ # the sandbox working directory.
295
+ # @param name [String] Name of the branch to delete
296
+ # @return [void]
297
+ # @raise [Cogbox::Sdk::Error] if deleting branch fails
298
+ #
299
+ # @example
300
+ # # Delete a branch
301
+ # sandbox.git.delete_branch("workspace/repo", "old-feature")
302
+ def delete_branch(path, name)
303
+ toolbox_api.delete_branch(
304
+ CogboxToolboxApiClient::GitDeleteBranchRequest.new(path:, name:)
305
+ )
306
+ rescue CogboxToolboxApiClient::ApiError => e
307
+ raise map_api_error(e, 'Failed to delete branch')
308
+ rescue StandardError => e
309
+ raise Sdk::Error, "Failed to delete branch: #{e.message}"
310
+ end
311
+
312
+ instrument :add, :branches, :clone, :commit, :push, :pull, :status,
313
+ :checkout_branch, :create_branch, :delete_branch,
314
+ component: 'Git'
315
+
316
+ private
317
+
318
+ # @return [Cogbox::OtelState, nil]
319
+ attr_reader :otel_state
320
+
321
+ def map_api_error(api_error, prefix)
322
+ msg = "#{prefix}: #{api_error.message}"
323
+ case api_error.code
324
+ when 400 then Sdk::ValidationError.new(msg)
325
+ when 401 then Sdk::AuthenticationError.new(msg)
326
+ when 403 then Sdk::ForbiddenError.new(msg)
327
+ when 404 then Sdk::NotFoundError.new(msg)
328
+ when 409 then Sdk::ConflictError.new(msg)
329
+ when 429 then Sdk::RateLimitError.new(msg)
330
+ when 500..599 then Sdk::ServerError.new(msg)
331
+ else Sdk::Error.new(msg)
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,140 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # Copyright Cognifyi
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # frozen_string_literal: true
6
+
7
+ module Cogbox
8
+ class LspServer
9
+ include Instrumentation
10
+
11
+ module Language
12
+ ALL = [
13
+ JAVASCRIPT = :javascript,
14
+ PYTHON = :python,
15
+ TYPESCRIPT = :typescript
16
+
17
+ ].freeze
18
+ end
19
+
20
+ # Represents a zero-based position in a text document,
21
+ # specified by line number and character offset.
22
+ Position = Data.define(:line, :character)
23
+
24
+ # @return [Symbol]
25
+ attr_reader :language_id
26
+
27
+ # @return [String]
28
+ attr_reader :path_to_project
29
+
30
+ # @return [CogboxToolboxApiClient::LspApi]
31
+ attr_reader :toolbox_api
32
+
33
+ # @return [String]
34
+ attr_reader :sandbox_id
35
+
36
+ # @param language_id [Symbol]
37
+ # @param path_to_project [String]
38
+ # @param toolbox_api [CogboxToolboxApiClient::LspApi]
39
+ # @param sandbox_id [String]
40
+ # @param otel_state [Cogbox::OtelState, nil]
41
+ def initialize(language_id:, path_to_project:, toolbox_api:, sandbox_id:, otel_state: nil)
42
+ @language_id = language_id
43
+ @path_to_project = path_to_project
44
+ @toolbox_api = toolbox_api
45
+ @sandbox_id = sandbox_id
46
+ @otel_state = otel_state
47
+ end
48
+
49
+ # Gets completion suggestions at a position in a file
50
+ #
51
+ # @param path [String]
52
+ # @param position [Cogbox::LspServer::Position]
53
+ # @return [CogboxApiClient::CompletionList]
54
+ def completions(path:, position:)
55
+ toolbox_api.completions(
56
+ CogboxToolboxApiClient::LspCompletionParams.new(
57
+ language_id:,
58
+ path_to_project:,
59
+ uri: uri(path),
60
+ position: CogboxApiClient::Position.new(line: position.line, character: position.character)
61
+ )
62
+ )
63
+ end
64
+
65
+ # Notify the language server that a file has been closed.
66
+ # This method should be called when a file is closed in the editor to allow
67
+ # the language server to clean up any resources associated with that file.
68
+ #
69
+ # @param path [String]
70
+ # @return [void]
71
+ def did_close(path)
72
+ toolbox_api.did_close(
73
+ CogboxToolboxApiClient::LspDocumentRequest.new(language_id:, path_to_project:, uri: uri(path))
74
+ )
75
+ end
76
+
77
+ # Notifies the language server that a file has been opened.
78
+ # This method should be called when a file is opened in the editor to enable
79
+ # language features like diagnostics and completions for that file. The server
80
+ # will begin tracking the file's contents and providing language features.
81
+ #
82
+ # @param path [String]
83
+ # @return [void]
84
+ def did_open(path)
85
+ toolbox_api.did_open(
86
+ CogboxToolboxApiClient::LspDocumentRequest.new(language_id:, path_to_project:, uri: uri(path))
87
+ )
88
+ end
89
+
90
+ # Gets symbol information (functions, classes, variables, etc.) from a document.
91
+ #
92
+ # @param path [String]
93
+ # @return [Array<CogboxToolboxApiClient::LspSymbol]
94
+ def document_symbols(path) = toolbox_api.document_symbols(language_id, path_to_project, uri(path))
95
+
96
+ # Searches for symbols matching the query string across all files
97
+ # in the Sandbox.
98
+ #
99
+ # @param query [String]
100
+ # @return [Array<CogboxToolboxApiClient::LspSymbol]
101
+ def sandbox_symbols(query) = toolbox_api.workspace_symbols(query, language_id, path_to_project)
102
+
103
+ # Starts the language server.
104
+ # This method must be called before using any other LSP functionality.
105
+ # It initializes the language server for the specified language and project.
106
+ #
107
+ # @return [void]
108
+ def start
109
+ toolbox_api.start(
110
+ CogboxToolboxApiClient::LspServerRequest.new(language_id:, path_to_project:)
111
+ )
112
+ end
113
+
114
+ # Stops the language server.
115
+ # This method should be called when the LSP server is no longer needed to
116
+ # free up system resources.
117
+ #
118
+ # @return [void]
119
+ def stop
120
+ toolbox_api.stop(
121
+ CogboxToolboxApiClient::LspServerRequest.new(language_id:, path_to_project:)
122
+ )
123
+ end
124
+
125
+ instrument :completions, :did_close, :did_open, :document_symbols, :sandbox_symbols,
126
+ :start, :stop,
127
+ component: 'LspServer'
128
+
129
+ private
130
+
131
+ # @return [Cogbox::OtelState, nil]
132
+ attr_reader :otel_state
133
+
134
+ # Convert path to file uri.
135
+ #
136
+ # @param path [String]
137
+ # @return [String]
138
+ def uri(path) = "file://#{path}"
139
+ end
140
+ end
@@ -0,0 +1,173 @@
1
+ # Copyright Daytona Platforms Inc.
2
+ # Copyright Cognifyi
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # frozen_string_literal: true
6
+
7
+ require 'digest'
8
+ require 'fileutils'
9
+ require 'pathname'
10
+ require 'tempfile'
11
+ require 'zlib'
12
+ require 'aws-sdk-s3'
13
+
14
+ module Cogbox
15
+ class ObjectStorage
16
+ # @return [String] The name of the S3 bucket used for object storage
17
+ attr_reader :bucket_name
18
+
19
+ # @return [Aws::S3::Client] The S3 client
20
+ attr_reader :s3_client
21
+
22
+ # Initialize ObjectStorage with S3-compatible credentials
23
+ #
24
+ # @param endpoint_url [String] The endpoint URL for the object storage service
25
+ # @param aws_access_key_id [String] The access key ID for the object storage service
26
+ # @param aws_secret_access_key [String] The secret access key for the object storage service
27
+ # @param aws_session_token [String] The session token for the object storage service
28
+ # @param bucket_name [String] The name of the bucket to use (defaults to "cogbox-volume-builds")
29
+ # @param region [String] AWS region (defaults to us-east-1)
30
+ def initialize(endpoint_url:, aws_access_key_id:, aws_secret_access_key:, aws_session_token:, # rubocop:disable Metrics/ParameterLists
31
+ bucket_name: DEFAULT_BUCKET_NAME, region: DEFAULT_REGION)
32
+ @bucket_name = bucket_name
33
+ @s3_client = Aws::S3::Client.new(
34
+ region:,
35
+ endpoint: endpoint_url,
36
+ access_key_id: aws_access_key_id,
37
+ secret_access_key: aws_secret_access_key,
38
+ session_token: aws_session_token
39
+ )
40
+ end
41
+
42
+ # Uploads a file to the object storage service
43
+ #
44
+ # @param path [String] The path to the file to upload
45
+ # @param organization_id [String] The organization ID to use
46
+ # @param archive_base_path [String, nil] The base path to use for the archive
47
+ # @return [String] The hash of the uploaded file
48
+ # @raise [Errno::ENOENT] If the path does not exist
49
+ def upload(path, organization_id, archive_base_path = nil)
50
+ raise Errno::ENOENT, "Path does not exist: #{path}" unless File.exist?(path)
51
+
52
+ path_hash = compute_hash_for_path_md5(path, archive_base_path)
53
+ s3_key = "#{organization_id}/#{path_hash}/context.tar"
54
+
55
+ return path_hash if file_exists_in_s3(s3_key)
56
+
57
+ upload_as_tar(s3_key, path, archive_base_path)
58
+
59
+ path_hash
60
+ end
61
+
62
+ # Compute the base path for an archive. Returns normalized path without the root
63
+ # (drive letter or leading slash).
64
+ #
65
+ # @param path_str [String] The path to compute the base path for
66
+ # @return [String] The base path for the given path
67
+ def self.compute_archive_base_path(path_str)
68
+ normalized_path = File.basename(path_str)
69
+
70
+ # Remove drive letter for Windows paths (e.g., C:)
71
+ path_without_drive = normalized_path.gsub(/^[A-Za-z]:/, '')
72
+
73
+ # Remove leading separators (both / and \)
74
+ path_without_drive.gsub(%r{^[/\\]+}, '')
75
+ end
76
+
77
+ private
78
+
79
+ # Computes the MD5 hash for a given path
80
+ #
81
+ # @param path_str [String] The path to compute the hash for
82
+ # @param archive_base_path [String, nil] The base path to use for the archive
83
+ # @return [String] The MD5 hash for the given path
84
+ def compute_hash_for_path_md5(path_str, archive_base_path = nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
85
+ md5_hasher = Digest::MD5.new
86
+ abs_path_str = File.expand_path(path_str)
87
+
88
+ archive_base_path = self.class.compute_archive_base_path(path_str) if archive_base_path.nil?
89
+ md5_hasher.update(archive_base_path)
90
+
91
+ if File.file?(abs_path_str)
92
+ File.open(abs_path_str, 'rb') do |f|
93
+ while (chunk = f.read(8192))
94
+ md5_hasher.update(chunk)
95
+ end
96
+ end
97
+ else
98
+ Dir.glob(File.join(abs_path_str, '**', '*')).each do |file_path|
99
+ next unless File.file?(file_path)
100
+
101
+ rel_path = Pathname.new(file_path).relative_path_from(Pathname.new(abs_path_str)).to_s
102
+
103
+ md5_hasher.update(rel_path)
104
+
105
+ File.open(file_path, 'rb') do |f|
106
+ while (chunk = f.read(8192))
107
+ md5_hasher.update(chunk)
108
+ end
109
+ end
110
+ end
111
+
112
+ # Handle empty directories
113
+ Dir
114
+ .glob(File.join(abs_path_str, '**', '*'))
115
+ .select { |path| File.directory?(path) && Dir.empty?(path) }
116
+ .each do |empty_dir|
117
+ rel_dir = Pathname.new(empty_dir).relative_path_from(Pathname.new(abs_path_str)).to_s
118
+ md5_hasher.update(rel_dir)
119
+ end
120
+ end
121
+
122
+ md5_hasher.hexdigest
123
+ end
124
+
125
+ # Checks whether a specific object exists at the given path
126
+ #
127
+ # @param file_path [String] Full object path, e.g. "org/abcd123/context.tar"
128
+ # @return [Boolean] True if the object exists, False otherwise
129
+ def file_exists_in_s3(file_path)
130
+ s3_client.head_object(bucket: bucket_name, key: file_path)
131
+ true
132
+ rescue Aws::S3::Errors::NotFound
133
+ false
134
+ rescue StandardError => e
135
+ Sdk.logger.debug("Error checking file existence: #{e.message}")
136
+ false
137
+ end
138
+
139
+ # Uploads a file to the object storage service as a tar
140
+ #
141
+ # @param s3_key [String] The key to upload the file to
142
+ # @param source_path [String] The path to the file to upload
143
+ # @param archive_base_path [String, nil] The base path to use for the archive
144
+ def upload_as_tar(s3_key, source_path, archive_base_path = nil) # rubocop:disable Metrics/MethodLength
145
+ source_path = File.expand_path(source_path)
146
+
147
+ self.class.compute_archive_base_path(source_path) if archive_base_path.nil?
148
+
149
+ temp_file = Tempfile.new(['context', '.tar'])
150
+
151
+ begin
152
+ system('tar', '-cf', temp_file.path, '-C', File.dirname(source_path), File.basename(source_path))
153
+
154
+ File.open(temp_file.path, 'rb') do |file|
155
+ s3_client.put_object(
156
+ bucket: bucket_name,
157
+ key: s3_key,
158
+ body: file
159
+ )
160
+ end
161
+ ensure
162
+ temp_file.close
163
+ temp_file.unlink
164
+ end
165
+ end
166
+
167
+ DEFAULT_BUCKET_NAME = 'cogbox-volume-builds'
168
+ private_constant :DEFAULT_BUCKET_NAME
169
+
170
+ DEFAULT_REGION = 'us-east-1'
171
+ private_constant :DEFAULT_REGION
172
+ end
173
+ end