bitfab 0.29.1 → 0.30.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f26df032729a0c1fbad20c807e1581ec1c68bb9019e0b8f32a85237c785a6ed
4
- data.tar.gz: 6acf7a7e153565cf4fb272b651f2740b4d3cb5722b67b6508552aa1b2e81d042
3
+ metadata.gz: 4a3e03384934c51dd57e27ac1213dca8f88b41ead5a0980c04bb10ba0ad464d8
4
+ data.tar.gz: da9c5574b00bd66ba481a78e0f1e0ce4f8e9336b35a7b2e9013498b475622237
5
5
  SHA512:
6
- metadata.gz: 5fdcbb34b7174b5308d46405c95735d0a986db51f1af6ddbeb26613ebcdd9c35fade8a40553b1f88b78dc699261146d8cd9d4d7abffda560fbf06f40527717ce
7
- data.tar.gz: ea2cd76d972b4b67f1554e1baf28b2aeeaf8560d1329eeff1f6df721273ce6f6e27193d20e0c0995ea77077544a5ab6f64cbb06908276eb8a27c6a6d590171b7
6
+ metadata.gz: 97f2755495103e92c94258f1763a1066bfc5a1944168e10dec1ad1e0bd24a58ca916791122cf3673f52326ca0d5b89b7ca8324071d40b6c408f3153167f6c95c
7
+ data.tar.gz: 61ba2d4b92cfa2c2665753319c6acf67dda2d48a547088d1ab47b2e9006835fe869185f3861d4e378f5c8e76167009f2580f385b5f6416467a24ae5b9dce8242
data/lib/bitfab/client.rb CHANGED
@@ -83,6 +83,10 @@ module Bitfab
83
83
  # replay runs into a single experiment batch
84
84
  # @param dataset_id [String, nil] optional UUID of the dataset this replay
85
85
  # runs against, stored on the resulting experiment for durable attribution
86
+ # @param grader_ids [Array<String>, nil] optional UUIDs of graders attached
87
+ # directly to this experiment, graded as the union with the dataset's
88
+ # runnable graders at completion; each must be an active/live grader in the
89
+ # same org and trace function or the server rejects the replay
86
90
  # @param mock [String] mock strategy for child spans: "marked" (default),
87
91
  # "none", or "all". "marked" only mocks spans declared with
88
92
  # mock_on_replay: true; "all" mocks every child span.
@@ -109,12 +113,12 @@ module Bitfab
109
113
  # A raising callback never crashes the run.
110
114
  # @return [Hash] with :items, :test_run_id, :test_run_url
111
115
  def replay(receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
112
- name: nil, code_change_description: nil, code_change_files: nil, experiment_group_id: nil, dataset_id: nil, mock: "marked",
116
+ name: nil, code_change_description: nil, code_change_files: nil, experiment_group_id: nil, dataset_id: nil, grader_ids: nil, mock: "marked",
113
117
  adapt_inputs: nil, mock_override: nil, environment: nil, on_progress: nil)
114
118
  Replay.run(
115
119
  self, receiver, method_name,
116
120
  trace_function_key:, limit:, trace_ids:, name:, max_concurrency:,
117
- code_change_description:, code_change_files:, experiment_group_id:, dataset_id:, mock:, adapt_inputs:,
121
+ code_change_description:, code_change_files:, experiment_group_id:, dataset_id:, grader_ids:, mock:, adapt_inputs:,
118
122
  mock_override:, environment:,
119
123
  on_progress:
120
124
  )
@@ -112,8 +112,13 @@ module Bitfab
112
112
  # experiment/test run
113
113
  # @param dataset_id [String, nil] optional UUID of the dataset this replay
114
114
  # runs against, stored on the resulting experiment for durable attribution
115
+ # @param grader_ids [Array<String>, nil] optional UUIDs of graders attached
116
+ # directly to this experiment, graded as the union with the dataset's
117
+ # runnable graders at completion; each must be an active/live grader in the
118
+ # same org and trace function or the server rejects the replay
115
119
  def start_replay(trace_function_key, limit, trace_ids: nil, code_change_description: nil,
116
- code_change_files: nil, experiment_group_id: nil, name: nil, include_db_branch_lease: false, dataset_id: nil)
120
+ code_change_files: nil, experiment_group_id: nil, name: nil, include_db_branch_lease: false, dataset_id: nil,
121
+ grader_ids: nil)
117
122
  payload = {
118
123
  "traceFunctionKey" => trace_function_key
119
124
  }
@@ -127,6 +132,7 @@ module Bitfab
127
132
  payload["experimentGroupId"] = experiment_group_id unless experiment_group_id.nil?
128
133
  payload["includeDbBranchLease"] = true if include_db_branch_lease
129
134
  payload["datasetId"] = dataset_id unless dataset_id.nil?
135
+ payload["graderIds"] = grader_ids unless grader_ids.nil?
130
136
 
131
137
  # When DB branching is on, the server resolves a Neon preview branch per
132
138
  # item (snapshot + restore + poll), which can run several seconds each.
data/lib/bitfab/replay.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "open3"
6
+ require "timeout"
5
7
 
6
8
  require_relative "constants"
7
9
  require_relative "mock_override"
@@ -104,6 +106,10 @@ module Bitfab
104
106
  # replay runs into a single experiment batch
105
107
  # @param dataset_id [String, nil] optional UUID of the dataset this replay
106
108
  # runs against, stored on the resulting experiment for durable attribution
109
+ # @param grader_ids [Array<String>, nil] optional UUIDs of graders attached
110
+ # directly to this experiment, graded as the union with the dataset's
111
+ # runnable graders at completion; each must be an active/live grader in the
112
+ # same org and trace function or the server rejects the replay
107
113
  # @param mock [String] mock strategy for child spans: "marked" (default),
108
114
  # "none", or "all". "marked" only mocks spans declared with
109
115
  # mock_on_replay: true; "all" mocks every child span.
@@ -129,7 +135,7 @@ module Bitfab
129
135
  # @return [Hash] with :items, :test_run_id, :test_run_url
130
136
  def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil,
131
137
  max_concurrency: 10, code_change_description: nil, code_change_files: nil, experiment_group_id: nil,
132
- dataset_id: nil, mock: "marked",
138
+ dataset_id: nil, grader_ids: nil, mock: "marked",
133
139
  adapt_inputs: nil, mock_override: nil, environment: nil, on_progress: nil)
134
140
  unless MOCK_STRATEGIES.include?(mock.to_s)
135
141
  raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
@@ -175,6 +181,19 @@ module Bitfab
175
181
 
176
182
  include_db_branch_lease = !environment.nil?
177
183
 
184
+ # An explicit code change always wins; only when the caller passed neither
185
+ # field do we fall back to the payload the replay wrapper captured from git
186
+ # and handed over via BITFAB_CODE_CHANGE_PATH. This is what makes an
187
+ # experiment show its diff even when the run happened outside the
188
+ # assistant's make-change rails.
189
+ if code_change_description.nil? && code_change_files.nil?
190
+ captured = resolve_auto_code_change(name)
191
+ unless captured.nil?
192
+ code_change_description = captured[0]
193
+ code_change_files = captured[1]
194
+ end
195
+ end
196
+
178
197
  replay_data = http_client.start_replay(
179
198
  trace_function_key,
180
199
  effective_limit,
@@ -184,7 +203,8 @@ module Bitfab
184
203
  code_change_files:,
185
204
  experiment_group_id:,
186
205
  include_db_branch_lease:,
187
- dataset_id:
206
+ dataset_id:,
207
+ grader_ids:
188
208
  )
189
209
  test_run_id = replay_data["testRunId"]
190
210
  test_run_url = replay_data["testRunUrl"]
@@ -290,6 +310,177 @@ module Bitfab
290
310
  result
291
311
  end
292
312
 
313
+ # Read the code-change payload the replay wrapper captured and pointed us at
314
+ # via BITFAB_CODE_CHANGE_PATH. Returns [description, files] or nil.
315
+ # Best-effort: any error (no env var, missing file, bad JSON) yields nil so
316
+ # the replay proceeds with no code change rather than raising.
317
+ TRUNK_CANDIDATES = ["origin/HEAD", "origin/main", "origin/master", "main", "master"].freeze
318
+ CC_MAX_FILES = 60
319
+ CC_MAX_FILE_BYTES = 500_000
320
+ CC_MAX_TOTAL_BYTES = 2_000_000
321
+
322
+ # Auto-capture the code change to attach when the caller passed none. Lives
323
+ # in replay() (the one call guaranteed to run on every replay) so it works no
324
+ # matter how the replay was launched. Precedence: a BITFAB_CODE_CHANGE_PATH
325
+ # override, else the git diff vs trunk. Best-effort: any failure yields nil.
326
+ # An explicit code_change_files always wins over this.
327
+ def resolve_auto_code_change(label = nil)
328
+ return nil if ENV["BITFAB_DISABLE_CODE_CHANGE_CAPTURE"]
329
+
330
+ from_env = read_code_change_file
331
+ return from_env unless from_env.nil?
332
+
333
+ capture_code_change_from_git(Dir.pwd, label)
334
+ end
335
+
336
+ def read_code_change_file
337
+ path = ENV["BITFAB_CODE_CHANGE_PATH"]
338
+ return nil if path.nil? || path.empty?
339
+
340
+ begin
341
+ parsed = JSON.parse(File.read(path))
342
+ files = parsed["files"]
343
+ # A malformed payload (non-array, or entries that aren't hashes) must
344
+ # yield no code change, never crash normalize_code_change_files.
345
+ files = nil unless files.is_a?(Array) && files.all?(Hash)
346
+ description = parsed["description"]
347
+ description = nil unless description.is_a?(String)
348
+ return nil if files.nil? && description.nil?
349
+
350
+ [description, files]
351
+ rescue
352
+ nil
353
+ end
354
+ end
355
+
356
+ def git(cwd, args)
357
+ # capture3 so git's stderr (e.g. "not a git repository") is swallowed, not
358
+ # leaked to the host process's console.
359
+ out, _err, status = Timeout.timeout(30) do
360
+ Open3.capture3("git", *args, chdir: cwd)
361
+ end
362
+ return nil unless status.success?
363
+
364
+ # Tag as UTF-8 and scrub invalid bytes so blob contents compare cleanly
365
+ # against the UTF-8 working-file reads and never break JSON.generate later
366
+ # (a non-UTF-8 file that slips past the null-byte check would otherwise
367
+ # abort the whole start-replay request).
368
+ out.force_encoding("UTF-8").scrub
369
+ rescue
370
+ nil
371
+ end
372
+
373
+ def cc_ref_exists?(root, ref)
374
+ !git(root, ["rev-parse", "--verify", "#{ref}^{object}"]).nil?
375
+ end
376
+
377
+ # Returns [base, from_trunk]; from_trunk is false on the HEAD fallback.
378
+ def cc_resolve_base(root)
379
+ forced = ENV["BITFAB_CODE_CHANGE_BASE"]
380
+ if forced && cc_ref_exists?(root, forced)
381
+ mb = git(root, ["merge-base", "HEAD", forced])
382
+ return [mb.strip, true] if mb && !mb.strip.empty?
383
+
384
+ rp = git(root, ["rev-parse", "--verify", forced])
385
+ return (rp && !rp.strip.empty?) ? [rp.strip, true] : nil
386
+ end
387
+ TRUNK_CANDIDATES.each do |candidate|
388
+ next unless cc_ref_exists?(root, candidate)
389
+
390
+ mb = git(root, ["merge-base", "HEAD", candidate])
391
+ return [mb.strip, true] if mb && !mb.strip.empty?
392
+ end
393
+ cc_ref_exists?(root, "HEAD") ? ["HEAD", false] : nil
394
+ end
395
+
396
+ def cc_blob_bytes(root, base, path)
397
+ out = git(root, ["cat-file", "-s", "#{base}:#{path}"])
398
+ (out && !out.strip.empty?) ? out.strip.to_i : 0
399
+ rescue
400
+ 0
401
+ end
402
+
403
+ def cc_working_bytes(root, path)
404
+ File.size(File.join(root, path))
405
+ rescue
406
+ 0
407
+ end
408
+
409
+ def capture_code_change_from_git(cwd, label = nil)
410
+ root = git(cwd, ["rev-parse", "--show-toplevel"])
411
+ return nil if root.nil? || root.strip.empty?
412
+
413
+ root = root.strip
414
+ resolved = cc_resolve_base(root)
415
+ return nil if resolved.nil?
416
+
417
+ base, from_trunk = resolved
418
+
419
+ tracked = git(root, ["diff", "--name-status", "--no-renames", "-z", base, "--", ":!.bitfab"]) || ""
420
+ untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ":!.bitfab"]) || ""
421
+
422
+ entries = cc_parse_name_status_z(tracked)
423
+ untracked.split("\0").reject(&:empty?).each { |p| entries << ["A", p] }
424
+ return nil if entries.empty?
425
+
426
+ files = []
427
+ total = 0
428
+ entries.each do |status, path|
429
+ break if files.length >= CC_MAX_FILES
430
+
431
+ # Skip oversized files by size BEFORE reading their full contents, so a
432
+ # huge changed file can't OOM/stall replay just to be discarded.
433
+ before_bytes = (status == "A") ? 0 : cc_blob_bytes(root, base, path)
434
+ after_bytes = (status == "D") ? 0 : cc_working_bytes(root, path)
435
+ next if before_bytes > CC_MAX_FILE_BYTES || after_bytes > CC_MAX_FILE_BYTES
436
+
437
+ # Normalize CRLF -> LF on both sides: git's blob is already LF-normalized
438
+ # (autocrlf clean), so a raw CRLF working file would otherwise skew every
439
+ # line as changed. Comparing/storing LF keeps the diff meaningful.
440
+ before = (status == "A") ? "" : (git(root, ["show", "#{base}:#{path}"]) || "").gsub("\r\n", "\n")
441
+ after = (status == "D") ? "" : cc_read_working_file(root, path).gsub("\r\n", "\n")
442
+ next if before == after
443
+
444
+ size = before.bytesize + after.bytesize
445
+ next if total + size > CC_MAX_TOTAL_BYTES ||
446
+ cc_looks_binary?(before) || cc_looks_binary?(after)
447
+
448
+ total += size
449
+ files << {"path" => path, "before" => before, "after" => after}
450
+ end
451
+ return nil if files.empty?
452
+
453
+ subject = git(root, ["log", "-1", "--format=%s", "HEAD"])
454
+ subject = subject ? subject.strip : ""
455
+ head = ((label && !label.strip.empty?) ? label.strip : nil) || (subject.empty? ? nil : subject) || "Working-tree change"
456
+ word = (files.length == 1) ? "file" : "files"
457
+ against = from_trunk ? "vs trunk" : "uncommitted (vs HEAD)"
458
+ ["#{head} (#{files.length} #{word} changed #{against})", files]
459
+ rescue
460
+ nil
461
+ end
462
+
463
+ def cc_parse_name_status_z(raw)
464
+ parts = raw.split("\0").reject(&:empty?)
465
+ out = []
466
+ i = 0
467
+ while i + 1 < parts.length
468
+ out << [parts[i][0], parts[i + 1]]
469
+ i += 2
470
+ end
471
+ out
472
+ end
473
+
474
+ def cc_read_working_file(root, path)
475
+ File.read(File.join(root, path), encoding: "UTF-8")
476
+ rescue
477
+ ""
478
+ end
479
+
480
+ def cc_looks_binary?(str)
481
+ str[0, 8000].include?("\0")
482
+ end
483
+
293
484
  def write_replay_result_file(result)
294
485
  result_path = ENV["BITFAB_REPLAY_RESULT_PATH"]
295
486
  return if result_path.nil? || result_path.empty?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.29.1"
4
+ VERSION = "0.30.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.1
4
+ version: 0.30.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team