mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-11 03:31:55 +00:00
feat(teams-pipeline): add plugin runtime and operator cli
Third slice of the Microsoft Teams meeting pipeline stack, salvaged onto current main. Adds the standalone teams_pipeline plugin that consumes Graph change notifications from the webhook listener, resolves meeting artifacts (transcript first, recording + STT fallback later), persists job state in a durable store, and exposes an operator CLI for inspection, replay, subscription management, and validation. Design choices follow maintainer review feedback on PR #19815: - Standalone plugin rather than bolted-on core surface (plugins/teams_pipeline/, kind: standalone in plugin.yaml). - Zero new model tools. The agent drives the pipeline by invoking the operator CLI via the terminal tool, guided by the skill that ships with a follow-up PR. - Reuses the existing msgraph_webhook gateway platform for Graph ingress. Pipeline runtime is wired in via bind_gateway_runtime and gated on plugins.enabled so gateways that don't run the plugin boot cleanly. Additions: - plugins/teams_pipeline/: runtime (gateway wiring + config builder), pipeline core, durable SQLite store, subscription maintenance helpers, Graph artifact resolution, operator CLI (list, show, run/replay, fetch dry-run, subscriptions list, subscribe, renew-subscription, delete-subscription, maintain-subscriptions, token-health, validate). - hermes_cli/main.py: second-pass plugin CLI discovery so any standalone plugin registered via ctx.register_cli_command() outside the memory-plugin convention path gets its subcommand wired into argparse without touching core. - gateway/run.py: _teams_pipeline_plugin_enabled() config gate, _wire_teams_pipeline_runtime() binding after adapter setup, and the two runner attributes used by the runtime. Credit to @dlkakbs for the entire plugin implementation.
This commit is contained in:
parent
ea86714cc0
commit
07bbd93337
14 changed files with 3332 additions and 1 deletions
249
plugins/teams_pipeline/subscriptions.py
Normal file
249
plugins/teams_pipeline/subscriptions.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"""Microsoft Graph subscription helpers for the Teams pipeline plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from plugins.teams_pipeline.models import GraphSubscription
|
||||
from plugins.teams_pipeline.store import TeamsPipelineStore, resolve_teams_pipeline_store_path
|
||||
from tools.microsoft_graph_auth import MicrosoftGraphTokenProvider
|
||||
from tools.microsoft_graph_client import MicrosoftGraphClient
|
||||
|
||||
|
||||
def build_graph_client() -> MicrosoftGraphClient:
|
||||
provider = MicrosoftGraphTokenProvider.from_env()
|
||||
return MicrosoftGraphClient(provider)
|
||||
|
||||
|
||||
def _parse_bool(value: Any, *, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _parse_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return _utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = f"{text[:-1]}+00:00"
|
||||
parsed = datetime.fromisoformat(text)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def resolve_store_path(path: str | None) -> str:
|
||||
return str(resolve_teams_pipeline_store_path(path))
|
||||
|
||||
|
||||
def build_store(path: str | None = None) -> TeamsPipelineStore:
|
||||
return TeamsPipelineStore(resolve_store_path(path))
|
||||
|
||||
|
||||
def sync_graph_subscription_record(
|
||||
store: TeamsPipelineStore,
|
||||
subscription_payload: dict[str, Any],
|
||||
*,
|
||||
status: str | None = None,
|
||||
renewed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
normalized = GraphSubscription.from_dict(subscription_payload).to_dict()
|
||||
expiration = _parse_datetime(normalized.get("expiration_datetime"))
|
||||
effective_status = status
|
||||
if effective_status is None:
|
||||
effective_status = "expired" if expiration and expiration <= _utc_now() else "active"
|
||||
normalized["status"] = effective_status
|
||||
if renewed:
|
||||
normalized["latest_renewal_at"] = _utc_now_iso()
|
||||
return store.upsert_subscription(normalized["subscription_id"], normalized)
|
||||
|
||||
|
||||
def expected_client_state(raw: str | None = None) -> str | None:
|
||||
if raw is None:
|
||||
from os import getenv
|
||||
|
||||
raw = getenv("MSGRAPH_WEBHOOK_CLIENT_STATE", "")
|
||||
value = str(raw or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def is_managed_subscription(
|
||||
store: TeamsPipelineStore,
|
||||
subscription_payload: dict[str, Any],
|
||||
*,
|
||||
expected_client_state_value: str | None,
|
||||
) -> bool:
|
||||
subscription_id = str(
|
||||
subscription_payload.get("subscription_id") or subscription_payload.get("id") or ""
|
||||
).strip()
|
||||
if subscription_id and store.get_subscription(subscription_id):
|
||||
return True
|
||||
|
||||
if expected_client_state_value:
|
||||
candidate_state = str(
|
||||
subscription_payload.get("client_state") or subscription_payload.get("clientState") or ""
|
||||
).strip()
|
||||
if candidate_state and candidate_state == expected_client_state_value:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def maintain_graph_subscriptions(
|
||||
*,
|
||||
client: MicrosoftGraphClient,
|
||||
store: TeamsPipelineStore,
|
||||
renew_within_hours: int = 24,
|
||||
extend_hours: int = 24,
|
||||
dry_run: bool = False,
|
||||
client_state: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
threshold_hours = max(1, int(renew_within_hours))
|
||||
extend_hours = max(1, int(extend_hours))
|
||||
managed_client_state = expected_client_state(client_state)
|
||||
now = _utc_now()
|
||||
|
||||
remote_subscriptions = await client.collect_paginated("/subscriptions")
|
||||
remote_ids: set[str] = set()
|
||||
synced = 0
|
||||
renewed: list[dict[str, Any]] = []
|
||||
candidates: list[dict[str, Any]] = []
|
||||
skipped: list[dict[str, Any]] = []
|
||||
|
||||
for raw in remote_subscriptions:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
subscription_id = str(raw.get("id") or "").strip()
|
||||
if not subscription_id:
|
||||
continue
|
||||
managed = is_managed_subscription(
|
||||
store,
|
||||
raw,
|
||||
expected_client_state_value=managed_client_state,
|
||||
)
|
||||
if not managed:
|
||||
skipped.append(
|
||||
{
|
||||
"subscription_id": subscription_id,
|
||||
"reason": "not_managed_by_teams_pipeline",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
remote_ids.add(subscription_id)
|
||||
try:
|
||||
sync_graph_subscription_record(store, raw)
|
||||
synced += 1
|
||||
except Exception as exc:
|
||||
skipped.append(
|
||||
{
|
||||
"subscription_id": subscription_id,
|
||||
"reason": f"failed_to_sync_local_store: {exc}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
expiration = _parse_datetime(raw.get("expirationDateTime"))
|
||||
if expiration is None:
|
||||
skipped.append({"subscription_id": subscription_id, "reason": "missing_expiration"})
|
||||
continue
|
||||
|
||||
seconds_until_expiry = int((expiration - now).total_seconds())
|
||||
if seconds_until_expiry < 0:
|
||||
store.upsert_subscription(
|
||||
subscription_id,
|
||||
{
|
||||
"status": "expired",
|
||||
"expiration_datetime": expiration.isoformat().replace("+00:00", "Z"),
|
||||
},
|
||||
)
|
||||
skipped.append(
|
||||
{
|
||||
"subscription_id": subscription_id,
|
||||
"reason": "already_expired",
|
||||
"expiration_datetime": expiration.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if seconds_until_expiry > threshold_hours * 3600:
|
||||
skipped.append(
|
||||
{
|
||||
"subscription_id": subscription_id,
|
||||
"reason": "not_due",
|
||||
"expires_in_seconds": seconds_until_expiry,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
new_expiration = (max(now, expiration) + timedelta(hours=extend_hours)).replace(
|
||||
microsecond=0
|
||||
).isoformat().replace("+00:00", "Z")
|
||||
candidate = {
|
||||
"subscription_id": subscription_id,
|
||||
"resource": raw.get("resource"),
|
||||
"current_expiration": expiration.isoformat().replace("+00:00", "Z"),
|
||||
"new_expiration": new_expiration,
|
||||
}
|
||||
candidates.append(candidate)
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
patched = await client.patch_json(
|
||||
f"/subscriptions/{subscription_id}",
|
||||
json_body={"expirationDateTime": new_expiration},
|
||||
)
|
||||
merged = {**raw, **(patched or {}), "id": subscription_id, "expirationDateTime": new_expiration}
|
||||
sync_graph_subscription_record(store, merged, status="active", renewed=True)
|
||||
renewed.append({**candidate, "result": patched})
|
||||
|
||||
for subscription_id in store.list_subscriptions():
|
||||
if subscription_id in remote_ids:
|
||||
continue
|
||||
store.upsert_subscription(
|
||||
subscription_id,
|
||||
{
|
||||
"status": "missing_remote",
|
||||
"last_seen_missing_remote_at": _utc_now_iso(),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"dry_run": bool(dry_run),
|
||||
"store_path": str(store.path),
|
||||
"remote_subscription_count": len(remote_subscriptions),
|
||||
"synced_subscription_count": synced,
|
||||
"candidate_count": len(candidates),
|
||||
"renewed_count": len(renewed),
|
||||
"threshold_hours": threshold_hours,
|
||||
"extend_hours": extend_hours,
|
||||
"candidates": candidates,
|
||||
"renewed": renewed,
|
||||
"skipped": skipped,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue