492 lines
16 KiB
Python
492 lines
16 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,
|
|
RxHandoffResult,
|
|
evaluate_ready,
|
|
load_result,
|
|
result_digest,
|
|
)
|
|
|
|
|
|
DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json"
|
|
DEFAULT_OUTBOX = ROOT / ".local" / "tx_authorizations"
|
|
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
|
|
|
|
class ResultSource(Protocol):
|
|
def fetch(self) -> str | 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
|
|
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
|
|
|
|
|
|
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")
|
|
return TxWatcherState(**raw)
|
|
|
|
|
|
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 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)
|
|
|
|
if arguments.result_file is not None:
|
|
source: ResultSource = StaticResultSource(load_result(arguments.result_file))
|
|
expected_source_head = (
|
|
arguments.expected_source_head or 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")
|
|
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:
|
|
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,
|
|
expected_source_head=expected_source_head,
|
|
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("--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()
|