Harden RF handoff and stream OTA decode

This commit is contained in:
LittleSam129
2026-09-22 17:43:58 +03:00
parent 168e03d9e7
commit a94f2db4ee
17 changed files with 1548 additions and 497 deletions

View File

@@ -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,