595 lines
22 KiB
Python
595 lines
22 KiB
Python
"""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",
|
|
]
|