Files
gyxx-flow/src/gyxx_flow/notification_routing.py
T
wangyunlong 78ea4641ce refactor(login): 移除脚本内扫码重登,登录统一交给账号 vault 与 cookie-gate
删除 relogin_*/friday_relogin_parallel 子进程及其测试;批量补采、评论采集、
星图/蒲公英/B站/抖音自营与抖店竞品脚本不再内嵌扫码等待,登录态失效统一由
cookie-gate 阻断并走 gyxx accounts login 恢复;runtime-bindings 与模块注册
同步移除 content.relogin.weekly。
2026-10-09 13:45:19 +08:00

658 lines
26 KiB
Python

"""Validated, revision-safe notification routing for workflow outputs."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import re
import unicodedata
import uuid
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Any, Mapping, Sequence
from gyxx_flow.core.artifacts import atomic_write_json
from gyxx_flow.core.config import Settings
from gyxx_flow.core.locks import LockManager, ResourceBusyError
NOTIFICATION_ROUTING_SCHEMA_VERSION = 1
NOTIFICATION_ROUTING_RELATIVE_PATH = Path("state") / "notifications" / "routing.json"
NOTIFICATION_ROUTING_CONFIG_NAME = "notification-routing.json"
ANALYZER_NOTIFICATION_APP_PROFILE = "hermes-analyzer"
ANALYZER_NOTIFICATION_APP_ID = "cli_aa8c4fc918b85cce"
ANALYZER_HERMES_PROFILE = "data-analyzer"
ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID = (
"ou_7ad5fc8012e2f741afc5346e05ffd447"
)
GYXX_NOTIFICATION_ROUTE_MODE = "GYXX_NOTIFICATION_ROUTE_MODE"
GYXX_NOTIFICATION_APP_PROFILE = "GYXX_NOTIFICATION_APP_PROFILE"
GYXX_NOTIFICATION_HERMES_PROFILE = "GYXX_NOTIFICATION_HERMES_PROFILE"
GYXX_NOTIFICATION_RECIPIENTS_JSON = "GYXX_NOTIFICATION_RECIPIENTS_JSON"
GYXX_NOTIFICATION_ROUTE_REVISION = "GYXX_NOTIFICATION_ROUTE_REVISION"
GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID = "GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID"
_ROUTE_MODES = frozenset({"legacy", "enabled", "disabled"})
_SAFE_ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
_SAFE_OPEN_ID = re.compile(r"^ou_[A-Za-z0-9_-]{8,128}$")
_SAFE_APP_ID = re.compile(r"^cli_[A-Za-z0-9_-]{6,128}$")
_SAFE_SOURCE = frozenset({"provided", "repository", "mobile_lookup", "manual"})
_REVISION = re.compile(r"^[0-9a-f]{64}$")
MAX_NOTIFICATION_ROUTING_BYTES = 256 * 1024
MAX_NOTIFICATION_PEOPLE = 500
MAX_NOTIFICATION_APP_PROFILES = 16
MAX_NOTIFICATION_RECIPIENTS = 100
MAX_NOTIFICATION_BINDINGS_PER_PERSON = 16
NOTIFICATION_CAPABILITIES: Mapping[str, str] = MappingProxyType(
{
"content.marketing_report.daily": "正式执行且启用发送时投递最终营销日报。",
"product.alert.daily": "命中销量下滑规则且尚未重复通知时投递告警。",
"product.market_rank": "存在本次新生成的平台报告链接且尚未重复通知时投递。",
"product.online_stock_alert.daily": "线上商品当前库存低于 3 时向配置接收人发送库存预警。",
"supply.purchase_confirmation.daily": "采购确认结果生成完成后投递最终业务通知。",
"supply.replenishment.weekly": "补货处理完成后投递最终结果,包括无新增补货项的结果。",
"supply.replenishment_alert.daily": "存在命中库存阈值的 SKU 时投递预警。",
}
)
class NotificationRoutingError(ValueError):
"""Raised when notification routing is missing, corrupt, or unsafe."""
class NotificationRoutingConflictError(NotificationRoutingError):
"""Raised when a routing update uses a stale revision or cannot lock."""
class NotificationRoutingPreconditionError(NotificationRoutingError):
"""Raised when a routing mutation omits its expected revision."""
@dataclass(frozen=True, slots=True)
class ResolvedNotificationRoute:
"""One immutable workflow notification decision."""
configured: bool
enabled: bool
app_profile: str | None
open_ids: tuple[str, ...]
revision: str | None = None
class NotificationRoutingStore:
"""Load the project baseline and atomically persist runtime routing state."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.baseline_path = (
settings.project_root / "config" / NOTIFICATION_ROUTING_CONFIG_NAME
)
self.path = settings.data_root / NOTIFICATION_ROUTING_RELATIVE_PATH
self._locks = LockManager(settings.data_root / "state" / "locks")
def snapshot(self) -> tuple[dict[str, Any], str]:
"""Return one validated snapshot and the digest of the same raw bytes."""
source = self.path if self.path.is_file() else self.baseline_path
try:
raw = source.read_bytes()
except OSError as exc:
raise NotificationRoutingError(
f"cannot read notification routing: {source.name}"
) from exc
if len(raw) > MAX_NOTIFICATION_ROUTING_BYTES:
raise NotificationRoutingError("notification routing file is too large")
revision = hashlib.sha256(raw).hexdigest()
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise NotificationRoutingError("notification routing JSON is invalid") from exc
return _normalize_payload(payload), revision
def update(
self,
payload: Mapping[str, Any],
*,
expected_revision: str | None,
) -> tuple[dict[str, Any], str]:
"""Validate and atomically replace runtime state under optimistic locking."""
if expected_revision is None or not expected_revision.strip():
raise NotificationRoutingPreconditionError(
"notification routing revision is required"
)
expected = expected_revision.strip().strip('"')
normalized = _normalize_payload(payload)
_require_payload_size(normalized)
owner = f"notification-{uuid.uuid4().hex}"
try:
with self._locks.acquire("config:notification-routing", owner=owner):
_current, current_revision = self.snapshot()
if not hmac.compare_digest(current_revision, expected):
raise NotificationRoutingConflictError(
"notification routing was updated; refresh and retry"
)
atomic_write_json(self.path, normalized)
updated, revision = self.snapshot()
return updated, revision
except ResourceBusyError as exc:
raise NotificationRoutingConflictError(
"notification routing is being updated"
) from exc
def resolve_notification_route(
workflow_id: str,
defaults: Sequence[str],
environment: Mapping[str, str] | None = None,
) -> ResolvedNotificationRoute:
"""Resolve a route, preferring the immutable environment snapshot."""
_require_safe_id(workflow_id, field="workflow id")
values = os.environ if environment is None else environment
mode = values.get(GYXX_NOTIFICATION_ROUTE_MODE)
if mode is not None:
snapshot_workflow = values.get(GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID, "").strip()
if snapshot_workflow != workflow_id:
raise NotificationRoutingError(
"notification route snapshot belongs to another workflow"
)
resolved = _route_from_environment(values, defaults)
elif workflow_id not in NOTIFICATION_CAPABILITIES:
resolved = ResolvedNotificationRoute(
configured=False,
enabled=True,
app_profile=None,
open_ids=_normalize_legacy_defaults(defaults),
)
else:
settings = Settings.from_env(
project_root=values.get("GYXX_PROJECT_ROOT") or None,
env=values,
)
payload, revision = NotificationRoutingStore(settings).snapshot()
route = payload["routes"].get(workflow_id)
if route is None:
resolved = ResolvedNotificationRoute(
configured=False,
enabled=True,
app_profile=None,
open_ids=_normalize_legacy_defaults(defaults),
revision=revision,
)
elif not route["enabled"]:
resolved = ResolvedNotificationRoute(
configured=True,
enabled=False,
app_profile=route["app_profile"],
open_ids=(),
revision=revision,
)
else:
open_ids = tuple(
payload["people"][person_id]["bindings"][route["app_profile"]][
"open_id"
]
for person_id in route["person_ids"]
)
resolved = ResolvedNotificationRoute(
configured=True,
enabled=True,
app_profile=route["app_profile"],
open_ids=open_ids,
revision=revision,
)
return _apply_acceptance_recipient(workflow_id, resolved, values)
def notification_environment_for_workflow(
workflow_id: str,
*,
project_root: Path | str,
data_root: Path | str,
environment: Mapping[str, str] | None = None,
) -> dict[str, str]:
"""Build the stable routing environment injected into one workflow run."""
values = dict(os.environ if environment is None else environment)
values["GYXX_PROJECT_ROOT"] = str(Path(project_root).expanduser().resolve())
values["GYXX_DATA_ROOT"] = str(Path(data_root).expanduser().resolve())
resolved = resolve_notification_route(workflow_id, (), values)
if resolved.configured:
mode = "enabled" if resolved.enabled else "disabled"
else:
mode = "legacy"
return {
GYXX_NOTIFICATION_ROUTE_MODE: mode,
GYXX_NOTIFICATION_APP_PROFILE: resolved.app_profile or "",
GYXX_NOTIFICATION_HERMES_PROFILE: (
ANALYZER_HERMES_PROFILE if resolved.configured else ""
),
GYXX_NOTIFICATION_RECIPIENTS_JSON: json.dumps(
resolved.open_ids,
ensure_ascii=True,
separators=(",", ":"),
),
GYXX_NOTIFICATION_ROUTE_REVISION: resolved.revision or "",
GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID: workflow_id,
}
def _route_from_environment(
values: Mapping[str, str],
defaults: Sequence[str],
) -> ResolvedNotificationRoute:
mode = values.get(GYXX_NOTIFICATION_ROUTE_MODE, "").strip()
if mode not in _ROUTE_MODES:
raise NotificationRoutingError("notification route snapshot mode is invalid")
revision = values.get(GYXX_NOTIFICATION_ROUTE_REVISION, "").strip() or None
if revision is not None and not _REVISION.fullmatch(revision):
raise NotificationRoutingError("notification route snapshot revision is invalid")
app_profile = values.get(GYXX_NOTIFICATION_APP_PROFILE, "").strip() or None
hermes_profile = (
values.get(GYXX_NOTIFICATION_HERMES_PROFILE, "").strip() or None
)
raw_recipients = values.get(GYXX_NOTIFICATION_RECIPIENTS_JSON, "[]")
try:
recipients = json.loads(raw_recipients)
except json.JSONDecodeError as exc:
raise NotificationRoutingError(
"notification route snapshot recipients are invalid"
) from exc
if not isinstance(recipients, list) or not all(
isinstance(item, str) and _SAFE_OPEN_ID.fullmatch(item) for item in recipients
):
raise NotificationRoutingError(
"notification route snapshot recipients are invalid"
)
open_ids = tuple(dict.fromkeys(recipients))
if len(open_ids) > MAX_NOTIFICATION_RECIPIENTS:
raise NotificationRoutingError("notification route has too many recipients")
if mode == "legacy":
if app_profile is not None or hermes_profile is not None or open_ids:
raise NotificationRoutingError("legacy notification snapshot is inconsistent")
return ResolvedNotificationRoute(
configured=False,
enabled=True,
app_profile=None,
open_ids=_normalize_legacy_defaults(defaults),
revision=revision,
)
if app_profile is None:
raise NotificationRoutingError("notification app profile is required")
if hermes_profile != ANALYZER_HERMES_PROFILE:
raise NotificationRoutingError(
"notification Hermes profile snapshot is invalid"
)
_require_safe_id(app_profile, field="app profile")
if mode == "disabled":
if open_ids:
raise NotificationRoutingError("disabled notification snapshot has recipients")
return ResolvedNotificationRoute(True, False, app_profile, (), revision)
if not open_ids:
raise NotificationRoutingError("enabled notification snapshot has no recipients")
return ResolvedNotificationRoute(True, True, app_profile, open_ids, revision)
def _apply_acceptance_recipient(
workflow_id: str,
resolved: ResolvedNotificationRoute,
values: Mapping[str, str],
) -> ResolvedNotificationRoute:
if not resolved.enabled or not _read_bool(values.get("GYXX_WORKFLOW_ACCEPTANCE")):
return resolved
if workflow_id in NOTIFICATION_CAPABILITIES:
# These capabilities are delivered by the analyzer app. The global
# acceptance recipient belongs to the collector app and cannot be used
# with analyzer-scoped OpenIDs.
recipient = ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID
else:
recipient = values.get("GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", "").strip()
if not recipient:
return resolved
if not _SAFE_OPEN_ID.fullmatch(recipient):
raise NotificationRoutingError("acceptance notification recipient is invalid")
return ResolvedNotificationRoute(
configured=resolved.configured,
enabled=True,
app_profile=resolved.app_profile,
open_ids=(recipient,),
revision=resolved.revision,
)
def _normalize_payload(payload: object) -> dict[str, Any]:
if not isinstance(payload, Mapping):
raise NotificationRoutingError("notification routing must be an object")
required = {
"schema_version",
"app_profile",
"app_profiles",
"people",
"capabilities",
"routes",
}
if set(payload) != required:
raise NotificationRoutingError("notification routing fields are invalid")
schema_version = payload.get("schema_version")
if (
isinstance(schema_version, bool)
or schema_version != NOTIFICATION_ROUTING_SCHEMA_VERSION
):
raise NotificationRoutingError("unsupported notification routing schema")
app_profiles = _normalize_app_profiles(payload.get("app_profiles"))
app_profile = payload.get("app_profile")
if not isinstance(app_profile, str) or app_profile not in app_profiles:
raise NotificationRoutingError("default notification app profile is invalid")
_require_analyzer_delivery_profile(app_profile, app_profiles)
capabilities = _normalize_capabilities(payload.get("capabilities"))
people = _normalize_people(payload.get("people"), app_profiles)
routes = _normalize_routes(
payload.get("routes"),
app_profiles=app_profiles,
people=people,
)
normalized = {
"schema_version": NOTIFICATION_ROUTING_SCHEMA_VERSION,
"app_profile": app_profile,
"app_profiles": app_profiles,
"people": people,
"capabilities": capabilities,
"routes": routes,
}
_require_payload_size(normalized)
return normalized
def _normalize_app_profiles(value: object) -> dict[str, dict[str, str]]:
if not isinstance(value, Mapping) or not value:
raise NotificationRoutingError("notification app profiles are invalid")
if len(value) > MAX_NOTIFICATION_APP_PROFILES:
raise NotificationRoutingError("too many notification app profiles")
profiles: dict[str, dict[str, str]] = {}
for profile_id, raw in value.items():
_require_safe_id(profile_id, field="app profile")
if not isinstance(raw, Mapping) or set(raw) != {
"label",
"app_id",
"hermes_profile",
}:
raise NotificationRoutingError(f"invalid app profile: {profile_id}")
app_id = raw.get("app_id")
if not isinstance(app_id, str) or not _SAFE_APP_ID.fullmatch(app_id):
raise NotificationRoutingError(f"invalid app id for profile: {profile_id}")
hermes_profile = raw.get("hermes_profile")
if not isinstance(hermes_profile, str):
raise NotificationRoutingError(
f"invalid Hermes profile for app profile: {profile_id}"
)
_require_safe_id(hermes_profile, field="Hermes profile")
profiles[profile_id] = {
"label": _normalize_text(raw.get("label"), field="app profile label"),
"app_id": app_id,
"hermes_profile": hermes_profile,
}
return profiles
def _normalize_capabilities(value: object) -> dict[str, dict[str, str]]:
if not isinstance(value, Mapping) or set(value) != set(NOTIFICATION_CAPABILITIES):
raise NotificationRoutingError("notification capabilities do not match the catalog")
capabilities: dict[str, dict[str, str]] = {}
for workflow_id, expected_condition in NOTIFICATION_CAPABILITIES.items():
raw = value.get(workflow_id)
if not isinstance(raw, Mapping) or set(raw) != {"condition"}:
raise NotificationRoutingError(
f"invalid notification capability: {workflow_id}"
)
if raw.get("condition") != expected_condition:
raise NotificationRoutingError(
f"notification capability condition changed: {workflow_id}"
)
capabilities[workflow_id] = {"condition": expected_condition}
return capabilities
def _normalize_people(
value: object,
app_profiles: Mapping[str, object],
) -> dict[str, dict[str, Any]]:
if not isinstance(value, Mapping):
raise NotificationRoutingError("notification people must be an object")
if len(value) > MAX_NOTIFICATION_PEOPLE:
raise NotificationRoutingError("too many notification people")
people: dict[str, dict[str, Any]] = {}
identities: dict[tuple[str, str], str] = {}
for person_id, raw in value.items():
_require_safe_id(person_id, field="person id")
if not isinstance(raw, Mapping) or set(raw) != {"name", "mobile", "bindings"}:
raise NotificationRoutingError(f"invalid person: {person_id}")
bindings_value = raw.get("bindings")
if not isinstance(bindings_value, Mapping):
raise NotificationRoutingError(f"invalid bindings for person: {person_id}")
if len(bindings_value) > MAX_NOTIFICATION_BINDINGS_PER_PERSON:
raise NotificationRoutingError(f"too many bindings for person: {person_id}")
bindings: dict[str, dict[str, Any]] = {}
for profile_id, binding in bindings_value.items():
_require_safe_id(profile_id, field="app profile")
if profile_id not in app_profiles:
raise NotificationRoutingError(
f"unknown app profile for person {person_id}: {profile_id}"
)
if not isinstance(binding, Mapping) or set(binding) != {
"open_id",
"verified",
"source",
}:
raise NotificationRoutingError(
f"invalid binding for person {person_id}: {profile_id}"
)
open_id = binding.get("open_id")
if not isinstance(open_id, str) or not _SAFE_OPEN_ID.fullmatch(open_id):
raise NotificationRoutingError(
f"invalid open_id for person {person_id}: {profile_id}"
)
verified = binding.get("verified")
source = binding.get("source")
if (
not isinstance(verified, bool)
or not isinstance(source, str)
or source not in _SAFE_SOURCE
):
raise NotificationRoutingError(
f"invalid binding metadata for person {person_id}: {profile_id}"
)
identity_key = (profile_id, open_id)
previous = identities.get(identity_key)
if previous is not None and previous != person_id:
raise NotificationRoutingError(
f"open_id is bound to multiple people in {profile_id}"
)
identities[identity_key] = person_id
bindings[profile_id] = {
"open_id": open_id,
"verified": verified,
"source": source,
}
mobile = _normalize_mobile(raw.get("mobile"))
if not bindings and mobile is None:
raise NotificationRoutingError(
f"person requires a mobile or app binding: {person_id}"
)
people[person_id] = {
"name": _normalize_text(raw.get("name"), field="person name"),
"mobile": mobile,
"bindings": bindings,
}
return people
def _normalize_routes(
value: object,
*,
app_profiles: Mapping[str, object],
people: Mapping[str, Mapping[str, Any]],
) -> dict[str, dict[str, Any]]:
if not isinstance(value, Mapping):
raise NotificationRoutingError("notification routes must be an object")
routes: dict[str, dict[str, Any]] = {}
for workflow_id, raw in value.items():
_require_safe_id(workflow_id, field="workflow id")
if workflow_id not in NOTIFICATION_CAPABILITIES:
raise NotificationRoutingError(
f"workflow does not support notification routing: {workflow_id}"
)
if not isinstance(raw, Mapping) or set(raw) != {
"enabled",
"app_profile",
"person_ids",
}:
raise NotificationRoutingError(f"invalid notification route: {workflow_id}")
enabled = raw.get("enabled")
profile_id = raw.get("app_profile")
person_ids_value = raw.get("person_ids")
if not isinstance(enabled, bool):
raise NotificationRoutingError(f"invalid enabled flag: {workflow_id}")
if not isinstance(profile_id, str) or profile_id not in app_profiles:
raise NotificationRoutingError(f"invalid app profile: {workflow_id}")
_require_analyzer_delivery_profile(profile_id, app_profiles)
if not isinstance(person_ids_value, list) or not all(
isinstance(person_id, str) for person_id in person_ids_value
):
raise NotificationRoutingError(f"invalid person list: {workflow_id}")
person_ids = list(dict.fromkeys(person_ids_value))
if len(person_ids) > MAX_NOTIFICATION_RECIPIENTS:
raise NotificationRoutingError(f"too many route recipients: {workflow_id}")
if enabled and not person_ids:
raise NotificationRoutingError(
f"enabled notification route has no recipients: {workflow_id}"
)
for person_id in person_ids:
_require_safe_id(person_id, field="person id")
person = people.get(person_id)
if person is None:
raise NotificationRoutingError(
f"notification route references unknown person: {person_id}"
)
if enabled and profile_id not in person["bindings"]:
raise NotificationRoutingError(
f"person {person_id} has no binding for {profile_id}"
)
routes[workflow_id] = {
"enabled": enabled,
"app_profile": profile_id,
"person_ids": person_ids,
}
return routes
def _normalize_legacy_defaults(defaults: Sequence[str]) -> tuple[str, ...]:
normalized: list[str] = []
for value in defaults:
if not isinstance(value, str):
raise NotificationRoutingError("legacy notification recipient must be a string")
recipient = value.strip()
if not recipient or len(recipient) > 256 or _has_unsafe_characters(recipient):
raise NotificationRoutingError("legacy notification recipient is unsafe")
if recipient not in normalized:
normalized.append(recipient)
if len(normalized) > MAX_NOTIFICATION_RECIPIENTS:
raise NotificationRoutingError("too many legacy notification recipients")
return tuple(normalized)
def _normalize_mobile(value: object) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise NotificationRoutingError("person mobile must be a string or null")
mobile = re.sub(r"[\s()-]", "", value)
if re.fullmatch(r"1\d{10}", mobile):
mobile = f"+86{mobile}"
elif re.fullmatch(r"86\d{11}", mobile):
mobile = f"+{mobile}"
if not re.fullmatch(r"\+[1-9]\d{6,14}", mobile):
raise NotificationRoutingError("person mobile must use E.164 format")
return mobile
def _normalize_text(value: object, *, field: str) -> str:
if not isinstance(value, str):
raise NotificationRoutingError(f"{field} must be a string")
normalized = unicodedata.normalize("NFC", value).strip()
if (
not normalized
or len(normalized) > 64
or len(normalized.encode("utf-8")) > 256
or _has_unsafe_characters(normalized)
):
raise NotificationRoutingError(f"{field} is invalid")
return normalized
def _has_unsafe_characters(value: str) -> bool:
return any(
unicodedata.category(character) in {"Cc", "Cf", "Cs"}
for character in value
)
def _require_safe_id(value: object, *, field: str) -> None:
if not isinstance(value, str) or not _SAFE_ID.fullmatch(value):
raise NotificationRoutingError(f"invalid {field}: {value!r}")
def _require_analyzer_delivery_profile(
profile_id: str,
app_profiles: Mapping[str, Mapping[str, str]],
) -> None:
profile = app_profiles.get(profile_id)
if profile is None or profile.get("hermes_profile") != ANALYZER_HERMES_PROFILE:
raise NotificationRoutingError(
"business notification routes must use an analyzer Hermes app profile"
)
def _require_payload_size(payload: Mapping[str, Any]) -> None:
encoded = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
if len(encoded) > MAX_NOTIFICATION_ROUTING_BYTES:
raise NotificationRoutingError("notification routing payload is too large")
def _read_bool(value: str | None) -> bool:
return bool(value and value.strip().casefold() in {"1", "true", "yes", "on"})
__all__ = [
"ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID",
"ANALYZER_HERMES_PROFILE",
"ANALYZER_NOTIFICATION_APP_ID",
"ANALYZER_NOTIFICATION_APP_PROFILE",
"GYXX_NOTIFICATION_APP_PROFILE",
"GYXX_NOTIFICATION_HERMES_PROFILE",
"GYXX_NOTIFICATION_RECIPIENTS_JSON",
"GYXX_NOTIFICATION_ROUTE_MODE",
"GYXX_NOTIFICATION_ROUTE_REVISION",
"GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID",
"MAX_NOTIFICATION_ROUTING_BYTES",
"NOTIFICATION_CAPABILITIES",
"NOTIFICATION_ROUTING_SCHEMA_VERSION",
"NotificationRoutingConflictError",
"NotificationRoutingError",
"NotificationRoutingPreconditionError",
"NotificationRoutingStore",
"ResolvedNotificationRoute",
"notification_environment_for_workflow",
"resolve_notification_route",
]