Skip to main content

yeti_types/plugins/
mod.rs

1//! Plugin traits and registration context.
2//!
3//! Defines `Plugin`, `RegistrationContext`, `VectorHook`, `AiHook`,
4//! `ComputedFieldHook`, `FieldMapping`, `ModelInfo`, `VectorBatcher`,
5//! `EmbeddedFile`. (The former `ServiceContext` was unified into
6//! `yeti_types::resource::Context` .)
7//!
8//! # Errors
9//!
10//! Result-returning methods on the traits in this file (`VectorHook`,
11//! `AiHook`, `Plugin`, `ComputedFieldHook`) carry implementation-defined
12//! error semantics — the trait declares the *shape* (`Result<T, String>`
13//! for hot-path hook traits, `Result<T, YetiError>` for `Plugin`
14//! lifecycle), but the actual conditions that produce `Err` belong to
15//! each implementor.
16//!
17//! - `VectorHook` / `AiHook` impls in yeti-ai surface model-load,
18//!   tokenizer, and inference failures as `String`. Hook implementors
19//!   should document their own error contracts.
20//! - `Plugin::resources` / `on_ready` / `install_event_subscriber`
21//!   propagate `YetiError` from plugin setup (table registration,
22//!   route conflicts, bootstrap failures).
23//!
24//! Per-method `# Errors` sections at the trait level would just say
25//! "implementation-defined", so the lint is suppressed at the module
26//! level. Each impl crate documents its own error conditions.
27#![allow(clippy::missing_errors_doc)]
28
29use async_trait::async_trait;
30use std::future::Future;
31use std::pin::Pin;
32use std::sync::Arc;
33
34// ============================================================================
35// Plugin extension contracts (relocated from yeti_types::extension)
36// ============================================================================
37//
38// One trait — `tower::Service<R>` — for every plugin surface. Composition
39// via `tower::Layer<S>` / `ServiceBuilder`. See ADR-006.
40//
41// | Module      | Domain                          | Request                 | Response               |
42// |-------------|---------------------------------|-------------------------|------------------------|
43// | `request`   | HTTP-shaped per-request work    | `YetiRequest`           | `YetiResponse`         |
44// | `lifecycle` | Fire-and-forget lifecycle taps  | `LifecycleEvent`        | `()`                   |
45// | `auth`      | Identity → access resolution    | `ResolveRequest`        | `ResolveResponse`      |
46// | `token`     | JWT mint extension              | `TokenRequest`          | `TokenResponse`        |
47// | `oauth`     | OAuth callback claim mutation   | `OAuthRequest`          | `OAuthResponse`        |
48// | `mcp`       | MCP tool list / call hooks      | `ListToolsRequest`      | `ListToolsResponse`    |
49// | `queue`     | Durable / async job execution   | `JobRequest`            | `JobResponse`          |
50
51pub use tower::util::{BoxCloneService, BoxCloneSyncService, BoxService, ServiceFn, service_fn};
52pub use tower::{Layer, Service, ServiceBuilder, ServiceExt};
53
54pub mod auth;
55pub mod lifecycle;
56pub mod mcp;
57pub mod oauth;
58pub mod queue;
59pub mod request;
60pub mod token;
61pub mod trust;
62
63// Re-export trust tier at the plugins level — commonly referenced.
64pub use trust::PluginTrustTier;
65
66// Hot-path re-exports for the most common pipeline types.
67pub use lifecycle::{LifecycleEvent, TelemetryEvent, TelemetryService};
68pub use mcp::{
69    CallToolRequest, CallToolResponse, CallToolService, ListToolsRequest, ListToolsResponse,
70    ListToolsService, ToolAnnotations, ToolDefinition,
71};
72pub use oauth::{OAuthRequest, OAuthResponse, OAuthService};
73pub use request::{ContextService, RequestPipeline, YetiRequest, YetiResponse};
74pub use token::{TokenRequest, TokenResponse, TokenService};
75
76// ============================================================================
77// ComputedFieldHook
78// ============================================================================
79
80/// Hook for computing virtual field values at read time.
81#[async_trait]
82pub trait ComputedFieldHook: Send + Sync {
83    /// Resolve computed fields for a record.
84    async fn resolve(
85        &self,
86        record: serde_json::Value,
87        fields: &[(String, String)],
88    ) -> std::result::Result<serde_json::Value, String>;
89}
90
91// ============================================================================
92// Vector Embedding Support
93// ============================================================================
94
95/// Mapping from a source field to a target vector field via an embedding model.
96#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
97pub struct FieldMapping {
98    /// Source field name containing text or image data
99    pub source: String,
100    /// Target field name for the generated embedding vector
101    pub target: String,
102    /// Model identifier (e.g., "BAAI/bge-small-en-v1.5")
103    pub model: String,
104    /// Field type: "text" (default) or "image"
105    #[serde(default = "default_field_type")]
106    pub field_type: String,
107}
108
109fn default_field_type() -> String {
110    "text".to_owned()
111}
112
113/// Hook for automatic vector embedding generation.
114///
115/// Implementations must be **sync** for safety across the WIT boundary.
116pub trait VectorHook: Send + Sync {
117    /// Embed text/image fields in a record based on mappings.
118    fn vectorize_fields(
119        &self,
120        record: serde_json::Value,
121        mappings: &[FieldMapping],
122    ) -> std::result::Result<serde_json::Value, String>;
123
124    /// Convert a text query to a vector for search.
125    fn vectorize_text(&self, text: &str, model: &str) -> std::result::Result<Vec<f32>, String>;
126
127    /// Convert raw image bytes to a vector.
128    fn vectorize_image(&self, bytes: &[u8], model: &str) -> std::result::Result<Vec<f32>, String>;
129
130    /// Check if a model is available for use.
131    fn validate_model(&self, _model: &str) -> std::result::Result<(), String> {
132        Ok(())
133    }
134
135    /// Eagerly load a model into memory during startup.
136    fn warmup_model(&self, _model: &str, _field_type: &str) -> std::result::Result<(), String> {
137        Ok(())
138    }
139
140    /// Batch-embed multiple records.
141    fn vectorize_fields_batch(
142        &self,
143        records: Vec<serde_json::Value>,
144        mappings: &[FieldMapping],
145    ) -> std::result::Result<Vec<serde_json::Value>, String> {
146        records
147            .into_iter()
148            .map(|r| self.vectorize_fields(r, mappings))
149            .collect()
150    }
151}
152
153// ============================================================================
154// AI Inference Support
155// ============================================================================
156
157/// Metadata about an available AI model.
158#[derive(Debug, Clone, serde::Serialize)]
159pub struct ModelInfo {
160    /// Unique identifier
161    pub id: String,
162    /// Human-readable model name
163    pub name: String,
164    /// Parameter count label ("8B", "3B")
165    pub parameters: String,
166    /// Quantization level ("`Q4_K_M`", "F16")
167    pub quantization: String,
168    /// Whether the model is currently loaded
169    pub loaded: bool,
170    /// Memory usage in bytes (0 if not loaded)
171    pub memory_bytes: u64,
172}
173
174/// Hook for local LLM inference.
175///
176/// Implementations must be **sync** for safety across the WIT boundary.
177pub trait AiHook: Send + Sync {
178    /// Single-turn text completion.
179    fn complete(&self, prompt: &str, max_tokens: u32) -> std::result::Result<String, String>;
180
181    /// Multi-turn chat completion.
182    fn chat(
183        &self,
184        messages: &[(&str, &str)],
185        max_tokens: u32,
186    ) -> std::result::Result<String, String>;
187
188    /// Structured JSON output.
189    fn complete_json(
190        &self,
191        prompt: &str,
192        max_tokens: u32,
193    ) -> std::result::Result<serde_json::Value, String>;
194
195    /// List available models.
196    fn models(&self) -> Vec<ModelInfo>;
197
198    /// Check if a specific model is loaded.
199    fn is_loaded(&self, model: &str) -> bool;
200}
201
202// ============================================================================
203// VectorContext — per-app vector capabilities (set once at startup)
204// ============================================================================
205
206/// Per-app vector capabilities. Set once at startup, never changes per-request.
207///
208/// Stored on `BackendManager` via `OnceLock` so any code with access to the
209/// backend manager can reach vector hooks without threading them through Context.
210pub struct VectorContext {
211    /// Vector embedding hook (from yeti-ai plugin)
212    pub hook: Arc<dyn VectorHook>,
213    /// Plugin `app_id` that provided the vector hook
214    pub hook_ext_id: String,
215    /// Vector micro-batcher for server-side batching
216    pub batcher: Option<Arc<VectorBatcher>>,
217    /// Pre-parsed vector field mappings
218    pub mappings: Vec<FieldMapping>,
219    /// Vector embedding cache backend
220    pub cache: Option<Arc<dyn crate::backend::KvBackend>>,
221}
222
223impl std::fmt::Debug for VectorContext {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.debug_struct("VectorContext")
226            .field("hook_ext_id", &self.hook_ext_id)
227            .field("mappings", &self.mappings)
228            .field("has_batcher", &self.batcher.is_some())
229            .field("has_cache", &self.cache.is_some())
230            // `hook` (dyn VectorHook), `cache` (dyn KvBackend) elided
231            .finish_non_exhaustive()
232    }
233}
234
235// ============================================================================
236// VectorBatcher
237// ============================================================================
238
239/// Server-side micro-batcher for vector embeddings.
240#[derive(Debug)]
241pub struct VectorBatcher {
242    tx: tokio::sync::mpsc::UnboundedSender<(
243        serde_json::Value,
244        tokio::sync::oneshot::Sender<serde_json::Value>,
245    )>,
246}
247
248impl VectorBatcher {
249    /// Create from a channel sender.
250    #[must_use]
251    pub const fn from_sender(
252        tx: tokio::sync::mpsc::UnboundedSender<(
253            serde_json::Value,
254            tokio::sync::oneshot::Sender<serde_json::Value>,
255        )>,
256    ) -> Self {
257        Self { tx }
258    }
259
260    /// Submit a record for embedding and wait for the result.
261    pub async fn embed(&self, record: serde_json::Value) -> serde_json::Value {
262        let fallback = record.clone();
263        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
264        if self.tx.send((record, reply_tx)).is_err() {
265            return fallback;
266        }
267        reply_rx.await.unwrap_or(fallback)
268    }
269}
270
271// ============================================================================
272// EmbeddedFile
273// ============================================================================
274
275/// A file embedded in the binary at compile time for static web serving.
276#[derive(Debug)]
277pub struct EmbeddedFile {
278    /// Relative path (e.g., "index.html", "assets/app.js")
279    pub path: &'static str,
280    /// File content bytes
281    pub content: &'static [u8],
282}
283
284// ============================================================================
285// Context Lookup Types
286// ============================================================================
287
288/// Closure type for looking up `PubSub` managers by table name.
289///
290/// `Arc<dyn Fn>` (rather than `Box<dyn Fn>`) so that the enclosing
291/// `Context` can be `Clone` — required for flowing the owned `Context`
292/// through Tower `Service<Context>` chains (each link clones once, then
293/// consumes its clone). `Arc::clone` is a single atomic op; the closure
294/// body is shared, not copied.
295pub type PubSubLookupFn =
296    Arc<dyn Fn(&str) -> Option<Arc<crate::pubsub::PubSubManager>> + Send + Sync>;
297
298// ============================================================================
299// TableSubscriber — host-bridged table pubsub for wasm applications
300// ============================================================================
301
302/// Subscribe to row changes on a named table from a wasm application.
303///
304/// # Why not just `ps.subscribe(table).await` in the guest?
305///
306/// The guest runs in a separate wasm sandbox with its own linear memory
307/// and no visibility into the host tokio runtime — there is no host
308/// reactor for guest-constructed futures to register wakers against, and
309/// the guest cannot hold a host `broadcast::Receiver` whose `Sender`
310/// lives host-side. Long-running subscription work has to be host-driven;
311/// the guest only awaits a channel the host hands it.
312///
313/// # How this bridge works
314///
315/// The app provides a `TableSubscriber` implementation via
316/// `RegistrationContext::table_subscribers`. During app-loader drain,
317/// the host:
318///
319///   1. Calls `ps.subscribe(subscriber.table()).await` — receiver is
320///      **host-allocated**.
321///   2. Creates an `mpsc::UnboundedChannel` pair (the mpsc is the
322///      safe-across-the-boundary primitive).
323///   3. `tokio::spawn`s a forwarder task on the host runtime that loops
324///      `broadcast_rx.recv().await` → `mpsc_tx.send()`. Both primitives
325///      live in host tokio.
326///   4. `tokio::spawn`s `subscriber.run(mpsc_rx)` on the host runtime.
327///      The loop body is guest-defined, but it only awaits on the
328///      host-allocated `mpsc::UnboundedReceiver` — no guest-side
329///      subscribe or broadcast-receive happens.
330///
331/// The result is symmetric with the publish path: publish goes guest →
332/// host via a WIT import; subscribe goes host → guest via an mpsc
333/// receiver handed in at `run()` time.
334pub trait TableSubscriber: Send + Sync + 'static {
335    /// Name of the table to subscribe to (matches `@table` name in schema).
336    fn table(&self) -> &str;
337
338    /// Long-running loop. `rx` carries every row change published to the
339    /// table's pubsub stream. Runs on the host tokio runtime; is
340    /// host-driven on behalf of the guest.
341    fn run(
342        self: Box<Self>,
343        rx: tokio::sync::mpsc::UnboundedReceiver<crate::resource::SubscriptionMessage>,
344    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>;
345}
346
347// ============================================================================
348// Plugin trait
349// ============================================================================
350
351/// Contract between the yeti binary and service crates.
352///
353/// The runtime calls methods in order:
354/// `is_required()` → `schemas()` → `resources()` → `on_ready()` → `on_shutdown()`
355///
356/// `schemas()` is called during discovery (before backends exist).
357/// `resources()` is called during loading (after backends exist).
358/// Each is called exactly once.
359pub trait Plugin: Send + Sync + 'static {
360    /// Unique identifier (e.g., "yeti-auth").
361    fn id(&self) -> &'static str;
362    /// Human-readable name.
363    fn name(&self) -> &'static str;
364    /// Semantic version.
365    fn version(&self) -> &'static str {
366        "0.1.0"
367    }
368    /// Plugin IDs this service depends on (for topological ordering).
369    fn depends_on(&self) -> &[&'static str] {
370        &[]
371    }
372    /// Whether this service should be activated given the current config.
373    fn is_required(&self, ctx: &StartupContext) -> bool {
374        let _ = ctx;
375        true
376    }
377    /// Whether this is a global plugin (loaded before user apps).
378    fn is_plugin(&self) -> bool {
379        false
380    }
381    /// Whether registration failure should abort startup.
382    fn is_critical(&self) -> bool {
383        false
384    }
385    /// Embedded `Cargo.toml` content. Discovery extracts
386    /// `[package.metadata.app]` (and any plugin metadata blocks) via
387    /// `yeti_sdk_host::application::cargo_manifest`.
388    fn config_toml(&self) -> Option<&'static str> {
389        None
390    }
391    /// GraphQL schema strings. Called once during discovery, before backends exist.
392    /// Filesystem apps return empty — their schemas come from schema.graphql files.
393    fn schemas(&self) -> Vec<&'static str> {
394        vec![]
395    }
396    /// Register resources, hooks, web files, and providers.
397    /// Called once during loading, after backends exist.
398    fn resources(&self, ctx: &mut RegistrationContext) -> crate::error::Result<()> {
399        let _ = ctx;
400        Ok(())
401    }
402
403    /// Register in-process hook services (ADR-009).
404    ///
405    /// Called by the app loader between [`Self::resources`] and
406    /// [`Self::on_ready`]. Static plugins that want to install
407    /// services into another static plugin's hook chain (e.g.
408    /// yeti-auth's OAuth/Token chains, yeti-mcp's tool/resource
409    /// chains) call the per-domain hook surface directly here (e.g.
410    /// `yeti_mcp::McpHooks::register_list_tools_service`), once per
411    /// chain they want to extend.
412    ///
413    /// Slotted before `on_ready` so registrations are visible by the
414    /// time any request can hit a dispatcher that reads the chain.
415    /// Default-empty so existing plugins compile unchanged.
416    fn register_hooks(&self) -> crate::error::Result<()> {
417        Ok(())
418    }
419
420    /// Post-registration setup (bootstrap data, background tasks, etc.).
421    ///
422    /// Receives a `Context` with `request`-less defaults (no method/headers/body).
423    /// The runtime populates `backend_manager`, `root_directory`, `app_id`,
424    /// and `pubsub_lookup` before calling.
425    fn on_ready(&self, ctx: &crate::resource::Context) -> crate::error::Result<()> {
426        let _ = ctx;
427        Ok(())
428    }
429
430    /// Build and return the service's telemetry-subscriber Service, if any.
431    ///
432    /// Per ADR-006 this returns a `TelemetryService` (Tower
433    /// `Service<TelemetryEvent>`) instead of the legacy
434    /// `Box<dyn EventSubscriber>`. The runtime creates an mpsc
435    /// channel, registers the sender globally so any code can
436    /// `event_sender().send(event)` to fire an event, and drives a
437    /// per-event call loop on the host tokio runtime:
438    ///
439    /// ```ignore
440    /// while let Some(ev) = rx.recv().await {
441    ///     if let Ok(svc) = service.ready().await {
442    ///         let _ = svc.call(ev).await;
443    ///     }
444    /// }
445    /// ```
446    ///
447    /// Plugin authors stack `tower::ServiceBuilder` layers
448    /// (timeout, trace, concurrency-limit) onto the Service before
449    /// returning it.
450    ///
451    /// Default: no subscriber.
452    fn install_event_subscriber(
453        &self,
454        ctx: &crate::resource::Context,
455    ) -> crate::error::Result<Option<crate::plugins::lifecycle::TelemetryService>> {
456        let _ = ctx;
457        Ok(None)
458    }
459
460    /// Called during graceful shutdown.
461    fn on_shutdown(&self) {}
462}
463
464/// Ordered list of service instances.
465pub type PluginRegistry = Vec<Box<dyn Plugin>>;
466
467// ============================================================================
468// StartupContext
469// ============================================================================
470
471/// Read-only context passed to `Plugin::is_required()`.
472#[derive(Debug)]
473pub struct StartupContext<'a> {
474    /// All discovered table definitions across all apps.
475    pub tables: &'a [crate::schema::TableDefinition],
476    /// All discovered application configs (as raw JSON for introspection).
477    pub app_configs: &'a [serde_json::Value],
478    /// Whether the MQTT interface is enabled (`mqtt.enabled` in
479    /// yeti-config.yaml). The broker requires the auth backend, so a plugin
480    /// that provides auth declares itself required when this is set — even when
481    /// no app otherwise needs auth.
482    pub mqtt_enabled: bool,
483}
484
485impl StartupContext<'_> {
486    /// Check if any application has a given top-level config key (e.g., "auth", "kafka").
487    #[must_use]
488    pub fn any_app_has_config(&self, key: &str) -> bool {
489        self.app_configs
490            .iter()
491            .any(|c| c.as_object().is_some_and(|obj| obj.contains_key(key)))
492    }
493
494    /// Check if any table has a field whose type matches the given name (case-insensitive).
495    #[must_use]
496    pub fn any_table_has_field_directive(&self, directive: &str) -> bool {
497        self.tables.iter().any(|t| {
498            t.fields
499                .iter()
500                .any(|f| f.field_type.eq_ignore_ascii_case(directive))
501        })
502    }
503
504    /// Check if any application requires authentication (has non-empty `required_roles`).
505    #[must_use]
506    pub fn any_app_requires_auth(&self) -> bool {
507        self.app_configs.iter().any(|c| {
508            c.get("requiredRoles")
509                .or_else(|| c.get("required_roles"))
510                .and_then(|v| v.as_array())
511                .is_some_and(|arr| !arr.is_empty())
512        })
513    }
514}
515
516// ============================================================================
517// RegistrationContext
518// ============================================================================
519
520/// Write context passed to `Plugin::register()`.
521pub struct RegistrationContext {
522    /// GraphQL schema strings
523    pub schemas: Vec<String>,
524    /// Seed data JSON strings
525    pub seed_data: Vec<String>,
526    /// Resource handlers
527    pub resources: Vec<crate::resource::ResourceEntry>,
528    /// Embedded web files for static serving
529    pub web_files: Vec<EmbeddedFile>,
530    /// Authentication providers
531    pub auth_providers: Vec<Arc<dyn crate::auth::AuthProvider>>,
532    // Per ADR-006: `auth_hooks` (legacy `AuthHook` trait objects) is deleted.
533    // yeti-auth reintroduces a plugin-side registry of
534    // `Service<ResolveRequest>` impls (see `plugins::auth`).
535    /// Vector embedding hooks
536    pub vector_hooks: Vec<Arc<dyn VectorHook>>,
537    /// AI inference hooks
538    pub ai_hooks: Vec<Arc<dyn AiHook>>,
539    /// Computed field hooks
540    pub computed_field_hooks: Vec<Arc<dyn ComputedFieldHook>>,
541    /// Pipeline services — Tower-native replacement for the legacy
542    /// `RequestMiddleware` Vec. Each entry is a
543    /// `BoxCloneService<Context, Context, YetiError>` (see
544    /// `plugins::request::ContextService`); the router runs them in
545    /// registration order via `RequestPipeline::run`.
546    pub request_services: Vec<crate::plugins::request::ContextService>,
547    /// Table subscribers (pubsub safe across the WIT boundary via host-created mpsc bridge)
548    pub table_subscribers: Vec<Box<dyn TableSubscriber>>,
549    /// Background tasks to spawn after registration
550    pub background_tasks: Vec<Pin<Box<dyn Future<Output = ()> + Send>>>,
551    /// Application ID
552    pub app_id: String,
553    /// Root directory
554    pub root_directory: String,
555}
556
557impl std::fmt::Debug for RegistrationContext {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        f.debug_struct("RegistrationContext")
560            .field("app_id", &self.app_id)
561            .field("root_directory", &self.root_directory)
562            .field("schemas", &self.schemas.len())
563            .field("seed_data", &self.seed_data.len())
564            .field("resources", &self.resources.len())
565            .field("web_files", &self.web_files.len())
566            .field("auth_providers", &self.auth_providers.len())
567            .field("vector_hooks", &self.vector_hooks.len())
568            .field("ai_hooks", &self.ai_hooks.len())
569            .field("computed_field_hooks", &self.computed_field_hooks.len())
570            .field("request_services", &self.request_services.len())
571            .field("table_subscribers", &self.table_subscribers.len())
572            .field("background_tasks", &self.background_tasks.len())
573            .finish_non_exhaustive()
574    }
575}
576
577impl RegistrationContext {
578    /// Create a new empty registration context.
579    #[must_use]
580    pub fn new(app_id: String, root_directory: String) -> Self {
581        Self {
582            schemas: Vec::new(),
583            seed_data: Vec::new(),
584            resources: Vec::new(),
585            web_files: Vec::new(),
586            auth_providers: Vec::new(),
587            vector_hooks: Vec::new(),
588            ai_hooks: Vec::new(),
589            computed_field_hooks: Vec::new(),
590            request_services: Vec::new(),
591            table_subscribers: Vec::new(),
592            background_tasks: Vec::new(),
593            app_id,
594            root_directory,
595        }
596    }
597
598    /// Add a GraphQL schema string.
599    pub fn add_schema(&mut self, graphql: &str) {
600        self.schemas.push(graphql.to_owned());
601    }
602    /// Add a resource handler.
603    pub fn add_resource(&mut self, entry: crate::resource::ResourceEntry) {
604        self.resources.push(entry);
605    }
606    /// Add embedded web files.
607    pub fn add_web_files(&mut self, files: Vec<EmbeddedFile>) {
608        self.web_files.extend(files);
609    }
610    /// Add an authentication provider.
611    pub fn add_auth_provider(&mut self, p: Arc<dyn crate::auth::AuthProvider>) {
612        self.auth_providers.push(p);
613    }
614    /// Add a vector embedding hook.
615    pub fn add_vector_hook(&mut self, h: Arc<dyn VectorHook>) {
616        self.vector_hooks.push(h);
617    }
618    /// Add an AI inference hook.
619    pub fn add_ai_hook(&mut self, h: Arc<dyn AiHook>) {
620        self.ai_hooks.push(h);
621    }
622    /// Register a request-pipeline service. The service runs as part of
623    /// the per-request middleware chain built by yeti-router. Plugins
624    /// produce a `ContextService` via `tower::service_fn` (or by
625    /// implementing `tower::Service<Context>` directly) and call
626    /// `BoxCloneService::new(svc)` before passing it here.
627    pub fn add_request_service(&mut self, svc: crate::plugins::request::ContextService) {
628        self.request_services.push(svc);
629    }
630    /// Add a table subscriber. The runtime will subscribe to the named
631    /// table's pubsub stream on the host tokio runtime and forward each
632    /// message to the subscriber's `run()` loop via a host-allocated
633    /// mpsc channel. Safe to call from wasm applications.
634    pub fn add_table_subscriber(&mut self, s: Box<dyn TableSubscriber>) {
635        self.table_subscribers.push(s);
636    }
637    /// Get the root directory path.
638    #[must_use]
639    pub fn root_dir(&self) -> &str {
640        &self.root_directory
641    }
642    /// Get the application ID.
643    #[must_use]
644    pub fn app_id(&self) -> &str {
645        &self.app_id
646    }
647}
648
649// now receives `&crate::resource::Context` directly. Non-request helpers
650// (`root_dir`, `pubsub`, `backend`) live on Context.