Harden RF handoff and stream OTA decode
This commit is contained in:
@@ -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