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 +418 -43
- package/dist/{chunk-RC5FXV76.js → chunk-4IRNG6KB.js} +2 -2
- package/dist/{chunk-RC5FXV76.js.map → chunk-4IRNG6KB.js.map} +1 -1
- package/dist/{chunk-XNJ5AGAF.js → chunk-A3CCZ7Q5.js} +3 -3
- package/dist/{chunk-JIPKI6RB.js → chunk-LRY4Y7HE.js} +2 -2
- package/dist/{chunk-JIPKI6RB.js.map → chunk-LRY4Y7HE.js.map} +1 -1
- package/dist/{chunk-2YDH4SPS.js → chunk-QF7FLTXB.js} +2 -2
- package/dist/{chunk-UZU2PNO7.js → chunk-YT45ZRGM.js} +6 -6
- package/dist/{http-KBTWSYL7.js → http-6TAXWGVJ.js} +2 -2
- package/dist/{http-D5YRJ57X.js → http-OGYWTBEP.js} +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -3
- package/dist/node.cjs +1 -1
- package/dist/node.cjs.map +1 -1
- package/dist/node.js +3 -3
- package/dist/{replay-PFHS24KV.js → replay-LOAD2DLO.js} +3 -3
- package/dist/replayCli.js +2 -2
- package/dist/seedCli.js +2 -2
- package/package.json +1 -1
- /package/dist/{chunk-XNJ5AGAF.js.map → chunk-A3CCZ7Q5.js.map} +0 -0
- /package/dist/{chunk-2YDH4SPS.js.map → chunk-QF7FLTXB.js.map} +0 -0
- /package/dist/{chunk-UZU2PNO7.js.map → chunk-YT45ZRGM.js.map} +0 -0
- /package/dist/{http-D5YRJ57X.js.map → http-6TAXWGVJ.js.map} +0 -0
- /package/dist/{http-KBTWSYL7.js.map → http-OGYWTBEP.js.map} +0 -0
- /package/dist/{replay-PFHS24KV.js.map → replay-LOAD2DLO.js.map} +0 -0
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
|
-
|
|
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 = (
|
|
81
|
-
|
|
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
|
-
|
|
1040
|
-
if DECLARED_INPUTS.search(
|
|
1141
|
+
declared = declared_inputs(
|
|
1041
1142
|
within(root, f"{WORKFLOW_DIRECTORY}/{workflow}").read_text()
|
|
1042
|
-
)
|
|
1043
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
1190
|
-
request =
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
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
|
|
1381
|
-
lines.append(
|
|
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 {
|
|
1753
|
+
f"trace {error['originalTraceId']}: {error['error']}"
|
|
1754
|
+
for error in errors[:ITEM_ERROR_LINES]
|
|
1386
1755
|
]
|
|
1387
|
-
if
|
|
1388
|
-
shown.append(f"and {
|
|
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,
|
|
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 =
|
|
1436
|
-
return
|
|
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":
|
|
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
|
}
|