Skip to main content

cordis/
events_payload.rs

1//! Typed event payloads bound to the declared catalog via [`TypedEvent`].
2//!
3//! Upstream Cordis binds payload types to events through TypeScript
4//! declaration merging with an `@mode` contract. Rust has no declaration
5//! merging; the equivalent here is one zero-sized marker type per catalog
6//! event implementing [`TypedEvent`] with an associated payload struct plus
7//! `NAME`/`MODE`/`AROUND` constants. A unit test enforces that every binding
8//! matches [`crate::events_catalog::CONTRACTS`], keeping the catalog the
9//! single source of truth.
10//!
11//! Dispatch sites construct the payload struct and call
12//! [`crate::EventsService::dispatch_typed`]; listeners register through
13//! [`crate::EventsService::on_typed`] / [`crate::EventsService::on_typed_waterfall`],
14//! which deserialize into the payload type and skip malformed payloads
15//! (warn + passthrough) instead of failing the chain. The raw
16//! `serde_json::Value` API stays authoritative for kernel mechanics, dynamic
17//! cases, and mid-chain JSON rewriting.
18
19use serde::de::DeserializeOwned;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23use crate::events::Dispatch;
24
25/// Compile-time binding between a catalog event and its payload type.
26///
27/// `NAME`/`MODE`/`AROUND` must mirror the event's
28/// [`crate::events_catalog::EventContract`]; the
29/// `typed_events_match_catalog_contracts` test fails on drift.
30pub trait TypedEvent {
31    /// Wire shape of the event payload.
32    type Payload: Serialize + DeserializeOwned + Clone + Send + Sync + 'static;
33    /// Canonical event name (an `events_catalog::ev::*` constant).
34    const NAME: &'static str;
35    /// Declared dispatch mode.
36    const MODE: Dispatch;
37    /// True for around-middleware waterfalls (`on_waterfall` registry).
38    const AROUND: bool;
39}
40
41// ---------------------------------------------------------------------------
42// agent.admit — Dispatch::Bail
43// ---------------------------------------------------------------------------
44
45/// Payload for [`AgentAdmitEvent`]. Built by the shared admission gate
46/// (`ares-agent::admit`) and the MCP server quota gate.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct AgentAdmitPayload {
49    pub tenant_id: String,
50    #[serde(default)]
51    pub monthly: u64,
52    #[serde(default)]
53    pub daily: u64,
54    #[serde(default)]
55    pub requests_per_month: Option<u64>,
56    #[serde(default)]
57    pub requests_per_day: Option<u64>,
58    pub tier: String,
59}
60
61/// `agent.admit` — quota admission policy (`Dispatch::Bail`). A handler
62/// denying the request returns a payload with a `deny`/`error` marker.
63#[derive(Debug, Clone, Copy)]
64pub struct AgentAdmitEvent;
65impl TypedEvent for AgentAdmitEvent {
66    type Payload = AgentAdmitPayload;
67    const NAME: &'static str = crate::events_catalog::ev::AGENT_ADMIT;
68    const MODE: Dispatch = Dispatch::Bail;
69    const AROUND: bool = false;
70}
71
72// ---------------------------------------------------------------------------
73// agent.started — Dispatch::Parallel
74// ---------------------------------------------------------------------------
75
76/// Payload for [`AgentStartedEvent`].
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct AgentStartedPayload {
79    pub agent_name: String,
80    #[serde(default)]
81    pub run_id: String,
82    #[serde(default)]
83    pub tenant: String,
84    #[serde(default)]
85    pub event: String,
86}
87
88/// `agent.started` — joined fan-out at run start (`Dispatch::Parallel`).
89#[derive(Debug, Clone, Copy)]
90pub struct AgentStartedEvent;
91impl TypedEvent for AgentStartedEvent {
92    type Payload = AgentStartedPayload;
93    const NAME: &'static str = crate::events_catalog::ev::AGENT_STARTED;
94    const MODE: Dispatch = Dispatch::Parallel;
95    const AROUND: bool = false;
96}
97
98// ---------------------------------------------------------------------------
99// agent.usage — Dispatch::Emit
100// ---------------------------------------------------------------------------
101
102/// Payload for [`AgentUsageEvent`]. `tenant` is `None` when no tenant scope
103/// was resolved for the run (serialized as JSON `null`, matching the raw API).
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct AgentUsagePayload {
106    #[serde(default)]
107    pub tenant: Option<String>,
108    #[serde(default)]
109    pub prompt: i64,
110    #[serde(default)]
111    pub completion: i64,
112    #[serde(default)]
113    pub total: i64,
114}
115
116/// `agent.usage` — fire-and-forget token accounting (`Dispatch::Emit`).
117#[derive(Debug, Clone, Copy)]
118pub struct AgentUsageEvent;
119impl TypedEvent for AgentUsageEvent {
120    type Payload = AgentUsagePayload;
121    const NAME: &'static str = crate::events_catalog::ev::AGENT_USAGE;
122    const MODE: Dispatch = Dispatch::Emit;
123    const AROUND: bool = false;
124}
125
126// ---------------------------------------------------------------------------
127// agent.completed / agent.failed — Dispatch::Emit
128// ---------------------------------------------------------------------------
129
130/// Payload for [`AgentCompletedEvent`].
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct AgentCompletedPayload {
133    pub agent_name: String,
134    #[serde(default)]
135    pub run_id: String,
136    #[serde(default = "default_status")]
137    pub status: String,
138    #[serde(default)]
139    pub event: String,
140}
141
142fn default_status() -> String {
143    "unknown".to_string()
144}
145
146/// `agent.completed` — terminal status for every run (`Dispatch::Emit`).
147#[derive(Debug, Clone, Copy)]
148pub struct AgentCompletedEvent;
149impl TypedEvent for AgentCompletedEvent {
150    type Payload = AgentCompletedPayload;
151    const NAME: &'static str = crate::events_catalog::ev::AGENT_COMPLETED;
152    const MODE: Dispatch = Dispatch::Emit;
153    const AROUND: bool = false;
154}
155
156/// Payload for [`AgentFailedEvent`].
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct AgentFailedPayload {
159    pub agent_name: String,
160    #[serde(default)]
161    pub run_id: String,
162    #[serde(default)]
163    pub tenant: String,
164    #[serde(default)]
165    pub event: String,
166}
167
168/// `agent.failed` — run failure signal feeding scheduler failure control
169/// (`Dispatch::Emit`).
170#[derive(Debug, Clone, Copy)]
171pub struct AgentFailedEvent;
172impl TypedEvent for AgentFailedEvent {
173    type Payload = AgentFailedPayload;
174    const NAME: &'static str = crate::events_catalog::ev::AGENT_FAILED;
175    const MODE: Dispatch = Dispatch::Emit;
176    const AROUND: bool = false;
177}
178
179// ---------------------------------------------------------------------------
180// agent.run — Dispatch::Waterfall (around)
181// ---------------------------------------------------------------------------
182
183/// Initial payload for [`AgentRunEvent`]: the requested agent and message.
184/// Waterfall handlers may rewrite either before the core runs.
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186pub struct AgentRunRequest {
187    #[serde(default)]
188    pub agent_name: String,
189    #[serde(default)]
190    pub message: String,
191}
192
193/// Result payload produced by the [`AgentRunEvent`] waterfall core. Handlers
194/// may set `deny` (with optional `reason`) to short-circuit the run, or read
195/// `content`/`usage` from downstream results.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct AgentRunResult {
198    #[serde(default)]
199    pub content: Value,
200    #[serde(default)]
201    pub source: Value,
202    #[serde(default)]
203    pub agent_name: String,
204    #[serde(default)]
205    pub run_id: String,
206    #[serde(default)]
207    pub usage: Option<Value>,
208    #[serde(default)]
209    pub metadata: Option<Value>,
210}
211
212/// `agent.run` — around-middleware waterfall wrapping whole agent runs
213/// (`Dispatch::Waterfall`).
214#[derive(Debug, Clone, Copy)]
215pub struct AgentRunEvent;
216impl TypedEvent for AgentRunEvent {
217    type Payload = AgentRunRequest;
218    const NAME: &'static str = crate::events_catalog::ev::AGENT_RUN;
219    const MODE: Dispatch = Dispatch::Waterfall;
220    const AROUND: bool = true;
221}
222
223// ---------------------------------------------------------------------------
224// llm.complete — Dispatch::Waterfall (around)
225// ---------------------------------------------------------------------------
226
227/// Initial payload for [`LlmCompleteEvent`].
228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
229pub struct LlmCompleteRequest {
230    #[serde(default)]
231    pub prompt: String,
232}
233
234/// Result payload of the [`LlmCompleteEvent`] waterfall core.
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236pub struct LlmCompleteResult {
237    #[serde(default)]
238    pub prompt: String,
239    #[serde(default)]
240    pub content: Value,
241}
242
243/// `llm.complete` — around-middleware waterfall wrapping single completions
244/// (`Dispatch::Waterfall`). Handlers may rewrite `prompt` or short-circuit by
245/// returning their own `content`.
246#[derive(Debug, Clone, Copy)]
247pub struct LlmCompleteEvent;
248impl TypedEvent for LlmCompleteEvent {
249    type Payload = LlmCompleteRequest;
250    const NAME: &'static str = crate::events_catalog::ev::LLM_COMPLETE;
251    const MODE: Dispatch = Dispatch::Waterfall;
252    const AROUND: bool = true;
253}
254
255// ---------------------------------------------------------------------------
256// llm.get_client — Dispatch::Waterfall (around)
257// ---------------------------------------------------------------------------
258
259/// Payload for [`LlmGetClientEvent`]. The core is identity; handlers may set
260/// `deny` to refuse client resolution or pin `model`.
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262pub struct LlmGetClientPayload {
263    pub capability: String,
264    #[serde(default)]
265    pub deny: Option<bool>,
266    #[serde(default)]
267    pub model: Option<String>,
268}
269
270/// `llm.get_client` — around-middleware waterfall over client resolution
271/// (`Dispatch::Waterfall`).
272#[derive(Debug, Clone, Copy)]
273pub struct LlmGetClientEvent;
274impl TypedEvent for LlmGetClientEvent {
275    type Payload = LlmGetClientPayload;
276    const NAME: &'static str = crate::events_catalog::ev::LLM_GET_CLIENT;
277    const MODE: Dispatch = Dispatch::Waterfall;
278    const AROUND: bool = true;
279}
280
281// ---------------------------------------------------------------------------
282// llm.generate / llm.generate_tools — Dispatch::Waterfall (around)
283// ---------------------------------------------------------------------------
284
285/// One conversation message in [`LlmGeneratePayload`]. Free-form shape: the
286/// waterfall core re-parses messages into provider-native types, so `content`
287/// stays a raw JSON value.
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289pub struct LlmMessage {
290    pub role: String,
291    #[serde(default)]
292    pub content: Value,
293}
294
295/// Initial payload for [`LlmGenerateEvent`].
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297pub struct LlmGeneratePayload {
298    #[serde(default)]
299    pub messages: Vec<LlmMessage>,
300}
301
302/// `llm.generate` — around-middleware waterfall wrapping history generation
303/// (`Dispatch::Waterfall`). Handlers may rewrite `messages` mid-chain.
304#[derive(Debug, Clone, Copy)]
305pub struct LlmGenerateEvent;
306impl TypedEvent for LlmGenerateEvent {
307    type Payload = LlmGeneratePayload;
308    const NAME: &'static str = crate::events_catalog::ev::LLM_GENERATE;
309    const MODE: Dispatch = Dispatch::Waterfall;
310    const AROUND: bool = true;
311}
312
313/// Initial payload for [`LlmGenerateToolsEvent`]. Messages are serialized
314/// conversation messages; tools are serialized tool definitions (kept as raw
315/// values because this leaf crate cannot depend on the crates defining them).
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317pub struct LlmGenerateToolsPayload {
318    #[serde(default)]
319    pub messages: Vec<Value>,
320    #[serde(default)]
321    pub tools: Vec<Value>,
322}
323
324/// `llm.generate_tools` — around-middleware waterfall wrapping tool-calling
325/// generation (`Dispatch::Waterfall`).
326#[derive(Debug, Clone, Copy)]
327pub struct LlmGenerateToolsEvent;
328impl TypedEvent for LlmGenerateToolsEvent {
329    type Payload = LlmGenerateToolsPayload;
330    const NAME: &'static str = crate::events_catalog::ev::LLM_GENERATE_TOOLS;
331    const MODE: Dispatch = Dispatch::Waterfall;
332    const AROUND: bool = true;
333}
334
335// ---------------------------------------------------------------------------
336// tools.execute / tools.list / tools.resolve — Dispatch::Waterfall (around)
337// ---------------------------------------------------------------------------
338
339/// Initial payload for [`ToolsExecuteEvent`]. The core injects `result`.
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341pub struct ToolsExecutePayload {
342    pub name: String,
343    #[serde(default)]
344    pub args: Value,
345}
346
347/// `tools.execute` — around-middleware waterfall wrapping tool execution
348/// (`Dispatch::Waterfall`).
349#[derive(Debug, Clone, Copy)]
350pub struct ToolsExecuteEvent;
351impl TypedEvent for ToolsExecuteEvent {
352    type Payload = ToolsExecutePayload;
353    const NAME: &'static str = crate::events_catalog::ev::TOOLS_EXECUTE;
354    const MODE: Dispatch = Dispatch::Waterfall;
355    const AROUND: bool = true;
356}
357
358/// Initial payload for [`ToolsListEvent`].
359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360pub struct ToolsListRequest {
361    #[serde(default)]
362    pub tenant: Option<String>,
363}
364
365/// Result payload of the [`ToolsListEvent`] waterfall core: serialized tool
366/// definitions visible to the tenant.
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
368pub struct ToolsListResult {
369    #[serde(default)]
370    pub tenant: Option<String>,
371    #[serde(default)]
372    pub tools: Vec<Value>,
373}
374
375/// `tools.list` — around-middleware waterfall wrapping tool listing
376/// (`Dispatch::Waterfall`).
377#[derive(Debug, Clone, Copy)]
378pub struct ToolsListEvent;
379impl TypedEvent for ToolsListEvent {
380    type Payload = ToolsListRequest;
381    const NAME: &'static str = crate::events_catalog::ev::TOOLS_LIST;
382    const MODE: Dispatch = Dispatch::Waterfall;
383    const AROUND: bool = true;
384}
385
386/// Initial payload for [`ToolsResolveEvent`]. The core adds `found`; handlers
387/// may set `deny` to block resolution.
388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
389pub struct ToolsResolveRequest {
390    pub name: String,
391    #[serde(default)]
392    pub tenant: Option<String>,
393}
394
395/// `tools.resolve` — around-middleware waterfall wrapping tool resolution
396/// (`Dispatch::Waterfall`).
397#[derive(Debug, Clone, Copy)]
398pub struct ToolsResolveEvent;
399impl TypedEvent for ToolsResolveEvent {
400    type Payload = ToolsResolveRequest;
401    const NAME: &'static str = crate::events_catalog::ev::TOOLS_RESOLVE;
402    const MODE: Dispatch = Dispatch::Waterfall;
403    const AROUND: bool = true;
404}
405
406// ---------------------------------------------------------------------------
407// scheduler.before_run / scheduler.admit
408// ---------------------------------------------------------------------------
409
410/// Payload for [`SchedulerBeforeRunEvent`]: a scheduled run about to execute.
411/// Waterfall handlers may enrich fields before the executor reads them back.
412#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
413pub struct SchedulerBeforeRunPayload {
414    #[serde(default)]
415    pub agent_name: String,
416    #[serde(default)]
417    pub run_id: String,
418    #[serde(default)]
419    pub tenant: Option<String>,
420}
421
422/// `scheduler.before_run` — around-middleware waterfall ahead of scheduled
423/// execution (`Dispatch::Waterfall`).
424#[derive(Debug, Clone, Copy)]
425pub struct SchedulerBeforeRunEvent;
426impl TypedEvent for SchedulerBeforeRunEvent {
427    type Payload = SchedulerBeforeRunPayload;
428    const NAME: &'static str = crate::events_catalog::ev::SCHEDULER_BEFORE_RUN;
429    const MODE: Dispatch = Dispatch::Waterfall;
430    const AROUND: bool = true;
431}
432
433/// Payload for [`SchedulerAdmitEvent`]. A handler denying the run returns a
434/// payload with `deny: true`.
435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
436pub struct SchedulerAdmitPayload {
437    #[serde(default)]
438    pub agent_name: String,
439    #[serde(default)]
440    pub deny: Option<bool>,
441}
442
443/// `scheduler.admit` — admission policy for scheduled runs (`Dispatch::Bail`).
444#[derive(Debug, Clone, Copy)]
445pub struct SchedulerAdmitEvent;
446impl TypedEvent for SchedulerAdmitEvent {
447    type Payload = SchedulerAdmitPayload;
448    const NAME: &'static str = crate::events_catalog::ev::SCHEDULER_ADMIT;
449    const MODE: Dispatch = Dispatch::Bail;
450    const AROUND: bool = false;
451}
452
453// ---------------------------------------------------------------------------
454// service.changed — Dispatch::Emit
455// ---------------------------------------------------------------------------
456
457/// Payload for [`ServiceChangedEvent`]. `type_id` is the `Debug` rendering of
458/// the changed service's `TypeId` (matches the raw API's `format!("{tid:?}")`).
459#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
460pub struct ServiceChangedPayload {
461    pub type_id: String,
462    #[serde(default)]
463    pub event: String,
464}
465
466// ---------------------------------------------------------------------------
467// Engine boundary events — Dispatch::Emit (fire-and-forget observability)
468// ---------------------------------------------------------------------------
469
470/// Payload for [`SchedulerTickEvent`]: counts from one due-schedule pass.
471#[derive(Debug, Clone, Default, Serialize, Deserialize)]
472pub struct SchedulerTickPayload {
473    #[serde(default)]
474    pub due_count: u64,
475    #[serde(default)]
476    pub catchup_count: u64,
477}
478
479/// `scheduler.tick` — emitted once per completed scheduler execution pass
480/// (`Dispatch::Emit`).
481#[derive(Debug, Clone, Copy)]
482pub struct SchedulerTickEvent;
483impl TypedEvent for SchedulerTickEvent {
484    type Payload = SchedulerTickPayload;
485    const NAME: &'static str = crate::events_catalog::ev::SCHEDULER_TICK;
486    const MODE: Dispatch = Dispatch::Emit;
487    const AROUND: bool = false;
488}
489
490/// Payload for [`ScheduleDispatchedEvent`]: outcome of one scheduled run.
491/// `denied: true` means admission policy skipped the run; a denial is not a
492/// failure and still advances the schedule's next_run.
493#[derive(Debug, Clone, Default, Serialize, Deserialize)]
494pub struct ScheduleDispatchedPayload {
495    pub schedule_id: String,
496    #[serde(default)]
497    pub agent_name: String,
498    #[serde(default)]
499    pub tenant_id: String,
500    #[serde(default)]
501    pub is_catchup: bool,
502    #[serde(default)]
503    pub ok: bool,
504    #[serde(default)]
505    pub denied: bool,
506    #[serde(default)]
507    pub error: Option<String>,
508}
509
510/// `scheduler.schedule.dispatched` — emitted after each scheduled-run attempt
511/// (`Dispatch::Emit`).
512#[derive(Debug, Clone, Copy)]
513pub struct ScheduleDispatchedEvent;
514impl TypedEvent for ScheduleDispatchedEvent {
515    type Payload = ScheduleDispatchedPayload;
516    const NAME: &'static str = crate::events_catalog::ev::SCHEDULER_SCHEDULE_DISPATCHED;
517    const MODE: Dispatch = Dispatch::Emit;
518    const AROUND: bool = false;
519}
520
521/// Payload for [`PipelineStepStartedEvent`].
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct PipelineStepStartedPayload {
524    pub pipeline_id: String,
525    pub target_agent: String,
526    #[serde(default)]
527    pub tenant_id: String,
528    #[serde(default)]
529    pub run_id: String,
530}
531
532/// `pipeline.step.started` — emitted just before each pipeline target runs
533/// (`Dispatch::Emit`).
534#[derive(Debug, Clone, Copy)]
535pub struct PipelineStepStartedEvent;
536impl TypedEvent for PipelineStepStartedEvent {
537    type Payload = PipelineStepStartedPayload;
538    const NAME: &'static str = crate::events_catalog::ev::PIPELINE_STEP_STARTED;
539    const MODE: Dispatch = Dispatch::Emit;
540    const AROUND: bool = false;
541}
542
543/// Payload for [`PipelineStepFinishedEvent`].
544#[derive(Debug, Clone, Serialize, Deserialize)]
545pub struct PipelineStepFinishedPayload {
546    pub pipeline_id: String,
547    pub target_agent: String,
548    #[serde(default)]
549    pub tenant_id: String,
550    #[serde(default)]
551    pub status: String,
552    #[serde(default)]
553    pub duration_ms: u64,
554    #[serde(default)]
555    pub error: Option<String>,
556}
557
558/// `pipeline.step.finished` — emitted after each pipeline target's status is
559/// known (`Dispatch::Emit`).
560#[derive(Debug, Clone, Copy)]
561pub struct PipelineStepFinishedEvent;
562impl TypedEvent for PipelineStepFinishedEvent {
563    type Payload = PipelineStepFinishedPayload;
564    const NAME: &'static str = crate::events_catalog::ev::PIPELINE_STEP_FINISHED;
565    const MODE: Dispatch = Dispatch::Emit;
566    const AROUND: bool = false;
567}
568
569/// Payload for [`PipelineFanoutCompletedEvent`].
570#[derive(Debug, Clone, Default, Serialize, Deserialize)]
571pub struct PipelineFanoutCompletedPayload {
572    #[serde(default)]
573    pub source_agent: String,
574    #[serde(default)]
575    pub tenant_id: String,
576    #[serde(default)]
577    pub triggered: Vec<String>,
578}
579
580/// `pipeline.fanout.completed` — emitted at the end of a pipeline fan-out
581/// (`Dispatch::Emit`).
582#[derive(Debug, Clone, Copy)]
583pub struct PipelineFanoutCompletedEvent;
584impl TypedEvent for PipelineFanoutCompletedEvent {
585    type Payload = PipelineFanoutCompletedPayload;
586    const NAME: &'static str = crate::events_catalog::ev::PIPELINE_FANOUT_COMPLETED;
587    const MODE: Dispatch = Dispatch::Emit;
588    const AROUND: bool = false;
589}
590
591/// Payload for [`TriggerFiredEvent`].
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct TriggerFiredPayload {
594    pub trigger_id: String,
595    #[serde(default)]
596    pub event_type: String,
597    #[serde(default)]
598    pub target_agent: String,
599    #[serde(default)]
600    pub tenant_id: String,
601}
602
603/// `trigger.fired` — emitted when a trigger executes its target agent
604/// successfully (`Dispatch::Emit`).
605#[derive(Debug, Clone, Copy)]
606pub struct TriggerFiredEvent;
607impl TypedEvent for TriggerFiredEvent {
608    type Payload = TriggerFiredPayload;
609    const NAME: &'static str = crate::events_catalog::ev::TRIGGER_FIRED;
610    const MODE: Dispatch = Dispatch::Emit;
611    const AROUND: bool = false;
612}
613
614/// `service.changed` — hot-reload notification emitted by ReflectService
615/// (`Dispatch::Emit`).
616#[derive(Debug, Clone, Copy)]
617pub struct ServiceChangedEvent;
618impl TypedEvent for ServiceChangedEvent {
619    type Payload = ServiceChangedPayload;
620    const NAME: &'static str = crate::events_catalog::ev::SERVICE_CHANGED;
621    const MODE: Dispatch = Dispatch::Emit;
622    const AROUND: bool = false;
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::events_catalog::{contract_for, CONTRACTS};
629
630    /// Every typed binding must exist in the catalog and agree on mode and
631    /// middleware shape, and every catalog event must have a typed binding.
632    #[test]
633    fn typed_events_match_catalog_contracts() {
634        let bindings: &[(&'static str, Dispatch, bool)] = &[
635            (
636                AgentAdmitEvent::NAME,
637                AgentAdmitEvent::MODE,
638                AgentAdmitEvent::AROUND,
639            ),
640            (
641                AgentCompletedEvent::NAME,
642                AgentCompletedEvent::MODE,
643                AgentCompletedEvent::AROUND,
644            ),
645            (
646                AgentFailedEvent::NAME,
647                AgentFailedEvent::MODE,
648                AgentFailedEvent::AROUND,
649            ),
650            (
651                AgentRunEvent::NAME,
652                AgentRunEvent::MODE,
653                AgentRunEvent::AROUND,
654            ),
655            (
656                AgentStartedEvent::NAME,
657                AgentStartedEvent::MODE,
658                AgentStartedEvent::AROUND,
659            ),
660            (
661                AgentUsageEvent::NAME,
662                AgentUsageEvent::MODE,
663                AgentUsageEvent::AROUND,
664            ),
665            (
666                LlmCompleteEvent::NAME,
667                LlmCompleteEvent::MODE,
668                LlmCompleteEvent::AROUND,
669            ),
670            (
671                LlmGetClientEvent::NAME,
672                LlmGetClientEvent::MODE,
673                LlmGetClientEvent::AROUND,
674            ),
675            (
676                LlmGenerateEvent::NAME,
677                LlmGenerateEvent::MODE,
678                LlmGenerateEvent::AROUND,
679            ),
680            (
681                LlmGenerateToolsEvent::NAME,
682                LlmGenerateToolsEvent::MODE,
683                LlmGenerateToolsEvent::AROUND,
684            ),
685            (
686                SchedulerAdmitEvent::NAME,
687                SchedulerAdmitEvent::MODE,
688                SchedulerAdmitEvent::AROUND,
689            ),
690            (
691                SchedulerBeforeRunEvent::NAME,
692                SchedulerBeforeRunEvent::MODE,
693                SchedulerBeforeRunEvent::AROUND,
694            ),
695            (
696                ServiceChangedEvent::NAME,
697                ServiceChangedEvent::MODE,
698                ServiceChangedEvent::AROUND,
699            ),
700            (
701                ToolsExecuteEvent::NAME,
702                ToolsExecuteEvent::MODE,
703                ToolsExecuteEvent::AROUND,
704            ),
705            (
706                ToolsListEvent::NAME,
707                ToolsListEvent::MODE,
708                ToolsListEvent::AROUND,
709            ),
710            (
711                ToolsResolveEvent::NAME,
712                ToolsResolveEvent::MODE,
713                ToolsResolveEvent::AROUND,
714            ),
715            (
716                SchedulerTickEvent::NAME,
717                SchedulerTickEvent::MODE,
718                SchedulerTickEvent::AROUND,
719            ),
720            (
721                ScheduleDispatchedEvent::NAME,
722                ScheduleDispatchedEvent::MODE,
723                ScheduleDispatchedEvent::AROUND,
724            ),
725            (
726                PipelineStepStartedEvent::NAME,
727                PipelineStepStartedEvent::MODE,
728                PipelineStepStartedEvent::AROUND,
729            ),
730            (
731                PipelineStepFinishedEvent::NAME,
732                PipelineStepFinishedEvent::MODE,
733                PipelineStepFinishedEvent::AROUND,
734            ),
735            (
736                PipelineFanoutCompletedEvent::NAME,
737                PipelineFanoutCompletedEvent::MODE,
738                PipelineFanoutCompletedEvent::AROUND,
739            ),
740            (
741                TriggerFiredEvent::NAME,
742                TriggerFiredEvent::MODE,
743                TriggerFiredEvent::AROUND,
744            ),
745        ];
746        for (name, mode, around) in bindings {
747            let contract = contract_for(name)
748                .unwrap_or_else(|| panic!("typed binding {name} missing from catalog"));
749            assert_eq!(&contract.mode, mode, "mode drift for {name}");
750            assert_eq!(&contract.around, around, "around drift for {name}");
751        }
752        assert_eq!(
753            bindings.len(),
754            CONTRACTS.len(),
755            "every catalog event must have exactly one typed binding"
756        );
757    }
758
759    /// Serialization keeps field names/types stable across a round trip, and
760    /// missing optional keys fall back to documented defaults (lenient
761    /// listener behavior preserved).
762    #[test]
763    fn payload_round_trip_and_defaults() {
764        let started = AgentStartedPayload {
765            agent_name: "a".into(),
766            run_id: "r".into(),
767            tenant: "t".into(),
768            event: crate::events_catalog::ev::AGENT_STARTED.into(),
769        };
770        let v = serde_json::to_value(&started).unwrap();
771        assert_eq!(v.get("agent_name").and_then(Value::as_str), Some("a"));
772        let back: AgentStartedPayload = serde_json::from_value(v).unwrap();
773        assert_eq!(back, started);
774
775        let minimal: AgentCompletedPayload =
776            serde_json::from_value(serde_json::json!({ "agent_name": "x" })).unwrap();
777        assert_eq!(minimal.status, "unknown");
778        assert_eq!(minimal.run_id, "");
779
780        let usage = AgentUsagePayload {
781            tenant: None,
782            prompt: 3,
783            completion: 4,
784            total: 7,
785        };
786        let v = serde_json::to_value(&usage).unwrap();
787        assert!(v.get("tenant").map(Value::is_null).unwrap_or(false));
788        let back: AgentUsagePayload = serde_json::from_value(v).unwrap();
789        assert_eq!(back.prompt, 3);
790        assert!(back.tenant.is_none());
791    }
792}