bitfab 0.64.3 → 0.64.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.
package/cloudReplay.py CHANGED
@@ -34,6 +34,7 @@ REPLAY_COMMAND_ENV = "BITFAB_REPLAY_COMMAND"
34
34
  SDK_LANGUAGE_ENV = "BITFAB_SDK_LANGUAGE"
35
35
  CHECK_COMMAND_ENV = "BITFAB_REPLAY_CHECK"
36
36
  REQUEST_VERSION = 3
37
+ OLDEST_DISPATCHED_VERSION = 2
37
38
  RESULT_LINE = "bitfab-replay-result "
38
39
  RESULT_CHUNK = 4000
39
40
  OUTPUT_BEGIN = "bitfab-replay-output-begin"
@@ -53,6 +54,14 @@ ITEM_ERROR_FIELDS = (
53
54
  ITEM_ID_FIELDS = ("originalTraceId", "original_trace_id", "traceId", "trace_id")
54
55
  ITEM_ERROR_LINES = 20
55
56
  ITEM_ERROR_LENGTH = 500
57
+ SUMMARY_ERROR_ITEMS = 100
58
+ EXPERIMENT_FIELDS = {
59
+ "experimentId": ("experimentId", "experiment_id"),
60
+ "experimentUrl": ("experimentUrl", "experiment_url"),
61
+ "testRunId": ("testRunId", "test_run_id"),
62
+ "testRunUrl": ("testRunUrl", "test_run_url"),
63
+ "attempts": ("attempts",),
64
+ }
56
65
  UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
57
66
  SHA = re.compile(r"^[0-9a-f]{40}$")
58
67
  GITHUB_REMOTE = re.compile(
@@ -60,6 +69,15 @@ GITHUB_REMOTE = re.compile(
60
69
  )
61
70
  LOG_TIMESTAMP = re.compile(r"^?\d{4}-\d\d-\d\dT[\d:.]+Z ?")
62
71
  EXPERIMENT_LINE = re.compile(rb"^\[replay\] Experiment ([0-9a-f-]{36}):")
72
+ PROGRESS_PREFIX = b"@@bitfab:progress "
73
+ PROGRESS_PAYLOAD_FIELDS = (
74
+ "input",
75
+ "result",
76
+ "originalOutput",
77
+ "original_output",
78
+ "selectiveReplay",
79
+ "selective_replay",
80
+ )
63
81
  SECRET_REFERENCE = re.compile(
64
82
  r"[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?[ \t]*:[ \t]*[\"']?\$\{\{\s*secrets(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*[\"']([A-Za-z_][A-Za-z0-9_]*)[\"']\s*\])\s*\}\}"
65
83
  )
@@ -70,15 +88,21 @@ ENVIRONMENT_SETTING = re.compile(
70
88
  PUSH_TRIGGER = re.compile(
71
89
  r"^[ \t]*\"?(?:on\"?[ \t]*:.*\bpush\b|push\"?[ \t]*:)", re.MULTILINE
72
90
  )
73
- DECLARED_INPUTS = re.compile(r"^[ \t]*\"?execution_id\"?[ \t]*:", re.MULTILINE)
91
+ DISPATCH_INPUTS = ("execution_id", "request")
92
+ BRANCH_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]*")
74
93
  FOLLOW_FLAGS = (
75
94
  "--cloud-status",
76
95
  "--cloud-watch",
77
96
  "--cloud-cancel",
78
97
  "--cloud-cleanup",
79
98
  )
80
- CLOUD_VALUE_FLAGS = (*FOLLOW_FLAGS, "--cloud-request-id", "--cloud-timeout")
81
- CLOUD_SWITCHES = ("--cloud", "--cloud-preview", "--cloud-detach")
99
+ CLOUD_VALUE_FLAGS = (
100
+ *FOLLOW_FLAGS,
101
+ "--cloud-request-id",
102
+ "--cloud-timeout",
103
+ "--cloud-ref",
104
+ )
105
+ CLOUD_SWITCHES = ("--cloud", "--cloud-preview", "--cloud-detach", "--cloud-full-output")
82
106
  RENAMED_FLAGS = {
83
107
  "--cloud-dry-run": "--cloud-preview",
84
108
  "--cloud-check": "--dry-run",
@@ -114,11 +138,36 @@ does not ignore) from the same directory, and this command prints the replay's o
114
138
  and exits with its exit code. Credential-like files are refused. Requires git and
115
139
  Python 3.10+ on macOS or Linux, with a github.com origin.
116
140
 
141
+ The replay's stderr comes back as it printed it. Its stdout result comes back as a
142
+ short summary instead of every item: the experiment ID and URL, how many traces were
143
+ replayed, same, changed, and errored, each errored trace with its error, and the exit
144
+ code. Every item's input and output are already saved in the experiment in Bitfab.
145
+
117
146
  Options added by --cloud:
118
147
  --cloud-preview List what the snapshot would contain and push nothing.
119
148
  --cloud-detach Return after dispatch instead of waiting.
149
+ --cloud-full-output Print the replay's whole stdout result, every item included,
150
+ instead of the summary. Large runs can exceed what the
151
+ GitHub job log holds.
120
152
  --cloud-timeout MIN Stop the replay after MIN minutes (1..7200).
121
153
  --cloud-request-id ID Recover a submission whose response was lost.
154
+ --cloud-ref BRANCH Replay BRANCH as it is on GitHub, with no snapshot of your
155
+ working tree, and record the experiment on BRANCH. Meant for
156
+ CI jobs, such as a nightly replay of dev. Needs only GitHub
157
+ access that can run Actions, and a workflow on BRANCH that
158
+ declares a request input.
159
+
160
+ A workflow can also dispatch bitfab-replay.yml itself on any branch other than a
161
+ bitfab-replay/ snapshot branch, passing the replay as JSON in the request input:
162
+
163
+ {"args": ["--registry", "scripts/replay.ts", "classify", "--dataset-ids", "UUID"],
164
+ "cwd": "web", "timeoutMinutes": 60}
165
+
166
+ args are the replay options without --cloud. cwd is the directory to replay from,
167
+ relative to the repository root, and defaults to the root. timeoutMinutes and
168
+ fullOutput (true) match --cloud-timeout and --cloud-full-output. version and id are
169
+ optional: version names the request format (3 now, 2 still accepted), and id is the
170
+ execution UUID, which must match an execution_id input when the workflow has one.
122
171
 
123
172
  Follow a replay by the execution UUID it prints:
124
173
  --cloud-watch ID | --cloud-status ID | --cloud-cancel ID | --cloud-cleanup ID
@@ -586,6 +635,16 @@ def parse(argv):
586
635
  timeout.isdigit() and 1 <= int(timeout) <= 7200
587
636
  ):
588
637
  raise ValueError("--cloud-timeout must be 1..7200 minutes")
638
+ branch = cloud.get("--cloud-ref")
639
+ if branch is not None and (
640
+ not BRANCH_NAME.fullmatch(branch)
641
+ or branch.startswith(PREFIX)
642
+ or ".." in branch
643
+ or branch.endswith((".", "/", ".lock"))
644
+ ):
645
+ raise ValueError(
646
+ f"--cloud-ref takes a branch name such as dev, other than a {PREFIX} snapshot branch"
647
+ )
589
648
  operation = "submit"
590
649
  execution_id = cloud.get("--cloud-request-id") or str(uuid.uuid4())
591
650
  if not UUID.fullmatch(execution_id):
@@ -648,6 +707,8 @@ def replay_request(root, parsed):
648
707
  }
649
708
  if "--cloud-timeout" in parsed["cloud"]:
650
709
  request["timeoutMinutes"] = int(parsed["cloud"]["--cloud-timeout"])
710
+ if "--cloud-full-output" in parsed["switches"]:
711
+ request["fullOutput"] = True
651
712
  return request, files
652
713
 
653
714
 
@@ -770,6 +831,8 @@ def execution_lock(directory, execution_id):
770
831
 
771
832
 
772
833
  def find_run(record):
834
+ if record.get("snapshot") is False:
835
+ return find_branch_run(record)
773
836
  query = urlencode(
774
837
  {"event": "workflow_dispatch", "branch": record["branch"], "per_page": 100}
775
838
  )
@@ -788,6 +851,31 @@ def find_run(record):
788
851
  return matches[0] if matches else None
789
852
 
790
853
 
854
+ def find_branch_run(record):
855
+ query = urlencode(
856
+ {
857
+ "event": "workflow_dispatch",
858
+ "branch": record["branch"],
859
+ "created": ">=" + record["dispatchedAt"],
860
+ "per_page": 100,
861
+ }
862
+ )
863
+ runs = api(
864
+ record["repository"], f"actions/workflows/{record['workflow']}/runs?{query}"
865
+ )
866
+ matches = [
867
+ run
868
+ for run in runs["workflow_runs"]
869
+ if run["head_branch"] == record["branch"]
870
+ and run.get("created_at", "") >= record["dispatchedAt"]
871
+ ]
872
+ if len(matches) > 1:
873
+ raise ValueError(
874
+ f"Several runs were dispatched on {record['branch']} at about the same time, so this execution cannot tell which is its own; find it in the Actions tab"
875
+ )
876
+ return matches[0] if matches else None
877
+
878
+
791
879
  def status(record, *, fetch_result=True):
792
880
  if record["state"] == "prepared":
793
881
  return record
@@ -799,6 +887,8 @@ def status(record, *, fetch_result=True):
799
887
  if run is None:
800
888
  record["state"] = "dispatch_unknown"
801
889
  return record
890
+ if record.get("snapshot") is False and run["head_branch"] == record["branch"]:
891
+ record["sha"] = run["head_sha"]
802
892
  if run["head_sha"] != record["sha"] or run["head_branch"] != record["branch"]:
803
893
  raise ValueError("GitHub run does not match the recorded execution")
804
894
  record.update(
@@ -871,9 +961,7 @@ def outcome(record):
871
961
  raise ValueError("Replay result does not match this execution")
872
962
  record["exitCode"] = result["exitCode"]
873
963
  parsed = last_json(result.get("stdout", "")) or {}
874
- test_run = result.get("testRunId") or parsed.get(
875
- "testRunId", parsed.get("test_run_id")
876
- )
964
+ test_run = result.get("testRunId") or experiment_fields(parsed).get("testRunId")
877
965
  if isinstance(test_run, str) and UUID.fullmatch(test_run):
878
966
  record["testRunId"] = test_run
879
967
  if result.get("stoppedEarly"):
@@ -903,6 +991,17 @@ def print_outcome(found):
903
991
  f"The replay also printed {result['stdoutOmittedBytes'] // (1024 * 1024)} MiB to stdout before its result, such as a library's logging; only the end was kept"
904
992
  )
905
993
  print(result.get("stdout", ""), end="", flush=True)
994
+ summary = last_json(result.get("stdout", "")) or {}
995
+ if summary.get("cloudSummary") is True:
996
+ progress(
997
+ "Printed a summary of the result; every item's input and output are in the experiment"
998
+ + (
999
+ f" at {summary['experimentUrl']}"
1000
+ if summary.get("experimentUrl")
1001
+ else ""
1002
+ )
1003
+ + ". Add --cloud-full-output to print every item here instead"
1004
+ )
906
1005
  if record.get("stoppedEarly"):
907
1006
  progress(
908
1007
  f"Replay stopped early: {record['stoppedEarly']}. Traces that finished are saved"
@@ -919,6 +1018,9 @@ def print_outcome(found):
919
1018
  def cleanup(root, record):
920
1019
  if record.get("cleaned"):
921
1020
  return
1021
+ if record.get("snapshot") is False:
1022
+ record["cleaned"] = True
1023
+ return
922
1024
  if record.get("state") not in ("completed", "prepared"):
923
1025
  raise ValueError(
924
1026
  "Cleanup requires a confirmed completed GitHub run; cancel and wait first"
@@ -947,7 +1049,7 @@ def http_status(error):
947
1049
  return f" (HTTP {status})" if status else ""
948
1050
 
949
1051
 
950
- def preflight(repo, workflow):
1052
+ def preflight(repo, workflow, *, pushes=True):
951
1053
  access = github_access()
952
1054
  try:
953
1055
  details = api(repo, "")
@@ -955,7 +1057,7 @@ def preflight(repo, workflow):
955
1057
  raise ValueError(
956
1058
  f"GitHub access from {access['source']} cannot read {repo}{http_status(error)}; use an account or token with write access to it"
957
1059
  ) from error
958
- if (details or {}).get("permissions", {}).get("push") is False:
1060
+ if pushes and (details or {}).get("permissions", {}).get("push") is False:
959
1061
  raise ValueError(
960
1062
  f"GitHub access from {access['source']} cannot push to {repo}; use an account or token with write access to it"
961
1063
  )
@@ -1036,21 +1138,43 @@ def submit(root, repo, workflow, parsed, path):
1036
1138
  ) from error
1037
1139
  record["state"] = "dispatch_unknown"
1038
1140
  save(path, record)
1039
- payload = {"ref": record["branch"]}
1040
- if DECLARED_INPUTS.search(
1141
+ declared = declared_inputs(
1041
1142
  within(root, f"{WORKFLOW_DIRECTORY}/{workflow}").read_text()
1042
- ):
1043
- payload["inputs"] = {"execution_id": execution_id, "request": "-"}
1143
+ )
1144
+ return dispatch(record, {"execution_id": execution_id, "request": "-"}, declared)
1145
+
1146
+
1147
+ def declared_inputs(text):
1148
+ return {
1149
+ name
1150
+ for name in DISPATCH_INPUTS
1151
+ if re.search(rf"^[ \t]*\"?{name}\"?[ \t]*:", text, re.MULTILINE)
1152
+ }
1153
+
1154
+
1155
+ def dispatch(record, inputs, declared, *, run_details=False):
1156
+ payload = {"ref": record["branch"]}
1157
+ sent = {name: value for name, value in inputs.items() if name in declared}
1158
+ if sent:
1159
+ payload["inputs"] = sent
1160
+ path = f"actions/workflows/{record['workflow']}/dispatches"
1044
1161
  try:
1045
- response = api(
1046
- repo,
1047
- f"actions/workflows/{workflow}/dispatches",
1048
- method="POST",
1049
- payload=payload,
1050
- )
1162
+ try:
1163
+ response = api(
1164
+ record["repository"],
1165
+ path,
1166
+ method="POST",
1167
+ payload={**payload, "return_run_details": True}
1168
+ if run_details
1169
+ else payload,
1170
+ )
1171
+ except RuntimeError as error:
1172
+ if not run_details or getattr(error, "http_status", None) != 422:
1173
+ raise
1174
+ response = api(record["repository"], path, method="POST", payload=payload)
1051
1175
  except RuntimeError as error:
1052
1176
  raise ValueError(
1053
- f"Dispatching {workflow} on {record['branch']} failed{http_status(error)}; the GitHub account needs write access to Actions, and the registered workflow must accept workflow_dispatch. Check the Actions tab, then resume with --cloud-watch {execution_id}"
1177
+ f"Dispatching {record['workflow']} on {record['branch']} failed{http_status(error)}; the GitHub account needs write access to Actions, and the registered workflow must accept workflow_dispatch. Check the Actions tab, then resume with --cloud-watch {record['id']}"
1054
1178
  ) from error
1055
1179
  if isinstance(response, dict) and response.get("workflow_run_id"):
1056
1180
  record["runId"] = response["workflow_run_id"]
@@ -1059,6 +1183,84 @@ def submit(root, repo, workflow, parsed, path):
1059
1183
  return record
1060
1184
 
1061
1185
 
1186
+ def branch_head(repo, branch):
1187
+ try:
1188
+ found = api(repo, f"git/ref/heads/{branch}")
1189
+ except RuntimeError as error:
1190
+ if getattr(error, "http_status", None) == 404:
1191
+ raise ValueError(f"{repo} has no branch {branch}") from error
1192
+ raise
1193
+ sha = ((found or {}).get("object") or {}).get("sha", "")
1194
+ if not SHA.fullmatch(sha):
1195
+ raise ValueError(f"GitHub gave no commit for branch {branch}")
1196
+ return sha
1197
+
1198
+
1199
+ def branch_workflow(repo, workflow, sha):
1200
+ path = f"{WORKFLOW_DIRECTORY}/{workflow}"
1201
+ try:
1202
+ found = api(repo, f"contents/{path}?ref={sha}")
1203
+ except RuntimeError as error:
1204
+ raise ValueError(
1205
+ f"Could not read {path} at {sha[:12]}{http_status(error)}; the branch needs the workflow"
1206
+ ) from error
1207
+ return base64.b64decode((found or {}).get("content", "")).decode(errors="replace")
1208
+
1209
+
1210
+ def submit_as_is(root, repo, workflow, parsed, path):
1211
+ execution_id = parsed["id"]
1212
+ branch = parsed["cloud"]["--cloud-ref"]
1213
+ request, _ = replay_request(root, parsed)
1214
+ sha = branch_head(repo, branch)
1215
+ if "--cloud-preview" in parsed["switches"]:
1216
+ return {
1217
+ "preview": True,
1218
+ "repository": repo,
1219
+ "branch": branch,
1220
+ "sha": sha,
1221
+ "request": request,
1222
+ }
1223
+ if path.exists():
1224
+ record = json.loads(path.read_text())
1225
+ if (
1226
+ record["request"] != request
1227
+ or record["repository"].lower() != repo.lower()
1228
+ or record["branch"] != branch
1229
+ ):
1230
+ raise ValueError("Execution ID already belongs to a different request")
1231
+ status(record, fetch_result=False)
1232
+ return record
1233
+ preflight(repo, workflow, pushes=False)
1234
+ declared = declared_inputs(branch_workflow(repo, workflow, sha))
1235
+ if "request" not in declared:
1236
+ raise ValueError(
1237
+ f"{WORKFLOW_DIRECTORY}/{workflow} on {branch} declares no request input, so it cannot take a replay; add request (type: string, required: false) under on.workflow_dispatch.inputs and land it on {branch}"
1238
+ )
1239
+ record = {
1240
+ "id": execution_id,
1241
+ "repository": repo,
1242
+ "workflow": workflow,
1243
+ "branch": branch,
1244
+ "snapshot": False,
1245
+ "request": request,
1246
+ "state": "dispatch_unknown",
1247
+ "sha": sha,
1248
+ "dispatchedAt": time.strftime(
1249
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 5)
1250
+ ),
1251
+ }
1252
+ save(path, record)
1253
+ progress(
1254
+ f"Replaying {branch} at {sha[:12]} as it is on GitHub, without local changes. Execution {execution_id}; resume with --cloud-watch {execution_id}"
1255
+ )
1256
+ return dispatch(
1257
+ record,
1258
+ {"execution_id": execution_id, "request": json.dumps(request)},
1259
+ declared,
1260
+ run_details=True,
1261
+ )
1262
+
1263
+
1062
1264
  def report_steps(record, shown):
1063
1265
  job = replay_job(record)
1064
1266
  for step in (job or {}).get("steps", []):
@@ -1104,7 +1306,8 @@ def run_cli(argv):
1104
1306
  path = directory / f"{execution_id}.json"
1105
1307
  with execution_lock(directory, execution_id):
1106
1308
  if operation == "submit":
1107
- record = submit(root, repo, workflow, parsed, path)
1309
+ submitter = submit_as_is if "--cloud-ref" in parsed["cloud"] else submit
1310
+ record = submitter(root, repo, workflow, parsed, path)
1108
1311
  if record.get("preview"):
1109
1312
  return record, None
1110
1313
  else:
@@ -1180,14 +1383,86 @@ def snapshot_request(root, commit):
1180
1383
  return request
1181
1384
 
1182
1385
 
1386
+ def dispatched_inputs():
1387
+ path = os.environ.get("GITHUB_EVENT_PATH")
1388
+ if not path or not Path(path).is_file():
1389
+ return {}
1390
+ inputs = json.loads(Path(path).read_text()).get("inputs")
1391
+ return inputs if isinstance(inputs, dict) else {}
1392
+
1393
+
1394
+ def decode_request(text):
1395
+ try:
1396
+ return json.loads(text)
1397
+ except ValueError:
1398
+ pass
1399
+ try:
1400
+ return json.loads(base64.b64decode(text, validate=True))
1401
+ except ValueError as error:
1402
+ raise ValueError(
1403
+ "The request input is neither JSON nor base64-encoded JSON"
1404
+ ) from error
1405
+
1406
+
1407
+ def dispatched_request():
1408
+ branch = os.environ.get("GITHUB_REF_NAME", "")
1409
+ inputs = dispatched_inputs()
1410
+ text = str(inputs.get("request") or "").strip()
1411
+ if text in ("", "-"):
1412
+ raise ValueError(
1413
+ f"A replay of {branch} takes its request from the workflow_dispatch request input, and this run has none; start replays with bitfab-replay --cloud, or --cloud --cloud-ref {branch} to replay the branch as it is"
1414
+ )
1415
+ request = decode_request(text)
1416
+ if not isinstance(request, dict):
1417
+ raise ValueError("The request input must be a JSON object")
1418
+ version = request.get("version", REQUEST_VERSION)
1419
+ if not isinstance(version, int) or version < OLDEST_DISPATCHED_VERSION:
1420
+ raise ValueError(
1421
+ f"Request version {version!r} is not supported; send version {REQUEST_VERSION}, or leave version out"
1422
+ )
1423
+ if version > REQUEST_VERSION:
1424
+ raise ValueError(
1425
+ f"This replay was submitted by a newer SDK than the one {branch} installs; the request is version {version} and this SDK reads up to {REQUEST_VERSION}"
1426
+ )
1427
+ execution_id = request.get("id") or inputs.get("execution_id") or str(uuid.uuid4())
1428
+ if not isinstance(execution_id, str) or not UUID.fullmatch(execution_id):
1429
+ raise ValueError("The request id must be a UUID")
1430
+ if inputs.get("execution_id") and inputs["execution_id"] != execution_id:
1431
+ raise ValueError("The request id does not match the execution_id input")
1432
+ args = request.get("args")
1433
+ validate_arguments(args)
1434
+ if request.get("check") is True and "--dry-run" not in args:
1435
+ args = [*args, "--dry-run"]
1436
+ normalized = {
1437
+ "version": REQUEST_VERSION,
1438
+ "id": execution_id,
1439
+ "cwd": request.get("cwd") or ".",
1440
+ "args": args,
1441
+ }
1442
+ timeout = request.get("timeoutMinutes")
1443
+ if timeout is not None:
1444
+ if not isinstance(timeout, int) or not 1 <= timeout <= 7200:
1445
+ raise ValueError("timeoutMinutes must be 1..7200")
1446
+ normalized["timeoutMinutes"] = timeout
1447
+ if request.get("fullOutput") is True:
1448
+ normalized["fullOutput"] = True
1449
+ return normalized
1450
+
1451
+
1452
+ def runner_request(root, commit):
1453
+ if os.environ.get("GITHUB_REF_NAME", "").startswith(PREFIX):
1454
+ return snapshot_request(root, commit)
1455
+ return dispatched_request()
1456
+
1457
+
1183
1458
  def execute():
1184
1459
  if os.environ.get("GITHUB_RUN_ATTEMPT") != "1":
1185
1460
  raise ValueError("Submit a new replay instead of rerunning an Actions job")
1186
1461
  root = root_directory()
1187
1462
  commit = os.environ["GITHUB_SHA"]
1188
1463
  if git(root, "rev-parse", "HEAD") != commit:
1189
- raise ValueError("Runner checkout does not match the dispatched snapshot SHA")
1190
- request = snapshot_request(root, commit)
1464
+ raise ValueError("Runner checkout does not match the dispatched commit")
1465
+ request = runner_request(root, commit)
1191
1466
  directory = within(root, request["cwd"])
1192
1467
  check_secrets(root)
1193
1468
  names = {option_name(value) for value in request["args"]}
@@ -1203,7 +1478,11 @@ def execute():
1203
1478
  print(OUTPUT_BEGIN, flush=True)
1204
1479
  try:
1205
1480
  code, stdout, omitted, stopped = run_command(
1206
- directory, args, request.get("timeoutMinutes"), experiment
1481
+ directory,
1482
+ args,
1483
+ request.get("timeoutMinutes"),
1484
+ experiment,
1485
+ summarize=request.get("fullOutput") is not True,
1207
1486
  )
1208
1487
  finally:
1209
1488
  print(OUTPUT_END, flush=True)
@@ -1286,10 +1565,75 @@ def item_errors(items):
1286
1565
  text = " ".join(str(error).split())
1287
1566
  if len(text) > ITEM_ERROR_LENGTH:
1288
1567
  text = text[:ITEM_ERROR_LENGTH] + "..."
1289
- errors.append((trace, text))
1568
+ errored = {"originalTraceId": trace, "error": text}
1569
+ if isinstance(item.get("attempt"), int):
1570
+ errored["attempt"] = item["attempt"]
1571
+ errors.append(errored)
1290
1572
  return errors
1291
1573
 
1292
1574
 
1575
+ def item_field(item, camel, snake):
1576
+ return item[camel] if camel in item else item.get(snake)
1577
+
1578
+
1579
+ def canonical(value):
1580
+ return json.dumps(value, separators=(",", ":"))
1581
+
1582
+
1583
+ def experiment_fields(parsed):
1584
+ fields = {}
1585
+ for name, spellings in EXPERIMENT_FIELDS.items():
1586
+ value = next(
1587
+ (parsed[key] for key in spellings if parsed.get(key) is not None), None
1588
+ )
1589
+ if value is not None:
1590
+ fields[name] = value
1591
+ return fields
1592
+
1593
+
1594
+ def replay_summary(parsed):
1595
+ items = [item for item in parsed["items"] if isinstance(item, dict)]
1596
+ replayed = [item for item in items if not carried_over(item)]
1597
+ errors = item_errors(replayed)
1598
+ counts = {
1599
+ "replayed": len(replayed),
1600
+ "carriedOver": len(items) - len(replayed),
1601
+ "same": 0,
1602
+ "changed": 0,
1603
+ "matchedExpected": 0,
1604
+ "missedExpected": 0,
1605
+ "errored": len(errors),
1606
+ }
1607
+ for item in replayed:
1608
+ if item_errored(item):
1609
+ continue
1610
+ equal = canonical(item.get("result")) == canonical(
1611
+ item_field(item, "originalOutput", "original_output")
1612
+ )
1613
+ if item_field(item, "ingestionType", "ingestion_type") == "seeded":
1614
+ counts["matchedExpected" if equal else "missedExpected"] += 1
1615
+ else:
1616
+ counts["same" if equal else "changed"] += 1
1617
+ summary = {
1618
+ "cloudSummary": True,
1619
+ **experiment_fields(parsed),
1620
+ "counts": counts,
1621
+ "erroredItems": errors[:SUMMARY_ERROR_ITEMS],
1622
+ }
1623
+ if len(errors) > SUMMARY_ERROR_ITEMS:
1624
+ summary["erroredItemsOmitted"] = len(errors) - SUMMARY_ERROR_ITEMS
1625
+ return summary
1626
+
1627
+
1628
+ def result_summary(stdout):
1629
+ parsed = last_json(stdout) or {}
1630
+ if parsed.get("cloudSummary") is True:
1631
+ return parsed
1632
+ if isinstance(parsed.get("items"), list):
1633
+ return replay_summary(parsed)
1634
+ return None
1635
+
1636
+
1293
1637
  def item_errored(item):
1294
1638
  return isinstance(item, dict) and any(
1295
1639
  item.get(field) is not None for field in ITEM_ERROR_FIELDS
@@ -1306,8 +1650,24 @@ def raise_interrupt(signum, frame):
1306
1650
  raise KeyboardInterrupt
1307
1651
 
1308
1652
 
1309
- def forward_stderr(stream, experiment):
1653
+ def without_item_payload(line):
1654
+ try:
1655
+ event = json.loads(line[len(PROGRESS_PREFIX) :])
1656
+ except ValueError:
1657
+ return line
1658
+ item = event.get("item") if isinstance(event, dict) else None
1659
+ if not isinstance(item, dict):
1660
+ return line
1661
+ event["item"] = {
1662
+ key: value for key, value in item.items() if key not in PROGRESS_PAYLOAD_FIELDS
1663
+ }
1664
+ return PROGRESS_PREFIX + json.dumps(event).encode() + b"\n"
1665
+
1666
+
1667
+ def forward_stderr(stream, experiment, summarize):
1310
1668
  for line in iter(stream.readline, b""):
1669
+ if summarize and line.startswith(PROGRESS_PREFIX):
1670
+ line = without_item_payload(line)
1311
1671
  sys.stdout.write(line.decode(errors="replace"))
1312
1672
  sys.stdout.flush()
1313
1673
  if "id" not in experiment:
@@ -1333,6 +1693,15 @@ def last_json(text):
1333
1693
  return trailing_json(text)[1]
1334
1694
 
1335
1695
 
1696
+ def relayed_stdout(output, code, summarize):
1697
+ if summarize:
1698
+ output.seek(0)
1699
+ parsed = last_json(output.read().decode(errors="replace"))
1700
+ if isinstance(parsed, dict) and isinstance(parsed.get("items"), list):
1701
+ return json.dumps({**replay_summary(parsed), "exitCode": code}) + "\n", 0
1702
+ return carried_stdout(output)
1703
+
1704
+
1336
1705
  def carried_stdout(output):
1337
1706
  start = max(0, os.fstat(output.fileno()).st_size - OUTPUT_LIMIT)
1338
1707
  output.seek(start)
@@ -1365,34 +1734,34 @@ def annotate(result):
1365
1734
 
1366
1735
 
1367
1736
  def summary_markdown(result):
1368
- parsed = last_json(result["stdout"]) or {}
1369
- test_run = result.get("testRunId") or parsed.get(
1370
- "testRunId", parsed.get("test_run_id")
1371
- )
1372
- items = parsed.get("items") if isinstance(parsed.get("items"), list) else []
1373
- replayed = [item for item in items if not carried_over(item)]
1374
- errors = item_errors(replayed)
1737
+ summary = result_summary(result["stdout"]) or {}
1738
+ counts = summary.get("counts", {})
1739
+ test_run = result.get("testRunId") or summary.get("testRunId")
1740
+ errors = summary.get("erroredItems", [])
1375
1741
  lines = [f"Commit: `{result['commitSha']}`", f"Exit code: {result['exitCode']}"]
1376
1742
  if test_run:
1377
1743
  lines.insert(0, f"Test run: `{test_run}`")
1378
1744
  if result.get("stoppedEarly"):
1379
1745
  lines.append(f"Stopped early: {result['stoppedEarly']}")
1380
- elif items:
1381
- lines.append(f"Replayed: {len(replayed)}, errored: {len(errors)}")
1746
+ elif counts.get("replayed") or counts.get("carriedOver"):
1747
+ lines.append(
1748
+ f"Replayed: {counts['replayed']}, same: {counts['same']}, changed: {counts['changed']}, errored: {counts['errored']}"
1749
+ )
1382
1750
  text = "### Bitfab replay\n\n" + "\n\n".join(lines) + "\n"
1383
1751
  if errors:
1384
1752
  shown = [
1385
- f"trace {trace}: {error}" for trace, error in errors[:ITEM_ERROR_LINES]
1753
+ f"trace {error['originalTraceId']}: {error['error']}"
1754
+ for error in errors[:ITEM_ERROR_LINES]
1386
1755
  ]
1387
- if len(errors) > len(shown):
1388
- shown.append(f"and {len(errors) - len(shown)} more errored items")
1756
+ if counts.get("errored", 0) > len(shown):
1757
+ shown.append(f"and {counts['errored'] - len(shown)} more errored items")
1389
1758
  text += "\n#### Errored items\n\n" + "".join(
1390
1759
  f"- `{line.replace('`', chr(39))}`\n" for line in shown
1391
1760
  )
1392
1761
  return text
1393
1762
 
1394
1763
 
1395
- def run_command(directory, args, timeout, experiment):
1764
+ def run_command(directory, args, timeout, experiment, *, summarize=True):
1396
1765
  stopped = None
1397
1766
  with tempfile.TemporaryFile() as output:
1398
1767
  with subprocess.Popen(
@@ -1404,7 +1773,9 @@ def run_command(directory, args, timeout, experiment):
1404
1773
  start_new_session=True,
1405
1774
  ) as child:
1406
1775
  reader = threading.Thread(
1407
- target=forward_stderr, args=(child.stderr, experiment), daemon=True
1776
+ target=forward_stderr,
1777
+ args=(child.stderr, experiment, summarize),
1778
+ daemon=True,
1408
1779
  )
1409
1780
  reader.start()
1410
1781
  try:
@@ -1431,9 +1802,9 @@ def run_command(directory, args, timeout, experiment):
1431
1802
  raise
1432
1803
  finally:
1433
1804
  reader.join(timeout=5)
1434
- code = child.returncode
1435
- text, omitted = carried_stdout(output)
1436
- return (code if code >= 0 else 1), text, omitted, stopped
1805
+ code = child.returncode if child.returncode >= 0 else 1
1806
+ text, omitted = relayed_stdout(output, code, summarize)
1807
+ return code, text, omitted, stopped
1437
1808
 
1438
1809
 
1439
1810
  def stop(child):
@@ -1696,7 +2067,11 @@ def workflow_document(steps, run, directory, env, *, runs_on, environment):
1696
2067
  ]
1697
2068
  return {
1698
2069
  "name": "Bitfab replay",
1699
- "on": "workflow_dispatch",
2070
+ "on": {
2071
+ "workflow_dispatch": {
2072
+ "inputs": {"request": {"type": "string", "required": False}}
2073
+ }
2074
+ },
1700
2075
  "permissions": {"contents": "read"},
1701
2076
  "jobs": {"replay": job},
1702
2077
  }