Skip to main content

synaps_cli/extensions/runtime/
mod.rs

1//! Extension runtime trait and registry.
2
3pub mod process;
4pub mod restart;
5
6pub use restart::RestartPolicy;
7
8use async_trait::async_trait;
9use serde_json::Value;
10use crate::extensions::hooks::events::{HookEvent, HookResult};
11use self::process::{ProviderCompleteParams, ProviderCompleteResult, ProviderStreamEvent};
12use crate::extensions::info::PluginInfo;
13use crate::extensions::commands::CommandOutputEvent;
14use crate::extensions::tasks::TaskEvent;
15
16/// Streamed event delivered to a `command.invoke` caller.
17#[derive(Debug, Clone, PartialEq)]
18pub enum InvokeCommandEvent {
19    /// Command output event matching the caller's `request_id`.
20    Output(CommandOutputEvent),
21    /// Spontaneous plugin task event (any `request_id`).
22    Task(TaskEvent),
23}
24
25/// Health state for an extension handler.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ExtensionHealth {
28    /// Manifest validated, process spawned, but `initialize` not yet completed.
29    Loaded,
30    /// Manifest failed validation — the extension never started.
31    FailedValidation,
32    /// `initialize` rpc failed — the extension started but couldn't capability-handshake.
33    FailedInitialize,
34    /// Healthy and serving requests.
35    Running,
36    /// Process restarting after transport failure but within restart budget.
37    Restarting,
38    /// Running, but at least one recent operation failed (e.g. hook timeout).
39    Degraded,
40    /// Permanent failure — restart budget exhausted or unrecoverable error.
41    Failed,
42}
43
44impl ExtensionHealth {
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::Loaded => "loaded",
48            Self::FailedValidation => "failed_validation",
49            Self::FailedInitialize => "failed_initialize",
50            Self::Running => "running",
51            Self::Restarting => "restarting",
52            Self::Degraded => "degraded",
53            Self::Failed => "failed",
54        }
55    }
56}
57
58#[cfg(test)]
59mod health_tests {
60    use super::ExtensionHealth;
61
62    #[test]
63    fn as_str_covers_all_variants() {
64        assert_eq!(ExtensionHealth::Loaded.as_str(), "loaded");
65        assert_eq!(ExtensionHealth::FailedValidation.as_str(), "failed_validation");
66        assert_eq!(ExtensionHealth::FailedInitialize.as_str(), "failed_initialize");
67        assert_eq!(ExtensionHealth::Running.as_str(), "running");
68        assert_eq!(ExtensionHealth::Restarting.as_str(), "restarting");
69        assert_eq!(ExtensionHealth::Degraded.as_str(), "degraded");
70        assert_eq!(ExtensionHealth::Failed.as_str(), "failed");
71    }
72}
73
74/// Trait for extension runtimes that can handle hook events.
75#[async_trait]
76pub trait ExtensionHandler: Send + Sync {
77    /// Unique identifier for this extension.
78    fn id(&self) -> &str;
79
80    /// Handle a hook event. Returns the handler's decision.
81    async fn handle(&self, event: &HookEvent) -> HookResult;
82
83    /// Call an extension-provided tool.
84    async fn call_tool(&self, _name: &str, _input: Value) -> Result<Value, String> {
85        Err("extension runtime does not support tool.call".to_string())
86    }
87
88    /// Complete a chat request through an extension-provided model provider.
89    async fn provider_complete(&self, _params: ProviderCompleteParams) -> Result<ProviderCompleteResult, String> {
90        Err("extension runtime does not support provider.complete".to_string())
91    }
92
93    /// Stream a chat request through an extension-provided model provider.
94    ///
95    /// The handler must forward `provider.stream.event` notifications to `sink`
96    /// in order. The returned `ProviderCompleteResult` is the final aggregated
97    /// response (so callers that don't need streaming can use it as the final
98    /// state). Implementations that don't support streaming should return
99    /// `Err("provider.stream is not supported by this extension")`.
100    async fn provider_stream(
101        &self,
102        _params: ProviderCompleteParams,
103        _sink: tokio::sync::mpsc::UnboundedSender<ProviderStreamEvent>,
104    ) -> Result<ProviderCompleteResult, String> {
105        Err("provider.stream is not supported by this extension".to_string())
106    }
107
108    /// Invoke a plugin-registered interactive slash command. The handler must
109    /// forward `command.output` notifications matching `request_id` and any
110    /// `task.*` notifications to `sink`. Returns the final response value.
111    async fn invoke_command(
112        &self,
113        _command: &str,
114        _args: Vec<String>,
115        _request_id: &str,
116        _sink: tokio::sync::mpsc::UnboundedSender<InvokeCommandEvent>,
117    ) -> Result<Value, String> {
118        Err("extension runtime does not support command.invoke".to_string())
119    }
120
121    /// Fetch optional plugin capability/build/model information.
122    async fn get_info(&self) -> Result<PluginInfo, String> {
123        Err("extension runtime does not support info.get".to_string())
124    }
125
126    /// Ask the plugin to supply sidecar spawn arguments. Used by the
127    /// modality-neutral sidecar bootstrap path (see
128    /// `crate::sidecar::spawn`); plugins that don't host a sidecar
129    /// should leave the default `Err` in place. Core treats the
130    /// returned [`SidecarSpawnArgs::args`] as opaque.
131    async fn sidecar_spawn_args(&self) -> Result<crate::sidecar::spawn::SidecarSpawnArgs, String> {
132        Err("extension runtime does not support sidecar.spawn_args".to_string())
133    }
134
135    /// Open a plugin-owned custom settings editor and return its initial render payload.
136    async fn settings_editor_open(&self, _category: &str, _field: &str) -> Result<Value, String> {
137        Err("extension runtime does not support settings.editor.open".to_string())
138    }
139
140    /// Forward a keypress to the active plugin-owned custom settings editor.
141    async fn settings_editor_key(&self, _category: &str, _field: &str, _key: &str) -> Result<Value, String> {
142        Err("extension runtime does not support settings.editor.key".to_string())
143    }
144
145    /// Ask the plugin to commit a custom editor value selected by the UI.
146    async fn settings_editor_commit(&self, _category: &str, _field: &str, _value: Value) -> Result<Value, String> {
147        Err("extension runtime does not support settings.editor.commit".to_string())
148    }
149
150    /// Gracefully shut down the extension.
151    async fn shutdown(&self);
152
153    /// Current health state of this handler.
154    async fn health(&self) -> ExtensionHealth {
155        ExtensionHealth::Running
156    }
157
158    /// Number of transport restarts observed by this handler.
159    async fn restart_count(&self) -> usize {
160        0
161    }
162}