Events
Every status change in a running workflow fires an event. There are two layers and you'll use one or the other depending on whether you're integrating from inside a workflow (subscribing to events from THIS workflow's executions) or from outside (every event from every execution).
Two layers
| Layer | Type | Where to subscribe | Scope |
|---|---|---|---|
EventBus (per-workflow) | event_name + ExecutionContext | wf.on(event_name, handler) inside @orca.workflow | Events from this workflow's executions only. |
System-level IEventSink | RuntimeEvent (Pydantic, JSON-serializable) | runtime.register_sink(sink) | Every event from every execution. The daemon's /events/stream SSE feeds off this layer. |
A _SystemEventForwarder automatically promotes each per-workflow EventBus event into a RuntimeEvent and pushes it to every registered sink. You don't have to wire that yourself.
Event name shape
Events are dot-separated. Two forms:
<entity_type>.<entity_id>.<status> # specific (most common)
<entity_type>.<status> # generalized (subscribe-friendly)
When the engine emits THREAD.{thread_id}.STARTED it also fans out to any handler subscribed to the generalized THREAD.STARTED. So a sink that wants every thread-start across the system subscribes to the generalized form once; a workflow that wants to react to a specific thread's status subscribes to the 3-part form.
Entity types and their statuses
| Entity | Status enum | Statuses |
|---|---|---|
ACTION | ActionStatus | CREATED, RESOLVED, AWAITING_LOCATION_RESERVATION, AWAITING_CO_THREADS, EXECUTING_ACTION, AWAITING_MOVE_RESERVATION, PREPARING_TO_MOVE, PICKING, PLACING, COMPLETED, ERRORED, SKIPPED, ABORTED |
METHOD | MethodStatus | CREATED, IN_PROGRESS, COMPLETED, SKIPPED, PARTIAL_COMPLETE |
THREAD | LabwareThreadStatus | CREATED, AWAITING_MANUAL_PLACE, RESOLVING_ACTION_LOCATION, AWAITING_ACTION_RESERVATION, ACTION_LOCATION_RESOLVED, AWAITING_MOVE_RESERVATION, AWAITING_MOVE_TARGET_AVAILABILITY, MOVING, AWAITING_CO_THREADS, EXECUTING_ACTION, AWAITING_EVENT, AWAITING_MANUAL_REMOVE, PAUSED, COMPLETED, STOPPING, STOPPED, ABORTED, FAILED (crash-only: the thread's task died on an unhandled error) |
WORKFLOW | WorkflowStatus | CREATED, IN_PROGRESS, COMPLETED, ERRORED |
EXECUTION | runtime-level | COMPLETED, FAILED, ABORTED (terminal only; the runtime emits EXECUTION.{id}.{status} at completion) |
Engine-emitted special events
| Event | When |
|---|---|
INCIDENT.<incident_id>.<category> | A non-action runtime error was recorded as an incident. Summary-only context; full detail lives on the incidents surface. |
GROUP.<group_id>.COMPLETED | A multi-group submission's group finishes. |
SLOT.<slot_key>.ABANDONED | A receiver slot is closed without contributions. |
SLOT.<slot_key>.<status> | Other slot lifecycle (overflow handling). |
TIP_RACK.EMPTY / TIP_RACK.LOW | The co-labware coordinator detected tip exhaustion. |
OPERATOR.INSTRUCTION | An action called ctx.manual_step(...). |
OPERATOR.CONFIRM.<step_id> | The operator's confirmation event for a specific manual step. |
You're not limited to the engine taxonomy — actions can emit arbitrary event names from inside their body.
The RuntimeEvent shape
System-level sinks (and the SSE stream) deliver RuntimeEvent objects:
class RuntimeEvent(BaseModel):
event_name: str # full 3-part name
execution_id: str
timestamp: float
entity_type: str # parsed from event_name
entity_id: str # parsed from event_name (empty for 2-part)
status: str # parsed from event_name
context: ExecutionContext # type-specific payload
event.context carries the per-event payload. The concrete subclass depends on the entity type:
LocationActionExecutionContextfor action eventsMethodExecutionContextfor method eventsThreadExecutionContextfor thread events- Generic
ExecutionContextfor everything else
to_dict() / model_dump(mode="json") give the JSON wire shape — that's what the SSE stream emits.
Subscribing
From inside a workflow
@orca.workflow(name="my_assay")
def my_assay(wf: WorkflowContext):
wf.start(plate_journey)
wf.on("ACTION.COMPLETED", AuditLogger())
wf.on(...) accepts:
- A callable:
def handler(event_name: str, context: ExecutionContext) -> None - A
SystemBoundEventHandlersubclass with ahandle(event_name, context)method (useful when the handler needs the boundSystemfor cross-entity lookups)
Handler exceptions are caught and logged to the orca logger — one handler failing does not stop other handlers or the runtime. A persistent handler exception fires a PLUGIN_HANDLER_EXCEPTION or EVENT_HANDLER_EXCEPTION incident.
From outside the workflow (sinks)
For runtime-level integration:
from orca.runtime.interfaces import IEventSink
class JsonlSink:
def __init__(self, path: str):
self._fp = open(path, "a")
def on_event(self, event: RuntimeEvent) -> None:
self._fp.write(event.model_dump_json() + "\n")
self._fp.flush()
runtime.register_sink(JsonlSink("/var/log/orca/events.jsonl"))
IEventSink is a one-method Protocol: on_event(event: RuntimeEvent) -> None. Register multiple sinks — they all receive every event.
Sinks are useful for:
- Audit trails — JSONL on disk, append-only.
- Live dashboards — fan out over WebSocket to UI clients.
- External orchestration — feed events into a downstream system.
- Persistence + index — the swarm
DiskAndIndexEventSinkwrites JSONL AND maintains a Postgres index for searchability.
Backpressure and ordering
on_eventis called synchronously from the runtime tick. A sink that blocks the call blocks the runtime. Sinks that need expensive work (DB writes, network) should hand off to a background queue/task.- Within one sink, events arrive in the order the runtime emitted them. Across sinks, ordering is not synchronized.
- The daemon's SSE sink uses per-connection bounded queues (1000 events by default) and drops the oldest event when full — slow subscribers cannot back-pressure the runtime or affect other subscribers.
Daemon REST surfaces
Two endpoints in orca.daemon:
| Endpoint | Use for |
|---|---|
GET /events?since=<ts>&execution_id=<id> | Polling. Returns every event recorded since the timestamp; optionally filter to one execution. |
GET /events/stream?execution_id=<id> | Server-Sent Events. Streams live; optional server-side filter. |
The SSE stream uses one SseEventSink that persists across load/unload cycles — your subscription stays alive when an operator unloads and loads a different topology.
SSE wire shape:
event: runtime_event
data: {"event_name": "THREAD.tid-2.STARTED", "execution_id": "...", ...}
The data payload is exactly RuntimeEvent.to_dict().
Custom events from actions
Inside an action, emit a custom event:
@orca.action(device=reader, inputs=[plate])
async def read_plate(ctx: ActionContext):
result = await ctx.device().read("absorbance.pro")
await ctx.emit("plate_reading", value="complete", data={"absorbance": result})
Other actions wait for it:
@orca.action(device=evaluator, inputs=[plate])
async def evaluate(ctx: ActionContext):
value, data = await ctx.wait_for("plate_reading")
...
Threads can wait too — ThreadContext.wait_for uses consumed semantics so looping sees each new publish.
Workflows can branch:
yield orca.branch("plate_reading", {
"complete": [evaluate_step],
"failed": [retry_step],
})
Event names with . (qc.result, assay.step1.done) are conventional for namespacing your custom events distinct from the engine taxonomy.
Pitfalls
on_eventruns synchronously on the runtime tick. Don't block. Persistence sinks should queue or offload.- 2-part subscribers see ALL 3-part fans of the same status. Subscribing to
ACTION.COMPLETEDfires for every action across every thread. If you only want one entity's events, subscribe to the 3-part form. wf.on(...)is per-workflow, not global. It fires only for events emitted by executions of THIS workflow template. For cross-workflow integration use a sink.- Handler exceptions are caught and logged — they don't bubble. If a handler is misbehaving, an
EVENT_HANDLER_EXCEPTIONincident fires. Acknowledging the incident does NOT disable the handler; fix the handler in code. - SSE subscriber queues are bounded. A subscriber that can't keep up loses events (oldest dropped). If you need lossless capture, use a sink with backpressure-aware queuing, not the SSE stream.
ctx.emitfromActionContext/MethodContextis async. Awaiting it is required — a missedawaitsilently fails to emit.- Incidents fire as events. Every recorded incident emits an
INCIDENT.{incident_id}.{category}RuntimeEvent(summary fields only; the full typed detail stays queryable on the incidents surface keyed byincident_id). A sink that wants to react to faults can subscribe to the event stream directly; it does not have to poll the incident store.
Where to go next
- Stores — the
IEventSinkProtocol and where it sits among the other store interfaces. - Context API —
emit/wait_forsemantics on each context. - Recovery — incident model (separate from the event stream).
- Runtime — wiring sinks at the runtime level.