theway_daemon/trigger_engine/event.rs
1//! Trigger lifecycle events emitted by the [`TriggerExecutor`](super::execution::TriggerExecutor)
2//! (moved out of `theway_core::SessionEvent`). The CLI subscribes to these alongside the
3//! core harness event stream: the executor is host-owned, so its event surface is a
4//! host-level contract — the TUI banner, `/triggers` command and JSONL listeners all
5//! consume this stream.
6//!
7//! Causality notes (RFC 1 §5.F, pinned by tests):
8//! - `TriggerHandled { state: Accepted }` always precedes `TriggerExecutionStarted` for
9//! the same `trace_id`.
10//! - `TriggerCompleted | TriggerFailed` → `TriggerPromoted` for the same `trace_id` when
11//! promotion is configured AND not held for approval.
12
13use std::sync::Arc;
14
15use super::types::{SourceKind, TriggerState};
16use crate::trigger_engine::execution::types::TriggerPromptRequest;
17
18/// Listener for [`TriggerEvent`]. Same shape as `SessionListener` so UI adapters can mix
19/// both streams with the same closure style.
20pub type TriggerListener = Arc<dyn Fn(TriggerEvent) + Send + Sync>;
21
22/// Event emitted by the trigger executor as a trigger moves through its lifecycle.
23#[derive(Clone, Debug)]
24pub enum TriggerEvent {
25 /// The executor has admitted a `Trigger` for processing — fires immediately at the
26 /// start of `TriggerExecutor::handle_trigger` before evaluation. Carries the source
27 /// identification needed to render a "processing X" banner. RFC 1 §2.7.
28 TriggerHandlingStart {
29 idempotency_key: String,
30 source_kind: SourceKind,
31 source_label: String,
32 event_label: String,
33 trace_id: String,
34 },
35 /// Terminal: the trigger reached an end state. `state` is one of the terminal variants
36 /// (`Accepted` / `Deduped` / `CycleSuppressed` / `PermissionDenied` / `NeedsApproval`).
37 ///
38 /// `audit_entry_id` is the `SessionTreeEntry::Custom` id when persistence succeeded,
39 /// `None` if persistence failed (a parallel `PersistenceError` event will describe
40 /// the failure).
41 ///
42 /// `evaluator_decision` mirrors what was persisted in the audit record (same JSON
43 /// shape) so live subscribers (TUI banner, `/triggers`, JSONL logs) can render *why*
44 /// the trigger reached its state without a secondary session lookup. Shape:
45 /// - Accept (Allow): `{ "outcome": "accept", "permission": "allow" }`
46 /// - Accept (Deny): `{ "outcome": "accept", "permission": "deny", "reason": ... }`
47 /// - Accept (Prompt):`{ "outcome": "accept", "permission": "prompt", "reason": ... }`
48 /// - Deduped: `{ "outcome": "deduped", "replacement_policy": ..., "previous_trace_id": ... }`
49 /// - CycleSuppressed:`{ "outcome": "cycle_suppressed", "hop_count": N }`
50 ///
51 /// `None` only when audit serialization failed (a `PersistenceError` will accompany).
52 TriggerHandled {
53 idempotency_key: String,
54 trace_id: String,
55 state: TriggerState,
56 audit_entry_id: Option<String>,
57 evaluator_decision: Option<serde_json::Value>,
58 },
59 /// A trigger admitted by the dedup / cycle evaluator reached
60 /// `BeforeTriggerDecision::Prompt` and is awaiting an embedder-owned user decision.
61 ///
62 /// The prompt is bound by `trigger_prompt_id`, not by a tool-call id / args hash, so a
63 /// decision cannot be replayed onto a different trigger envelope. The executor also
64 /// writes a `trigger_prompt` Custom audit entry when the prompt resolves.
65 TriggerPromptRequest { request: TriggerPromptRequest },
66 /// Best-effort persistence error reflux from the trigger engine. The trigger itself
67 /// still produced a `TriggerHandled` event with `audit_entry_id = None`; this event
68 /// explains why so that observability (TUI banner, `/triggers`, JSONL logs) can mark
69 /// the audit as best-effort lost rather than dropping it silently.
70 ///
71 /// `context` is free-form with pinned strings: `"trigger_audit"`, `"trigger_result"`,
72 /// `"trigger_prompt"`, `"trigger_promotion"`, `"trigger_inject_and_run"`. New write
73 /// sites must pin themselves to a stable string.
74 PersistenceError {
75 context: String,
76 /// Short, secret-free message. The original `SessionError` is *not* exposed because
77 /// some implementations include filesystem paths or storage backend details that
78 /// belong in trace logs, not user-facing event surfaces.
79 message: String,
80 },
81 /// A sub-agent execution started for an accepted trigger. Emitted by the spawned task
82 /// just before the sub-agent's first turn runs. `prompt_preview` is the first ~80
83 /// characters of the resolved action prompt, preview-safe for banners.
84 TriggerExecutionStarted {
85 trace_id: String,
86 source_label: String,
87 event_label: String,
88 prompt_preview: String,
89 },
90 /// A sub-agent execution finished successfully and the parent `trigger_result` audit
91 /// entry has been written. `summary` is the sub-agent's self-summary (size-capped at
92 /// 4 KiB). `cost_usd` is `None` when the bare sub-`Agent` had no `CostTracker` wrapper
93 /// (mirrors the audit's `cost_usd: null`).
94 ///
95 /// `details` is the structured sub-agent result envelope populated through marker tools
96 /// (see `TriggerResultDetailsBuilder`). Defaults to `serde_json::Value::Null`.
97 /// Authorization for `PromoteAction::PromoteSummaryWhenResultDetailsMatch` flows
98 /// exclusively through this field — `summary` is display-only.
99 TriggerCompleted {
100 trace_id: String,
101 summary: Option<String>,
102 cost_usd: Option<f64>,
103 details: serde_json::Value,
104 },
105 /// A sub-agent execution failed (agent loop error, panic-via-spawn-error, or aborted by
106 /// `TriggerExecutor::abort_trigger` / `abort_all_triggers`). `reason` is sanitized —
107 /// never contains raw payload, provider response bodies, or credential material. The
108 /// parent `trigger_result` audit entry has been written with `success: false`.
109 TriggerFailed { trace_id: String, reason: String },
110 /// An `TriggerDelivery::InjectAndRun` trigger has injected its prompt into the **idle**
111 /// parent conversation and is asking the embedder to run ONE model turn in the parent's
112 /// full context. The executor never runs the single-tenant parent agent itself from the
113 /// detached trigger task, so it delegates: the embedder (which owns the parent agent
114 /// and its input loop) should funnel this through the same serialized path as user
115 /// input and call `AgentHarness::continue_`. Emitted only on the idle path — when the
116 /// parent is mid-turn the runtime enqueues a follow-up instead and this event is NOT
117 /// emitted.
118 TriggerRequestsMainRun { trace_id: String },
119 /// A trigger's `PromoteAction` rendered successfully and the executor committed to
120 /// surfacing the sub-agent result to the user / LLM. theway_llm_provider has no System
121 /// role today; the inserted entry is a `Message::User` with a `[Trigger ...]` body
122 /// prefix so the LLM disambiguates trigger-driven context from human input.
123 ///
124 /// `inserted_entry_id` semantics depend on the parent agent state at promotion time:
125 /// - **Idle parent**: durable id of the appended `Message::User` (matches the audit).
126 /// - **Streaming parent** (queued through the loop's follow-up queue): **empty string**
127 /// because the session entry ID is only known after the loop drains the queue.
128 /// Consumers should correlate by `trace_id` in this case.
129 TriggerPromoted {
130 trace_id: String,
131 promote_kind: String,
132 inserted_entry_id: String,
133 template_name: Option<String>,
134 redaction_status: String,
135 },
136 /// A trigger's `PromoteAction` was held pending approval (`promote_requires_approval =
137 /// true`) and is awaiting an explicit `/triggers approve <trace_id>`. The parent
138 /// transcript has NOT been modified; a `trigger_promotion` audit entry with
139 /// `state: "pending"` has been written. `preview` is the rendered template body the
140 /// approval UI would surface, or `None` when the render itself would have failed.
141 PromotionPending {
142 trace_id: String,
143 promote_kind: String,
144 template_name: Option<String>,
145 preview: Option<String>,
146 },
147}