Repository navigation
feat: deliver an external event by event type, without a run id (#181) - #758
Merged
Merged
Conversation
nodus workflow deliver order.paid --correlation-key cust-42 --payload '{"amount": 4200}'
Also POST /workflow/deliver and WorkflowFrameworkRunner.deliver_event().
A webhook receiver has an event type; every delivery path required a graph_id
it had no way to obtain. A wait has always recorded its event_type durably, and
resume_workflow has always been willing to *verify* one -- but nothing could
answer which run is waiting for this event, so a host had to list_runs() and
reach into record.wait, which is not a published surface.
Only the lookup is new. #181 framed the gap as three host steps: receive the
webhook, look up the waiting run, resume it. The first is irreducibly the
host's. The third is the host's *by decision* -- #176 settled that a run record
carries the program's whole source (#499), so a runtime that re-entered runs on
its own would compile and execute whatever the working directory's store
happened to hold. This is an explicit call for exactly the reason sweep is, and
the issue's proposed wait_event() -- a Nodus-native call that subscribes and
resumes itself -- is deliberately not what was built, because it would make the
guest the subscriber.
Everything per-run is resume_workflow's, unchanged: it re-verifies the event
type and correlation key, validates the wait's declared payload schema (#472),
and claims through claim_waiting_run_for_resume, so two dispatchers racing one
event was already handled. None of it is re-implemented here.
Two decisions that could have gone the other way:
- Ambiguity is refused, not resolved. More than one run waiting and no
correlation key to name one fails, listing the candidates; --all opts into
fan-out. Returning an arbitrary one of N is #584's exact failure mode. The CLI
exits 2 for an ambiguity and 1 for a failed delivery, so a cron can retry the
second and knows not to retry the first.
- Matching nothing is an outcome, not an error. A dispatcher does not control
ordering: the event can beat the run into the store or arrive after it
finished, and failing would make a correct caller look broken.
list_runs_waiting_for is concrete on the WorkflowStore ABC, joining restore_run
and delete_run. It was written as @AbstractMethod first -- which breaks every
out-of-tree store at construction, the #185 failure, with the warning recorded
in that same file three methods above. The default scans list_runs(), so any
store gains the capability without changing; the cost bound is #380's, shared
with list_rehydratable_runs and list_due_retry_runs.
The route is registered on both server tables, and a test pins that every
/workflow/ POST route is in both -- one surface behind two tables is the
recurring shape with the bypass chosen by whether FastAPI is installed.
Each decision was falsified separately rather than against a tree where the
feature is merely absent: resolving ambiguity instead of refusing, ignoring the
correlation key, making the store method abstract, and registering the route on
one table only each turn the suite red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Masterplanner25
force-pushed
the
feat/181-deliver-event
branch
from
September 4, 2026 14:18
01d0068 to
1205a97
Compare
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 #181.
nodus workflow deliver order.paid --correlation-key cust-42 --payload '{"amount": 4200}'Also
POST /workflow/deliverandWorkflowFrameworkRunner.deliver_event()for a Python host.The gap is one of the three steps, not all three
The issue was filed against v4.0.0 and its own framing names the answer. It says the host must (a) receive the webhook, (b) look up the waiting
graph_id, (c) callresume_workflow().nodus workflow sweepis an explicit call for that reason, and this follows the same precedent.So the issue's proposed
wait_event()— a Nodus-native call that subscribes and resumes itself — is deliberately not what was built. It would make the guest the subscriber, which is precisely what #176 declined.What was actually missing
Probed rather than read off the source:
Both ends existed and the middle did not.
claim_waiting_run_for_resumetakes anevent_typeonly to verify one against a run the caller already identified — the store could confirm "this run is waiting for this event" and could not answer "which run is". A host had tolist_runs()(a full scan) and reach intorecord.wait.event_type, which is not a published surface.sweep()does not help: it is purely a clock and reads no event type anywhere.deliver_eventresolves the run(s) and hands each to the existingresume_workflow, which already re-verifies the event type and correlation key, validates the wait's declared payload schema (#472), and claims throughclaim_waiting_run_for_resume— so two dispatchers racing one event was already handled, and none of it is re-implemented.Two decisions that could have gone the other way
--correlation-keyto name one fails, listing the candidates;--allopts into fan-out. Returning an arbitrary one of N is latest_graph_state() returns the lexicographically largest random graph id, not the latest — and the HTTP surface reports it as the caller's #584's exact failure mode — the copy that could not answer correctly returned something plausible, and the defect hid for as long as the substitute looked reasonable. The CLI exits 2 for an ambiguity and 1 for a delivery that failed, so a retrying cron can tell "try again" from "this will never succeed as asked".One thing I got wrong and fixed
list_runs_waiting_forwas written as@abstractmethodfirst.restore_run's docstring — three methods above, in the file I was editing — records that adding an abstract method toWorkflowStorebreaks every out-of-tree store at construction, which is exactly how 5.0.3 brokenodus_sdk(#185). It is concrete now, joiningrestore_runanddelete_run, with a default that scanslist_runs(); any store gains the capability without changing, and a backend that can push the filter into its query engine may override it.TheStoreSurfaceTestspins both that it is not abstract and that the default actually answers — a default returning[]would satisfy the first and deliver nothing.The scan cost is #380's, shared with
list_rehydratable_runsandlist_due_retry_runs. No index was added:wait_jsonis unindexed TEXT, and indexing for one query while its two neighbours scan would be the wrong place to start.Verification
nodus runparks a workflow, a second process delivers knowing only the event type, and the downstream step completes./workflow/POST route is in both. One surface behind two tables is the recurring shape with the bypass chosen by whether an optional dependency happens to be installed.ruffandmypyclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01B4R857intVYD1fLgnN4uVn