Skip to content

refactor: extract a transport-neutral accept_event() from the webhook handler - #367

Open
VascoSch92 wants to merge 3 commits into
mainfrom
vasco/accept-event-seam
Open

refactor: extract a transport-neutral accept_event() from the webhook handler#367
VascoSch92 wants to merge 3 commits into
mainfrom
vasco/accept-event-seam

Conversation

@VascoSch92

@VascoSch92 VascoSch92 commented Aug 23, 2026

Copy link
Copy Markdown
Member

Closes #358 — phase 1 of 5, part of #363.

Pure refactor: no behaviour change, no schema change, no new dependencies.

What changed

receive_event() was one function doing two unrelated jobs. Lines 137–244 are
HTTP-specific — they turn an untrusted request into a trustworthy, interpreted
event. Lines 246–313 are not HTTP-specific at all — they are the routing and
run-creation logic every transport needs. Because both lived in one function, a
second transport had nowhere to attach: it had to re-implement matching and run
creation (draft PR #134), or fake an HTTP request to reach them (what the OSS
VM's Slack bridge does — HMAC-signing a body and POSTing to loopback purely to
get past the handler's front half).

This adds openhands/automation/ingest.py with the boundary:

async def accept_event(
    org_id: uuid.UUID,
    event: AcceptedEvent,
    session: AsyncSession,
    *,
    request: Request | None = None,   # telemetry only
) -> AcceptResult

The routing half moves there unchanged. receive_event() keeps acquisition,
authentication and interpretation, then calls accept_event() and maps
AcceptResult onto the existing EventResponse. Net effect on the router:
−65 lines, one call.

Authentication is deliberately not in this module, per the design rule in #363:
accept_event() receives an already-authenticated event and never asks how it
was authenticated.

⚠️ One correction to the issue: accept_event() needs a session_factory

Issue #358 says:

capture_automation_event already accepts request: Request | None = None
(telemetry.py:391), so non-HTTP callers simply omit it and all existing
telemetry events keep firing. No telemetry changes are needed.

That is not correct, and phase 3 would have shipped with no telemetry.
Telemetry resolves a backend distinct id from the database. With no request,
no session and no session_factory, get_automation_backend_distinct_id()
returns None (telemetry.py:75–77), and _capture_automation_event then
returns before the PostHog POST. capture_automation_event swallows every
exception (telemetry.py:395–396), so the loss is completely silent.

Measured, calling capture_automation_event exactly as the seam does:

distinct_id with no request/session = None
distinct_id with session=           = 'automation-backend:0b72aac9-…'
posts after request=None call: []          <- all events dropped
posts after session= call:     ['automation_event_matched']

A transport calling accept_event(..., request=None) loses all three events per
run (automation_event_matched, automation_run_scheduled,
automation_run_created) while routing keeps working — which is exactly what
makes it easy to miss.

So accept_event() takes an optional session_factory and forwards it. The
HTTP path passes nothing and reaches the factory via
request.app.state.session_factory as before, so it is byte-identical.

Why not just pass session? It would work, but
_get_or_create_backend_distinct_id issues a raw INSERT … ON CONFLICT DO NOTHING on whatever session it is handed (telemetry.py:40–58). Today that
runs on a fresh session committed immediately (telemetry.py:79–82). Sharing
the caller's session moves that write into the caller's transaction, and a
failure there would be hidden by the same broad except while poisoning the
create_automation_run() and commit() that follow.

This also means the issue's # telemetry only comment on request understates
it: request is load-bearing for a DB lookup, not just metadata.

Two implementation notes worth reviewing

1. parsed_event — the field the issue didn't specify, and why it's needed.

The issue flags that event_payload behaviour is subtle and easy to get wrong:
event.model_dump(mode="json") for Pydantic-parsed events, raw
webhook_payload otherwise (event_router.py:276–280). But the proposed
AcceptedEvent carries only payload — the raw provider payload that JMESPath
filters run against — which for GitHub is not what gets persisted on the run.
There is no way to reproduce the existing behaviour from the six specified
fields alone.

So AcceptedEvent gains one additional optional field, parsed_event, holding
the typed WebhookEvent the HTTP handler already builds. All six fields from
the issue are present, in order, with the specified defaults; this is additive
and defaulted, so it doesn't change the signature for later phases. Transports
with no typed model simply omit it and their raw payload is persisted, which
is the documented fallback behaviour.

Worth noting for anyone checking the branch: parse_event() always returns a
WebhookEvent, which is a BaseModel, so the else webhook_payload branch is
in practice unreachable from the HTTP path today. The isinstance check is
preserved verbatim rather than simplified — this is a refactor, and collapsing
it would be a behaviour decision that belongs in its own change.

2. EventSubject did not exist.

It is referenced by the issue's dataclass but is defined nowhere in the repo,
and #362 (which consumes it) only describes it as producing a subject_key.
It is defined here minimally — a single key: str — so the reserved field has
a real type. Nothing populates or reads it.

Acceptance criteria

  • The existing event_router test suite passes unmodified. 14/14, with
    zero changes to tests/test_event_router.py.
  • EventResponse is byte-identical on the wire. Verified by generating
    the full OpenAPI spec on main and on this branch and diffing them —
    identical, 160,253 bytes each.
  • No schema change, no new dependency, no change to trigger_matcher.py,
    create_automation_run(), or the dispatcher.
    The diff is three files:
    one new module, one new test file, and the router.
  • accept_event() has direct unit tests that construct an AcceptedEvent
    without going through HTTP.
    16 tests in tests/test_ingest.py — no
    request, no signature, no FastAPI app. They cover no-match, match,
    per-automation run fan-out, filter evaluation against payload, event-key
    mismatch, source and org isolation, commit visibility from a second
    session, both event_payload shapes, the full telemetry sequence for a
    request-less caller, that the reserved fields change nothing, and that the
    dataclasses are frozen and slotted.

All three dataclasses are frozen=True, slots=True. Two caveats, since frozen
is easy to over-read: it is shallow, so payload and run_ids are still
mutable in place; and it generates a __hash__ that raises at runtime for
AcceptedEvent/AcceptResult because of those dict/list fields. Phase 4's
dedupe should key on provider_event_id, not on the event object.

Full suite: 1350 passed. pre-commit (ruff format, ruff lint, pycodestyle,
pyright) clean on all three files.

One question for the author

The else webhook_payload branch is already dead. parse_event() returns
WebhookEvent (event_schemas/__init__.py:22, a BaseModel) and
CustomWebhookEvent subclasses it (custom.py:72), so isinstance(event, BaseModel) is always true. Custom webhooks today persist {"payload": …, "source_override": …, "event_key": …}, not the raw payload that the code
comment and the issue both describe. I preserved the branch verbatim, since
collapsing it is a behaviour decision — but phases 4 and 5 should not assume the
documented behaviour is what actually happens.

Out of scope

Deduplication, event persistence, and any new transport — phases 3 and 4 use
this seam.

… handler

`receive_event()` fused two unrelated halves into one function: an HTTP front
half that turns an untrusted request into a trustworthy, interpreted event, and
a back half that routes that event to automations and creates runs. Only the
first half is HTTP-specific, so a second transport had nowhere to attach — it
had to duplicate matching and run creation, or fake an HTTP request to reach
them.

Add `openhands/automation/ingest.py` with `AcceptedEvent`, `AcceptResult` and
`accept_event()`. The routing half moves there unchanged; `receive_event()`
keeps acquisition, authentication and interpretation, then calls
`accept_event()` and maps the result onto `EventResponse`.

Authentication stays with the transport by design: `accept_event()` receives an
already-authenticated event and never asks how it was authenticated.

Behaviour is unchanged. The existing `event_router` test suite passes
unmodified, and the generated OpenAPI spec is byte-identical.

`AcceptedEvent.provider_event_id`, `.subject` and `.occurred_at` are reserved
for later phases: present in the signature, but not populated or read.
`.parsed_event` carries the typed `WebhookEvent` the HTTP handler already
builds, which is what preserves the run `event_payload` shape exactly —
`model_dump(mode="json")` for Pydantic-parsed events, the raw payload for
transports that have no model.

No schema change, no new dependency, and no change to `trigger_matcher.py`,
`create_automation_run()`, or the dispatcher.
@github-actions github-actions Bot added the type: refactor Code refactoring label Aug 23, 2026
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Coverage

Cut the module, class and function docstrings down to what is not already
obvious from the code, and drop the step-numbering and restated-logic comments.

Make `EventSubject`, `AcceptedEvent` and `AcceptResult` `frozen=True,
slots=True`: an accepted event is a value handed across the seam, so nothing
downstream should be able to rewrite it, and slots keeps it cheap. Two tests
cover both properties.
…emetry

Issue #358 says non-HTTP callers can simply omit `request` and "all existing
telemetry events keep firing". They do not. Telemetry resolves a backend
distinct id from the database, and with no request, session or session_factory
`get_automation_backend_distinct_id()` returns None (telemetry.py:75-77);
`_capture_automation_event` then returns before the POST. `capture_automation_event`
swallows everything, so the loss is silent.

A transport calling `accept_event(..., request=None)` therefore loses all three
events per run. The HTTP path is unaffected — it reaches the factory through
`request.app.state.session_factory` — so this is latent until phase 3.

Add an optional `session_factory` and forward it to the three telemetry calls.
`receive_event()` passes nothing, so the HTTP path is byte-identical; the
OpenAPI spec still matches main exactly.

Not `session=`: telemetry writes its id row, and sharing the caller's session
would move that INSERT into the caller's transaction, where a failure would be
hidden by the same broad except and poison the run creation that follows.

Two tests cover it — the full event sequence for a request-less caller, and
that routing still succeeds while telemetry is dropped when neither a request
nor a factory is given, which is what makes the bug easy to miss.
@VascoSch92
VascoSch92 marked this pull request as ready for review August 23, 2026 12:04
@all-hands-bot

Copy link
Copy Markdown
Contributor

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

@malhotra5 malhotra5 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

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

Labels

type: refactor Code refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extract a transport-neutral accept_event() from the webhook handler

3 participants