bitfab 0.62.2 → 0.62.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: e9c9645b803f604245b2f2f2bbd14117590321d81234a8f423c1cd28a3e44a87
4
- data.tar.gz: a2171ba8262decea1b34c57bcb67cb4d678aab2b10fa42f9f3ba7ca889c2a5b0
3
+ metadata.gz: 17cb4215f2940d7b31473b2de0b4e5c1a0f87cc9eaed67050cb78f866ea1a24d
4
+ data.tar.gz: cab44aa11d1bb5f8a30cbfc38f2507dfc53e230821d0851279ace1ed24a19dff
5
5
  SHA512:
6
- metadata.gz: de9ac521536899df9364387a8ad984328b952463bf79a440156156d8478aaf58331d9b4feec5f08b56f9bda8518eb6833a654301a3b9d9600dfd7fd6d20ffb49
7
- data.tar.gz: 928e7013f798a18e730225d97e13ea2a6752d5e76c0a880b5df860fdfa7b016d8f44382026b926b0759c656c10b634d28abba5c4d8bc448aa3f3b85a26d539a4
6
+ metadata.gz: 7f2c5073f314c9afe5b9dccb6c101476803a00602467089d618fe0131be631c97f2768bd240ae31728875953605f508517bfcb041339b43795dacba6bd50f695
7
+ data.tar.gz: cb1933586e1b8aeaf0233b7c163abb10cbe6805b156d26489c596cbbdba972984614ac5245aab38d0e35ad0333ae09fd7edc27f42b1ae9d826fef05aaa44c09b
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
40
+ attr_reader :datasets, :assertion_categories, :organization_members, :traces, :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)
@@ -59,6 +59,7 @@ module Bitfab
59
59
  @traces = Traces.new(@http_client)
60
60
  @labels = Labels.new(@http_client)
61
61
  @graders = Graders.new(@http_client)
62
+ @experiments = Experiments.new(@http_client)
62
63
  # Mock overrides registered via register_mock_override, applied to every
63
64
  # replay on this client (after any per-call mock_override). Instance
64
65
  # state, no global; clear_mock_overrides resets it.
@@ -197,7 +198,7 @@ module Bitfab
197
198
  # @return [Hash] with :items, :experiment_id, :experiment_url, and the
198
199
  # deprecated aliases :test_run_id and :test_run_url carrying the same values
199
200
  def replay(receiver, method_name = nil, trace_function_key:, limit: nil, trace_ids: nil, concurrency: nil, max_concurrency: ReplayConcurrency::UNSET,
200
- name: nil, notes: nil, code_change_description: Replay::CODE_CHANGE_UNSET, code_change_files: Replay::CODE_CHANGE_UNSET, experiment_group_id: nil, dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, judge_assertions: false, dry_run: false,
201
+ name: nil, notes: nil, metadata: nil, code_change_description: Replay::CODE_CHANGE_UNSET, code_change_files: Replay::CODE_CHANGE_UNSET, experiment_group_id: nil, dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, judge_assertions: false, dry_run: false,
201
202
  adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil,
202
203
  on_experiment_start: nil, experimental_selective_replay: nil)
203
204
  Replay.run(
@@ -209,6 +210,7 @@ module Bitfab
209
210
  trace_ids:,
210
211
  name:,
211
212
  notes:,
213
+ metadata:,
212
214
  concurrency:,
213
215
  max_concurrency:,
214
216
  attempts:,
@@ -0,0 +1,1065 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import base64
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import re
9
+ import shlex
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import uuid
16
+ from pathlib import Path
17
+ from urllib.parse import urlencode
18
+
19
+ CONFIG = ".bitfab/cloud.json"
20
+ PREFIX = "bitfab-replay/"
21
+ DEFAULT_SECRET_PREFIX = "BITFAB_CLOUD_"
22
+ API_VERSION = "2026-03-10"
23
+ ITEM_ERROR_FIELDS = (
24
+ "error",
25
+ "traceError",
26
+ "trace_error",
27
+ "replayError",
28
+ "replay_error",
29
+ )
30
+ UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
31
+ SHA = re.compile(r"^[0-9a-f]{40}$")
32
+ PIPELINE = re.compile(r"^[\w.][\w.-]*$")
33
+ HELP = """GitHub cloud replay (requires git, gh login, and Python 3.10+).
34
+ --cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH]
35
+ [--max-concurrency 1..32] [--cloud-request-id UUID]
36
+ [--cloud-include PATH ...] [--cloud-dry-run] [--cloud-detach]
37
+ --cloud-status UUID | --cloud-watch UUID | --cloud-cancel UUID
38
+ --cloud-cleanup UUID
39
+ --cloud-init --config SPEC | --cloud-secrets --env-file FILE [NAME ...]
40
+ PIPELINE may be any pipeline in the registry, unless .bitfab/cloud.json names one.
41
+ Snapshot tracked working files without changing HEAD, the index, or local files.
42
+ New files require explicit --cloud-include. Credentials and ignored files are refused.
43
+ By default wait for completion and remove the remote snapshot branch. Detached runs
44
+ continue on GitHub; watch/status/cleanup can recover them using the printed UUID.
45
+ """
46
+
47
+
48
+ def command(args, *, cwd=None, env=None, timeout=60, input=None):
49
+ result = subprocess.run(
50
+ args,
51
+ cwd=cwd,
52
+ env=env,
53
+ input=input,
54
+ text=True,
55
+ check=False,
56
+ capture_output=True,
57
+ timeout=timeout,
58
+ )
59
+ if result.returncode:
60
+ # Child errors can contain credential-bearing URLs or application output.
61
+ raise RuntimeError(
62
+ f"{args[0]} {args[1]} failed (exit {result.returncode}); check authentication and permissions"
63
+ )
64
+ return result.stdout.strip()
65
+
66
+
67
+ def git(root, *args, env=None, input=None):
68
+ return command(["git", "-C", str(root), *args], env=env, input=input)
69
+
70
+
71
+ def root_directory():
72
+ return Path(command(["git", "rev-parse", "--show-toplevel"])).resolve()
73
+
74
+
75
+ def relative_path(value):
76
+ if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
77
+ raise ValueError("Expected a repository-relative path")
78
+ if Path(value).is_absolute() or ".." in Path(value).parts or value.startswith("-"):
79
+ raise ValueError("Paths must stay inside the repository")
80
+ return value
81
+
82
+
83
+ def within(root, value):
84
+ path = (root / relative_path(value)).resolve()
85
+ path.relative_to(root)
86
+ return path
87
+
88
+
89
+ def repository(root):
90
+ remote = git(root, "remote", "get-url", "origin")
91
+ match = re.fullmatch(
92
+ r"(?:git@github\.com:|https://github\.com/)([\w.-]+/[\w.-]+?)(?:\.git)?", remote
93
+ )
94
+ if match is None:
95
+ raise ValueError(
96
+ "origin must be a github.com repository without embedded credentials"
97
+ )
98
+ # A pushurl can silently send the snapshot somewhere other than origin's fetch URL.
99
+ push = git(root, "remote", "get-url", "--push", "origin")
100
+ other = re.fullmatch(
101
+ r"(?:git@github\.com:|https://github\.com/)([\w.-]+/[\w.-]+?)(?:\.git)?", push
102
+ )
103
+ if other is None or other[1].lower() != match[1].lower():
104
+ raise ValueError("origin fetch and push repositories must match")
105
+ return match[1]
106
+
107
+
108
+ def configuration(root):
109
+ path = within(root, CONFIG)
110
+ if not path.is_file():
111
+ raise ValueError(
112
+ f"Run bitfab:setup cloud first. Expected {CONFIG} inside this repository."
113
+ )
114
+ config = json.loads(path.read_text())
115
+ validate_config(config)
116
+ return config
117
+
118
+
119
+ ENV_ASSIGNMENT = re.compile(r"[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=")
120
+
121
+ ENV_ESCAPES = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
122
+
123
+
124
+ def read_environment_value(text, assignment_end, name):
125
+ start = assignment_end
126
+ while start < len(text) and text[start] in " \t":
127
+ start += 1
128
+ if start < len(text) and text[start] in ("'", '"'):
129
+ quote = text[start]
130
+ pieces = []
131
+ position = start + 1
132
+ while position < len(text):
133
+ character = text[position]
134
+ if character == "\\" and quote == '"' and position + 1 < len(text):
135
+ following = text[position + 1]
136
+ pieces.append(ENV_ESCAPES.get(following, character + following))
137
+ position += 2
138
+ continue
139
+ if character == quote:
140
+ return "".join(pieces), position + 1
141
+ pieces.append(character)
142
+ position += 1
143
+ raise ValueError(f"{name} opens a quote that the file never closes")
144
+ end = text.find("\n", assignment_end)
145
+ end = len(text) if end == -1 else end
146
+ raw = text[assignment_end:end]
147
+ comment = re.search(r"\s#", raw)
148
+ return (raw if comment is None else raw[: comment.start()]).strip(), end
149
+
150
+
151
+ def parse_environment_file(text):
152
+ values = {}
153
+ position = 0
154
+ while position < len(text):
155
+ end = text.find("\n", position)
156
+ end = len(text) if end == -1 else end
157
+ line = text[position:end]
158
+ match = ENV_ASSIGNMENT.match(line)
159
+ if match is None or line.lstrip().startswith("#"):
160
+ position = end + 1
161
+ continue
162
+ value, consumed = read_environment_value(text, position + match.end(), match[1])
163
+ values[match[1]] = value
164
+ newline = text.find("\n", consumed)
165
+ position = len(text) if newline == -1 else newline + 1
166
+ return values
167
+
168
+
169
+ def configure_secrets(argv):
170
+ parser = argparse.ArgumentParser(
171
+ prog="bitfab-replay --cloud-secrets",
172
+ description="Copy named values from local environment files into GitHub Actions secrets. Values are piped to gh on standard input and never printed, logged, or passed as arguments.",
173
+ )
174
+ parser.add_argument(
175
+ "names",
176
+ nargs="*",
177
+ help="Environment variable names to copy; defaults to the secrets recorded by setup",
178
+ )
179
+ parser.add_argument(
180
+ "--env-file",
181
+ action="append",
182
+ required=True,
183
+ help="Repository-relative environment file to read; repeatable, earliest definition wins",
184
+ )
185
+ parser.add_argument(
186
+ "--environment",
187
+ help="Set GitHub Environment secrets instead of repository ones",
188
+ )
189
+ parser.add_argument(
190
+ "--dry-run",
191
+ action="store_true",
192
+ help="Report which names were found without writing anything to GitHub",
193
+ )
194
+ args = parser.parse_args(argv)
195
+ root = root_directory()
196
+ config = configuration(root)
197
+ prefix = config.get("secretPrefix", "")
198
+ names = args.names or config.get("secrets") or ["BITFAB_API_KEY"]
199
+ if not all(re.fullmatch(r"[A-Z_][A-Z0-9_]*", name) for name in names):
200
+ raise ValueError("Secret names must be uppercase environment variable names")
201
+ values = {}
202
+ for path in args.env_file:
203
+ for key, value in parse_environment_file(
204
+ within(root, path).read_text()
205
+ ).items():
206
+ values.setdefault(key, value)
207
+ repo = repository(root)
208
+ assigned = []
209
+ missing = []
210
+ empty = []
211
+ for name in names:
212
+ value = values.get(name)
213
+ if value is None:
214
+ missing.append(name)
215
+ continue
216
+ if not value:
217
+ empty.append(name)
218
+ continue
219
+ target = prefix + name
220
+ if not args.dry_run:
221
+ command(
222
+ [
223
+ "gh",
224
+ "secret",
225
+ "set",
226
+ target,
227
+ "--repo",
228
+ repo,
229
+ *(["--env", args.environment] if args.environment else []),
230
+ ],
231
+ input=value,
232
+ )
233
+ assigned.append(target)
234
+ return {
235
+ "repository": repo,
236
+ "environment": args.environment,
237
+ "dryRun": args.dry_run,
238
+ "set": assigned,
239
+ "missing": missing,
240
+ "empty": empty,
241
+ "next": "Create the missing secrets by hand; empty local values were skipped because an empty secret overrides a working default with nothing; a wrong value only surfaces when the first real replay runs",
242
+ }
243
+
244
+
245
+ def validate_config(config):
246
+ if config.get("version") != 1 or config.get("provider") != "github":
247
+ raise ValueError("Run bitfab:setup cloud to configure the GitHub provider")
248
+ if not re.fullmatch(r"[\w-]+\.ya?ml", config.get("workflow", "")):
249
+ raise ValueError("Invalid workflow filename")
250
+ relative_path(config["workingDirectory"])
251
+ if config.get("registry") is not None:
252
+ relative_path(config["registry"])
253
+ pipeline = config.get("pipeline")
254
+ if pipeline is not None and not (
255
+ isinstance(pipeline, str) and PIPELINE.fullmatch(pipeline)
256
+ ):
257
+ raise ValueError("Invalid pipeline name")
258
+ args = config.get("command")
259
+ if (
260
+ not isinstance(args, list)
261
+ or not args
262
+ or not all(isinstance(v, str) and v and "\x00" not in v for v in args)
263
+ ):
264
+ raise ValueError("command must be a nonempty JSON argument array")
265
+ if any(v.startswith("--cloud") for v in args):
266
+ raise ValueError(
267
+ "The runner command must execute locally, not recursively dispatch"
268
+ )
269
+ if config.get("pushTriggersReviewed") is not True:
270
+ raise ValueError(
271
+ "Setup must review push-triggered CI/deployments and set pushTriggersReviewed=true"
272
+ )
273
+ prefix = config.get("secretPrefix", "")
274
+ if not isinstance(prefix, str) or (
275
+ prefix and not re.fullmatch(r"[A-Z][A-Z0-9_]*_", prefix)
276
+ ):
277
+ raise ValueError(
278
+ "secretPrefix must be uppercase and end with an underscore, such as BITFAB_CLOUD_"
279
+ )
280
+ names = config.get("secrets", [])
281
+ if not isinstance(names, list) or not all(
282
+ isinstance(name, str) and re.fullmatch(r"[A-Z_][A-Z0-9_]*", name)
283
+ for name in names
284
+ ):
285
+ raise ValueError("secrets must be uppercase environment variable names")
286
+
287
+
288
+ def check_pipeline(config, pipeline):
289
+ if not isinstance(pipeline, str) or not PIPELINE.fullmatch(pipeline):
290
+ raise ValueError("Invalid pipeline name")
291
+ allowed = config.get("pipeline")
292
+ if allowed is not None and pipeline != allowed:
293
+ raise ValueError("Pipeline does not match .bitfab/cloud.json")
294
+
295
+
296
+ def gh(repo, *args, payload=None):
297
+ return command(
298
+ [
299
+ "gh",
300
+ "api",
301
+ "--hostname",
302
+ "github.com",
303
+ "-H",
304
+ f"X-GitHub-Api-Version: {API_VERSION}",
305
+ "--jq",
306
+ "tojson",
307
+ *args,
308
+ *(["--input", "-"] if payload is not None else []),
309
+ ],
310
+ input=json.dumps(payload) if payload is not None else None,
311
+ )
312
+
313
+
314
+ def api(repo, suffix, *, method="GET", payload=None):
315
+ result = gh(
316
+ repo,
317
+ f"repos/{repo}" + (f"/{suffix}" if suffix else ""),
318
+ "--method",
319
+ method,
320
+ payload=payload,
321
+ )
322
+ return json.loads(result) if result else None
323
+
324
+
325
+ def parse(argv):
326
+ flags = [value.split("=", 1)[0] for value in argv if value.startswith("--")]
327
+ if any(flags.count(flag) > 1 for flag in flags if flag != "--cloud-include"):
328
+ raise ValueError("Duplicate cloud option")
329
+ parser = argparse.ArgumentParser(
330
+ description=HELP,
331
+ formatter_class=argparse.RawDescriptionHelpFormatter,
332
+ allow_abbrev=False,
333
+ )
334
+ parser.add_argument("pipeline", nargs="?")
335
+ parser.add_argument("--registry")
336
+ parser.add_argument("--cloud", action="store_true")
337
+ for name in ("status", "watch", "cancel", "cleanup"):
338
+ parser.add_argument(f"--cloud-{name}")
339
+ parser.add_argument("--trace-ids")
340
+ parser.add_argument("--max-concurrency", type=int, default=1)
341
+ parser.add_argument("--cloud-request-id")
342
+ parser.add_argument("--cloud-include", action="append", default=[])
343
+ parser.add_argument("--cloud-dry-run", action="store_true")
344
+ parser.add_argument("--cloud-detach", action="store_true")
345
+ args = parser.parse_args(argv)
346
+ operations = [
347
+ name
348
+ for name in ("status", "watch", "cancel", "cleanup")
349
+ if getattr(args, "cloud_" + name)
350
+ ]
351
+ if operations:
352
+ if len(operations) != 1 or len(argv) != 2:
353
+ raise ValueError("Cloud lifecycle commands take only their execution UUID")
354
+ operation = operations[0]
355
+ execution_id = getattr(args, "cloud_" + operation)
356
+ else:
357
+ if not args.cloud or not args.pipeline or not args.trace_ids:
358
+ raise ValueError(HELP)
359
+ operation = "submit"
360
+ execution_id = args.cloud_request_id or str(uuid.uuid4())
361
+ traces = args.trace_ids.split(",")
362
+ if not 1 <= len(traces) <= 100 or not all(UUID.fullmatch(t) for t in traces):
363
+ raise ValueError("Supply 1..100 explicit trace UUIDs")
364
+ if not 1 <= args.max_concurrency <= 32:
365
+ raise ValueError("--max-concurrency must be 1..32")
366
+ if not UUID.fullmatch(execution_id):
367
+ raise ValueError("Execution ID must be a UUID")
368
+ return args, operation, execution_id
369
+
370
+
371
+ def sensitive(path):
372
+ parts = Path(path).parts
373
+ name = Path(path).name.lower()
374
+ return (
375
+ any(part in (".git", ".ssh", ".aws") for part in parts)
376
+ or name == ".env"
377
+ or (
378
+ name.startswith(".env.")
379
+ and name not in (".env.example", ".env.sample", ".env.template")
380
+ )
381
+ or name in (".npmrc", ".pypirc", ".netrc", "id_rsa", "id_ed25519")
382
+ or name.endswith((".pem", ".key", ".p12", ".pfx", ".local.json"))
383
+ or name
384
+ in (
385
+ "credentials",
386
+ "credentials.json",
387
+ "secrets.json",
388
+ "secrets.yml",
389
+ "secrets.yaml",
390
+ )
391
+ )
392
+
393
+
394
+ def snapshot(root, config, args, execution_id):
395
+ if git(root, "ls-files", "-u"):
396
+ raise ValueError("Resolve merge conflicts before snapshotting")
397
+ head = git(root, "rev-parse", "HEAD")
398
+ includes = []
399
+ for value in args.cloud_include:
400
+ path = within(root, value)
401
+ if not path.is_file() or path.is_symlink() or (root / value).is_symlink():
402
+ raise ValueError("--cloud-include requires individual regular files")
403
+ # Never force-add ignored content, even when explicitly requested.
404
+ ignored = subprocess.run(
405
+ ["git", "-C", str(root), "check-ignore", "--quiet", "--", value],
406
+ check=False,
407
+ timeout=15,
408
+ ).returncode
409
+ if ignored not in (0, 1):
410
+ raise ValueError("Unable to determine whether the included file is ignored")
411
+ if ignored == 0 or sensitive(value):
412
+ raise ValueError(f"Refusing ignored or credential-like file: {value}")
413
+ includes.append(value)
414
+ with tempfile.TemporaryDirectory(prefix="bitfab-cloud-index-") as directory:
415
+ env = {**os.environ, "GIT_INDEX_FILE": str(Path(directory) / "index")}
416
+ git(root, "read-tree", head, env=env)
417
+ git(root, "add", "-u", "--", ".", env=env)
418
+ if includes:
419
+ git(root, "--literal-pathspecs", "add", "--", *includes, env=env)
420
+ entries = git(root, "ls-files", "--stage", "-z", env=env).split("\x00")
421
+ for entry in filter(None, entries):
422
+ metadata, path = entry.split("\t", 1)
423
+ if metadata.startswith("160000"):
424
+ raise ValueError(
425
+ "Submodules/gitlinks require explicit setup support before cloud snapshots"
426
+ )
427
+ if sensitive(path):
428
+ raise ValueError(
429
+ f"Refusing credential-like tracked file in snapshot: {path}"
430
+ )
431
+ required = [CONFIG, f".github/workflows/{config['workflow']}"]
432
+ if config.get("registry") is not None:
433
+ required.append(config["registry"])
434
+ tracked = set(
435
+ git(
436
+ root, "--literal-pathspecs", "ls-files", "-z", "--", *required, env=env
437
+ ).split("\x00")
438
+ )
439
+ untracked = [path for path in required if path not in tracked]
440
+ if untracked:
441
+ raise ValueError(
442
+ "Not in the snapshot: "
443
+ + ", ".join(untracked)
444
+ + ". Commit these files, or pass --cloud-include for each one"
445
+ )
446
+ tree = git(root, "write-tree", env=env)
447
+ files = git(root, "diff", "--name-only", head, tree).splitlines()
448
+ if args.cloud_dry_run:
449
+ new_files = set(
450
+ git(root, "ls-files", "--others", "--exclude-standard").splitlines()
451
+ )
452
+ new_files.update(
453
+ git(
454
+ root, "diff", "--cached", "--name-only", "--diff-filter=A"
455
+ ).splitlines()
456
+ )
457
+ return {
458
+ "baseSha": head,
459
+ "files": files,
460
+ "includedFiles": includes,
461
+ "omittedNewFiles": sorted(new_files - set(includes)),
462
+ }
463
+ # commit-tree adds an unreachable object, never touching HEAD or the real index.
464
+ sha = git(
465
+ root,
466
+ "commit-tree",
467
+ tree,
468
+ "-p",
469
+ head,
470
+ input=f"Bitfab replay {execution_id}\n",
471
+ )
472
+ return {"baseSha": head, "sha": sha, "files": files}
473
+
474
+
475
+ def state_directory(root):
476
+ path = Path(git(root, "rev-parse", "--absolute-git-dir")) / "bitfab-cloud"
477
+ path.mkdir(mode=0o700, exist_ok=True)
478
+ return path
479
+
480
+
481
+ def save(path, record):
482
+ with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
483
+ json.dump(record, file)
484
+ file.write("\n")
485
+ name = file.name
486
+ os.replace(name, path)
487
+
488
+
489
+ @contextlib.contextmanager
490
+ def execution_lock(directory, execution_id):
491
+ import fcntl
492
+
493
+ with (directory / f"{execution_id}.lock").open("a") as lock:
494
+ try:
495
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
496
+ except BlockingIOError as error:
497
+ raise ValueError(
498
+ "Another command is operating on this cloud execution"
499
+ ) from error
500
+ yield
501
+
502
+
503
+ def find_run(record):
504
+ query = urlencode(
505
+ {"event": "workflow_dispatch", "branch": record["branch"], "per_page": 100}
506
+ )
507
+ runs = api(
508
+ record["repository"], f"actions/workflows/{record['workflow']}/runs?{query}"
509
+ )
510
+ matches = [
511
+ run
512
+ for run in runs["workflow_runs"]
513
+ if run["head_sha"] == record["sha"]
514
+ and run.get("display_title") == f"Bitfab replay {record['id']}"
515
+ ]
516
+ if len(matches) > 1:
517
+ raise ValueError(
518
+ "Multiple matching GitHub runs; inspect Actions before cleanup"
519
+ )
520
+ return matches[0] if matches else None
521
+
522
+
523
+ def status(record, *, fetch_result=True):
524
+ if record["state"] == "prepared":
525
+ return record
526
+ run = (
527
+ api(record["repository"], f"actions/runs/{record['runId']}")
528
+ if record.get("runId")
529
+ else find_run(record)
530
+ )
531
+ if run is None:
532
+ record["state"] = "dispatch_unknown"
533
+ return record
534
+ if run["head_sha"] != record["sha"] or run["head_branch"] != record["branch"]:
535
+ raise ValueError("GitHub run does not match the recorded execution")
536
+ if run.get("display_title") != f"Bitfab replay {record['id']}":
537
+ if run["status"] == "completed":
538
+ raise ValueError("GitHub run does not match the recorded execution")
539
+ record.update(runId=run["id"], url=run["html_url"], state=run["status"])
540
+ return record
541
+ record.update(
542
+ runId=run["id"],
543
+ url=run["html_url"],
544
+ state=run["status"],
545
+ conclusion=run.get("conclusion"),
546
+ )
547
+ if (
548
+ fetch_result
549
+ and record["state"] == "completed"
550
+ and record["conclusion"] == "success"
551
+ and not record.get("testRunId")
552
+ ):
553
+ with tempfile.TemporaryDirectory(prefix="bitfab-cloud-result-") as directory:
554
+ command(
555
+ [
556
+ "gh",
557
+ "run",
558
+ "download",
559
+ str(record["runId"]),
560
+ "--repo",
561
+ record["repository"],
562
+ "--name",
563
+ "bitfab-replay-" + record["id"],
564
+ "--dir",
565
+ directory,
566
+ ]
567
+ )
568
+ artifact = Path(directory) / "bitfab-cloud-result.json"
569
+ if artifact.is_symlink() or artifact.stat().st_size > 4096:
570
+ raise ValueError("Invalid replay result artifact")
571
+ result = json.loads(artifact.read_text())
572
+ if (
573
+ result.get("executionId") != record["id"]
574
+ or result.get("commitSha") != record["sha"]
575
+ or not UUID.fullmatch(result.get("testRunId", ""))
576
+ ):
577
+ raise ValueError("Replay result artifact does not match this execution")
578
+ counts = replay_counts(result)
579
+ record["testRunId"] = result["testRunId"]
580
+ if counts is not None:
581
+ record["replayed"], record["errored"] = counts
582
+ return record
583
+
584
+
585
+ def cleanup(root, record):
586
+ if record.get("cleaned"):
587
+ return
588
+ if record.get("state") not in ("completed", "prepared"):
589
+ raise ValueError(
590
+ "Cleanup requires a confirmed completed GitHub run; cancel and wait first"
591
+ )
592
+ if record["branch"] != PREFIX + record["id"] or not SHA.fullmatch(record["sha"]):
593
+ raise ValueError("Refusing cleanup of an unowned branch")
594
+ ref = "refs/heads/" + record["branch"]
595
+ remote = git(root, "ls-remote", "--heads", "origin", ref)
596
+ if remote:
597
+ if remote.split()[0] != record["sha"]:
598
+ raise ValueError(
599
+ "Snapshot branch moved; refusing to delete someone else's changes"
600
+ )
601
+ git(
602
+ root,
603
+ "push",
604
+ f"--force-with-lease={ref}:{record['sha']}",
605
+ "origin",
606
+ f":{ref}",
607
+ )
608
+ record["cleaned"] = True
609
+
610
+
611
+ def preflight(repo, config):
612
+ info = api(repo, "")
613
+ workflow = api(repo, f"actions/workflows/{config['workflow']}")
614
+ if workflow.get("state") != "active":
615
+ raise ValueError("Replay workflow must be active")
616
+ api(
617
+ repo,
618
+ f"contents/.github/workflows/{config['workflow']}?"
619
+ + urlencode({"ref": info["default_branch"]}),
620
+ )
621
+
622
+
623
+ def run_cli(argv):
624
+ args, operation, execution_id = parse(argv)
625
+ if os.name != "posix" or sys.version_info < (3, 10):
626
+ raise ValueError("Cloud replay currently requires macOS/Linux and Python 3.10+")
627
+ root = root_directory()
628
+ config = configuration(root) if operation == "submit" else None
629
+ repo = repository(root)
630
+ directory = state_directory(root)
631
+ path = directory / f"{execution_id}.json"
632
+ with execution_lock(directory, execution_id):
633
+ if operation == "submit":
634
+ within(root, config["workingDirectory"])
635
+ check_pipeline(config, args.pipeline)
636
+ if args.registry is not None:
637
+ registry = Path(args.registry).resolve().relative_to(root).as_posix()
638
+ if registry != config.get("registry"):
639
+ raise ValueError("Registry does not match .bitfab/cloud.json")
640
+ request = {
641
+ "id": execution_id,
642
+ "pipeline": args.pipeline,
643
+ "traceIds": args.trace_ids.split(","),
644
+ "maxConcurrency": args.max_concurrency,
645
+ }
646
+ if args.cloud_dry_run:
647
+ return {
648
+ "dryRun": True,
649
+ "repository": repo,
650
+ **snapshot(root, config, args, execution_id),
651
+ }
652
+ if path.exists():
653
+ record = json.loads(path.read_text())
654
+ if (
655
+ record["request"] != request
656
+ or record["repository"].lower() != repo.lower()
657
+ ):
658
+ raise ValueError(
659
+ "Execution ID already belongs to a different request"
660
+ )
661
+ # Never redispatch an uncertain request, even after a lost response.
662
+ status(record)
663
+ if record["state"] == "prepared":
664
+ raise ValueError(
665
+ "Submission stopped before dispatch. Use --cloud-cleanup, then submit a new execution UUID"
666
+ )
667
+ else:
668
+ command(["gh", "auth", "status", "--hostname", "github.com"])
669
+ preflight(repo, config)
670
+ source = snapshot(root, config, args, execution_id)
671
+ record = {
672
+ "id": execution_id,
673
+ "repository": repo,
674
+ "workflow": config["workflow"],
675
+ "branch": PREFIX + execution_id,
676
+ "request": request,
677
+ "state": "prepared",
678
+ **source,
679
+ }
680
+ save(path, record)
681
+ print(
682
+ f"Cloud execution {execution_id}. Recover with --cloud-status {execution_id}",
683
+ file=sys.stderr,
684
+ flush=True,
685
+ )
686
+ ref = "refs/heads/" + record["branch"]
687
+ # Empty lease asserts that the temporary remote branch does not exist.
688
+ git(
689
+ root,
690
+ "push",
691
+ f"--force-with-lease={ref}:",
692
+ "origin",
693
+ f"{record['sha']}:{ref}",
694
+ )
695
+ record["state"] = "dispatch_unknown"
696
+ save(path, record)
697
+ encoded = base64.b64encode(json.dumps(request).encode()).decode()
698
+ response = api(
699
+ repo,
700
+ f"actions/workflows/{config['workflow']}/dispatches",
701
+ method="POST",
702
+ payload={
703
+ "ref": record["branch"],
704
+ "inputs": {"execution_id": execution_id, "request": encoded},
705
+ },
706
+ )
707
+ if isinstance(response, dict) and response.get("workflow_run_id"):
708
+ record["runId"] = response["workflow_run_id"]
709
+ record["url"] = response.get("html_url")
710
+ record["state"] = "queued"
711
+ else:
712
+ if not path.exists():
713
+ raise ValueError(
714
+ "No local execution record for this UUID in this worktree"
715
+ )
716
+ record = json.loads(path.read_text())
717
+ if record["repository"].lower() != repo.lower():
718
+ raise ValueError("origin no longer matches the execution repository")
719
+ status(record, fetch_result=operation != "cleanup")
720
+ if operation == "cancel" and record["state"] != "completed":
721
+ if not record.get("runId"):
722
+ raise ValueError(
723
+ "Run not yet found; inspect Actions and retry status before cancelling"
724
+ )
725
+ api(repo, f"actions/runs/{record['runId']}/cancel", method="POST")
726
+ record["state"] = "cancel_requested"
727
+ save(path, record)
728
+ if record["state"] == "prepared" and operation != "cleanup":
729
+ save(path, record)
730
+ return record
731
+ if operation == "watch" or (operation == "submit" and not args.cloud_detach):
732
+ deadline = time.monotonic() + 40 * 60
733
+ while record["state"] != "completed" and time.monotonic() < deadline:
734
+ status(record)
735
+ save(path, record)
736
+ if record["state"] != "completed":
737
+ time.sleep(5)
738
+ if record["state"] != "completed":
739
+ raise ValueError(
740
+ "Watch timed out; the job may still run. Resume with --cloud-watch "
741
+ + execution_id
742
+ )
743
+ if record["state"] == "completed" or (
744
+ operation == "cleanup" and record["state"] == "prepared"
745
+ ):
746
+ cleanup(root, record)
747
+ elif operation == "cleanup":
748
+ raise ValueError("Execution is not completed; no branch was deleted")
749
+ save(path, record)
750
+ return record
751
+
752
+
753
+ def execute():
754
+ if os.environ.get("GITHUB_RUN_ATTEMPT") != "1":
755
+ raise ValueError("Submit a new replay instead of rerunning an Actions job")
756
+ request = json.loads(
757
+ base64.b64decode(os.environ["BITFAB_CLOUD_REQUEST"], validate=True)
758
+ )
759
+ root = root_directory()
760
+ if git(root, "rev-parse", "HEAD") != os.environ["GITHUB_SHA"]:
761
+ raise ValueError("Runner checkout does not match the dispatched snapshot SHA")
762
+ config = configuration(root)
763
+ check_pipeline(config, request["pipeline"])
764
+ if request["id"] != os.environ["BITFAB_EXECUTION_ID"]:
765
+ raise ValueError("Replay request does not match the configured execution")
766
+ parse(
767
+ [
768
+ "--cloud",
769
+ request["pipeline"],
770
+ "--trace-ids",
771
+ ",".join(request["traceIds"]),
772
+ "--max-concurrency",
773
+ str(request["maxConcurrency"]),
774
+ "--cloud-request-id",
775
+ request["id"],
776
+ ]
777
+ )
778
+ if not os.environ.get("BITFAB_API_KEY"):
779
+ raise ValueError("Configure the BITFAB_API_KEY GitHub secret")
780
+ args = [
781
+ *config["command"],
782
+ request["pipeline"],
783
+ "--trace-ids",
784
+ ",".join(request["traceIds"]),
785
+ "--max-concurrency",
786
+ str(request["maxConcurrency"]),
787
+ "--no-code-change",
788
+ ]
789
+ with tempfile.TemporaryFile() as output:
790
+ with subprocess.Popen(
791
+ args,
792
+ cwd=within(root, config["workingDirectory"]),
793
+ stdout=output,
794
+ stdin=subprocess.DEVNULL,
795
+ start_new_session=True,
796
+ ) as child:
797
+ try:
798
+ deadline = time.monotonic() + 25 * 60
799
+ while child.poll() is None:
800
+ if os.fstat(output.fileno()).st_size > 16 * 1024 * 1024:
801
+ raise ValueError("Replay output exceeded 16 MiB")
802
+ if time.monotonic() >= deadline:
803
+ raise subprocess.TimeoutExpired(args, 25 * 60)
804
+ time.sleep(0.1)
805
+ code = child.returncode
806
+ except BaseException:
807
+ with contextlib.suppress(ProcessLookupError):
808
+ os.killpg(child.pid, signal.SIGTERM)
809
+ with contextlib.suppress(subprocess.TimeoutExpired):
810
+ child.wait(timeout=10)
811
+ with contextlib.suppress(ProcessLookupError):
812
+ os.killpg(child.pid, signal.SIGKILL)
813
+ child.wait()
814
+ raise
815
+ if output.tell() > 16 * 1024 * 1024:
816
+ raise ValueError("Replay output exceeded 16 MiB")
817
+ output.seek(0)
818
+ text = output.read().decode()
819
+ if code:
820
+ raise ValueError(f"Replay command exited {code}; inspect the job logs")
821
+ decoder = json.JSONDecoder()
822
+ result = None
823
+ for offset in [0, *[i + 1 for i, value in enumerate(text) if value == "\n"]]:
824
+ if text.startswith("{", offset):
825
+ try:
826
+ value, end = decoder.raw_decode(text, offset)
827
+ if not text[end:].strip() and isinstance(value, dict):
828
+ result = value
829
+ except json.JSONDecodeError:
830
+ pass
831
+ test_run = (
832
+ None if result is None else result.get("testRunId", result.get("test_run_id"))
833
+ )
834
+ if not isinstance(test_run, str) or not UUID.fullmatch(test_run):
835
+ raise ValueError("Replay did not return a valid persisted test run UUID")
836
+ items = result.get("items")
837
+ if not isinstance(items, list):
838
+ raise ValueError("Replay did not return its replayed items")
839
+ summary = {
840
+ "executionId": request["id"],
841
+ "commitSha": os.environ["GITHUB_SHA"],
842
+ "testRunId": test_run,
843
+ "replayed": len(items),
844
+ "errored": sum(1 for item in items if item_errored(item)),
845
+ }
846
+ (Path(os.environ["RUNNER_TEMP"]) / "bitfab-cloud-result.json").write_text(
847
+ json.dumps(summary) + "\n"
848
+ )
849
+ with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
850
+ file.write(
851
+ f"### Bitfab replay\n\nTest run: `{test_run}`\n\nCommit: `{os.environ['GITHUB_SHA']}`\n\n"
852
+ f"Replayed: {summary['replayed']}, errored: {summary['errored']}\n"
853
+ )
854
+ return summary
855
+
856
+
857
+ def item_errored(item):
858
+ return isinstance(item, dict) and any(
859
+ item.get(field) is not None for field in ITEM_ERROR_FIELDS
860
+ )
861
+
862
+
863
+ def replay_counts(result):
864
+ replayed, errored = result.get("replayed"), result.get("errored")
865
+ if replayed is None and errored is None:
866
+ return None
867
+ if (
868
+ not all(
869
+ isinstance(value, int) and not isinstance(value, bool) and value >= 0
870
+ for value in (replayed, errored)
871
+ )
872
+ or errored > replayed
873
+ ):
874
+ raise ValueError("Replay result artifact has invalid item counts")
875
+ return replayed, errored
876
+
877
+
878
+ def report_errored_items(record):
879
+ replayed, errored = record.get("replayed"), record.get("errored")
880
+ if not errored:
881
+ return 0
882
+ if errored == replayed:
883
+ print(
884
+ f"Cloud replay: every replayed trace errored ({errored} of {replayed}), so nothing ran successfully. Open test run {record.get('testRunId')} to see why.",
885
+ file=sys.stderr,
886
+ )
887
+ return 1
888
+ print(
889
+ f"Cloud replay: {errored} of {replayed} replayed traces errored. Open test run {record.get('testRunId')} to see why.",
890
+ file=sys.stderr,
891
+ )
892
+ return 0
893
+
894
+
895
+ def initialize(argv):
896
+ parser = argparse.ArgumentParser(
897
+ prog="bitfab-replay --cloud-init",
898
+ description="Install direct GitHub cloud replay from a reviewed JSON setup specification. Existing files are never overwritten.",
899
+ )
900
+ parser.add_argument(
901
+ "--config",
902
+ required=True,
903
+ help="JSON file with cloud config, cliCommand, setupSteps, optional pipeline (omit or null to allow every registry pipeline), secrets, variables, environment, services, runsOn",
904
+ )
905
+ args = parser.parse_args(argv)
906
+ spec = json.loads(Path(args.config).read_text())
907
+ config = {
908
+ key: spec[key]
909
+ for key in (
910
+ "version",
911
+ "provider",
912
+ "workflow",
913
+ "workingDirectory",
914
+ "registry",
915
+ "command",
916
+ "pushTriggersReviewed",
917
+ )
918
+ }
919
+ config["pipeline"] = spec.get("pipeline")
920
+ secrets = spec.get("secrets", ["BITFAB_API_KEY"])
921
+ variables = spec.get("variables", [])
922
+ secret_prefix = spec.get("secretPrefix")
923
+ if secret_prefix is None:
924
+ secret_prefix = DEFAULT_SECRET_PREFIX
925
+ config["secrets"] = secrets
926
+ config["secretPrefix"] = secret_prefix
927
+ validate_config(config)
928
+ if set(secrets) & set(variables):
929
+ raise ValueError("Secret names and variable names must not overlap")
930
+ if "BITFAB_API_KEY" not in secrets or not all(
931
+ re.fullmatch(r"[A-Z_][A-Z0-9_]*", name) for name in [*secrets, *variables]
932
+ ):
933
+ raise ValueError(
934
+ "Supply uppercase secret/variable names, including BITFAB_API_KEY; never values"
935
+ )
936
+ cli_command = spec.get("cliCommand")
937
+ if (
938
+ not isinstance(cli_command, list)
939
+ or not cli_command
940
+ or not all(isinstance(v, str) and v and "\x00" not in v for v in cli_command)
941
+ or any(v.startswith("--cloud") for v in cli_command)
942
+ ):
943
+ raise ValueError(
944
+ "cliCommand must be the argument array that starts the SDK's bitfab-replay command from workingDirectory"
945
+ )
946
+ steps = spec.get("setupSteps")
947
+ if (
948
+ not isinstance(steps, list)
949
+ or not steps
950
+ or not all(isinstance(step, dict) for step in steps)
951
+ ):
952
+ raise ValueError("setupSteps must contain reviewed GitHub Actions setup steps")
953
+ env = {key: "${{ secrets." + secret_prefix + key + " }}" for key in secrets}
954
+ env.update({key: "${{ vars." + key + " }}" for key in variables})
955
+ env.update(
956
+ BITFAB_CLOUD_REQUEST="${{ inputs.request }}",
957
+ BITFAB_EXECUTION_ID="${{ inputs.execution_id }}",
958
+ BITFAB_COMMIT_SHA="${{ github.sha }}",
959
+ )
960
+ job = {
961
+ "runs-on": spec.get("runsOn", "ubuntu-24.04"),
962
+ "timeout-minutes": 35,
963
+ "steps": [
964
+ {
965
+ "name": "Check out replay snapshot",
966
+ "uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
967
+ "with": {"ref": "${{ github.sha }}", "persist-credentials": False},
968
+ },
969
+ *steps,
970
+ {
971
+ "name": "Replay",
972
+ "working-directory": config["workingDirectory"],
973
+ "run": shlex.join([*cli_command, "--cloud-execute"]),
974
+ "env": env,
975
+ },
976
+ {
977
+ "name": "Save replay identity",
978
+ "if": "always()",
979
+ "uses": "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
980
+ "with": {
981
+ "name": "bitfab-replay-${{ inputs.execution_id }}",
982
+ "path": "${{ runner.temp }}/bitfab-cloud-result.json",
983
+ "if-no-files-found": "ignore",
984
+ "retention-days": 7,
985
+ },
986
+ },
987
+ ],
988
+ }
989
+ for key in ("environment", "services"):
990
+ if key in spec:
991
+ job[key] = spec[key]
992
+ workflow = {
993
+ "name": "Bitfab cloud replay",
994
+ "run-name": "Bitfab replay ${{ inputs.execution_id }}",
995
+ "on": {
996
+ "workflow_dispatch": {
997
+ "inputs": {
998
+ "execution_id": {"required": True, "type": "string"},
999
+ "request": {"required": True, "type": "string"},
1000
+ }
1001
+ }
1002
+ },
1003
+ "permissions": {"contents": "read"},
1004
+ "jobs": {"replay": job},
1005
+ }
1006
+ root = root_directory()
1007
+ outputs = {
1008
+ CONFIG: json.dumps(config, indent=2) + "\n",
1009
+ f".github/workflows/{config['workflow']}": json.dumps(workflow, indent=2)
1010
+ + "\n",
1011
+ }
1012
+ for name in outputs:
1013
+ target = within(root, name)
1014
+ if target.exists():
1015
+ raise ValueError(
1016
+ f"Refusing to overwrite {name}; review and edit the existing setup"
1017
+ )
1018
+ for name, content in outputs.items():
1019
+ target = within(root, name)
1020
+ target.parent.mkdir(parents=True, exist_ok=True)
1021
+ with target.open("x") as file:
1022
+ file.write(content)
1023
+ return {
1024
+ "files": list(outputs),
1025
+ "requiredSecrets": [secret_prefix + name for name in secrets],
1026
+ "requiredVariables": variables,
1027
+ "next": "Configure secrets securely, review push triggers, merge the workflow to the default branch, then run --cloud-dry-run",
1028
+ }
1029
+
1030
+
1031
+ def main():
1032
+ try:
1033
+ if sys.argv[1:2] == ["--cloud-init"]:
1034
+ result = initialize(sys.argv[2:])
1035
+ elif sys.argv[1:2] == ["--cloud-secrets"]:
1036
+ result = configure_secrets(sys.argv[2:])
1037
+ elif sys.argv[1:] == ["--cloud-execute"]:
1038
+ result = execute()
1039
+ else:
1040
+ result = run_cli(sys.argv[1:])
1041
+ print(json.dumps(result, indent=2))
1042
+ if result.get("state") == "completed" and result.get("conclusion") != "success":
1043
+ return 1
1044
+ if result.get("state") == "completed":
1045
+ return report_errored_items(result)
1046
+ return 0
1047
+ except KeyboardInterrupt:
1048
+ print(
1049
+ "Detached. The GitHub job continues; use the printed execution UUID to resume or cancel.",
1050
+ file=sys.stderr,
1051
+ )
1052
+ return 130
1053
+ except (
1054
+ ValueError,
1055
+ RuntimeError,
1056
+ OSError,
1057
+ subprocess.SubprocessError,
1058
+ KeyError,
1059
+ ) as error:
1060
+ print(f"Cloud replay: {error}", file=sys.stderr)
1061
+ return 1
1062
+
1063
+
1064
+ if __name__ == "__main__":
1065
+ raise SystemExit(main())
@@ -2,12 +2,13 @@
2
2
 
3
3
  require "json"
4
4
  require "open3"
5
- require "pathname"
6
5
  require "tempfile"
7
6
 
8
7
  module Bitfab
9
8
  module CloudReplayCli
10
- HELP = "Direct GitHub replay: --cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH] [--max-concurrency 1..32] [--cloud-include FILE] [--cloud-dry-run] [--cloud-detach] [--cloud-request-id UUID]. Lifecycle: --cloud-status|--cloud-watch|--cloud-cancel|--cloud-cleanup UUID. Requires git, gh auth login, Python 3.10+, and bitfab:setup cloud."
9
+ HELP = "Direct GitHub replay: --cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH] [--max-concurrency 1..32] [--cloud-include FILE] [--cloud-dry-run] [--cloud-detach] [--cloud-request-id UUID]. Lifecycle: --cloud-status|--cloud-watch|--cloud-cancel|--cloud-cleanup UUID. Setup: --cloud-init --config FILE, --cloud-secrets --env-file FILE [NAME ...]. Requires git, gh auth login, Python 3.10+, and bitfab:setup cloud."
10
+
11
+ HELPER = File.expand_path("cloudReplay.py", __dir__)
11
12
 
12
13
  module_function
13
14
 
@@ -15,21 +16,14 @@ module Bitfab
15
16
  argv.any? { |arg| arg == "--cloud" || arg.start_with?("--cloud-") }
16
17
  end
17
18
 
18
- def run(argv:, stdout:, stderr:)
19
+ def run(argv:, stdout:, stderr:, helper: HELPER)
19
20
  if argv.include?("--help") || argv.include?("-h")
20
21
  stdout.puts HELP
21
22
  return {}
22
23
  end
23
- output, status = Open3.capture2("git", "rev-parse", "--show-toplevel")
24
- raise ArgumentError, "Cloud replay must run inside a Git repository" unless status.success?
25
- root = Pathname.new(output.strip).realpath
26
- helper = root.join(".bitfab", "cloudReplay.py")
27
- unless helper.file? && helper.realpath == helper
28
- raise ArgumentError, "Run bitfab:setup cloud first. Expected .bitfab/cloudReplay.py inside this repository."
29
- end
30
24
  Tempfile.create("bitfab-cloud-output") do |file|
31
25
  result_status = nil
32
- Open3.popen3("python3", helper.to_s, *argv) do |input, output_stream, error_stream, thread|
26
+ Open3.popen3("python3", helper, *argv) do |input, output_stream, error_stream, thread|
33
27
  input.close
34
28
  reader = Thread.new { IO.copy_stream(output_stream, file) }
35
29
  error_stream.each_line { |line| stderr.write(line) }
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module Bitfab
8
+ class Experiments
9
+ PATH = "/api/sdk/experiments"
10
+ private_constant :PATH
11
+
12
+ def initialize(http_client)
13
+ @http_client = http_client
14
+ end
15
+
16
+ def list(dataset_id: nil, trace_function_key: nil, git_branch: nil, experiment_group_id: nil, status: nil,
17
+ metadata: nil, created_after: nil, created_before: nil, cursor: nil, limit: nil)
18
+ params = {}
19
+ params["datasetId"] = dataset_id unless dataset_id.nil?
20
+ params["traceFunctionKey"] = trace_function_key unless trace_function_key.nil?
21
+ params["gitBranch"] = git_branch unless git_branch.nil?
22
+ params["experimentGroupId"] = experiment_group_id unless experiment_group_id.nil?
23
+ params["status"] = status unless status.nil?
24
+ params["metadata"] = JSON.generate(metadata) unless metadata.nil?
25
+ params["createdAfter"] = iso8601(created_after) unless created_after.nil?
26
+ params["createdBefore"] = iso8601(created_before) unless created_before.nil?
27
+ params["cursor"] = cursor unless cursor.nil?
28
+ params["limit"] = limit unless limit.nil?
29
+ query = URI.encode_www_form(params)
30
+ @http_client.get(query.empty? ? PATH : "#{PATH}?#{query}")
31
+ end
32
+
33
+ def get(id)
34
+ @http_client.get("#{PATH}/#{encode(id)}")["experiment"]
35
+ end
36
+
37
+ def get_rollup(id)
38
+ @http_client.get("#{PATH}/#{encode(id)}/rollup")
39
+ end
40
+
41
+ def get_rollup_all(ids)
42
+ return [] if ids.empty?
43
+
44
+ query = URI.encode_www_form("experimentIds" => ids.join(","))
45
+ @http_client.get("#{PATH}/rollups?#{query}")["rollups"]
46
+ end
47
+
48
+ private
49
+
50
+ def iso8601(value)
51
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
52
+ end
53
+
54
+ def encode(value)
55
+ URI.encode_www_form_component(value).gsub("+", "%20")
56
+ end
57
+ end
58
+ end
@@ -261,7 +261,7 @@ module Bitfab
261
261
  # runnable graders at completion; each must be an active/live grader in the
262
262
  # same org and trace function or the server rejects the replay
263
263
  def start_replay(trace_function_key, limit, trace_ids: nil, code_change_description: nil,
264
- code_change_files: nil, experiment_group_id: nil, name: nil, notes: nil, include_db_branch_lease: false, dataset_ids: nil,
264
+ code_change_files: nil, experiment_group_id: nil, name: nil, notes: nil, metadata: nil, include_db_branch_lease: false, dataset_ids: nil,
265
265
  grader_ids: nil, db_branch_settings: nil, attempts: 1, only_with_assertions: false, judge_assertions: false, include_original_metadata: false, experimental_selective_replay: nil)
266
266
  payload = {
267
267
  "traceFunctionKey" => trace_function_key
@@ -272,6 +272,7 @@ module Bitfab
272
272
  payload["traceIds"] = trace_ids if trace_ids
273
273
  payload["name"] = name unless name.nil?
274
274
  payload["notes"] = notes unless notes.nil?
275
+ payload["metadata"] = metadata unless metadata.nil?
275
276
  payload["codeChangeDescription"] = code_change_description unless code_change_description.nil?
276
277
  payload["codeChangeFiles"] = normalize_code_change_files(code_change_files) unless code_change_files.nil?
277
278
  payload["experimentGroupId"] = experiment_group_id unless experiment_group_id.nil?
data/lib/bitfab/replay.rb CHANGED
@@ -280,7 +280,7 @@ module Bitfab
280
280
  # crashes the run.
281
281
  # @return [Hash] with :items, :experiment_id, :experiment_url, and the
282
282
  # deprecated aliases :test_run_id and :test_run_url carrying the same values
283
- def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil, notes: nil,
283
+ def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil, notes: nil, metadata: nil,
284
284
  concurrency: nil, max_concurrency: ReplayConcurrency::UNSET, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil,
285
285
  dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, judge_assertions: false, dry_run: false,
286
286
  adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil,
@@ -378,6 +378,7 @@ module Bitfab
378
378
  trace_ids:,
379
379
  name:,
380
380
  notes:,
381
+ metadata:,
381
382
  code_change_description:,
382
383
  code_change_files:,
383
384
  experiment_group_id:,
@@ -19,7 +19,7 @@ module Bitfab
19
19
  # Project-owned registry of production replay roots.
20
20
  class ReplayRegistry
21
21
  OPTION_NAMES = %i[
22
- limit trace_ids name notes concurrency max_concurrency code_change_description
22
+ limit trace_ids name notes metadata concurrency max_concurrency code_change_description
23
23
  code_change_files experiment_group_id dataset_id dataset_ids grader_ids mock
24
24
  mock_override adapt_inputs db_branch attempts only_with_assertions judge_assertions dry_run on_item_finish
25
25
  ].freeze
@@ -186,7 +186,7 @@ module Bitfab
186
186
  options[:limit] = bound || 10
187
187
  end
188
188
 
189
- %i[name notes max_concurrency experiment_group_id dataset_ids grader_ids mock attempts only_with_assertions judge_assertions dry_run].each do |key|
189
+ %i[name notes metadata max_concurrency experiment_group_id dataset_ids grader_ids mock attempts only_with_assertions judge_assertions dry_run].each do |key|
190
190
  options[key] = args[key] unless args[key].nil?
191
191
  end
192
192
 
@@ -291,6 +291,15 @@ module Bitfab
291
291
  options.on("--notes TEXT",
292
292
  "Run conditions Bitfab cannot see on its own, such as an environment override or a forced feature flag. " \
293
293
  "Kept on the experiment next to its name.") { |value| args[:notes] = value }
294
+ options.on("--metadata KEY=VALUE",
295
+ "A key and value stored on the experiment, such as schedule=eod. Repeat the flag for more pairs. " \
296
+ "Filter later runs on it with client.experiments.list(metadata: {...}).") do |value|
297
+ key, pair_value = value.split("=", 2)
298
+ if key.to_s.empty? || pair_value.nil?
299
+ raise OptionParser::InvalidArgument, "--metadata expects KEY=VALUE, got '#{value}'"
300
+ end
301
+ (args[:metadata] ||= {})[key] = pair_value
302
+ end
294
303
  options.on("--concurrency N", Integer) do |value|
295
304
  args[:max_concurrency] = positive_integer("--concurrency", value)
296
305
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.62.2"
4
+ VERSION = "0.62.4"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -21,6 +21,7 @@ require_relative "bitfab/traces"
21
21
  require_relative "bitfab/assertions"
22
22
  require_relative "bitfab/labels"
23
23
  require_relative "bitfab/graders"
24
+ require_relative "bitfab/experiments"
24
25
  require_relative "bitfab/mock_override"
25
26
  require_relative "bitfab/replay"
26
27
  require_relative "bitfab/replay_branch"
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.62.2
4
+ version: 0.62.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -159,6 +159,7 @@ files:
159
159
  - lib/bitfab/assertions.rb
160
160
  - lib/bitfab/baml.rb
161
161
  - lib/bitfab/client.rb
162
+ - lib/bitfab/cloudReplay.py
162
163
  - lib/bitfab/cloud_replay_cli.rb
163
164
  - lib/bitfab/commit_ref.rb
164
165
  - lib/bitfab/compress.rb
@@ -169,6 +170,7 @@ files:
169
170
  - lib/bitfab/detached_trace.rb
170
171
  - lib/bitfab/env.rb
171
172
  - lib/bitfab/experiment_id.rb
173
+ - lib/bitfab/experiments.rb
172
174
  - lib/bitfab/git_command.rb
173
175
  - lib/bitfab/git_state.rb
174
176
  - lib/bitfab/graders.rb