feat: MACP Phase 2A — Event Bridge + Notification System (#11)
This commit was merged in pull request #11.
This commit is contained in:
@@ -109,4 +109,10 @@ mosaic macp submit ...
|
||||
mosaic macp status
|
||||
mosaic macp drain
|
||||
mosaic macp history --task-id TASK-001
|
||||
mosaic macp watch --once
|
||||
mosaic macp watch --webhook
|
||||
```
|
||||
|
||||
The Phase 2A event bridge consumes `.mosaic/orchestrator/events.ndjson` through a polling watcher,
|
||||
persists cursor state in `.mosaic/orchestrator/event_cursor.json`, and can fan out events to
|
||||
Discord-formatted stdout lines or webhook callbacks.
|
||||
|
||||
15
tools/orchestrator-matrix/events/__init__.py
Normal file
15
tools/orchestrator-matrix/events/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Event bridge helpers for MACP orchestrator events."""
|
||||
|
||||
from .discord_formatter import format_event
|
||||
from .discord_formatter import format_summary
|
||||
from .event_watcher import EventWatcher
|
||||
from .webhook_adapter import create_webhook_callback
|
||||
from .webhook_adapter import send_webhook
|
||||
|
||||
__all__ = [
|
||||
"EventWatcher",
|
||||
"create_webhook_callback",
|
||||
"format_event",
|
||||
"format_summary",
|
||||
"send_webhook",
|
||||
]
|
||||
138
tools/orchestrator-matrix/events/discord_formatter.py
Normal file
138
tools/orchestrator-matrix/events/discord_formatter.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Format MACP orchestrator events for Discord-friendly text delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Strip ANSI escapes before generic control characters so escape fragments do not survive.
|
||||
_CTRL_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]|[\x00-\x1f\x7f]")
|
||||
# Collapse Discord @-mentions / role pings to prevent deceptive pings
|
||||
_MENTION_RE = re.compile(r"@(everyone|here|&?\d+)")
|
||||
|
||||
|
||||
def _sanitize(value: str) -> str:
|
||||
"""Normalize untrusted text for safe rendering in Discord/terminal output."""
|
||||
value = _CTRL_RE.sub(" ", value)
|
||||
value = _MENTION_RE.sub(lambda match: "@\u200b" + match.group(1), value)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _task_label(event: dict[str, Any]) -> str:
|
||||
task_id = str(event.get("task_id") or "unknown")
|
||||
return f"Task {task_id}"
|
||||
|
||||
|
||||
def _title(event: dict[str, Any]) -> str:
|
||||
metadata = event.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
for key in ("task_title", "title", "description"):
|
||||
value = _sanitize(str(metadata.get(key) or ""))
|
||||
if value:
|
||||
return value
|
||||
message = _sanitize(str(event.get("message") or ""))
|
||||
return message if message else "No details provided"
|
||||
|
||||
|
||||
def _attempt_suffix(event: dict[str, Any]) -> str:
|
||||
metadata = event.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return ""
|
||||
attempt = metadata.get("attempt")
|
||||
max_attempts = metadata.get("max_attempts")
|
||||
if attempt in (None, "") and max_attempts in (None, ""):
|
||||
return ""
|
||||
if attempt in (None, ""):
|
||||
return f"attempt ?/{max_attempts}"
|
||||
if max_attempts in (None, ""):
|
||||
return f"attempt {attempt}"
|
||||
return f"attempt {attempt}/{max_attempts}"
|
||||
|
||||
|
||||
def _duration_suffix(event: dict[str, Any]) -> str:
|
||||
metadata = event.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return ""
|
||||
duration = metadata.get("duration_seconds")
|
||||
if duration in (None, ""):
|
||||
return ""
|
||||
try:
|
||||
seconds = int(round(float(duration)))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
return f"{seconds}s"
|
||||
|
||||
|
||||
def _runtime_dispatch_suffix(event: dict[str, Any]) -> str:
|
||||
metadata = event.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
runtime = str(metadata.get("runtime") or "").strip()
|
||||
dispatch = str(metadata.get("dispatch") or "").strip()
|
||||
if runtime:
|
||||
parts.append(f"Worker: {runtime}")
|
||||
if dispatch:
|
||||
parts.append(f"dispatch: {dispatch}")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _meta_clause(*parts: str) -> str:
|
||||
clean = [part for part in parts if part]
|
||||
if not clean:
|
||||
return ""
|
||||
return f" ({', '.join(clean)})"
|
||||
|
||||
|
||||
def format_event(event: dict[str, Any]) -> str | None:
|
||||
"""Format an MACP event for Discord. Returns None for unformattable events."""
|
||||
|
||||
event_type = str(event.get("event_type") or "").strip()
|
||||
task_label = _task_label(event)
|
||||
title = _title(event)
|
||||
attempt_suffix = _attempt_suffix(event)
|
||||
duration_suffix = _duration_suffix(event)
|
||||
runtime_dispatch = _runtime_dispatch_suffix(event)
|
||||
message = _sanitize(str(event.get("message") or ""))
|
||||
|
||||
if event_type == "task.completed":
|
||||
return f"✅ **{task_label} completed** — {title}{_meta_clause(attempt_suffix, duration_suffix)}"
|
||||
if event_type == "task.failed":
|
||||
detail = message or title
|
||||
return f"❌ **{task_label} failed** — {detail}{_meta_clause(attempt_suffix)}"
|
||||
if event_type == "task.escalated":
|
||||
detail = message or title
|
||||
return f"🚨 **{task_label} escalated** — {detail}{_meta_clause(attempt_suffix)}"
|
||||
if event_type == "task.gated":
|
||||
detail = message or "Quality gates running..."
|
||||
return f"🔍 **{task_label} gated** — {detail}"
|
||||
if event_type == "task.started":
|
||||
detail = runtime_dispatch or message or "Worker execution started"
|
||||
return f"⚙️ **{task_label} started** — {detail}{_meta_clause(attempt_suffix)}"
|
||||
return None
|
||||
|
||||
|
||||
def format_summary(events: list[dict[str, Any]]) -> str:
|
||||
"""Format a batch summary (e.g., daily digest)."""
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
first_task = ""
|
||||
last_task = ""
|
||||
for event in events:
|
||||
event_type = str(event.get("event_type") or "unknown")
|
||||
counts[event_type] = counts.get(event_type, 0) + 1
|
||||
task_id = str(event.get("task_id") or "").strip()
|
||||
if task_id and not first_task:
|
||||
first_task = task_id
|
||||
if task_id:
|
||||
last_task = task_id
|
||||
|
||||
if not counts:
|
||||
return "No MACP events processed."
|
||||
|
||||
ordered = ", ".join(f"{event_type}: {counts[event_type]}" for event_type in sorted(counts))
|
||||
task_span = ""
|
||||
if first_task and last_task:
|
||||
task_span = f" Tasks {first_task}" if first_task == last_task else f" Tasks {first_task} -> {last_task}"
|
||||
return f"MACP event summary: {len(events)} events ({ordered}).{task_span}"
|
||||
144
tools/orchestrator-matrix/events/event_watcher.py
Normal file
144
tools/orchestrator-matrix/events/event_watcher.py
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Portable file-polling watcher for MACP orchestrator events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _warn(message: str) -> None:
|
||||
print(f"[macp-event-watcher] {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def _load_json(path: pathlib.Path, default: Any) -> Any:
|
||||
if not path.exists():
|
||||
return default
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_warn(f"failed to load cursor {path}: {exc}")
|
||||
return default
|
||||
|
||||
|
||||
def _save_json_atomic(path: pathlib.Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
handle.write("\n")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
class EventWatcher:
|
||||
"""Poll an events NDJSON file and dispatch matching callbacks."""
|
||||
|
||||
def __init__(self, events_path: pathlib.Path, cursor_path: pathlib.Path, poll_interval: float = 2.0):
|
||||
self.events_path = events_path
|
||||
self.cursor_path = cursor_path
|
||||
self.poll_interval = max(0.1, float(poll_interval))
|
||||
self._callbacks: list[tuple[set[str] | None, Callable[[dict[str, Any]], None]]] = []
|
||||
self._cursor_position = self._load_cursor()
|
||||
|
||||
def on(self, event_types: list[str], callback: Callable[[dict[str, Any]], None]) -> None:
|
||||
"""Register a callback for specific event types."""
|
||||
|
||||
normalized = {str(event_type).strip() for event_type in event_types if str(event_type).strip()}
|
||||
self._callbacks.append((normalized or None, callback))
|
||||
|
||||
def poll_once(self) -> list[dict[str, Any]]:
|
||||
"""Read new events since last cursor position. Returns list of new events."""
|
||||
|
||||
if not self.events_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
file_size = self.events_path.stat().st_size
|
||||
except OSError as exc:
|
||||
_warn(f"failed to stat events file {self.events_path}: {exc}")
|
||||
return []
|
||||
|
||||
if file_size < self._cursor_position:
|
||||
_warn(
|
||||
f"events file shrank from cursor={self._cursor_position} to size={file_size}; "
|
||||
"resetting cursor to start"
|
||||
)
|
||||
self._cursor_position = 0
|
||||
self._persist_cursor()
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
new_position = self._cursor_position
|
||||
try:
|
||||
with self.events_path.open("r", encoding="utf-8") as handle:
|
||||
handle.seek(self._cursor_position)
|
||||
while True:
|
||||
line_start = handle.tell()
|
||||
line = handle.readline()
|
||||
if not line:
|
||||
break
|
||||
line_end = handle.tell()
|
||||
if not line.endswith("\n"):
|
||||
new_position = line_start
|
||||
break
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
new_position = line_end
|
||||
continue
|
||||
try:
|
||||
event = json.loads(stripped)
|
||||
except json.JSONDecodeError as exc:
|
||||
_warn(f"skipping corrupt event at byte {line_start}: {exc}")
|
||||
new_position = line_end
|
||||
continue
|
||||
if not isinstance(event, dict):
|
||||
_warn(f"skipping non-object event at byte {line_start}")
|
||||
new_position = line_end
|
||||
continue
|
||||
events.append(event)
|
||||
self._dispatch(event)
|
||||
new_position = line_end
|
||||
except OSError as exc:
|
||||
_warn(f"failed to read events file {self.events_path}: {exc}")
|
||||
return []
|
||||
|
||||
if new_position != self._cursor_position:
|
||||
self._cursor_position = new_position
|
||||
self._persist_cursor()
|
||||
return events
|
||||
|
||||
def run(self, max_iterations: int = 0) -> None:
|
||||
"""Polling loop. max_iterations=0 means infinite."""
|
||||
|
||||
iterations = 0
|
||||
while max_iterations <= 0 or iterations < max_iterations:
|
||||
self.poll_once()
|
||||
iterations += 1
|
||||
if max_iterations > 0 and iterations >= max_iterations:
|
||||
break
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def _dispatch(self, event: dict[str, Any]) -> None:
|
||||
event_type = str(event.get("event_type") or "").strip()
|
||||
for filters, callback in self._callbacks:
|
||||
if filters is not None and event_type not in filters:
|
||||
continue
|
||||
try:
|
||||
callback(event)
|
||||
except Exception as exc: # pragma: no cover - defensive boundary
|
||||
_warn(f"callback failure for event {event_type or '<unknown>'}: {exc}")
|
||||
|
||||
def _load_cursor(self) -> int:
|
||||
payload = _load_json(self.cursor_path, {"position": 0})
|
||||
try:
|
||||
position = int(payload.get("position", 0))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
position = 0
|
||||
return max(0, position)
|
||||
|
||||
def _persist_cursor(self) -> None:
|
||||
_save_json_atomic(self.cursor_path, {"position": self._cursor_position})
|
||||
120
tools/orchestrator-matrix/events/webhook_adapter.py
Normal file
120
tools/orchestrator-matrix/events/webhook_adapter.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Webhook delivery helpers for MACP events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _warn(message: str) -> None:
|
||||
print(f"[macp-webhook] {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def _webhook_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
macp = config.get("macp")
|
||||
if isinstance(macp, dict) and isinstance(macp.get("webhook"), dict):
|
||||
return dict(macp["webhook"])
|
||||
return dict(config)
|
||||
|
||||
|
||||
def _validate_webhook_url(url: str, auth_token: str) -> str | None:
|
||||
"""Validate webhook URL for SSRF and cleartext credential risks.
|
||||
|
||||
Returns an error message if the URL is disallowed, or None if safe.
|
||||
"""
|
||||
import ipaddress
|
||||
import urllib.parse as urlparse
|
||||
|
||||
parsed = urlparse.urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
if scheme not in ("http", "https"):
|
||||
return f"unsupported scheme '{scheme}' — must be http or https"
|
||||
|
||||
if auth_token and scheme == "http":
|
||||
host = parsed.hostname or ""
|
||||
# Allow cleartext only for explicit loopback (dev use)
|
||||
if host not in ("localhost", "127.0.0.1", "::1"):
|
||||
return "refusing to send auth_token over non-HTTPS to non-localhost — use https://"
|
||||
|
||||
host = parsed.hostname or ""
|
||||
# Block RFC1918, loopback, and link-local IPs outright.
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if ip.is_loopback or ip.is_private or ip.is_link_local:
|
||||
return f"refusing to send webhook to private/internal IP {ip}"
|
||||
except ValueError:
|
||||
pass # hostname — DNS resolution not validated here (best-effort)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def send_webhook(event: dict[str, Any], config: dict[str, Any]) -> bool:
|
||||
"""POST event to webhook URL. Returns True on success."""
|
||||
|
||||
webhook = _webhook_config(config)
|
||||
if webhook.get("enabled") is False:
|
||||
return False
|
||||
|
||||
url = str(webhook.get("url") or "").strip()
|
||||
if not url:
|
||||
_warn("missing webhook url")
|
||||
return False
|
||||
|
||||
timeout_seconds = max(1.0, float(webhook.get("timeout_seconds") or 10))
|
||||
retry_count = max(0, int(webhook.get("retry_count") or 0))
|
||||
auth_token = str(webhook.get("auth_token") or "").strip()
|
||||
|
||||
url_err = _validate_webhook_url(url, auth_token)
|
||||
if url_err:
|
||||
_warn(f"webhook URL rejected: {url_err}")
|
||||
return False
|
||||
|
||||
payload = json.dumps(event, ensure_ascii=True).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
|
||||
attempts = retry_count + 1
|
||||
for attempt in range(1, attempts + 1):
|
||||
request = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
||||
status = getattr(response, "status", response.getcode())
|
||||
if 200 <= int(status) < 300:
|
||||
return True
|
||||
_warn(f"webhook returned HTTP {status} on attempt {attempt}/{attempts}")
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError) as exc:
|
||||
_warn(f"webhook attempt {attempt}/{attempts} failed: {exc}")
|
||||
if attempt < attempts:
|
||||
time.sleep(min(timeout_seconds, 2 ** (attempt - 1)))
|
||||
return False
|
||||
|
||||
|
||||
def create_webhook_callback(config: dict[str, Any]) -> Callable[[dict[str, Any]], None]:
|
||||
"""Factory that creates a watcher callback from config."""
|
||||
|
||||
webhook = _webhook_config(config)
|
||||
enabled = bool(webhook.get("enabled", False))
|
||||
event_filter = {
|
||||
str(event_type).strip()
|
||||
for event_type in list(webhook.get("event_filter") or [])
|
||||
if str(event_type).strip()
|
||||
}
|
||||
|
||||
def callback(event: dict[str, Any]) -> None:
|
||||
if not enabled:
|
||||
return
|
||||
event_type = str(event.get("event_type") or "").strip()
|
||||
if event_filter and event_type not in event_filter:
|
||||
return
|
||||
if not send_webhook(event, config):
|
||||
_warn(f"delivery failed for event {event.get('event_type', '<unknown>')}")
|
||||
|
||||
return callback
|
||||
Reference in New Issue
Block a user