Add MAIN-to-RX acknowledgement channel

This commit is contained in:
LittleSam129
2026-09-22 15:09:37 +03:00
parent a9317989a2
commit 168e03d9e7
9 changed files with 1330 additions and 30 deletions

View File

@@ -20,7 +20,19 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tools.rf_handoff_channel import GitResultChannel, load_result # noqa: E402
from tools.rf_handoff_channel import ( # noqa: E402
EXPECTED_RX_TASK_ID,
GitResultChannel,
load_result,
)
from tools.tx_handoff_channel import ( # noqa: E402
CHANNEL_TEST_COMPLETE_COMMIT,
CHANNEL_TEST_COMPLETE_DIGEST,
CHANNEL_TEST_READY_COMMIT,
CHANNEL_TEST_READY_DIGEST,
GitAcknowledgementChannel,
channel_test_acknowledgements,
)
DEFAULT_TASK_PATH = "docs/handoff/NEXT_RX_TASK.md"
@@ -81,6 +93,10 @@ class WatcherState:
deferred_task_id: str | None = None
running: bool = False
pid: int | None = None
channel_test_ready_digest: str | None = None
channel_test_complete_digest: str | None = None
channel_test_ready_ack_commit: str | None = None
channel_test_complete_ack_commit: str | None = None
@dataclass(frozen=True)
@@ -387,6 +403,70 @@ class GitTaskSource:
return text, head
def confirm_main_channel_test(
repository: Path,
state: WatcherState,
*,
remote: str = "origin",
result_branch: str = "rx-node",
acknowledgement_branch: str = "tx-node",
output_function: Callable[[str], None] = print,
) -> WatcherState:
"""Confirm that MAIN acknowledged both exact channel-test events."""
rx_channel = GitResultChannel(
repository,
remote=remote,
branch=result_branch,
)
rx_channel.fetch()
ready_result, ready_digest, _ready_commit = rx_channel.read_commit(
CHANNEL_TEST_READY_COMMIT
)
complete_result, complete_digest, _complete_commit = rx_channel.read_commit(
CHANNEL_TEST_COMPLETE_COMMIT
)
if ready_digest != CHANNEL_TEST_READY_DIGEST:
raise ValueError("historical channel-test READY digest is wrong")
if complete_digest != CHANNEL_TEST_COMPLETE_DIGEST:
raise ValueError("historical channel-test COMPLETE digest is wrong")
if ready_result.source_head != complete_result.source_head:
raise ValueError("channel-test source HEAD changed between READY and COMPLETE")
acknowledgement_channel = GitAcknowledgementChannel(
repository,
remote=remote,
branch=acknowledgement_branch,
)
confirmations = channel_test_acknowledgements(
acknowledgement_channel,
expected_source_head=ready_result.source_head,
ready_source_commit=CHANNEL_TEST_READY_COMMIT,
ready_source_digest=CHANNEL_TEST_READY_DIGEST,
complete_source_commit=CHANNEL_TEST_COMPLETE_COMMIT,
complete_source_digest=CHANNEL_TEST_COMPLETE_DIGEST,
)
if not confirmations.ready_confirmed:
raise ValueError("MAIN READY acknowledgement is not confirmed")
if not confirmations.complete_confirmed:
raise ValueError("MAIN COMPLETE acknowledgement is not confirmed")
state.channel_test_ready_digest = CHANNEL_TEST_READY_DIGEST
state.channel_test_complete_digest = CHANNEL_TEST_COMPLETE_DIGEST
state.channel_test_ready_ack_commit = confirmations.ready_ack_commit
state.channel_test_complete_ack_commit = confirmations.complete_ack_commit
output_function("MAIN READY RECEIVED = CONFIRMED")
output_function("MAIN COMPLETE RECEIVED = CONFIRMED")
output_function(
f"MAIN READY ACK COMMIT = {confirmations.ready_ack_commit}"
)
output_function(
f"MAIN COMPLETE ACK COMMIT = {confirmations.complete_ack_commit}"
)
output_function("RF TX = 0")
return state
def handle_task(
task_text: str,
source_head: str,
@@ -588,6 +668,16 @@ def run_watcher(arguments: argparse.Namespace) -> int:
else:
source.fetch()
task_text, source_head = source.read()
task = parse_task(task_text)
if task.task_id == EXPECTED_RX_TASK_ID and not arguments.dry_run:
confirm_main_channel_test(
arguments.repository.resolve(),
state,
remote=arguments.remote,
result_branch=arguments.result_branch,
acknowledgement_branch=arguments.acknowledgement_branch,
)
save_state(state_path, state)
status, state = handle_task(
task_text,
source_head,
@@ -637,6 +727,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--mark-completed")
parser.add_argument("--publish-result", type=Path)
parser.add_argument("--result-branch", default="rx-node")
parser.add_argument("--acknowledgement-branch", default="tx-node")
return parser

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import argparse
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, replace
from datetime import datetime, timezone
import json
import os
@@ -33,6 +33,14 @@ from tools.rf_handoff_channel import ( # noqa: E402
load_result,
result_digest,
)
from tools.tx_handoff_channel import ( # noqa: E402
CHANNEL_COMPLETE_OBSERVED,
CHANNEL_READY_OBSERVED,
RX_READY_OBSERVED,
TX_AUTHORIZATION_CREATED,
GitAcknowledgementChannel,
TxHandoffAcknowledgement,
)
DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json"
@@ -67,6 +75,13 @@ class TxExecutor(Protocol):
) -> Path: ...
class AcknowledgementPublisher(Protocol):
def publish(
self,
acknowledgement: TxHandoffAcknowledgement,
) -> object: ...
@dataclass
class TxWatcherState:
state_version: int = STATE_VERSION
@@ -410,6 +425,45 @@ def migrate_state_cursor(
return True
def acknowledgement_for_result(
result: RxHandoffResult,
digest: str,
result_head: str,
status: str,
*,
observed_at: datetime,
main_watcher_pid: int,
) -> TxHandoffAcknowledgement | None:
"""Build an acknowledgement only for an event handled successfully."""
acknowledgement_state: str | None = None
rf_tx_authorized = False
if status == "CHANNEL_TEST_READY":
acknowledgement_state = CHANNEL_READY_OBSERVED
elif status == "CHANNEL_TEST_COMPLETE":
acknowledgement_state = CHANNEL_COMPLETE_OBSERVED
elif status == "RED_APPROVAL_BLOCKED":
acknowledgement_state = RX_READY_OBSERVED
elif status == "TX_AUTHORIZATION_PREPARED":
acknowledgement_state = TX_AUTHORIZATION_CREATED
rf_tx_authorized = True
if acknowledgement_state is None:
return None
return TxHandoffAcknowledgement(
schema_version=1,
task_id=result.task_id,
state=acknowledgement_state,
source_rx_commit=result_head,
source_rx_digest=digest,
source_head=result.source_head,
observed_at=observed_at.astimezone(timezone.utc).isoformat(),
main_watcher_pid=main_watcher_pid,
rf_tx_authorized=rf_tx_authorized,
rf_tx_started=False,
rf_tx=0,
)
def process_history_cycle(
source: ResultSource,
state: TxWatcherState,
@@ -423,6 +477,8 @@ def process_history_cycle(
expected_source_head: str | None = None,
max_age_seconds: float = 30.0,
min_remaining_seconds: float = 10.0,
acknowledgement_publisher: AcknowledgementPublisher | None = None,
main_watcher_pid: int | None = None,
) -> list[tuple[str, str]]:
"""Process every new result commit in chronological order."""
@@ -432,11 +488,12 @@ def process_history_cycle(
outcomes: list[tuple[str, str]] = []
for commit in commits:
result, digest, result_head = source.read_commit(commit)
status, state = handle_result(
candidate_state = replace(state)
status, candidate_state = handle_result(
result,
digest,
result_head,
state,
candidate_state,
source,
executor,
now_function=now_function,
@@ -447,6 +504,26 @@ def process_history_cycle(
max_age_seconds=max_age_seconds,
min_remaining_seconds=min_remaining_seconds,
)
acknowledgement = acknowledgement_for_result(
result,
digest,
result_head,
status,
observed_at=now_function(),
main_watcher_pid=main_watcher_pid or os.getpid(),
)
if acknowledgement is not None and acknowledgement_publisher is not None:
publication = acknowledgement_publisher.publish(acknowledgement)
output_function(
"TX ACKNOWLEDGEMENT PUBLISH = "
f"{getattr(publication, 'status', 'PUBLISHED')}"
)
output_function(
"TX ACKNOWLEDGEMENT HEAD = "
f"{getattr(publication, 'commit', 'UNKNOWN')}"
)
for field_name in TxWatcherState.__dataclass_fields__:
setattr(state, field_name, getattr(candidate_state, field_name))
state.last_seen_commit = result_head
state.last_processed_rx_node_commit = result_head
state.state_version = STATE_VERSION
@@ -481,6 +558,7 @@ def run_watcher(arguments: argparse.Namespace) -> int:
stop_path.unlink(missing_ok=True)
executor = ManualTxExecutor(arguments.outbox.resolve())
acknowledgement_publisher: AcknowledgementPublisher | None = None
def input_function(prompt: str) -> str:
if arguments.approval == "authorize":
@@ -511,6 +589,11 @@ def run_watcher(arguments: argparse.Namespace) -> int:
remote=arguments.remote,
branch=arguments.result_branch,
)
acknowledgement_publisher = GitAcknowledgementChannel(
repository,
remote=arguments.remote,
branch=arguments.acknowledgement_branch,
)
if not _HEAD_RE.fullmatch(expected_source_head):
raise ValueError(
"expected source HEAD must be a full lowercase Git hash"
@@ -534,6 +617,8 @@ def run_watcher(arguments: argparse.Namespace) -> int:
expected_source_head=expected_source_head,
max_age_seconds=arguments.max_age,
min_remaining_seconds=arguments.min_remaining,
acknowledgement_publisher=acknowledgement_publisher,
main_watcher_pid=os.getpid(),
)
if arguments.once or arguments.dry_run:
break
@@ -569,6 +654,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--remote", default="origin")
parser.add_argument("--source-branch", default="video-development")
parser.add_argument("--result-branch", default="rx-node")
parser.add_argument("--acknowledgement-branch", default="tx-node")
parser.add_argument("--expected-source-head")
parser.add_argument("--poll-interval", type=float, default=5.0)
parser.add_argument("--max-age", type=float, default=30.0)

594
tools/tx_handoff_channel.py Normal file
View File

@@ -0,0 +1,594 @@
"""Strict MAIN-to-RX acknowledgement channel stored on ``tx-node``."""
from __future__ import annotations
from dataclasses import asdict, dataclass, fields
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import re
import tempfile
from typing import Any, Mapping
from tools.rf_handoff_channel import (
CHANNEL_TEST_TASK_ID,
MAX_RESULT_BYTES,
PublishOutcome,
ResultBranchMissing,
ResultChannelError,
ResultHistoryRewritten,
GitResultChannel,
)
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))
TX_NODE_BRANCH = "tx-node"
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_STARTED = "TX_STARTED"
TX_COMPLETE = "TX_COMPLETE"
TX_ACK_STATES = frozenset(
(
CHANNEL_READY_OBSERVED,
CHANNEL_COMPLETE_OBSERVED,
RX_READY_OBSERVED,
TX_AUTHORIZATION_CREATED,
TX_STARTED,
TX_COMPLETE,
)
)
CHANNEL_TEST_READY_COMMIT = "4cb6c8cad1ab5194755c2740c2a5a83933d099d8"
CHANNEL_TEST_READY_DIGEST = (
"23fc54f1f788f068fd4a842daf0a445bd4ec192866b4d89406c74f1797a9952b"
)
CHANNEL_TEST_COMPLETE_COMMIT = "83d91ff5309777ab639a24f774e9d0b8825a11e9"
CHANNEL_TEST_COMPLETE_DIGEST = (
"7d98ed2e70e98a087b968c0d65fb1db4277b81f251e075d20583de2e5002b2d6"
)
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
_TASK_ID_RE = re.compile(r"^[A-Z0-9][A-Z0-9_-]{2,79}$")
@dataclass(frozen=True)
class TxHandoffAcknowledgement:
"""One strict acknowledgement of an exact event from ``rx-node``."""
schema_version: int
task_id: str
state: str
source_rx_commit: str
source_rx_digest: str
source_head: str
observed_at: str
main_watcher_pid: int
rf_tx_authorized: bool
rf_tx_started: bool
rf_tx: int
@dataclass(frozen=True)
class ChannelTestAcknowledgements:
ready_confirmed: bool
complete_confirmed: bool
ready_ack_commit: str | None
complete_ack_commit: str | None
def _parse_utc(value: str) -> datetime:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except (TypeError, ValueError) as error:
raise ValueError("observed_at must be an ISO-8601 timestamp") from error
if parsed.tzinfo is None:
raise ValueError("observed_at must contain a timezone")
return parsed.astimezone(timezone.utc)
def validate_acknowledgement(
acknowledgement: TxHandoffAcknowledgement,
) -> 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}")
string_values = (
acknowledgement.task_id,
acknowledgement.state,
acknowledgement.source_rx_commit,
acknowledgement.source_rx_digest,
acknowledgement.source_head,
acknowledgement.observed_at,
)
if any(type(value) is not str for value in string_values):
raise ValueError("acknowledgement string fields must contain strings")
if not _TASK_ID_RE.fullmatch(acknowledgement.task_id):
raise ValueError("task_id is invalid")
if acknowledgement.state not in TX_ACK_STATES:
raise ValueError("state is not supported")
if not _HEAD_RE.fullmatch(acknowledgement.source_rx_commit):
raise ValueError("source_rx_commit must be a lowercase 40-character hash")
if not _SHA256_RE.fullmatch(acknowledgement.source_rx_digest):
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)
if (
type(acknowledgement.main_watcher_pid) is not int
or acknowledgement.main_watcher_pid <= 0
):
raise ValueError("main_watcher_pid must be a positive integer")
if type(acknowledgement.rf_tx_authorized) is not bool:
raise ValueError("rf_tx_authorized must contain a boolean")
if type(acknowledgement.rf_tx_started) is not bool:
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")
if (
acknowledgement.state
in {
CHANNEL_READY_OBSERVED,
CHANNEL_COMPLETE_OBSERVED,
RX_READY_OBSERVED,
}
and (
acknowledgement.rf_tx_authorized
or acknowledgement.rf_tx_started
or acknowledgement.rf_tx != 0
)
):
raise ValueError("observation states cannot claim RF TX authorization")
if acknowledgement.state in {
CHANNEL_READY_OBSERVED,
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
)
):
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
):
raise ValueError("TX execution state is inconsistent")
return acknowledgement
def acknowledgement_from_mapping(
raw: Mapping[str, Any],
) -> TxHandoffAcknowledgement:
expected_fields = {field.name for field in fields(TxHandoffAcknowledgement)}
actual_fields = set(raw)
if actual_fields != expected_fields:
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}
)
return validate_acknowledgement(acknowledgement)
def canonical_acknowledgement_json(
acknowledgement: TxHandoffAcknowledgement,
) -> str:
validate_acknowledgement(acknowledgement)
return json.dumps(
asdict(acknowledgement),
indent=2,
sort_keys=True,
ensure_ascii=False,
) + "\n"
def acknowledgement_digest(acknowledgement: TxHandoffAcknowledgement) -> str:
return hashlib.sha256(
canonical_acknowledgement_json(acknowledgement).encode("utf-8")
).hexdigest()
def render_acknowledgement_markdown(
acknowledgement: TxHandoffAcknowledgement,
) -> str:
validate_acknowledgement(acknowledgement)
return "\n".join(
(
"# MAIN acknowledgement",
"",
f"SCHEMA_VERSION = {acknowledgement.schema_version}",
f"TASK_ID = {acknowledgement.task_id}",
f"STATE = {acknowledgement.state}",
f"SOURCE_RX_COMMIT = {acknowledgement.source_rx_commit}",
f"SOURCE_RX_DIGEST = {acknowledgement.source_rx_digest}",
f"SOURCE_HEAD = {acknowledgement.source_head}",
f"OBSERVED_AT = {acknowledgement.observed_at}",
f"MAIN_WATCHER_PID = {acknowledgement.main_watcher_pid}",
"RF_TX_AUTHORIZED = "
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}",
"",
"Raw IQ, video, waveforms, source code, local state and large logs "
"are not stored in this branch.",
"",
)
)
class GitAcknowledgementChannel(GitResultChannel):
"""Read and publish strict acknowledgements on an independent Git branch."""
def __init__(
self,
repository: Path,
*,
remote: str = "origin",
branch: str = TX_NODE_BRANCH,
) -> None:
super().__init__(repository, remote=remote, branch=branch)
def read(self) -> tuple[TxHandoffAcknowledgement, str, str]:
remote_head = self.fetch()
return self.read_commit(remote_head)
def read_commit(
self,
commit: str,
) -> tuple[TxHandoffAcknowledgement, str, str]:
if not _HEAD_RE.fullmatch(commit):
raise ResultChannelError(
"acknowledgement commit must be a full lowercase Git hash"
)
commit_check = self._run(
["cat-file", "-e", f"{commit}^{{commit}}"],
check=False,
)
if commit_check.returncode != 0:
raise ResultChannelError(
f"acknowledgement commit is unavailable: {commit}"
)
tree_text = self._run(
["ls-tree", "-r", "--name-only", commit]
).stdout
tree_paths = {
line.strip().replace("\\", "/")
for line in tree_text.splitlines()
if line.strip()
}
if tree_paths != TX_RESULT_PATHS:
raise ResultChannelError(
f"tx-node tree contains unexpected paths: {sorted(tree_paths)}"
)
for path in TX_RESULT_PATHS:
size_text = self._run(["cat-file", "-s", f"{commit}:{path}"]).stdout
if int(size_text.strip()) > MAX_RESULT_BYTES:
raise ResultChannelError(f"acknowledgement file exceeds limit: {path}")
json_result = self._run(
["show", f"{commit}:{TX_RESULT_JSON_PATH}"],
check=False,
)
if json_result.returncode != 0:
raise ResultChannelError(
f"TX_RESULT.json is unavailable at commit {commit}: "
f"{json_result.stderr.strip()}"
)
raw = json.loads(json_result.stdout)
if not isinstance(raw, dict):
raise ResultChannelError(
f"TX_RESULT.json root is not an object at commit {commit}"
)
acknowledgement = acknowledgement_from_mapping(raw)
return acknowledgement, acknowledgement_digest(acknowledgement), commit
def commits_since(
self,
last_processed_commit: str | None,
) -> tuple[list[str], str]:
remote_head = self.fetch()
if last_processed_commit is not None:
if not _HEAD_RE.fullmatch(last_processed_commit):
raise ResultChannelError(
"last processed acknowledgement commit is invalid"
)
if self._optional_revision(last_processed_commit) is None:
raise ResultHistoryRewritten(
"last processed acknowledgement commit is unavailable"
)
if not self._is_ancestor(last_processed_commit, remote_head):
raise ResultHistoryRewritten(
"tx-node no longer descends from the last processed commit"
)
if last_processed_commit == remote_head:
return [], remote_head
history = self._run(
[
"rev-list",
"--reverse",
"--topo-order",
f"{last_processed_commit}..{remote_head}",
]
).stdout
return (
[line.strip() for line in history.splitlines() if line.strip()],
remote_head,
)
history = self._run(
["rev-list", "--reverse", "--topo-order", remote_head]
).stdout
all_commits = [line.strip() for line in history.splitlines() if line.strip()]
first_result_index: int | None = None
for index, commit in enumerate(all_commits):
exists = self._run(
["cat-file", "-e", f"{commit}:{TX_RESULT_JSON_PATH}"],
check=False,
)
if exists.returncode == 0:
first_result_index = index
break
if first_result_index is None:
return [], remote_head
return all_commits[first_result_index:], remote_head
def _matching_commit(
self,
acknowledgement: TxHandoffAcknowledgement,
) -> str | None:
commits, _remote_head = self.commits_since(None)
for commit in reversed(commits):
observed, _digest, _head = self.read_commit(commit)
if (
observed.task_id == acknowledgement.task_id
and observed.state == acknowledgement.state
and observed.source_rx_commit
== acknowledgement.source_rx_commit
and observed.source_rx_digest
== acknowledgement.source_rx_digest
and observed.source_head == acknowledgement.source_head
and observed.rf_tx_authorized
== acknowledgement.rf_tx_authorized
and observed.rf_tx_started == acknowledgement.rf_tx_started
and observed.rf_tx == acknowledgement.rf_tx
):
return commit
return None
def publish(
self,
acknowledgement: TxHandoffAcknowledgement,
) -> PublishOutcome:
validate_acknowledgement(acknowledgement)
remote_before = self.fetch()
matching_commit = self._matching_commit(acknowledgement)
if matching_commit is not None:
return PublishOutcome(
"UNCHANGED",
matching_commit,
remote_before,
json_path=TX_RESULT_JSON_PATH,
markdown_path=TX_RESULT_MARKDOWN_PATH,
)
tracking_reference = f"refs/remotes/{self.remote}/{self.branch}"
with tempfile.TemporaryDirectory(prefix="tx-ack-worktree-") as directory:
checkout = Path(directory) / "tx-node"
worktree_added = False
try:
self._run(
["worktree", "add", "--detach", str(checkout), tracking_reference]
)
worktree_added = True
existing_text = self._run(
["ls-tree", "-r", "--name-only", "HEAD"],
cwd=checkout,
).stdout
existing_paths = {
line.strip().replace("\\", "/")
for line in existing_text.splitlines()
if line.strip()
}
if not existing_paths.issubset(TX_RESULT_PATHS):
raise ResultChannelError(
"tx-node contains paths outside the acknowledgement allowlist"
)
json_path = checkout / TX_RESULT_JSON_PATH
markdown_path = checkout / TX_RESULT_MARKDOWN_PATH
json_path.parent.mkdir(parents=True, exist_ok=True)
json_text = canonical_acknowledgement_json(acknowledgement)
markdown_text = render_acknowledgement_markdown(acknowledgement)
if len(json_text.encode("utf-8")) > MAX_RESULT_BYTES:
raise ResultChannelError("TX_RESULT.json exceeds the size limit")
if len(markdown_text.encode("utf-8")) > MAX_RESULT_BYTES:
raise ResultChannelError("TX_RESULT.md exceeds the size limit")
json_path.write_text(json_text, encoding="utf-8", newline="\n")
markdown_path.write_text(
markdown_text,
encoding="utf-8",
newline="\n",
)
self._run(["add", "--", *sorted(TX_RESULT_PATHS)], cwd=checkout)
staged_text = self._run(
["diff", "--cached", "--name-only", "--"],
cwd=checkout,
).stdout
staged = {
line.strip().replace("\\", "/")
for line in staged_text.splitlines()
if line.strip()
}
if staged != TX_RESULT_PATHS:
raise ResultChannelError(
f"refusing unexpected staged paths: {sorted(staged)}"
)
self._run(
[
"-c",
"user.name=MAIN TX Watcher",
"-c",
"user.email=tx-node@local.invalid",
"commit",
"--quiet",
"-m",
f"Acknowledge {acknowledgement.task_id} "
f"{acknowledgement.state}",
],
cwd=checkout,
)
commit = self._run(
["rev-parse", "HEAD"],
cwd=checkout,
).stdout.strip()
committed_text = self._run(
["ls-tree", "-r", "--name-only", commit],
cwd=checkout,
).stdout
committed_paths = {
line.strip().replace("\\", "/")
for line in committed_text.splitlines()
if line.strip()
}
if committed_paths != TX_RESULT_PATHS:
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,
)
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,
commit,
json_path=TX_RESULT_JSON_PATH,
markdown_path=TX_RESULT_MARKDOWN_PATH,
)
finally:
if worktree_added:
removal = self._run(
["worktree", "remove", "--force", str(checkout)],
check=False,
)
if removal.returncode != 0:
raise ResultChannelError(
"temporary acknowledgement worktree cleanup failed: "
f"{removal.stderr.strip()}"
)
self._run(["worktree", "prune"], check=False)
def channel_test_acknowledgements(
channel: GitAcknowledgementChannel,
*,
expected_source_head: str,
ready_source_commit: str = CHANNEL_TEST_READY_COMMIT,
ready_source_digest: str = CHANNEL_TEST_READY_DIGEST,
complete_source_commit: str = CHANNEL_TEST_COMPLETE_COMMIT,
complete_source_digest: str = CHANNEL_TEST_COMPLETE_DIGEST,
) -> ChannelTestAcknowledgements:
"""Find both exact channel-test acknowledgements in chronological history."""
if not _HEAD_RE.fullmatch(expected_source_head):
raise ValueError("expected_source_head must be a full lowercase Git hash")
if not _HEAD_RE.fullmatch(ready_source_commit):
raise ValueError("ready_source_commit must be a full lowercase Git hash")
if not _SHA256_RE.fullmatch(ready_source_digest):
raise ValueError("ready_source_digest must be a lowercase SHA256")
if not _HEAD_RE.fullmatch(complete_source_commit):
raise ValueError("complete_source_commit must be a full lowercase Git hash")
if not _SHA256_RE.fullmatch(complete_source_digest):
raise ValueError("complete_source_digest must be a lowercase SHA256")
commits, _remote_head = channel.commits_since(None)
ready_commit: str | None = None
complete_commit: str | None = None
for commit in commits:
acknowledgement, _digest, _head = channel.read_commit(commit)
common = (
acknowledgement.task_id == CHANNEL_TEST_TASK_ID
and acknowledgement.source_head == expected_source_head
and not acknowledgement.rf_tx_authorized
and not acknowledgement.rf_tx_started
and acknowledgement.rf_tx == 0
)
if (
common
and acknowledgement.state == CHANNEL_READY_OBSERVED
and acknowledgement.source_rx_commit == ready_source_commit
and acknowledgement.source_rx_digest == ready_source_digest
):
ready_commit = commit
elif (
common
and ready_commit is not None
and acknowledgement.state == CHANNEL_COMPLETE_OBSERVED
and acknowledgement.source_rx_commit == complete_source_commit
and acknowledgement.source_rx_digest == complete_source_digest
):
complete_commit = commit
return ChannelTestAcknowledgements(
ready_confirmed=ready_commit is not None,
complete_confirmed=complete_commit is not None,
ready_ack_commit=ready_commit,
complete_ack_commit=complete_commit,
)
__all__ = [
"CHANNEL_COMPLETE_OBSERVED",
"CHANNEL_READY_OBSERVED",
"CHANNEL_TEST_COMPLETE_COMMIT",
"CHANNEL_TEST_COMPLETE_DIGEST",
"CHANNEL_TEST_READY_COMMIT",
"CHANNEL_TEST_READY_DIGEST",
"ChannelTestAcknowledgements",
"GitAcknowledgementChannel",
"ResultBranchMissing",
"ResultChannelError",
"ResultHistoryRewritten",
"TX_ACK_SCHEMA_VERSION",
"TX_NODE_BRANCH",
"TX_RESULT_JSON_PATH",
"TX_RESULT_MARKDOWN_PATH",
"TX_RESULT_PATHS",
"TxHandoffAcknowledgement",
"acknowledgement_digest",
"acknowledgement_from_mapping",
"canonical_acknowledgement_json",
"channel_test_acknowledgements",
"render_acknowledgement_markdown",
"validate_acknowledgement",
]