"""Poll the RX result branch and gate a single RF-001A TX authorization.""" from __future__ import annotations import argparse from dataclasses import asdict, dataclass from datetime import datetime, timezone import json import os from pathlib import Path 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 EXPECTED_TX_TASK_ID, GitResultChannel, ResultBranchMissing, ResultChannelError, RxHandoffResult, evaluate_ready, load_result, result_digest, ) DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json" DEFAULT_OUTBOX = ROOT / ".local" / "tx_authorizations" class ResultSource(Protocol): def fetch(self) -> None: ... def read(self) -> tuple[RxHandoffResult, str, str]: ... class TxExecutor(Protocol): def prepare( self, result: RxHandoffResult, digest: str, result_head: str, *, dry_run: bool, ) -> Path: ... @dataclass class TxWatcherState: running: bool = False pid: int | None = None last_seen_digest: str | None = None last_prompted_digest: str | None = None last_authorized_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 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, "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")) if not isinstance(raw, dict): raise ValueError("TX watcher state must be a JSON object") return TxWatcherState( running=bool(raw.get("running", False)), pid=raw.get("pid"), last_seen_digest=raw.get("last_seen_digest"), last_prompted_digest=raw.get("last_prompted_digest"), last_authorized_digest=raw.get("last_authorized_digest"), ) 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: 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 or not process_exists(state.pid): 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 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, 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 digest == state.last_seen_digest: return "DUPLICATE_SUPPRESSED", state evaluation = evaluate_ready( result, now=now_function(), 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: 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(), 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 run_watcher(arguments: argparse.Namespace) -> int: state_path = arguments.state_file.resolve() 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") 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) if arguments.result_file is not None: source: ResultSource = StaticResultSource(load_result(arguments.result_file)) else: source = GitResultChannel( arguments.repository.resolve(), remote=arguments.remote, branch=arguments.result_branch, ) executor = ManualTxExecutor(arguments.outbox.resolve()) 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: while not stop_path.exists(): try: source.fetch() result, digest, result_head = source.read() status, state = handle_result( result, digest, result_head, state, source, executor, now_function=lambda: datetime.now(timezone.utc), input_function=input_function, dry_run=arguments.dry_run, max_age_seconds=arguments.max_age, min_remaining_seconds=arguments.min_remaining, ) save_state(state_path, state) if status != "DUPLICATE_SUPPRESSED": print(f"TX WATCHER STATUS = {status}") 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("--result-branch", default="rx-node") 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("--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()