Skip to content

feat(events): emit structured events for engine to fan out to Slack#164

Open
jacsamell wants to merge 1 commit into
mainfrom
claude/engine-events
Open

feat(events): emit structured events for engine to fan out to Slack#164
jacsamell wants to merge 1 commit into
mainfrom
claude/engine-events

Conversation

@jacsamell
Copy link
Copy Markdown
Contributor

@jacsamell jacsamell commented May 18, 2026

Cube emits structured events; Engine handles Slack delivery. Splits responsibilities cleanly.

Two emit channels

1. PR-context events ride the review body (the common case)

Every cube prv review body now ends in a hidden HTML-comment footer:

<!-- agent-cube-events
{
  "schema": 1,
  "events": [
    {"type": "human_call", "severity": "must-decide", "question": "...", "options": ["...", "..."], "lenses": ["principal-engineer"], "evidence": "file:line", "request_id": "cube-decision-..."},
    {"type": "pr_ready_to_merge", "approvals": 5, "total_judges": 5, "request_id": "cube-ready-..."}
  ]
}
-->
  • Invisible in rendered GitHub UI
  • Engine subscribes to pull_request_review.submitted for reviews from the-agent-cube[bot]
  • Zero new cube-side ingress / auth / infrastructure
  • The PR is the durable event log

2. Non-PR events POST to $CUBE_ENGINE_WEBHOOK_URL (rare path: setup_needed, scheduled sweeps). Fire-and-forget + one retry. No-op when env-var unset.

Event types (schema v1)

  • human_call (severity: must-decide | worth-asking) — wired
  • pr_ready_to_merge — wired
  • pr_blocked — defined, detection path follow-up
  • setup_needed — defined, detection path follow-up

What Engine needs to do

  • Subscribe to GitHub pull_request_review.submitted from the-agent-cube[bot]
  • Parse the <!-- agent-cube-events ... --> marker
  • Fan out to Slack with whatever routing logic you want
  • For resolutions: post a <!-- agent-cube-resolution {...} --> comment, cube picks up on resume (via PR feat(auto): persist -p feedback to task spec so judges see it #156's -p persistence) — follow-up PR

Open to your preference on resolution pathway (GitHub PR comment vs .cube/resolutions/<request_id>.json on the cube host).

8 module smoke tests + 192 existing tests pass. Not admin-merging — architecture choice worth eyeballs.

🤖 Generated with Claude Code

Overview

This PR introduces structured event emission from Cube to delegate Slack delivery responsibilities to the Engine. It establishes a clear event-driven boundary between the two systems via two communication channels.

Key Changes

New module: engine_events.py (+258 lines)

  • Defines event schema versioning and four event types: HumanCallEvent, PrReadyToMergeEvent, PrBlockedEvent, SetupNeededEvent
  • Implements two transport channels:
    1. PR context events: Serialized as an invisible HTML comment footer (<!-- agent-cube-events { ... } -->) appended to review bodies
    2. Non-PR events: POSTed to $CUBE_ENGINE_WEBHOOK_URL with one retry; no-op if env var unset
  • Provides utilities for building/parsing event footers and translating panel review inputs into structured events

Updated: peer_review.py (+14 lines)

  • Appends the engine events footer to review summary bodies after auto-approve gate logic

Design

  • PR as durable event log: Engine subscribes to pull_request_review.submitted and extracts events from bot review footers—no new Cube infrastructure needed
  • Schema v1 events: Support must-decide and worth-asking severity levels for human calls, plus merge readiness and blocking states
  • Fire-and-forget reliability: Webhook delivery retries transient failures but respects 4xx errors

Test Coverage

8 new module smoke tests + 192 existing tests pass

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

Walkthrough

This PR adds a new event system for communicating peer review outcomes and human decisions from Cube to Aetheron Engine. It introduces typed event dataclasses, two transport mechanisms (GitHub PR footer comments and webhook delivery), and integrates event creation into the peer review workflow.

Changes

Engine Events System for Cube→Aetheron Communication

Layer / File(s) Summary
Event schema and PR footer transport
python/cube/integrations/engine_events.py
Introduces EVENT_SCHEMA_VERSION constant, four typed event dataclasses (HumanCallEvent, PrReadyToMergeEvent, PrBlockedEvent, SetupNeededEvent), and EngineEvent union type. Provides build_events_footer() to serialise events as an invisible HTML comment, and parse_events_footer() to extract and validate raw event dictionaries with JSON parsing and shape validation.
Webhook delivery and event translation
python/cube/integrations/engine_events.py
Implements post_webhook_event() to deliver events via HTTP POST to CUBE_ENGINE_WEBHOOK_URL with schema version, single retry logic for transient failures (no retry for 4xx errors), and timeout handling. Provides events_for_panel_review() to convert panel review inputs (deduped human calls, gate outcome, approvals, judge count) into EngineEvent instances with severity normalisation.
Peer review summary integration
python/cube/commands/peer_review.py
Constructs engine_events via events_for_panel_review() using gate result, gate reasons, deduped human calls, approval counts, and judge counts, then appends the serialised events footer to the GitHub review summary body.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A whispered message in the PR review thread,
Events hiding in HTML comments, by Cube instead,
To the Engine they hop with webhook precision,
Human calls and gate verdicts, in structured transmission!
No mystery now—just facts, neat and tidy,
For orchestration to follow, swiftly and bridy. 🚀

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding structured event emission for engine fan-out to Slack, which is the primary purpose of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/cube/integrations/engine_events.py`:
- Around line 130-153: The parser parse_events_footer currently returns any JSON
with an "events" list; update it to validate the footer schema version too:
retrieve payload.get("schema") and return [] unless it exactly equals the
expected footer schema constant (introduce or use a module-level constant like
_FOOTER_SCHEMA_VERSION), so only when schema is present and matches do you
proceed to retrieve "events" and filter dict entries; keep all other error paths
returning [] and continue using _FOOTER_OPEN/_FOOTER_CLOSE to locate the
payload.
- Around line 175-196: The code currently passes the value returned by
_webhook_url() directly to urllib.request.Request / urlopen; add an explicit
scheme allowlist by parsing the URL (e.g., via urllib.parse.urlparse) and
verifying parsed.scheme is either "http" or "https" before proceeding. If the
scheme is missing or not in {"http","https"}, return False (same behavior as
when url is falsy) or log and abort; perform this check right after calling
_webhook_url() and before building the payload/Request and before the urlopen
loop in send (or the function containing the for-attempt loop) so only
http/https requests are allowed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 66a1f50c-f7f4-49cb-94e0-77d3254878b6

📥 Commits

Reviewing files that changed from the base of the PR and between 8bd9e9e and 92addc5.

📒 Files selected for processing (2)
  • python/cube/commands/peer_review.py
  • python/cube/integrations/engine_events.py
📜 Review details
🧰 Additional context used
🪛 Ruff (0.15.12)
python/cube/integrations/engine_events.py

[error] 180-188: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.

(S310)


[error] 195-195: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.

(S310)

🔇 Additional comments (2)
python/cube/commands/peer_review.py (1)

506-519: LGTM!

python/cube/integrations/engine_events.py (1)

44-127: LGTM!

Also applies to: 216-258

Comment on lines +130 to +153
def parse_events_footer(body: str) -> list[dict[str, Any]]:
"""Inverse of `build_events_footer`. Returns raw event dicts.

Used by tests and (optionally) by engine implementations that want to
consume cube's emitted events without duplicating the marker constants.

Returns an empty list when no footer is present or the JSON is malformed.
"""
start = body.find(_FOOTER_OPEN)
if start < 0:
return []
end = body.find(_FOOTER_CLOSE, start + len(_FOOTER_OPEN))
if end < 0:
return []
raw = body[start + len(_FOOTER_OPEN) : end].strip()
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return []
events = payload.get("events")
if not isinstance(events, list):
return []
return [e for e in events if isinstance(e, dict)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate schema version in footer parser.

At Line 146 onwards, the parser accepts any JSON containing an events list, even when schema is missing or incompatible. That weakens the versioned contract and can produce silent misreads across schema upgrades.

Suggested fix
 def parse_events_footer(body: str) -> list[dict[str, Any]]:
@@
     try:
         payload = json.loads(raw)
     except json.JSONDecodeError:
         return []
+    if payload.get("schema") != EVENT_SCHEMA_VERSION:
+        return []
     events = payload.get("events")
     if not isinstance(events, list):
         return []
     return [e for e in events if isinstance(e, dict)]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cube/integrations/engine_events.py` around lines 130 - 153, The parser
parse_events_footer currently returns any JSON with an "events" list; update it
to validate the footer schema version too: retrieve payload.get("schema") and
return [] unless it exactly equals the expected footer schema constant
(introduce or use a module-level constant like _FOOTER_SCHEMA_VERSION), so only
when schema is present and matches do you proceed to retrieve "events" and
filter dict entries; keep all other error paths returning [] and continue using
_FOOTER_OPEN/_FOOTER_CLOSE to locate the payload.

Comment on lines +175 to +196
url = _webhook_url()
if not url:
return False

payload = json.dumps({"schema": EVENT_SCHEMA_VERSION, "event": asdict(event)}).encode()
req = urllib.request.Request(
url,
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"User-Agent": "agent-cube",
},
)

# Track last error for debugging; not surfaced upstream — engine is the
# source of truth for human-facing comms.
_last_err: Optional[Exception] = None
for attempt in range(2):
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict webhook URL schemes to http/https before opening.

At Line 175 and Line 195, CUBE_ENGINE_WEBHOOK_URL is used directly with urlopen. Add an explicit scheme allowlist to avoid unexpected scheme handling (file:, custom handlers) and tighten outbound request safety.

Suggested fix
 import json
 import os
 import urllib.error
+import urllib.parse
 import urllib.request
@@
 def post_webhook_event(event: EngineEvent, *, timeout: int = 10) -> bool:
@@
     url = _webhook_url()
     if not url:
         return False
+    parsed = urllib.parse.urlparse(url)
+    if parsed.scheme not in {"http", "https"}:
+        return False
 
     payload = json.dumps({"schema": EVENT_SCHEMA_VERSION, "event": asdict(event)}).encode()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
url = _webhook_url()
if not url:
return False
payload = json.dumps({"schema": EVENT_SCHEMA_VERSION, "event": asdict(event)}).encode()
req = urllib.request.Request(
url,
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"User-Agent": "agent-cube",
},
)
# Track last error for debugging; not surfaced upstream — engine is the
# source of truth for human-facing comms.
_last_err: Optional[Exception] = None
for attempt in range(2):
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
url = _webhook_url()
if not url:
return False
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"}:
return False
payload = json.dumps({"schema": EVENT_SCHEMA_VERSION, "event": asdict(event)}).encode()
req = urllib.request.Request(
url,
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"User-Agent": "agent-cube",
},
)
# Track last error for debugging; not surfaced upstream — engine is the
# source of truth for human-facing comms.
_last_err: Optional[Exception] = None
for attempt in range(2):
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
🧰 Tools
🪛 Ruff (0.15.12)

[error] 180-188: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.

(S310)


[error] 195-195: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.

(S310)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cube/integrations/engine_events.py` around lines 175 - 196, The code
currently passes the value returned by _webhook_url() directly to
urllib.request.Request / urlopen; add an explicit scheme allowlist by parsing
the URL (e.g., via urllib.parse.urlparse) and verifying parsed.scheme is either
"http" or "https" before proceeding. If the scheme is missing or not in
{"http","https"}, return False (same behavior as when url is falsy) or log and
abort; perform this check right after calling _webhook_url() and before building
the payload/Request and before the urlopen loop in send (or the function
containing the for-attempt loop) so only http/https requests are allowed.

Copy link
Copy Markdown
Contributor

@leobaldock leobaldock left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direction looks right to me. This PR is a good narrow slice: Cube emits structured review events and Engine owns Slack routing/fanout via a GitHub review trigger. I would keep this scoped to removing the Slack chatter path rather than expanding it into the full runner/conformance work.

To get this ready, I think the remaining work is:

  1. Rebase onto current main and resolve the conflict in python/cube/commands/peer_review.py. Keep the current main reconciliation / print_panel_summary flow, then append the Engine footer after the gate has been computed and before the review body is printed/posted. Use the current expected judge count, not the stale old total variable.

  2. Keep the v1 event surface to the two wired event types for now:

    • human_call
    • pr_ready_to_merge

    I would drop pr_blocked and setup_needed from this PR unless there are actual emitters. They are reasonable future events, but adding unwired contract surface now creates support burden without moving this slice forward.

  3. Address the two CodeRabbit comments in engine_events.py:

    • parse_events_footer() should reject missing/wrong schema versions.
    • post_webhook_event() should only allow http / https webhook URLs before passing anything to urlopen.
  4. Add focused tests for the event module. I would cover footer round-trip, malformed/missing/wrong-schema footers returning [], human-call event construction, ready-to-merge only on APPROVE, invalid human-call severity normalization, unset webhook env returning False, and non-http webhook URLs being rejected.

  5. Update the PR description to make the integration point explicit: Engine should subscribe to pull_request_review.submitted, filter reviews from the-agent-cube[bot], parse the hidden agent-cube-events footer, then route human_call / pr_ready_to_merge through the native Engine trigger/session path.

After that: rerun mypy + the targeted tests + CI. I would leave the separate “does this implementation match the approved plan?” judge as a follow-up PR, because that changes approval semantics. This PR can land the transport/event boundary cleanly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants