1530 lines
56 KiB
Python
1530 lines
56 KiB
Python
"""Polling RX-laptop agent for the RF-001 authenticated HTTP channel.
|
|
|
|
Only fixed inventory, software verification, receive-only capture, capture
|
|
status, and offline decode tasks are implemented. The agent has no generic
|
|
command endpoint and contains no RF-transmit operation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import getpass
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from typing import Callable, Sequence
|
|
from urllib.parse import quote
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from tools.rf001_http_control import ( # noqa: E402
|
|
AGENT_ID_PATTERN,
|
|
CAPTURE_DECODE_TASK,
|
|
CAPTURE_DIAGNOSTICS_TASK,
|
|
DEFAULT_TOKEN_PATH,
|
|
INVENTORY_TASK,
|
|
OFFLINE_VERIFY_TASK,
|
|
PROTOCOL_VERSION,
|
|
ProtocolError,
|
|
RX_CAPTURE_START_TASK,
|
|
RX_CAPTURE_STATUS_TASK,
|
|
SESSION_ID_PATTERN,
|
|
SHA256_PATTERN,
|
|
STAIRCASE_SEARCH_TASK,
|
|
UPDATE_AGENT_TASK,
|
|
read_token,
|
|
request_file,
|
|
request_json,
|
|
utc_now,
|
|
)
|
|
from tools.rf001_agent_update import ( # noqa: E402
|
|
ReleaseValidationError,
|
|
atomic_write_json,
|
|
inspect_release_archive,
|
|
sha256_file as update_sha256_file,
|
|
verify_update_manifest_signature,
|
|
)
|
|
from tools.rf001_rx_capture import ( # noqa: E402
|
|
decode_completed_capture,
|
|
read_capture_diagnostics,
|
|
read_capture_status,
|
|
start_rx_capture,
|
|
)
|
|
|
|
|
|
DEFAULT_SERVER = "http://192.168.255.185:8765"
|
|
DEFAULT_POLL_INTERVAL_SECONDS = 2.0
|
|
AGENT_VERSION = "2026.09.29.2"
|
|
SUPPORTED_TASKS = (
|
|
"INVENTORY",
|
|
"OFFLINE_VERIFY",
|
|
"RX_CAPTURE_START",
|
|
"RX_CAPTURE_STATUS",
|
|
"CAPTURE_DECODE",
|
|
"CAPTURE_DIAGNOSTICS",
|
|
"STAIRCASE_SEARCH",
|
|
"UPDATE_AGENT",
|
|
)
|
|
DEFAULT_WORK_ROOT = (
|
|
Path(os.environ.get("USERPROFILE", str(Path.home())))
|
|
/ "Documents"
|
|
/ "SDR_Rover_Runtime"
|
|
)
|
|
CommandRunner = Callable[[Sequence[str], float], "CommandResult"]
|
|
|
|
|
|
def redirect_output(path: Path) -> None:
|
|
"""Send service-mode stdout and stderr to one line-buffered UTF-8 log."""
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
stream = path.open("a", encoding="utf-8", buffering=1)
|
|
sys.stdout = stream
|
|
sys.stderr = stream
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CommandResult:
|
|
arguments: list[str]
|
|
returncode: int
|
|
stdout: str
|
|
stderr: str
|
|
|
|
def as_mapping(self) -> dict[str, object]:
|
|
return {
|
|
"arguments": self.arguments,
|
|
"returncode": self.returncode,
|
|
"stdout": self.stdout,
|
|
"stderr": self.stderr,
|
|
}
|
|
|
|
|
|
def run_command(arguments: Sequence[str], timeout: float) -> CommandResult:
|
|
completed = subprocess.run(
|
|
list(arguments),
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
return CommandResult(
|
|
arguments=list(arguments),
|
|
returncode=completed.returncode,
|
|
stdout=completed.stdout.strip(),
|
|
stderr=completed.stderr.strip(),
|
|
)
|
|
|
|
|
|
def normalized_agent_id(value: str) -> str:
|
|
candidate = re.sub(r"[^a-z0-9._-]+", "-", value.strip().lower()).strip("-._")
|
|
if len(candidate) < 3:
|
|
candidate = f"rx-{candidate or 'node'}"
|
|
candidate = candidate[:64]
|
|
if AGENT_ID_PATTERN.fullmatch(candidate) is None:
|
|
raise ValueError("unable to derive a valid agent ID")
|
|
return candidate
|
|
|
|
|
|
def read_git_metadata(repository: Path) -> dict[str, object]:
|
|
"""Read branch and HEAD without requiring git.exe or changing the checkout."""
|
|
|
|
marker = repository / ".git"
|
|
if not marker.exists():
|
|
raise ValueError(f"Git metadata is absent: {marker}")
|
|
if marker.is_dir():
|
|
git_dir = marker.resolve()
|
|
else:
|
|
line = marker.read_text(encoding="utf-8-sig").strip()
|
|
match = re.fullmatch(r"gitdir:\s*(.+)", line)
|
|
if match is None:
|
|
raise ValueError("invalid .git indirection file")
|
|
candidate = Path(match.group(1).strip())
|
|
git_dir = (
|
|
candidate.resolve()
|
|
if candidate.is_absolute()
|
|
else (repository / candidate).resolve()
|
|
)
|
|
|
|
common_dir_file = git_dir / "commondir"
|
|
if common_dir_file.is_file():
|
|
common_value = Path(common_dir_file.read_text(encoding="utf-8-sig").strip())
|
|
common_dir = (
|
|
common_value.resolve()
|
|
if common_value.is_absolute()
|
|
else (git_dir / common_value).resolve()
|
|
)
|
|
else:
|
|
common_dir = git_dir
|
|
|
|
head_entry = (git_dir / "HEAD").read_text(encoding="ascii").strip()
|
|
if head_entry.startswith("ref: "):
|
|
ref_name = head_entry[5:]
|
|
if not ref_name.startswith("refs/heads/"):
|
|
raise ValueError(f"unsupported HEAD reference: {ref_name}")
|
|
branch = ref_name.removeprefix("refs/heads/")
|
|
loose_refs = (
|
|
git_dir.joinpath(*ref_name.split("/")),
|
|
common_dir.joinpath(*ref_name.split("/")),
|
|
)
|
|
loose_ref = next((path for path in loose_refs if path.is_file()), None)
|
|
if loose_ref is not None:
|
|
head = loose_ref.read_text(encoding="ascii").strip()
|
|
else:
|
|
head = ""
|
|
for packed_refs in (git_dir / "packed-refs", common_dir / "packed-refs"):
|
|
if packed_refs.is_file():
|
|
for packed_line in packed_refs.read_text(
|
|
encoding="ascii"
|
|
).splitlines():
|
|
fields = packed_line.split()
|
|
if len(fields) == 2 and fields[1] == ref_name:
|
|
head = fields[0]
|
|
break
|
|
if head:
|
|
break
|
|
if not head:
|
|
raise ValueError(f"Git reference is unresolved: {ref_name}")
|
|
else:
|
|
branch = "DETACHED"
|
|
head = head_entry
|
|
if re.fullmatch(r"[0-9a-f]{40}", head) is None:
|
|
raise ValueError("Git HEAD is not a lowercase 40-character hash")
|
|
return {
|
|
"git_dir": str(git_dir),
|
|
"common_dir": str(common_dir),
|
|
"head_entry": head_entry,
|
|
"branch": branch,
|
|
"head": head,
|
|
"status": "NOT_RUN_NO_GIT_EXECUTABLE",
|
|
}
|
|
|
|
|
|
def _git_inventory(
|
|
repository: Path,
|
|
command_runner: CommandRunner,
|
|
) -> dict[str, object]:
|
|
result: dict[str, object] = {
|
|
"path": str(repository),
|
|
"exists": repository.is_dir(),
|
|
}
|
|
if not repository.is_dir():
|
|
return result
|
|
try:
|
|
result["metadata"] = read_git_metadata(repository)
|
|
except (OSError, ValueError) as error:
|
|
result["metadata_error"] = f"{type(error).__name__}: {error}"
|
|
commands = {
|
|
"top_level": ["git", "-C", str(repository), "rev-parse", "--show-toplevel"],
|
|
"branch": ["git", "-C", str(repository), "branch", "--show-current"],
|
|
"head": ["git", "-C", str(repository), "rev-parse", "HEAD"],
|
|
"status": ["git", "-C", str(repository), "status", "--short", "--branch"],
|
|
}
|
|
for name, arguments in commands.items():
|
|
result[name] = command_runner(arguments, 15.0).as_mapping()
|
|
return result
|
|
|
|
|
|
def _resolve_hackrf_info(configured: Path | None) -> Path | None:
|
|
if configured is not None:
|
|
return configured
|
|
discovered = shutil.which("hackrf_info") or shutil.which("hackrf_info.exe")
|
|
if discovered:
|
|
return Path(discovered)
|
|
user_profile = Path(os.environ.get("USERPROFILE", str(Path.home())))
|
|
candidates = (
|
|
user_profile / "Tools" / "HackRF" / "Library" / "bin" / "hackrf_info.exe",
|
|
user_profile / "radioconda" / "Library" / "bin" / "hackrf_info.exe",
|
|
)
|
|
return next((path for path in candidates if path.is_file()), None)
|
|
|
|
|
|
def collect_inventory(
|
|
repository: Path,
|
|
*,
|
|
include_hackrf_info: bool,
|
|
hackrf_info_path: Path | None,
|
|
command_runner: CommandRunner = run_command,
|
|
) -> tuple[dict[str, object], list[str]]:
|
|
"""Collect fixed read-only host, Git, and optional HackRF information."""
|
|
|
|
errors: list[str] = []
|
|
inventory: dict[str, object] = {
|
|
"hostname": socket.gethostname(),
|
|
"account": getpass.getuser(),
|
|
"platform": platform.platform(),
|
|
"python_executable": sys.executable,
|
|
"python_version": platform.python_version(),
|
|
"collected_at": utc_now(),
|
|
"rf_tx": 0,
|
|
}
|
|
try:
|
|
inventory["hostname_command"] = command_runner(["hostname"], 10.0).as_mapping()
|
|
except (OSError, subprocess.SubprocessError) as error:
|
|
errors.append(f"hostname failed: {type(error).__name__}: {error}")
|
|
try:
|
|
inventory["whoami_command"] = command_runner(["whoami"], 10.0).as_mapping()
|
|
except (OSError, subprocess.SubprocessError) as error:
|
|
errors.append(f"whoami failed: {type(error).__name__}: {error}")
|
|
try:
|
|
inventory["git"] = _git_inventory(repository, command_runner)
|
|
except (OSError, subprocess.SubprocessError) as error:
|
|
errors.append(f"git inventory failed: {type(error).__name__}: {error}")
|
|
|
|
if include_hackrf_info:
|
|
resolved = _resolve_hackrf_info(hackrf_info_path)
|
|
if resolved is None:
|
|
inventory["hackrf_info"] = {
|
|
"found": False,
|
|
"executed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
errors.append("hackrf_info executable was not found")
|
|
elif not resolved.is_file():
|
|
inventory["hackrf_info"] = {
|
|
"found": False,
|
|
"configured_path": str(resolved),
|
|
"executed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
errors.append(f"hackrf_info path does not exist: {resolved}")
|
|
else:
|
|
try:
|
|
outcome = command_runner([str(resolved)], 20.0)
|
|
inventory["hackrf_info"] = {
|
|
"found": True,
|
|
"path": str(resolved),
|
|
"executed": True,
|
|
"result": outcome.as_mapping(),
|
|
"rf_tx": 0,
|
|
}
|
|
if outcome.returncode != 0:
|
|
errors.append(
|
|
f"hackrf_info returned exit code {outcome.returncode}"
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as error:
|
|
inventory["hackrf_info"] = {
|
|
"found": True,
|
|
"path": str(resolved),
|
|
"executed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
errors.append(f"hackrf_info failed: {type(error).__name__}: {error}")
|
|
else:
|
|
inventory["hackrf_info"] = {
|
|
"requested": False,
|
|
"executed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
return inventory, errors
|
|
|
|
|
|
def _bounded_command(result: CommandResult) -> dict[str, object]:
|
|
return {
|
|
"arguments": result.arguments,
|
|
"returncode": result.returncode,
|
|
"stdout": result.stdout[-8_000:],
|
|
"stderr": result.stderr[-8_000:],
|
|
}
|
|
|
|
|
|
def run_offline_verify(
|
|
repository: Path,
|
|
work_root: Path,
|
|
task_id: str,
|
|
expected_head: str,
|
|
*,
|
|
command_runner: CommandRunner = run_command,
|
|
) -> tuple[dict[str, object], list[str]]:
|
|
"""Generate and decode the deterministic RF-001A waveform without hardware."""
|
|
|
|
errors: list[str] = []
|
|
result: dict[str, object] = {
|
|
"mode": "SOFTWARE_ONLY",
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
try:
|
|
metadata = read_git_metadata(repository)
|
|
result["git"] = metadata
|
|
if metadata["branch"] != "video-development":
|
|
raise ValueError("repository branch is not video-development")
|
|
if metadata["head"] != expected_head:
|
|
raise ValueError(
|
|
f"repository HEAD {metadata['head']} does not match {expected_head}"
|
|
)
|
|
|
|
prepare_script = repository / "tools" / "rf001a_prepare.py"
|
|
decoder_script = ROOT / "tools" / "rf001_offline_decode.py"
|
|
truth_path = repository / "configs" / "rf" / "rf001a_truth_metadata.json"
|
|
for required in (prepare_script, decoder_script, truth_path):
|
|
if not required.is_file():
|
|
raise FileNotFoundError(f"required project file is absent: {required}")
|
|
|
|
output_directory = work_root / "offline_verify" / task_id
|
|
output_directory.parent.mkdir(parents=True, exist_ok=True)
|
|
if output_directory.exists():
|
|
raise FileExistsError(
|
|
f"offline verification directory already exists: {output_directory}"
|
|
)
|
|
report_path = output_directory / "offline_decode_report.json"
|
|
|
|
prepare = command_runner(
|
|
[
|
|
sys.executable,
|
|
"-B",
|
|
"-X",
|
|
"utf8",
|
|
str(prepare_script),
|
|
"--output-dir",
|
|
str(output_directory),
|
|
],
|
|
300.0,
|
|
)
|
|
result["prepare"] = _bounded_command(prepare)
|
|
if prepare.returncode != 0:
|
|
raise RuntimeError(f"rf001a_prepare exited with {prepare.returncode}")
|
|
|
|
iq_path = output_directory / "tx_waveform.cs8"
|
|
generated_truth = output_directory / "tx_metadata.json"
|
|
decode = command_runner(
|
|
[
|
|
sys.executable,
|
|
"-B",
|
|
"-X",
|
|
"utf8",
|
|
str(decoder_script),
|
|
"--repository",
|
|
str(repository),
|
|
"--iq-file",
|
|
str(iq_path),
|
|
"--truth-metadata",
|
|
str(generated_truth),
|
|
"--report",
|
|
str(report_path),
|
|
],
|
|
900.0,
|
|
)
|
|
result["decode"] = _bounded_command(decode)
|
|
if decode.returncode != 0:
|
|
raise RuntimeError(f"qpsk_rf_rx exited with {decode.returncode}")
|
|
report = json.loads(report_path.read_text(encoding="utf-8-sig"))
|
|
exact_packets = report.get("exact_packets")
|
|
crc_valid_packets = report.get("crc_valid_packets")
|
|
result.update(
|
|
{
|
|
"output_directory": str(output_directory),
|
|
"report_path": str(report_path),
|
|
"crc_valid_packets": crc_valid_packets,
|
|
"exact_packets": exact_packets,
|
|
"expected_packets": 100,
|
|
}
|
|
)
|
|
if crc_valid_packets != 100 or exact_packets != 100:
|
|
raise RuntimeError(
|
|
"offline decode did not produce 100 CRC-valid and exact packets"
|
|
)
|
|
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
|
errors.append(f"{type(error).__name__}: {error}")
|
|
return result, errors
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
while block := stream.read(4 * 1024 * 1024):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _staircase_result_from_report(
|
|
report: dict[str, object],
|
|
*,
|
|
report_path: Path,
|
|
command: CommandResult | None,
|
|
reused_existing_report: bool,
|
|
) -> dict[str, object]:
|
|
search = report.get("search")
|
|
if not isinstance(search, dict):
|
|
raise ValueError("staircase report search section is invalid")
|
|
candidates = search.get("candidates")
|
|
if not isinstance(candidates, list):
|
|
raise ValueError("staircase report candidates are invalid")
|
|
result: dict[str, object] = {
|
|
"mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH",
|
|
"session_id": report_path.parent.name,
|
|
"reference_detection": report.get("verdict"),
|
|
"verdict_reasons": report.get("verdict_reasons"),
|
|
"coverage_start_sample": search.get("coverage_start_sample"),
|
|
"coverage_stop_sample": search.get("coverage_stop_sample"),
|
|
"coverage_seconds": search.get("coverage_seconds"),
|
|
"coverage_complete": search.get("coverage_complete"),
|
|
"source_samples_processed": search.get("source_samples_processed"),
|
|
"frequency_grid_hz": search.get("frequency_grid_hz"),
|
|
"background_score_p99": search.get("background_score_p99"),
|
|
"candidates": candidates,
|
|
"verifications": report.get("verifications"),
|
|
"capture_sha256": report.get("capture_sha256"),
|
|
"reference_sha256": report.get("reference_sha256"),
|
|
"analysis_seconds": report.get("analysis_seconds"),
|
|
"peak_process_memory_bytes": report.get("peak_process_memory_bytes"),
|
|
"peak_additional_memory_bytes": report.get("peak_additional_memory_bytes"),
|
|
"analyzer_version": report.get("analyzer_version"),
|
|
"report_path": str(report_path),
|
|
"report_bytes": report_path.stat().st_size,
|
|
"report_sha256": _sha256_file(report_path),
|
|
"reused_existing_report": reused_existing_report,
|
|
"automatic_retry": False,
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
if command is not None:
|
|
result["analysis_process"] = _bounded_command(command)
|
|
return result
|
|
|
|
|
|
def run_staircase_search(
|
|
work_root: Path,
|
|
session_id: str,
|
|
expected_rx_sha256: str,
|
|
expected_reference_sha256: str,
|
|
*,
|
|
command_runner: CommandRunner = run_command,
|
|
progress_path: Path | None = None,
|
|
) -> tuple[dict[str, object], list[str]]:
|
|
"""Run the one fixed, software-only staircase analysis."""
|
|
|
|
session_directory = work_root / "rx_capture" / session_id
|
|
capture_path = session_directory / "rf001a_ota_capture.cs8"
|
|
reference_path = work_root / "references" / "lab065_reference_staircase.cs8"
|
|
analyzer_path = Path(__file__).resolve().with_name("rf001_staircase_search.py")
|
|
report_path = session_directory / "staircase_search_report.json"
|
|
result: dict[str, object] = {
|
|
"mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH",
|
|
"session_id": session_id,
|
|
"reference_detection": "EXECUTION_FAILED",
|
|
"report_path": str(report_path),
|
|
"automatic_retry": False,
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
errors: list[str] = []
|
|
try:
|
|
for required in (capture_path, reference_path, analyzer_path):
|
|
if not required.is_file():
|
|
raise FileNotFoundError(f"required file is absent: {required}")
|
|
capture_sha256 = _sha256_file(capture_path)
|
|
if capture_sha256 != expected_rx_sha256:
|
|
raise ValueError("RX SHA256 does not match the task")
|
|
reference_sha256 = _sha256_file(reference_path)
|
|
if reference_sha256 != expected_reference_sha256:
|
|
raise ValueError("reference SHA256 does not match the task")
|
|
|
|
if report_path.exists():
|
|
report = json.loads(report_path.read_text(encoding="utf-8-sig"))
|
|
if not isinstance(report, dict):
|
|
raise ValueError("existing staircase report is not a JSON object")
|
|
if report.get("capture_sha256") != expected_rx_sha256:
|
|
raise ValueError("existing report has another RX SHA256")
|
|
if report.get("reference_sha256") != expected_reference_sha256:
|
|
raise ValueError("existing report has another reference SHA256")
|
|
if report.get("hardware_accessed") is not False:
|
|
raise ValueError("existing report does not prove software-only analysis")
|
|
if report.get("rf_tx") != 0:
|
|
raise ValueError("existing report does not preserve rf_tx = 0")
|
|
return (
|
|
_staircase_result_from_report(
|
|
report,
|
|
report_path=report_path,
|
|
command=None,
|
|
reused_existing_report=True,
|
|
),
|
|
[],
|
|
)
|
|
|
|
command_arguments = [
|
|
sys.executable,
|
|
"-B",
|
|
"-X",
|
|
"utf8",
|
|
str(analyzer_path),
|
|
"--reference",
|
|
str(reference_path),
|
|
"--capture",
|
|
str(capture_path),
|
|
"--capture-sha256",
|
|
expected_rx_sha256,
|
|
"--report",
|
|
str(report_path),
|
|
]
|
|
if progress_path is not None:
|
|
command_arguments.extend(("--progress", str(progress_path)))
|
|
command = command_runner(
|
|
command_arguments,
|
|
43_200.0,
|
|
)
|
|
result["analysis_process"] = _bounded_command(command)
|
|
if command.returncode != 0:
|
|
raise RuntimeError(
|
|
f"staircase analyzer exited with {command.returncode}"
|
|
)
|
|
if not report_path.is_file():
|
|
raise RuntimeError("staircase analyzer did not create its report")
|
|
report = json.loads(report_path.read_text(encoding="utf-8-sig"))
|
|
if not isinstance(report, dict):
|
|
raise ValueError("staircase report is not a JSON object")
|
|
if report.get("capture_sha256") != expected_rx_sha256:
|
|
raise ValueError("staircase report RX SHA256 is invalid")
|
|
if report.get("reference_sha256") != expected_reference_sha256:
|
|
raise ValueError("staircase report reference SHA256 is invalid")
|
|
result = _staircase_result_from_report(
|
|
report,
|
|
report_path=report_path,
|
|
command=command,
|
|
reused_existing_report=False,
|
|
)
|
|
except (
|
|
OSError,
|
|
ValueError,
|
|
RuntimeError,
|
|
json.JSONDecodeError,
|
|
subprocess.SubprocessError,
|
|
) as error:
|
|
errors.append(f"{type(error).__name__}: {error}")
|
|
return result, errors
|
|
|
|
|
|
def _read_optional_json(path: Path | None) -> dict[str, object] | None:
|
|
if path is None or not path.is_file():
|
|
return None
|
|
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
return value if isinstance(value, dict) else None
|
|
|
|
|
|
def lightweight_inventory(
|
|
repository: Path,
|
|
*,
|
|
release_id: str | None = None,
|
|
launcher_status_path: Path | None = None,
|
|
) -> dict[str, object]:
|
|
inventory: dict[str, object] = {
|
|
"agent_version": AGENT_VERSION,
|
|
"release_id": release_id,
|
|
"supported_tasks": list(SUPPORTED_TASKS),
|
|
"hostname": socket.gethostname(),
|
|
"account": getpass.getuser(),
|
|
"python_version": platform.python_version(),
|
|
"repository": str(repository),
|
|
"repository_exists": repository.is_dir(),
|
|
"checked_at": utc_now(),
|
|
"rf_tx": 0,
|
|
}
|
|
try:
|
|
launcher_status = _read_optional_json(launcher_status_path)
|
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
inventory["launcher_status_error"] = f"{type(error).__name__}: {error}"
|
|
else:
|
|
inventory["launcher_status"] = launcher_status
|
|
return inventory
|
|
|
|
|
|
def _process_exists(process_id: int) -> bool:
|
|
if process_id <= 0:
|
|
return False
|
|
try:
|
|
os.kill(process_id, 0)
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _job_pointer_path(work_root: Path) -> Path:
|
|
return work_root / "agent_jobs" / "active_job.json"
|
|
|
|
|
|
def _job_directory(work_root: Path, task_id: str) -> Path:
|
|
return work_root / "agent_jobs" / task_id
|
|
|
|
|
|
def _load_active_job(work_root: Path) -> dict[str, object] | None:
|
|
pointer = _job_pointer_path(work_root)
|
|
if not pointer.is_file():
|
|
return None
|
|
value = json.loads(pointer.read_text(encoding="utf-8-sig"))
|
|
if not isinstance(value, dict):
|
|
raise ValueError("active job pointer is invalid")
|
|
return value
|
|
|
|
|
|
def _staircase_total_samples(work_root: Path, task: dict[str, object]) -> int:
|
|
session_id = str(task.get("session_id", ""))
|
|
capture = work_root / "rx_capture" / session_id / "rf001a_ota_capture.cs8"
|
|
if not capture.is_file() or capture.stat().st_size % 2:
|
|
return 1
|
|
return max(1, capture.stat().st_size // 2)
|
|
|
|
|
|
def _failed_job_result(
|
|
pointer: dict[str, object],
|
|
*,
|
|
error: str,
|
|
) -> dict[str, object]:
|
|
task = pointer.get("task")
|
|
if not isinstance(task, dict):
|
|
raise ValueError("active job task is invalid")
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": str(task["task_id"]),
|
|
"agent_id": str(task["agent_id"]),
|
|
"status": "FAILED",
|
|
"started_at": str(pointer.get("started_at", utc_now())),
|
|
"finished_at": utc_now(),
|
|
"result": {
|
|
"mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH",
|
|
"session_id": task.get("session_id"),
|
|
"reference_detection": "EXECUTION_FAILED",
|
|
"automatic_retry": False,
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
},
|
|
"errors": [error],
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
def _ensure_job_result(work_root: Path, pointer: dict[str, object]) -> Path | None:
|
|
task = pointer.get("task")
|
|
if not isinstance(task, dict):
|
|
raise ValueError("active job task is invalid")
|
|
directory = _job_directory(work_root, str(task["task_id"]))
|
|
result_path = directory / "result.json"
|
|
if result_path.is_file():
|
|
return result_path
|
|
process_id = pointer.get("pid")
|
|
if type(process_id) is int and _process_exists(process_id):
|
|
return None
|
|
atomic_write_json(
|
|
result_path,
|
|
_failed_job_result(
|
|
pointer,
|
|
error="background staircase process ended without a result; not retried",
|
|
),
|
|
)
|
|
return result_path
|
|
|
|
|
|
def _active_task_payload(
|
|
work_root: Path,
|
|
pointer: dict[str, object],
|
|
) -> dict[str, object]:
|
|
task = pointer.get("task")
|
|
if not isinstance(task, dict):
|
|
raise ValueError("active job task is invalid")
|
|
directory = _job_directory(work_root, str(task["task_id"]))
|
|
progress_path = directory / "progress.json"
|
|
total = _staircase_total_samples(work_root, task)
|
|
processed = 0
|
|
phase = "STARTING"
|
|
if progress_path.is_file():
|
|
progress = json.loads(progress_path.read_text(encoding="utf-8-sig"))
|
|
if isinstance(progress, dict):
|
|
candidate_total = progress.get("total_samples")
|
|
candidate_processed = progress.get("processed_samples")
|
|
if type(candidate_total) is int and candidate_total > 0:
|
|
total = candidate_total
|
|
if type(candidate_processed) is int:
|
|
processed = min(total, max(0, candidate_processed))
|
|
phase = str(progress.get("phase", "RUNNING"))
|
|
result_path = directory / "result.json"
|
|
state = "RESULT_PENDING" if result_path.is_file() else "RUNNING"
|
|
return {
|
|
"task_id": str(task["task_id"]),
|
|
"kind": STAIRCASE_SEARCH_TASK,
|
|
"state": state,
|
|
"phase": phase,
|
|
"processed_samples": processed,
|
|
"total_samples": total,
|
|
"last_error": None,
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
def start_staircase_background_job(
|
|
task: dict[str, object],
|
|
*,
|
|
agent_id: str,
|
|
work_root: Path,
|
|
) -> dict[str, object]:
|
|
if _load_active_job(work_root) is not None:
|
|
raise RuntimeError("another background job is already active")
|
|
task_id = str(task.get("task_id", ""))
|
|
directory = _job_directory(work_root, task_id)
|
|
directory.mkdir(parents=True, exist_ok=False)
|
|
spec_path = directory / "job_spec.json"
|
|
result_path = directory / "result.json"
|
|
progress_path = directory / "progress.json"
|
|
spec = {
|
|
"schema_version": 1,
|
|
"task": task,
|
|
"agent_id": agent_id,
|
|
"work_root": str(work_root),
|
|
"progress_path": str(progress_path),
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
atomic_write_json(spec_path, spec)
|
|
worker = Path(__file__).resolve().with_name("rf001_staircase_job.py")
|
|
if not worker.is_file():
|
|
raise FileNotFoundError(f"staircase worker is absent: {worker}")
|
|
log_stream = (directory / "worker.log").open(
|
|
"a", encoding="utf-8", buffering=1
|
|
)
|
|
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
pointer: dict[str, object] = {
|
|
"schema_version": 1,
|
|
"task": task,
|
|
"pid": None,
|
|
"started_at": utc_now(),
|
|
"automatic_retry": False,
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
# Persist ownership before creating the child. If the agent stops at any
|
|
# point after this write, its replacement sees the pointer and must not
|
|
# start a second analyzer.
|
|
atomic_write_json(_job_pointer_path(work_root), pointer)
|
|
process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-u",
|
|
"-B",
|
|
"-X",
|
|
"utf8",
|
|
str(worker),
|
|
"--job-spec",
|
|
str(spec_path),
|
|
"--result",
|
|
str(result_path),
|
|
],
|
|
cwd=ROOT,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=log_stream,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
creationflags=creation_flags,
|
|
)
|
|
log_stream.close()
|
|
pointer["pid"] = process.pid
|
|
atomic_write_json(_job_pointer_path(work_root), pointer)
|
|
return pointer
|
|
|
|
|
|
def deliver_background_result(
|
|
arguments: argparse.Namespace,
|
|
token: bytes,
|
|
pointer: dict[str, object],
|
|
) -> bool:
|
|
result_path = _ensure_job_result(arguments.work_root, pointer)
|
|
if result_path is None:
|
|
return False
|
|
result = json.loads(result_path.read_text(encoding="utf-8-sig"))
|
|
if not isinstance(result, dict):
|
|
raise ValueError("background result is invalid")
|
|
acknowledgement = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/agent/result",
|
|
result,
|
|
timeout=arguments.timeout,
|
|
)
|
|
if acknowledgement.get("accepted") is not True:
|
|
raise RuntimeError("server did not accept background result")
|
|
task = pointer.get("task")
|
|
if not isinstance(task, dict):
|
|
raise ValueError("active job task is invalid")
|
|
directory = _job_directory(arguments.work_root, str(task["task_id"]))
|
|
atomic_write_json(
|
|
directory / "delivery_receipt.json",
|
|
{
|
|
"delivered_at": utc_now(),
|
|
"acknowledgement": acknowledgement,
|
|
"rf_tx": 0,
|
|
},
|
|
)
|
|
os.replace(_job_pointer_path(arguments.work_root), directory / "delivered_job.json")
|
|
return True
|
|
|
|
|
|
def _validate_update_task(task: dict[str, object], agent_id: str) -> None:
|
|
if task.get("kind") != UPDATE_AGENT_TASK:
|
|
raise ProtocolError("task is not UPDATE_AGENT")
|
|
if task.get("agent_id") != agent_id:
|
|
raise ProtocolError("update task is addressed to another agent")
|
|
if task.get("protocol_version") != PROTOCOL_VERSION:
|
|
raise ProtocolError("update task protocol version is unsupported")
|
|
if task.get("compatible_protocol_version") != PROTOCOL_VERSION:
|
|
raise ProtocolError("release protocol version is incompatible")
|
|
if task.get("automatic_retry") is not False:
|
|
raise ProtocolError("agent update must prohibit automatic retry")
|
|
if task.get("hardware_accessed") is not False or task.get("rf_tx") != 0:
|
|
raise ProtocolError("agent update must be software-only")
|
|
if SHA256_PATTERN.fullmatch(str(task.get("package_sha256", ""))) is None:
|
|
raise ProtocolError("update package SHA256 is invalid")
|
|
if SHA256_PATTERN.fullmatch(
|
|
str(task.get("content_manifest_sha256", ""))
|
|
) is None:
|
|
raise ProtocolError("update content manifest SHA256 is invalid")
|
|
package_bytes = task.get("package_bytes")
|
|
if type(package_bytes) is not int or package_bytes <= 0:
|
|
raise ProtocolError("update package size is invalid")
|
|
expires = task.get("approval_expires_at")
|
|
if not isinstance(expires, str):
|
|
raise ProtocolError("update approval expiry is invalid")
|
|
if datetime.fromisoformat(expires) < datetime.now(timezone.utc):
|
|
raise ProtocolError("update approval expired")
|
|
|
|
|
|
def stage_agent_update(
|
|
task: dict[str, object],
|
|
*,
|
|
arguments: argparse.Namespace,
|
|
token: bytes,
|
|
command_runner: CommandRunner = run_command,
|
|
) -> tuple[dict[str, object], list[str], dict[str, object] | None]:
|
|
result: dict[str, object] = {
|
|
"mode": "FIXED_AGENT_UPDATE",
|
|
"update_status": "EXECUTION_FAILED",
|
|
"automatic_retry": False,
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
errors: list[str] = []
|
|
launcher_request: dict[str, object] | None = None
|
|
try:
|
|
_validate_update_task(task, arguments.agent_id)
|
|
if arguments.install_root is None or arguments.launcher_request is None:
|
|
raise RuntimeError("versioned launcher paths are not configured")
|
|
release_id = str(task["release_id"])
|
|
agent_version = str(task["agent_version"])
|
|
install_root = arguments.install_root.resolve()
|
|
releases_root = install_root / "versions"
|
|
final_release = releases_root / release_id
|
|
ready_path = final_release / "READY.json"
|
|
if ready_path.is_file():
|
|
ready = json.loads(ready_path.read_text(encoding="utf-8-sig"))
|
|
if not isinstance(ready, dict):
|
|
raise ValueError("existing release READY marker is invalid")
|
|
if (
|
|
ready.get("release_id") != release_id
|
|
or ready.get("agent_version") != agent_version
|
|
or ready.get("package_sha256") != task["package_sha256"]
|
|
):
|
|
raise ValueError("existing release differs from the approved release")
|
|
update_status = "ALREADY_STAGED"
|
|
else:
|
|
quoted_release = quote(release_id, safe="")
|
|
quoted_agent = quote(arguments.agent_id, safe="")
|
|
manifest_target = (
|
|
f"/api/v1/agent/releases/{quoted_release}/manifest"
|
|
f"?agent_id={quoted_agent}"
|
|
)
|
|
manifest_response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"GET",
|
|
manifest_target,
|
|
timeout=arguments.timeout,
|
|
)
|
|
manifest = manifest_response.get("manifest")
|
|
signature = manifest_response.get("manifest_signature")
|
|
if not isinstance(manifest, dict) or not isinstance(signature, str):
|
|
raise ReleaseValidationError("signed release manifest is invalid")
|
|
verify_update_manifest_signature(token, manifest, signature)
|
|
expected = {
|
|
"release_id": release_id,
|
|
"agent_version": agent_version,
|
|
"protocol_version": task["compatible_protocol_version"],
|
|
"package_sha256": task["package_sha256"],
|
|
"package_bytes": task["package_bytes"],
|
|
"content_manifest_sha256": task["content_manifest_sha256"],
|
|
"package_kind": "AGENT_RELEASE",
|
|
}
|
|
for field, expected_value in expected.items():
|
|
if manifest.get(field) != expected_value:
|
|
raise ReleaseValidationError(
|
|
f"signed release manifest field mismatch: {field}"
|
|
)
|
|
required_free = (
|
|
int(task["package_bytes"])
|
|
+ int(manifest.get("uncompressed_bytes", 0))
|
|
+ 32 * 1024 * 1024
|
|
)
|
|
if shutil.disk_usage(install_root).free < required_free:
|
|
raise RuntimeError("insufficient free space for staged update")
|
|
|
|
staging = install_root / "staging" / str(task["task_id"])
|
|
staging.mkdir(parents=True, exist_ok=True)
|
|
package_target = (
|
|
f"/api/v1/agent/releases/{quoted_release}/package"
|
|
f"?agent_id={quoted_agent}"
|
|
)
|
|
inspection = None
|
|
extracted = None
|
|
last_error: Exception | None = None
|
|
download_state_path = staging / "download_state.json"
|
|
download_state = _read_optional_json(download_state_path) or {
|
|
"schema_version": 1,
|
|
"network_attempts": 0,
|
|
"maximum_network_attempts": 2,
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
network_attempts = download_state.get("network_attempts")
|
|
if type(network_attempts) is not int or not 0 <= network_attempts <= 2:
|
|
raise ValueError("staged download attempt counter is invalid")
|
|
while network_attempts < 2:
|
|
attempt = network_attempts + 1
|
|
download = staging / f"package_attempt_{attempt}.zip"
|
|
if download.exists():
|
|
last_error = RuntimeError(
|
|
f"download attempt {attempt} was interrupted"
|
|
)
|
|
network_attempts = attempt
|
|
download_state["network_attempts"] = network_attempts
|
|
atomic_write_json(download_state_path, download_state)
|
|
continue
|
|
network_attempts = attempt
|
|
download_state["network_attempts"] = network_attempts
|
|
download_state["last_attempt_at"] = utc_now()
|
|
atomic_write_json(download_state_path, download_state)
|
|
try:
|
|
request_file(
|
|
arguments.server,
|
|
token,
|
|
package_target,
|
|
download,
|
|
expected_bytes=int(task["package_bytes"]),
|
|
timeout=max(30.0, arguments.timeout),
|
|
)
|
|
if update_sha256_file(download) != task["package_sha256"]:
|
|
raise ReleaseValidationError("downloaded package SHA256 mismatch")
|
|
extracted = staging / f"extracted_attempt_{attempt}"
|
|
inspection = inspect_release_archive(
|
|
download,
|
|
expected_package_sha256=str(task["package_sha256"]),
|
|
expected_package_bytes=int(task["package_bytes"]),
|
|
expected_content_manifest_sha256=str(
|
|
task["content_manifest_sha256"]
|
|
),
|
|
extraction_root=extracted,
|
|
)
|
|
break
|
|
except (OSError, RuntimeError, ReleaseValidationError) as error:
|
|
last_error = error
|
|
if inspection is None or extracted is None:
|
|
raise RuntimeError(f"release download failed twice: {last_error}")
|
|
if (
|
|
inspection.release_id != release_id
|
|
or inspection.agent_version != agent_version
|
|
or inspection.protocol_version != PROTOCOL_VERSION
|
|
):
|
|
raise ReleaseValidationError("extracted release identity mismatch")
|
|
self_test = command_runner(
|
|
[
|
|
sys.executable,
|
|
"-B",
|
|
"-X",
|
|
"utf8",
|
|
str(extracted / "app" / "tools" / "rf001_rx_http_agent.py"),
|
|
"--self-test",
|
|
],
|
|
120.0,
|
|
)
|
|
result["self_test"] = _bounded_command(self_test)
|
|
if self_test.returncode != 0:
|
|
raise RuntimeError(
|
|
f"new agent self-test exited with {self_test.returncode}"
|
|
)
|
|
ready = {
|
|
"schema_version": 1,
|
|
"status": "READY",
|
|
"release_id": release_id,
|
|
"agent_version": agent_version,
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"package_sha256": inspection.package_sha256,
|
|
"content_manifest_sha256": inspection.content_manifest_sha256,
|
|
"verified_at": utc_now(),
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
atomic_write_json(extracted / "READY.json", ready)
|
|
releases_root.mkdir(parents=True, exist_ok=True)
|
|
os.replace(extracted, final_release)
|
|
atomic_write_json(
|
|
install_root / "update_receipts" / f"{task['task_id']}.json",
|
|
{**ready, "task_id": task["task_id"]},
|
|
)
|
|
update_status = "STAGED"
|
|
launcher_status = _read_optional_json(arguments.launcher_status)
|
|
active_release = (
|
|
launcher_status.get("active_release_id")
|
|
if isinstance(launcher_status, dict)
|
|
else arguments.release_id
|
|
)
|
|
if active_release == release_id:
|
|
update_status = "ALREADY_INSTALLED"
|
|
else:
|
|
launcher_request = {
|
|
"schema_version": 1,
|
|
"task_id": task["task_id"],
|
|
"from_release_id": active_release,
|
|
"release_id": release_id,
|
|
"agent_version": agent_version,
|
|
"package_sha256": task["package_sha256"],
|
|
"safe_to_switch": True,
|
|
"requested_at": utc_now(),
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
}
|
|
result.update(
|
|
{
|
|
"update_status": update_status,
|
|
"release_id": release_id,
|
|
"agent_version": agent_version,
|
|
"package_sha256": task["package_sha256"],
|
|
"launcher_switch_requested": launcher_request is not None,
|
|
}
|
|
)
|
|
except (
|
|
OSError,
|
|
ValueError,
|
|
RuntimeError,
|
|
ProtocolError,
|
|
ReleaseValidationError,
|
|
subprocess.SubprocessError,
|
|
json.JSONDecodeError,
|
|
) as error:
|
|
errors.append(f"{type(error).__name__}: {error}")
|
|
return result, errors, launcher_request
|
|
|
|
|
|
def execute_task(
|
|
task: dict[str, object],
|
|
*,
|
|
agent_id: str,
|
|
repository: Path,
|
|
hackrf_info_path: Path | None,
|
|
hackrf_clock_path: Path | None = None,
|
|
hackrf_transfer_path: Path | None = None,
|
|
work_root: Path = DEFAULT_WORK_ROOT,
|
|
command_runner: CommandRunner = run_command,
|
|
) -> dict[str, object]:
|
|
if task.get("protocol_version") != PROTOCOL_VERSION:
|
|
raise ProtocolError("task protocol version is not supported")
|
|
if task.get("agent_id") != agent_id:
|
|
raise ProtocolError("task is addressed to another agent")
|
|
if task.get("rf_tx") != 0:
|
|
raise ProtocolError("task does not preserve rf_tx = 0")
|
|
task_id = task.get("task_id")
|
|
if not isinstance(task_id, str):
|
|
raise ProtocolError("task_id is missing")
|
|
started_at = utc_now()
|
|
kind = task.get("kind")
|
|
if kind == INVENTORY_TASK:
|
|
include = task.get("include_hackrf_info")
|
|
if type(include) is not bool:
|
|
raise ProtocolError("include_hackrf_info must be boolean")
|
|
task_result, errors = collect_inventory(
|
|
repository,
|
|
include_hackrf_info=include,
|
|
hackrf_info_path=hackrf_info_path,
|
|
command_runner=command_runner,
|
|
)
|
|
elif kind == OFFLINE_VERIFY_TASK:
|
|
expected_head = task.get("expected_head")
|
|
if not isinstance(expected_head, str) or re.fullmatch(
|
|
r"[0-9a-f]{40}", expected_head
|
|
) is None:
|
|
raise ProtocolError("expected_head is invalid")
|
|
task_result, errors = run_offline_verify(
|
|
repository,
|
|
work_root,
|
|
task_id,
|
|
expected_head,
|
|
command_runner=command_runner,
|
|
)
|
|
elif kind == RX_CAPTURE_START_TASK:
|
|
expected_head = task.get("expected_head")
|
|
session_id = task.get("session_id")
|
|
rx_serial = task.get("rx_serial")
|
|
if task.get("rx_authorized") is not True:
|
|
raise ProtocolError("RX capture task is not explicitly authorized")
|
|
if task.get("automatic_retry") is not False:
|
|
raise ProtocolError("RX capture task must prohibit automatic retry")
|
|
if not isinstance(expected_head, str) or re.fullmatch(
|
|
r"[0-9a-f]{40}", expected_head
|
|
) is None:
|
|
raise ProtocolError("expected_head is invalid")
|
|
if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch(
|
|
session_id
|
|
) is None:
|
|
raise ProtocolError("session_id is invalid")
|
|
if not isinstance(rx_serial, str):
|
|
raise ProtocolError("rx_serial is invalid")
|
|
metadata = read_git_metadata(repository)
|
|
if metadata["branch"] != "video-development":
|
|
raise ProtocolError("repository branch is not video-development")
|
|
if metadata["head"] != expected_head:
|
|
raise ProtocolError("repository HEAD does not match the capture task")
|
|
task_result, errors = start_rx_capture(
|
|
repository,
|
|
work_root,
|
|
session_id,
|
|
rx_serial,
|
|
hackrf_info_path=hackrf_info_path,
|
|
hackrf_clock_path=hackrf_clock_path,
|
|
hackrf_transfer_path=hackrf_transfer_path,
|
|
command_runner=command_runner,
|
|
)
|
|
task_result["git"] = metadata
|
|
elif kind == RX_CAPTURE_STATUS_TASK:
|
|
session_id = task.get("session_id")
|
|
if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch(
|
|
session_id
|
|
) is None:
|
|
raise ProtocolError("session_id is invalid")
|
|
task_result, errors = read_capture_status(work_root, session_id)
|
|
elif kind == CAPTURE_DECODE_TASK:
|
|
session_id = task.get("session_id")
|
|
tx_started_at = task.get("tx_started_at")
|
|
tx_finished_at = task.get("tx_finished_at")
|
|
if not all(
|
|
isinstance(value, str)
|
|
for value in (session_id, tx_started_at, tx_finished_at)
|
|
):
|
|
raise ProtocolError("capture decode task fields are invalid")
|
|
if SESSION_ID_PATTERN.fullmatch(str(session_id)) is None:
|
|
raise ProtocolError("session_id is invalid")
|
|
task_result, errors = decode_completed_capture(
|
|
repository,
|
|
work_root,
|
|
str(session_id),
|
|
str(tx_started_at),
|
|
str(tx_finished_at),
|
|
command_runner=command_runner,
|
|
)
|
|
elif kind == CAPTURE_DIAGNOSTICS_TASK:
|
|
session_id = task.get("session_id")
|
|
tx_started_at = task.get("tx_started_at")
|
|
tx_finished_at = task.get("tx_finished_at")
|
|
if not all(
|
|
isinstance(value, str)
|
|
for value in (session_id, tx_started_at, tx_finished_at)
|
|
):
|
|
raise ProtocolError("capture diagnostics task fields are invalid")
|
|
if SESSION_ID_PATTERN.fullmatch(str(session_id)) is None:
|
|
raise ProtocolError("session_id is invalid")
|
|
task_result, errors = read_capture_diagnostics(
|
|
work_root,
|
|
str(session_id),
|
|
str(tx_started_at),
|
|
str(tx_finished_at),
|
|
)
|
|
elif kind == STAIRCASE_SEARCH_TASK:
|
|
session_id = task.get("session_id")
|
|
expected_rx_sha256 = task.get("expected_rx_sha256")
|
|
expected_reference_sha256 = task.get("expected_reference_sha256")
|
|
if task.get("automatic_retry") is not False:
|
|
raise ProtocolError("staircase search must prohibit automatic retry")
|
|
if task.get("hardware_accessed") is not False:
|
|
raise ProtocolError("staircase search must be software-only")
|
|
if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch(
|
|
session_id
|
|
) is None:
|
|
raise ProtocolError("session_id is invalid")
|
|
if not isinstance(expected_rx_sha256, str) or SHA256_PATTERN.fullmatch(
|
|
expected_rx_sha256
|
|
) is None:
|
|
raise ProtocolError("expected_rx_sha256 is invalid")
|
|
if not isinstance(
|
|
expected_reference_sha256, str
|
|
) or SHA256_PATTERN.fullmatch(expected_reference_sha256) is None:
|
|
raise ProtocolError("expected_reference_sha256 is invalid")
|
|
task_result, errors = run_staircase_search(
|
|
work_root,
|
|
session_id,
|
|
expected_rx_sha256,
|
|
expected_reference_sha256,
|
|
command_runner=command_runner,
|
|
)
|
|
else:
|
|
raise ProtocolError("unsupported task kind")
|
|
return {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": task_id,
|
|
"agent_id": agent_id,
|
|
"status": "COMPLETE" if not errors else "FAILED",
|
|
"started_at": started_at,
|
|
"finished_at": utc_now(),
|
|
"result": task_result,
|
|
"errors": errors,
|
|
"rf_tx": 0,
|
|
}
|
|
|
|
|
|
def run_once(arguments: argparse.Namespace, token: bytes) -> str:
|
|
pointer = _load_active_job(arguments.work_root)
|
|
if pointer is not None and deliver_background_result(arguments, token, pointer):
|
|
pointer = None
|
|
elif pointer is not None:
|
|
_ensure_job_result(arguments.work_root, pointer)
|
|
active_task = (
|
|
_active_task_payload(arguments.work_root, pointer)
|
|
if pointer is not None
|
|
else None
|
|
)
|
|
write_health(arguments, active_task)
|
|
check_in = {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"agent_id": arguments.agent_id,
|
|
"inventory": lightweight_inventory(
|
|
arguments.repository,
|
|
release_id=getattr(arguments, "release_id", None),
|
|
launcher_status_path=getattr(arguments, "launcher_status", None),
|
|
),
|
|
"active_task": active_task,
|
|
"rf_tx": 0,
|
|
}
|
|
response = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/agent/check-in",
|
|
check_in,
|
|
timeout=arguments.timeout,
|
|
)
|
|
if response.get("protocol_version") != PROTOCOL_VERSION:
|
|
raise ProtocolError("server protocol version is not supported")
|
|
if response.get("rf_tx") != 0:
|
|
raise ProtocolError("server response does not preserve rf_tx = 0")
|
|
task = response.get("task")
|
|
if active_task is not None:
|
|
if task is not None:
|
|
raise ProtocolError("server issued a task while another task is active")
|
|
return "TASK_RUNNING"
|
|
if task is None:
|
|
if not arguments.quiet_idle:
|
|
print("TASK = NONE")
|
|
print("RF TX = 0")
|
|
return "NO_TASK"
|
|
if not isinstance(task, dict):
|
|
raise ProtocolError("server task is not a JSON object")
|
|
|
|
if task.get("kind") == STAIRCASE_SEARCH_TASK:
|
|
if task.get("automatic_retry") is not False:
|
|
raise ProtocolError("staircase search must prohibit automatic retry")
|
|
session_id = task.get("session_id")
|
|
if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch(
|
|
session_id
|
|
) is None:
|
|
raise ProtocolError("session_id is invalid")
|
|
for field in ("expected_rx_sha256", "expected_reference_sha256"):
|
|
value = task.get(field)
|
|
if not isinstance(value, str) or SHA256_PATTERN.fullmatch(value) is None:
|
|
raise ProtocolError(f"{field} is invalid")
|
|
start_staircase_background_job(
|
|
task,
|
|
agent_id=arguments.agent_id,
|
|
work_root=arguments.work_root,
|
|
)
|
|
print(f"TASK ID = {task['task_id']}")
|
|
print("TASK STATUS = STARTED_BACKGROUND")
|
|
print("HARDWARE ACCESSED = false")
|
|
print("RF TX = 0")
|
|
return "STARTED_BACKGROUND"
|
|
|
|
launcher_request = None
|
|
if task.get("kind") == UPDATE_AGENT_TASK:
|
|
task_result, errors, launcher_request = stage_agent_update(
|
|
task,
|
|
arguments=arguments,
|
|
token=token,
|
|
)
|
|
result = {
|
|
"protocol_version": PROTOCOL_VERSION,
|
|
"task_id": task["task_id"],
|
|
"agent_id": arguments.agent_id,
|
|
"status": "COMPLETE" if not errors else "FAILED",
|
|
"started_at": utc_now(),
|
|
"finished_at": utc_now(),
|
|
"result": task_result,
|
|
"errors": errors,
|
|
"rf_tx": 0,
|
|
}
|
|
else:
|
|
result = execute_task(
|
|
task,
|
|
agent_id=arguments.agent_id,
|
|
repository=arguments.repository,
|
|
hackrf_info_path=arguments.hackrf_info,
|
|
hackrf_clock_path=arguments.hackrf_clock,
|
|
hackrf_transfer_path=arguments.hackrf_transfer,
|
|
work_root=arguments.work_root,
|
|
)
|
|
|
|
acknowledgement = request_json(
|
|
arguments.server,
|
|
token,
|
|
"POST",
|
|
"/api/v1/agent/result",
|
|
result,
|
|
timeout=arguments.timeout,
|
|
)
|
|
if acknowledgement.get("accepted") is not True:
|
|
raise RuntimeError("server did not accept task result")
|
|
if launcher_request is not None:
|
|
launcher_request_path = arguments.launcher_request
|
|
if launcher_request_path is None:
|
|
raise RuntimeError("launcher request path is not configured")
|
|
if launcher_request_path.exists():
|
|
existing = json.loads(
|
|
launcher_request_path.read_text(encoding="utf-8-sig")
|
|
)
|
|
if existing != launcher_request:
|
|
raise RuntimeError("another launcher request already exists")
|
|
else:
|
|
atomic_write_json(launcher_request_path, launcher_request)
|
|
print(f"TASK ID = {result['task_id']}")
|
|
print(f"TASK STATUS = {result['status']}")
|
|
print(f"RESULT ACCEPTED = {acknowledgement.get('accepted')}")
|
|
print("RF TX = 0")
|
|
return str(result["status"])
|
|
|
|
|
|
def write_health(
|
|
arguments: argparse.Namespace,
|
|
active_task: dict[str, object] | None,
|
|
) -> None:
|
|
health_file = getattr(arguments, "health_file", None)
|
|
if health_file is None:
|
|
return
|
|
atomic_write_json(
|
|
health_file,
|
|
{
|
|
"schema_version": 1,
|
|
"state": "RUNNING",
|
|
"pid": os.getpid(),
|
|
"agent_version": AGENT_VERSION,
|
|
"release_id": getattr(arguments, "release_id", None),
|
|
"launch_nonce": getattr(arguments, "launch_nonce", None),
|
|
"busy": active_task is not None,
|
|
"active_task_id": (
|
|
active_task.get("task_id") if active_task is not None else None
|
|
),
|
|
"updated_at": utc_now(),
|
|
"hardware_accessed": False,
|
|
"rf_tx": 0,
|
|
},
|
|
)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--server", default=DEFAULT_SERVER)
|
|
parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH)
|
|
parser.add_argument(
|
|
"--agent-id",
|
|
default=normalized_agent_id(socket.gethostname()),
|
|
type=normalized_agent_id,
|
|
)
|
|
parser.add_argument("--repository", type=Path, default=ROOT)
|
|
parser.add_argument("--hackrf-info", type=Path)
|
|
parser.add_argument("--hackrf-clock", type=Path)
|
|
parser.add_argument("--hackrf-transfer", type=Path)
|
|
parser.add_argument("--work-root", type=Path, default=DEFAULT_WORK_ROOT)
|
|
parser.add_argument("--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL_SECONDS)
|
|
parser.add_argument("--timeout", type=float, default=10.0)
|
|
parser.add_argument("--once", action="store_true")
|
|
parser.add_argument("--quiet-idle", action="store_true")
|
|
parser.add_argument("--log-file", type=Path)
|
|
parser.add_argument("--release-id")
|
|
parser.add_argument("--launch-nonce")
|
|
parser.add_argument("--health-file", type=Path)
|
|
parser.add_argument("--launcher-status", type=Path)
|
|
parser.add_argument("--launcher-request", type=Path)
|
|
parser.add_argument("--install-root", type=Path)
|
|
parser.add_argument("--self-test", action="store_true")
|
|
return parser
|
|
|
|
|
|
def run_self_test() -> None:
|
|
required = (
|
|
Path(__file__).resolve().with_name("rf001_agent_update.py"),
|
|
Path(__file__).resolve().with_name("rf001_http_control.py"),
|
|
Path(__file__).resolve().with_name("rf001_staircase_job.py"),
|
|
Path(__file__).resolve().with_name("rf001_staircase_search.py"),
|
|
)
|
|
missing = [str(path) for path in required if not path.is_file()]
|
|
if missing:
|
|
raise FileNotFoundError(f"self-test files are absent: {missing}")
|
|
if UPDATE_AGENT_TASK not in SUPPORTED_TASKS:
|
|
raise RuntimeError("UPDATE_AGENT is not enabled")
|
|
if STAIRCASE_SEARCH_TASK not in SUPPORTED_TASKS:
|
|
raise RuntimeError("STAIRCASE_SEARCH is not enabled")
|
|
print(f"AGENT VERSION = {AGENT_VERSION}")
|
|
print("SELF TEST = PASS")
|
|
print("HARDWARE ACCESSED = false")
|
|
print("RF TX = 0")
|
|
|
|
|
|
def main() -> None:
|
|
arguments = build_parser().parse_args()
|
|
if arguments.self_test:
|
|
run_self_test()
|
|
return
|
|
if arguments.log_file is not None:
|
|
redirect_output(arguments.log_file)
|
|
if arguments.poll_interval <= 0:
|
|
raise ValueError("poll interval must be positive")
|
|
if arguments.timeout <= 0:
|
|
raise ValueError("timeout must be positive")
|
|
token = read_token(arguments.token_file)
|
|
print(f"AGENT ID = {arguments.agent_id}")
|
|
print(f"SERVER = {arguments.server}")
|
|
print(
|
|
f"AGENT VERSION = {AGENT_VERSION}\n"
|
|
f"SUPPORTED TASKS = {', '.join(SUPPORTED_TASKS)}"
|
|
)
|
|
print("RF TX = 0")
|
|
while True:
|
|
try:
|
|
run_once(arguments, token)
|
|
except (OSError, RuntimeError, ProtocolError, json.JSONDecodeError) as error:
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
print(f"{timestamp} AGENT ERROR = {type(error).__name__}: {error}")
|
|
print("RF TX = 0")
|
|
if arguments.once:
|
|
raise SystemExit(1) from error
|
|
if arguments.once:
|
|
return
|
|
time.sleep(arguments.poll_interval)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|