Skip to main content

exomonad_core/
plugin_manager.rs

1//! WASM plugin hosting with single yield_effect host function.
2//!
3//! All effects flow through one entry point: `yield_effect`. The WASM guest
4//! sends an `EffectEnvelope` (protobuf) and receives an `EffectResponse`.
5//! The host dispatches to the appropriate handler via `EffectRegistry`.
6
7use crate::effects::{host_fn::yield_effect_host_fn, host_fn::YieldEffectContext, EffectRegistry};
8use anyhow::{Context, Result};
9use extism::{Manifest, Plugin, PluginBuilder};
10use serde::{Deserialize, Serialize};
11use std::sync::{Arc, RwLock};
12
13/// Manages the lifecycle of a Haskell WASM plugin.
14///
15/// Loads a WASM module from embedded bytes and registers a single host function
16/// (`yield_effect`) that dispatches all effects through the [`EffectRegistry`].
17///
18/// # Architecture
19///
20/// ```text
21/// PluginManager::call("handle_mcp_call", input)
22///     ↓
23/// WASM Guest (Haskell) - pure logic, yields effects
24///     ↓
25/// yield_effect host function → EffectRegistry::dispatch
26///     ↓
27/// EffectHandler implementations (git, github, custom, ...)
28/// ```
29#[derive(Clone)]
30pub struct PluginManager {
31    /// The underlying Extism plugin instance.
32    plugin: Arc<RwLock<Plugin>>,
33    content_hash: String,
34}
35
36impl PluginManager {
37    /// Load a WASM plugin from bytes and register the yield_effect host function.
38    ///
39    /// # Arguments
40    ///
41    /// * `wasm_bytes` - WASM binary content (embedded at compile time)
42    /// * `registry` - Effect registry for dispatching all effects
43    pub async fn new(wasm_bytes: &[u8], registry: Arc<EffectRegistry>) -> Result<Self> {
44        let hash = sha256_short(wasm_bytes);
45        tracing::info!(size = wasm_bytes.len(), hash = %hash, "Loading embedded WASM plugin");
46
47        let manifest = Manifest::new([extism::Wasm::data(wasm_bytes.to_vec())]);
48
49        // Single host function: yield_effect dispatches ALL effects via registry
50        tracing::info!(
51            namespaces = ?registry.namespaces(),
52            "Registering yield_effect with {} handler namespaces",
53            registry.namespaces().len()
54        );
55
56        let ctx = YieldEffectContext { registry };
57        let functions = vec![yield_effect_host_fn(ctx)];
58
59        let plugin = PluginBuilder::new(manifest)
60            .with_functions(functions)
61            .with_wasi(true)
62            .build()
63            .context("Failed to create plugin")?;
64
65        Ok(Self {
66            plugin: Arc::new(RwLock::new(plugin)),
67            content_hash: hash,
68        })
69    }
70
71    /// Get the SHA256 content hash of the loaded WASM binary (first 12 hex chars).
72    pub fn content_hash(&self) -> &str {
73        &self.content_hash
74    }
75
76    /// Call a WASM guest function with typed input/output marshalling.
77    ///
78    /// Input is serialized to JSON, passed to the WASM function, and the
79    /// result is deserialized from JSON.
80    ///
81    /// Uses spawn_blocking because Extism Plugin is not Send.
82    pub async fn call<I, O>(&self, function: &str, input: &I) -> Result<O>
83    where
84        I: Serialize + Send + Sync + 'static,
85        O: for<'de> Deserialize<'de> + Send + 'static,
86    {
87        let plugin_lock = self.plugin.clone();
88        let function_name = function.to_string();
89        let input_data = serde_json::to_vec(input)?;
90
91        let result_bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
92            let mut plugin = plugin_lock
93                .write()
94                .map_err(|e| anyhow::anyhow!("Plugin lock poisoned: {}", e))?;
95            plugin.call::<&[u8], Vec<u8>>(&function_name, &input_data)
96        })
97        .await??;
98
99        if result_bytes.is_empty() {
100            let null: O = serde_json::from_str("null")?;
101            return Ok(null);
102        }
103
104        let output: O = serde_json::from_slice(&result_bytes)?;
105        Ok(output)
106    }
107}
108
109fn sha256_short(data: &[u8]) -> String {
110    use sha2::{Digest, Sha256};
111    format!("{:x}", Sha256::digest(data))[..12].to_string()
112}