Skip to content

Module system: registry-based dispatch and invoke API #232

@dcellison

Description

@dcellison

Summary

Replace the if-branch dispatch in webhook.py with registry-based dispatch, and add the HTTP invoke endpoint. This is where the old special-casing actually gets deleted.

Parent issue: #208
Depends on: #228 (registry), #230 (PR review module), #231 (issue triage module)

Current dispatch code to replace

webhook.py lines 657-800 (_process_github_event_for_user) contain the if-branch dispatch:

# Line 688: PR review routing
if settings["pr_review"] and event_type == "pull_request":
    action = payload.get("action", "")
    if action in ("opened", "reopened", "synchronize"):
        # ... cooldown check, resolve local repo, create_task(review.review_pr(...))

# Line 748: Issue triage routing  
if settings["issue_triage"] and event_type == "issues":
    action = payload.get("action", "")
    if action == "opened":
        # ... cooldown check, create_task(triage.triage_issue(...))

# Line 785: Standard notification fallback
formatter = _GITHUB_FORMATTERS.get(event_type)

This also includes the cooldown infrastructure (lines 93-199): _review_cooldowns, _triage_cooldowns, and six associated functions that get replaced by the generic CooldownTracker from #228.

Proposed changes

New dispatch flow

async def _process_github_event_for_user(
    request, payload, event_type, bot, config, chat_id
):
    settings = await sessions.resolve_github_settings(chat_id, config)
    target_chat_id = settings["notify_chat_id"]
    action = payload.get("action", "")

    # 1. Ask the registry for matching modules
    registry: ModuleRegistry = request.app["module_registry"]
    bindings = registry.get_modules_for_trigger("webhook", event_type, action)

    # 2. For each matching module, check user feature flags and cooldown,
    #    then dispatch as a background task
    for binding in bindings:
        module = binding.module

        # Feature flag check: user must have the relevant capability enabled.
        # Map module name to user setting (e.g., "pr-review" -> settings["pr_review"]).
        if not _user_allows_module(settings, module.name):
            continue

        # Cooldown check (generic, replaces per-module cooldown functions)
        entity_key = _entity_key_for_event(event_type, payload)
        cooldown_seconds = binding.trigger_config.get("cooldown", 0)
        cooldown_tracker: CooldownTracker = request.app["cooldown_tracker"]

        if cooldown_tracker.should_skip(module.name, entity_key, cooldown_seconds):
            log.info("Skipping %s for %s (cooldown)", module.name, entity_key)
            continue

        cooldown_tracker.record(module.name, entity_key, cooldown_seconds)

        # Build context and dispatch
        ctx = ModuleContext(
            trigger_type="webhook",
            trigger_data=payload,
            config=config,
            chat_id=target_chat_id,
            webhook_port=request.app["webhook_port"],
            webhook_secret=request.app["webhook_secret"],
            claude_user=request.app.get("claude_user"),
            state={},
            journal=ModuleJournal(module.name, DATA_DIR / "modules" / module.name / "events"),
        )

        task = asyncio.create_task(_run_module(module, ctx))
        _background_tasks.add(task)
        task.add_done_callback(_background_tasks.discard)

    # 3. Standard notification fallback (for events no module claims,
    #    or for actions that modules filter out like "closed")
    # Only send if no module handled this event
    if not any_module_handled:
        formatter = _GITHUB_FORMATTERS.get(event_type)
        ...

_user_allows_module mapping

The user feature-flag check needs a mapping from module name to user setting:

_MODULE_FEATURE_FLAGS: dict[str, str] = {
    "pr-review": "pr_review",
    "issue-triage": "issue_triage",
}

New modules added later register their feature flag name in the manifest. For now, this mapping is hardcoded for the two existing modules. Future work could make this fully declarative via the manifest.

_run_module wrapper

A thin wrapper that calls module.handle(ctx), catches exceptions, logs the result, and sends a crash notification to Telegram if the module raises. This replaces the per-module try/except in the current review_pr() and triage_issue() top-level functions.

HTTP invoke endpoint

Add POST /api/modules/{name}/invoke to webhook.py routes:

async def _handle_module_invoke(request: web.Request) -> web.Response:
    """Invoke a module directly via HTTP API."""
    module_name = request.match_info["name"]
    registry: ModuleRegistry = request.app["module_registry"]
    module = registry.get_module(module_name)
    if not module:
        return web.json_response({"error": f"Unknown module: {module_name}"}, status=404)

    body = await request.json()
    # Build ModuleContext from request body
    # Dispatch as background task
    # Return 202 Accepted with task tracking info

This lets inner Claude (or scheduled jobs) trigger modules directly without going through a webhook event.

Registry initialization

In webhook.py start(), scan src/kai/modules/*/module.yaml, instantiate module classes, build the registry, and store it in request.app["module_registry"]. Also create the CooldownTracker instance in request.app["cooldown_tracker"].

Code to delete

After the migration:

  • _review_cooldowns dict and _should_skip_review, _record_review functions (lines 93-152)
  • _triage_cooldowns dict and _should_skip_triage, _record_triage functions (lines 155-199)
  • _prune_expired helper (lines 100-115)
  • The if-branches in _process_github_event_for_user (lines 683-783)
  • Direct imports of review and triage in webhook.py (line 54) - these move to the module packages

That is roughly 120 lines of dispatch code and 110 lines of cooldown code replaced by ~50 lines of generic dispatch + the CooldownTracker from #228.

Tests

  • Registry-based dispatch routes a pull_request:opened event to the PR review module
  • Registry-based dispatch routes an issues:opened event to the issue triage module
  • Action filtering: pull_request:closed does NOT trigger PR review, falls through to formatter
  • User feature flag check: user with pr_review: false does not trigger review
  • Cooldown: duplicate events within cooldown window are skipped
  • Standard notification fallback: events with no matching module get the formatter path
  • /api/modules/{name}/invoke returns 202 for valid module, 404 for unknown
  • Existing test_webhook.py tests (1660 lines) updated to use registry instead of direct imports
  • No regressions: full test suite passes

Rollback safety

The old dispatch code gets deleted in this issue. If something breaks, git revert restores it. The module wrappers from #230 and #231 are additive (they do not modify review.py or triage.py), so reverting #232 alone restores the old behavior while keeping the module packages available for re-integration.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions