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

View File

@@ -0,0 +1,152 @@
# MACP Phase 2A — Event Bridge + Notification System
**Branch:** `feat/macp-phase2a`
**Repo worktree:** `~/src/mosaic-bootstrap-worktrees/macp-phase2a`
---
## Objective
Build the event bridge that makes MACP events consumable by external systems (OpenClaw, Discord, webhooks). This is the observability layer — the controller already writes events to `events.ndjson`, but nothing reads them yet.
---
## Task 1: Event File Watcher (`tools/orchestrator-matrix/events/event_watcher.py`)
New Python module that tails `events.ndjson` and fires callbacks on new events.
### Requirements:
- Watch `.mosaic/orchestrator/events.ndjson` for new lines (use file polling, not inotify — keeps it portable)
- Parse each new line as JSON
- Call registered callback functions with the parsed event
- Support filtering by event type (e.g., only `task.completed` and `task.failed`)
- Maintain a cursor (last read position) so restarts don't replay old events
- Cursor stored in `.mosaic/orchestrator/event_cursor.json`
### Key Functions:
```python
class EventWatcher:
def __init__(self, events_path: Path, cursor_path: Path, poll_interval: float = 2.0):
...
def on(self, event_types: list[str], callback: Callable[[dict], None]) -> None:
"""Register a callback for specific event types."""
def poll_once(self) -> list[dict]:
"""Read new events since last cursor position. Returns list of new events."""
def run(self, max_iterations: int = 0) -> None:
"""Polling loop. max_iterations=0 means infinite."""
```
### Constraints:
- Python 3.10+ stdlib only (no pip dependencies)
- Must handle truncated/corrupt lines gracefully (skip, log warning)
- File might not exist yet — handle gracefully
- Thread-safe cursor updates (atomic write via temp file rename)
---
## Task 2: Webhook Adapter (`tools/orchestrator-matrix/events/webhook_adapter.py`)
POST events to a configurable URL. This is how the OC plugin will consume MACP events.
### Requirements:
- Accept an event dict, POST it as JSON to a configured URL
- Support optional `Authorization` header (bearer token)
- Configurable from `.mosaic/orchestrator/config.json` under `macp.webhook`:
```json
{
"macp": {
"webhook": {
"enabled": false,
"url": "http://localhost:8080/macp/events",
"auth_token": "",
"timeout_seconds": 10,
"retry_count": 2,
"event_filter": ["task.completed", "task.failed", "task.escalated"]
}
}
}
```
- Retry with exponential backoff on failure (configurable count)
- Log failures but don't crash the watcher
- Return success/failure status
### Key Functions:
```python
def send_webhook(event: dict, config: dict) -> bool:
"""POST event to webhook URL. Returns True on success."""
def create_webhook_callback(config: dict) -> Callable[[dict], None]:
"""Factory that creates a watcher callback from config."""
```
### Constraints:
- Use `urllib.request` only (no `requests` library)
- Must not block the event watcher for more than `timeout_seconds` per event
- Log to stderr on failure
---
## Task 3: Discord Notification Formatter (`tools/orchestrator-matrix/events/discord_formatter.py`)
Format MACP events into human-readable Discord messages.
### Requirements:
- Format functions for each event type:
- `task.completed` → "✅ **Task TASK-001 completed** — Implement user auth (attempt 1/1, 45s)"
- `task.failed` → "❌ **Task TASK-001 failed** — Build error: exit code 1 (attempt 2/3)"
- `task.escalated` → "🚨 **Task TASK-001 escalated** — Gate failures after 3 attempts. Human review needed."
- `task.gated` → "🔍 **Task TASK-001 gated** — Quality gates running..."
- `task.started` → "⚙️ **Task TASK-001 started** — Worker: codex, dispatch: yolo"
- Include task metadata: runtime, dispatch type, attempt count, duration (if available)
- Keep messages concise — Discord has character limits
- Return plain strings (the caller decides where to send them)
### Key Functions:
```python
def format_event(event: dict) -> str | None:
"""Format an MACP event for Discord. Returns None for unformattable events."""
def format_summary(events: list[dict]) -> str:
"""Format a batch summary (e.g., daily digest)."""
```
---
## Wiring: CLI Integration
Add to `bin/mosaic-macp`:
```bash
mosaic macp watch [--webhook] [--once]
```
- `watch`: Start the event watcher with configured callbacks
- `--webhook`: Enable webhook delivery (reads config from `.mosaic/orchestrator/config.json`)
- `--once`: Poll once and exit (useful for cron)
---
## Verification
1. Create a test `events.ndjson` with sample events, run `event_watcher.poll_once()`, verify all events returned
2. Run watcher with webhook pointing to a local echo server, verify POST payload
3. Format each event type through `discord_formatter`, verify output strings
4. `mosaic macp watch --once` processes events without error
5. Cursor persistence: run twice, second run returns no events
## File Map
```
tools/orchestrator-matrix/
├── events/
│ ├── __init__.py ← NEW
│ ├── event_watcher.py ← NEW
│ ├── webhook_adapter.py ← NEW
│ └── discord_formatter.py ← NEW
```
## Ground Rules
- Python 3.10+ stdlib only
- No async/threads — synchronous polling
- Commit: `feat: add MACP event bridge — watcher, webhook, Discord formatter`
- Push to `feat/macp-phase2a`

View File

@@ -0,0 +1,81 @@
# MACP Phase 2A — Test Suite
**Branch:** `feat/macp-phase2a` (commit on top of existing)
**Repo worktree:** `~/src/mosaic-bootstrap-worktrees/macp-phase2a`
---
## Objective
Write a comprehensive test suite for the Phase 2A event bridge code using Python `unittest` (stdlib only). Tests must be runnable with `python3 -m pytest tests/` or `python3 -m unittest discover tests/`.
---
## Task 1: Test infrastructure (`tests/conftest.py` + `tests/run_tests.sh`)
Create `tests/` directory at repo root with:
- `conftest.py` — shared fixtures: temp directories, sample events, sample config
- `run_tests.sh` — simple runner: `python3 -m unittest discover -s tests -p 'test_*.py' -v`
- `__init__.py` — empty, makes tests a package
Sample events fixture should include one of each type: `task.assigned`, `task.started`, `task.completed`, `task.failed`, `task.escalated`, `task.gated`, `task.retry.scheduled`
---
## Task 2: Event watcher tests (`tests/test_event_watcher.py`)
Test the `EventWatcher` class from `tools/orchestrator-matrix/events/event_watcher.py`.
### Test cases:
1. `test_poll_empty_file` — No events file exists → returns empty list
2. `test_poll_new_events` — Write 3 events to ndjson, poll → returns all 3
3. `test_cursor_persistence` — Poll once (reads 3), poll again → returns 0 (cursor saved)
4. `test_cursor_survives_restart` — Poll, create new watcher instance, poll → no duplicates
5. `test_corrupt_line_skipped` — Insert a corrupt JSON line between valid events → valid events returned, corrupt skipped
6. `test_callback_filtering` — Register callback for `task.completed` only → only completed events trigger it
7. `test_callback_receives_events` — Register callback, poll → callback called with correct event dicts
8. `test_file_grows_between_polls` — Poll (gets 2), append 3 more, poll → gets 3
---
## Task 3: Webhook adapter tests (`tests/test_webhook_adapter.py`)
Test `send_webhook` and `create_webhook_callback` from `tools/orchestrator-matrix/events/webhook_adapter.py`.
### Test cases:
1. `test_send_webhook_success` — Mock HTTP response 200 → returns True
2. `test_send_webhook_failure` — Mock HTTP response 500 → returns False
3. `test_send_webhook_timeout` — Mock timeout → returns False, no crash
4. `test_send_webhook_retry` — Mock 500 then 200 → retries and succeeds
5. `test_event_filter` — Config with filter `["task.completed"]` → callback ignores `task.started`
6. `test_webhook_disabled` — Config with `enabled: false` → no HTTP call made
7. `test_ssrf_blocked` — URL with private IP (127.0.0.1, 10.x) → blocked, returns False
Use `unittest.mock.patch` to mock `urllib.request.urlopen`.
---
## Task 4: Discord formatter tests (`tests/test_discord_formatter.py`)
Test `format_event` and `format_summary` from `tools/orchestrator-matrix/events/discord_formatter.py`.
### Test cases:
1. `test_format_completed` — Completed event → contains "✅" and task ID
2. `test_format_failed` — Failed event → contains "❌" and error message
3. `test_format_escalated` — Escalated event → contains "🚨" and escalation reason
4. `test_format_gated` — Gated event → contains "🔍"
5. `test_format_started` — Started event → contains "⚙️" and runtime info
6. `test_format_unknown_type` — Unknown event type → returns None
7. `test_sanitize_control_chars` — Event with control characters in message → stripped in output
8. `test_sanitize_mentions` — Event with `@everyone` in message → neutralized in output
9. `test_format_summary` — List of mixed events → summary with counts
---
## Verification
After writing tests:
1. `cd ~/src/mosaic-bootstrap-worktrees/macp-phase2a && python3 -m unittest discover -s tests -p 'test_*.py' -v` — ALL tests must pass
2. Fix any failures before committing
Commit: `test: add comprehensive test suite for Phase 2A event bridge`