bitfab 0.36.2 → 0.36.4

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: 44af81a548095fe4bf8a347b6e58bb1c3bdcf2726becd78f029ec5c7c9507ca9
4
- data.tar.gz: 4a00a95452cf45e5992e7eee4754850923eed8767713368a063fa1fed84d4b7c
3
+ metadata.gz: 5578e8fdcdd81610b4e65fa94a5e9be88339e816742f6ba7fea81b53867eedf5
4
+ data.tar.gz: 308d35969ac643d1ddcfb76c169a6108906c958275b392f7d159ea647b0bf63c
5
5
  SHA512:
6
- metadata.gz: 9043c05ffb79c8830299127c39de0dc77b77dc3fa1fd2a573813696bcd9a7ba3fa95d7a06a7371ff596ed04781f439311f773bdff24afe055b252b36e0d36bb5
7
- data.tar.gz: d73327657254114a22c091bb58503c17f1f8d0fcc19f0179c283b452df4c7240d446ff8d36962b16fcc47ea13e0315c80f554280b79f47b684f24ba50b11ce23
6
+ metadata.gz: 2c0511909223595a7792e780d0c77b8c0f5ef2d239e4b2a5dda7946a63fbe4801893ae02ba3392b74520ad9d82844e519d3319a900f22c30bef4857e616c0420
7
+ data.tar.gz: 0ae83e2f731bc2f6fa9f564b6fe8754b7fff82c5ae03a914067cca6fb75f4ddb10e935bba8976de6f0dabe7025812e49d9392e2ace61b5202c02d80b21fe00aa
data/lib/bitfab/client.rb CHANGED
@@ -132,21 +132,27 @@ module Bitfab
132
132
  # +:min_cu+/+:max_cu+ size the branch compute in Neon Compute Units and
133
133
  # +:warmup_sql+ warms its cache. Read the resolved branch inside the
134
134
  # replayed method with +Bitfab.current_replay_branch+.
135
- # @param on_progress [#call, nil] optional callback invoked once per item as
136
- # it finishes, with a running-totals hash { completed:, total:, succeeded:,
137
- # errored: }. Use it to render replay progress (e.g. a terminal progress
138
- # bar). Replay does not know pass/fail yet, so the totals only distinguish
139
- # items whose method ran (:succeeded) from items that raised (:errored).
140
- # A raising callback never crashes the run.
135
+ # @param on_item_finish [#call, nil] optional callback invoked exactly once
136
+ # per item as it finishes, always with a running-totals hash containing
137
+ # { completed:, total:, succeeded:, errored:, item: }. It never receives a
138
+ # whole-run completion event. Replay does not know pass/fail yet, so the
139
+ # totals only distinguish items whose method ran (:succeeded) from items
140
+ # that raised (:errored). A raising callback never crashes the run.
141
+ # @param on_progress [#call, nil] deprecated compatibility callback. It
142
+ # receives the same per-item events plus the legacy item-less +"complete"+
143
+ # event. Ignored when +on_item_finish+ is also provided.
144
+ # @param on_item_start [#call, nil] optional callback invoked when a worker
145
+ # begins processing each item. Pair it with on_item_finish to distinguish
146
+ # queued work from in-flight work. A raising callback never crashes the run.
141
147
  # @return [Hash] with :items, :test_run_id, :test_run_url
142
148
  def replay(receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
143
149
  name: nil, code_change_description: Replay::CODE_CHANGE_UNSET, code_change_files: Replay::CODE_CHANGE_UNSET, experiment_group_id: nil, dataset_id: nil, grader_ids: nil, mock: "marked",
144
- adapt_inputs: nil, mock_override: nil, db_branch: nil, on_progress: nil)
150
+ adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil)
145
151
  Replay.run(
146
152
  self, receiver, method_name,
147
153
  trace_function_key:, limit:, trace_ids:, name:, max_concurrency:,
148
154
  code_change_description:, code_change_files:, experiment_group_id:, dataset_id:, grader_ids:, mock:, adapt_inputs:,
149
- mock_override:, db_branch:,
155
+ mock_override:, db_branch:, on_item_start:, on_item_finish:,
150
156
  on_progress:
151
157
  )
152
158
  end
data/lib/bitfab/replay.rb CHANGED
@@ -12,6 +12,30 @@ require_relative "traceable"
12
12
  require_relative "transport"
13
13
 
14
14
  module Bitfab
15
+ # A requested replay database branch could not be resolved. The resolver code
16
+ # and original message remain structured for callers to inspect.
17
+ class DbBranchReplayError < StandardError
18
+ attr_reader :code, :original_trace_id
19
+
20
+ def initialize(code, message, original_trace_id)
21
+ super(message)
22
+ @code = code
23
+ @original_trace_id = original_trace_id
24
+ end
25
+ end
26
+
27
+ # Whole-run replay failure with every item collected before the run failed.
28
+ class ReplayError < StandardError
29
+ attr_reader :items, :test_run_id, :test_run_url
30
+
31
+ def initialize(message, items:, test_run_id:, test_run_url:)
32
+ super(message)
33
+ @items = items
34
+ @test_run_id = test_run_id
35
+ @test_run_url = test_run_url
36
+ end
37
+ end
38
+
15
39
  # Replay mock strategies. Mirrors the Python and TypeScript SDKs.
16
40
  #
17
41
  # - "marked" : only spans declared with mock_on_replay: true return historical
@@ -85,6 +109,50 @@ module Bitfab
85
109
 
86
110
  module_function
87
111
 
112
+ def json_safe(value)
113
+ case value
114
+ when Exception
115
+ serialized = {type: value.class.name, message: value.message, backtrace: value.backtrace}
116
+ if value.is_a?(DbBranchReplayError)
117
+ serialized[:code] = value.code
118
+ serialized[:original_trace_id] = value.original_trace_id
119
+ serialized[:cause] = json_safe(value.cause) if value.cause
120
+ end
121
+ serialized
122
+ when Hash
123
+ value.to_h { |key, child| [key, json_safe(child)] }
124
+ when Array
125
+ value.map { |child| json_safe(child) }
126
+ else
127
+ value
128
+ end
129
+ end
130
+
131
+ def replay_item_error_message(error)
132
+ return error.message unless error.is_a?(DbBranchReplayError)
133
+
134
+ "Replay requested a database branch for trace #{error.original_trace_id} but it " \
135
+ "could not be resolved (#{error.code}): #{error.message}. The method was not run, " \
136
+ "because replaying it against the live database would produce a result that looks " \
137
+ "valid but did not use the historical data you asked for."
138
+ end
139
+
140
+ def public_replay_items(items)
141
+ items.map { |item| item.except(:_sdk_trace_id) }
142
+ end
143
+
144
+ def preserve_replay_failure(items, test_run_id, test_run_url)
145
+ yield
146
+ rescue => cause
147
+ error = ReplayError.new(
148
+ cause.message,
149
+ items: public_replay_items(items),
150
+ test_run_id:,
151
+ test_run_url:
152
+ )
153
+ raise error, cause:
154
+ end
155
+
88
156
  # Replay historical traces through a method and create a test run.
89
157
  #
90
158
  # Fetches the last N traces for the given trace function key, re-runs each
@@ -133,11 +201,11 @@ module Bitfab
133
201
  # and ceiling in Neon Compute Units, and +:warmup_sql+ warms the branch's
134
202
  # cache before the replayed method sees it. Read the resolved branch
135
203
  # inside the method with +Bitfab.current_replay_branch+.
136
- # @param on_progress [#call, nil] optional callback invoked once per item as
204
+ # @param on_item_finish [#call, nil] optional callback invoked once per item as
137
205
  # it finishes, with a running-totals hash { completed:, total:, succeeded:,
138
206
  # errored:, item: } where item is { trace_id:, original_trace_id:,
139
207
  # original_span_id:, error:, duration_ms: } for the single item that just
140
- # settled (source_trace_id/source_span_id remain as deprecated aliases).
208
+ # finished (source_trace_id/source_span_id remain as deprecated aliases).
141
209
  # trace_id is the new server replay trace id, written in after the run
142
210
  # completes (nil during progress callbacks); original_trace_id is the
143
211
  # ORIGINAL (historical) trace that was replayed, error is that item's replay
@@ -145,14 +213,22 @@ module Bitfab
145
213
  # duration_ms is how long that one trace took to replay. Use it to
146
214
  # render replay progress (e.g. a per-trace log). A raising callback never
147
215
  # crashes the run.
216
+ # @param on_progress [#call, nil] deprecated compatibility callback. It
217
+ # receives the same per-item finish events plus the legacy whole-run
218
+ # +"complete"+ event. Ignored when +on_item_finish+ is also provided.
219
+ # @param on_item_start [#call, nil] optional callback invoked when a worker
220
+ # begins processing each item, before replay setup and customer code run.
221
+ # Pair it with on_item_finish to distinguish queued work from in-flight work.
222
+ # A raising callback never crashes the run.
148
223
  # @return [Hash] with :items, :test_run_id, :test_run_url
149
224
  def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil,
150
225
  max_concurrency: 10, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil,
151
226
  dataset_id: nil, grader_ids: nil, mock: "marked",
152
- adapt_inputs: nil, mock_override: nil, db_branch: nil, on_progress: nil)
227
+ adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil)
153
228
  unless MOCK_STRATEGIES.include?(mock.to_s)
154
229
  raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
155
230
  end
231
+ item_finish_callback = on_item_finish || on_progress
156
232
  if trace_ids
157
233
  raise ArgumentError, "trace_ids must contain at least one trace ID." if trace_ids.empty?
158
234
  if trace_ids.length > 100
@@ -223,11 +299,13 @@ module Bitfab
223
299
  )
224
300
  test_run_id = replay_data["testRunId"]
225
301
  test_run_url = replay_data["testRunUrl"]
302
+ full_test_run_url = "#{client.service_url}#{test_run_url}"
226
303
  server_items = replay_data["items"] || []
227
304
 
228
305
  result_items = if server_items.any?
229
306
  process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock.to_s,
230
- adapt_inputs, include_db_branch_lease, resolved_db_branch_settings, on_progress:,
307
+ adapt_inputs, include_db_branch_lease, resolved_db_branch_settings, on_item_start:,
308
+ on_item_finish: item_finish_callback,
231
309
  mock_overrides: resolved_overrides)
232
310
  else
233
311
  []
@@ -237,12 +315,16 @@ module Bitfab
237
315
  # to finalize once the server confirms every replay trace it queued: the
238
316
  # trace-ID mapping complete_replay builds would otherwise race the
239
317
  # in-flight batches and hand back nil for every item.
240
- wait_for_replay_persistence(http_client, test_run_id, result_items.map { |item| item[:_sdk_trace_id] })
318
+ preserve_replay_failure(result_items, test_run_id, full_test_run_url) do
319
+ wait_for_replay_persistence(http_client, test_run_id, result_items.map { |item| item[:_sdk_trace_id] })
320
+ end
241
321
 
242
322
  # complete_replay finalizes the run and returns the token/diagnostic
243
323
  # mapping; its failures propagate loudly because a run that never
244
324
  # completed can't be finalized.
245
- complete_response = http_client.complete_replay(test_run_id)
325
+ complete_response = preserve_replay_failure(result_items, test_run_id, full_test_run_url) do
326
+ http_client.complete_replay(test_run_id)
327
+ end
246
328
  trace_id_map = complete_response&.dig("traceIds")
247
329
  # Per-replay-trace token usage keyed by server trace id: the REPLAYED
248
330
  # run's tokens (span-aggregated server-side), used below to fill each
@@ -278,11 +360,18 @@ module Bitfab
278
360
  if completed_count.positive? && missing.length == completed_count
279
361
  trace_count = complete_response["traceCount"]
280
362
  server_count = trace_count.nil? ? "" : " The server persisted #{trace_count} trace(s) for this run."
281
- raise "Replay completed but the server has no persisted trace for " \
363
+ cause = RuntimeError.new("Replay completed but the server has no persisted trace for " \
282
364
  "any of the #{completed_count} completed item(s) " \
283
365
  "(test_run_id #{test_run_id}).#{server_count} Trace uploads were " \
284
366
  "flushed, so either the uploads failed or the replayed method is " \
285
- "not traced (no root span was emitted)."
367
+ "not traced (no root span was emitted).")
368
+ error = ReplayError.new(
369
+ cause.message,
370
+ items: public_replay_items(result_items),
371
+ test_run_id:,
372
+ test_run_url: full_test_run_url
373
+ )
374
+ raise error, cause:
286
375
  end
287
376
  # SOME completed items missing: per-item upload failure. Warn, but
288
377
  # return the run; the items that landed can still be labeled.
@@ -301,16 +390,14 @@ module Bitfab
301
390
  result = {
302
391
  items: result_items,
303
392
  test_run_id:,
304
- test_run_url: "#{client.service_url}#{test_run_url}"
393
+ test_run_url: full_test_run_url
305
394
  }
306
- # Persist the enriched result two ways so the Bitfab plugin never has to
307
- # parse the replay's stdout (which a dependency's logging can corrupt):
308
- # write it to BITFAB_REPLAY_RESULT_PATH when the plugin set that env var,
309
- # and stream a terminal "complete" progress event. The plugin prefers the
310
- # streamed event and falls back to the file. The event routes through
311
- # on_progress so only progress-reporting runs emit it.
395
+ # Persist the enriched result so the Bitfab plugin never has to parse the
396
+ # replay's stdout, which a dependency's logging can corrupt.
312
397
  write_replay_result_file(result)
313
- if on_progress
398
+ # Preserve the legacy terminal event only for on_progress. on_item_finish
399
+ # is strictly item-scoped, so every invocation always includes an item.
400
+ if on_item_finish.nil? && on_progress
314
401
  errored = result_items.count { |item| !item[:error].nil? }
315
402
  total = result_items.length
316
403
  begin
@@ -543,7 +630,7 @@ module Bitfab
543
630
  begin
544
631
  dir = File.dirname(result_path)
545
632
  FileUtils.mkdir_p(dir) unless dir.nil? || dir.empty? || dir == "."
546
- File.write(result_path, "#{JSON.pretty_generate(result)}\n")
633
+ File.write(result_path, "#{Bitfab.serialize_replay_result(result)}\n")
547
634
  rescue => e
548
635
  warn "Bitfab: failed to write replay result to BITFAB_REPLAY_RESULT_PATH (#{result_path}): #{e.message}"
549
636
  end
@@ -551,36 +638,56 @@ module Bitfab
551
638
 
552
639
  # Process all replay items, optionally in parallel using threads.
553
640
  def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy,
554
- adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, on_progress: nil,
641
+ adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, on_item_start: nil, on_item_finish: nil,
555
642
  mock_overrides: [])
556
643
  concurrency = max_concurrency || server_items.length
557
644
 
558
- # Reports running totals once per item as it settles. In the parallel
559
- # path it runs from worker threads, so the mutex both makes the counter
560
- # updates safe and serializes the user's callback (never called
561
- # concurrently). A raising callback is swallowed: progress UI must never
562
- # crash the run.
645
+ # Lifecycle callbacks run from worker threads in the parallel path, so the
646
+ # mutex makes counter updates safe and serializes each callback. A raising
647
+ # callback is swallowed because progress UI must never crash the run.
563
648
  total = server_items.length
564
649
  progress_mutex = Mutex.new
650
+ started = 0
565
651
  completed = 0
566
652
  succeeded = 0
567
653
  errored = 0
568
- # Each event carries the single item that just settled so a progress UI
654
+ # Each event carries the single item that just finished so a progress UI
569
655
  # can render per-trace pass/fail as the run streams. The item's :trace_id
570
656
  # is nil at this stage: the server replay trace id isn't known until run()
571
657
  # writes it in after complete_replay, and the client correlation id is
572
658
  # never surfaced. original_trace_id (the historical trace being replayed,
573
659
  # taken from the server item) is what a UI keys on to identify what just
574
- # settled. source_trace_id/source_span_id are kept as deprecated aliases.
575
- report = lambda do |result, original_trace_id, original_span_id, test_run_id|
576
- return unless on_progress
660
+ # finished. source_trace_id/source_span_id are kept as deprecated aliases.
661
+ report_start = lambda do |original_trace_id, original_span_id|
662
+ progress_mutex.synchronize do
663
+ started += 1
664
+ next unless on_item_start
577
665
 
666
+ begin
667
+ on_item_start.call({
668
+ type: "started", test_run_id:, started:, completed:, total:, succeeded:, errored:,
669
+ item: {
670
+ original_trace_id:,
671
+ original_span_id:,
672
+ source_trace_id: original_trace_id,
673
+ source_span_id: original_span_id
674
+ }
675
+ })
676
+ rescue => e
677
+ warn "Bitfab: replay on_item_start callback raised: #{e.message}"
678
+ end
679
+ end
680
+ end
681
+
682
+ report = lambda do |result, original_trace_id, original_span_id, test_run_id|
578
683
  progress_mutex.synchronize do
579
684
  completed += 1
580
685
  error = result[:error]
581
686
  error.nil? ? (succeeded += 1) : (errored += 1)
687
+ next unless on_item_finish
688
+
582
689
  begin
583
- on_progress.call({
690
+ on_item_finish.call({
584
691
  test_run_id:, completed:, total:, succeeded:, errored:,
585
692
  item: {
586
693
  trace_id: result[:trace_id],
@@ -593,6 +700,8 @@ module Bitfab
593
700
  result: result[:result],
594
701
  original_output: result[:original_output],
595
702
  error:,
703
+ trace_error: result[:trace_error],
704
+ replay_error: result[:replay_error],
596
705
  duration_ms: result[:duration_ms],
597
706
  tokens: result[:tokens],
598
707
  model: result[:model],
@@ -600,13 +709,14 @@ module Bitfab
600
709
  }
601
710
  })
602
711
  rescue => e
603
- warn "Bitfab: replay on_progress callback raised: #{e.message}"
712
+ warn "Bitfab: replay on_item_finish callback raised: #{e.message}"
604
713
  end
605
714
  end
606
715
  end
607
716
 
608
717
  if concurrency <= 1
609
718
  server_items.map do |item|
719
+ report_start.call(original_trace_id_of(item), original_span_id_of(item))
610
720
  result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
611
721
  adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:)
612
722
  report.call(result, original_trace_id_of(item), original_span_id_of(item), test_run_id)
@@ -624,6 +734,7 @@ module Bitfab
624
734
  item, idx = work_mutex.synchronize { work_queue.shift }
625
735
  break unless item
626
736
 
737
+ report_start.call(original_trace_id_of(item), original_span_id_of(item))
627
738
  result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
628
739
  adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:)
629
740
  results_mutex.synchronize { results[idx] = result }
@@ -664,16 +775,26 @@ module Bitfab
664
775
  lease_error = include_db_branch_lease ? server_item["dbBranchLeaseError"] : nil
665
776
  db_snapshot_ref = server_item["dbSnapshotRef"]
666
777
  if include_db_branch_lease && lease.nil? && lease_error.nil?
667
- resolved = http_client.resolve_db_branch_lease(test_run_id, original_trace_id, db_branch_settings)
778
+ begin
779
+ resolved = http_client.resolve_db_branch_lease(test_run_id, original_trace_id, db_branch_settings)
780
+ rescue => cause
781
+ error = DbBranchReplayError.new(
782
+ "lease_request_failed",
783
+ "Bitfab could not request the database branch: #{cause.message}",
784
+ original_trace_id
785
+ )
786
+ raise error, cause:
787
+ end
668
788
  lease = resolved["lease"]
669
789
  lease_error = resolved["leaseError"]
670
790
  db_snapshot_ref = resolved["dbSnapshotRef"] || db_snapshot_ref
671
791
  end
672
792
  if lease_error
673
- raise "Replay requested a database branch for trace #{original_trace_id} but it " \
674
- "could not be resolved (#{lease_error["code"]}): #{lease_error["message"]}. " \
675
- "The method was not run, because replaying it against the live database would " \
676
- "produce a result that looks valid but did not use the historical data you asked for."
793
+ raise DbBranchReplayError.new(
794
+ lease_error["code"].to_s,
795
+ lease_error["message"].to_s,
796
+ original_trace_id
797
+ )
677
798
  end
678
799
 
679
800
  span = http_client.get_external_span(original_span_id, replay_view: true)
@@ -752,7 +873,9 @@ module Bitfab
752
873
  input: [],
753
874
  result: nil,
754
875
  original_output: nil,
755
- error: e.message,
876
+ error: replay_item_error_message(e),
877
+ trace_error: nil,
878
+ replay_error: e,
756
879
  duration_ms: metrics&.dig(:duration_ms),
757
880
  tokens: metrics&.dig(:tokens),
758
881
  model: metrics&.dig(:model),
@@ -941,51 +1064,64 @@ module Bitfab
941
1064
 
942
1065
  fn_result = nil
943
1066
  fn_error = nil
1067
+ replay_error = nil
944
1068
  # Client-side correlation id that tags this item's replay spans so the
945
1069
  # server can echo back the row id it minted (resolved in run()'s
946
1070
  # complete-replay loop). Carried on the item under :_sdk_trace_id, never
947
1071
  # surfaced as the public :trace_id.
948
1072
  sdk_trace_id = SecureRandom.uuid
949
1073
 
950
- ReplayContext.with_context(
951
- test_run_id:,
952
- input_source_span_id:,
953
- input_source_trace_id:,
954
- trace_id: sdk_trace_id,
955
- mock_tree:,
956
- mock_strategy:,
957
- mock_overrides:,
958
- fetch_span_output:,
959
- db_branch_lease:,
960
- source_bitfab_trace_id:
961
- ) do
962
- # Reshape recorded inputs onto the current signature when an adapter is
963
- # supplied. Inside the rescue so a raising adapter surfaces on this
964
- # item's :error instead of crashing the run; args is reported on :input.
965
- if adapt_inputs
966
- ctx = adapt_ctx || {
967
- original_trace_id: nil,
968
- original_span_id: input_source_span_id,
969
- # Deprecated aliases for original_trace_id/original_span_id.
970
- source_trace_id: nil,
971
- source_span_id: input_source_span_id
972
- }
973
- args, kwargs = adapt_inputs.call(args, kwargs, ctx)
974
- end
975
- fn_result = if kwargs.empty?
976
- receiver.send(method_name, *args)
977
- else
978
- receiver.send(method_name, *args, **kwargs)
1074
+ begin
1075
+ ReplayContext.with_context(
1076
+ test_run_id:,
1077
+ input_source_span_id:,
1078
+ input_source_trace_id:,
1079
+ trace_id: sdk_trace_id,
1080
+ mock_tree:,
1081
+ mock_strategy:,
1082
+ mock_overrides:,
1083
+ fetch_span_output:,
1084
+ db_branch_lease:,
1085
+ source_bitfab_trace_id:
1086
+ ) do
1087
+ begin
1088
+ if adapt_inputs
1089
+ ctx = adapt_ctx || {
1090
+ original_trace_id: nil,
1091
+ original_span_id: input_source_span_id,
1092
+ # Deprecated aliases for original_trace_id/original_span_id.
1093
+ source_trace_id: nil,
1094
+ source_span_id: input_source_span_id
1095
+ }
1096
+ args, kwargs = adapt_inputs.call(args, kwargs, ctx)
1097
+ end
1098
+ rescue => e
1099
+ replay_error = e
1100
+ end
1101
+ if replay_error.nil?
1102
+ begin
1103
+ fn_result = if kwargs.empty?
1104
+ receiver.send(method_name, *args)
1105
+ else
1106
+ receiver.send(method_name, *args, **kwargs)
1107
+ end
1108
+ rescue => e
1109
+ fn_error = e
1110
+ end
1111
+ end
979
1112
  end
980
1113
  rescue => e
981
- fn_error = e.message
1114
+ replay_error = e
982
1115
  end
983
1116
 
1117
+ item_error = fn_error || replay_error
984
1118
  {
985
1119
  input: args,
986
1120
  result: fn_result,
987
1121
  original_output: item["output"],
988
- error: fn_error,
1122
+ error: item_error&.message,
1123
+ trace_error: fn_error,
1124
+ replay_error:,
989
1125
  duration_ms: metrics[:duration_ms],
990
1126
  tokens: metrics[:tokens],
991
1127
  model: metrics[:model],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.36.2"
4
+ VERSION = "0.36.4"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -144,24 +144,27 @@ module Bitfab
144
144
  CurrentTrace.new(entry[:trace_id])
145
145
  end
146
146
 
147
- # A ready-made on_progress callback for replay: writes one @@bitfab:progress
148
- # line per trace to stderr, which the Bitfab plugin polls to report live
149
- # progress while the replay runs in the background, so replay scripts never
150
- # hand-format the protocol. stdout stays the ReplayResult JSON. Never raises
151
- # (progress must not crash a run). The progress hash's item is forwarded
152
- # verbatim through to_json, so the plugin sees the source trace id, local
153
- # replay trace id, outputs, error, and duration of the item that just settled.
147
+ # A ready-made replay lifecycle reporter. Pass it as both on_item_start and
148
+ # on_item_finish so the Bitfab plugin sees in-flight and finished items. It writes
149
+ # @@bitfab:progress lines to stderr, leaving stdout for ReplayResult JSON, and
150
+ # never raises because progress reporting must not crash a run.
154
151
  #
155
152
  # @example
156
- # Bitfab.client.replay(..., on_progress: Bitfab.method(:report_replay_progress))
153
+ # reporter = Bitfab.method(:report_replay_progress)
154
+ # Bitfab.client.replay(..., on_item_start: reporter, on_item_finish: reporter)
157
155
  #
158
156
  # @param progress [Hash] running totals with keys completed, total,
159
157
  # succeeded, errored, and item (the object replay already passes to
160
- # on_progress)
158
+ # on_item_finish)
161
159
  def report_replay_progress(progress)
162
- warn "#{BITFAB_PROGRESS_PREFIX}#{progress.to_json}"
160
+ warn "#{BITFAB_PROGRESS_PREFIX}#{Replay.json_safe(progress).to_json}"
163
161
  rescue
164
162
  nil
165
163
  end
164
+
165
+ # Serialize a replay result while retaining structured exception fields.
166
+ def serialize_replay_result(result)
167
+ JSON.pretty_generate(Replay.json_safe(result))
168
+ end
166
169
  end
167
170
  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.36.2
4
+ version: 0.36.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team