refactor: extract a transport-neutral accept_event() from the webhook handler - #367
Open
VascoSch92 wants to merge 3 commits into
Open
refactor: extract a transport-neutral accept_event() from the webhook handler#367VascoSch92 wants to merge 3 commits into
VascoSch92 wants to merge 3 commits into
Conversation
… 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.
Contributor
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
marked this pull request as ready for review
August 23, 2026 12:04
Contributor
|
👋 This PR needs a couple of things fixed before OpenHands can review it:
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. |
Open
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 areHTTP-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.pywith the boundary:The routing half moves there unchanged.
receive_event()keeps acquisition,authentication and interpretation, then calls
accept_event()and mapsAcceptResultonto the existingEventResponse. 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 itwas authenticated.
accept_event()needs asession_factoryIssue #358 says:
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
sessionand nosession_factory,get_automation_backend_distinct_id()returns
None(telemetry.py:75–77), and_capture_automation_eventthenreturns before the PostHog POST.
capture_automation_eventswallows everyexception (
telemetry.py:395–396), so the loss is completely silent.Measured, calling
capture_automation_eventexactly as the seam does:A transport calling
accept_event(..., request=None)loses all three events perrun (
automation_event_matched,automation_run_scheduled,automation_run_created) while routing keeps working — which is exactly whatmakes it easy to miss.
So
accept_event()takes an optionalsession_factoryand forwards it. TheHTTP path passes nothing and reaches the factory via
request.app.state.session_factoryas before, so it is byte-identical.Why not just pass
session? It would work, but_get_or_create_backend_distinct_idissues a rawINSERT … ON CONFLICT DO NOTHINGon whatever session it is handed (telemetry.py:40–58). Today thatruns on a fresh session committed immediately (
telemetry.py:79–82). Sharingthe caller's session moves that write into the caller's transaction, and a
failure there would be hidden by the same broad
exceptwhile poisoning thecreate_automation_run()andcommit()that follow.This also means the issue's
# telemetry onlycomment onrequestunderstatesit:
requestis 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_payloadbehaviour is subtle and easy to get wrong:event.model_dump(mode="json")for Pydantic-parsed events, rawwebhook_payloadotherwise (event_router.py:276–280). But the proposedAcceptedEventcarries onlypayload— the raw provider payload that JMESPathfilters 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
AcceptedEventgains one additional optional field,parsed_event, holdingthe typed
WebhookEventthe HTTP handler already builds. All six fields fromthe 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
payloadis persisted, whichis the documented fallback behaviour.
Worth noting for anyone checking the branch:
parse_event()always returns aWebhookEvent, which is aBaseModel, so theelse webhook_payloadbranch isin practice unreachable from the HTTP path today. The
isinstancecheck ispreserved verbatim rather than simplified — this is a refactor, and collapsing
it would be a behaviour decision that belongs in its own change.
2.
EventSubjectdid 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 hasa real type. Nothing populates or reads it.
Acceptance criteria
event_routertest suite passes unmodified. 14/14, withzero changes to
tests/test_event_router.py.EventResponseis byte-identical on the wire. Verified by generatingthe full OpenAPI spec on
mainand on this branch and diffing them —identical, 160,253 bytes each.
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 anAcceptedEventwithout going through HTTP. 16 tests in
tests/test_ingest.py— norequest, no signature, no FastAPI app. They cover no-match, match,
per-automation run fan-out, filter evaluation against
payload, event-keymismatch, source and org isolation, commit visibility from a second
session, both
event_payloadshapes, the full telemetry sequence for arequest-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 frozenis easy to over-read: it is shallow, so
payloadandrun_idsare stillmutable in place; and it generates a
__hash__that raises at runtime forAcceptedEvent/AcceptResultbecause of those dict/list fields. Phase 4'sdedupe 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_payloadbranch is already dead.parse_event()returnsWebhookEvent(event_schemas/__init__.py:22, aBaseModel) andCustomWebhookEventsubclasses it (custom.py:72), soisinstance(event, BaseModel)is always true. Custom webhooks today persist{"payload": …, "source_override": …, "event_key": …}, not the raw payload that the codecomment 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.