"""Poll the RX result branch and gate a single RF-001A TX authorization.""" from __future__ import annotations import argparse from dataclasses import asdict, dataclass, replace from datetime import datetime, timezone import json import os from pathlib import Path import re import subprocess import sys import time from typing import Callable, Protocol ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from tools.rf_handoff_channel import ( # noqa: E402 CHANNEL_TEST_TASK_ID, COMPLETE_STATE, EXPECTED_TX_TASK_ID, GitResultChannel, READY_STATE, ResultBranchMissing, ResultChannelError, ResultHistoryRewritten, RxHandoffResult, evaluate_ready, load_result, result_digest, ) from tools.tx_handoff_channel import ( # noqa: E402 CHANNEL_COMPLETE_OBSERVED, CHANNEL_READY_OBSERVED, RX_READY_OBSERVED, TX_ACK_SCHEMA_VERSION, TX_AUTHORIZED, TX_COMPLETE, TX_STARTED, GitAcknowledgementChannel, TxHandoffAcknowledgement, load_acknowledgement, ) DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json" DEFAULT_OUTBOX = ROOT / ".local" / "tx_authorizations" STATE_VERSION = 2 _HEAD_RE = re.compile(r"^[0-9a-f]{40}$") class ResultSource(Protocol): def fetch(self) -> str | None: ... def read(self) -> tuple[RxHandoffResult, str, str]: ... def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]: ... def commits_since( self, last_processed_commit: str | None, ) -> tuple[list[str], str]: ... def find_commit_by_digest(self, digest: str) -> str | None: ... class TxExecutor(Protocol): def prepare( self, result: RxHandoffResult, digest: str, result_head: str, *, dry_run: bool, ) -> Path: ... class AcknowledgementPublisher(Protocol): def publish( self, acknowledgement: TxHandoffAcknowledgement, ) -> object: ... @dataclass class TxWatcherState: state_version: int = STATE_VERSION running: bool = False pid: int | None = None last_seen_digest: str | None = None last_seen_commit: str | None = None last_processed_rx_node_commit: str | None = None last_prompted_digest: str | None = None last_authorized_digest: str | None = None channel_test_ready_digest: str | None = None channel_test_complete_digest: str | None = None class StaticResultSource: """Read one local fixture without network or Git writes.""" def __init__(self, result: RxHandoffResult) -> None: self.result = result self.digest = result_digest(result) def fetch(self) -> None: return None def read(self) -> tuple[RxHandoffResult, str, str]: return self.result, self.digest, self.result.source_head def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]: if commit != self.result.source_head: raise ResultHistoryRewritten("static result commit changed") return self.read() def commits_since( self, last_processed_commit: str | None, ) -> tuple[list[str], str]: head = self.result.source_head return ([] if last_processed_commit == head else [head], head) def find_commit_by_digest(self, digest: str) -> str | None: return self.result.source_head if digest == self.digest else None class ManualTxExecutor: """Write a local authorization record; never access SDR hardware.""" def __init__(self, outbox: Path) -> None: self.outbox = outbox def prepare( self, result: RxHandoffResult, digest: str, result_head: str, *, dry_run: bool, ) -> Path: self.outbox.mkdir(parents=True, exist_ok=True) suffix = "fake" if dry_run else "authorized" target = self.outbox / f"{EXPECTED_TX_TASK_ID}_{suffix}.json" temporary = target.with_suffix(target.suffix + ".tmp") payload = { "task_id": EXPECTED_TX_TASK_ID, "authorization": "AUTHORIZE RF TX", "rx_task_id": result.task_id, "rx_result_digest": digest, "rx_result_head": result_head, "source_head": result.source_head, "capture_started_at": result.capture_started_at, "expected_capture_end": result.expected_capture_end, "prepared_at": datetime.now(timezone.utc).isoformat(), "dry_run": dry_run, "action": "FAKE_TX_ONLY" if dry_run else "MANUAL_EXECUTOR_GATE_OPEN", "rf_tx_started": False, "rf_tx": 0, } temporary.write_text( json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) os.replace(temporary, target) return target def load_state(path: Path) -> TxWatcherState: if not path.exists(): return TxWatcherState() raw = json.loads(path.read_text(encoding="utf-8-sig")) if not isinstance(raw, dict): raise ValueError("TX watcher state must be a JSON object") allowed = set(TxWatcherState.__dataclass_fields__) if any(key not in allowed for key in raw): raise ValueError("TX watcher state contains an unknown field") version = raw.get("state_version", 1) if type(version) is not int or version not in {1, STATE_VERSION}: raise ValueError("TX watcher state version is not supported") migrated = dict(raw) migrated["state_version"] = STATE_VERSION return TxWatcherState(**migrated) def save_state(path: Path, state: TxWatcherState) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text( json.dumps(asdict(state), indent=2, sort_keys=True) + "\n", encoding="utf-8", ) os.replace(temporary, path) def stop_path_for(state_path: Path) -> Path: return state_path.with_suffix(state_path.suffix + ".stop") def process_exists(pid: int | None) -> bool: if pid is None or pid <= 0: return False try: os.kill(pid, 0) except (OSError, SystemError): return False return True def request_stop(state_path: Path, timeout_s: float) -> bool: stop_path = stop_path_for(state_path) stop_path.parent.mkdir(parents=True, exist_ok=True) stop_path.write_text("STOP\n", encoding="ascii") deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: state = load_state(state_path) if not state.running: stop_path.unlink(missing_ok=True) return True if not process_exists(state.pid): state.running = False state.pid = None save_state(state_path, state) stop_path.unlink(missing_ok=True) return True time.sleep(0.2) return False def interruptible_input(prompt: str, stop_path: Path) -> str: if os.name != "nt": return input(prompt) import msvcrt print(prompt, end="", flush=True) characters: list[str] = [] while True: if stop_path.exists(): print() return "S" if not msvcrt.kbhit(): time.sleep(0.1) continue character = msvcrt.getwch() if character in {"\r", "\n"}: print() return "".join(characters) if character == "\b": if characters: characters.pop() print("\b \b", end="", flush=True) continue characters.append(character) print(character, end="", flush=True) def ready_message(result: RxHandoffResult) -> str: return ( "RX IS READY\n\n" f"TASK ID =\n{result.task_id}\n\n" f"CAPTURE STARTED =\n{result.capture_started_at}\n\n" f"CAPTURE END EXPECTED =\n{result.expected_capture_end}\n\n" f"TX TASK =\n{EXPECTED_TX_TASK_ID}\n\n" "RF TX REQUIRES RED APPROVAL\n\n" "Enter:\nAUTHORIZE RF TX" ) def _valid_result_head(result_head: str) -> bool: return _HEAD_RE.fullmatch(result_head) is not None def handle_channel_test( result: RxHandoffResult, digest: str, result_head: str, state: TxWatcherState, *, output_function: Callable[[str], None] = print, ) -> tuple[str, TxWatcherState]: """Record model-free channel smoke states without opening the RED gate.""" if digest == state.last_seen_digest: return "DUPLICATE_SUPPRESSED", state state.last_seen_digest = digest if result.task_id != CHANNEL_TEST_TASK_ID: return "WRONG_TASK_ID", state if not _valid_result_head(result_head): return "INVALID_RESULT_COMMIT", state if result.common_clock_verified: return "CHANNEL_TEST_CLAIMS_HARDWARE", state if result.state == READY_STATE: state.channel_test_ready_digest = digest output_function("CHANNEL TEST READY RECEIVED = YES") output_function(f"RX RESULT HEAD = {result_head}") output_function("RF TX = 0") return "CHANNEL_TEST_READY", state if result.state == COMPLETE_STATE: state.channel_test_complete_digest = digest output_function("CHANNEL TEST COMPLETE RECEIVED = YES") output_function(f"RX RESULT HEAD = {result_head}") output_function("RF TX = 0") if state.channel_test_ready_digest is None: return "CHANNEL_TEST_COMPLETE_WITHOUT_READY", state return "CHANNEL_TEST_COMPLETE", state return "CHANNEL_TEST_INVALID_STATE", state def handle_result( result: RxHandoffResult, digest: str, result_head: str, state: TxWatcherState, source: ResultSource, executor: TxExecutor, *, now_function: Callable[[], datetime], input_function: Callable[[str], str], output_function: Callable[[str], None] = print, dry_run: bool = False, expected_source_head: str | None = None, max_age_seconds: float = 30.0, min_remaining_seconds: float = 10.0, ) -> tuple[str, TxWatcherState]: """Validate READY, request exact approval, then revalidate remote state.""" if result.task_id == CHANNEL_TEST_TASK_ID: return handle_channel_test( result, digest, result_head, state, output_function=output_function, ) if digest == state.last_seen_digest: return "DUPLICATE_SUPPRESSED", state if not _valid_result_head(result_head): state.last_seen_digest = digest return "INVALID_RESULT_COMMIT", state evaluation = evaluate_ready( result, now=now_function(), expected_source_head=expected_source_head, max_age_seconds=max_age_seconds, min_remaining_seconds=min_remaining_seconds, ) state.last_seen_digest = digest if not evaluation.ready: return evaluation.reason, state output_function(ready_message(result)) state.last_prompted_digest = digest phrase = input_function("Type AUTHORIZE RF TX to prepare one burst: ").strip() if phrase != "AUTHORIZE RF TX": output_function("RED APPROVAL BLOCKED") output_function("RF TX = 0") return "RED_APPROVAL_BLOCKED", state source.fetch() refreshed, refreshed_digest, refreshed_head = source.read() if refreshed_digest != digest or refreshed_head != result_head: output_function("RX RESULT CHANGED; NEW APPROVAL REQUIRED") output_function("RF TX = 0") return "RESULT_CHANGED", state refreshed_evaluation = evaluate_ready( refreshed, now=now_function(), expected_source_head=expected_source_head, max_age_seconds=max_age_seconds, min_remaining_seconds=min_remaining_seconds, ) if not refreshed_evaluation.ready: output_function(f"TX WINDOW BLOCKED = {refreshed_evaluation.reason}") output_function("RF TX = 0") return refreshed_evaluation.reason, state authorization_path = executor.prepare( refreshed, refreshed_digest, refreshed_head, dry_run=dry_run, ) state.last_authorized_digest = refreshed_digest output_function(f"TX AUTHORIZATION PREPARED = {authorization_path}") output_function("RF TX = 0") return "TX_AUTHORIZATION_PREPARED", state def _git_output(repository: Path, arguments: list[str]) -> str: return subprocess.run( ["git", *arguments], cwd=repository, capture_output=True, text=True, encoding="utf-8", errors="replace", check=True, ).stdout.strip() def migrate_state_cursor( source: ResultSource, state: TxWatcherState, ) -> bool: """Map a version-1 digest-only state to its exact result commit.""" state.state_version = STATE_VERSION if state.last_processed_rx_node_commit is not None: if state.last_seen_commit is None: state.last_seen_commit = state.last_processed_rx_node_commit return True return False if state.last_seen_digest is None: return False matched_commit = source.find_commit_by_digest(state.last_seen_digest) if matched_commit is None: raise ResultHistoryRewritten( "stored result digest cannot be mapped to origin/rx-node history" ) state.last_seen_commit = matched_commit state.last_processed_rx_node_commit = matched_commit return True def acknowledgement_for_result( result: RxHandoffResult, digest: str, result_head: str, status: str, *, observed_at: datetime, main_watcher_pid: int, ) -> TxHandoffAcknowledgement | None: """Build an acknowledgement only for an event handled successfully.""" acknowledgement_state: str | None = None rf_tx_authorized = False if status == "CHANNEL_TEST_READY": acknowledgement_state = CHANNEL_READY_OBSERVED elif status == "CHANNEL_TEST_COMPLETE": acknowledgement_state = CHANNEL_COMPLETE_OBSERVED elif status == "RED_APPROVAL_BLOCKED": acknowledgement_state = RX_READY_OBSERVED elif status == "TX_AUTHORIZATION_PREPARED": acknowledgement_state = TX_AUTHORIZED rf_tx_authorized = True if acknowledgement_state is None: return None return TxHandoffAcknowledgement( schema_version=TX_ACK_SCHEMA_VERSION, task_id=result.task_id, state=acknowledgement_state, source_rx_commit=result_head, source_rx_digest=digest, source_head=result.source_head, observed_at=observed_at.astimezone(timezone.utc).isoformat(), main_watcher_pid=main_watcher_pid, rf_tx_authorized=rf_tx_authorized, rf_tx_started=False, rf_tx=0, tx_task_id=(EXPECTED_TX_TASK_ID if rf_tx_authorized else None), ) def process_history_cycle( source: ResultSource, state: TxWatcherState, executor: TxExecutor, *, now_function: Callable[[], datetime], input_function: Callable[[str], str], save_function: Callable[[TxWatcherState], None], output_function: Callable[[str], None] = print, dry_run: bool = False, expected_source_head: str | None = None, max_age_seconds: float = 30.0, min_remaining_seconds: float = 10.0, acknowledgement_publisher: AcknowledgementPublisher | None = None, main_watcher_pid: int | None = None, ) -> list[tuple[str, str]]: """Process every new result commit in chronological order.""" commits, _remote_head = source.commits_since( state.last_processed_rx_node_commit ) outcomes: list[tuple[str, str]] = [] for commit in commits: result, digest, result_head = source.read_commit(commit) candidate_state = replace(state) status, candidate_state = handle_result( result, digest, result_head, candidate_state, source, executor, now_function=now_function, input_function=input_function, output_function=output_function, dry_run=dry_run, expected_source_head=expected_source_head, max_age_seconds=max_age_seconds, min_remaining_seconds=min_remaining_seconds, ) acknowledgement = acknowledgement_for_result( result, digest, result_head, status, observed_at=now_function(), main_watcher_pid=main_watcher_pid or os.getpid(), ) if acknowledgement is not None and acknowledgement_publisher is not None: publication = acknowledgement_publisher.publish(acknowledgement) output_function( "TX ACKNOWLEDGEMENT PUBLISH = " f"{getattr(publication, 'status', 'PUBLISHED')}" ) output_function( "TX ACKNOWLEDGEMENT HEAD = " f"{getattr(publication, 'commit', 'UNKNOWN')}" ) for field_name in TxWatcherState.__dataclass_fields__: setattr(state, field_name, getattr(candidate_state, field_name)) state.last_seen_commit = result_head state.last_processed_rx_node_commit = result_head state.state_version = STATE_VERSION save_function(state) outcomes.append((result_head, status)) if status != "DUPLICATE_SUPPRESSED": output_function(f"TX WATCHER STATUS = {status}") return outcomes def run_watcher(arguments: argparse.Namespace) -> int: state_path = arguments.state_file.resolve() if arguments.publish_tx_event is not None: if arguments.dry_run: raise ValueError("--publish-tx-event cannot be combined with --dry-run") repository = arguments.repository.resolve() event = load_acknowledgement(arguments.publish_tx_event.resolve()) if event.state not in {TX_STARTED, TX_COMPLETE}: raise ValueError("published execution event has the wrong state") branch = _git_output(repository, ["branch", "--show-current"]) if branch != arguments.source_branch: raise ValueError("TX event publisher is on the wrong source branch") if _git_output(repository, ["status", "--porcelain", "--untracked-files=no"]): raise ValueError("tracked worktree must be clean before TX event publication") _git_output( repository, ["fetch", "--quiet", arguments.remote, arguments.source_branch], ) head = _git_output(repository, ["rev-parse", "HEAD"]) remote_head = _git_output( repository, ["rev-parse", f"{arguments.remote}/{arguments.source_branch}"], ) if head != remote_head: raise ValueError("current HEAD disagrees with remote source branch") if event.source_head != head: raise ValueError("TX event SOURCE_HEAD disagrees with current HEAD") publisher = GitAcknowledgementChannel( repository, remote=arguments.remote, branch=arguments.acknowledgement_branch, ) outcome = publisher.publish_execution_event(event) print(f"TX EVENT PUBLISH = {outcome.status}") print(f"TX EVENT STATE = {event.state}") print(f"TX EVENT HEAD = {outcome.commit}") print(f"TX EVENT REMOTE HEAD = {outcome.remote_head}") return 0 if arguments.request_stop: stopped = request_stop(state_path, arguments.stop_timeout) print("TX WATCHER STOPPED" if stopped else "TX WATCHER STOP TIMEOUT") return 0 if stopped else 1 if arguments.result_file is not None and not arguments.dry_run: raise ValueError("--result-file is allowed only with --dry-run") if arguments.dry_run and arguments.result_file is None: raise ValueError("--dry-run requires --result-file and never fetches Git") if arguments.approval is not None and not arguments.dry_run: raise ValueError("--approval is allowed only with --dry-run") repository = arguments.repository.resolve() state = load_state(state_path) if state.running and process_exists(state.pid): raise RuntimeError(f"TX watcher already running with PID {state.pid}") state.running = True state.pid = os.getpid() save_state(state_path, state) stop_path = stop_path_for(state_path) stop_path.unlink(missing_ok=True) executor = ManualTxExecutor(arguments.outbox.resolve()) acknowledgement_publisher: AcknowledgementPublisher | None = None def input_function(prompt: str) -> str: if arguments.approval == "authorize": return "AUTHORIZE RF TX" if arguments.approval == "deny": return "DENY" return interruptible_input(prompt, stop_path) try: if arguments.result_file is not None: static_source = StaticResultSource(load_result(arguments.result_file)) source: ResultSource = static_source expected_source_head = ( arguments.expected_source_head or static_source.result.source_head ) else: branch = _git_output(repository, ["branch", "--show-current"]) if branch != arguments.source_branch: raise ValueError( "TX watcher must run on the configured source branch" ) expected_source_head = ( arguments.expected_source_head or _git_output(repository, ["rev-parse", "HEAD"]) ) source = GitResultChannel( repository, remote=arguments.remote, branch=arguments.result_branch, ) acknowledgement_publisher = GitAcknowledgementChannel( repository, remote=arguments.remote, branch=arguments.acknowledgement_branch, ) if not _HEAD_RE.fullmatch(expected_source_head): raise ValueError( "expected source HEAD must be a full lowercase Git hash" ) if migrate_state_cursor(source, state): save_state(state_path, state) while not stop_path.exists(): try: process_history_cycle( source, state, executor, now_function=lambda: datetime.now(timezone.utc), input_function=input_function, save_function=lambda current: save_state( state_path, current, ), dry_run=arguments.dry_run, expected_source_head=expected_source_head, max_age_seconds=arguments.max_age, min_remaining_seconds=arguments.min_remaining, acknowledgement_publisher=acknowledgement_publisher, main_watcher_pid=os.getpid(), ) if arguments.once or arguments.dry_run: break except ( OSError, ValueError, ResultBranchMissing, ResultChannelError, RuntimeError, json.JSONDecodeError, subprocess.SubprocessError, ) as error: print(f"TX WATCHER ERROR = {error}", file=sys.stderr) if arguments.once or arguments.dry_run: return 1 if arguments.once: break for _ in range(max(1, int(arguments.poll_interval * 10))): if stop_path.exists(): break time.sleep(0.1) finally: state.running = False state.pid = None save_state(state_path, state) stop_path.unlink(missing_ok=True) return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repository", type=Path, default=ROOT) parser.add_argument("--remote", default="origin") parser.add_argument("--source-branch", default="video-development") parser.add_argument("--result-branch", default="rx-node") parser.add_argument("--acknowledgement-branch", default="tx-node") parser.add_argument("--expected-source-head") parser.add_argument("--poll-interval", type=float, default=5.0) parser.add_argument("--max-age", type=float, default=30.0) parser.add_argument("--min-remaining", type=float, default=10.0) parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH) parser.add_argument("--outbox", type=Path, default=DEFAULT_OUTBOX) parser.add_argument("--once", action="store_true") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--result-file", type=Path) parser.add_argument("--publish-tx-event", type=Path) parser.add_argument("--approval", choices=("authorize", "deny")) parser.add_argument("--request-stop", action="store_true") parser.add_argument("--stop-timeout", type=float, default=15.0) return parser def main() -> None: arguments = build_parser().parse_args() positive = ( arguments.poll_interval, arguments.max_age, arguments.min_remaining, arguments.stop_timeout, ) if any(value <= 0 for value in positive): raise SystemExit("intervals must be positive") raise SystemExit(run_watcher(arguments)) if __name__ == "__main__": main()