hermes-agent/plugins/teams_pipeline/runtime.py
Teknium a99547740d fix(teams-pipeline): drop-scheduler fallback + test wiring for enablement gate
Two salvage follow-ups on top of @dlkakbs's plugin runtime.

1. Install a drop-scheduler when the runtime fails to build.

   Previously when ``build_pipeline_runtime()`` raised (e.g. missing
   Graph env vars, subscription store path unwritable), ``bind_gateway_runtime``
   logged a warning and returned False, leaving the msgraph_webhook
   adapter with no scheduler at all. Incoming Graph notifications
   would then fall back to the adapter's default ``handle_message``
   path, which produces a raw JSON dump as a user-role message — not
   useful and fires every time Graph retries.

   Now a no-op drop-scheduler is installed instead, so:
   - Graph notifications ack cleanly (202) so Graph stops retrying.
   - The failure is surfaced once in the log with the error.
   - No user-role messages get manufactured from raw change payloads.

   The adapter is still bindable later once the runtime becomes
   available (e.g. after the operator runs ``hermes teams-pipeline
   validate`` and fixes the config), since the gateway's
   ``_teams_pipeline_runtime`` sentinel wasn't set to a non-None value.

2. Test wiring for ``_teams_pipeline_plugin_enabled()`` gate.

   The happy-path runner-wiring tests monkeypatched ``bind_gateway_runtime``
   but not ``_load_gateway_config``. In the hermetic test environment
   the real config read ran, saw no enabled plugins, and short-circuited
   the bind call before the test could observe it — so the test
   expected ``calls == [runner]`` but got ``calls == []``.

   Adds a ``_load_gateway_config`` monkeypatch with
   ``plugins.enabled = ["teams_pipeline"]`` to the happy-path tests.
   The explicit-disabled test ``test_gateway_runner_skips_wiring_when_teams_pipeline_plugin_disabled``
   already patches the config correctly.

   Also renames ``test_bind_gateway_runtime_leaves_scheduler_unchanged_on_failure``
   to ``test_bind_gateway_runtime_installs_drop_scheduler_on_failure``
   and updates the assertion — this test contradicted the drop-scheduler
   test in ``tests/plugins/test_teams_pipeline_plugin.py`` which
   expected the scheduler to be installed. The plugin-test name
   (``test_bind_gateway_runtime_drops_notifications_when_unavailable``)
   clearly describes the intended behavior; fixing the wiring-test
   assertion aligns both tests.

Validation:
- ``scripts/run_tests.sh tests/plugins/test_teams_pipeline_plugin.py
  tests/gateway/test_teams_pipeline_runtime_wiring.py
  tests/hermes_cli/test_teams_pipeline_plugin_cli.py`` — 25/25 passed.
2026-05-08 11:18:14 -07:00

135 lines
4.8 KiB
Python

"""Gateway runtime wiring for the Teams meeting pipeline plugin."""
from __future__ import annotations
import logging
from typing import Any
from gateway.config import Platform
from plugins.teams_pipeline.pipeline import TeamsMeetingPipeline
from plugins.teams_pipeline.store import TeamsPipelineStore, resolve_teams_pipeline_store_path
from plugins.teams_pipeline.subscriptions import build_graph_client
logger = logging.getLogger(__name__)
def _teams_delivery_is_configured(teams_extra: dict[str, Any], teams_delivery: dict[str, Any]) -> bool:
delivery_mode = str(
teams_delivery.get("mode")
or teams_delivery.get("delivery_mode")
or teams_extra.get("delivery_mode")
or ""
).strip().lower()
if delivery_mode == "incoming_webhook":
return bool(
teams_delivery.get("incoming_webhook_url")
or teams_extra.get("incoming_webhook_url")
)
if delivery_mode == "graph":
chat_id = teams_delivery.get("chat_id") or teams_extra.get("chat_id")
team_id = teams_delivery.get("team_id") or teams_extra.get("team_id")
channel_id = teams_delivery.get("channel_id") or teams_extra.get("channel_id")
return bool(chat_id or (team_id and channel_id))
return False
def build_pipeline_runtime_config(gateway_config: Any) -> dict[str, Any]:
"""Build pipeline config from gateway platform config.
Pipeline-specific knobs live under ``teams.extra.meeting_pipeline`` while
Teams delivery continues to source its target details from the existing
Teams platform config.
"""
teams_config = gateway_config.platforms.get(Platform("teams"))
teams_extra = dict((teams_config.extra or {}) if teams_config else {})
pipeline_config = dict(teams_extra.get("meeting_pipeline") or {})
if teams_config and teams_config.enabled:
teams_delivery = dict(pipeline_config.get("teams_delivery") or {})
delivery_mode = str(teams_extra.get("delivery_mode") or "").strip()
if delivery_mode:
teams_delivery["mode"] = delivery_mode
for key in (
"incoming_webhook_url",
"access_token",
"team_id",
"channel_id",
"chat_id",
):
value = teams_extra.get(key)
if value not in (None, ""):
teams_delivery[key] = value
if teams_delivery:
teams_delivery["enabled"] = _teams_delivery_is_configured(teams_extra, teams_delivery)
pipeline_config["teams_delivery"] = teams_delivery
return pipeline_config
def build_pipeline_runtime(gateway: Any) -> TeamsMeetingPipeline:
teams_sender = None
teams_config = gateway.config.platforms.get(Platform("teams"))
pipeline_config = build_pipeline_runtime_config(gateway.config)
teams_delivery = dict(pipeline_config.get("teams_delivery") or {})
if teams_config and teams_config.enabled and teams_delivery.get("enabled"):
try:
from plugins.platforms.teams.adapter import TeamsSummaryWriter
except ImportError:
logger.debug(
"TeamsSummaryWriter unavailable; Teams outbound delivery remains disabled until the adapter layer is present."
)
else:
teams_sender = TeamsSummaryWriter(platform_config=teams_config)
return TeamsMeetingPipeline(
graph_client=build_graph_client(),
store=TeamsPipelineStore(resolve_teams_pipeline_store_path()),
config=pipeline_config,
teams_sender=teams_sender,
)
def bind_gateway_runtime(gateway: Any) -> bool:
"""Attach the Teams pipeline runtime to the msgraph webhook adapter."""
adapter = gateway.adapters.get(Platform.MSGRAPH_WEBHOOK)
if adapter is None:
return False
if getattr(gateway, "_teams_pipeline_runtime", None) is not None:
return True
try:
runtime = build_pipeline_runtime(gateway)
except Exception as exc:
error_message = str(exc)
gateway._teams_pipeline_runtime_error = error_message
logger.warning(
"Teams pipeline runtime unavailable: %s. Installing a drop-scheduler "
"so Graph notifications ack cleanly without piling up unbound.",
error_message,
)
async def _drop(notification: dict[str, Any], event: Any) -> None:
logger.debug(
"Dropping Graph notification because runtime is unavailable: id=%s resource=%s",
notification.get("id"),
notification.get("resource"),
)
adapter.set_notification_scheduler(_drop)
return False
async def _schedule(notification: dict[str, Any], event: Any) -> None:
await runtime.run_notification(notification)
adapter.set_notification_scheduler(_schedule)
gateway._teams_pipeline_runtime = runtime
gateway._teams_pipeline_runtime_error = None
return True