Files
SDR-Rover/tools/tx_agent_watcher.py
2026-09-22 11:46:53 +03:00

602 lines
20 KiB
Python

"""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 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,
)
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: ...
@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 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,
) -> 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)
status, state = handle_result(
result,
digest,
result_head,
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,
)
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.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())
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,
)
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,
)
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("--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("--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()