feat: MACP Phase 2A — Event Bridge + Notification System (#11)

This commit was merged in pull request #11.
This commit is contained in:
2026-03-28 13:05:28 +00:00
parent 28392914a7
commit 356c756cfb
22 changed files with 1412 additions and 53 deletions

10
tests/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
from __future__ import annotations
import pathlib
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
EVENTS_DIR = REPO_ROOT / "tools" / "orchestrator-matrix" / "events"
if str(EVENTS_DIR) not in sys.path:
sys.path.insert(0, str(EVENTS_DIR))

121
tests/conftest.py Normal file
View File

@@ -0,0 +1,121 @@
from __future__ import annotations
import json
import pathlib
import sys
import tempfile
from dataclasses import dataclass
from typing import Any
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
EVENTS_DIR = REPO_ROOT / "tools" / "orchestrator-matrix" / "events"
if str(EVENTS_DIR) not in sys.path:
sys.path.insert(0, str(EVENTS_DIR))
@dataclass
class TempPaths:
root: pathlib.Path
events_path: pathlib.Path
cursor_path: pathlib.Path
config_path: pathlib.Path
def make_temp_paths() -> tuple[tempfile.TemporaryDirectory[str], TempPaths]:
tempdir = tempfile.TemporaryDirectory()
root = pathlib.Path(tempdir.name)
orch_dir = root / ".mosaic" / "orchestrator"
return tempdir, TempPaths(
root=root,
events_path=orch_dir / "events.ndjson",
cursor_path=orch_dir / "event_cursor.json",
config_path=orch_dir / "config.json",
)
def sample_events() -> list[dict[str, Any]]:
return [
{
"event_type": "task.assigned",
"task_id": "TASK-001",
"message": "Assigned to codex",
"metadata": {"runtime": "codex", "dispatch": "exec"},
},
{
"event_type": "task.started",
"task_id": "TASK-001",
"message": "Worker execution started",
"metadata": {"runtime": "codex", "dispatch": "exec", "attempt": 1, "max_attempts": 3},
},
{
"event_type": "task.completed",
"task_id": "TASK-001",
"message": "Completed successfully",
"metadata": {"task_title": "Finish test suite", "duration_seconds": 12.4},
},
{
"event_type": "task.failed",
"task_id": "TASK-002",
"message": "Exploded with stack trace",
"metadata": {"attempt": 2, "max_attempts": 3},
},
{
"event_type": "task.escalated",
"task_id": "TASK-003",
"message": "Human review required",
"metadata": {"attempt": 1},
},
{
"event_type": "task.gated",
"task_id": "TASK-004",
"message": "Waiting for quality gates",
"metadata": {},
},
{
"event_type": "task.retry.scheduled",
"task_id": "TASK-002",
"message": "Retry scheduled",
"metadata": {"attempt": 3, "max_attempts": 3},
},
]
def sample_config(
*,
enabled: bool = True,
event_filter: list[str] | None = None,
url: str = "https://hooks.example.com/macp",
retry_count: int = 1,
timeout_seconds: float = 2.0,
auth_token: str = "token-123",
) -> dict[str, Any]:
return {
"macp": {
"watch_poll_interval_seconds": 0.1,
"webhook": {
"enabled": enabled,
"url": url,
"event_filter": list(event_filter or []),
"retry_count": retry_count,
"timeout_seconds": timeout_seconds,
"auth_token": auth_token,
},
}
}
def write_ndjson(path: pathlib.Path, events: list[dict[str, Any]], extra_lines: list[str] | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines = [json.dumps(event) for event in events]
if extra_lines:
lines.extend(extra_lines)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def append_ndjson(path: pathlib.Path, events: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
for event in events:
handle.write(json.dumps(event))
handle.write("\n")

4
tests/run_tests.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
python3 -m unittest discover -s tests -p 'test_*.py' -v

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import unittest
from tests.conftest import sample_events
from discord_formatter import format_event, format_summary
class DiscordFormatterTests(unittest.TestCase):
def setUp(self) -> None:
self.events = sample_events()
def test_format_completed(self) -> None:
formatted = format_event(self.events[2])
self.assertIsNotNone(formatted)
self.assertIn("", formatted)
self.assertIn("TASK-001", formatted)
def test_format_failed(self) -> None:
formatted = format_event(self.events[3])
self.assertIsNotNone(formatted)
self.assertIn("", formatted)
self.assertIn("Exploded with stack trace", formatted)
def test_format_escalated(self) -> None:
formatted = format_event(self.events[4])
self.assertIsNotNone(formatted)
self.assertIn("🚨", formatted)
self.assertIn("Human review required", formatted)
def test_format_gated(self) -> None:
formatted = format_event(self.events[5])
self.assertIsNotNone(formatted)
self.assertIn("🔍", formatted)
def test_format_started(self) -> None:
formatted = format_event(self.events[1])
self.assertIsNotNone(formatted)
self.assertIn("⚙️", formatted)
self.assertIn("Worker: codex", formatted)
def test_format_unknown_type(self) -> None:
self.assertIsNone(format_event({"event_type": "task.unknown", "task_id": "TASK-999"}))
def test_sanitize_control_chars(self) -> None:
event = {
"event_type": "task.failed",
"task_id": "TASK-200",
"message": "bad\x00news\x1b[31m!",
}
formatted = format_event(event)
self.assertIsNotNone(formatted)
self.assertNotIn("\x00", formatted)
self.assertNotIn("\x1b", formatted)
self.assertIn("bad news !", formatted)
def test_sanitize_mentions(self) -> None:
event = {
"event_type": "task.escalated",
"task_id": "TASK-201",
"message": "@everyone investigate",
}
formatted = format_event(event)
self.assertIsNotNone(formatted)
self.assertIn("@\u200beveryone investigate", formatted)
def test_format_summary(self) -> None:
summary = format_summary([self.events[1], self.events[2], self.events[3]])
self.assertIn("3 events", summary)
self.assertIn("task.completed: 1", summary)
self.assertIn("task.failed: 1", summary)
self.assertIn("task.started: 1", summary)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import json
import unittest
from tests.conftest import append_ndjson, make_temp_paths, sample_events, write_ndjson
from event_watcher import EventWatcher
class EventWatcherTests(unittest.TestCase):
def setUp(self) -> None:
self.tempdir, self.paths = make_temp_paths()
self.events = sample_events()
def tearDown(self) -> None:
self.tempdir.cleanup()
def watcher(self) -> EventWatcher:
return EventWatcher(self.paths.events_path, self.paths.cursor_path, poll_interval=0.1)
def test_poll_empty_file(self) -> None:
watcher = self.watcher()
self.assertEqual(watcher.poll_once(), [])
self.assertFalse(self.paths.cursor_path.exists())
def test_poll_new_events(self) -> None:
write_ndjson(self.paths.events_path, self.events[:3])
polled = self.watcher().poll_once()
self.assertEqual(polled, self.events[:3])
def test_cursor_persistence(self) -> None:
watcher = self.watcher()
write_ndjson(self.paths.events_path, self.events[:3])
first = watcher.poll_once()
second = watcher.poll_once()
self.assertEqual(first, self.events[:3])
self.assertEqual(second, [])
cursor = json.loads(self.paths.cursor_path.read_text(encoding="utf-8"))
self.assertGreater(cursor["position"], 0)
def test_cursor_survives_restart(self) -> None:
write_ndjson(self.paths.events_path, self.events[:3])
first_watcher = self.watcher()
self.assertEqual(first_watcher.poll_once(), self.events[:3])
second_watcher = self.watcher()
self.assertEqual(second_watcher.poll_once(), [])
def test_corrupt_line_skipped(self) -> None:
self.paths.events_path.parent.mkdir(parents=True, exist_ok=True)
with self.paths.events_path.open("w", encoding="utf-8") as handle:
handle.write(json.dumps(self.events[0]) + "\n")
handle.write("{not-json}\n")
handle.write(json.dumps(self.events[1]) + "\n")
polled = self.watcher().poll_once()
self.assertEqual(polled, [self.events[0], self.events[1]])
def test_callback_filtering(self) -> None:
write_ndjson(self.paths.events_path, self.events)
received: list[dict[str, object]] = []
watcher = self.watcher()
watcher.on(["task.completed"], received.append)
watcher.poll_once()
self.assertEqual(received, [self.events[2]])
def test_callback_receives_events(self) -> None:
write_ndjson(self.paths.events_path, self.events[:2])
received: list[dict[str, object]] = []
watcher = self.watcher()
watcher.on([], received.append)
polled = watcher.poll_once()
self.assertEqual(received, self.events[:2])
self.assertEqual(polled, self.events[:2])
def test_file_grows_between_polls(self) -> None:
watcher = self.watcher()
write_ndjson(self.paths.events_path, self.events[:2])
first = watcher.poll_once()
append_ndjson(self.paths.events_path, self.events[2:5])
second = watcher.poll_once()
self.assertEqual(first, self.events[:2])
self.assertEqual(second, self.events[2:5])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
import unittest
import urllib.error
from unittest.mock import patch
from tests.conftest import sample_config, sample_events
from webhook_adapter import create_webhook_callback, send_webhook
class MockHTTPResponse:
def __init__(self, status: int) -> None:
self.status = status
def getcode(self) -> int:
return self.status
def __enter__(self) -> "MockHTTPResponse":
return self
def __exit__(self, exc_type, exc, tb) -> bool:
return False
class WebhookAdapterTests(unittest.TestCase):
def setUp(self) -> None:
self.events = sample_events()
self.completed_event = self.events[2]
self.started_event = self.events[1]
@patch("webhook_adapter.urllib.request.urlopen", return_value=MockHTTPResponse(200))
def test_send_webhook_success(self, mock_urlopen) -> None:
config = sample_config()
result = send_webhook(self.completed_event, config)
self.assertTrue(result)
self.assertEqual(mock_urlopen.call_count, 1)
request = mock_urlopen.call_args.args[0]
self.assertEqual(request.full_url, "https://hooks.example.com/macp")
self.assertEqual(request.get_method(), "POST")
self.assertEqual(request.headers["Authorization"], "Bearer token-123")
@patch("webhook_adapter.urllib.request.urlopen", return_value=MockHTTPResponse(500))
def test_send_webhook_failure(self, mock_urlopen) -> None:
result = send_webhook(self.completed_event, sample_config(retry_count=0))
self.assertFalse(result)
self.assertEqual(mock_urlopen.call_count, 1)
@patch("webhook_adapter.urllib.request.urlopen", side_effect=TimeoutError("timed out"))
def test_send_webhook_timeout(self, mock_urlopen) -> None:
result = send_webhook(self.completed_event, sample_config(retry_count=0))
self.assertFalse(result)
self.assertEqual(mock_urlopen.call_count, 1)
@patch("webhook_adapter.time.sleep", return_value=None)
@patch(
"webhook_adapter.urllib.request.urlopen",
side_effect=[MockHTTPResponse(500), MockHTTPResponse(200)],
)
def test_send_webhook_retry(self, mock_urlopen, mock_sleep) -> None:
result = send_webhook(self.completed_event, sample_config(retry_count=1))
self.assertTrue(result)
self.assertEqual(mock_urlopen.call_count, 2)
mock_sleep.assert_called_once()
@patch("webhook_adapter.send_webhook", return_value=True)
def test_event_filter(self, mock_send_webhook) -> None:
callback = create_webhook_callback(sample_config(event_filter=["task.completed"]))
callback(self.started_event)
callback(self.completed_event)
mock_send_webhook.assert_called_once_with(self.completed_event, sample_config(event_filter=["task.completed"]))
@patch("webhook_adapter.send_webhook")
def test_webhook_disabled(self, mock_send_webhook) -> None:
callback = create_webhook_callback(sample_config(enabled=False))
callback(self.completed_event)
mock_send_webhook.assert_not_called()
@patch("webhook_adapter.urllib.request.urlopen")
def test_ssrf_blocked(self, mock_urlopen) -> None:
for url in ("http://127.0.0.1/webhook", "http://10.1.2.3/webhook"):
with self.subTest(url=url):
result = send_webhook(
self.completed_event,
sample_config(url=url, auth_token="", retry_count=0),
)
self.assertFalse(result)
mock_urlopen.assert_not_called()
if __name__ == "__main__":
unittest.main()