Skip to main content

cordis/
lib.rs

1#![allow(missing_docs)]
2#![allow(dead_code)]
3
4use parking_lot::RwLock;
5use std::any::TypeId;
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::sync::{Arc, Weak};
8use tokio::sync::watch;
9
10// Inventory / linkme static registration — real compile-time collection
11#[cfg(feature = "inventory")]
12pub struct CordisInventory {
13    pub name: &'static str,
14}
15
16#[cfg(feature = "inventory")]
17inventory::collect!(CordisInventory);
18
19#[cfg(feature = "inventory")]
20inventory::submit! {
21    CordisInventory { name: "RegistryService" }
22}
23
24#[cfg(feature = "inventory")]
25inventory::submit! {
26    CordisInventory { name: "EventsService" }
27}
28
29#[cfg(feature = "inventory")]
30inventory::submit! {
31    CordisInventory { name: "ReflectService" }
32}
33
34#[cfg(feature = "inventory")]
35inventory::submit! {
36    CordisInventory { name: "Loader" }
37}
38
39// Kernel factory submits — same feature gates as the manual registrations.
40#[cfg(feature = "inventory")]
41inventory::submit! {
42    CordisPluginFactory { name: "EventsService", make: factory_events_service }
43}
44#[cfg(all(feature = "inventory", feature = "rhai"))]
45inventory::submit! {
46    CordisPluginFactory { name: "RhaiPolicy", make: factory_rhai_policy }
47}
48
49/// Function-pointer form of [`PluginFactory`] carried through inventory.
50///
51/// Factories are plain `fn` items so they can cross crate boundaries as
52/// static data; the registry wraps them in the usual `Arc<dyn Fn>`.
53#[cfg(feature = "inventory")]
54pub type PluginFactoryFn = fn(&Arc<Context>, &serde_json::Value) -> Result<FiberId, CordisError>;
55
56/// Compile-time collected plugin factory (inventory feature only).
57#[cfg(feature = "inventory")]
58pub struct CordisPluginFactory {
59    /// Loader plugin key (e.g. `"Http"`, `"SchedulerService"`).
60    pub name: &'static str,
61    /// The factory function itself.
62    pub make: PluginFactoryFn,
63}
64
65#[cfg(feature = "inventory")]
66inventory::collect!(CordisPluginFactory);
67
68/// Register every inventory-collected factory onto `reg`.
69///
70/// This is the primary registration path for binaries built with the
71/// default features; the hand-written per-crate `register_plugins` chains
72/// remain as the fallback when `inventory` is off.
73#[cfg(feature = "inventory")]
74pub fn register_inventory_factories(reg: &PluginRegistry) {
75    for entry in inventory::iter::<CordisPluginFactory> {
76        reg.register(entry.name, Arc::new(entry.make));
77    }
78}
79
80#[cfg(feature = "inventory")]
81pub fn inventory_len() -> usize {
82    inventory::iter::<CordisInventory>.into_iter().count()
83}
84
85#[cfg(not(feature = "inventory"))]
86pub fn inventory_len() -> usize {
87    0
88}
89
90pub mod context;
91pub mod effect;
92pub mod error;
93pub mod events;
94pub mod fiber;
95pub mod logger;
96pub mod service;
97pub mod timer;
98
99pub use context::{Accessor, Context, EffectHandle};
100pub use effect::Disposable;
101pub use events::{summarize_listener_errors, AggregateError, Dispatch, EventsService};
102pub use error::{ValidationError, ValidationIssue};
103pub use fiber::{Fiber, FiberState, UndoMeta};
104pub use service::{CordisError, Service, ServiceInitFuture};
105
106pub mod events_catalog;
107pub use events_catalog::{contract_for, validate_dispatch, validate_listener, EventContract};
108pub mod events_payload;
109pub use events_payload::{
110    AgentAdmitEvent, AgentAdmitPayload, AgentCompletedEvent, AgentCompletedPayload,
111    AgentFailedEvent, AgentFailedPayload, AgentRunEvent, AgentRunRequest, AgentRunResult,
112    AgentStartedEvent, AgentStartedPayload, AgentUsageEvent, AgentUsagePayload, LlmCompleteEvent,
113    LlmCompleteRequest, LlmCompleteResult, LlmEmbedEvent, LlmEmbedRequest, LlmEmbedResponse,
114    LlmGenerateEvent, LlmGeneratePayload, LlmGenerateToolsEvent, LlmGenerateToolsPayload,
115    LlmGetClientEvent, LlmGetClientPayload, LlmMessage, PipelineFanoutCompletedEvent,
116    PipelineFanoutCompletedPayload,
117    PipelineStepFinishedEvent, PipelineStepFinishedPayload, PipelineStepStartedEvent,
118    PipelineStepStartedPayload, ScheduleDispatchedEvent, ScheduleDispatchedPayload,
119    SchedulerAdmitEvent, SchedulerAdmitPayload, SchedulerBeforeRunEvent, SchedulerBeforeRunPayload,
120    SchedulerTickEvent, SchedulerTickPayload, ServiceChangedEvent, ServiceChangedPayload,
121    ToolsExecuteEvent, ToolsExecutePayload, ToolsListEvent, ToolsListRequest, ToolsListResult,
122    ToolsResolveEvent, ToolsResolveRequest, TriggerFiredEvent, TriggerFiredPayload, TypedEvent,
123};
124pub mod loader;
125pub use loader::{
126    AppliedAction, CurrentEntries, Entry, EntryConfigFiller, EntryConfigFillerHandle, EntryTree,
127    EntryUpdate, Loader, LoaderOps,
128};
129
130pub mod cycles;
131pub use cycles::{find_dependency_cycle, DependencyGraph};
132
133pub mod reload;
134pub mod stamp;
135pub use reload::reload_entries_from_disk;
136pub use stamp::{FileStamp, ReloadOutcome};
137pub use watcher::SettleBarrier;
138
139pub mod metatheory;
140
141pub mod hmr;
142pub mod module_graph;
143pub mod registry;
144pub mod watcher;
145pub use registry::{Plugin, RegistryService};
146pub use module_graph::{ChangeOutcome, ModuleEntry, ModuleGraph, ModuleReload, NoopReload};
147
148pub use logger::{
149    derived_name, hyphenate, Exporter, ExporterConfig, LogArg, LogKind, LogLevel,
150    LoggerIntercept, LoggerService, Message,
151};
152
153pub mod compose;
154#[cfg(feature = "rhai")]
155pub mod rhai_service;
156pub mod worker;
157
158#[cfg(feature = "rhai")]
159pub use compose::{
160    compose_all, compose_entries, interpolate_config, resolve_includes, GROUP_PLUGIN,
161    INCLUDE_PLUGIN,
162};
163#[cfg(not(feature = "rhai"))]
164pub use compose::{compose_all, resolve_includes, GROUP_PLUGIN, INCLUDE_PLUGIN};
165#[cfg(feature = "rhai")]
166pub use rhai_service::{RhaiListenerConfig, RhaiPlugin, RhaiService, RhaiServiceConfig};
167
168pub type Symbol = String;
169pub type EventId = String;
170pub type FiberId = u64;
171
172pub fn compute_epoch(inject: &HashMap<TypeId, Symbol>) -> String {
173    if inject.is_empty() {
174        return ":".to_string();
175    }
176    let mut frags: Vec<String> = inject.values().cloned().collect();
177    frags.sort();
178    format!(":{}", frags.join(":"))
179}
180
181// RegistryService and Plugin live in registry.rs to keep single-source discipline
182// and isolate-aware checks in one place. Re-exported here for ergonomics.
183
184// ---------------------------------------------------------------------------
185// PluginRegistry — name → factory map consumed by `Loader::instantiate`
186// ---------------------------------------------------------------------------
187
188/// Factory closure that turns one declarative entry into a live fiber.
189///
190/// The body must call [`Context::plugin`] (directly or via a helper) so that
191/// single-source discipline applies: a factory whose service is already
192/// provided fails with `CordisError::Configuration("duplicate provider …")`
193/// instead of silently shadowing it.
194pub type PluginFactory =
195    Arc<dyn Fn(&Arc<Context>, &serde_json::Value) -> Result<FiberId, CordisError> + Send + Sync>;
196
197/// Name-keyed directory of [`PluginFactory`] closures.
198///
199/// Registered at bootstrap (`root_ctx.provide(PluginRegistry::new())` +
200/// `register(name, …)`); consulted by `Loader::instantiate` when applying
201/// entries from `config/cordis-entries.toml`. Entries naming a plugin with no
202/// registered factory fail their own instantiation but never abort startup.
203pub struct PluginRegistry {
204    factories: RwLock<HashMap<String, PluginFactory>>,
205}
206
207impl PluginRegistry {
208    pub fn new() -> Self {
209        Self {
210            factories: RwLock::new(HashMap::new()),
211        }
212    }
213
214    pub fn register(&self, name: &str, f: PluginFactory) {
215        self.factories.write().insert(name.to_string(), f);
216    }
217
218    pub fn get(&self, name: &str) -> Option<PluginFactory> {
219        self.factories.read().get(name).cloned()
220    }
221
222    pub fn names(&self) -> Vec<String> {
223        self.factories.read().keys().cloned().collect()
224    }
225}
226
227impl Default for PluginRegistry {
228    fn default() -> Self {
229        Self::new()
230    }
231}
232
233impl Service for PluginRegistry {}
234
235fn block_on_plugin<S: Service + 'static>(
236    ctx: &Arc<Context>,
237    svc: S,
238) -> Result<FiberId, CordisError> {
239    tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(ctx.plugin(svc)))
240}
241
242/// Kernel `EventsService` loader factory.
243pub fn factory_events_service(
244    ctx: &Arc<Context>,
245    _config: &serde_json::Value,
246) -> Result<FiberId, CordisError> {
247    block_on_plugin(ctx, EventsService::new())
248}
249
250/// Kernel `RhaiPolicy` loader factory (feature `rhai`).
251#[cfg(feature = "rhai")]
252pub fn factory_rhai_policy(
253    ctx: &Arc<Context>,
254    config: &serde_json::Value,
255) -> Result<FiberId, CordisError> {
256    let cfg: RhaiServiceConfig = serde_json::from_value(config.clone())
257        .map_err(|e| CordisError::Configuration(format!("invalid RhaiPolicy config: {e}")))?;
258    tokio::task::block_in_place(|| {
259        tokio::runtime::Handle::current().block_on(ctx.plugin_with(RhaiPlugin, cfg))
260    })
261}
262
263/// Register kernel string factories consumed by the declarative loader.
264pub fn register_plugins(reg: &PluginRegistry) {
265    reg.register("EventsService", Arc::new(factory_events_service));
266    #[cfg(feature = "rhai")]
267    reg.register("RhaiPolicy", Arc::new(factory_rhai_policy));
268}
269
270// ---------------------------------------------------------------------------
271// ReflectService — Phase 3 unified hot-reload (watch + BFS via Fiber::refresh)
272// ---------------------------------------------------------------------------
273
274/// Unified hot-reload coordinator — replaces 60s `ArcSwap` polling.
275///
276/// Tracks `notifiers: RwLock<HashMap<TypeId, watch::Sender<()>>>` for DB/file
277/// change fan-out and `dependents: RwLock<HashMap<TypeId, Vec<FiberId>>>` for
278/// BFS dependency walks. `notify(TypeId)` BFS-walks `dependents` and calls
279/// `Fiber::refresh` on each dependent fiber, using the same `Fiber` impl that
280/// recomputes `epoch` from `inject` versions (see `Fiber::refresh`). Watch
281/// channels are created lazily on `provide` via `ensure_notifier` — prove by
282/// calling it on registry creation (e.g. `RuntimeToolRegistry` / `ProviderRegistry`
283/// insertion). See `docs/cordis-mapping.md` §7, §11.
284///
285/// `fibers` / `fiber_provides` / `ctx` are extra bookkeeping for BFS + async
286/// `refresh`; `notifiers` + `dependents` are the required fields per spec.
287#[allow(dead_code)]
288pub struct ReflectService {
289    notifiers: RwLock<HashMap<TypeId, watch::Sender<()>>>,
290    dependents: RwLock<HashMap<TypeId, Vec<FiberId>>>,
291    fibers: RwLock<HashMap<FiberId, Arc<Fiber>>>,
292    fiber_provides: RwLock<HashMap<FiberId, TypeId>>,
293    ctx: RwLock<Option<Weak<Context>>>,
294}
295
296impl ReflectService {
297    pub fn new() -> Self {
298        Self {
299            notifiers: RwLock::new(HashMap::new()),
300            dependents: RwLock::new(HashMap::new()),
301            fibers: RwLock::new(HashMap::new()),
302            fiber_provides: RwLock::new(HashMap::new()),
303            ctx: RwLock::new(None),
304        }
305    }
306
307    /// Ensure a `watch` channel exists for `tid`; create lazily on `provide`.
308    /// Returns a `Receiver` that callers can `changed().await` on for DB/file updates.
309    /// This is the “provide watch channel creation on provide” hook — call after
310    /// `ctx.provide::<T>(svc)` to prove compile-time insertion.
311    pub fn ensure_notifier(&self, tid: TypeId) -> watch::Receiver<()> {
312        let mut notifiers = self.notifiers.write();
313        if let Some(sender) = notifiers.get(&tid) {
314            return sender.subscribe();
315        }
316        let (tx, rx) = watch::channel(());
317        notifiers.insert(tid, tx);
318        rx
319    }
320
321    /// Convenience: ensure notifier for a `Service` type.
322    pub fn ensure_notifier_for<T: Service>(&self) -> watch::Receiver<()> {
323        self.ensure_notifier(TypeId::of::<T>())
324    }
325
326    /// Register that `fid` depends on `tid` (i.e. `fid.injects` contains `tid`).
327    /// Populates `dependents` for BFS walks.
328    pub fn register_dependent(&self, tid: TypeId, fid: FiberId) {
329        let mut deps = self.dependents.write();
330        let entry = deps.entry(tid).or_default();
331        if !entry.contains(&fid) {
332            entry.push(fid);
333        }
334    }
335
336    /// Register a fiber and what `TypeId` it provides (for transitive BFS).
337    /// Call from `RegistryService::plugin` after allocating `fid`.
338    pub fn register_fiber(&self, fid: FiberId, fiber: Arc<Fiber>, provides: TypeId) {
339        self.fibers.write().insert(fid, fiber);
340        self.fiber_provides.write().insert(fid, provides);
341    }
342
343    /// Remember the root `Context` weakly so `notify` can `upgrade()` and call
344    /// `Fiber::refresh` without caller passing `ctx`.
345    pub fn set_context(&self, ctx: &Arc<Context>) {
346        *self.ctx.write() = Some(Arc::downgrade(ctx));
347    }
348
349    /// BFS walks `dependents` starting at `tid`, notifies `watch` senders,
350    /// and spawns `Fiber::refresh` for each dependent fiber (uses existing
351    /// `Fiber::refresh` impl). This replaces the 60s `ArcSwap` poll;
352    /// registry reload is now triggered by `notify` via `watch` channel on DB
353    /// `NOTIFY`/`LISTEN` or file change, not a timer.
354    pub fn notify(&self, tid: TypeId) {
355        self.prune_disposed();
356        // Snapshot context weakly; if no context, still notify watch channels
357        let ctx_opt = self.ctx.read().as_ref().and_then(|w| w.upgrade());
358
359        // Emit service.changed event via EventsService (fire-and-forget)
360        if let Some(ctx) = &ctx_opt {
361            if let Some(events) = ctx.get::<EventsService>() {
362                let payload = crate::ServiceChangedPayload {
363                    type_id: format!("{tid:?}"),
364                    event: crate::events_catalog::ev::SERVICE_CHANGED.to_string(),
365                };
366                tokio::spawn(async move {
367                    let _ = events
368                        .dispatch_typed::<crate::ServiceChangedEvent>(&payload)
369                        .await;
370                });
371            }
372        }
373
374        let mut queue = VecDeque::new();
375        let mut visited_type = HashSet::new();
376        let mut visited_fiber = HashSet::new();
377        queue.push_back(tid);
378        visited_type.insert(tid);
379        while let Some(cur) = queue.pop_front() {
380            // Fan-out via watch channel
381            if let Some(sender) = self.notifiers.read().get(&cur).cloned() {
382                let _ = sender.send(());
383            }
384            // BFS over dependent fibers
385            let fids = self
386                .dependents
387                .read()
388                .get(&cur)
389                .cloned()
390                .unwrap_or_default();
391            for fid in fids {
392                if !visited_fiber.insert(fid) {
393                    continue;
394                }
395                let fiber_opt = self.fibers.read().get(&fid).cloned();
396                if let Some(fiber) = fiber_opt {
397                    if let Some(ctx) = ctx_opt.clone() {
398                        let fiber_clone = fiber.clone();
399                        tokio::spawn(async move {
400                            fiber_clone.refresh(&ctx).await;
401                        });
402                    }
403                    // Transitive: if this fiber provides a TypeId, enqueue its dependents
404                    if let Some(provided) = self.fiber_provides.read().get(&fid).copied() {
405                        if visited_type.insert(provided) {
406                            queue.push_back(provided);
407                        }
408                    }
409                }
410            }
411        }
412    }
413
414    /// Async variant that `await`s each `Fiber::refresh` directly (for tests / direct callers that have `ctx`).
415    #[allow(clippy::await_holding_lock)]
416    pub async fn notify_with_ctx(&self, tid: TypeId, ctx: &Arc<Context>) {
417        self.prune_disposed();
418        let mut queue = VecDeque::new();
419        let mut visited_type = HashSet::new();
420        let mut visited_fiber = HashSet::new();
421        queue.push_back(tid);
422        visited_type.insert(tid);
423        while let Some(cur) = queue.pop_front() {
424            if let Some(sender) = self.notifiers.read().get(&cur).cloned() {
425                let _ = sender.send(());
426            }
427            let fids = self
428                .dependents
429                .read()
430                .get(&cur)
431                .cloned()
432                .unwrap_or_default();
433            for fid in fids {
434                if !visited_fiber.insert(fid) {
435                    continue;
436                }
437                let fiber = { self.fibers.read().get(&fid).cloned() };
438                if let Some(fiber) = fiber {
439                    fiber.refresh(ctx).await;
440                    if let Some(provided) = self.fiber_provides.read().get(&fid).copied() {
441                        if visited_type.insert(provided) {
442                            queue.push_back(provided);
443                        }
444                    }
445                }
446            }
447        }
448    }
449
450    /// Drop `fibers` / `fiber_provides` entries whose disposal already ran.
451    ///
452    /// The BFS walk never drops entries on its own, so disposed fibers used
453    /// to accumulate here forever. Pruning runs opportunistically at the top
454    /// of each notify: a pruned fiber can no longer be refreshed, which is
455    /// exactly right — disposal already ran its undos. `Failed{error}`
456    /// fibers are NOT disposed (see [`Fiber::is_disposed`]) and stay
457    /// inspectable by design.
458    pub fn prune_disposed(&self) -> usize {
459        let dead: Vec<FiberId> = self
460            .fibers
461            .read()
462            .iter()
463            .filter(|(_, fiber)| fiber.is_disposed())
464            .map(|(fid, _)| *fid)
465            .collect();
466        let mut removed = 0;
467        {
468            let mut fibers = self.fibers.write();
469            for fid in &dead {
470                if fibers.remove(fid).is_some() {
471                    removed += 1;
472                }
473            }
474        }
475        self.fiber_provides
476            .write()
477            .retain(|fid, _| !dead.contains(fid));
478        removed
479    }
480
481    /// Get a `watch::Receiver` if already created (no creation).
482    pub fn subscribe(&self, tid: TypeId) -> Option<watch::Receiver<()>> {
483        self.notifiers.read().get(&tid).map(|s| s.subscribe())
484    }
485}
486
487impl Default for ReflectService {
488    fn default() -> Self {
489        Self::new()
490    }
491}
492
493impl Service for ReflectService {}
494
495// Inventory/linkme static registration placeholder (preferred for production).
496// Real static registration would use `inventory::submit!` or `linkme::distributed_slice`
497// to collect `fn(&Arc<Context>) -> Result<FiberId, CordisError>` at compile time.
498// This spike stubs it — Phase 3 Loader will drive declarative reconciliation.
499// Behind `#[cfg(feature = "hmr")]`, `libloading` would `dlopen` a `.so` and call `Plugin::apply`
500// via an `extern "C"` entry point; if ABI fragility blocks, fallback is file-watch + full fiber reload
501// (see docs/cordis-mapping.md §11 — 90% value without dynamic code).
502
503// ---------------------------------------------------------------------------
504// LoaderJournal — live bookkeeping for `Loader::execute_action` / `instantiate`
505// ---------------------------------------------------------------------------
506
507/// One record in the [`LoaderJournal`]: the plugin label owning an entry, the
508/// last applied config, the live fiber id when known, and a monotonically
509/// increasing generation counter.  `generation` lets reconciliation callers
510/// detect whether an entry's config actually changed (see
511/// [`Loader::execute_action`](crate::loader::Loader::execute_action)).
512#[derive(Debug, Clone, PartialEq, Eq, Default)]
513pub struct JournalRecord {
514    pub plugin: String,
515    pub config: serde_json::Value,
516    pub fiber_id: Option<FiberId>,
517    pub generation: u64,
518}
519
520/// Optional journal that makes the loader lifecycle real for `UpdateConfig`
521/// and `Retire` arms.
522///
523/// Provide it as a `Service` (`ctx.provide(LoaderJournal::new())`) so
524/// [`Context::get::<LoaderJournal>`] returns the shared handle; when absent,
525/// [`Loader::execute_action`](crate::loader::Loader::execute_action) and
526/// [`Loader::instantiate`](crate::loader::Loader::instantiate) degrade to
527/// log-only.  Every mutation bumps `generation`; the journal is the single
528/// source of truth for "is this entry live, with which fiber, at what
529/// config/version".
530#[derive(Clone, Default)]
531pub struct LoaderJournal {
532    records: Arc<RwLock<HashMap<String, JournalRecord>>>,
533}
534
535impl LoaderJournal {
536    pub fn new() -> Self {
537        Self::default()
538    }
539
540    /// Construct the journal and provide it on `ctx` in one step.
541    pub fn provide_new(ctx: &std::sync::Arc<Context>) -> std::sync::Arc<Self> {
542        let journal = std::sync::Arc::new(Self::default());
543        ctx.provide_arc(journal.clone());
544        journal
545    }
546
547    /// Insert or replace a record, bumping generation by 1 from the prior value
548    /// (or from 0 for a fresh id).
549    pub fn upsert(
550        &self,
551        id: &str,
552        plugin: &str,
553        config: serde_json::Value,
554        fiber_id: Option<FiberId>,
555    ) {
556        let mut records = self.records.write();
557        let generation = records.get(id).map(|r| r.generation).unwrap_or(0) + 1;
558        records.insert(
559            id.to_string(),
560            JournalRecord {
561                plugin: plugin.to_string(),
562                config,
563                fiber_id,
564                generation,
565            },
566        );
567    }
568
569    /// Replace the stored config for `id`, bumping generation, and optionally
570    /// refresh the tracked fiber id.  Returns the prior record if present.
571    pub fn update_config(
572        &self,
573        id: &str,
574        new_config: serde_json::Value,
575        fiber_id: Option<FiberId>,
576    ) -> Option<JournalRecord> {
577        let mut records = self.records.write();
578        let record = records.get_mut(id)?;
579        record.config = new_config;
580        if let Some(fid) = fiber_id {
581            record.fiber_id = Some(fid);
582        }
583        record.generation += 1;
584        Some(record.clone())
585    }
586
587    /// Remove `id` from the journal (retirement).  Returns the removed record.
588    pub fn retire(&self, id: &str) -> Option<JournalRecord> {
589        self.records.write().remove(id)
590    }
591    /// Re-key the record `old` → `new` (subtree move), PRESERVING the plugin
592    /// label, config, generation, and tracked fiber id. This is how a
593    /// structural entry move keeps its live fiber: the record moves, the
594    /// fiber does not. Returns the record under its new key, or `None` when
595    /// `old` was not journaled.
596    pub fn rename(&self, old: &str, new: &str) -> Option<JournalRecord> {
597        let mut records = self.records.write();
598        let record = records.remove(old)?;
599        records.insert(new.to_string(), record.clone());
600        Some(record)
601    }
602
603    pub fn get(&self, id: &str) -> Option<JournalRecord> {
604        self.records.read().get(id).cloned()
605    }
606
607    pub fn len(&self) -> usize {
608        self.records.read().len()
609    }
610
611    pub fn is_empty(&self) -> bool {
612        self.records.read().is_empty()
613    }
614}
615
616impl Service for LoaderJournal {}
617
618// ---------------------------------------------------------------------------
619// Tests — the two theorems that must hold before Phase 2
620// ---------------------------------------------------------------------------
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use parking_lot::Mutex;
626
627    #[test]
628    fn inventory_len_is_kernel_only() {
629        #[cfg(feature = "inventory")]
630        assert_eq!(inventory_len(), 4);
631        #[cfg(not(feature = "inventory"))]
632        assert_eq!(inventory_len(), 0);
633    }
634
635    #[derive(Debug)]
636    struct FooService(pub i32);
637    impl Service for FooService {}
638
639    #[derive(Debug)]
640    struct BarService(pub i32);
641    impl Service for BarService {}
642
643    #[derive(Debug)]
644    struct ConsumerService;
645    impl Service for ConsumerService {}
646
647    #[tokio::test]
648    async fn temporal_composability() {
649        // Register plugin → mutate context → dispose fiber → assert context recovered
650        let ctx = Context::new_root();
651        let pre_len = ctx.snapshot_len();
652        assert!(ctx.get::<BarService>().is_none());
653
654        // mutate via provide (witnessed effect)
655        let bar = ctx.provide(BarService(42));
656        assert_eq!(bar.0, 42);
657        assert!(ctx.get::<BarService>().is_some());
658        assert_eq!(ctx.snapshot_len(), pre_len + 1);
659
660        // dispose fiber should LIFO revert
661        let _ = ctx.fiber().dispose().await;
662        assert!(ctx.get::<BarService>().is_none());
663        assert_eq!(ctx.snapshot_len(), pre_len);
664    }
665
666    #[tokio::test]
667    async fn spatial_composability() {
668        // Provide service A → fiber depending on A activates → re-provide A → fiber automatically reloads
669        let ctx = Context::new_root();
670        let consumer_fiber = Arc::new(Fiber::new());
671        consumer_fiber.declare_inject::<FooService>();
672
673        // Initially Inactive (dep missing)
674        assert_eq!(consumer_fiber.state(), FiberState::Inactive { error: None });
675        assert_eq!(consumer_fiber.epoch(), "");
676
677        // Provide FooService v1 -> fiber should become Active after refresh
678        ctx.provide(FooService(1));
679        consumer_fiber.refresh(&ctx).await;
680        assert!(matches!(consumer_fiber.state(), FiberState::Active { .. }));
681        let epoch_v1 = consumer_fiber.epoch();
682        assert!(epoch_v1.contains("FooService"));
683        assert!(epoch_v1.contains(":1") || epoch_v1.contains("1"));
684
685        // Re-provide FooService v2 -> epoch should change and reload triggered
686        ctx.provide(FooService(2));
687        let prev_epoch = epoch_v1.clone();
688        consumer_fiber.refresh(&ctx).await;
689        let epoch_v2 = consumer_fiber.epoch();
690        assert_ne!(prev_epoch, epoch_v2);
691        assert!(matches!(consumer_fiber.state(), FiberState::Active { .. }));
692        // Ensure new provider visible
693        assert_eq!(ctx.get::<FooService>().unwrap().0, 2);
694    }
695
696    #[tokio::test]
697    async fn isolate_and_intercept() {
698        let root = Context::new_root();
699        root.provide(FooService(10));
700        assert_eq!(root.get::<FooService>().unwrap().0, 10);
701
702        // isolate tenant
703        let tenant_ctx = root.isolate::<FooService>("tenant:acme");
704        // tenant initially has no Foo (isolated) — but parent lookup would still find root's Foo
705        // Our get walks parent, so it will find root's Foo. Isolate semantics: should not leak?
706        // For spike, we test that tenant can provide its own Foo without affecting root
707        tenant_ctx.provide(FooService(99));
708        assert_eq!(tenant_ctx.get::<FooService>().unwrap().0, 99);
709        assert_eq!(root.get::<FooService>().unwrap().0, 10);
710
711        // intercept per-request override
712        let req_ctx = root.intercept(FooService(77));
713        assert_eq!(req_ctx.get::<FooService>().unwrap().0, 77);
714        // root unchanged
715        assert_eq!(root.get::<FooService>().unwrap().0, 10);
716    }
717
718    #[tokio::test]
719    async fn events_dispatch_modes() {
720        let svc = EventsService::new();
721        svc.on("test".into(), |v| async move {
722            let n = v.as_i64().unwrap_or(0);
723            Ok(serde_json::Value::Number((n + 1).into()))
724        });
725        let out = svc
726            .dispatch(
727                "test".into(),
728                serde_json::Value::Number(1.into()),
729                Dispatch::Serial,
730            )
731            .await
732            .unwrap();
733        assert_eq!(out, serde_json::Value::Number(2.into()));
734    }
735
736    #[tokio::test]
737    async fn epoch_monoid() {
738        let mut map = HashMap::new();
739        map.insert(TypeId::of::<FooService>(), "uid1".to_string());
740        map.insert(TypeId::of::<BarService>(), "uid2".to_string());
741        let e = compute_epoch(&map);
742        assert!(e.starts_with(':'));
743        assert!(e.contains("uid1"));
744        assert!(e.contains("uid2"));
745        // Empty
746        let empty: HashMap<TypeId, Symbol> = HashMap::new();
747        assert_eq!(compute_epoch(&empty), ":");
748    }
749
750    #[tokio::test]
751    async fn fiber_inertia_serializes_transitions() {
752        let fiber = Arc::new(Fiber::new());
753        fiber.declare_inject::<FooService>();
754        let ctx = Context::new_root();
755        // concurrent refreshes should serialize via inertia mutex
756        let f1 = fiber.clone();
757        let c1 = ctx.clone();
758        let f2 = fiber.clone();
759        let c2 = ctx.clone();
760        let (r1, r2) = tokio::join!(f1.refresh(&c1), f2.refresh(&c2));
761        // both should complete without deadlock
762        let _ = (r1, r2);
763        assert!(matches!(
764            fiber.state(),
765            FiberState::Inactive { .. } | FiberState::Active { .. }
766        ));
767    }
768
769    #[tokio::test]
770    async fn registry_single_source_discipline() {
771        let ctx = Context::new_root();
772        let registry = RegistryService::new();
773
774        struct FooPlugin;
775        impl Plugin for FooPlugin {
776            type Config = ();
777            type Provides = FooService;
778            fn apply(
779                &self,
780                _ctx: &Arc<Context>,
781                _cfg: Self::Config,
782            ) -> Result<Arc<Self::Provides>, CordisError> {
783                Ok(Arc::new(FooService(1)))
784            }
785        }
786
787        struct FooPlugin2;
788        impl Plugin for FooPlugin2 {
789            type Config = ();
790            type Provides = FooService;
791            fn apply(
792                &self,
793                _ctx: &Arc<Context>,
794                _cfg: Self::Config,
795            ) -> Result<Arc<Self::Provides>, CordisError> {
796                Ok(Arc::new(FooService(2)))
797            }
798        }
799
800        let fid1 = registry
801            .plugin(&ctx, FooPlugin, ())
802            .expect("first plugin ok");
803        assert!(registry.get_fiber(fid1).is_some());
804        let err = registry
805            .plugin(&ctx, FooPlugin2, ())
806            .expect_err("duplicate should fail");
807        assert!(err.to_string().contains("duplicate provider"));
808        // original still present
809        assert!(registry.get_fiber(fid1).is_some());
810    }
811
812    #[tokio::test]
813    async fn test_event_bus_dispatch_received() {
814        let ctx = Context::new_root();
815        let events = ctx.provide(EventsService::new());
816
817        // Register a listener
818        let received = Arc::new(Mutex::new(Vec::new()));
819        let received_clone = received.clone();
820        events.on("test.event".into(), move |payload| {
821            let r = received_clone.clone();
822            async move {
823                r.lock().push(payload.clone());
824                Ok(payload)
825            }
826        });
827
828        // Dispatch
829        let payload = serde_json::json!({"key": "value"});
830        events
831            .dispatch("test.event".into(), payload.clone(), Dispatch::Serial)
832            .await
833            .unwrap();
834
835        // Verify received
836        let msgs = received.lock();
837        assert_eq!(msgs.len(), 1);
838        assert_eq!(msgs[0]["key"], "value");
839    }
840
841    #[tokio::test]
842    async fn test_reactive_activation_deactivation() {
843        // Service A that fiber depends on
844        struct DepService;
845        impl Service for DepService {}
846
847        // Create root context with ReflectService
848        let ctx = Context::new_root();
849        ctx.provide(ReflectService::new());
850        let reflect = ctx.get::<ReflectService>().unwrap();
851        reflect.set_context(&ctx);
852
853        // Create fiber that injects DepService
854        let fiber = Arc::new(Fiber::new());
855        fiber.declare_inject::<DepService>();
856        let fid: FiberId = 100;
857        reflect.register_dependent(TypeId::of::<DepService>(), fid);
858        reflect.register_fiber(fid, fiber.clone(), TypeId::of::<DepService>());
859
860        // Initially Inactive (dep not provided)
861        fiber.refresh(&ctx).await;
862        assert!(matches!(fiber.state(), FiberState::Inactive { .. }));
863
864        // Provide DepService -> fiber should activate via notify cascade
865        ctx.provide(DepService);
866        // Give tokio a chance to run the spawned refresh
867        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
868        assert!(
869            matches!(fiber.state(), FiberState::Active { .. }),
870            "fiber should be Active after provide, got: {:?}",
871            fiber.state()
872        );
873
874        // Remove DepService -> fiber should deactivate via notify cascade
875        let _ = ctx.remove::<DepService>();
876        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
877        assert!(
878            matches!(fiber.state(), FiberState::Inactive { .. }),
879            "fiber should be Inactive after remove, got: {:?}",
880            fiber.state()
881        );
882    }
883
884    #[tokio::test]
885    async fn test_isolate_disjoint_namespaces() {
886        #[derive(Debug)]
887        struct ToolSvc(String);
888        impl Service for ToolSvc {}
889
890        let root = Context::new_root();
891
892        // Create two isolated contexts for different tenants
893        let ctx_a = root.isolate::<ToolSvc>("tenant_a");
894        ctx_a.provide(ToolSvc("tool_for_a".into()));
895
896        let ctx_b = root.isolate::<ToolSvc>("tenant_b");
897        ctx_b.provide(ToolSvc("tool_for_b".into()));
898
899        // Each tenant sees only its own service via get_isolated
900        let svc_a = ctx_a.get_isolated::<ToolSvc>("tenant_a");
901        assert!(svc_a.is_some());
902        assert_eq!(svc_a.unwrap().0, "tool_for_a");
903
904        let svc_b = ctx_b.get_isolated::<ToolSvc>("tenant_b");
905        assert!(svc_b.is_some());
906        assert_eq!(svc_b.unwrap().0, "tool_for_b");
907
908        // Cross-tenant access returns None
909        assert!(ctx_a.get_isolated::<ToolSvc>("tenant_b").is_none());
910        assert!(ctx_b.get_isolated::<ToolSvc>("tenant_a").is_none());
911
912        // Root has no isolated service
913        assert!(root.get_isolated::<ToolSvc>("tenant_a").is_none());
914        assert!(root.get_isolated::<ToolSvc>("tenant_b").is_none());
915    }
916
917    #[test]
918    fn bind_isolate_labels_provided_service_in_place() {
919        #[derive(Debug)]
920        struct ToolSvc(String);
921        impl Service for ToolSvc {}
922
923        let root = Context::new_root();
924        root.provide(ToolSvc("fleet".into()));
925        root.bind_isolate(TypeId::of::<ToolSvc>(), "tenant:acme");
926        let got = root
927            .get_isolated::<ToolSvc>("tenant:acme")
928            .expect("in-place isolate");
929        assert_eq!(got.0, "fleet");
930        assert!(root.get::<ToolSvc>().is_some());
931    }
932
933    #[tokio::test]
934    async fn test_intercept_overrides_get() {
935        #[derive(Debug)]
936        struct ModelSvc {
937            model: String,
938        }
939        impl Service for ModelSvc {}
940
941        let root = Context::new_root();
942        root.provide(ModelSvc {
943            model: "gpt-4".into(),
944        });
945
946        // Root returns the original
947        assert_eq!(root.get::<ModelSvc>().unwrap().model, "gpt-4");
948
949        // with_intercept creates a child context where get returns the override
950        let req_ctx = root.with_intercept(ModelSvc {
951            model: "gpt-4o-mini".into(),
952        });
953        assert_eq!(req_ctx.get::<ModelSvc>().unwrap().model, "gpt-4o-mini");
954
955        // Root remains unaffected
956        assert_eq!(root.get::<ModelSvc>().unwrap().model, "gpt-4");
957
958        // Stacking intercepts: innermost wins
959        let inner_ctx = req_ctx.intercept(ModelSvc {
960            model: "o1-preview".into(),
961        });
962        assert_eq!(inner_ctx.get::<ModelSvc>().unwrap().model, "o1-preview");
963        // Outer still sees its own override
964        assert_eq!(req_ctx.get::<ModelSvc>().unwrap().model, "gpt-4o-mini");
965    }
966
967    #[tokio::test]
968    async fn isolate_wins_over_same_type_intercept() {
969        #[derive(Debug)]
970        struct ToolSvc(String);
971        impl Service for ToolSvc {}
972
973        #[derive(Debug)]
974        struct OtherSvc(String);
975        impl Service for OtherSvc {}
976
977        let root = Context::new_root();
978        let child = root.isolate::<ToolSvc>("acme");
979        child.provide(ToolSvc("store".into()));
980
981        let intercepted = child.intercept(ToolSvc("override".into()));
982        assert_eq!(intercepted.get::<ToolSvc>().unwrap().0, "store");
983
984        let mixed = child.intercept(OtherSvc("override".into()));
985        assert_eq!(mixed.get::<OtherSvc>().unwrap().0, "override");
986        assert_eq!(mixed.get::<ToolSvc>().unwrap().0, "store");
987    }
988
989    #[tokio::test]
990    async fn inject_returns_immediately_when_already_provided() {
991        let ctx = Context::new_root();
992        ctx.provide(FooService(1));
993        let got = ctx.inject::<FooService>().await;
994        assert_eq!(got.name(), FooService(1).name());
995        assert_eq!(got.0, 1);
996    }
997
998    #[tokio::test]
999    async fn inject_waits_until_service_is_provided() {
1000        let ctx = Context::new_root();
1001        let waiter = ctx.clone();
1002        let handle = tokio::spawn(async move { waiter.inject::<FooService>().await });
1003        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1004        ctx.provide(FooService(42));
1005        let got = tokio::time::timeout(std::time::Duration::from_millis(200), handle)
1006            .await
1007            .expect("inject should complete within 200ms")
1008            .expect("inject task should not panic");
1009        assert_eq!(got.0, 42);
1010    }
1011
1012    #[tokio::test]
1013    async fn inject_unblocks_via_reflect_notify() {
1014        let ctx = Context::new_root();
1015        ctx.provide(ReflectService::new());
1016        let reflect = ctx.get::<ReflectService>().unwrap();
1017        reflect.set_context(&ctx);
1018
1019        let waiter = ctx.clone();
1020        let handle = tokio::spawn(async move { waiter.inject::<FooService>().await });
1021        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1022        ctx.provide(FooService(7));
1023        let got = tokio::time::timeout(std::time::Duration::from_millis(200), handle)
1024            .await
1025            .expect("inject should complete within 200ms via reflect notify")
1026            .expect("inject task should not panic");
1027        assert_eq!(got.0, 7);
1028    }
1029
1030    #[tokio::test]
1031    async fn test_production_style_reactive_cycle() {
1032        #[derive(Debug)]
1033        struct Probe;
1034        impl Service for Probe {}
1035
1036        let ctx = Context::new_root();
1037        ctx.provide(ReflectService::new());
1038        let reflect = ctx.get::<ReflectService>().unwrap();
1039        reflect.set_context(&ctx);
1040
1041        let f = Arc::new(Fiber::new());
1042        f.declare_inject::<Probe>();
1043        reflect.register_dependent(TypeId::of::<Probe>(), 777);
1044        reflect.register_fiber(777, f.clone(), TypeId::of::<Probe>());
1045
1046        // Initially Inactive: dep not yet provided
1047        f.refresh(&ctx).await;
1048        assert!(
1049            matches!(f.state(), FiberState::Inactive { .. }),
1050            "expected Inactive before provide, got {:?}",
1051            f.state()
1052        );
1053
1054        // Provide -> notify_with_ctx (synchronous, no sleeps) drives activation
1055        let _probe = ctx.provide(Probe);
1056        reflect.notify_with_ctx(TypeId::of::<Probe>(), &ctx).await;
1057        assert!(
1058            matches!(f.state(), FiberState::Active { .. }),
1059            "expected Active after provide, got {:?}",
1060            f.state()
1061        );
1062
1063        // Remove -> notify_with_ctx drives deactivation
1064        let _ = ctx.remove::<Probe>();
1065        reflect.notify_with_ctx(TypeId::of::<Probe>(), &ctx).await;
1066        assert!(
1067            matches!(f.state(), FiberState::Inactive { .. }),
1068            "expected Inactive after remove, got {:?}",
1069            f.state()
1070        );
1071    }
1072
1073    // -----------------------------------------------------------------------
1074    // EventsService dispatch parity with Cordis TS semantics
1075    // -----------------------------------------------------------------------
1076
1077    use std::sync::atomic::{AtomicUsize, Ordering};
1078
1079    #[tokio::test]
1080    async fn events_emit_fire_and_forget_and_broadcast() {
1081        let svc = EventsService::new();
1082        // Each handler signals completion via an mpsc channel after doing work.
1083        let (done_tx, mut done_rx) = tokio::sync::mpsc::channel::<()>(16);
1084        let mut bus_rx = svc.subscribe();
1085
1086        for i in 0..3 {
1087            let tx = done_tx.clone();
1088            svc.on("emit.test".into(), move |payload| {
1089                let tx = tx.clone();
1090                async move {
1091                    // simulate async work so dispatch must NOT await us
1092                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1093                    let _ = tx.send(()).await;
1094                    Ok(serde_json::json!({ "handler": i, "seen": payload }))
1095                }
1096            });
1097        }
1098
1099        let payload = serde_json::json!({ "n": 1 });
1100        let start = std::time::Instant::now();
1101        let out = svc
1102            .dispatch("emit.test".into(), payload.clone(), Dispatch::Emit)
1103            .await
1104            .unwrap();
1105        let dispatch_elapsed = start.elapsed();
1106
1107        // Emit returns immediately (fire-and-forget) with Null — it does NOT
1108        // await handler completion.
1109        assert_eq!(out, serde_json::Value::Null);
1110        assert!(
1111            dispatch_elapsed < std::time::Duration::from_millis(20),
1112            "emit returned after {:?} — should return immediately",
1113            dispatch_elapsed
1114        );
1115
1116        // The raw event+payload was broadcast on the bus.
1117        let (evt, bus_payload) =
1118            tokio::time::timeout(std::time::Duration::from_secs(1), bus_rx.recv())
1119                .await
1120                .expect("bus should broadcast")
1121                .expect("bus recv should be a value");
1122        assert_eq!(evt, "emit.test");
1123        assert_eq!(bus_payload, payload);
1124
1125        // Even though emit is fire-and-forget, every handler must still run to
1126        // completion before the test asserts.
1127        for _ in 0..3 {
1128            tokio::time::timeout(std::time::Duration::from_secs(1), done_rx.recv())
1129                .await
1130                .expect("handlers should complete")
1131                .expect("handler completion signal");
1132        }
1133    }
1134
1135    #[tokio::test]
1136    async fn events_emit_invokes_registered_handler_counter() {
1137        // Spec: a handler registered via `on()` actually RUNS on `Emit`. Prove it
1138        // with an `Arc<AtomicUsize>` counter that the handler increments, then poll
1139        // (sleep loop) until it is > 0.
1140        let svc = EventsService::new();
1141        let counter = Arc::new(AtomicUsize::new(0));
1142
1143        let c = counter.clone();
1144        svc.on("emit.counter".into(), move |payload| {
1145            let c = c.clone();
1146            async move {
1147                // Simulate a little async work so the spawn completes on the runtime.
1148                let n = payload.as_i64().unwrap_or(0);
1149                for _ in 0..n {
1150                    tokio::task::yield_now().await;
1151                }
1152                c.fetch_add(1, Ordering::SeqCst);
1153                Ok(serde_json::Value::Null)
1154            }
1155        });
1156
1157        let out = svc
1158            .dispatch("emit.counter".into(), serde_json::json!(5), Dispatch::Emit)
1159            .await
1160            .unwrap();
1161        assert_eq!(out, serde_json::Value::Null);
1162
1163        // Poll until the spawned handler has actually run (fire-and-forget means we
1164        // cannot await it directly).
1165        for _ in 0..100 {
1166            if counter.load(Ordering::SeqCst) > 0 {
1167                break;
1168            }
1169            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1170        }
1171        assert!(
1172            counter.load(Ordering::SeqCst) > 0,
1173            "emit handler should have run and incremented the counter"
1174        );
1175    }
1176
1177    #[tokio::test]
1178    async fn events_serial_threads_payload_in_order() {
1179        let svc = EventsService::new();
1180        let payload = serde_json::json!({ "n": 1 });
1181        let seen = Arc::new(Mutex::new(Vec::new()));
1182
1183        for tag in ["a", "b", "c"] {
1184            let seen = seen.clone();
1185            let tag = tag.to_string();
1186            svc.on("serial.test".into(), move |received| {
1187                let seen = seen.clone();
1188                let tag = tag.clone();
1189                async move {
1190                    seen.lock().push((tag, received));
1191                    Ok(serde_json::Value::Null)
1192                }
1193            });
1194        }
1195
1196        let out = svc
1197            .dispatch("serial.test".into(), payload.clone(), Dispatch::Serial)
1198            .await
1199            .unwrap();
1200
1201        // Serial handlers see the original payload, and an all-null chain preserves it.
1202        assert_eq!(out, payload);
1203        assert_eq!(
1204            seen.lock().clone(),
1205            vec![
1206                ("a".to_string(), payload.clone()),
1207                ("b".to_string(), payload.clone()),
1208                ("c".to_string(), payload),
1209            ]
1210        );
1211    }
1212
1213    #[tokio::test]
1214    async fn events_bail_stops_at_first_non_null_and_skips_later_handlers() {
1215        let svc = EventsService::new();
1216        let ran = Arc::new(AtomicUsize::new(0));
1217
1218        // Handler 1 returns Null → does not bail, chain continues.
1219        let h1 = ran.clone();
1220        svc.on("bail.test".into(), move |_payload| {
1221            let r = h1.clone();
1222            async move {
1223                r.fetch_add(1, Ordering::SeqCst);
1224                Ok(serde_json::Value::Null)
1225            }
1226        });
1227        // Handler 2 returns a non-null value → bails.
1228        let h2 = ran.clone();
1229        svc.on("bail.test".into(), move |_payload| {
1230            let r = h2.clone();
1231            async move {
1232                r.fetch_add(1, Ordering::SeqCst);
1233                Ok(serde_json::json!({ "bailed": true }))
1234            }
1235        });
1236        // Handler 3 must NOT run.
1237        let h3 = ran.clone();
1238        svc.on("bail.test".into(), move |_payload| {
1239            let r = h3.clone();
1240            async move {
1241                r.fetch_add(1, Ordering::SeqCst);
1242                Ok(serde_json::Value::Null)
1243            }
1244        });
1245
1246        let payload = serde_json::json!({ "n": 1 });
1247        let out = svc
1248            .dispatch("bail.test".into(), payload.clone(), Dispatch::Bail)
1249            .await
1250            .unwrap();
1251        assert_eq!(out, serde_json::json!({ "bailed": true }));
1252        // Only the first two handlers ran; handler 3 was skipped.
1253        assert_eq!(ran.load(Ordering::SeqCst), 2);
1254    }
1255
1256    #[tokio::test]
1257    async fn events_waterfall_handler_calls_next_and_receives_downstream_result() {
1258        let svc = EventsService::new();
1259        // Chain: outer wraps inner. The inner handler runs first during `next`,
1260        // then the outer transforms the downstream result.
1261        svc.on_waterfall("wf.next".into(), |payload, next| {
1262            let next = next;
1263            async move {
1264                let downstream = next(payload).await?;
1265                // The outer transforms what came back from downstream.
1266                let mut obj = downstream.as_object().cloned().unwrap_or_default();
1267                obj.insert("outer".into(), serde_json::json!(true));
1268                Ok(serde_json::Value::Object(obj))
1269            }
1270        });
1271        svc.on_waterfall("wf.next".into(), |payload, _next| async move {
1272            let mut obj = payload.as_object().cloned().unwrap_or_default();
1273            obj.insert("inner_seen".into(), serde_json::json!(payload.get("value")));
1274            Ok(serde_json::Value::Object(obj))
1275        });
1276
1277        let payload = serde_json::json!({ "value": 42 });
1278        let out = svc
1279            .dispatch("wf.next".into(), payload, Dispatch::Waterfall)
1280            .await
1281            .unwrap();
1282        let obj = out
1283            .as_object()
1284            .expect("waterfall output should be an object");
1285        // Inner ran (during next) and outer wrapped its result.
1286        assert_eq!(obj["inner_seen"], serde_json::json!(42));
1287        assert_eq!(obj["outer"], serde_json::json!(true));
1288    }
1289
1290    #[tokio::test]
1291    async fn events_waterfall_handler_short_circuits_skips_later_handlers() {
1292        let svc = EventsService::new();
1293        let ran = Arc::new(AtomicUsize::new(0));
1294
1295        // First handler short-circuits: does NOT call next.
1296        let h1 = ran.clone();
1297        svc.on_waterfall("wf.short".into(), move |_payload, _next| {
1298            let r = h1.clone();
1299            async move {
1300                r.fetch_add(1, Ordering::SeqCst);
1301                Ok(serde_json::json!({ "owned": true }))
1302            }
1303        });
1304        // Later handler must NOT run.
1305        let h2 = ran.clone();
1306        svc.on_waterfall("wf.short".into(), move |payload, next| {
1307            let r = h2.clone();
1308            async move {
1309                r.fetch_add(1, Ordering::SeqCst);
1310                next(payload).await
1311            }
1312        });
1313
1314        let payload = serde_json::json!({ "n": 1 });
1315        let out = svc
1316            .dispatch("wf.short".into(), payload, Dispatch::Waterfall)
1317            .await
1318            .unwrap();
1319        assert_eq!(out, serde_json::json!({ "owned": true }));
1320        // The later handler never ran.
1321        assert_eq!(ran.load(Ordering::SeqCst), 1);
1322    }
1323
1324    #[tokio::test]
1325    async fn events_waterfall_empty_chain_returns_payload_unchanged() {
1326        let svc = EventsService::new();
1327        let payload = serde_json::json!({ "n": 7 });
1328        let out = svc
1329            .dispatch("wf.empty".into(), payload.clone(), Dispatch::Waterfall)
1330            .await
1331            .unwrap();
1332        assert_eq!(out, payload);
1333    }
1334
1335    #[tokio::test]
1336    async fn events_parallel_propagates_aggregate_error() {
1337        let svc = EventsService::new();
1338        svc.on("par.test".into(), |_payload| async move {
1339            Ok(serde_json::json!({ "ok": 1 }))
1340        });
1341        svc.on("par.test".into(), |_payload| async move {
1342            Err(CordisError::Fiber("boom".into()))
1343        });
1344        svc.on("par.test".into(), |_payload| async move {
1345            Ok(serde_json::json!({ "ok": 2 }))
1346        });
1347
1348        let payload = serde_json::json!({ "n": 1 });
1349        let err = svc
1350            .dispatch("par.test".into(), payload, Dispatch::Parallel)
1351            .await
1352            .unwrap_err();
1353        assert!(
1354            err.to_string().contains("boom"),
1355            "parallel should propagate the handler error, got: {err}"
1356        );
1357    }
1358
1359    #[tokio::test]
1360    async fn events_parallel_returns_a_value_when_no_handler_errors() {
1361        let svc = EventsService::new();
1362        let payload = serde_json::json!({ "n": 1 });
1363        let seen = Arc::new(Mutex::new(Vec::new()));
1364
1365        for tag in ["a", "b"] {
1366            let seen = seen.clone();
1367            let tag = tag.to_string();
1368            svc.on("par2.test".into(), move |received| {
1369                let seen = seen.clone();
1370                let tag = tag.clone();
1371                async move {
1372                    seen.lock().push((tag, received));
1373                    Ok(serde_json::json!({ "handler": "complete" }))
1374                }
1375            });
1376        }
1377
1378        let out = svc
1379            .dispatch("par2.test".into(), payload.clone(), Dispatch::Parallel)
1380            .await
1381            .unwrap();
1382
1383        // Parallel waits for every handler, but successful dispatch returns null.
1384        assert_eq!(out, serde_json::Value::Null);
1385        let mut completed = seen.lock().clone();
1386        completed.sort_by(|left, right| left.0.cmp(&right.0));
1387        assert_eq!(
1388            completed,
1389            vec![
1390                ("a".to_string(), payload.clone()),
1391                ("b".to_string(), payload)
1392            ]
1393        );
1394    }
1395
1396    #[tokio::test(flavor = "multi_thread")]
1397    async fn notify_broadcasts_service_changed_event() {
1398        let ctx = Context::new_root();
1399        let events_handle = ctx.provide(EventsService::new());
1400        let reflect = ctx.provide(ReflectService::new());
1401        reflect.set_context(&ctx);
1402
1403        let mut rx = events_handle.subscribe();
1404        reflect.notify(TypeId::of::<u64>());
1405
1406        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
1407        let mut seen = false;
1408        while std::time::Instant::now() < deadline {
1409            match rx.try_recv() {
1410                Ok((name, payload)) => {
1411                    assert_eq!(name, crate::events_catalog::ev::SERVICE_CHANGED);
1412                    // TypeId formats as a hash, not a name; just require presence.
1413                    assert!(
1414                        payload["type_id"].as_str().unwrap().starts_with("TypeId("),
1415                        "payload should identify the changed type: {payload}"
1416                    );
1417                    seen = true;
1418                    break;
1419                }
1420                Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {
1421                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1422                }
1423                Err(e) => panic!("unexpected broadcast error: {e}"),
1424            }
1425        }
1426        assert!(
1427            seen,
1428            "service.changed broadcast not observed within timeout"
1429        );
1430    }
1431
1432    /// ReflectService bookkeeping must not leak disposed fibers: pruning
1433    /// drops `fibers` / `fiber_provides` entries whose disposal already ran
1434    /// (opportunistic sweep at the top of every notify), while live and
1435    /// Failed fibers stay tracked.
1436    #[tokio::test]
1437    async fn reflect_prune_disposed_drops_dead_fibers_only() {
1438        let _ctx = Context::new_root();
1439        let reflect = ReflectService::new();
1440
1441        let dead = Arc::new(Fiber::new());
1442        let live = Arc::new(Fiber::new());
1443        let failed = Arc::new(Fiber::new());
1444        failed.set_state(crate::FiberState::Failed { error: None });
1445
1446        reflect.register_fiber(1, dead.clone(), TypeId::of::<u64>());
1447        reflect.register_fiber(2, live.clone(), TypeId::of::<u64>());
1448        reflect.register_fiber(3, failed.clone(), TypeId::of::<u64>());
1449
1450        // Nothing pruned before any disposal.
1451        assert_eq!(reflect.prune_disposed(), 0);
1452
1453        let _ = dead.dispose().await;
1454        assert_eq!(
1455            reflect.prune_disposed(),
1456            1,
1457            "exactly the disposed fiber is dropped"
1458        );
1459        // Live + Failed remain; the disposed one is gone.
1460        assert!(matches!(live.state(), crate::FiberState::Inactive { .. }));
1461        assert!(matches!(failed.state(), crate::FiberState::Failed { .. }));
1462
1463        // The opportunistic path: notify() sweeps again before walking.
1464        reflect.notify(TypeId::of::<u64>());
1465    }
1466}