Add event causality tracking (id + caused_by chains)

Every BreadEvent now gets a unique id at construction (via
BreadEvent::new/with_timestamp, and the normalizer's struct-literal call
sites, which all now assign one explicitly). A new caused_by field is
populated only when a Lua bread.emit() call runs synchronously inside a
bread.on subscriber's handler: LuaEngine tracks the currently-dispatching
event's id (set/restored around each handle_event invocation, single-
threaded so a plain Mutex-guarded slot suffices) and bread.emit()'s Rust
binding reads it when constructing the outgoing event. This lets chains of
Lua modules that react to each other's events be reconstructed instead of
timestamp-guessed from a live `bread events` log.

Also:
- bread events --tree renders the caused_by chain as an indented,
  live-streaming-friendly tree instead of a flat line-per-event stream.
- API_VERSION bumped 1.4.0 -> 1.5.0 (additive-only change).
- Documentation.md's event envelope, bread.emit, and debugging-tips
  sections updated with Since: v1.5 markers.
- New end-to-end regression test spawning the real daemon with 3 chained
  Lua handlers (A emits X on trigger, B emits Y on X, C emits Z on Y) and
  asserting caused_by threads correctly through all three hops.
This commit is contained in:
Breadway 2026-08-04 17:55:31 +08:00
parent 96639516b1
commit 6ff1ee910b
10 changed files with 420 additions and 9 deletions

View file

@ -8,3 +8,4 @@ serde.workspace = true
serde_json.workspace = true
dirs.workspace = true
toml = "0.8"
uuid = { version = "1", features = ["v4"] }

View file

@ -90,20 +90,65 @@ pub struct BreadEvent {
pub source: AdapterSource,
/// Structured event data. The shape depends on the event family.
pub data: serde_json::Value,
/// Unique id for this specific event instance, assigned at construction.
///
/// *Since: v1.5* — enables causality tracking (`caused_by`) across chains
/// of Lua modules that re-emit events from inside `bread.on` handlers.
pub id: String,
/// The `id` of the event whose Lua handler emitted this event via
/// `bread.emit()`, if any.
///
/// `None` for events that originate outside any Lua handler invocation
/// (adapter-normalized events, IPC `emit`, daemon-internal sends like
/// `bread.system.startup` / `bread.profile.activated`). Populated only
/// when the event was constructed by `bread.emit()` while a subscriber
/// callback was synchronously running — see the "current dispatch id"
/// mechanism on `breadd`'s Lua engine.
///
/// *Since: v1.5*
pub caused_by: Option<String>,
}
impl BreadEvent {
/// Construct a new event with `timestamp` set to the current wall-clock.
/// Construct a new event with `timestamp` set to the current wall-clock,
/// a freshly generated `id`, and `caused_by` unset.
pub fn new(event: impl Into<String>, source: AdapterSource, data: serde_json::Value) -> Self {
Self::with_timestamp(event, now_unix_ms(), source, data)
}
/// Construct a new event with an explicit `timestamp`, preserving the
/// originating signal's observed time instead of "now". Used by the
/// normalizer, which carries `RawEvent::timestamp` through unchanged.
///
/// Like [`BreadEvent::new`], this always assigns a fresh `id` and leaves
/// `caused_by` unset — callers that need to thread causality set
/// `caused_by` on the returned value themselves.
pub fn with_timestamp(
event: impl Into<String>,
timestamp: u64,
source: AdapterSource,
data: serde_json::Value,
) -> Self {
Self {
event: event.into(),
timestamp: now_unix_ms(),
timestamp,
source,
data,
id: new_event_id(),
caused_by: None,
}
}
}
/// Generate a fresh unique id for a [`BreadEvent`].
///
/// Every construction path (`BreadEvent::new`, `BreadEvent::with_timestamp`,
/// and any remaining struct-literal construction) calls this so every event
/// gets a stable identity to hang `caused_by` chains off of.
pub fn new_event_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// Current Unix epoch in milliseconds.
///
/// Falls back to `0` if the system clock is before the epoch, which keeps
@ -316,6 +361,8 @@ mod tests {
timestamp: 1_700_000_000_000,
source: AdapterSource::Udev,
data: json!({ "id": "usb-1-1.4", "name": "Logitech" }),
id: "test-id-1".to_string(),
caused_by: Some("test-id-0".to_string()),
};
let raw = serde_json::to_string(&original).unwrap();
let decoded: BreadEvent = serde_json::from_str(&raw).unwrap();
@ -324,6 +371,30 @@ mod tests {
assert_eq!(decoded.timestamp, original.timestamp);
assert_eq!(decoded.source, original.source);
assert_eq!(decoded.data, original.data);
assert_eq!(decoded.id, original.id);
assert_eq!(decoded.caused_by, original.caused_by);
}
#[test]
fn bread_event_new_assigns_unique_id_and_no_cause() {
let a = BreadEvent::new("bread.test.a", AdapterSource::System, json!({}));
let b = BreadEvent::new("bread.test.b", AdapterSource::System, json!({}));
assert!(!a.id.is_empty());
assert_ne!(a.id, b.id, "each constructed event should get a unique id");
assert_eq!(a.caused_by, None);
}
#[test]
fn bread_event_with_timestamp_preserves_timestamp_and_assigns_id() {
let event = BreadEvent::with_timestamp(
"bread.test.c",
42,
AdapterSource::Udev,
json!({ "x": 1 }),
);
assert_eq!(event.timestamp, 42);
assert!(!event.id.is_empty());
assert_eq!(event.caused_by, None);
}
#[test]