Skip to content

feat: deliver an external event by event type, without a run id (#181) - #758

Merged
Masterplanner25 merged 1 commit into
mainfrom
feat/181-deliver-event
Sep 4, 2026
Merged

Masterplanner25 merged 1 commit into
mainfrom
feat/181-deliver-event

Conversation

@Masterplanner25

Copy link
Copy Markdown
Owner

Closes #181.

nodus workflow deliver order.paid --correlation-key cust-42 --payload '{"amount": 4200}'

Also POST /workflow/deliver and WorkflowFrameworkRunner.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) call resume_workflow().

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:

wait.event_type='order.paid'  wait.correlation_key='cust-42'   (durably stored)

the only store methods mentioning event_type:
  claim_waiting_run_for_resume(run_id, *, owner, event_type=None, correlation_key=None)
  register_wait(run_id, *, event_type, ...)

Both ends existed and the middle did not. claim_waiting_run_for_resume takes an event_type only 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 to list_runs() (a full scan) and reach into record.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_event resolves the run(s) and hands each to the existing resume_workflow, which already 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, and none of it is re-implemented.

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 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".
  • 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 — so failing would make a correct caller look broken.

One thing I got wrong and fixed

list_runs_waiting_for was written as @abstractmethod first. restore_run's docstring — three methods above, in the file I was editing — records that adding an abstract method to WorkflowStore breaks every out-of-tree store at construction, which is exactly how 5.0.3 broke nodus_sdk (#185). It is concrete now, joining restore_run and delete_run, with a default that scans list_runs(); any store gains the capability without changing, and a backend that can push the filter into its query engine may override it. TheStoreSurfaceTests pins 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_runs and list_due_retry_runs. No index was added: wait_json is unindexed TEXT, and indexing for one query while its two neighbours scan would be the wrong place to start.

Verification

  • Each decision falsified separately, not against a tree where the feature is merely absent — that only proves the import fails. Four variants, each red: resolving ambiguity instead of refusing, accepting-and-ignoring the correlation key, making the store method abstract, and registering the route on one table only.
  • 20 tests, including a genuine cross-process case: one nodus run parks a workflow, a second process delivers knowing only the event type, and the downstream step completes.
  • The route is on both server tables — FastAPI and the stdlib fallback — with a test pinning that every /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.
  • 185 tests across the workflow, store, server, and CLI suites; server run separately since it binds ports.
  • All nine gate phases pass. ruff and mypy clean.
  • The documented commands were executed, per the guide-file standard.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B4R857intVYD1fLgnN4uVn

    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
Masterplanner25 merged commit 1a19294 into main Sep 4, 2026
4 checks passed
@Masterplanner25
Masterplanner25 deleted the feat/181-deliver-event branch September 4, 2026 14:48
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.

CAPAB-003: External event delivery to waiting workflows requires explicit host call

1 participant