bitfab 0.64.16 → 0.64.18

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: 663335d46afd24e88e03d2bb0e8614928b95ebe4f9e24abad0e4f2db68255784
4
- data.tar.gz: e0e696b4ed008232aaf21cce80b8f48fa95168500d8bf520874237a5ab54f1fa
3
+ metadata.gz: 9b213b2d758ad1fbcafc95b9f3704d6fa06c5b288be373dfd9c97565c699eaa4
4
+ data.tar.gz: 67f58161475956d24a8bac5592e080b42892b277fc0fd132e9a1b679254fddbc
5
5
  SHA512:
6
- metadata.gz: 52d0d805f4ffe7e66584885c853a9a5a8d26d04af5159ec174ac4c3befa079e33c977bbab0b3804644fd22e415f97bfe48a5358bd3eb51ab791d16861e97e9f9
7
- data.tar.gz: 3a345ad2555bff9970cfeae3bf9e965f0cf08c0ec30765526173ef394f4eb4b02c213d50d7b5352d923ca9b5a2d2fe06c46c19d0f494fb1d75ed82242d04b4d6
6
+ metadata.gz: 54b089f5d3a87132403ec8bff156d0a2bcb596480956c14b9bdb8f158c877589a33a30a8ee1eafaed363ff1ac97b61638997abed639aeb487f16eaba6fd90c81
7
+ data.tar.gz: c34da0a8b44cb56dad4d788188940fb11197b8377155ff614fb7dd930141dde8911f4bdbdb83480c0a1a4cca73748e2b296459430c3641002a8e1a4a8fe9b6c8
data/lib/bitfab/client.rb CHANGED
@@ -37,7 +37,7 @@ module Bitfab
37
37
 
38
38
  attr_reader :service_url
39
39
 
40
- attr_reader :datasets, :assertion_categories, :organization_members, :traces, :labels, :graders, :experiments
40
+ attr_reader :datasets, :assertion_categories, :organization_members, :traces, :spans, :labels, :graders, :experiments
41
41
 
42
42
  def initialize(api_key: nil, service_url: nil, capture_enabled: nil, enabled: nil, strict: false, simulation_plan: true, timeout: 120, trace_across_threads: nil, env_vars: nil, baml_client: nil, db_snapshot: nil)
43
43
  @db_snapshot = DbSnapshot.normalize_config(db_snapshot)
@@ -57,6 +57,7 @@ module Bitfab
57
57
  @assertion_categories = AssertionCategories.new(@http_client)
58
58
  @organization_members = OrganizationMembers.new(@http_client)
59
59
  @traces = Traces.new(@http_client)
60
+ @spans = Spans.new(@http_client)
60
61
  @labels = Labels.new(@http_client)
61
62
  @graders = Graders.new(@http_client)
62
63
  @experiments = Experiments.new(@http_client)
@@ -370,24 +371,28 @@ module Bitfab
370
371
  end
371
372
 
372
373
  # Fetch one persisted span without loading the full trace.
373
- # Exactly one of id or name is required. Name lookups return the last
374
- # matching span by default; occurrence also accepts "first" or a zero-based
375
- # Integer index.
376
- def get_trace_span(trace_id, id: nil, name: nil, occurrence: "last")
374
+ # Pass id alone, or name and/or type (llm, agent, function, guardrail,
375
+ # handoff, custom). Name and type lookups return the last matching span by
376
+ # default; occurrence also accepts "first" or a zero-based Integer index.
377
+ # Raises ArgumentError before sending for any other combination or an
378
+ # unknown type.
379
+ def get_trace_span(trace_id, id: nil, name: nil, type: nil, occurrence: "last")
377
380
  validate_trace_id(trace_id)
378
- raise ArgumentError, "Provide exactly one of id or name" if id.nil? == name.nil?
381
+ has_position_selector = !name.nil? || !type.nil?
382
+ raise ArgumentError, "Provide id, or name and/or type" if id.nil? != has_position_selector
379
383
 
380
384
  validate_span_id(id) unless id.nil?
381
385
  if !name.nil? && (!name.is_a?(String) || name.empty?)
382
386
  raise ArgumentError, "name must be a non-empty string"
383
387
  end
388
+ validate_span_type!(type) unless type.nil?
384
389
 
385
390
  valid_occurrence = ["first", "last"].include?(occurrence) || (occurrence.is_a?(Integer) && occurrence >= 0)
386
391
  unless valid_occurrence
387
392
  raise ArgumentError, 'occurrence must be "first", "last", or a non-negative integer'
388
393
  end
389
394
 
390
- @http_client.get_trace_span(trace_id, id:, name:, occurrence:)
395
+ @http_client.get_trace_span(trace_id, id:, name:, type:, occurrence:)
391
396
  end
392
397
 
393
398
  def get_span(span_id)
@@ -137,6 +137,11 @@ RENAMED_FLAGS = {
137
137
  SEED_FLAGS = ("--seed", "--cases", "--from-trace", "--run")
138
138
  PATH_FLAGS = ("--registry", "--params", "--code-change")
139
139
  CODE_CHANGE_FLAGS = ("--code-change", "--no-code-change")
140
+ CHECKOUT_DEPTH = 2
141
+ CODE_CHANGE_PATH_ENV = "BITFAB_CODE_CHANGE_PATH"
142
+ CODE_CHANGE_MAX_FILES = 60
143
+ CODE_CHANGE_MAX_FILE_BYTES = 500_000
144
+ CODE_CHANGE_MAX_TOTAL_BYTES = 2_000_000
140
145
  CHECK_ARGUMENTS_SECONDS = 300
141
146
  CHECK_ARGUMENTS_FLAG = "--check-arguments"
142
147
  REPLAY_ARGUMENTS_FLAG = "--replay-arguments"
@@ -165,6 +170,8 @@ Every replay option works as it does locally, including --dry-run and --fail-on-
165
170
  and your SDK checks them before anything is uploaded. The runner replays a snapshot of
166
171
  your working tree (changed files, plus new files git does not ignore) from the same
167
172
  directory, and this command prints the replay's output and exits with its exit code.
173
+ The experiment records your branch, the commit the snapshot was taken on, and the
174
+ snapshot's changed files as its code change, as it would for a local replay.
168
175
  Credential-like files that are not on GitHub yet are refused unless you name them with
169
176
  --cloud-allow-file. Requires git and Python 3.10+ on macOS or Linux, with a github.com
170
177
  origin.
@@ -887,6 +894,13 @@ def replay_request(root, parsed):
887
894
  return request, files
888
895
 
889
896
 
897
+ def current_branch(root):
898
+ try:
899
+ return git(root, "symbolic-ref", "--short", "-q", "HEAD") or None
900
+ except CommandError:
901
+ return None
902
+
903
+
890
904
  def sensitive(path):
891
905
  parts = Path(path).parts
892
906
  name = Path(path).name.lower()
@@ -1300,6 +1314,9 @@ def allowed_files(root, parsed):
1300
1314
  def submit(root, repo, workflow, parsed, path):
1301
1315
  execution_id = parsed["id"]
1302
1316
  request, files = replay_request(root, parsed)
1317
+ branch = current_branch(root)
1318
+ if branch:
1319
+ request["branch"] = branch
1303
1320
  check_arguments(parsed["replay"])
1304
1321
  allowed = allowed_files(root, parsed)
1305
1322
  if "--cloud-preview" in parsed["switches"]:
@@ -1817,6 +1834,111 @@ def runner_request(root, commit):
1817
1834
  return dispatched_request()
1818
1835
 
1819
1836
 
1837
+ def check_out_snapshot_base(root, commit, branch):
1838
+ try:
1839
+ base = git(root, "rev-parse", "--verify", "-q", f"{commit}^")
1840
+ except CommandError:
1841
+ print(
1842
+ "This checkout does not include the commit the snapshot was taken on, so the experiment records no code change; set fetch-depth: 2 on the workflow's actions/checkout step, or run bitfab-replay --cloud-update",
1843
+ flush=True,
1844
+ )
1845
+ return None
1846
+ if isinstance(branch, str) and BRANCH_NAME.fullmatch(branch):
1847
+ try:
1848
+ git(root, "update-ref", f"refs/heads/{branch}", base)
1849
+ git(root, "symbolic-ref", "HEAD", f"refs/heads/{branch}")
1850
+ return base
1851
+ except CommandError:
1852
+ pass
1853
+ git(root, "reset", "-q", "--soft", base)
1854
+ return base
1855
+
1856
+
1857
+ def replay_name(args):
1858
+ for index, value in enumerate(args):
1859
+ if value.startswith("--name="):
1860
+ return value.partition("=")[2]
1861
+ if value == "--name" and index + 1 < len(args):
1862
+ return args[index + 1]
1863
+ return None
1864
+
1865
+
1866
+ def git_blob(root, ref, path):
1867
+ try:
1868
+ size = int(git(root, "cat-file", "-s", f"{ref}:{path}"))
1869
+ except (CommandError, ValueError):
1870
+ return None
1871
+ if size > CODE_CHANGE_MAX_FILE_BYTES:
1872
+ return None
1873
+ result = subprocess.run(
1874
+ ["git", "-C", str(root), "show", f"{ref}:{path}"],
1875
+ capture_output=True,
1876
+ check=False,
1877
+ )
1878
+ if result.returncode or b"\x00" in result.stdout[:8000]:
1879
+ return None
1880
+ return result.stdout.decode("utf-8", "replace").replace("\r\n", "\n")
1881
+
1882
+
1883
+ def changed_paths(root, base, commit):
1884
+ raw = git(
1885
+ root,
1886
+ "diff",
1887
+ "--name-status",
1888
+ "--find-renames",
1889
+ "-z",
1890
+ base,
1891
+ commit,
1892
+ "--",
1893
+ ":!.bitfab",
1894
+ )
1895
+ parts = [part for part in raw.split("\x00") if part]
1896
+ index = 0
1897
+ while index + 1 < len(parts):
1898
+ status, before_path = parts[index][0], parts[index + 1]
1899
+ index += 2
1900
+ path = before_path
1901
+ if status in "RC" and index < len(parts):
1902
+ path = parts[index]
1903
+ index += 1
1904
+ yield status, before_path, path
1905
+
1906
+
1907
+ def snapshot_code_change(root, base, commit, name):
1908
+ files, total = [], 0
1909
+ for status, before_path, path in changed_paths(root, base, commit):
1910
+ if len(files) >= CODE_CHANGE_MAX_FILES:
1911
+ break
1912
+ before = "" if status == "A" else git_blob(root, base, before_path)
1913
+ after = "" if status == "D" else git_blob(root, commit, path)
1914
+ if before is None or after is None or before == after:
1915
+ continue
1916
+ size = len(before.encode()) + len(after.encode())
1917
+ if total + size > CODE_CHANGE_MAX_TOTAL_BYTES:
1918
+ continue
1919
+ total += size
1920
+ files.append({"path": path, "before": before, "after": after})
1921
+ if not files:
1922
+ return None
1923
+ head = (name or "").strip() or git(root, "log", "-1", "--format=%s", base)
1924
+ word = "file" if len(files) == 1 else "files"
1925
+ return {
1926
+ "description": f"{head or 'Working-tree change'} ({len(files)} {word} changed uncommitted (vs HEAD))",
1927
+ "files": files,
1928
+ }
1929
+
1930
+
1931
+ def write_code_change(change):
1932
+ handle, path = tempfile.mkstemp(
1933
+ prefix="bitfab-code-change-",
1934
+ suffix=".json",
1935
+ dir=os.environ.get("RUNNER_TEMP") or None,
1936
+ )
1937
+ with os.fdopen(handle, "w") as out:
1938
+ json.dump(change, out)
1939
+ return path
1940
+
1941
+
1820
1942
  def execute():
1821
1943
  if os.environ.get("GITHUB_RUN_ATTEMPT") != "1":
1822
1944
  raise ValueError("Submit a new replay instead of rerunning an Actions job")
@@ -1825,6 +1947,11 @@ def execute():
1825
1947
  if git(root, "rev-parse", "HEAD") != commit:
1826
1948
  raise ValueError("Runner checkout does not match the dispatched commit")
1827
1949
  request = runner_request(root, commit)
1950
+ base = (
1951
+ check_out_snapshot_base(root, commit, request.get("branch"))
1952
+ if os.environ.get("GITHUB_REF_NAME", "").startswith(PREFIX)
1953
+ else None
1954
+ )
1828
1955
  directory = within(root, request["cwd"])
1829
1956
  check_secrets(root)
1830
1957
  if os.environ.get(API_KEY_ENV, "").strip():
@@ -1837,10 +1964,19 @@ def execute():
1837
1964
  workflow_environment_conflicts(root, environment)
1838
1965
  print(f"Setting {', '.join(sorted(environment))} from the request", flush=True)
1839
1966
  names = {option_name(value) for value in request["args"]}
1967
+ explicit_change = bool(names & set(CODE_CHANGE_FLAGS))
1968
+ change = (
1969
+ None
1970
+ if explicit_change or base is None
1971
+ else snapshot_code_change(root, base, commit, replay_name(request["args"]))
1972
+ )
1973
+ run_environment = dict(environment)
1974
+ if change:
1975
+ run_environment[CODE_CHANGE_PATH_ENV] = write_code_change(change)
1840
1976
  args = [
1841
1977
  *replay_command(),
1842
1978
  *request["args"],
1843
- *([] if names & set(CODE_CHANGE_FLAGS) else ["--no-code-change"]),
1979
+ *([] if explicit_change or change else ["--no-code-change"]),
1844
1980
  ]
1845
1981
  if dry_run(request["args"]):
1846
1982
  run_check_command(directory, environment)
@@ -1854,7 +1990,7 @@ def execute():
1854
1990
  request.get("timeoutMinutes"),
1855
1991
  experiment,
1856
1992
  summarize=request.get("fullOutput") is not True,
1857
- environment=environment,
1993
+ environment=run_environment,
1858
1994
  )
1859
1995
  finally:
1860
1996
  print(OUTPUT_END, flush=True)
@@ -2497,7 +2633,10 @@ def workflow_document(steps, run, directory, env, *, runs_on, environment):
2497
2633
  if environment:
2498
2634
  job["environment"] = environment
2499
2635
  job["steps"] = [
2500
- {"uses": ACTIONS["checkout"], "with": {"persist-credentials": False}},
2636
+ {
2637
+ "uses": ACTIONS["checkout"],
2638
+ "with": {"persist-credentials": False, "fetch-depth": CHECKOUT_DEPTH},
2639
+ },
2501
2640
  *steps,
2502
2641
  {
2503
2642
  **({} if directory == "." else {"working-directory": directory}),
@@ -2608,6 +2747,57 @@ def add_key_env(lines):
2608
2747
  return True
2609
2748
 
2610
2749
 
2750
+ def raise_checkout_depth(lines, start, end):
2751
+ for at in range(start, end):
2752
+ if not re.match(r'[ \t]*"?fetch-depth"?[ \t]*:', lines[at]):
2753
+ continue
2754
+ depth = re.match(
2755
+ r'([ \t]*"?fetch-depth"?[ \t]*:[ \t]*)["\']?(\d+)["\']?(.*)',
2756
+ lines[at],
2757
+ )
2758
+ if depth is None or int(depth[2]) == 0 or int(depth[2]) >= CHECKOUT_DEPTH:
2759
+ return False
2760
+ lines[at] = f"{depth[1]}{CHECKOUT_DEPTH}{depth[3]}"
2761
+ return True
2762
+ return None
2763
+
2764
+
2765
+ def add_checkout_depth(lines):
2766
+ for index, line in enumerate(lines):
2767
+ match = re.match(
2768
+ r'([ \t]*)(- +)?"?uses"?[ \t]*:[ \t]*"?actions/checkout@',
2769
+ without_yaml_comment(line),
2770
+ )
2771
+ if not match:
2772
+ continue
2773
+ key = len(match[1]) + len(match[2] or "")
2774
+ start = index
2775
+ while start > 0 and not re.match(r"[ \t]*- ", lines[start]):
2776
+ start -= 1
2777
+ end = block_end(lines, start, key - 1)
2778
+ raised = raise_checkout_depth(lines, start, end)
2779
+ if raised is not None:
2780
+ return "updated" if raised else "unchanged"
2781
+ entry = f"fetch-depth: {CHECKOUT_DEPTH}"
2782
+ for at in range(start, end):
2783
+ with_key = re.fullmatch(
2784
+ r"[ \t]*(- +)?with[ \t]*:(.*)", without_yaml_comment(lines[at])
2785
+ )
2786
+ if not with_key:
2787
+ continue
2788
+ if len(lines[at]) - len(lines[at].lstrip(" \t-")) != key:
2789
+ continue
2790
+ if with_key[2].strip():
2791
+ return "by hand"
2792
+ entries = block_end(lines, at, key)
2793
+ child = indentation(lines[at + 1]) if entries > at + 1 else key + 2
2794
+ lines.insert(entries, " " * child + entry)
2795
+ return "updated"
2796
+ lines[end:end] = [" " * key + "with:", " " * (key + 2) + entry]
2797
+ return "updated"
2798
+ return "unchanged"
2799
+
2800
+
2611
2801
  def update_workflow(root, path):
2612
2802
  target = within(root, path)
2613
2803
  text = target.read_text()
@@ -2633,6 +2823,15 @@ def update_workflow(root, path):
2633
2823
  by_hand.append(
2634
2824
  f'{API_KEY_ENV}: "{KEY_SECRET_REFERENCE}" in the replay step\'s env'
2635
2825
  )
2826
+ depth = add_checkout_depth(lines)
2827
+ if depth == "updated":
2828
+ updated.append(
2829
+ "Checked out the commit each snapshot is taken on, so experiments record the code change"
2830
+ )
2831
+ elif depth == "by hand":
2832
+ by_hand.append(
2833
+ f"fetch-depth: {CHECKOUT_DEPTH} in the actions/checkout step's with"
2834
+ )
2636
2835
  if updated:
2637
2836
  target.write_text("\n".join(lines))
2638
2837
  steps = []
@@ -321,11 +321,11 @@ module Bitfab
321
321
  get("/api/sdk/externalSpans/#{span_id}#{query}", timeout: 30)
322
322
  end
323
323
 
324
- def get_trace_span(trace_id, id: nil, name: nil, occurrence: "last")
324
+ def get_trace_span(trace_id, id: nil, name: nil, type: nil, occurrence: "last")
325
325
  query = if id
326
326
  {id:}
327
327
  else
328
- {name:, occurrence: occurrence.to_s}
328
+ {name:, type: type&.to_s, occurrence: occurrence.to_s}.compact
329
329
  end
330
330
  encoded_trace_id = URI.encode_www_form_component(trace_id)
331
331
  get(
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ class Spans
5
+ def initialize(http_client)
6
+ @http_client = http_client
7
+ end
8
+
9
+ def list(trace_id, span_type: nil, span_name: nil, fields: nil, cursor: nil, limit: nil)
10
+ payload = {"traceId" => trace_id}
11
+ payload["spanType"] = span_type unless span_type.nil?
12
+ payload["spanName"] = span_name unless span_name.nil?
13
+ payload["fields"] = fields unless fields.nil?
14
+ payload["cursor"] = cursor unless cursor.nil?
15
+ payload["limit"] = limit unless limit.nil?
16
+ @http_client.request("/api/sdk/spans/list", payload)
17
+ end
18
+ end
19
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.64.16"
4
+ VERSION = "0.64.18"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "bitfab/datasets"
18
18
  require_relative "bitfab/assertion_categories"
19
19
  require_relative "bitfab/organization_members"
20
20
  require_relative "bitfab/traces"
21
+ require_relative "bitfab/spans"
21
22
  require_relative "bitfab/assertions"
22
23
  require_relative "bitfab/scenarios"
23
24
  require_relative "bitfab/labels"
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.64.16
4
+ version: 0.64.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -197,6 +197,7 @@ files:
197
197
  - lib/bitfab/simulation_plan.rb
198
198
  - lib/bitfab/span_context.rb
199
199
  - lib/bitfab/span_origin.rb
200
+ - lib/bitfab/spans.rb
200
201
  - lib/bitfab/subtree.rb
201
202
  - lib/bitfab/thread_propagation.rb
202
203
  - lib/bitfab/timestamp.rb