Harden RF handoff and stream OTA decode
This commit is contained in:
@@ -31,6 +31,12 @@ from tools.qpsk_rf_common import (
|
||||
GAP_MS, PACKET_COUNT, PAYLOAD_BYTES, SEED, TEST_ID,
|
||||
payload_for_sequence, read_iq_file, sha256_file,
|
||||
)
|
||||
from tools.rf001a_streaming import (
|
||||
DEFAULT_CHUNK_SAMPLES,
|
||||
MemoryPeakSampler,
|
||||
cs8_sample_count,
|
||||
decode_cs8_streaming,
|
||||
)
|
||||
|
||||
|
||||
def _bit_errors(observed: bytes, expected: bytes) -> tuple[int, int]:
|
||||
@@ -124,7 +130,7 @@ def summarize_result(
|
||||
unique_sequences = set(sequences)
|
||||
cfo = [row["cfo_hz"] for row in rows]
|
||||
report: dict[str, Any] = {
|
||||
"rf001_status": "PREPARED_NOT_RUN",
|
||||
"rf001_status": "OFFLINE_ANALYZED",
|
||||
"test_id": truth.get("test_id") if truth is not None else None,
|
||||
"modulation": "QPSK",
|
||||
"symbol_rate": 500_000,
|
||||
@@ -242,10 +248,32 @@ def decode_file(
|
||||
iq_format: str = "c64le",
|
||||
truth_metadata_path: Path | None = None,
|
||||
decoder_backend: str = "numba",
|
||||
capture_started_at: str | None = None,
|
||||
tx_started_at: str | None = None,
|
||||
tx_finished_at: str | None = None,
|
||||
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
|
||||
) -> dict[str, Any]:
|
||||
"""Открывает только файл; truth читается после вызова рабочего приёмника."""
|
||||
iq, clipping_codes = read_iq_file(iq_path, iq_format)
|
||||
reception = receive_iq_stream(iq, decoder_backend=decoder_backend)
|
||||
"""Декодирует c64le целиком, а большие cs8 — ограниченными окнами."""
|
||||
|
||||
streaming = None
|
||||
with MemoryPeakSampler() as memory:
|
||||
if iq_format == "cs8":
|
||||
streaming = decode_cs8_streaming(
|
||||
iq_path,
|
||||
capture_started_at=capture_started_at,
|
||||
tx_started_at=tx_started_at,
|
||||
tx_finished_at=tx_finished_at,
|
||||
decoder_backend=decoder_backend,
|
||||
chunk_samples=chunk_samples,
|
||||
)
|
||||
reception = streaming.reception
|
||||
iq_sample_count = cs8_sample_count(iq_path)
|
||||
clipping_codes = streaming.clipping_codes
|
||||
iq = None
|
||||
else:
|
||||
iq, clipping_codes = read_iq_file(iq_path, iq_format)
|
||||
reception = receive_iq_stream(iq, decoder_backend=decoder_backend)
|
||||
iq_sample_count = int(iq.size)
|
||||
truth = (json.loads(truth_metadata_path.read_text(encoding="utf-8"))
|
||||
if truth_metadata_path is not None else None)
|
||||
if truth is not None and (
|
||||
@@ -262,17 +290,38 @@ def decode_file(
|
||||
or truth.get("seed") != SEED
|
||||
):
|
||||
raise ValueError("TX-ведомость не соответствует режиму RF-001")
|
||||
raw_bits = (_raw_ber_after_decode(iq, reception, int(truth["seed"]), decoder_backend)
|
||||
if truth is not None else None)
|
||||
return summarize_result(
|
||||
raw_bits = (
|
||||
_raw_ber_after_decode(iq, reception, int(truth["seed"]), decoder_backend)
|
||||
if truth is not None and iq is not None
|
||||
else None
|
||||
)
|
||||
report = summarize_result(
|
||||
reception,
|
||||
iq_sample_count=int(iq.size),
|
||||
iq_sample_count=iq_sample_count,
|
||||
iq_sha256=sha256_file(iq_path),
|
||||
iq_format=iq_format,
|
||||
clipping_codes=clipping_codes,
|
||||
truth=truth,
|
||||
raw_bits=raw_bits,
|
||||
)
|
||||
report["peak_analysis_memory_bytes"] = memory.peak_bytes
|
||||
report["peak_additional_ram_bytes"] = memory.additional_peak_bytes
|
||||
if streaming is not None:
|
||||
report.update({
|
||||
"streaming_decoder": True,
|
||||
"window_source": streaming.window_source,
|
||||
"analysis_windows": [
|
||||
{"start_sample": window.start, "stop_sample": window.stop}
|
||||
for window in streaming.windows
|
||||
],
|
||||
"overlap_samples": streaming.overlap_samples,
|
||||
"maximum_loaded_complex_samples": streaming.maximum_loaded_samples,
|
||||
"maximum_loaded_iq_bytes": streaming.maximum_loaded_samples * 8,
|
||||
"raw_ber_scope": "NOT_COMPUTED_FOR_STREAMING_WINDOWS",
|
||||
})
|
||||
else:
|
||||
report["streaming_decoder"] = False
|
||||
return report
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -282,6 +331,10 @@ def main() -> None:
|
||||
parser.add_argument("--truth-metadata", type=Path)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
parser.add_argument("--numba-packages", type=Path)
|
||||
parser.add_argument("--capture-started-at")
|
||||
parser.add_argument("--tx-started-at")
|
||||
parser.add_argument("--tx-finished-at")
|
||||
parser.add_argument("--chunk-samples", type=int, default=DEFAULT_CHUNK_SAMPLES)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.report.exists():
|
||||
parser.error("отчёт уже существует; перезапись запрещена")
|
||||
@@ -290,7 +343,11 @@ def main() -> None:
|
||||
parser.error("каталог пакетов Numba отсутствует")
|
||||
sys.path.insert(0, str(arguments.numba_packages.resolve()))
|
||||
result = decode_file(arguments.iq_file, iq_format=arguments.iq_format,
|
||||
truth_metadata_path=arguments.truth_metadata)
|
||||
truth_metadata_path=arguments.truth_metadata,
|
||||
capture_started_at=arguments.capture_started_at,
|
||||
tx_started_at=arguments.tx_started_at,
|
||||
tx_finished_at=arguments.tx_finished_at,
|
||||
chunk_samples=arguments.chunk_samples)
|
||||
arguments.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
arguments.report.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
|
||||
363
tools/rf001a_streaming.py
Normal file
363
tools/rf001a_streaming.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Потоковое чтение больших cs8-захватов и оконный приём RF-001A."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
import ctypes
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol.video003_modem import SAMPLE_RATE_HZ, build_frame, samples_per_symbol
|
||||
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE, shape_packet
|
||||
from protocol.video005_receiver import IqStreamResult, ReceivedIqPacket, receive_iq_stream
|
||||
from tools.qpsk_rf_common import PAYLOAD_BYTES, SEED, payload_for_sequence
|
||||
|
||||
|
||||
DEFAULT_CHUNK_SAMPLES = 4_000_000
|
||||
LOCATOR_BLOCK_SAMPLES = 400_000
|
||||
TIMESTAMP_MARGIN_SECONDS = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SampleWindow:
|
||||
start: int
|
||||
stop: int
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return self.stop - self.start
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamingReception:
|
||||
reception: IqStreamResult
|
||||
windows: tuple[SampleWindow, ...]
|
||||
window_source: str
|
||||
overlap_samples: int
|
||||
maximum_loaded_samples: int
|
||||
clipping_codes: int
|
||||
|
||||
|
||||
class MemoryPeakSampler:
|
||||
"""Измеряет пиковый working set процесса во время анализа."""
|
||||
|
||||
def __init__(self, poll_seconds: float = 0.02) -> None:
|
||||
self.poll_seconds = poll_seconds
|
||||
self.baseline_bytes = current_process_rss_bytes()
|
||||
self.peak_bytes = self.baseline_bytes
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._sample, daemon=True)
|
||||
|
||||
def _sample(self) -> None:
|
||||
while not self._stop.wait(self.poll_seconds):
|
||||
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||
|
||||
def __enter__(self) -> MemoryPeakSampler:
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||
self._stop.set()
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
@property
|
||||
def additional_peak_bytes(self) -> int:
|
||||
return max(0, self.peak_bytes - self.baseline_bytes)
|
||||
|
||||
|
||||
def current_process_rss_bytes() -> int:
|
||||
"""Возвращает текущий working set процесса без сторонних библиотек."""
|
||||
|
||||
if not hasattr(ctypes, "windll"):
|
||||
return 0
|
||||
|
||||
class ProcessMemoryCounters(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cb", ctypes.c_ulong),
|
||||
("page_fault_count", ctypes.c_ulong),
|
||||
("peak_working_set_size", ctypes.c_size_t),
|
||||
("working_set_size", ctypes.c_size_t),
|
||||
("quota_peak_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_peak_non_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_non_paged_pool_usage", ctypes.c_size_t),
|
||||
("pagefile_usage", ctypes.c_size_t),
|
||||
("peak_pagefile_usage", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
counters = ProcessMemoryCounters()
|
||||
counters.cb = ctypes.sizeof(counters)
|
||||
get_current_process = ctypes.windll.kernel32.GetCurrentProcess # type: ignore[attr-defined]
|
||||
get_current_process.restype = ctypes.c_void_p
|
||||
get_process_memory_info = ctypes.windll.psapi.GetProcessMemoryInfo # type: ignore[attr-defined]
|
||||
get_process_memory_info.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(ProcessMemoryCounters),
|
||||
ctypes.c_ulong,
|
||||
]
|
||||
get_process_memory_info.restype = ctypes.c_int
|
||||
succeeded = get_process_memory_info(
|
||||
get_current_process(),
|
||||
ctypes.byref(counters),
|
||||
counters.cb,
|
||||
)
|
||||
return int(counters.working_set_size) if succeeded else 0
|
||||
|
||||
|
||||
def cs8_sample_count(path: Path) -> int:
|
||||
size = path.stat().st_size
|
||||
if size == 0 or size % 2:
|
||||
raise ValueError("cs8 должен содержать целое ненулевое число пар I/Q")
|
||||
return size // 2
|
||||
|
||||
|
||||
def read_cs8_window(raw: np.memmap, start: int, stop: int) -> np.ndarray:
|
||||
"""Преобразует в complex64 только заданное окно cs8."""
|
||||
|
||||
total_samples = raw.size // 2
|
||||
if start < 0 or stop <= start or stop > total_samples:
|
||||
raise ValueError("границы окна cs8 выходят за пределы файла")
|
||||
values = np.asarray(raw[start * 2:stop * 2], dtype=np.int8)
|
||||
real = values[0::2].astype(np.float32)
|
||||
imaginary = values[1::2].astype(np.float32)
|
||||
return ((real + 1j * imaginary) / 128).astype(np.complex64)
|
||||
|
||||
|
||||
def rf001_packet_waveform_samples() -> int:
|
||||
"""Вычисляет длину волны из настоящего 256-байтного пакета RF-001."""
|
||||
|
||||
if PAYLOAD_BYTES != 256:
|
||||
raise AssertionError("канонический RF-001 payload неожиданно изменился")
|
||||
payload = payload_for_sequence(SEED, 0)
|
||||
frame = build_frame(payload, "QPSK", "2/3", sequence_number=0)
|
||||
return int(shape_packet(frame).size)
|
||||
|
||||
|
||||
def required_overlap_samples() -> int:
|
||||
"""Перекрывает целую максимальную волну одного пакета RF-001."""
|
||||
|
||||
return rf001_packet_waveform_samples()
|
||||
|
||||
|
||||
def _parse_utc(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp должен содержать часовой пояс")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def timestamp_sample_window(
|
||||
*,
|
||||
capture_started_at: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
total_samples: int,
|
||||
sample_rate: int = SAMPLE_RATE_HZ,
|
||||
margin_seconds: float = TIMESTAMP_MARGIN_SECONDS,
|
||||
) -> SampleWindow:
|
||||
"""Переводит UTC-границы TX в безопасное окно отсчётов RX."""
|
||||
|
||||
if total_samples <= 0 or sample_rate <= 0 or margin_seconds < 0:
|
||||
raise ValueError("параметры timestamp-окна должны быть положительными")
|
||||
capture = _parse_utc(capture_started_at)
|
||||
started = _parse_utc(tx_started_at)
|
||||
finished = _parse_utc(tx_finished_at)
|
||||
if finished < started:
|
||||
raise ValueError("TX_FINISHED_AT не может предшествовать TX_STARTED_AT")
|
||||
margin_samples = round(margin_seconds * sample_rate)
|
||||
start_offset = round((started - capture).total_seconds() * sample_rate)
|
||||
stop_offset = round((finished - capture).total_seconds() * sample_rate)
|
||||
start = max(0, start_offset - margin_samples)
|
||||
stop = min(total_samples, stop_offset + margin_samples)
|
||||
if stop <= start:
|
||||
raise ValueError("TX timestamps не пересекаются с RX capture")
|
||||
return SampleWindow(start, stop)
|
||||
|
||||
|
||||
def _merge_windows(windows: Iterable[SampleWindow]) -> tuple[SampleWindow, ...]:
|
||||
ordered = sorted(windows, key=lambda item: item.start)
|
||||
merged: list[SampleWindow] = []
|
||||
for window in ordered:
|
||||
if not merged or window.start > merged[-1].stop:
|
||||
merged.append(window)
|
||||
else:
|
||||
merged[-1] = SampleWindow(
|
||||
merged[-1].start,
|
||||
max(merged[-1].stop, window.stop),
|
||||
)
|
||||
return tuple(merged)
|
||||
|
||||
|
||||
def locate_power_windows(
|
||||
path: Path,
|
||||
*,
|
||||
block_samples: int = LOCATOR_BLOCK_SAMPLES,
|
||||
padding_seconds: float = 1.5,
|
||||
) -> tuple[SampleWindow, ...]:
|
||||
"""Ищет повышенную мощность по всему cs8-файлу ограниченными блоками."""
|
||||
|
||||
total_samples = cs8_sample_count(path)
|
||||
if block_samples <= 0 or padding_seconds < 0:
|
||||
raise ValueError("параметры locator должны быть неотрицательными")
|
||||
powers: list[float] = []
|
||||
with path.open("rb") as stream:
|
||||
while raw_bytes := stream.read(block_samples * 2):
|
||||
values = np.frombuffer(raw_bytes, dtype=np.int8).astype(np.float32)
|
||||
powers.append(float(np.mean(values * values)))
|
||||
if not powers:
|
||||
return ()
|
||||
power_array = np.asarray(powers, dtype=np.float64)
|
||||
median = float(np.median(power_array))
|
||||
mad = float(np.median(np.abs(power_array - median)))
|
||||
threshold = median + max(6.0 * mad, median * 0.15, 1.0)
|
||||
active = np.flatnonzero(power_array > threshold)
|
||||
if active.size == 0:
|
||||
return ()
|
||||
padding = round(padding_seconds * SAMPLE_RATE_HZ)
|
||||
candidates = (
|
||||
SampleWindow(
|
||||
max(0, int(index) * block_samples - padding),
|
||||
min(total_samples, (int(index) + 1) * block_samples + padding),
|
||||
)
|
||||
for index in active
|
||||
)
|
||||
return _merge_windows(candidates)
|
||||
|
||||
|
||||
def count_cs8_clipping(path: Path, *, block_samples: int = DEFAULT_CHUNK_SAMPLES) -> int:
|
||||
"""Считает rail-коды во всём файле, сохраняя ограниченный объём памяти."""
|
||||
|
||||
cs8_sample_count(path)
|
||||
clipping = 0
|
||||
with path.open("rb") as stream:
|
||||
while raw_bytes := stream.read(block_samples * 2):
|
||||
values = np.frombuffer(raw_bytes, dtype=np.int8)
|
||||
clipping += int(np.count_nonzero((values == -128) | (values == 127)))
|
||||
return clipping
|
||||
|
||||
|
||||
def _is_overlap_duplicate(
|
||||
packets: list[ReceivedIqPacket],
|
||||
candidate: ReceivedIqPacket,
|
||||
) -> bool:
|
||||
tolerance = samples_per_symbol(SYMBOL_RATE)
|
||||
return any(
|
||||
packet.packet == candidate.packet
|
||||
and abs(packet.start_sample - candidate.start_sample) <= tolerance
|
||||
for packet in packets
|
||||
)
|
||||
|
||||
|
||||
def receive_cs8_windows(
|
||||
path: Path,
|
||||
windows: Iterable[SampleWindow],
|
||||
*,
|
||||
decoder_backend: str = "numba",
|
||||
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
|
||||
overlap_samples: int | None = None,
|
||||
) -> StreamingReception:
|
||||
"""Принимает заданные окна частями с перекрытием и удаляет повторы."""
|
||||
|
||||
selected = _merge_windows(windows)
|
||||
overlap = required_overlap_samples() if overlap_samples is None else overlap_samples
|
||||
if chunk_samples <= overlap or overlap < required_overlap_samples():
|
||||
raise ValueError("chunk должен быть длиннее полного overlap RF-001")
|
||||
raw = np.memmap(path, dtype=np.int8, mode="r")
|
||||
packets: list[ReceivedIqPacket] = []
|
||||
candidate_count = 0
|
||||
rejected_candidates = 0
|
||||
stage_time: dict[str, float] = {}
|
||||
maximum_loaded = 0
|
||||
for selected_window in selected:
|
||||
for primary_start in range(selected_window.start, selected_window.stop, chunk_samples):
|
||||
primary_stop = min(selected_window.stop, primary_start + chunk_samples)
|
||||
read_start = max(selected_window.start, primary_start - overlap)
|
||||
read_stop = min(selected_window.stop, primary_stop + overlap)
|
||||
iq = read_cs8_window(raw, read_start, read_stop)
|
||||
maximum_loaded = max(maximum_loaded, int(iq.size))
|
||||
received = receive_iq_stream(
|
||||
iq,
|
||||
carrier_hz=NOMINAL_CARRIER_HZ,
|
||||
decoder_backend=decoder_backend,
|
||||
)
|
||||
candidate_count += received.candidate_count
|
||||
rejected_candidates += received.rejected_candidates
|
||||
for name, duration in received.stage_time_s.items():
|
||||
stage_time[name] = stage_time.get(name, 0.0) + duration
|
||||
for packet in received.packets:
|
||||
global_packet = replace(
|
||||
packet,
|
||||
start_sample=packet.start_sample + read_start,
|
||||
)
|
||||
if not primary_start <= global_packet.start_sample < primary_stop:
|
||||
continue
|
||||
if not _is_overlap_duplicate(packets, global_packet):
|
||||
packets.append(global_packet)
|
||||
del iq
|
||||
packets.sort(key=lambda packet: packet.start_sample)
|
||||
return StreamingReception(
|
||||
IqStreamResult(
|
||||
packets=tuple(packets),
|
||||
candidate_count=candidate_count,
|
||||
rejected_candidates=rejected_candidates,
|
||||
stage_time_s=stage_time,
|
||||
),
|
||||
selected,
|
||||
"EXPLICIT",
|
||||
overlap,
|
||||
maximum_loaded,
|
||||
count_cs8_clipping(path),
|
||||
)
|
||||
|
||||
|
||||
def decode_cs8_streaming(
|
||||
path: Path,
|
||||
*,
|
||||
capture_started_at: str | None = None,
|
||||
tx_started_at: str | None = None,
|
||||
tx_finished_at: str | None = None,
|
||||
decoder_backend: str = "numba",
|
||||
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
|
||||
margin_seconds: float = TIMESTAMP_MARGIN_SECONDS,
|
||||
) -> StreamingReception:
|
||||
"""Выбирает timestamp-окно либо потоковый power locator и запускает приём."""
|
||||
|
||||
total_samples = cs8_sample_count(path)
|
||||
timestamp_values = (capture_started_at, tx_started_at, tx_finished_at)
|
||||
if all(value is not None for value in timestamp_values):
|
||||
windows = (
|
||||
timestamp_sample_window(
|
||||
capture_started_at=str(capture_started_at),
|
||||
tx_started_at=str(tx_started_at),
|
||||
tx_finished_at=str(tx_finished_at),
|
||||
total_samples=total_samples,
|
||||
margin_seconds=margin_seconds,
|
||||
),
|
||||
)
|
||||
source = "TX_TIMESTAMPS"
|
||||
elif any(value is not None for value in timestamp_values):
|
||||
raise ValueError("для timestamp-окна нужны все три времени RX/TX")
|
||||
else:
|
||||
windows = locate_power_windows(path)
|
||||
source = "STREAMING_POWER_LOCATOR"
|
||||
if not windows:
|
||||
return StreamingReception(
|
||||
IqStreamResult((), 0, 0, {}),
|
||||
(),
|
||||
source,
|
||||
required_overlap_samples(),
|
||||
0,
|
||||
count_cs8_clipping(path),
|
||||
)
|
||||
reception = receive_cs8_windows(
|
||||
path,
|
||||
windows,
|
||||
decoder_backend=decoder_backend,
|
||||
chunk_samples=chunk_samples,
|
||||
)
|
||||
return replace(reception, window_source=source)
|
||||
@@ -14,8 +14,9 @@ from typing import Any, Mapping
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_003"
|
||||
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_003"
|
||||
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_004"
|
||||
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_004"
|
||||
EXPECTED_CAPTURE_DURATION_SECONDS = 60.0
|
||||
CHANNEL_TEST_TASK_ID = "RF_HANDOFF_CHANNEL_TEST_001"
|
||||
READY_STATE = "RX_READY_FOR_TX"
|
||||
COMPLETE_STATE = "CAPTURE_COMPLETE"
|
||||
@@ -25,6 +26,21 @@ RESULT_PATHS = frozenset((RESULT_JSON_PATH, RESULT_MARKDOWN_PATH))
|
||||
MAX_RESULT_BYTES = 64 * 1024
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_TRANSIENT_PUSH_ERRORS = (
|
||||
"authentication failed",
|
||||
"failed to authenticate",
|
||||
"could not resolve host",
|
||||
"connection reset",
|
||||
"connection timed out",
|
||||
"failed to connect",
|
||||
"remote end hung up",
|
||||
"rpc failed",
|
||||
"schannel",
|
||||
"ssl",
|
||||
"tls",
|
||||
"the requested url returned error: 5",
|
||||
"unable to access",
|
||||
)
|
||||
|
||||
|
||||
class ResultChannelError(RuntimeError):
|
||||
@@ -319,7 +335,11 @@ def evaluate_ready(
|
||||
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
|
||||
published = _parse_utc(result.published_at, "published_at")
|
||||
planned_duration = (expected_end - started).total_seconds()
|
||||
if not 119.0 <= planned_duration <= 121.0:
|
||||
if not (
|
||||
EXPECTED_CAPTURE_DURATION_SECONDS - 1.0
|
||||
<= planned_duration
|
||||
<= EXPECTED_CAPTURE_DURATION_SECONDS + 1.0
|
||||
):
|
||||
return ReadyEvaluation(False, "WRONG_CAPTURE_WINDOW")
|
||||
age = (now_utc - published).total_seconds()
|
||||
if age < -5.0:
|
||||
@@ -386,6 +406,32 @@ class GitResultChannel:
|
||||
def branch_exists(self) -> bool:
|
||||
return self.remote_head() is not None
|
||||
|
||||
def verify_write_access(self) -> str:
|
||||
"""Проверить право записи dry-run push без коммита и изменения ветки."""
|
||||
|
||||
remote_head = self.fetch()
|
||||
dry_run = self._run(
|
||||
[
|
||||
"push",
|
||||
"--dry-run",
|
||||
"--porcelain",
|
||||
self.remote,
|
||||
f"{remote_head}:refs/heads/{self.branch}",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if dry_run.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
"authenticated result-branch push preflight failed: "
|
||||
f"{dry_run.stderr.strip()}"
|
||||
)
|
||||
confirmed_head = self.remote_head()
|
||||
if confirmed_head != remote_head:
|
||||
raise ResultChannelError(
|
||||
"result branch changed during write preflight"
|
||||
)
|
||||
return remote_head
|
||||
|
||||
def _tracking_reference(self) -> str:
|
||||
return f"refs/remotes/{self.remote}/{self.branch}"
|
||||
|
||||
@@ -553,6 +599,66 @@ class GitResultChannel:
|
||||
return commit
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_transient_push_error(stderr: str) -> bool:
|
||||
normalized = stderr.casefold()
|
||||
return any(pattern in normalized for pattern in _TRANSIENT_PUSH_ERRORS)
|
||||
|
||||
def _push_exact_commit(
|
||||
self,
|
||||
checkout: Path,
|
||||
commit: str,
|
||||
remote_before: str,
|
||||
) -> str:
|
||||
"""Отправить неизменный коммит с одной безопасной повторной попыткой."""
|
||||
|
||||
push_arguments = [
|
||||
"push",
|
||||
"--quiet",
|
||||
self.remote,
|
||||
f"{commit}:refs/heads/{self.branch}",
|
||||
]
|
||||
first = self._run(push_arguments, cwd=checkout, check=False)
|
||||
if first.returncode == 0:
|
||||
remote_after = self.remote_head()
|
||||
if remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote result HEAD does not confirm the published commit"
|
||||
)
|
||||
return commit
|
||||
|
||||
observed_after_failure = self.remote_head()
|
||||
if observed_after_failure == commit:
|
||||
return commit
|
||||
if observed_after_failure != remote_before:
|
||||
raise ResultChannelError(
|
||||
"result push failed and remote branch changed to a conflicting "
|
||||
f"commit: {remote_before} -> {observed_after_failure}"
|
||||
)
|
||||
if not self._is_transient_push_error(first.stderr):
|
||||
raise ResultChannelError(f"result push failed: {first.stderr.strip()}")
|
||||
|
||||
fetched_head = self.fetch()
|
||||
if fetched_head != remote_before:
|
||||
raise ResultChannelError(
|
||||
"remote result branch changed before the single retry"
|
||||
)
|
||||
second = self._run(push_arguments, cwd=checkout, check=False)
|
||||
if second.returncode != 0:
|
||||
observed_after_retry = self.remote_head()
|
||||
if observed_after_retry == commit:
|
||||
return commit
|
||||
raise ResultChannelError(
|
||||
"result push failed after one retry of the same commit: "
|
||||
f"{second.stderr.strip()}"
|
||||
)
|
||||
remote_after = self.remote_head()
|
||||
if remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote result HEAD does not confirm the retried commit"
|
||||
)
|
||||
return commit
|
||||
|
||||
def publish(self, result: RxHandoffResult) -> PublishOutcome:
|
||||
validate_result(result)
|
||||
remote_before = self.fetch()
|
||||
@@ -610,25 +716,11 @@ class GitResultChannel:
|
||||
cwd=checkout,
|
||||
)
|
||||
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
|
||||
push = self._run(
|
||||
[
|
||||
"push",
|
||||
"--quiet",
|
||||
self.remote,
|
||||
f"HEAD:refs/heads/{self.branch}",
|
||||
],
|
||||
cwd=checkout,
|
||||
check=False,
|
||||
remote_after = self._push_exact_commit(
|
||||
checkout,
|
||||
commit,
|
||||
remote_before,
|
||||
)
|
||||
if push.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"result push failed: {push.stderr.strip()}"
|
||||
)
|
||||
remote_after = self.remote_head()
|
||||
if remote_after is None or remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote result HEAD does not confirm the published commit"
|
||||
)
|
||||
return PublishOutcome("PUBLISHED", commit, remote_after)
|
||||
finally:
|
||||
if worktree_added:
|
||||
|
||||
@@ -23,6 +23,8 @@ if str(ROOT) not in sys.path:
|
||||
from tools.rf_handoff_channel import ( # noqa: E402
|
||||
EXPECTED_RX_TASK_ID,
|
||||
GitResultChannel,
|
||||
READY_STATE,
|
||||
RxHandoffResult,
|
||||
load_result,
|
||||
)
|
||||
from tools.tx_handoff_channel import ( # noqa: E402
|
||||
@@ -571,6 +573,7 @@ def publish_result(arguments: argparse.Namespace) -> int:
|
||||
task = parse_task((repository / DEFAULT_TASK_PATH).read_text(encoding="utf-8-sig"))
|
||||
state = load_state(arguments.state_file.resolve())
|
||||
result = load_result(arguments.publish_result.resolve())
|
||||
require_live_ready_window(result)
|
||||
if task.task_id != result.task_id:
|
||||
raise ValueError("result TASK_ID disagrees with current RX task")
|
||||
if task.allow_push != "RX_RESULT_ONLY" or task.rf_tx:
|
||||
@@ -613,10 +616,55 @@ def publish_result(arguments: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def require_live_ready_window(
|
||||
result: RxHandoffResult,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
"""Запретить READY после окончания конечного окна захвата."""
|
||||
|
||||
if result.state != READY_STATE:
|
||||
return
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
expected_end = datetime.fromisoformat(
|
||||
result.expected_capture_end.replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
if current >= expected_end:
|
||||
raise ValueError("READY capture window has already ended")
|
||||
|
||||
|
||||
def verify_result_channel_write_access(
|
||||
repository: Path,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
result_branch: str = "rx-node",
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> str:
|
||||
"""Проверить право публикации результата RX без изменения ветки."""
|
||||
|
||||
channel = GitResultChannel(
|
||||
repository,
|
||||
remote=remote,
|
||||
branch=result_branch,
|
||||
)
|
||||
remote_head = channel.verify_write_access()
|
||||
output_function("RX-NODE WRITE PREFLIGHT = PASS")
|
||||
output_function(f"RX-NODE REMOTE HEAD = {remote_head}")
|
||||
output_function("RF TX = 0")
|
||||
return remote_head
|
||||
|
||||
|
||||
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
state_path = arguments.state_file.resolve()
|
||||
if arguments.publish_result is not None:
|
||||
return publish_result(arguments)
|
||||
if arguments.check_result_write:
|
||||
verify_result_channel_write_access(
|
||||
arguments.repository.resolve(),
|
||||
remote=arguments.remote,
|
||||
result_branch=arguments.result_branch,
|
||||
)
|
||||
return 0
|
||||
if arguments.request_stop:
|
||||
stopped = request_stop(state_path, arguments.stop_timeout)
|
||||
print("WATCHER STOPPED" if stopped else "WATCHER STOP TIMEOUT")
|
||||
@@ -670,6 +718,12 @@ def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
task_text, source_head = source.read()
|
||||
task = parse_task(task_text)
|
||||
if task.task_id == EXPECTED_RX_TASK_ID and not arguments.dry_run:
|
||||
if is_new_task(task, state):
|
||||
verify_result_channel_write_access(
|
||||
arguments.repository.resolve(),
|
||||
remote=arguments.remote,
|
||||
result_branch=arguments.result_branch,
|
||||
)
|
||||
confirm_main_channel_test(
|
||||
arguments.repository.resolve(),
|
||||
state,
|
||||
@@ -726,6 +780,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--stop-timeout", type=float, default=35.0)
|
||||
parser.add_argument("--mark-completed")
|
||||
parser.add_argument("--publish-result", type=Path)
|
||||
parser.add_argument("--check-result-write", action="store_true")
|
||||
parser.add_argument("--result-branch", default="rx-node")
|
||||
parser.add_argument("--acknowledgement-branch", default="tx-node")
|
||||
return parser
|
||||
|
||||
@@ -37,9 +37,13 @@ from tools.tx_handoff_channel import ( # noqa: E402
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
CHANNEL_READY_OBSERVED,
|
||||
RX_READY_OBSERVED,
|
||||
TX_AUTHORIZATION_CREATED,
|
||||
TX_ACK_SCHEMA_VERSION,
|
||||
TX_AUTHORIZED,
|
||||
TX_COMPLETE,
|
||||
TX_STARTED,
|
||||
GitAcknowledgementChannel,
|
||||
TxHandoffAcknowledgement,
|
||||
load_acknowledgement,
|
||||
)
|
||||
|
||||
|
||||
@@ -445,12 +449,12 @@ def acknowledgement_for_result(
|
||||
elif status == "RED_APPROVAL_BLOCKED":
|
||||
acknowledgement_state = RX_READY_OBSERVED
|
||||
elif status == "TX_AUTHORIZATION_PREPARED":
|
||||
acknowledgement_state = TX_AUTHORIZATION_CREATED
|
||||
acknowledgement_state = TX_AUTHORIZED
|
||||
rf_tx_authorized = True
|
||||
if acknowledgement_state is None:
|
||||
return None
|
||||
return TxHandoffAcknowledgement(
|
||||
schema_version=1,
|
||||
schema_version=TX_ACK_SCHEMA_VERSION,
|
||||
task_id=result.task_id,
|
||||
state=acknowledgement_state,
|
||||
source_rx_commit=result_head,
|
||||
@@ -461,6 +465,7 @@ def acknowledgement_for_result(
|
||||
rf_tx_authorized=rf_tx_authorized,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
tx_task_id=(EXPECTED_TX_TASK_ID if rf_tx_authorized else None),
|
||||
)
|
||||
|
||||
|
||||
@@ -536,6 +541,42 @@ def process_history_cycle(
|
||||
|
||||
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
state_path = arguments.state_file.resolve()
|
||||
if arguments.publish_tx_event is not None:
|
||||
if arguments.dry_run:
|
||||
raise ValueError("--publish-tx-event cannot be combined with --dry-run")
|
||||
repository = arguments.repository.resolve()
|
||||
event = load_acknowledgement(arguments.publish_tx_event.resolve())
|
||||
if event.state not in {TX_STARTED, TX_COMPLETE}:
|
||||
raise ValueError("published execution event has the wrong state")
|
||||
branch = _git_output(repository, ["branch", "--show-current"])
|
||||
if branch != arguments.source_branch:
|
||||
raise ValueError("TX event publisher is on the wrong source branch")
|
||||
if _git_output(repository, ["status", "--porcelain", "--untracked-files=no"]):
|
||||
raise ValueError("tracked worktree must be clean before TX event publication")
|
||||
_git_output(
|
||||
repository,
|
||||
["fetch", "--quiet", arguments.remote, arguments.source_branch],
|
||||
)
|
||||
head = _git_output(repository, ["rev-parse", "HEAD"])
|
||||
remote_head = _git_output(
|
||||
repository,
|
||||
["rev-parse", f"{arguments.remote}/{arguments.source_branch}"],
|
||||
)
|
||||
if head != remote_head:
|
||||
raise ValueError("current HEAD disagrees with remote source branch")
|
||||
if event.source_head != head:
|
||||
raise ValueError("TX event SOURCE_HEAD disagrees with current HEAD")
|
||||
publisher = GitAcknowledgementChannel(
|
||||
repository,
|
||||
remote=arguments.remote,
|
||||
branch=arguments.acknowledgement_branch,
|
||||
)
|
||||
outcome = publisher.publish_execution_event(event)
|
||||
print(f"TX EVENT PUBLISH = {outcome.status}")
|
||||
print(f"TX EVENT STATE = {event.state}")
|
||||
print(f"TX EVENT HEAD = {outcome.commit}")
|
||||
print(f"TX EVENT REMOTE HEAD = {outcome.remote_head}")
|
||||
return 0
|
||||
if arguments.request_stop:
|
||||
stopped = request_stop(state_path, arguments.stop_timeout)
|
||||
print("TX WATCHER STOPPED" if stopped else "TX WATCHER STOP TIMEOUT")
|
||||
@@ -664,6 +705,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--result-file", type=Path)
|
||||
parser.add_argument("--publish-tx-event", type=Path)
|
||||
parser.add_argument("--approval", choices=("authorize", "deny"))
|
||||
parser.add_argument("--request-stop", action="store_true")
|
||||
parser.add_argument("--stop-timeout", type=float, default=15.0)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from dataclasses import asdict, dataclass, fields, replace
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
@@ -22,7 +22,8 @@ from tools.rf_handoff_channel import (
|
||||
)
|
||||
|
||||
|
||||
TX_ACK_SCHEMA_VERSION = 1
|
||||
TX_ACK_SCHEMA_VERSION = 2
|
||||
LEGACY_TX_ACK_SCHEMA_VERSION = 1
|
||||
TX_RESULT_JSON_PATH = "docs/handoff/results/TX_RESULT.json"
|
||||
TX_RESULT_MARKDOWN_PATH = "docs/handoff/results/TX_RESULT.md"
|
||||
TX_RESULT_PATHS = frozenset((TX_RESULT_JSON_PATH, TX_RESULT_MARKDOWN_PATH))
|
||||
@@ -32,6 +33,7 @@ CHANNEL_READY_OBSERVED = "CHANNEL_READY_OBSERVED"
|
||||
CHANNEL_COMPLETE_OBSERVED = "CHANNEL_COMPLETE_OBSERVED"
|
||||
RX_READY_OBSERVED = "RX_READY_OBSERVED"
|
||||
TX_AUTHORIZATION_CREATED = "TX_AUTHORIZATION_CREATED"
|
||||
TX_AUTHORIZED = "TX_AUTHORIZED"
|
||||
TX_STARTED = "TX_STARTED"
|
||||
TX_COMPLETE = "TX_COMPLETE"
|
||||
TX_ACK_STATES = frozenset(
|
||||
@@ -40,6 +42,7 @@ TX_ACK_STATES = frozenset(
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
RX_READY_OBSERVED,
|
||||
TX_AUTHORIZATION_CREATED,
|
||||
TX_AUTHORIZED,
|
||||
TX_STARTED,
|
||||
TX_COMPLETE,
|
||||
)
|
||||
@@ -74,6 +77,12 @@ class TxHandoffAcknowledgement:
|
||||
rf_tx_authorized: bool
|
||||
rf_tx_started: bool
|
||||
rf_tx: int
|
||||
tx_task_id: str | None = None
|
||||
tx_started_at: str | None = None
|
||||
tx_finished_at: str | None = None
|
||||
tx_process_exit_code: int | None = None
|
||||
tx_underruns: int | None = None
|
||||
tx_errors: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -99,8 +108,13 @@ def validate_acknowledgement(
|
||||
) -> TxHandoffAcknowledgement:
|
||||
if type(acknowledgement.schema_version) is not int:
|
||||
raise ValueError("schema_version must contain an integer")
|
||||
if acknowledgement.schema_version != TX_ACK_SCHEMA_VERSION:
|
||||
raise ValueError(f"schema_version must be {TX_ACK_SCHEMA_VERSION}")
|
||||
if acknowledgement.schema_version not in {
|
||||
LEGACY_TX_ACK_SCHEMA_VERSION,
|
||||
TX_ACK_SCHEMA_VERSION,
|
||||
}:
|
||||
raise ValueError(
|
||||
"schema_version must be a supported TX acknowledgement version"
|
||||
)
|
||||
string_values = (
|
||||
acknowledgement.task_id,
|
||||
acknowledgement.state,
|
||||
@@ -121,7 +135,7 @@ def validate_acknowledgement(
|
||||
raise ValueError("source_rx_digest must be a lowercase SHA256")
|
||||
if not _HEAD_RE.fullmatch(acknowledgement.source_head):
|
||||
raise ValueError("source_head must be a lowercase 40-character hash")
|
||||
_parse_utc(acknowledgement.observed_at)
|
||||
observed_at = _parse_utc(acknowledgement.observed_at)
|
||||
if (
|
||||
type(acknowledgement.main_watcher_pid) is not int
|
||||
or acknowledgement.main_watcher_pid <= 0
|
||||
@@ -133,6 +147,58 @@ def validate_acknowledgement(
|
||||
raise ValueError("rf_tx_started must contain a boolean")
|
||||
if type(acknowledgement.rf_tx) is not int or acknowledgement.rf_tx not in {0, 1}:
|
||||
raise ValueError("rf_tx must be 0 or 1")
|
||||
optional_integers = (
|
||||
acknowledgement.tx_process_exit_code,
|
||||
acknowledgement.tx_underruns,
|
||||
acknowledgement.tx_errors,
|
||||
)
|
||||
if any(value is not None and type(value) is not int for value in optional_integers):
|
||||
raise ValueError("TX counters and exit code must contain integers or null")
|
||||
if any(
|
||||
value is not None and value < 0
|
||||
for value in (acknowledgement.tx_underruns, acknowledgement.tx_errors)
|
||||
):
|
||||
raise ValueError("TX error counters cannot be negative")
|
||||
optional_strings = (
|
||||
acknowledgement.tx_task_id,
|
||||
acknowledgement.tx_started_at,
|
||||
acknowledgement.tx_finished_at,
|
||||
)
|
||||
if any(value is not None and type(value) is not str for value in optional_strings):
|
||||
raise ValueError("optional TX fields must contain strings or null")
|
||||
if (
|
||||
acknowledgement.tx_task_id is not None
|
||||
and not _TASK_ID_RE.fullmatch(acknowledgement.tx_task_id)
|
||||
):
|
||||
raise ValueError("tx_task_id is invalid")
|
||||
tx_started_at = (
|
||||
_parse_utc(acknowledgement.tx_started_at)
|
||||
if acknowledgement.tx_started_at is not None
|
||||
else None
|
||||
)
|
||||
tx_finished_at = (
|
||||
_parse_utc(acknowledgement.tx_finished_at)
|
||||
if acknowledgement.tx_finished_at is not None
|
||||
else None
|
||||
)
|
||||
if (
|
||||
tx_started_at is not None
|
||||
and tx_finished_at is not None
|
||||
and tx_finished_at < tx_started_at
|
||||
):
|
||||
raise ValueError("tx_finished_at cannot precede tx_started_at")
|
||||
if acknowledgement.schema_version == LEGACY_TX_ACK_SCHEMA_VERSION and any(
|
||||
value is not None
|
||||
for value in (
|
||||
acknowledgement.tx_task_id,
|
||||
acknowledgement.tx_started_at,
|
||||
acknowledgement.tx_finished_at,
|
||||
acknowledgement.tx_process_exit_code,
|
||||
acknowledgement.tx_underruns,
|
||||
acknowledgement.tx_errors,
|
||||
)
|
||||
):
|
||||
raise ValueError("legacy acknowledgement cannot contain TX event metadata")
|
||||
|
||||
if (
|
||||
acknowledgement.state
|
||||
@@ -153,41 +219,146 @@ def validate_acknowledgement(
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
} and acknowledgement.task_id != CHANNEL_TEST_TASK_ID:
|
||||
raise ValueError("channel acknowledgement has the wrong task_id")
|
||||
if (
|
||||
acknowledgement.state == TX_AUTHORIZATION_CREATED
|
||||
and (
|
||||
not acknowledgement.rf_tx_authorized
|
||||
or acknowledgement.rf_tx_started
|
||||
or acknowledgement.rf_tx != 0
|
||||
)
|
||||
if acknowledgement.state == TX_AUTHORIZATION_CREATED and (
|
||||
acknowledgement.schema_version != LEGACY_TX_ACK_SCHEMA_VERSION
|
||||
or not acknowledgement.rf_tx_authorized
|
||||
or acknowledgement.rf_tx_started
|
||||
or acknowledgement.rf_tx != 0
|
||||
):
|
||||
raise ValueError("legacy TX authorization state is inconsistent")
|
||||
if acknowledgement.state == TX_AUTHORIZED and (
|
||||
acknowledgement.schema_version != TX_ACK_SCHEMA_VERSION
|
||||
or not acknowledgement.rf_tx_authorized
|
||||
or acknowledgement.rf_tx_started
|
||||
or acknowledgement.rf_tx != 0
|
||||
or acknowledgement.tx_task_id is None
|
||||
or acknowledgement.tx_started_at is not None
|
||||
or acknowledgement.tx_finished_at is not None
|
||||
or acknowledgement.tx_process_exit_code is not None
|
||||
):
|
||||
raise ValueError("TX authorization state is inconsistent")
|
||||
if acknowledgement.state in {TX_STARTED, TX_COMPLETE} and not (
|
||||
acknowledgement.rf_tx_authorized
|
||||
and acknowledgement.rf_tx_started
|
||||
and acknowledgement.rf_tx == 1
|
||||
if acknowledgement.state == TX_STARTED and not (
|
||||
acknowledgement.rf_tx_authorized
|
||||
and acknowledgement.rf_tx_started
|
||||
and acknowledgement.rf_tx == 1
|
||||
and acknowledgement.tx_task_id is not None
|
||||
and acknowledgement.tx_started_at is not None
|
||||
and acknowledgement.tx_finished_at is None
|
||||
and acknowledgement.tx_process_exit_code is None
|
||||
):
|
||||
raise ValueError("TX execution state is inconsistent")
|
||||
raise ValueError("TX start state is inconsistent")
|
||||
if acknowledgement.state == TX_STARTED and (
|
||||
tx_started_at is None or observed_at < tx_started_at
|
||||
):
|
||||
raise ValueError("TX start observation cannot precede process start")
|
||||
if acknowledgement.state == TX_COMPLETE and not (
|
||||
acknowledgement.rf_tx_authorized
|
||||
and acknowledgement.rf_tx_started
|
||||
and acknowledgement.rf_tx == 1
|
||||
and acknowledgement.tx_task_id is not None
|
||||
and acknowledgement.tx_started_at is not None
|
||||
and acknowledgement.tx_finished_at is not None
|
||||
and acknowledgement.tx_process_exit_code is not None
|
||||
and acknowledgement.tx_underruns is not None
|
||||
and acknowledgement.tx_errors is not None
|
||||
):
|
||||
raise ValueError("TX completion state is inconsistent")
|
||||
if acknowledgement.state == TX_COMPLETE and (
|
||||
tx_finished_at is None or observed_at < tx_finished_at
|
||||
):
|
||||
raise ValueError("TX completion observation cannot precede process finish")
|
||||
if acknowledgement.state in {
|
||||
CHANNEL_READY_OBSERVED,
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
RX_READY_OBSERVED,
|
||||
} and any(
|
||||
value is not None
|
||||
for value in (
|
||||
acknowledgement.tx_task_id,
|
||||
acknowledgement.tx_started_at,
|
||||
acknowledgement.tx_finished_at,
|
||||
acknowledgement.tx_process_exit_code,
|
||||
acknowledgement.tx_underruns,
|
||||
acknowledgement.tx_errors,
|
||||
)
|
||||
):
|
||||
raise ValueError("observation state cannot contain TX event metadata")
|
||||
return acknowledgement
|
||||
|
||||
|
||||
def build_tx_execution_event(
|
||||
authorization: TxHandoffAcknowledgement,
|
||||
*,
|
||||
state: str,
|
||||
observed_at: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str | None = None,
|
||||
process_exit_code: int | None = None,
|
||||
underruns: int | None = None,
|
||||
errors: int | None = None,
|
||||
) -> TxHandoffAcknowledgement:
|
||||
"""Построить строгое событие TX_STARTED или TX_COMPLETE из разрешения."""
|
||||
|
||||
validate_acknowledgement(authorization)
|
||||
if authorization.state != TX_AUTHORIZED:
|
||||
raise ValueError("TX execution event requires a TX_AUTHORIZED source")
|
||||
if state not in {TX_STARTED, TX_COMPLETE}:
|
||||
raise ValueError("TX execution event state is not supported")
|
||||
event = replace(
|
||||
authorization,
|
||||
state=state,
|
||||
observed_at=observed_at,
|
||||
rf_tx_started=True,
|
||||
rf_tx=1,
|
||||
tx_started_at=tx_started_at,
|
||||
tx_finished_at=tx_finished_at,
|
||||
tx_process_exit_code=process_exit_code,
|
||||
tx_underruns=underruns,
|
||||
tx_errors=errors,
|
||||
)
|
||||
return validate_acknowledgement(event)
|
||||
|
||||
|
||||
def acknowledgement_from_mapping(
|
||||
raw: Mapping[str, Any],
|
||||
) -> TxHandoffAcknowledgement:
|
||||
expected_fields = {field.name for field in fields(TxHandoffAcknowledgement)}
|
||||
optional_event_fields = {
|
||||
"tx_task_id",
|
||||
"tx_started_at",
|
||||
"tx_finished_at",
|
||||
"tx_process_exit_code",
|
||||
"tx_underruns",
|
||||
"tx_errors",
|
||||
}
|
||||
actual_fields = set(raw)
|
||||
if actual_fields != expected_fields:
|
||||
schema_version = raw.get("schema_version")
|
||||
legacy_fields = expected_fields - optional_event_fields
|
||||
if schema_version == LEGACY_TX_ACK_SCHEMA_VERSION and actual_fields == legacy_fields:
|
||||
normalized = {**raw, **dict.fromkeys(optional_event_fields)}
|
||||
elif actual_fields == expected_fields:
|
||||
normalized = dict(raw)
|
||||
else:
|
||||
missing = sorted(expected_fields - actual_fields)
|
||||
extra = sorted(actual_fields - expected_fields)
|
||||
raise ValueError(
|
||||
f"acknowledgement fields disagree: missing={missing}, extra={extra}"
|
||||
)
|
||||
acknowledgement = TxHandoffAcknowledgement(
|
||||
**{name: raw[name] for name in expected_fields}
|
||||
**{name: normalized[name] for name in expected_fields}
|
||||
)
|
||||
return validate_acknowledgement(acknowledgement)
|
||||
|
||||
|
||||
def load_acknowledgement(path: Path) -> TxHandoffAcknowledgement:
|
||||
"""Прочитать строгое малое TX-событие из локального JSON."""
|
||||
|
||||
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("TX acknowledgement root must be a JSON object")
|
||||
return acknowledgement_from_mapping(raw)
|
||||
|
||||
|
||||
def canonical_acknowledgement_json(
|
||||
acknowledgement: TxHandoffAcknowledgement,
|
||||
) -> str:
|
||||
@@ -226,6 +397,12 @@ def render_acknowledgement_markdown(
|
||||
f"{'YES' if acknowledgement.rf_tx_authorized else 'NO'}",
|
||||
f"RF_TX_STARTED = {'YES' if acknowledgement.rf_tx_started else 'NO'}",
|
||||
f"RF_TX = {acknowledgement.rf_tx}",
|
||||
f"TX_TASK_ID = {acknowledgement.tx_task_id}",
|
||||
f"TX_STARTED_AT = {acknowledgement.tx_started_at}",
|
||||
f"TX_FINISHED_AT = {acknowledgement.tx_finished_at}",
|
||||
f"TX_PROCESS_EXIT_CODE = {acknowledgement.tx_process_exit_code}",
|
||||
f"TX_UNDERRUNS = {acknowledgement.tx_underruns}",
|
||||
f"TX_ERRORS = {acknowledgement.tx_errors}",
|
||||
"",
|
||||
"Raw IQ, video, waveforms, source code, local state and large logs "
|
||||
"are not stored in this branch.",
|
||||
@@ -368,10 +545,59 @@ class GitAcknowledgementChannel(GitResultChannel):
|
||||
== acknowledgement.rf_tx_authorized
|
||||
and observed.rf_tx_started == acknowledgement.rf_tx_started
|
||||
and observed.rf_tx == acknowledgement.rf_tx
|
||||
and observed.tx_task_id == acknowledgement.tx_task_id
|
||||
and observed.tx_started_at == acknowledgement.tx_started_at
|
||||
and observed.tx_finished_at == acknowledgement.tx_finished_at
|
||||
and observed.tx_process_exit_code
|
||||
== acknowledgement.tx_process_exit_code
|
||||
and observed.tx_underruns == acknowledgement.tx_underruns
|
||||
and observed.tx_errors == acknowledgement.tx_errors
|
||||
):
|
||||
return commit
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _same_execution_binding(
|
||||
left: TxHandoffAcknowledgement,
|
||||
right: TxHandoffAcknowledgement,
|
||||
) -> bool:
|
||||
return (
|
||||
left.task_id == right.task_id
|
||||
and left.source_rx_commit == right.source_rx_commit
|
||||
and left.source_rx_digest == right.source_rx_digest
|
||||
and left.source_head == right.source_head
|
||||
and left.tx_task_id == right.tx_task_id
|
||||
)
|
||||
|
||||
def publish_execution_event(
|
||||
self,
|
||||
event: TxHandoffAcknowledgement,
|
||||
) -> PublishOutcome:
|
||||
"""Опубликовать TX-событие только после связанного TX_AUTHORIZED."""
|
||||
|
||||
validate_acknowledgement(event)
|
||||
if event.state not in {TX_STARTED, TX_COMPLETE}:
|
||||
raise ResultChannelError("execution event must be TX_STARTED or TX_COMPLETE")
|
||||
commits, _remote_head = self.commits_since(None)
|
||||
authorization_found = False
|
||||
start_found = event.state == TX_STARTED
|
||||
for commit in commits:
|
||||
observed, _digest, _head = self.read_commit(commit)
|
||||
if not self._same_execution_binding(observed, event):
|
||||
continue
|
||||
if observed.state == TX_AUTHORIZED:
|
||||
authorization_found = True
|
||||
if (
|
||||
observed.state == TX_STARTED
|
||||
and observed.tx_started_at == event.tx_started_at
|
||||
):
|
||||
start_found = True
|
||||
if not authorization_found:
|
||||
raise ResultChannelError("matching TX_AUTHORIZED event is absent")
|
||||
if not start_found:
|
||||
raise ResultChannelError("matching TX_STARTED event is absent")
|
||||
return self.publish(event)
|
||||
|
||||
def publish(
|
||||
self,
|
||||
acknowledgement: TxHandoffAcknowledgement,
|
||||
@@ -471,25 +697,11 @@ class GitAcknowledgementChannel(GitResultChannel):
|
||||
raise ResultChannelError(
|
||||
"committed tx-node tree violates the path allowlist"
|
||||
)
|
||||
push = self._run(
|
||||
[
|
||||
"push",
|
||||
"--quiet",
|
||||
self.remote,
|
||||
f"HEAD:refs/heads/{self.branch}",
|
||||
],
|
||||
cwd=checkout,
|
||||
check=False,
|
||||
self._push_exact_commit(
|
||||
checkout,
|
||||
commit,
|
||||
remote_before,
|
||||
)
|
||||
if push.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"acknowledgement push failed: {push.stderr.strip()}"
|
||||
)
|
||||
remote_after = self.remote_head()
|
||||
if remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote tx-node HEAD does not confirm the published commit"
|
||||
)
|
||||
return PublishOutcome(
|
||||
"PUBLISHED",
|
||||
commit,
|
||||
|
||||
Reference in New Issue
Block a user