Fix bidirectional RF-001A coordination
This commit is contained in:
@@ -14,8 +14,9 @@ from typing import Any, Mapping
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_002"
|
||||
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_002"
|
||||
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_003"
|
||||
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_003"
|
||||
CHANNEL_TEST_TASK_ID = "RF_HANDOFF_CHANNEL_TEST_001"
|
||||
READY_STATE = "RX_READY_FOR_TX"
|
||||
COMPLETE_STATE = "CAPTURE_COMPLETE"
|
||||
RESULT_JSON_PATH = "docs/handoff/results/RX_RESULT.json"
|
||||
@@ -72,6 +73,7 @@ class ReadyEvaluation:
|
||||
class PublishOutcome:
|
||||
status: str
|
||||
commit: str
|
||||
remote_head: str
|
||||
json_path: str = RESULT_JSON_PATH
|
||||
markdown_path: str = RESULT_MARKDOWN_PATH
|
||||
|
||||
@@ -172,9 +174,13 @@ def _validate_ready(result: RxHandoffResult) -> None:
|
||||
result.rx_ready_for_tx
|
||||
and result.process_alive
|
||||
and result.file_growing
|
||||
and result.common_clock_verified
|
||||
):
|
||||
raise ValueError("READY requires a live capture and verified common clock")
|
||||
raise ValueError("READY requires a live process and growing capture")
|
||||
if result.task_id == CHANNEL_TEST_TASK_ID:
|
||||
if result.common_clock_verified:
|
||||
raise ValueError("channel smoke READY must not claim hardware clock proof")
|
||||
elif not result.common_clock_verified:
|
||||
raise ValueError("production READY requires verified common clock")
|
||||
if result.capture_pid is None or result.capture_pid <= 0:
|
||||
raise ValueError("READY requires a positive capture_pid")
|
||||
final_fields = (
|
||||
@@ -289,15 +295,22 @@ def evaluate_ready(
|
||||
*,
|
||||
now: datetime,
|
||||
expected_task_id: str = EXPECTED_RX_TASK_ID,
|
||||
expected_source_head: str | None = None,
|
||||
max_age_seconds: float = 30.0,
|
||||
min_remaining_seconds: float = 10.0,
|
||||
) -> ReadyEvaluation:
|
||||
validate_result(result)
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must contain a timezone")
|
||||
now_utc = now.astimezone(timezone.utc)
|
||||
if result.task_id != expected_task_id:
|
||||
return ReadyEvaluation(False, "WRONG_TASK_ID")
|
||||
if expected_source_head is not None and result.source_head != expected_source_head:
|
||||
return ReadyEvaluation(False, "WRONG_SOURCE_HEAD")
|
||||
if result.state != READY_STATE or not result.rx_ready_for_tx:
|
||||
return ReadyEvaluation(False, "MISSING_READY")
|
||||
if not result.common_clock_verified:
|
||||
return ReadyEvaluation(False, "COMMON_CLOCK_NOT_VERIFIED")
|
||||
started = _parse_utc(result.capture_started_at, "capture_started_at")
|
||||
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
|
||||
published = _parse_utc(result.published_at, "published_at")
|
||||
@@ -316,7 +329,7 @@ def evaluate_ready(
|
||||
|
||||
|
||||
class GitResultChannel:
|
||||
"""Read and publish the two allowlisted result files on an existing branch."""
|
||||
"""Publish through a temporary worktree of the configured main repository."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -346,99 +359,163 @@ class GitResultChannel:
|
||||
check=check,
|
||||
)
|
||||
|
||||
def branch_exists(self) -> bool:
|
||||
def remote_head(self) -> str | None:
|
||||
result = self._run(
|
||||
["ls-remote", "--exit-code", "--heads", self.remote, self.branch],
|
||||
["ls-remote", "--heads", self.remote, self.branch],
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0 and bool(result.stdout.strip())
|
||||
|
||||
def _require_branch(self) -> None:
|
||||
if not self.branch_exists():
|
||||
raise ResultBranchMissing(
|
||||
f"remote branch {self.remote}/{self.branch} does not exist; "
|
||||
"separate authorization is required before creating it"
|
||||
if result.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"git ls-remote failed for {self.remote}/{self.branch}: "
|
||||
f"{result.stderr.strip()}"
|
||||
)
|
||||
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return None
|
||||
if len(lines) != 1:
|
||||
raise ResultChannelError("remote result branch lookup is ambiguous")
|
||||
head, reference = lines[0].split(maxsplit=1)
|
||||
if reference != f"refs/heads/{self.branch}" or not _HEAD_RE.fullmatch(head):
|
||||
raise ResultChannelError("remote result branch returned an invalid HEAD")
|
||||
return head
|
||||
|
||||
def fetch(self) -> None:
|
||||
self._require_branch()
|
||||
self._run(
|
||||
[
|
||||
"fetch",
|
||||
"--quiet",
|
||||
self.remote,
|
||||
f"refs/heads/{self.branch}:refs/remotes/{self.remote}/{self.branch}",
|
||||
]
|
||||
def branch_exists(self) -> bool:
|
||||
return self.remote_head() is not None
|
||||
|
||||
def fetch(self) -> str:
|
||||
initial_fetch = self._run(
|
||||
["fetch", "--quiet", self.remote, self.branch],
|
||||
check=False,
|
||||
)
|
||||
remote_head = self.remote_head()
|
||||
if remote_head is None:
|
||||
raise ResultBranchMissing(
|
||||
f"remote branch {self.remote}/{self.branch} does not exist"
|
||||
)
|
||||
if initial_fetch.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"git fetch failed for {self.remote}/{self.branch}: "
|
||||
f"{initial_fetch.stderr.strip()}"
|
||||
)
|
||||
tracking_reference = (
|
||||
f"refs/heads/{self.branch}:"
|
||||
f"refs/remotes/{self.remote}/{self.branch}"
|
||||
)
|
||||
tracking_fetch = self._run(
|
||||
["fetch", "--quiet", self.remote, tracking_reference],
|
||||
check=False,
|
||||
)
|
||||
if tracking_fetch.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"tracking fetch failed for {self.remote}/{self.branch}: "
|
||||
f"{tracking_fetch.stderr.strip()}"
|
||||
)
|
||||
tracking_head = self._run(
|
||||
["rev-parse", f"refs/remotes/{self.remote}/{self.branch}"]
|
||||
).stdout.strip()
|
||||
if tracking_head != remote_head:
|
||||
raise ResultChannelError("remote-tracking result branch is stale")
|
||||
return remote_head
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||
self.fetch()
|
||||
remote_head = self.fetch()
|
||||
reference = f"refs/remotes/{self.remote}/{self.branch}"
|
||||
json_result = self._run(["show", f"{reference}:{RESULT_JSON_PATH}"])
|
||||
raw = json.loads(json_result.stdout)
|
||||
if not isinstance(raw, dict):
|
||||
raise ResultChannelError("remote result JSON root is not an object")
|
||||
result = result_from_mapping(raw)
|
||||
head = self._run(["rev-parse", reference]).stdout.strip()
|
||||
return result, result_digest(result), head
|
||||
observed_head = self._run(["rev-parse", reference]).stdout.strip()
|
||||
if observed_head != remote_head:
|
||||
raise ResultChannelError("result commit differs from remote HEAD")
|
||||
return result, result_digest(result), observed_head
|
||||
|
||||
def publish(self, result: RxHandoffResult) -> PublishOutcome:
|
||||
validate_result(result)
|
||||
self._require_branch()
|
||||
remote_url = self._run(["remote", "get-url", self.remote]).stdout.strip()
|
||||
with tempfile.TemporaryDirectory(prefix="rx-result-publish-") as directory:
|
||||
checkout = Path(directory) / "result-branch"
|
||||
self._run(
|
||||
[
|
||||
"clone",
|
||||
"--quiet",
|
||||
"--single-branch",
|
||||
"--branch",
|
||||
self.branch,
|
||||
remote_url,
|
||||
str(checkout),
|
||||
]
|
||||
)
|
||||
json_path = checkout / RESULT_JSON_PATH
|
||||
markdown_path = checkout / RESULT_MARKDOWN_PATH
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_text = canonical_result_json(result)
|
||||
markdown_text = render_result_markdown(result)
|
||||
if len(json_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("result JSON exceeds the size limit")
|
||||
if len(markdown_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("result Markdown exceeds the size limit")
|
||||
json_path.write_text(json_text, encoding="utf-8", newline="\n")
|
||||
markdown_path.write_text(markdown_text, encoding="utf-8", newline="\n")
|
||||
self._run(["add", "--", *sorted(RESULT_PATHS)], cwd=checkout)
|
||||
staged_text = self._run(
|
||||
["diff", "--cached", "--name-only", "--"],
|
||||
cwd=checkout,
|
||||
).stdout
|
||||
staged = {
|
||||
line.strip().replace("\\", "/")
|
||||
for line in staged_text.splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
if not staged:
|
||||
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
|
||||
return PublishOutcome("UNCHANGED", commit)
|
||||
if staged != RESULT_PATHS:
|
||||
raise ResultChannelError(
|
||||
f"refusing unexpected staged paths: {sorted(staged)}"
|
||||
remote_before = self.fetch()
|
||||
tracking_reference = f"refs/remotes/{self.remote}/{self.branch}"
|
||||
with tempfile.TemporaryDirectory(prefix="rx-result-worktree-") as directory:
|
||||
checkout = Path(directory) / "rx-node"
|
||||
worktree_added = False
|
||||
try:
|
||||
self._run(
|
||||
["worktree", "add", "--detach", str(checkout), tracking_reference]
|
||||
)
|
||||
self._run(["config", "user.name", "RX Node"], cwd=checkout)
|
||||
self._run(
|
||||
["config", "user.email", "rx-node@local.invalid"],
|
||||
cwd=checkout,
|
||||
)
|
||||
self._run(
|
||||
["commit", "--quiet", "-m", f"Publish {result.task_id} {result.state}"],
|
||||
cwd=checkout,
|
||||
)
|
||||
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
|
||||
self._run(
|
||||
["push", "--quiet", "origin", f"HEAD:refs/heads/{self.branch}"],
|
||||
cwd=checkout,
|
||||
)
|
||||
return PublishOutcome("PUBLISHED", commit)
|
||||
worktree_added = True
|
||||
json_path = checkout / RESULT_JSON_PATH
|
||||
markdown_path = checkout / RESULT_MARKDOWN_PATH
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_text = canonical_result_json(result)
|
||||
markdown_text = render_result_markdown(result)
|
||||
if len(json_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("result JSON exceeds the size limit")
|
||||
if len(markdown_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("result Markdown exceeds the size limit")
|
||||
json_path.write_text(json_text, encoding="utf-8", newline="\n")
|
||||
markdown_path.write_text(
|
||||
markdown_text,
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
self._run(["add", "--", *sorted(RESULT_PATHS)], cwd=checkout)
|
||||
staged_text = self._run(
|
||||
["diff", "--cached", "--name-only", "--"],
|
||||
cwd=checkout,
|
||||
).stdout
|
||||
staged = {
|
||||
line.strip().replace("\\", "/")
|
||||
for line in staged_text.splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
if not staged:
|
||||
return PublishOutcome("UNCHANGED", remote_before, remote_before)
|
||||
if staged != RESULT_PATHS:
|
||||
raise ResultChannelError(
|
||||
f"refusing unexpected staged paths: {sorted(staged)}"
|
||||
)
|
||||
self._run(
|
||||
[
|
||||
"-c",
|
||||
"user.name=RX Node",
|
||||
"-c",
|
||||
"user.email=rx-node@local.invalid",
|
||||
"commit",
|
||||
"--quiet",
|
||||
"-m",
|
||||
f"Publish {result.task_id} {result.state}",
|
||||
],
|
||||
cwd=checkout,
|
||||
)
|
||||
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
|
||||
push = self._run(
|
||||
[
|
||||
"push",
|
||||
"--quiet",
|
||||
self.remote,
|
||||
f"HEAD:refs/heads/{self.branch}",
|
||||
],
|
||||
cwd=checkout,
|
||||
check=False,
|
||||
)
|
||||
if push.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"result push failed: {push.stderr.strip()}"
|
||||
)
|
||||
remote_after = self.remote_head()
|
||||
if remote_after is None or remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote result HEAD does not confirm the published commit"
|
||||
)
|
||||
return PublishOutcome("PUBLISHED", commit, remote_after)
|
||||
finally:
|
||||
if worktree_added:
|
||||
removal = self._run(
|
||||
["worktree", "remove", "--force", str(checkout)],
|
||||
check=False,
|
||||
)
|
||||
if removal.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"temporary worktree cleanup failed: "
|
||||
f"{removal.stderr.strip()}"
|
||||
)
|
||||
self._run(["worktree", "prune"], check=False)
|
||||
|
||||
Reference in New Issue
Block a user