Skip to main content

cymule_runtime/
plugin.rs

1use std::collections::BTreeMap;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5
6use cymule_core::{ReconciliationResolution, WorldOutcome};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::{RuntimeError, RuntimeResult};
11
12/// Process plugin protocol version.
13pub const PLUGIN_VERSION: &str = "cymule.plugin/1";
14
15/// One abstract component operation advertised by a plugin.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct PluginOperation {
19    /// Stable implementation-specific revision.
20    pub implementation_revision: String,
21}
22
23/// One abstract effect implementation advertised by a plugin.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct PluginEffect {
27    /// Stable implementation-specific revision.
28    pub implementation_revision: String,
29    /// Whether the adapter can authoritatively reconcile ambiguity.
30    pub can_reconcile: bool,
31}
32
33/// Plugin capability advertisement. It does not grant authority.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct PluginManifest {
37    /// Protocol version.
38    pub plugin_version: String,
39    /// Immutable implementation identity used in occurrence bindings.
40    pub implementation_id: String,
41    /// Component implementations.
42    #[serde(default)]
43    pub components: BTreeMap<String, PluginOperation>,
44    /// Effect implementations.
45    #[serde(default)]
46    pub effects: BTreeMap<String, PluginEffect>,
47}
48
49/// Versioned process-plugin request.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum PluginRequest {
53    /// Describe supported abstract operations.
54    Describe,
55    /// Execute one component occurrence.
56    Call {
57        /// Abstract component operation.
58        component: String,
59        /// Typed input.
60        input: Value,
61    },
62    /// Prepare an effect without claiming external application.
63    PrepareEffect {
64        /// Abstract effect operation.
65        operation: String,
66        /// Structural intent identity.
67        intent_id: String,
68        /// Typed input.
69        input: Value,
70    },
71    /// Dispatch an authorized effect occurrence.
72    DispatchEffect {
73        /// Abstract effect operation.
74        operation: String,
75        /// Structural intent identity and provider idempotency source.
76        intent_id: String,
77        /// Typed input.
78        input: Value,
79    },
80    /// Reconcile an unknown effect using the same occurrence binding.
81    ReconcileEffect {
82        /// Abstract effect operation.
83        operation: String,
84        /// Original structural intent identity.
85        intent_id: String,
86        /// Original typed input.
87        input: Value,
88    },
89}
90
91/// Versioned process-plugin response.
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93#[serde(tag = "type", rename_all = "snake_case")]
94pub enum PluginResponse {
95    /// Capability advertisement.
96    Manifest {
97        /// Manifest.
98        manifest: PluginManifest,
99    },
100    /// Component result.
101    CallResult {
102        /// Typed output.
103        value: Value,
104    },
105    /// Preparation succeeded.
106    Prepared,
107    /// Dispatch produced an observed outcome.
108    EffectResult {
109        /// External-world observation.
110        outcome: WorldOutcome,
111        /// Optional typed operation result.
112        #[serde(default)]
113        value: Option<Value>,
114    },
115    /// Reconciliation produced a typed resolution.
116    ReconciliationResult {
117        /// Resolution.
118        resolution: ReconciliationResolution,
119        /// Optional typed operation result.
120        #[serde(default)]
121        value: Option<Value>,
122    },
123    /// Structured adapter error.
124    Error {
125        /// Stable adapter code.
126        code: String,
127        /// Human-readable summary.
128        message: String,
129    },
130}
131
132/// Abstract plugin transport.
133pub trait PluginHost {
134    /// Invoke one typed plugin request.
135    fn invoke(&mut self, request: PluginRequest) -> RuntimeResult<PluginResponse>;
136
137    /// Fetch and validate the plugin manifest.
138    fn describe(&mut self) -> RuntimeResult<PluginManifest> {
139        match self.invoke(PluginRequest::Describe)? {
140            PluginResponse::Manifest { manifest } => {
141                if manifest.plugin_version != PLUGIN_VERSION {
142                    return Err(RuntimeError::Plugin(format!(
143                        "unsupported plugin version {:?}",
144                        manifest.plugin_version
145                    )));
146                }
147                if manifest.implementation_id.is_empty() {
148                    return Err(RuntimeError::Plugin(
149                        "plugin implementation_id is empty".to_owned(),
150                    ));
151                }
152                Ok(manifest)
153            }
154            response => Err(RuntimeError::Plugin(format!(
155                "describe returned unexpected response {response:?}"
156            ))),
157        }
158    }
159}
160
161/// One-request-per-process plugin transport used by the Embedded profile.
162#[derive(Debug, Clone)]
163pub struct ProcessPlugin {
164    executable: PathBuf,
165}
166
167impl ProcessPlugin {
168    /// Create a process plugin transport.
169    pub fn new(executable: impl AsRef<Path>) -> Self {
170        Self {
171            executable: executable.as_ref().to_path_buf(),
172        }
173    }
174}
175
176impl PluginHost for ProcessPlugin {
177    fn invoke(&mut self, request: PluginRequest) -> RuntimeResult<PluginResponse> {
178        let mut child = Command::new(&self.executable)
179            .stdin(Stdio::piped())
180            .stdout(Stdio::piped())
181            .stderr(Stdio::piped())
182            .spawn()
183            .map_err(|error| {
184                RuntimeError::Io(format!(
185                    "failed to start plugin {}: {error}",
186                    self.executable.display()
187                ))
188            })?;
189        let input = serde_json::to_vec(&request)?;
190        child
191            .stdin
192            .take()
193            .ok_or_else(|| RuntimeError::Io("plugin stdin was not captured".to_owned()))?
194            .write_all(&input)?;
195        let output = child.wait_with_output()?;
196        if !output.status.success() {
197            return Err(RuntimeError::Plugin(format!(
198                "plugin {} exited with {}: {}",
199                self.executable.display(),
200                output.status,
201                String::from_utf8_lossy(&output.stderr).trim()
202            )));
203        }
204        let response: PluginResponse = serde_json::from_slice(&output.stdout)?;
205        if let PluginResponse::Error { code, message } = &response {
206            return Err(RuntimeError::Plugin(format!("{code}: {message}")));
207        }
208        Ok(response)
209    }
210}