Skip to main content

strixonomy_plugin/
host.rs

1use crate::discovery::{discover_plugins, PluginDiscoveryError};
2use crate::lifecycle::{activation_order, dependents_of, LifecycleError};
3use crate::manifest::{
4    DiscoveredPlugin, PluginActivation, PluginKind, PluginLifecycleState, PluginManifest,
5    PluginPermission,
6};
7use crate::protocol::{jail_output_paths, plugin_diagnostic, PluginOutput};
8use crate::subprocess::{run_plugin_subprocess, SubprocessError, SubprocessRequest};
9use crate::traits::{
10    ExporterPlugin, GraphPlugin, GraphProviderResult, QueryPlugin, QueryProviderResult,
11    ReasonerPlugin, ReasonerProviderResult, RefactorPlugin, RefactorProviderResult,
12    ValidatorPlugin, WorkflowPlugin, WorkflowRequest,
13};
14use serde::Serialize;
15use std::collections::{HashMap, HashSet};
16use std::path::{Path, PathBuf};
17use strixonomy_catalog::OntologyCatalog;
18use strixonomy_core::{validate_workspace_scope, Diagnostic, DiagnosticSeverity};
19use strixonomy_docs::ExportOptions;
20use thiserror::Error;
21
22/// Default in-process export directory (relative to the workspace root).
23const DEFAULT_PLUGIN_EXPORT_DIR: &str = ".strixonomy/plugin-out";
24/// Persisted user-disabled plugin ids (workspace-relative).
25const DISABLED_STATE_FILE: &str = ".strixonomy/plugin-disabled.json";
26
27#[derive(Debug, Error)]
28pub enum PluginHostError {
29    #[error(transparent)]
30    Discovery(#[from] PluginDiscoveryError),
31    #[error(transparent)]
32    Subprocess(#[from] SubprocessError),
33    #[error(transparent)]
34    Lifecycle(#[from] LifecycleError),
35    #[error("plugin not found: {0}")]
36    NotFound(String),
37    #[error("plugin is disabled: {0}")]
38    Disabled(String),
39    #[error("plugin is not active: {0}")]
40    NotActive(String),
41    #[error("plugin {0} is missing required permission: {1}")]
42    MissingPermission(String, String),
43    #[error("plugin {0} does not support action {1}")]
44    UnsupportedAction(String, String),
45    #[error("export failed: {0}")]
46    Export(String),
47}
48
49#[derive(Debug, Clone, Serialize)]
50pub struct PluginDescriptor {
51    pub id: String,
52    pub name: String,
53    pub version: String,
54    pub kind: String,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub api_version: Option<String>,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub permissions: Vec<PluginPermission>,
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub depends_on: Vec<String>,
61    pub activation: String,
62    pub state: String,
63    pub capabilities: crate::manifest::PluginCapabilities,
64    pub manifest_path: String,
65    pub ui: crate::manifest::PluginUiContributions,
66    pub in_process: bool,
67    /// True when user-disabled or inactive (not Active).
68    #[serde(default)]
69    pub disabled: bool,
70    #[serde(default)]
71    pub enabled: bool,
72}
73
74#[derive(Debug, Clone, Serialize)]
75pub struct RunPluginResult {
76    pub diagnostics: Vec<Diagnostic>,
77    pub output_paths: Vec<String>,
78    pub logs: Option<String>,
79    pub view_html: Option<String>,
80    pub success: bool,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub result: Option<serde_json::Value>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub columns: Option<Vec<String>>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub rows: Option<Vec<Vec<String>>>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub unsatisfiable: Option<Vec<String>>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub affected_iris: Option<Vec<String>>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub root_iris: Option<Vec<String>>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub graph_kind: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub hints: Option<Vec<String>>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub profile: Option<String>,
99}
100
101impl RunPluginResult {
102    fn from_output(plugin_id: &str, workspace: &Path, output: PluginOutput, success: bool) -> Self {
103        let output = jail_output_paths(plugin_id, workspace, output);
104        let has_violations = !output.violations.is_empty();
105        let diags = wire_to_diagnostics(plugin_id, workspace, output.clone());
106        Self {
107            diagnostics: diags,
108            output_paths: output.output_paths,
109            logs: output.logs,
110            view_html: output.view_html,
111            // #348: jail violations must not report silent success.
112            success: success && !has_violations,
113            result: output.result,
114            columns: output.columns,
115            rows: output.rows,
116            unsatisfiable: output.unsatisfiable,
117            affected_iris: output.affected_iris,
118            root_iris: output.root_iris,
119            graph_kind: output.graph_kind,
120            hints: output.hints,
121            profile: output.profile,
122        }
123    }
124
125    fn empty_ok() -> Self {
126        Self {
127            diagnostics: Vec::new(),
128            output_paths: Vec::new(),
129            logs: None,
130            view_html: None,
131            success: true,
132            result: None,
133            columns: None,
134            rows: None,
135            unsatisfiable: None,
136            affected_iris: None,
137            root_iris: None,
138            graph_kind: None,
139            hints: None,
140            profile: None,
141        }
142    }
143}
144
145pub struct PluginHost {
146    workspace: PathBuf,
147    discovered: Vec<DiscoveredPlugin>,
148    /// User-disabled plugin ids (persists across activate_all).
149    disabled: HashSet<String>,
150    states: HashMap<String, PluginLifecycleState>,
151    /// Deterministic activation order from the last activate_all / resolve.
152    activation_events: Vec<String>,
153    validators: HashMap<String, Box<dyn ValidatorPlugin>>,
154    exporters: HashMap<String, Box<dyn ExporterPlugin>>,
155    workflows: HashMap<String, Box<dyn WorkflowPlugin>>,
156    reasoners: HashMap<String, Box<dyn ReasonerPlugin>>,
157    queries: HashMap<String, Box<dyn QueryPlugin>>,
158    refactors: HashMap<String, Box<dyn RefactorPlugin>>,
159    graphs: HashMap<String, Box<dyn GraphPlugin>>,
160    /// In-process runtimes stashed while a plugin is user-disabled (restored on enable).
161    validators_stash: HashMap<String, Box<dyn ValidatorPlugin>>,
162    exporters_stash: HashMap<String, Box<dyn ExporterPlugin>>,
163    workflows_stash: HashMap<String, Box<dyn WorkflowPlugin>>,
164    reasoners_stash: HashMap<String, Box<dyn ReasonerPlugin>>,
165    queries_stash: HashMap<String, Box<dyn QueryPlugin>>,
166    refactors_stash: HashMap<String, Box<dyn RefactorPlugin>>,
167    graphs_stash: HashMap<String, Box<dyn GraphPlugin>>,
168}
169
170impl PluginHost {
171    pub fn new(workspace: impl AsRef<Path>) -> Self {
172        let workspace = workspace.as_ref().to_path_buf();
173        let disabled = load_disabled_ids(&workspace);
174        Self {
175            workspace,
176            discovered: Vec::new(),
177            disabled,
178            states: HashMap::new(),
179            activation_events: Vec::new(),
180            validators: HashMap::new(),
181            exporters: HashMap::new(),
182            workflows: HashMap::new(),
183            reasoners: HashMap::new(),
184            queries: HashMap::new(),
185            refactors: HashMap::new(),
186            graphs: HashMap::new(),
187            validators_stash: HashMap::new(),
188            exporters_stash: HashMap::new(),
189            workflows_stash: HashMap::new(),
190            reasoners_stash: HashMap::new(),
191            queries_stash: HashMap::new(),
192            refactors_stash: HashMap::new(),
193            graphs_stash: HashMap::new(),
194        }
195    }
196
197    pub fn discover(&mut self) -> Result<(), PluginHostError> {
198        self.discovered = discover_plugins(&self.workspace)?;
199        for p in &self.discovered {
200            let id = p.plugin_id().to_string();
201            self.states.entry(id.clone()).or_insert(PluginLifecycleState::Discovered);
202            if p.manifest.kind.is_hosted() {
203                *self.states.get_mut(&id).unwrap() = PluginLifecycleState::Validated;
204            }
205        }
206        // Resolve dependency graph early so missing deps surface at discover time.
207        let _ = activation_order(&self.discovered)?;
208        for p in &self.discovered {
209            let id = p.plugin_id().to_string();
210            if self.disabled.contains(&id) {
211                *self.states.get_mut(&id).unwrap() = PluginLifecycleState::Disabled;
212            } else if p.manifest.kind.is_hosted() {
213                *self.states.get_mut(&id).unwrap() = PluginLifecycleState::Registered;
214            }
215        }
216        Ok(())
217    }
218
219    /// Activate plugins with `on_startup` / `on_workspace_open` activation in dependency order.
220    pub fn activate_all(&mut self) -> Result<Vec<String>, PluginHostError> {
221        let order = activation_order(&self.discovered)?;
222        self.activation_events.clear();
223        for id in &order {
224            let plugin = self.find_plugin(id)?;
225            if self.disabled.contains(id) {
226                *self.states.entry(id.clone()).or_insert(PluginLifecycleState::Disabled) =
227                    PluginLifecycleState::Disabled;
228                continue;
229            }
230            if !plugin.manifest.kind.is_hosted() {
231                continue;
232            }
233            match plugin.manifest.activation {
234                PluginActivation::OnStartup | PluginActivation::OnWorkspaceOpen => {
235                    self.activate(id)?;
236                }
237                PluginActivation::OnCommand => {
238                    *self.states.entry(id.clone()).or_insert(PluginLifecycleState::Registered) =
239                        PluginLifecycleState::Registered;
240                }
241            }
242        }
243        Ok(self.activation_events.clone())
244    }
245
246    pub fn activate(&mut self, plugin_id: &str) -> Result<(), PluginHostError> {
247        if self.disabled.contains(plugin_id) {
248            return Err(PluginHostError::Disabled(plugin_id.to_string()));
249        }
250        let plugin = self.find_plugin(plugin_id)?.clone();
251        if !plugin.manifest.kind.is_hosted() {
252            return Err(PluginHostError::UnsupportedAction(
253                plugin_id.to_string(),
254                "activate (reserved kind)".into(),
255            ));
256        }
257        for dep in &plugin.manifest.depends_on {
258            if self.disabled.contains(dep) {
259                return Err(PluginHostError::Lifecycle(LifecycleError::MissingDependency(
260                    plugin_id.to_string(),
261                    format!("{dep} (disabled)"),
262                )));
263            }
264            if self.state_of(dep) != PluginLifecycleState::Active {
265                self.activate(dep)?;
266            }
267        }
268        *self.states.entry(plugin_id.to_string()).or_insert(PluginLifecycleState::Active) =
269            PluginLifecycleState::Active;
270        if !self.activation_events.iter().any(|e| e == plugin_id) {
271            self.activation_events.push(plugin_id.to_string());
272        }
273        Ok(())
274    }
275
276    pub fn deactivate(&mut self, plugin_id: &str) -> Result<(), PluginHostError> {
277        let _ = self.find_plugin(plugin_id)?;
278        for dep in dependents_of(&self.discovered, plugin_id) {
279            if self.state_of(&dep) == PluginLifecycleState::Active {
280                self.deactivate(&dep)?;
281            }
282        }
283        *self.states.entry(plugin_id.to_string()).or_insert(PluginLifecycleState::Registered) =
284            PluginLifecycleState::Registered;
285        self.activation_events.retain(|e| e != plugin_id);
286        Ok(())
287    }
288
289    pub fn state_of(&self, plugin_id: &str) -> PluginLifecycleState {
290        self.states.get(plugin_id).copied().unwrap_or(PluginLifecycleState::Discovered)
291    }
292
293    pub fn activation_events(&self) -> &[String] {
294        &self.activation_events
295    }
296
297    pub fn register_validator(&mut self, plugin: Box<dyn ValidatorPlugin>) {
298        self.validators.insert(plugin.id().to_string(), plugin);
299    }
300
301    pub fn register_exporter(&mut self, plugin: Box<dyn ExporterPlugin>) {
302        self.exporters.insert(plugin.id().to_string(), plugin);
303    }
304
305    pub fn register_workflow(&mut self, plugin: Box<dyn WorkflowPlugin>) {
306        self.workflows.insert(plugin.id().to_string(), plugin);
307    }
308
309    pub fn register_reasoner(&mut self, plugin: Box<dyn ReasonerPlugin>) {
310        self.reasoners.insert(plugin.id().to_string(), plugin);
311    }
312
313    pub fn register_query(&mut self, plugin: Box<dyn QueryPlugin>) {
314        self.queries.insert(plugin.id().to_string(), plugin);
315    }
316
317    pub fn register_refactor(&mut self, plugin: Box<dyn RefactorPlugin>) {
318        self.refactors.insert(plugin.id().to_string(), plugin);
319    }
320
321    pub fn register_graph(&mut self, plugin: Box<dyn GraphPlugin>) {
322        self.graphs.insert(plugin.id().to_string(), plugin);
323    }
324
325    fn stash_in_process_runtime(&mut self, plugin_id: &str) {
326        if let Some(v) = self.validators.remove(plugin_id) {
327            self.validators_stash.insert(plugin_id.to_string(), v);
328        }
329        if let Some(v) = self.exporters.remove(plugin_id) {
330            self.exporters_stash.insert(plugin_id.to_string(), v);
331        }
332        if let Some(v) = self.workflows.remove(plugin_id) {
333            self.workflows_stash.insert(plugin_id.to_string(), v);
334        }
335        if let Some(v) = self.reasoners.remove(plugin_id) {
336            self.reasoners_stash.insert(plugin_id.to_string(), v);
337        }
338        if let Some(v) = self.queries.remove(plugin_id) {
339            self.queries_stash.insert(plugin_id.to_string(), v);
340        }
341        if let Some(v) = self.refactors.remove(plugin_id) {
342            self.refactors_stash.insert(plugin_id.to_string(), v);
343        }
344        if let Some(v) = self.graphs.remove(plugin_id) {
345            self.graphs_stash.insert(plugin_id.to_string(), v);
346        }
347    }
348
349    fn restore_in_process_runtime(&mut self, plugin_id: &str) {
350        if let Some(v) = self.validators_stash.remove(plugin_id) {
351            self.validators.insert(plugin_id.to_string(), v);
352        }
353        if let Some(v) = self.exporters_stash.remove(plugin_id) {
354            self.exporters.insert(plugin_id.to_string(), v);
355        }
356        if let Some(v) = self.workflows_stash.remove(plugin_id) {
357            self.workflows.insert(plugin_id.to_string(), v);
358        }
359        if let Some(v) = self.reasoners_stash.remove(plugin_id) {
360            self.reasoners.insert(plugin_id.to_string(), v);
361        }
362        if let Some(v) = self.queries_stash.remove(plugin_id) {
363            self.queries.insert(plugin_id.to_string(), v);
364        }
365        if let Some(v) = self.refactors_stash.remove(plugin_id) {
366            self.refactors.insert(plugin_id.to_string(), v);
367        }
368        if let Some(v) = self.graphs_stash.remove(plugin_id) {
369            self.graphs.insert(plugin_id.to_string(), v);
370        }
371    }
372
373    pub fn workspace(&self) -> &Path {
374        &self.workspace
375    }
376
377    pub fn discovered(&self) -> &[DiscoveredPlugin] {
378        &self.discovered
379    }
380
381    pub fn disable_plugin(&mut self, plugin_id: &str) -> Result<(), PluginHostError> {
382        let _ = self.find_plugin(plugin_id)?;
383        let dependents = dependents_of(&self.discovered, plugin_id);
384        for dep in &dependents {
385            if dep != plugin_id {
386                let _ = self.disable_plugin(dep);
387            }
388        }
389        let _ = self.deactivate(plugin_id);
390        self.disabled.insert(plugin_id.to_string());
391        *self.states.entry(plugin_id.to_string()).or_insert(PluginLifecycleState::Disabled) =
392            PluginLifecycleState::Disabled;
393        self.stash_in_process_runtime(plugin_id);
394        persist_disabled_ids(&self.workspace, &self.disabled);
395        Ok(())
396    }
397
398    pub fn enable_plugin(&mut self, plugin_id: &str) -> Result<(), PluginHostError> {
399        let activation = self.find_plugin(plugin_id)?.manifest.activation;
400        self.disabled.remove(plugin_id);
401        persist_disabled_ids(&self.workspace, &self.disabled);
402        self.restore_in_process_runtime(plugin_id);
403        *self.states.entry(plugin_id.to_string()).or_insert(PluginLifecycleState::Registered) =
404            PluginLifecycleState::Registered;
405        if matches!(activation, PluginActivation::OnStartup | PluginActivation::OnWorkspaceOpen) {
406            self.activate(plugin_id)?;
407        }
408        Ok(())
409    }
410
411    pub fn is_disabled(&self, plugin_id: &str) -> bool {
412        self.disabled.contains(plugin_id)
413    }
414
415    pub fn plugin_info(&self, plugin_id: &str) -> Result<PluginDescriptor, PluginHostError> {
416        self.list_plugins()
417            .into_iter()
418            .find(|p| p.id == plugin_id)
419            .ok_or_else(|| PluginHostError::NotFound(plugin_id.to_string()))
420    }
421
422    pub fn list_plugins(&self) -> Vec<PluginDescriptor> {
423        self.discovered
424            .iter()
425            .map(|p| {
426                let id = p.plugin_id().to_string();
427                let state = self.state_of(&id);
428                let disabled = self.is_disabled(&id) || state == PluginLifecycleState::Disabled;
429                PluginDescriptor {
430                    id: id.clone(),
431                    name: p.manifest.name.clone(),
432                    version: p.manifest.version.clone(),
433                    kind: p.manifest.kind.as_str().to_string(),
434                    api_version: p.manifest.api_version.clone(),
435                    permissions: p.manifest.permissions.clone(),
436                    depends_on: p.manifest.depends_on.clone(),
437                    activation: p.manifest.activation.as_str().to_string(),
438                    state: state.as_str().to_string(),
439                    capabilities: p.manifest.capabilities.clone(),
440                    manifest_path: p.manifest_path.display().to_string(),
441                    ui: p.manifest.ui.clone(),
442                    in_process: self.validators.contains_key(&id)
443                        || self.exporters.contains_key(&id)
444                        || self.workflows.contains_key(&id)
445                        || self.reasoners.contains_key(&id)
446                        || self.queries.contains_key(&id)
447                        || self.refactors.contains_key(&id)
448                        || self.graphs.contains_key(&id),
449                    disabled,
450                    enabled: !disabled && state == PluginLifecycleState::Active,
451                }
452            })
453            .collect()
454    }
455
456    fn find_plugin(&self, plugin_id: &str) -> Result<&DiscoveredPlugin, PluginHostError> {
457        self.discovered
458            .iter()
459            .find(|p| p.plugin_id() == plugin_id)
460            .ok_or_else(|| PluginHostError::NotFound(plugin_id.to_string()))
461    }
462
463    fn ensure_runnable(&self, plugin_id: &str) -> Result<(), PluginHostError> {
464        if self.is_disabled(plugin_id) {
465            return Err(PluginHostError::Disabled(plugin_id.to_string()));
466        }
467        let plugin = self.find_plugin(plugin_id)?;
468        // Lazily allow on_command plugins to run without prior activate_all (auto-activate).
469        if self.state_of(plugin_id) != PluginLifecycleState::Active
470            && plugin.manifest.activation == PluginActivation::OnCommand
471        {
472            // Caller must activate — return NotActive so CLI can activate first, or we activate in run_plugin_action.
473            return Ok(());
474        }
475        if self.state_of(plugin_id) != PluginLifecycleState::Active
476            && self.state_of(plugin_id) != PluginLifecycleState::Registered
477        {
478            return Err(PluginHostError::NotActive(plugin_id.to_string()));
479        }
480        Ok(())
481    }
482
483    fn ensure_permission(
484        plugin: &DiscoveredPlugin,
485        plugin_id: &str,
486        required: PluginPermission,
487    ) -> Result<(), PluginHostError> {
488        if plugin.manifest.permissions.contains(&required) {
489            return Ok(());
490        }
491        Err(PluginHostError::MissingPermission(plugin_id.to_string(), required.to_string()))
492    }
493
494    pub fn run_validate_plugin(
495        &self,
496        plugin_id: &str,
497        catalog: &OntologyCatalog,
498    ) -> Result<Vec<Diagnostic>, PluginHostError> {
499        self.ensure_runnable(plugin_id)?;
500        let plugin = self.find_plugin(plugin_id)?;
501        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
502        if !plugin.manifest.capabilities.supports_validation() {
503            return Err(PluginHostError::UnsupportedAction(
504                plugin_id.to_string(),
505                "validate".into(),
506            ));
507        }
508        if let Some(v) = self.validators.get(plugin_id) {
509            return Ok(v.validate(catalog, &self.workspace));
510        }
511        if plugin.manifest.entry.is_some() {
512            Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
513            Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
514            let output = run_plugin_subprocess(
515                plugin,
516                SubprocessRequest {
517                    action: "validate",
518                    workspace: &self.workspace,
519                    step: None,
520                    extra_args: &[],
521                },
522            )?;
523            let output = jail_output_paths(plugin_id, &self.workspace, output);
524            return Ok(wire_to_diagnostics(plugin_id, &self.workspace, output));
525        }
526        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
527    }
528
529    pub fn run_all_validators(&self, catalog: &OntologyCatalog) -> Vec<Diagnostic> {
530        let mut all = Vec::new();
531        for plugin in &self.discovered {
532            if !plugin.manifest.capabilities.supports_validation() {
533                continue;
534            }
535            let id = plugin.plugin_id();
536            if self.is_disabled(id) || self.state_of(id) == PluginLifecycleState::Disabled {
537                continue;
538            }
539            match self.run_validate_plugin(id, catalog) {
540                Ok(mut diags) => all.append(&mut diags),
541                Err(err) => all.push(plugin_diagnostic(
542                    id,
543                    "plugin_error",
544                    DiagnosticSeverity::Error,
545                    err.to_string(),
546                    self.workspace.clone(),
547                    None,
548                )),
549            }
550        }
551        all
552    }
553
554    pub fn run_export_plugin(
555        &self,
556        plugin_id: &str,
557        catalog: &OntologyCatalog,
558        options: ExportOptions,
559    ) -> Result<RunPluginResult, PluginHostError> {
560        self.ensure_runnable(plugin_id)?;
561        let plugin = self.find_plugin(plugin_id)?;
562        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
563        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
564        if !plugin.manifest.capabilities.export {
565            return Err(PluginHostError::UnsupportedAction(plugin_id.to_string(), "export".into()));
566        }
567        let options = jail_export_options(&self.workspace, options)?;
568        if let Some(e) = self.exporters.get(plugin_id) {
569            let paths = e
570                .export(catalog, &self.workspace, options)
571                .map_err(|e| PluginHostError::Export(e.to_string()))?;
572            let mut result = RunPluginResult::empty_ok();
573            result.output_paths = paths.iter().map(|p| p.display().to_string()).collect();
574            return Ok(result);
575        }
576        if plugin.manifest.entry.is_some() {
577            Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
578            let output = run_plugin_subprocess(
579                plugin,
580                SubprocessRequest {
581                    action: "export",
582                    workspace: &self.workspace,
583                    step: None,
584                    extra_args: &[],
585                },
586            )?;
587            return Ok(RunPluginResult::from_output(plugin_id, &self.workspace, output, true));
588        }
589        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
590    }
591
592    pub fn run_workflow_plugin(
593        &self,
594        plugin_id: &str,
595        step: &str,
596    ) -> Result<RunPluginResult, PluginHostError> {
597        self.ensure_runnable(plugin_id)?;
598        let plugin = self.find_plugin(plugin_id)?;
599        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
600        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
601        if plugin.manifest.kind != PluginKind::Workflow && plugin.manifest.kind != PluginKind::Build
602        {
603            return Err(PluginHostError::UnsupportedAction(
604                plugin_id.to_string(),
605                "workflow".into(),
606            ));
607        }
608        if let Some(w) = self.workflows.get(plugin_id) {
609            let result =
610                w.run(&self.workspace, WorkflowRequest { step: step.to_string(), dry_run: false });
611            let mut out = RunPluginResult::empty_ok();
612            out.diagnostics = result.diagnostics;
613            out.logs = Some(result.logs);
614            out.success = result.success;
615            return Ok(out);
616        }
617        if plugin.manifest.entry.is_some() {
618            Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
619            let output = run_plugin_subprocess(
620                plugin,
621                SubprocessRequest {
622                    action: "workflow",
623                    workspace: &self.workspace,
624                    step: Some(step),
625                    extra_args: &[],
626                },
627            )?;
628            let success = output.exit_message.is_none();
629            return Ok(RunPluginResult::from_output(plugin_id, &self.workspace, output, success));
630        }
631        Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")))
632    }
633
634    pub fn run_reasoner_plugin(
635        &self,
636        plugin_id: &str,
637        catalog: &OntologyCatalog,
638    ) -> Result<RunPluginResult, PluginHostError> {
639        self.ensure_runnable(plugin_id)?;
640        let plugin = self.find_plugin(plugin_id)?;
641        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
642        if !plugin.manifest.capabilities.reasoner && plugin.manifest.kind != PluginKind::Reasoner {
643            return Err(PluginHostError::UnsupportedAction(
644                plugin_id.to_string(),
645                "reasoner.classify".into(),
646            ));
647        }
648        if let Some(r) = self.reasoners.get(plugin_id) {
649            let result = r.classify(catalog, &self.workspace);
650            return Ok(provider_reasoner_result(result));
651        }
652        self.run_subprocess_provider(plugin, "reasoner.classify", &[])
653    }
654
655    pub fn run_query_plugin(
656        &self,
657        plugin_id: &str,
658        catalog: &OntologyCatalog,
659        query: &str,
660    ) -> Result<RunPluginResult, PluginHostError> {
661        self.ensure_runnable(plugin_id)?;
662        let plugin = self.find_plugin(plugin_id)?;
663        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
664        if !plugin.manifest.capabilities.query && plugin.manifest.kind != PluginKind::Query {
665            return Err(PluginHostError::UnsupportedAction(
666                plugin_id.to_string(),
667                "query.run".into(),
668            ));
669        }
670        if let Some(q) = self.queries.get(plugin_id) {
671            let result = q.run(catalog, &self.workspace, query);
672            return Ok(provider_query_result(result));
673        }
674        let extra = vec!["--query".to_string(), query.to_string()];
675        self.run_subprocess_provider(plugin, "query.run", &extra)
676    }
677
678    pub fn run_refactor_plugin(
679        &self,
680        plugin_id: &str,
681        catalog: &OntologyCatalog,
682        focus_iri: Option<&str>,
683    ) -> Result<RunPluginResult, PluginHostError> {
684        self.ensure_runnable(plugin_id)?;
685        let plugin = self.find_plugin(plugin_id)?;
686        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
687        if !plugin.manifest.capabilities.refactor && plugin.manifest.kind != PluginKind::Refactor {
688            return Err(PluginHostError::UnsupportedAction(
689                plugin_id.to_string(),
690                "refactor.preview".into(),
691            ));
692        }
693        if let Some(r) = self.refactors.get(plugin_id) {
694            let result = r.preview(catalog, &self.workspace, focus_iri);
695            return Ok(provider_refactor_result(result));
696        }
697        let mut extra = Vec::new();
698        if let Some(iri) = focus_iri {
699            extra.push("--iri".to_string());
700            extra.push(iri.to_string());
701        }
702        self.run_subprocess_provider(plugin, "refactor.preview", &extra)
703    }
704
705    pub fn run_graph_plugin(
706        &self,
707        plugin_id: &str,
708        catalog: &OntologyCatalog,
709        root_iri: Option<&str>,
710    ) -> Result<RunPluginResult, PluginHostError> {
711        self.ensure_runnable(plugin_id)?;
712        let plugin = self.find_plugin(plugin_id)?;
713        Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
714        if !plugin.manifest.capabilities.graph && plugin.manifest.kind != PluginKind::Graph {
715            return Err(PluginHostError::UnsupportedAction(
716                plugin_id.to_string(),
717                "graph.build".into(),
718            ));
719        }
720        if let Some(g) = self.graphs.get(plugin_id) {
721            let result = g.build(catalog, &self.workspace, root_iri);
722            return Ok(provider_graph_result(result));
723        }
724        let mut extra = Vec::new();
725        if let Some(iri) = root_iri {
726            extra.push("--root".to_string());
727            extra.push(iri.to_string());
728        }
729        self.run_subprocess_provider(plugin, "graph.build", &extra)
730    }
731
732    fn run_subprocess_provider(
733        &self,
734        plugin: &DiscoveredPlugin,
735        action: &str,
736        extra_args: &[String],
737    ) -> Result<RunPluginResult, PluginHostError> {
738        let plugin_id = plugin.plugin_id();
739        if plugin.manifest.entry.is_none() {
740            return Err(PluginHostError::NotFound(format!("no runtime for plugin {plugin_id}")));
741        }
742        Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
743        let output = run_plugin_subprocess(
744            plugin,
745            SubprocessRequest { action, workspace: &self.workspace, step: None, extra_args },
746        )?;
747        let success = output.exit_message.is_none();
748        Ok(RunPluginResult::from_output(plugin_id, &self.workspace, output, success))
749    }
750
751    #[allow(clippy::too_many_arguments)]
752    pub fn run_plugin_action(
753        &mut self,
754        plugin_id: &str,
755        action: &str,
756        catalog: Option<&OntologyCatalog>,
757        export_options: Option<ExportOptions>,
758        step: Option<&str>,
759        view_id: Option<&str>,
760        query: Option<&str>,
761        focus_iri: Option<&str>,
762    ) -> Result<RunPluginResult, PluginHostError> {
763        // Auto-activate on_command / registered plugins when running an action.
764        if !self.is_disabled(plugin_id) && self.state_of(plugin_id) != PluginLifecycleState::Active
765        {
766            let _ = self.activate(plugin_id);
767        }
768        match action {
769            "validate" => {
770                let catalog = catalog.ok_or_else(|| {
771                    PluginHostError::UnsupportedAction(
772                        plugin_id.to_string(),
773                        "validate (no catalog)".into(),
774                    )
775                })?;
776                let diags = self.run_validate_plugin(plugin_id, catalog)?;
777                let mut out = RunPluginResult::empty_ok();
778                out.diagnostics = diags;
779                Ok(out)
780            }
781            "export" => {
782                let catalog = catalog.ok_or_else(|| {
783                    PluginHostError::UnsupportedAction(
784                        plugin_id.to_string(),
785                        "export (no catalog)".into(),
786                    )
787                })?;
788                let options = export_options
789                    .unwrap_or_else(|| ExportOptions::markdown(DEFAULT_PLUGIN_EXPORT_DIR));
790                self.run_export_plugin(plugin_id, catalog, options)
791            }
792            "workflow" => self.run_workflow_plugin(plugin_id, step.unwrap_or("qc")),
793            "ui_view" => {
794                let plugin = self.find_plugin(plugin_id)?;
795                Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceRead)?;
796                // #347: subprocess ui_view can mutate the workspace — require write like validate.
797                Self::ensure_permission(plugin, plugin_id, PluginPermission::WorkspaceWrite)?;
798                Self::ensure_permission(plugin, plugin_id, PluginPermission::ExternalProcess)?;
799                let view = view_id.ok_or_else(|| {
800                    PluginHostError::UnsupportedAction(
801                        plugin_id.to_string(),
802                        "ui_view (missing view_id)".into(),
803                    )
804                })?;
805                let extra = vec!["--view".to_string(), view.to_string()];
806                let output = run_plugin_subprocess(
807                    plugin,
808                    SubprocessRequest {
809                        action: "ui-view",
810                        workspace: &self.workspace,
811                        step: None,
812                        extra_args: &extra,
813                    },
814                )?;
815                let success = output.exit_message.is_none();
816                Ok(RunPluginResult::from_output(plugin_id, &self.workspace, output, success))
817            }
818            "reasoner.classify" | "reasoner" => {
819                let catalog = catalog.ok_or_else(|| {
820                    PluginHostError::UnsupportedAction(
821                        plugin_id.to_string(),
822                        "reasoner.classify (no catalog)".into(),
823                    )
824                })?;
825                self.run_reasoner_plugin(plugin_id, catalog)
826            }
827            "query.run" | "query" => {
828                let catalog = catalog.ok_or_else(|| {
829                    PluginHostError::UnsupportedAction(
830                        plugin_id.to_string(),
831                        "query.run (no catalog)".into(),
832                    )
833                })?;
834                self.run_query_plugin(plugin_id, catalog, query.unwrap_or("SELECT * FROM classes"))
835            }
836            "refactor.preview" | "refactor" => {
837                let catalog = catalog.ok_or_else(|| {
838                    PluginHostError::UnsupportedAction(
839                        plugin_id.to_string(),
840                        "refactor.preview (no catalog)".into(),
841                    )
842                })?;
843                self.run_refactor_plugin(plugin_id, catalog, focus_iri)
844            }
845            "graph.build" | "graph" => {
846                let catalog = catalog.ok_or_else(|| {
847                    PluginHostError::UnsupportedAction(
848                        plugin_id.to_string(),
849                        "graph.build (no catalog)".into(),
850                    )
851                })?;
852                self.run_graph_plugin(plugin_id, catalog, focus_iri)
853            }
854            _ => Err(PluginHostError::UnsupportedAction(plugin_id.to_string(), action.into())),
855        }
856    }
857}
858
859fn provider_reasoner_result(result: ReasonerProviderResult) -> RunPluginResult {
860    let mut out = RunPluginResult::empty_ok();
861    out.unsatisfiable = Some(result.unsatisfiable.clone());
862    out.profile = Some(result.profile.clone());
863    out.logs = result.logs.clone();
864    out.result = serde_json::to_value(&result).ok();
865    out
866}
867
868fn provider_query_result(result: QueryProviderResult) -> RunPluginResult {
869    let mut out = RunPluginResult::empty_ok();
870    out.columns = Some(result.columns.clone());
871    out.rows = Some(result.rows.clone());
872    out.result = serde_json::to_value(&result).ok();
873    out
874}
875
876fn provider_refactor_result(result: RefactorProviderResult) -> RunPluginResult {
877    let mut out = RunPluginResult::empty_ok();
878    out.affected_iris = Some(result.affected_iris.clone());
879    out.hints = Some(result.hints.clone());
880    out.result = serde_json::to_value(&result).ok();
881    out
882}
883
884fn provider_graph_result(mut result: GraphProviderResult) -> RunPluginResult {
885    let mut out = RunPluginResult::empty_ok();
886    out.graph_kind = Some(result.graph_kind.clone());
887    out.root_iris = Some(result.root_iris.clone());
888    out.result = result.result.take().or_else(|| serde_json::to_value(&result).ok());
889    out
890}
891
892fn wire_to_diagnostics(plugin_id: &str, workspace: &Path, output: PluginOutput) -> Vec<Diagnostic> {
893    let mut diags: Vec<_> =
894        output.diagnostics.into_iter().map(|d| d.into_diagnostic(plugin_id, workspace)).collect();
895    // #348: surface path-jail violations as error diagnostics (not silent drops).
896    for violation in output.violations {
897        diags.push(plugin_diagnostic(
898            plugin_id,
899            "path_jail",
900            DiagnosticSeverity::Error,
901            violation,
902            workspace.to_path_buf(),
903            None,
904        ));
905    }
906    diags
907}
908
909fn load_disabled_ids(workspace: &Path) -> HashSet<String> {
910    let path = workspace.join(DISABLED_STATE_FILE);
911    let Ok(bytes) = std::fs::read(&path) else {
912        return HashSet::new();
913    };
914    serde_json::from_slice::<Vec<String>>(&bytes).unwrap_or_default().into_iter().collect()
915}
916
917fn persist_disabled_ids(workspace: &Path, disabled: &HashSet<String>) {
918    let path = workspace.join(DISABLED_STATE_FILE);
919    if let Some(parent) = path.parent() {
920        let _ = std::fs::create_dir_all(parent);
921    }
922    let mut ids: Vec<_> = disabled.iter().cloned().collect();
923    ids.sort();
924    if let Ok(bytes) = serde_json::to_vec_pretty(&ids) {
925        let _ = std::fs::write(path, bytes);
926    }
927}
928
929/// Resolve export output under the workspace root (relative paths join the root, not process CWD).
930fn jail_export_options(
931    workspace: &Path,
932    mut options: ExportOptions,
933) -> Result<ExportOptions, PluginHostError> {
934    let jailed = validate_workspace_scope(&options.output_dir, workspace)
935        .map_err(PluginHostError::Export)?;
936    options.output_dir = jailed;
937    Ok(options)
938}
939
940pub fn merge_plugin_diagnostics(base: &mut Vec<Diagnostic>, plugin: Vec<Diagnostic>) {
941    base.extend(plugin);
942}
943
944pub fn manifest_for_builtin(id: &str) -> Option<PluginManifest> {
945    let text = match id {
946        "strixonomy.naming-validator" => include_str!("../fixtures/builtin-naming.toml"),
947        "strixonomy.markdown-export" => include_str!("../fixtures/builtin-markdown.toml"),
948        "strixonomy.shacl-validator" => include_str!("../fixtures/builtin-shacl.toml"),
949        _ => return None,
950    };
951    crate::manifest::parse_manifest(text).ok()
952}
953
954#[cfg(test)]
955mod tests {
956    use super::*;
957    use strixonomy_core::is_path_within;
958
959    #[test]
960    fn jail_export_options_joins_relative_to_workspace_not_cwd() {
961        let dir = tempfile::tempdir().unwrap();
962        let workspace = dir.path().join("ws");
963        std::fs::create_dir_all(&workspace).unwrap();
964        let cwd = tempfile::tempdir().unwrap();
965        let prev = std::env::current_dir().unwrap();
966        struct RestoreCwd(std::path::PathBuf);
967        impl Drop for RestoreCwd {
968            fn drop(&mut self) {
969                let _ = std::env::set_current_dir(&self.0);
970            }
971        }
972        let _restore = RestoreCwd(prev);
973        std::env::set_current_dir(cwd.path()).unwrap();
974
975        let options =
976            jail_export_options(&workspace, ExportOptions::markdown(DEFAULT_PLUGIN_EXPORT_DIR))
977                .expect("jail default export dir");
978
979        let root = workspace.canonicalize().unwrap();
980        assert!(is_path_within(&root, &options.output_dir));
981        assert!(options.output_dir.ends_with(".strixonomy/plugin-out"));
982        assert!(!cwd.path().join("plugin-out").exists());
983        assert!(!cwd.path().join(".strixonomy/plugin-out").exists());
984    }
985
986    #[test]
987    fn jail_export_options_rejects_absolute_outside_workspace() {
988        let dir = tempfile::tempdir().unwrap();
989        let workspace = dir.path().join("ws");
990        std::fs::create_dir_all(&workspace).unwrap();
991        let outside = dir.path().join("outside-out");
992        let err = jail_export_options(&workspace, ExportOptions::markdown(&outside)).unwrap_err();
993        assert!(matches!(err, PluginHostError::Export(_)));
994    }
995
996    #[test]
997    fn activate_all_orders_dependencies() {
998        let dir = tempfile::tempdir().unwrap();
999        let plugins = dir.path().join(".strixonomy/plugins");
1000        std::fs::create_dir_all(&plugins).unwrap();
1001        std::fs::write(
1002            plugins.join("a.toml"),
1003            r#"
1004[plugin]
1005name = "a"
1006version = "0.1.0"
1007kind = "validator"
1008id = "a"
1009api_version = "1"
1010permissions = ["workspace.read"]
1011[capabilities]
1012validate = true
1013"#,
1014        )
1015        .unwrap();
1016        std::fs::write(
1017            plugins.join("b.toml"),
1018            r#"
1019[plugin]
1020name = "b"
1021version = "0.1.0"
1022kind = "graph"
1023id = "b"
1024api_version = "1"
1025depends_on = ["a"]
1026permissions = ["workspace.read"]
1027[capabilities]
1028graph = true
1029"#,
1030        )
1031        .unwrap();
1032        let mut host = PluginHost::new(dir.path());
1033        host.discover().expect("discover");
1034        let order = host.activate_all().expect("activate");
1035        assert_eq!(order, vec!["a".to_string(), "b".to_string()]);
1036        assert_eq!(host.state_of("a"), PluginLifecycleState::Active);
1037        assert_eq!(host.state_of("b"), PluginLifecycleState::Active);
1038    }
1039
1040    #[test]
1041    fn disable_cascades_dependents() {
1042        let dir = tempfile::tempdir().unwrap();
1043        let plugins = dir.path().join(".strixonomy/plugins");
1044        std::fs::create_dir_all(&plugins).unwrap();
1045        std::fs::write(
1046            plugins.join("a.toml"),
1047            r#"
1048[plugin]
1049name = "a"
1050version = "0.1.0"
1051kind = "validator"
1052id = "a"
1053api_version = "1"
1054permissions = ["workspace.read"]
1055"#,
1056        )
1057        .unwrap();
1058        std::fs::write(
1059            plugins.join("b.toml"),
1060            r#"
1061[plugin]
1062name = "b"
1063version = "0.1.0"
1064kind = "graph"
1065id = "b"
1066api_version = "1"
1067depends_on = ["a"]
1068permissions = ["workspace.read"]
1069"#,
1070        )
1071        .unwrap();
1072        let mut host = PluginHost::new(dir.path());
1073        host.discover().unwrap();
1074        host.activate_all().unwrap();
1075        host.disable_plugin("a").unwrap();
1076        assert_eq!(host.state_of("a"), PluginLifecycleState::Disabled);
1077        assert_eq!(host.state_of("b"), PluginLifecycleState::Disabled);
1078    }
1079}