bitfab 0.62.3 → 0.62.5
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 +4 -4
- data/lib/bitfab/client.rb +4 -2
- data/lib/bitfab/cloudReplay.py +374 -125
- data/lib/bitfab/cloud_replay_cli.rb +1 -1
- data/lib/bitfab/experiments.rb +58 -0
- data/lib/bitfab/http_client.rb +2 -1
- data/lib/bitfab/replay.rb +2 -1
- data/lib/bitfab/replay_registry.rb +11 -2
- data/lib/bitfab/version.rb +1 -1
- data/lib/bitfab.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7055ee2b869bb3acd9740221ebe869d67564ec79faa41ec34c3de2d1e5c309c
|
|
4
|
+
data.tar.gz: 1e1fa48e73ebe238b50a1d88804512026dfa0e1faa833422c1646afce461d25c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79492cc60cb6ef608357d1bde581d632fdbd8140cf89b1a622e4a5b9efbbd265ac558d0342efa0d165252ab43233aa458bd0eb2a2f3e59770c02244742889eea
|
|
7
|
+
data.tar.gz: 213a1c8ce50982a81b59fdfce1ecc6e1f724e6e75833a08793da16307037a2c4e5b10d631365c2b76468b0596bfcc524426bd986a39539a9339357b58ef7ee06
|
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:,
|
data/lib/bitfab/cloudReplay.py
CHANGED
|
@@ -11,6 +11,7 @@ import signal
|
|
|
11
11
|
import subprocess
|
|
12
12
|
import sys
|
|
13
13
|
import tempfile
|
|
14
|
+
import threading
|
|
14
15
|
import time
|
|
15
16
|
import uuid
|
|
16
17
|
from pathlib import Path
|
|
@@ -20,6 +21,20 @@ CONFIG = ".bitfab/cloud.json"
|
|
|
20
21
|
PREFIX = "bitfab-replay/"
|
|
21
22
|
DEFAULT_SECRET_PREFIX = "BITFAB_CLOUD_"
|
|
22
23
|
API_VERSION = "2026-03-10"
|
|
24
|
+
# The SDK finds runs by this title and feeds them through these inputs. Never change them.
|
|
25
|
+
RUN_NAME = "Bitfab replay ${{ inputs.execution_id }}"
|
|
26
|
+
RUNNER_ENV = {
|
|
27
|
+
"BITFAB_CLOUD_REQUEST": "${{ inputs.request }}",
|
|
28
|
+
"BITFAB_EXECUTION_ID": "${{ inputs.execution_id }}",
|
|
29
|
+
"BITFAB_COMMIT_SHA": "${{ github.sha }}",
|
|
30
|
+
}
|
|
31
|
+
# The runner reports its result as a job annotation with this title, so the workflow
|
|
32
|
+
# needs no upload step. Older workflows still upload RESULT_FILE as an artifact.
|
|
33
|
+
RESULT_TITLE = "Bitfab replay result"
|
|
34
|
+
RESULT_FILE = "bitfab-cloud-result.json"
|
|
35
|
+
# Steps older setups generated that the SDK now owns.
|
|
36
|
+
OLD_UPLOAD_STEP = "Save replay identity"
|
|
37
|
+
OLD_JOB_TIMEOUT = 35
|
|
23
38
|
ITEM_ERROR_FIELDS = (
|
|
24
39
|
"error",
|
|
25
40
|
"traceError",
|
|
@@ -30,13 +45,17 @@ ITEM_ERROR_FIELDS = (
|
|
|
30
45
|
UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
|
|
31
46
|
SHA = re.compile(r"^[0-9a-f]{40}$")
|
|
32
47
|
PIPELINE = re.compile(r"^[\w.][\w.-]*$")
|
|
48
|
+
# Every SDK's replay prints this to stderr as soon as the server creates the experiment.
|
|
49
|
+
EXPERIMENT_LINE = re.compile(rb"^\[replay\] Experiment ([0-9a-f-]{36}):")
|
|
33
50
|
HELP = """GitHub cloud replay (requires git, gh login, and Python 3.10+).
|
|
34
51
|
--cloud PIPELINE --trace-ids UUID[,UUID] [--registry PATH]
|
|
35
52
|
[--max-concurrency 1..32] [--cloud-request-id UUID]
|
|
36
53
|
[--cloud-include PATH ...] [--cloud-dry-run] [--cloud-detach]
|
|
54
|
+
[--cloud-timeout MINUTES]
|
|
37
55
|
--cloud-status UUID | --cloud-watch UUID | --cloud-cancel UUID
|
|
38
56
|
--cloud-cleanup UUID
|
|
39
|
-
--cloud-init --config SPEC
|
|
57
|
+
--cloud-init [--config SPEC] (creates a setup, or brings an existing one up to date)
|
|
58
|
+
--cloud-secrets --env-file FILE [NAME ...]
|
|
40
59
|
PIPELINE may be any pipeline in the registry, unless .bitfab/cloud.json names one.
|
|
41
60
|
Snapshot tracked working files without changing HEAD, the index, or local files.
|
|
42
61
|
New files require explicit --cloud-include. Credentials and ignored files are refused.
|
|
@@ -266,6 +285,8 @@ def validate_config(config):
|
|
|
266
285
|
raise ValueError(
|
|
267
286
|
"The runner command must execute locally, not recursively dispatch"
|
|
268
287
|
)
|
|
288
|
+
if "cliCommand" in config:
|
|
289
|
+
validate_cli_command(config["cliCommand"])
|
|
269
290
|
if config.get("pushTriggersReviewed") is not True:
|
|
270
291
|
raise ValueError(
|
|
271
292
|
"Setup must review push-triggered CI/deployments and set pushTriggersReviewed=true"
|
|
@@ -285,6 +306,18 @@ def validate_config(config):
|
|
|
285
306
|
raise ValueError("secrets must be uppercase environment variable names")
|
|
286
307
|
|
|
287
308
|
|
|
309
|
+
def validate_cli_command(cli_command):
|
|
310
|
+
if (
|
|
311
|
+
not isinstance(cli_command, list)
|
|
312
|
+
or not cli_command
|
|
313
|
+
or not all(isinstance(v, str) and v and "\x00" not in v for v in cli_command)
|
|
314
|
+
or any(v.startswith("--cloud") for v in cli_command)
|
|
315
|
+
):
|
|
316
|
+
raise ValueError(
|
|
317
|
+
"cliCommand must be the argument array that starts the SDK's bitfab-replay command from workingDirectory"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
288
321
|
def check_pipeline(config, pipeline):
|
|
289
322
|
if not isinstance(pipeline, str) or not PIPELINE.fullmatch(pipeline):
|
|
290
323
|
raise ValueError("Invalid pipeline name")
|
|
@@ -342,6 +375,7 @@ def parse(argv):
|
|
|
342
375
|
parser.add_argument("--cloud-include", action="append", default=[])
|
|
343
376
|
parser.add_argument("--cloud-dry-run", action="store_true")
|
|
344
377
|
parser.add_argument("--cloud-detach", action="store_true")
|
|
378
|
+
parser.add_argument("--cloud-timeout", type=int)
|
|
345
379
|
args = parser.parse_args(argv)
|
|
346
380
|
operations = [
|
|
347
381
|
name
|
|
@@ -363,6 +397,9 @@ def parse(argv):
|
|
|
363
397
|
raise ValueError("Supply 1..100 explicit trace UUIDs")
|
|
364
398
|
if not 1 <= args.max_concurrency <= 32:
|
|
365
399
|
raise ValueError("--max-concurrency must be 1..32")
|
|
400
|
+
# Self-hosted runners allow jobs of up to 5 days; GitHub-hosted ones stop at 6 hours.
|
|
401
|
+
if args.cloud_timeout is not None and not 1 <= args.cloud_timeout <= 7200:
|
|
402
|
+
raise ValueError("--cloud-timeout must be 1..7200 minutes")
|
|
366
403
|
if not UUID.fullmatch(execution_id):
|
|
367
404
|
raise ValueError("Execution ID must be a UUID")
|
|
368
405
|
return args, operation, execution_id
|
|
@@ -547,39 +584,82 @@ def status(record, *, fetch_result=True):
|
|
|
547
584
|
if (
|
|
548
585
|
fetch_result
|
|
549
586
|
and record["state"] == "completed"
|
|
550
|
-
and record["conclusion"] == "success"
|
|
551
587
|
and not record.get("testRunId")
|
|
588
|
+
and not record.get("resultChecked")
|
|
552
589
|
):
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
)
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
590
|
+
success = record["conclusion"] == "success"
|
|
591
|
+
try:
|
|
592
|
+
result = read_result(record)
|
|
593
|
+
except RuntimeError:
|
|
594
|
+
# A run that failed before its replay started reports no result.
|
|
595
|
+
if success:
|
|
596
|
+
raise
|
|
597
|
+
record["resultChecked"] = True
|
|
598
|
+
return record
|
|
599
|
+
if (
|
|
600
|
+
result.get("executionId") != record["id"]
|
|
601
|
+
or result.get("commitSha") != record["sha"]
|
|
602
|
+
or not UUID.fullmatch(result.get("testRunId", ""))
|
|
603
|
+
):
|
|
604
|
+
raise ValueError("Replay result does not match this execution")
|
|
605
|
+
counts = replay_counts(result)
|
|
606
|
+
record["testRunId"] = result["testRunId"]
|
|
607
|
+
if isinstance(result.get("stoppedEarly"), str):
|
|
608
|
+
record["stoppedEarly"] = result["stoppedEarly"]
|
|
609
|
+
if counts is not None:
|
|
610
|
+
record["replayed"], record["errored"] = counts
|
|
611
|
+
return record
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def read_result(record):
|
|
615
|
+
try:
|
|
616
|
+
result = read_result_annotation(record)
|
|
617
|
+
except RuntimeError:
|
|
618
|
+
# Older workflows still upload the result, so a failed Checks API read
|
|
619
|
+
# must not hide it.
|
|
620
|
+
result = None
|
|
621
|
+
return result or read_result_artifact(record)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def read_result_annotation(record):
|
|
625
|
+
repo = record["repository"]
|
|
626
|
+
jobs = api(repo, f"actions/runs/{record['runId']}/jobs?per_page=100") or {}
|
|
627
|
+
for job in jobs.get("jobs", []):
|
|
628
|
+
check_run = str(job.get("check_run_url", "")).rsplit("/", 1)[-1]
|
|
629
|
+
if not check_run.isdigit():
|
|
630
|
+
continue
|
|
631
|
+
for note in api(repo, f"check-runs/{check_run}/annotations?per_page=100") or []:
|
|
572
632
|
if (
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
or not UUID.fullmatch(result.get("testRunId", ""))
|
|
633
|
+
note.get("title") == RESULT_TITLE
|
|
634
|
+
and len(note.get("message", "")) <= 4096
|
|
576
635
|
):
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
636
|
+
try:
|
|
637
|
+
return json.loads(base64.b64decode(note["message"], validate=True))
|
|
638
|
+
except ValueError as error:
|
|
639
|
+
raise ValueError("Invalid replay result annotation") from error
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def read_result_artifact(record):
|
|
644
|
+
with tempfile.TemporaryDirectory(prefix="bitfab-cloud-result-") as directory:
|
|
645
|
+
command(
|
|
646
|
+
[
|
|
647
|
+
"gh",
|
|
648
|
+
"run",
|
|
649
|
+
"download",
|
|
650
|
+
str(record["runId"]),
|
|
651
|
+
"--repo",
|
|
652
|
+
record["repository"],
|
|
653
|
+
"--name",
|
|
654
|
+
"bitfab-replay-" + record["id"],
|
|
655
|
+
"--dir",
|
|
656
|
+
directory,
|
|
657
|
+
]
|
|
658
|
+
)
|
|
659
|
+
artifact = Path(directory) / RESULT_FILE
|
|
660
|
+
if artifact.is_symlink() or artifact.stat().st_size > 4096:
|
|
661
|
+
raise ValueError("Invalid replay result artifact")
|
|
662
|
+
return json.loads(artifact.read_text())
|
|
583
663
|
|
|
584
664
|
|
|
585
665
|
def cleanup(root, record):
|
|
@@ -643,6 +723,8 @@ def run_cli(argv):
|
|
|
643
723
|
"traceIds": args.trace_ids.split(","),
|
|
644
724
|
"maxConcurrency": args.max_concurrency,
|
|
645
725
|
}
|
|
726
|
+
if args.cloud_timeout is not None:
|
|
727
|
+
request["timeoutMinutes"] = args.cloud_timeout
|
|
646
728
|
if args.cloud_dry_run:
|
|
647
729
|
return {
|
|
648
730
|
"dryRun": True,
|
|
@@ -729,17 +811,24 @@ def run_cli(argv):
|
|
|
729
811
|
save(path, record)
|
|
730
812
|
return record
|
|
731
813
|
if operation == "watch" or (operation == "submit" and not args.cloud_detach):
|
|
732
|
-
|
|
733
|
-
|
|
814
|
+
# Once GitHub shows the run, its job timeout bounds the wait and Ctrl-C detaches.
|
|
815
|
+
# A dispatch GitHub never shows as a run would otherwise be waited on forever.
|
|
816
|
+
started = time.monotonic()
|
|
817
|
+
while record["state"] != "completed":
|
|
734
818
|
status(record)
|
|
735
819
|
save(path, record)
|
|
820
|
+
if (
|
|
821
|
+
record["state"] == "dispatch_unknown"
|
|
822
|
+
and time.monotonic() - started >= 10 * 60
|
|
823
|
+
):
|
|
824
|
+
raise ValueError(
|
|
825
|
+
"GitHub has shown no run for this dispatch after 10 minutes. Check Actions, then resume with --cloud-watch "
|
|
826
|
+
+ execution_id
|
|
827
|
+
+ " or remove the snapshot branch with --cloud-cleanup "
|
|
828
|
+
+ execution_id
|
|
829
|
+
)
|
|
736
830
|
if record["state"] != "completed":
|
|
737
831
|
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
832
|
if record["state"] == "completed" or (
|
|
744
833
|
operation == "cleanup" and record["state"] == "prepared"
|
|
745
834
|
):
|
|
@@ -763,6 +852,7 @@ def execute():
|
|
|
763
852
|
check_pipeline(config, request["pipeline"])
|
|
764
853
|
if request["id"] != os.environ["BITFAB_EXECUTION_ID"]:
|
|
765
854
|
raise ValueError("Replay request does not match the configured execution")
|
|
855
|
+
timeout = request.get("timeoutMinutes")
|
|
766
856
|
parse(
|
|
767
857
|
[
|
|
768
858
|
"--cloud",
|
|
@@ -773,6 +863,7 @@ def execute():
|
|
|
773
863
|
str(request["maxConcurrency"]),
|
|
774
864
|
"--cloud-request-id",
|
|
775
865
|
request["id"],
|
|
866
|
+
*([] if timeout is None else ["--cloud-timeout", str(timeout)]),
|
|
776
867
|
]
|
|
777
868
|
)
|
|
778
869
|
if not os.environ.get("BITFAB_API_KEY"):
|
|
@@ -786,32 +877,100 @@ def execute():
|
|
|
786
877
|
str(request["maxConcurrency"]),
|
|
787
878
|
"--no-code-change",
|
|
788
879
|
]
|
|
880
|
+
experiment = {}
|
|
881
|
+
# GitHub cancels and times out a job with SIGINT then SIGTERM; turn SIGTERM into the
|
|
882
|
+
# same interrupt so the replay is stopped cleanly and its experiment is still reported.
|
|
883
|
+
previous = signal.signal(signal.SIGTERM, raise_interrupt)
|
|
884
|
+
try:
|
|
885
|
+
summary = run_replay(root, config, request, args, timeout, experiment)
|
|
886
|
+
except BaseException as error:
|
|
887
|
+
if UUID.fullmatch(experiment.get("id", "")):
|
|
888
|
+
write_result(
|
|
889
|
+
{
|
|
890
|
+
"executionId": request["id"],
|
|
891
|
+
"commitSha": os.environ["GITHUB_SHA"],
|
|
892
|
+
"testRunId": experiment["id"],
|
|
893
|
+
"stoppedEarly": (str(error) or type(error).__name__)[:300],
|
|
894
|
+
}
|
|
895
|
+
)
|
|
896
|
+
raise
|
|
897
|
+
finally:
|
|
898
|
+
signal.signal(signal.SIGTERM, previous)
|
|
899
|
+
write_result(summary)
|
|
900
|
+
return summary
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def raise_interrupt(signum, frame):
|
|
904
|
+
raise KeyboardInterrupt
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def forward_stderr(stream, experiment):
|
|
908
|
+
for line in iter(stream.readline, b""):
|
|
909
|
+
sys.stderr.buffer.write(line)
|
|
910
|
+
sys.stderr.flush()
|
|
911
|
+
if "id" not in experiment:
|
|
912
|
+
match = EXPERIMENT_LINE.match(line)
|
|
913
|
+
if match:
|
|
914
|
+
experiment["id"] = match[1].decode()
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def write_result(summary):
|
|
918
|
+
# Base64, because GitHub masks every line of a multi-line secret in logs and
|
|
919
|
+
# annotations, so a JSON credential alone turns each { and } into ***.
|
|
920
|
+
message = base64.b64encode(json.dumps(summary).encode()).decode()
|
|
921
|
+
print(f"::notice title={RESULT_TITLE}::{message}", flush=True)
|
|
922
|
+
lines = [
|
|
923
|
+
f"Test run: `{summary['testRunId']}`",
|
|
924
|
+
f"Commit: `{summary['commitSha']}`",
|
|
925
|
+
]
|
|
926
|
+
if "stoppedEarly" in summary:
|
|
927
|
+
lines.append(
|
|
928
|
+
f"Stopped early: {summary['stoppedEarly']}. Traces that finished are saved in this test run."
|
|
929
|
+
)
|
|
930
|
+
else:
|
|
931
|
+
lines.append(f"Replayed: {summary['replayed']}, errored: {summary['errored']}")
|
|
932
|
+
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as file:
|
|
933
|
+
file.write("### Bitfab replay\n\n" + "\n\n".join(lines) + "\n")
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def run_replay(root, config, request, args, timeout, experiment):
|
|
789
937
|
with tempfile.TemporaryFile() as output:
|
|
790
938
|
with subprocess.Popen(
|
|
791
939
|
args,
|
|
792
940
|
cwd=within(root, config["workingDirectory"]),
|
|
793
941
|
stdout=output,
|
|
942
|
+
stderr=subprocess.PIPE,
|
|
794
943
|
stdin=subprocess.DEVNULL,
|
|
795
944
|
start_new_session=True,
|
|
796
945
|
) as child:
|
|
946
|
+
reader = threading.Thread(
|
|
947
|
+
target=forward_stderr, args=(child.stderr, experiment), daemon=True
|
|
948
|
+
)
|
|
949
|
+
reader.start()
|
|
797
950
|
try:
|
|
798
|
-
|
|
951
|
+
# Without --cloud-timeout the job's own timeout is the only limit.
|
|
952
|
+
deadline = None if timeout is None else time.monotonic() + timeout * 60
|
|
799
953
|
while child.poll() is None:
|
|
800
954
|
if os.fstat(output.fileno()).st_size > 16 * 1024 * 1024:
|
|
801
955
|
raise ValueError("Replay output exceeded 16 MiB")
|
|
802
|
-
if time.monotonic() >= deadline:
|
|
803
|
-
raise
|
|
956
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
957
|
+
raise ValueError(
|
|
958
|
+
f"Replay stopped at the {timeout}-minute --cloud-timeout; traces that finished are saved in Bitfab as an interrupted experiment"
|
|
959
|
+
)
|
|
804
960
|
time.sleep(0.1)
|
|
805
961
|
code = child.returncode
|
|
806
962
|
except BaseException:
|
|
807
963
|
with contextlib.suppress(ProcessLookupError):
|
|
808
964
|
os.killpg(child.pid, signal.SIGTERM)
|
|
965
|
+
# Time for the SDK to mark the experiment interrupted before the hard kill.
|
|
809
966
|
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
810
|
-
child.wait(timeout=
|
|
967
|
+
child.wait(timeout=30)
|
|
811
968
|
with contextlib.suppress(ProcessLookupError):
|
|
812
969
|
os.killpg(child.pid, signal.SIGKILL)
|
|
813
970
|
child.wait()
|
|
814
971
|
raise
|
|
972
|
+
finally:
|
|
973
|
+
reader.join(timeout=5)
|
|
815
974
|
if output.tell() > 16 * 1024 * 1024:
|
|
816
975
|
raise ValueError("Replay output exceeded 16 MiB")
|
|
817
976
|
output.seek(0)
|
|
@@ -843,14 +1002,6 @@ def execute():
|
|
|
843
1002
|
"replayed": len(items),
|
|
844
1003
|
"errored": sum(1 for item in items if item_errored(item)),
|
|
845
1004
|
}
|
|
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
1005
|
return summary
|
|
855
1006
|
|
|
856
1007
|
|
|
@@ -892,18 +1043,77 @@ def report_errored_items(record):
|
|
|
892
1043
|
return 0
|
|
893
1044
|
|
|
894
1045
|
|
|
1046
|
+
def replay_step(config, cli_command, env):
|
|
1047
|
+
return {
|
|
1048
|
+
"name": "Replay",
|
|
1049
|
+
"working-directory": config["workingDirectory"],
|
|
1050
|
+
"run": shlex.join([*cli_command, "--cloud-execute"]),
|
|
1051
|
+
"env": {**env, **RUNNER_ENV},
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def workflow_document(job_options, setup_steps, replay):
|
|
1056
|
+
"""Only settings belong here; everything Bitfab may need to change runs inside the SDK."""
|
|
1057
|
+
job = {
|
|
1058
|
+
"runs-on": job_options.get("runsOn", "ubuntu-24.04"),
|
|
1059
|
+
"steps": [
|
|
1060
|
+
{
|
|
1061
|
+
"name": "Check out replay snapshot",
|
|
1062
|
+
"uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
|
|
1063
|
+
"with": {"ref": "${{ github.sha }}", "persist-credentials": False},
|
|
1064
|
+
},
|
|
1065
|
+
*setup_steps,
|
|
1066
|
+
replay,
|
|
1067
|
+
],
|
|
1068
|
+
}
|
|
1069
|
+
for key in ("environment", "services"):
|
|
1070
|
+
if key in job_options:
|
|
1071
|
+
job[key] = job_options[key]
|
|
1072
|
+
return {
|
|
1073
|
+
"name": "Bitfab cloud replay",
|
|
1074
|
+
"run-name": RUN_NAME,
|
|
1075
|
+
"on": {
|
|
1076
|
+
"workflow_dispatch": {
|
|
1077
|
+
"inputs": {
|
|
1078
|
+
"execution_id": {"required": True, "type": "string"},
|
|
1079
|
+
"request": {"required": True, "type": "string"},
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
},
|
|
1083
|
+
"permissions": {"contents": "read"},
|
|
1084
|
+
"jobs": {"replay": job},
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def write_new_files(root, outputs):
|
|
1089
|
+
for name in outputs:
|
|
1090
|
+
if within(root, name).exists():
|
|
1091
|
+
raise ValueError(
|
|
1092
|
+
f"Refusing to overwrite {name}; review and edit the existing setup"
|
|
1093
|
+
)
|
|
1094
|
+
for name, content in outputs.items():
|
|
1095
|
+
target = within(root, name)
|
|
1096
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1097
|
+
with target.open("x") as file:
|
|
1098
|
+
file.write(content)
|
|
1099
|
+
|
|
1100
|
+
|
|
895
1101
|
def initialize(argv):
|
|
896
1102
|
parser = argparse.ArgumentParser(
|
|
897
1103
|
prog="bitfab-replay --cloud-init",
|
|
898
|
-
description="Install direct GitHub cloud replay from a reviewed JSON setup specification
|
|
1104
|
+
description="Install direct GitHub cloud replay from a reviewed JSON setup specification, or bring an existing setup up to date in place. Safe to run again.",
|
|
899
1105
|
)
|
|
900
1106
|
parser.add_argument(
|
|
901
1107
|
"--config",
|
|
902
|
-
|
|
903
|
-
help="JSON file with cloud config, cliCommand, setupSteps, optional pipeline (omit or null to allow every registry pipeline), secrets, variables, environment, services, runsOn",
|
|
1108
|
+
help="JSON file with cloud config, cliCommand, setupSteps, optional pipeline (omit or null to allow every registry pipeline), secrets, variables, environment, services, runsOn. Required for a new setup; for an existing one only cliCommand is read, and only when the workflow does not already show it",
|
|
904
1109
|
)
|
|
905
1110
|
args = parser.parse_args(argv)
|
|
906
|
-
spec = json.loads(Path(args.config).read_text())
|
|
1111
|
+
spec = json.loads(Path(args.config).read_text()) if args.config else None
|
|
1112
|
+
root = root_directory()
|
|
1113
|
+
if within(root, CONFIG).exists():
|
|
1114
|
+
return update_existing(root, spec)
|
|
1115
|
+
if spec is None:
|
|
1116
|
+
raise ValueError(f"No {CONFIG} yet; pass --config with a setup specification")
|
|
907
1117
|
config = {
|
|
908
1118
|
key: spec[key]
|
|
909
1119
|
for key in (
|
|
@@ -925,6 +1135,7 @@ def initialize(argv):
|
|
|
925
1135
|
config["secrets"] = secrets
|
|
926
1136
|
config["secretPrefix"] = secret_prefix
|
|
927
1137
|
validate_config(config)
|
|
1138
|
+
validate_cli_command(spec.get("cliCommand"))
|
|
928
1139
|
if set(secrets) & set(variables):
|
|
929
1140
|
raise ValueError("Secret names and variable names must not overlap")
|
|
930
1141
|
if "BITFAB_API_KEY" not in secrets or not all(
|
|
@@ -933,16 +1144,6 @@ def initialize(argv):
|
|
|
933
1144
|
raise ValueError(
|
|
934
1145
|
"Supply uppercase secret/variable names, including BITFAB_API_KEY; never values"
|
|
935
1146
|
)
|
|
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
1147
|
steps = spec.get("setupSteps")
|
|
947
1148
|
if (
|
|
948
1149
|
not isinstance(steps, list)
|
|
@@ -952,74 +1153,15 @@ def initialize(argv):
|
|
|
952
1153
|
raise ValueError("setupSteps must contain reviewed GitHub Actions setup steps")
|
|
953
1154
|
env = {key: "${{ secrets." + secret_prefix + key + " }}" for key in secrets}
|
|
954
1155
|
env.update({key: "${{ vars." + key + " }}" for key in variables})
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
BITFAB_EXECUTION_ID="${{ inputs.execution_id }}",
|
|
958
|
-
BITFAB_COMMIT_SHA="${{ github.sha }}",
|
|
1156
|
+
workflow = workflow_document(
|
|
1157
|
+
spec, steps, replay_step(config, spec["cliCommand"], env)
|
|
959
1158
|
)
|
|
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
1159
|
outputs = {
|
|
1008
1160
|
CONFIG: json.dumps(config, indent=2) + "\n",
|
|
1009
1161
|
f".github/workflows/{config['workflow']}": json.dumps(workflow, indent=2)
|
|
1010
1162
|
+ "\n",
|
|
1011
1163
|
}
|
|
1012
|
-
|
|
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)
|
|
1164
|
+
write_new_files(root, outputs)
|
|
1023
1165
|
return {
|
|
1024
1166
|
"files": list(outputs),
|
|
1025
1167
|
"requiredSecrets": [secret_prefix + name for name in secrets],
|
|
@@ -1028,6 +1170,108 @@ def initialize(argv):
|
|
|
1028
1170
|
}
|
|
1029
1171
|
|
|
1030
1172
|
|
|
1173
|
+
def replace_file(target, content):
|
|
1174
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=target.parent, delete=False) as file:
|
|
1175
|
+
file.write(content)
|
|
1176
|
+
temporary = file.name
|
|
1177
|
+
os.replace(temporary, target)
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def update_existing(root, spec):
|
|
1181
|
+
"""Existing files win: only the Replay step and steps older setups generated change."""
|
|
1182
|
+
config = json.loads(within(root, CONFIG).read_text())
|
|
1183
|
+
validate_config(config)
|
|
1184
|
+
name = f".github/workflows/{config['workflow']}"
|
|
1185
|
+
workflow_path = within(root, name)
|
|
1186
|
+
text = workflow_path.read_text() if workflow_path.is_file() else ""
|
|
1187
|
+
try:
|
|
1188
|
+
workflow = json.loads(text)
|
|
1189
|
+
job = workflow["jobs"]["replay"]
|
|
1190
|
+
steps = job["steps"]
|
|
1191
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
1192
|
+
# A workflow the customer rewrote as YAML is current when nothing older remains.
|
|
1193
|
+
if (
|
|
1194
|
+
"--cloud-execute" in text
|
|
1195
|
+
and OLD_UPLOAD_STEP not in text
|
|
1196
|
+
and ".bitfab/cloudReplay.py" not in text
|
|
1197
|
+
and not re.search(rf"timeout-minutes:\s*{OLD_JOB_TIMEOUT}\b", text)
|
|
1198
|
+
):
|
|
1199
|
+
return {"files": [], "updated": False, "next": "Already up to date"}
|
|
1200
|
+
raise ValueError(
|
|
1201
|
+
f"{name} is missing or was not generated by --cloud-init; edit its Replay step by hand to run the SDK's bitfab-replay command with --cloud-execute"
|
|
1202
|
+
) from error
|
|
1203
|
+
replay = [
|
|
1204
|
+
index
|
|
1205
|
+
for index, step in enumerate(steps)
|
|
1206
|
+
if isinstance(step, dict) and step.get("name") == "Replay"
|
|
1207
|
+
]
|
|
1208
|
+
if len(replay) != 1:
|
|
1209
|
+
raise ValueError(f"{name} must contain exactly one step named Replay")
|
|
1210
|
+
step = steps[replay[0]]
|
|
1211
|
+
words = shlex.split(step.get("run", ""))
|
|
1212
|
+
if spec is not None and spec.get("cliCommand") is not None:
|
|
1213
|
+
cli_command = spec["cliCommand"]
|
|
1214
|
+
elif words[-1:] == ["--cloud-execute"]:
|
|
1215
|
+
cli_command = words[:-1]
|
|
1216
|
+
elif config.get("cliCommand") is not None:
|
|
1217
|
+
cli_command = config["cliCommand"]
|
|
1218
|
+
else:
|
|
1219
|
+
raise ValueError(
|
|
1220
|
+
"The Replay step does not run the SDK command; pass --config with a file whose cliCommand starts bitfab-replay from workingDirectory"
|
|
1221
|
+
)
|
|
1222
|
+
validate_cli_command(cli_command)
|
|
1223
|
+
env = {
|
|
1224
|
+
key: value
|
|
1225
|
+
for key, value in (step.get("env") or {}).items()
|
|
1226
|
+
if key not in RUNNER_ENV
|
|
1227
|
+
}
|
|
1228
|
+
extra = {
|
|
1229
|
+
key: value
|
|
1230
|
+
for key, value in step.items()
|
|
1231
|
+
if key not in ("name", "uses", "with", "run", "working-directory", "env")
|
|
1232
|
+
}
|
|
1233
|
+
before = json.dumps([config, workflow], sort_keys=True)
|
|
1234
|
+
# Edit in place so the customer's checkout options, extra steps, and job
|
|
1235
|
+
# settings survive; only the Replay step and our old result upload change.
|
|
1236
|
+
job["steps"] = [
|
|
1237
|
+
{**replay_step(config, cli_command, env), **extra}
|
|
1238
|
+
if index == replay[0]
|
|
1239
|
+
else entry
|
|
1240
|
+
for index, entry in enumerate(steps)
|
|
1241
|
+
if not (
|
|
1242
|
+
isinstance(entry, dict)
|
|
1243
|
+
and entry.get("name") == OLD_UPLOAD_STEP
|
|
1244
|
+
and str(entry.get("uses", "")).startswith("actions/upload-artifact@")
|
|
1245
|
+
)
|
|
1246
|
+
]
|
|
1247
|
+
# The limit older setups generated; a value the customer chose stays.
|
|
1248
|
+
if job.get("timeout-minutes") == OLD_JOB_TIMEOUT:
|
|
1249
|
+
del job["timeout-minutes"]
|
|
1250
|
+
config.pop("cliCommand", None)
|
|
1251
|
+
# Setups before the SDK carried the script left a copy that nothing reads now.
|
|
1252
|
+
old_script = ".bitfab/cloudReplay.py"
|
|
1253
|
+
removable = [old_script] if within(root, old_script).exists() else []
|
|
1254
|
+
if json.dumps([config, workflow], sort_keys=True) == before:
|
|
1255
|
+
return {
|
|
1256
|
+
"files": [],
|
|
1257
|
+
"updated": False,
|
|
1258
|
+
"removable": removable,
|
|
1259
|
+
"next": "Already up to date",
|
|
1260
|
+
}
|
|
1261
|
+
outputs = {
|
|
1262
|
+
CONFIG: json.dumps(config, indent=2) + "\n",
|
|
1263
|
+
name: json.dumps(workflow, indent=2) + "\n",
|
|
1264
|
+
}
|
|
1265
|
+
for output, content in outputs.items():
|
|
1266
|
+
replace_file(within(root, output), content)
|
|
1267
|
+
return {
|
|
1268
|
+
"files": list(outputs),
|
|
1269
|
+
"updated": True,
|
|
1270
|
+
"removable": removable,
|
|
1271
|
+
"next": "Review the diff, merge the workflow to the default branch, then run --cloud-dry-run",
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
|
|
1031
1275
|
def main():
|
|
1032
1276
|
try:
|
|
1033
1277
|
if sys.argv[1:2] == ["--cloud-init"]:
|
|
@@ -1040,6 +1284,11 @@ def main():
|
|
|
1040
1284
|
result = run_cli(sys.argv[1:])
|
|
1041
1285
|
print(json.dumps(result, indent=2))
|
|
1042
1286
|
if result.get("state") == "completed" and result.get("conclusion") != "success":
|
|
1287
|
+
if result.get("testRunId"):
|
|
1288
|
+
print(
|
|
1289
|
+
f"Cloud replay stopped early ({result.get('stoppedEarly', result.get('conclusion'))}). Traces that finished are saved in test run {result['testRunId']}.",
|
|
1290
|
+
file=sys.stderr,
|
|
1291
|
+
)
|
|
1043
1292
|
return 1
|
|
1044
1293
|
if result.get("state") == "completed":
|
|
1045
1294
|
return report_errored_items(result)
|
|
@@ -6,7 +6,7 @@ require "tempfile"
|
|
|
6
6
|
|
|
7
7
|
module Bitfab
|
|
8
8
|
module CloudReplayCli
|
|
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."
|
|
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] [--cloud-timeout MINUTES]. Lifecycle: --cloud-status|--cloud-watch|--cloud-cancel|--cloud-cleanup UUID. Setup: --cloud-init [--config FILE] (creates a setup or updates it in place), --cloud-secrets --env-file FILE [NAME ...]. Requires git, gh auth login, Python 3.10+, and bitfab:setup cloud."
|
|
10
10
|
|
|
11
11
|
HELPER = File.expand_path("cloudReplay.py", __dir__)
|
|
12
12
|
|
|
@@ -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
|
data/lib/bitfab/http_client.rb
CHANGED
|
@@ -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
|
data/lib/bitfab/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.62.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Harvest Team
|
|
@@ -170,6 +170,7 @@ files:
|
|
|
170
170
|
- lib/bitfab/detached_trace.rb
|
|
171
171
|
- lib/bitfab/env.rb
|
|
172
172
|
- lib/bitfab/experiment_id.rb
|
|
173
|
+
- lib/bitfab/experiments.rb
|
|
173
174
|
- lib/bitfab/git_command.rb
|
|
174
175
|
- lib/bitfab/git_state.rb
|
|
175
176
|
- lib/bitfab/graders.rb
|