445 lines
16 KiB
Python
445 lines
16 KiB
Python
"""Strict Git result channel for coordinated RX and MAIN PC handoff."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass, fields
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
from typing import Any, Mapping
|
|
|
|
|
|
SCHEMA_VERSION = 1
|
|
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_002"
|
|
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_002"
|
|
READY_STATE = "RX_READY_FOR_TX"
|
|
COMPLETE_STATE = "CAPTURE_COMPLETE"
|
|
RESULT_JSON_PATH = "docs/handoff/results/RX_RESULT.json"
|
|
RESULT_MARKDOWN_PATH = "docs/handoff/results/RX_RESULT.md"
|
|
RESULT_PATHS = frozenset((RESULT_JSON_PATH, RESULT_MARKDOWN_PATH))
|
|
MAX_RESULT_BYTES = 64 * 1024
|
|
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
|
|
|
|
class ResultChannelError(RuntimeError):
|
|
"""The result channel cannot be read or updated safely."""
|
|
|
|
|
|
class ResultBranchMissing(ResultChannelError):
|
|
"""The configured result branch does not exist on the remote."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RxHandoffResult:
|
|
schema_version: int
|
|
task_id: str
|
|
state: str
|
|
rx_ready_for_tx: bool
|
|
process_alive: bool
|
|
file_growing: bool
|
|
capture_pid: int | None
|
|
published_at: str
|
|
capture_started_at: str
|
|
expected_capture_end: str
|
|
capture_path: str
|
|
common_clock_verified: bool
|
|
source_head: str
|
|
rf_tx: int
|
|
exit_code: int | None
|
|
file_size: int | None
|
|
duration_seconds: float | None
|
|
overruns: int | None
|
|
usb_errors: int | None
|
|
file_errors: int | None
|
|
sha256: str | None
|
|
clkin_after: str
|
|
rf_tx_observed: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReadyEvaluation:
|
|
ready: bool
|
|
reason: str
|
|
seconds_remaining: float | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PublishOutcome:
|
|
status: str
|
|
commit: str
|
|
json_path: str = RESULT_JSON_PATH
|
|
markdown_path: str = RESULT_MARKDOWN_PATH
|
|
|
|
|
|
def _parse_utc(value: str, field_name: str) -> datetime:
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError(f"{field_name} must be an ISO-8601 timestamp") from error
|
|
if parsed.tzinfo is None:
|
|
raise ValueError(f"{field_name} must contain a timezone")
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def _validate_types(result: RxHandoffResult) -> None:
|
|
string_fields = (
|
|
result.task_id,
|
|
result.state,
|
|
result.published_at,
|
|
result.capture_started_at,
|
|
result.expected_capture_end,
|
|
result.capture_path,
|
|
result.source_head,
|
|
result.clkin_after,
|
|
result.rf_tx_observed,
|
|
)
|
|
if any(type(value) is not str for value in string_fields):
|
|
raise ValueError("string result fields must contain strings")
|
|
boolean_fields = (
|
|
result.rx_ready_for_tx,
|
|
result.process_alive,
|
|
result.file_growing,
|
|
result.common_clock_verified,
|
|
)
|
|
if any(type(value) is not bool for value in boolean_fields):
|
|
raise ValueError("boolean result fields must contain booleans")
|
|
if type(result.schema_version) is not int or type(result.rf_tx) is not int:
|
|
raise ValueError("schema_version and rf_tx must contain integers")
|
|
optional_integer_fields = (
|
|
result.capture_pid,
|
|
result.exit_code,
|
|
result.file_size,
|
|
result.overruns,
|
|
result.usb_errors,
|
|
result.file_errors,
|
|
)
|
|
if any(
|
|
value is not None and type(value) is not int
|
|
for value in optional_integer_fields
|
|
):
|
|
raise ValueError("integer result fields must contain integers or null")
|
|
if (
|
|
result.duration_seconds is not None
|
|
and (
|
|
isinstance(result.duration_seconds, bool)
|
|
or not isinstance(result.duration_seconds, (int, float))
|
|
)
|
|
):
|
|
raise ValueError("duration_seconds must contain a number or null")
|
|
if result.sha256 is not None and type(result.sha256) is not str:
|
|
raise ValueError("sha256 must contain a string or null")
|
|
|
|
|
|
def _validate_common(result: RxHandoffResult) -> None:
|
|
_validate_types(result)
|
|
if result.schema_version != SCHEMA_VERSION:
|
|
raise ValueError(f"schema_version must be {SCHEMA_VERSION}")
|
|
if result.state not in {READY_STATE, COMPLETE_STATE}:
|
|
raise ValueError("state is not supported")
|
|
if result.rf_tx != 0:
|
|
raise ValueError("rf_tx must remain 0 on the RX node")
|
|
if not result.task_id or any(character in result.task_id for character in "\r\n"):
|
|
raise ValueError("task_id is invalid")
|
|
if not _HEAD_RE.fullmatch(result.source_head):
|
|
raise ValueError("source_head must be a lowercase 40-character Git hash")
|
|
if not result.capture_path or any(
|
|
character in result.capture_path for character in "\r\n"
|
|
):
|
|
raise ValueError("capture_path is invalid")
|
|
published = _parse_utc(result.published_at, "published_at")
|
|
started = _parse_utc(result.capture_started_at, "capture_started_at")
|
|
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
|
|
if published < started:
|
|
raise ValueError("published_at cannot precede capture_started_at")
|
|
if expected_end <= started:
|
|
raise ValueError("expected_capture_end must follow capture_started_at")
|
|
if result.clkin_after not in {"DETECTED", "NOT_DETECTED", "NOT_RUN"}:
|
|
raise ValueError("clkin_after is invalid")
|
|
if result.rf_tx_observed not in {
|
|
"EXPECTED_EXTERNAL_TX",
|
|
"UNKNOWN",
|
|
}:
|
|
raise ValueError("rf_tx_observed is invalid")
|
|
|
|
|
|
def _validate_ready(result: RxHandoffResult) -> None:
|
|
if not (
|
|
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")
|
|
if result.capture_pid is None or result.capture_pid <= 0:
|
|
raise ValueError("READY requires a positive capture_pid")
|
|
final_fields = (
|
|
result.exit_code,
|
|
result.file_size,
|
|
result.duration_seconds,
|
|
result.overruns,
|
|
result.usb_errors,
|
|
result.file_errors,
|
|
result.sha256,
|
|
)
|
|
if any(value is not None for value in final_fields):
|
|
raise ValueError("READY must not contain final capture evidence")
|
|
|
|
|
|
def _validate_complete(result: RxHandoffResult) -> None:
|
|
if result.rx_ready_for_tx or result.process_alive or result.file_growing:
|
|
raise ValueError("COMPLETE cannot advertise a live capture")
|
|
if result.capture_pid is None or result.capture_pid <= 0:
|
|
raise ValueError("COMPLETE requires the capture_pid")
|
|
if result.exit_code is None or result.file_size is None:
|
|
raise ValueError("COMPLETE requires exit_code and file_size")
|
|
if result.file_size < 0:
|
|
raise ValueError("file_size cannot be negative")
|
|
counters = (result.overruns, result.usb_errors, result.file_errors)
|
|
if any(value is not None and value < 0 for value in counters):
|
|
raise ValueError("error counters cannot be negative")
|
|
if result.duration_seconds is None or result.duration_seconds < 0:
|
|
raise ValueError("COMPLETE requires a non-negative duration_seconds")
|
|
if result.sha256 is None or not _SHA256_RE.fullmatch(result.sha256):
|
|
raise ValueError("COMPLETE requires a lowercase SHA256")
|
|
|
|
|
|
def validate_result(result: RxHandoffResult) -> RxHandoffResult:
|
|
_validate_common(result)
|
|
if result.state == READY_STATE:
|
|
_validate_ready(result)
|
|
else:
|
|
_validate_complete(result)
|
|
return result
|
|
|
|
|
|
def result_from_mapping(raw: Mapping[str, Any]) -> RxHandoffResult:
|
|
expected_fields = {field.name for field in fields(RxHandoffResult)}
|
|
actual_fields = set(raw)
|
|
if actual_fields != expected_fields:
|
|
missing = sorted(expected_fields - actual_fields)
|
|
extra = sorted(actual_fields - expected_fields)
|
|
raise ValueError(f"result fields disagree: missing={missing}, extra={extra}")
|
|
result = RxHandoffResult(**{name: raw[name] for name in expected_fields})
|
|
return validate_result(result)
|
|
|
|
|
|
def load_result(path: Path) -> RxHandoffResult:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("result JSON root must be an object")
|
|
return result_from_mapping(raw)
|
|
|
|
|
|
def canonical_result_json(result: RxHandoffResult) -> str:
|
|
validate_result(result)
|
|
return json.dumps(
|
|
asdict(result),
|
|
indent=2,
|
|
sort_keys=True,
|
|
ensure_ascii=False,
|
|
) + "\n"
|
|
|
|
|
|
def result_digest(result: RxHandoffResult) -> str:
|
|
return hashlib.sha256(canonical_result_json(result).encode("utf-8")).hexdigest()
|
|
|
|
|
|
def render_result_markdown(result: RxHandoffResult) -> str:
|
|
validate_result(result)
|
|
rows = [
|
|
"# RX result",
|
|
"",
|
|
f"SCHEMA_VERSION = {result.schema_version}",
|
|
f"TASK_ID = {result.task_id}",
|
|
f"STATE = {result.state}",
|
|
f"RX_READY_FOR_TX = {'YES' if result.rx_ready_for_tx else 'NO'}",
|
|
f"PROCESS_ALIVE = {'YES' if result.process_alive else 'NO'}",
|
|
f"FILE_GROWING = {'YES' if result.file_growing else 'NO'}",
|
|
f"CAPTURE_PID = {result.capture_pid}",
|
|
f"PUBLISHED_AT = {result.published_at}",
|
|
f"CAPTURE_STARTED_AT = {result.capture_started_at}",
|
|
f"EXPECTED_CAPTURE_END = {result.expected_capture_end}",
|
|
f"CAPTURE_PATH = {result.capture_path}",
|
|
f"COMMON_CLOCK_VERIFIED = {'YES' if result.common_clock_verified else 'NO'}",
|
|
f"SOURCE_HEAD = {result.source_head}",
|
|
f"EXIT_CODE = {result.exit_code}",
|
|
f"FILE_SIZE = {result.file_size}",
|
|
f"DURATION_SECONDS = {result.duration_seconds}",
|
|
f"OVERRUNS = {result.overruns}",
|
|
f"USB_ERRORS = {result.usb_errors}",
|
|
f"FILE_ERRORS = {result.file_errors}",
|
|
f"SHA256 = {result.sha256}",
|
|
f"CLKIN_AFTER = {result.clkin_after}",
|
|
f"RF_TX_OBSERVED = {result.rf_tx_observed}",
|
|
f"RF_TX = {result.rf_tx}",
|
|
"",
|
|
"Raw IQ, video, waveforms, and large logs are not stored in this branch.",
|
|
"",
|
|
]
|
|
return "\n".join(rows)
|
|
|
|
|
|
def evaluate_ready(
|
|
result: RxHandoffResult,
|
|
*,
|
|
now: datetime,
|
|
expected_task_id: str = EXPECTED_RX_TASK_ID,
|
|
max_age_seconds: float = 30.0,
|
|
min_remaining_seconds: float = 10.0,
|
|
) -> ReadyEvaluation:
|
|
validate_result(result)
|
|
now_utc = now.astimezone(timezone.utc)
|
|
if result.task_id != expected_task_id:
|
|
return ReadyEvaluation(False, "WRONG_TASK_ID")
|
|
if result.state != READY_STATE or not result.rx_ready_for_tx:
|
|
return ReadyEvaluation(False, "MISSING_READY")
|
|
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")
|
|
planned_duration = (expected_end - started).total_seconds()
|
|
if not 119.0 <= planned_duration <= 121.0:
|
|
return ReadyEvaluation(False, "WRONG_CAPTURE_WINDOW")
|
|
age = (now_utc - published).total_seconds()
|
|
if age < -5.0:
|
|
return ReadyEvaluation(False, "FUTURE_READY")
|
|
if age > max_age_seconds:
|
|
return ReadyEvaluation(False, "STALE_READY")
|
|
remaining = (expected_end - now_utc).total_seconds()
|
|
if remaining < min_remaining_seconds:
|
|
return ReadyEvaluation(False, "CAPTURE_WINDOW_EXPIRED", remaining)
|
|
return ReadyEvaluation(True, "READY", remaining)
|
|
|
|
|
|
class GitResultChannel:
|
|
"""Read and publish the two allowlisted result files on an existing branch."""
|
|
|
|
def __init__(
|
|
self,
|
|
repository: Path,
|
|
*,
|
|
remote: str = "origin",
|
|
branch: str = "rx-node",
|
|
) -> None:
|
|
self.repository = repository
|
|
self.remote = remote
|
|
self.branch = branch
|
|
|
|
def _run(
|
|
self,
|
|
arguments: list[str],
|
|
*,
|
|
cwd: Path | None = None,
|
|
check: bool = True,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
["git", *arguments],
|
|
cwd=cwd or self.repository,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
check=check,
|
|
)
|
|
|
|
def branch_exists(self) -> bool:
|
|
result = self._run(
|
|
["ls-remote", "--exit-code", "--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"
|
|
)
|
|
|
|
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 read(self) -> tuple[RxHandoffResult, str, str]:
|
|
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
|
|
|
|
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)}"
|
|
)
|
|
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)
|