1use std::collections::HashMap;
2use std::sync::Arc;
3
4use arc_swap::ArcSwap;
5use tokio::sync::broadcast;
6use wasmtime::Engine;
7
8use crate::capability::{CapabilityRegistry, OfferedBinding};
9use crate::discovery::DiscoveryPaths;
10use crate::error::RuntimeError;
11use crate::loaded::{ExtensionId, HostOverrides, LoadedExtension, LoadedExtensionRef};
12
13const STATE_FILENAME: &str = "extensions-state.json";
19
20#[derive(Clone, Debug)]
29pub struct RuntimeConfig {
30 pub paths: DiscoveryPaths,
31 pub host_overrides: HostOverrides,
37}
38
39impl RuntimeConfig {
40 #[must_use]
43 pub fn from_paths(paths: DiscoveryPaths) -> Self {
44 Self {
45 paths,
46 host_overrides: HostOverrides::default(),
47 }
48 }
49
50 #[must_use]
58 pub fn with_host_overrides(mut self, overrides: HostOverrides) -> Self {
59 self.host_overrides = overrides;
60 self
61 }
62}
63
64pub struct ExtensionRuntime {
65 engine: Engine,
66 config: RuntimeConfig,
67 loaded: ArcSwap<HashMap<ExtensionId, LoadedExtensionRef>>,
68 capability_registry: ArcSwap<CapabilityRegistry>,
69 events: broadcast::Sender<RuntimeEvent>,
70}
71
72#[derive(Debug, Clone)]
73pub enum RuntimeEvent {
74 ExtensionInstalled(ExtensionId),
75 ExtensionUpdated {
76 id: ExtensionId,
77 prev_version: String,
78 },
79 ExtensionRemoved(ExtensionId),
80 CapabilityRegistryRebuilt,
81 StateFileChanged,
84}
85
86pub struct WatcherGuard {
89 stop_tx: Option<std::sync::mpsc::Sender<()>>,
90 join: Option<std::thread::JoinHandle<()>>,
91}
92
93impl Drop for WatcherGuard {
94 fn drop(&mut self) {
95 drop(self.stop_tx.take());
97 if let Some(handle) = self.join.take() {
98 let _ = handle.join();
99 }
100 }
101}
102
103impl ExtensionRuntime {
104 pub fn new(config: RuntimeConfig) -> Result<Self, RuntimeError> {
105 let mut ec = wasmtime::Config::new();
106 ec.wasm_component_model(true);
107
108 match wasmtime::Cache::from_file(None) {
116 Ok(cache) => {
117 ec.cache(Some(cache));
118 }
119 Err(e) => {
120 tracing::warn!("wasmtime compilation cache disabled: {e}");
121 }
122 }
123
124 let engine = Engine::new(&ec).map_err(|e| RuntimeError::Wasmtime(e.into()))?;
125 let (tx, _) = broadcast::channel(64);
126 Ok(Self {
127 engine,
128 config,
129 loaded: ArcSwap::from_pointee(HashMap::new()),
130 capability_registry: ArcSwap::from_pointee(CapabilityRegistry::default()),
131 events: tx,
132 })
133 }
134
135 #[must_use]
147 pub fn for_test() -> Self {
148 let paths =
149 DiscoveryPaths::new(std::path::PathBuf::from("/nonexistent/aw-ext-runtime-test"));
150 Self::new(RuntimeConfig::from_paths(paths))
151 .expect("for_test ExtensionRuntime construction is infallible")
152 }
153
154 #[must_use]
170 pub fn with_host_overrides(mut self, host_overrides: HostOverrides) -> Self {
171 self.config.host_overrides = host_overrides;
172 self
173 }
174
175 #[must_use]
176 pub fn engine(&self) -> &Engine {
177 &self.engine
178 }
179
180 #[must_use]
183 pub(crate) fn host_overrides(&self) -> &HostOverrides {
184 &self.config.host_overrides
185 }
186
187 #[must_use]
188 pub fn subscribe(&self) -> broadcast::Receiver<RuntimeEvent> {
189 self.events.subscribe()
190 }
191
192 #[must_use]
193 pub fn config(&self) -> &RuntimeConfig {
194 &self.config
195 }
196
197 #[must_use]
198 pub fn loaded(&self) -> Arc<HashMap<ExtensionId, LoadedExtensionRef>> {
199 self.loaded.load_full()
200 }
201
202 #[must_use]
203 pub fn capability_registry(&self) -> Arc<CapabilityRegistry> {
204 self.capability_registry.load_full()
205 }
206
207 pub fn register_loaded_from_dir(&mut self, dir: &std::path::Path) -> Result<(), RuntimeError> {
208 Self::verify_dir_signature(dir)?;
209 let loaded = LoadedExtension::load_from_dir(&self.engine, dir)?;
210 let id = loaded.id.clone();
211
212 let mut new_registry = CapabilityRegistry::new();
214 for existing in self.capability_registry.load().offerings() {
215 new_registry.add_offering(existing.clone());
216 }
217 for cap in &loaded.describe.capabilities.offered {
218 let version: semver::Version = cap.version.parse().map_err(|e: semver::Error| {
219 RuntimeError::Wasmtime(anyhow::anyhow!("bad offered version: {e}"))
220 })?;
221 new_registry.add_offering(OfferedBinding {
222 extension_id: id.as_str().to_string(),
223 cap_id: cap.id.clone(),
224 version,
225 kind: loaded.kind,
226 export_path: String::new(),
227 });
228 }
229
230 let mut new_map = (**self.loaded.load()).clone();
232 new_map.insert(id.clone(), Arc::new(loaded));
233 self.loaded.store(Arc::new(new_map));
234 self.capability_registry.store(Arc::new(new_registry));
235
236 let _ = self.events.send(RuntimeEvent::ExtensionInstalled(id));
237 Ok(())
238 }
239
240 fn verify_dir_signature(dir: &std::path::Path) -> Result<(), RuntimeError> {
241 #[cfg(feature = "dev-allow-unsigned")]
242 if std::env::var("GREENTIC_EXT_ALLOW_UNSIGNED").is_ok() {
243 tracing::warn!(
244 extension_dir = %dir.display(),
245 "GREENTIC_EXT_ALLOW_UNSIGNED is set — signature verification skipped"
246 );
247 return Ok(());
248 }
249 let path = dir.join("describe.json");
250 let raw = std::fs::read_to_string(&path)?;
251 let describe: greentic_extension_sdk_contract::DescribeJson = serde_json::from_str(&raw)?;
252 greentic_extension_sdk_contract::verify_describe_self_consistent(&describe).map_err(
259 |e| RuntimeError::SignatureInvalid {
260 extension_id: describe.metadata.id.clone(),
261 reason: e.to_string(),
262 },
263 )?;
264 let pub_prefix = describe.signature.as_ref().map_or_else(
265 || "?".to_string(),
266 |s| s.public_key.chars().take(16).collect::<String>(),
267 );
268 tracing::info!(
269 extension_id = %describe.metadata.id,
270 key_prefix = %pub_prefix,
271 "extension signature verified"
272 );
273 Self::verify_dir_manifest(dir, &describe)?;
274 Ok(())
275 }
276
277 fn verify_dir_manifest(
293 dir: &std::path::Path,
294 describe: &greentic_extension_sdk_contract::DescribeJson,
295 ) -> Result<(), RuntimeError> {
296 use sha2::{Digest, Sha256};
297 let extension_id = describe.metadata.id.as_str();
298 let manifest_path = dir.join(greentic_extension_sdk_contract::MANIFEST_ENTRY_NAME);
299 if !manifest_path.exists() {
300 return Err(RuntimeError::SignatureInvalid {
301 extension_id: extension_id.to_string(),
302 reason: "manifest.json absent — refusing to load an extension without a \
303 whole-archive integrity ledger (set GREENTIC_EXT_ALLOW_UNSIGNED \
304 with the dev-allow-unsigned build for local dev)"
305 .to_string(),
306 });
307 }
308 let raw = std::fs::read(&manifest_path)?;
309 greentic_extension_sdk_contract::verify_manifest_binding(describe, &raw).map_err(|e| {
313 RuntimeError::SignatureInvalid {
314 extension_id: extension_id.to_string(),
315 reason: format!("manifest binding: {e}"),
316 }
317 })?;
318 let manifest: greentic_extension_sdk_contract::Manifest = serde_json::from_slice(&raw)
319 .map_err(|e| RuntimeError::SignatureInvalid {
320 extension_id: extension_id.to_string(),
321 reason: format!("manifest.json parse: {e}"),
322 })?;
323 if manifest.schema != greentic_extension_sdk_contract::MANIFEST_SCHEMA_V1 {
324 return Err(RuntimeError::SignatureInvalid {
325 extension_id: extension_id.to_string(),
326 reason: format!("manifest schema unsupported: {}", manifest.schema),
327 });
328 }
329 for entry in &manifest.entries {
330 let path = dir.join(&entry.path);
331 if !path.exists() {
332 return Err(RuntimeError::SignatureInvalid {
333 extension_id: extension_id.to_string(),
334 reason: format!("manifest lists missing file: {}", entry.path),
335 });
336 }
337 let bytes = std::fs::read(&path)?;
338 let computed = format!("{:x}", Sha256::digest(&bytes));
339 if computed != entry.sha256 {
340 return Err(RuntimeError::SignatureInvalid {
341 extension_id: extension_id.to_string(),
342 reason: format!(
343 "manifest sha256 mismatch for {}: expected {} got {}",
344 entry.path, entry.sha256, computed
345 ),
346 });
347 }
348 }
349 tracing::info!(
350 extension_id = %extension_id,
351 entries = manifest.entries.len(),
352 "whole-archive manifest verified"
353 );
354 Ok(())
355 }
356
357 pub fn start_watcher(self: Arc<Self>) -> Result<WatcherGuard, RuntimeError> {
362 let mut paths: Vec<std::path::PathBuf> =
363 self.config.paths.all().into_iter().cloned().collect();
364 if let Some(home) = self.config.paths.home()
369 && home.exists()
370 && !paths.iter().any(|p| p == home)
371 {
372 paths.push(home.to_path_buf());
373 }
374 let (rx, watch_handle) = crate::watcher::watch(&paths)?;
375 let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
376 let this = self.clone();
377 let join = std::thread::spawn(move || {
378 let _watch_handle = watch_handle;
381 loop {
382 match stop_rx.try_recv() {
384 Ok(()) | Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
385 Err(std::sync::mpsc::TryRecvError::Empty) => {}
386 }
387 match rx.recv_timeout(std::time::Duration::from_millis(200)) {
388 Ok(event) => {
389 if let Err(e) = this.handle_fs_event(&event) {
390 tracing::warn!(error = %e, "hot reload failed");
391 }
392 }
393 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
394 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
395 }
396 }
397 });
398 Ok(WatcherGuard {
399 stop_tx: Some(stop_tx),
400 join: Some(join),
401 })
402 }
403
404 fn handle_fs_event(&self, event: &crate::watcher::FsEvent) -> Result<(), RuntimeError> {
405 use crate::watcher::FsEvent;
406 let path = match event {
407 FsEvent::Added(p) | FsEvent::Modified(p) | FsEvent::Removed(p) => p.clone(),
408 };
409
410 if path.file_name().is_some_and(|n| n == STATE_FILENAME) {
415 let _ = self.events.send(RuntimeEvent::StateFileChanged);
416 return Ok(());
417 }
418
419 let ext_dir = find_extension_dir(&path);
420 match event {
421 FsEvent::Removed(_) => {
422 if let Some(dir) = ext_dir {
423 self.handle_removal(&dir);
424 }
425 }
426 FsEvent::Added(_) | FsEvent::Modified(_) => {
427 if let Some(dir) = ext_dir {
428 self.handle_added_or_modified(&dir)?;
429 }
430 }
431 }
432 Ok(())
433 }
434
435 fn handle_removal(&self, dir: &std::path::Path) {
436 let current = self.loaded.load();
437 let Some((id, _)) = current.iter().find(|(_, v)| v.source_dir == dir) else {
438 return;
439 };
440 let id = id.clone();
441 let mut new_map = (**current).clone();
442 new_map.remove(&id);
443 self.loaded.store(Arc::new(new_map));
444 let _ = self.events.send(RuntimeEvent::ExtensionRemoved(id));
445 }
446
447 fn handle_added_or_modified(&self, dir: &std::path::Path) -> Result<(), RuntimeError> {
448 let loaded = crate::loaded::LoadedExtension::load_from_dir(&self.engine, dir)?;
449 let id = loaded.id.clone();
450 let mut new_map = (**self.loaded.load()).clone();
451 let prev_version = new_map
452 .get(&id)
453 .map(|e| e.describe.metadata.version.clone());
454 new_map.insert(id.clone(), Arc::new(loaded));
455 self.loaded.store(Arc::new(new_map));
456 let event = match prev_version {
457 Some(prev) => RuntimeEvent::ExtensionUpdated {
458 id,
459 prev_version: prev,
460 },
461 None => RuntimeEvent::ExtensionInstalled(id),
462 };
463 let _ = self.events.send(event);
464 Ok(())
465 }
466}
467
468impl ExtensionRuntime {
469 pub fn invoke_tool(
476 &self,
477 ext_id: &str,
478 tool_name: &str,
479 args_json: &str,
480 ) -> Result<String, RuntimeError> {
481 self.invoke_tool_ctx(
482 ext_id,
483 tool_name,
484 args_json,
485 &crate::host_ports::HostCallContext::default(),
486 )
487 }
488
489 pub fn invoke_tool_ctx(
494 &self,
495 ext_id: &str,
496 tool_name: &str,
497 args_json: &str,
498 ctx: &crate::host_ports::HostCallContext,
499 ) -> Result<String, RuntimeError> {
500 let loaded = self
501 .loaded
502 .load()
503 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
504 .cloned()
505 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
506
507 let (mut store, instance) = loaded
508 .build_store_and_instance(&self.engine, self.config.host_overrides.clone(), ctx)
509 .map_err(RuntimeError::Wasmtime)?;
510
511 let (iface_idx, iface_name, version) = resolve_iface_versions(
519 &mut store,
520 &instance,
521 "greentic:extension-design/tools",
522 DESIGN_VERSIONS,
523 )?;
524 warn_if_legacy_contract(ext_id, version, DESIGN_VERSIONS[0]);
525 let func_idx = instance
526 .get_export_index(&mut store, Some(&iface_idx), "invoke-tool")
527 .ok_or_else(|| {
528 RuntimeError::Wasmtime(anyhow::anyhow!(
529 "interface '{iface_name}' does not export 'invoke-tool'"
530 ))
531 })?;
532
533 let call_args = (tool_name.to_string(), args_json.to_string());
534 let mapped: Result<String, crate::types::HostExtensionError> = if version == "0.3.0" {
536 use crate::host_bindings::design_v03::greentic::extension_base0_2_0::types::ExtensionError as E2;
537 let func = instance
538 .get_typed_func::<(String, String), (Result<String, E2>,)>(&mut store, &func_idx)
539 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
540 let (r,) = func
541 .call(&mut store, call_args)
542 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
543 r.map_err(crate::ext_error::from_design_v03)
544 } else {
545 use crate::host_bindings::greentic::extension_base0_1_0::types::ExtensionError as E1;
546 let func = instance
547 .get_typed_func::<(String, String), (Result<String, E1>,)>(&mut store, &func_idx)
548 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
549 let (r,) = func
550 .call(&mut store, call_args)
551 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
552 r.map_err(crate::ext_error::from_design_v01)
553 };
554
555 mapped.map_err(RuntimeError::Extension)
556 }
557}
558
559impl ExtensionRuntime {
560 pub fn evaluate_guardrail(
593 &self,
594 ext_id: &str,
595 input_json: &str,
596 ) -> Result<String, RuntimeError> {
597 let loaded = self
598 .loaded
599 .load()
600 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
601 .cloned()
602 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
603
604 let (mut store, instance) = loaded
605 .build_store_and_instance(
606 &self.engine,
607 self.config.host_overrides.clone(),
608 &crate::host_ports::HostCallContext::default(),
609 )
610 .map_err(RuntimeError::Wasmtime)?;
611
612 let (iface_idx, iface_name, _version) = resolve_iface_versions(
614 &mut store,
615 &instance,
616 "greentic:extension-design/guardrail",
617 GUARDRAIL_VERSIONS,
618 )?;
619
620 let func_idx = instance
621 .get_export_index(&mut store, Some(&iface_idx), "evaluate")
622 .ok_or_else(|| {
623 RuntimeError::Wasmtime(anyhow::anyhow!(
624 "interface '{iface_name}' does not export 'evaluate'"
625 ))
626 })?;
627
628 let wire =
629 crate::guardrail_map::call_evaluate(&mut store, &instance, &func_idx, input_json)?;
630
631 serde_json::to_string(&wire).map_err(|e| RuntimeError::Wasmtime(e.into()))
632 }
633}
634
635impl ExtensionRuntime {
636 pub fn validate_content(
650 &self,
651 ext_id: &str,
652 content_type: &str,
653 content_json: &str,
654 ) -> Result<crate::types::ValidateResult, RuntimeError> {
655 use crate::host_bindings::exports::greentic::extension_design0_2_0::validation::{
656 Diagnostic as WitDiagnostic, ValidateResult as WitValidateResult,
657 };
658 use crate::host_bindings::greentic::extension_base0_1_0::types::Severity as WitSeverity;
659
660 let loaded = self
661 .loaded
662 .load()
663 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
664 .cloned()
665 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
666
667 let (mut store, instance) = loaded
668 .build_store_and_instance(
669 &self.engine,
670 self.config.host_overrides.clone(),
671 &crate::host_ports::HostCallContext::default(),
672 )
673 .map_err(RuntimeError::Wasmtime)?;
674
675 let (iface_idx, iface_name) = resolve_design_iface(
676 &mut store,
677 &instance,
678 "greentic:extension-design/validation",
679 )?;
680 let func_idx = instance
681 .get_export_index(&mut store, Some(&iface_idx), "validate-content")
682 .ok_or_else(|| {
683 RuntimeError::Wasmtime(anyhow::anyhow!(
684 "interface '{iface_name}' does not export 'validate-content'"
685 ))
686 })?;
687
688 let func = instance
689 .get_typed_func::<(String, String), (WitValidateResult,)>(&mut store, &func_idx)
690 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
691
692 let (result,) = func
693 .call(
694 &mut store,
695 (content_type.to_string(), content_json.to_string()),
696 )
697 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
698
699 let diagnostics = result
700 .diagnostics
701 .into_iter()
702 .map(|d: WitDiagnostic| crate::types::Diagnostic {
703 severity: match d.severity {
704 WitSeverity::Error => crate::types::Severity::Error,
705 WitSeverity::Warning => crate::types::Severity::Warning,
706 WitSeverity::Info => crate::types::Severity::Info,
707 WitSeverity::Hint => crate::types::Severity::Hint,
708 },
709 code: d.code,
710 message: d.message,
711 path: d.path,
712 })
713 .collect();
714
715 Ok(crate::types::ValidateResult {
716 valid: result.valid,
717 diagnostics,
718 })
719 }
720}
721
722#[must_use]
730pub fn contribution_tool_to_definition(
731 t: &greentic_extension_sdk_contract::describe::contributions::Tool,
732) -> crate::types::ToolDefinition {
733 crate::types::ToolDefinition {
734 name: t.name.clone(),
735 description: t.description.clone().unwrap_or_default(),
736 input_schema_json: t.input_schema.clone().unwrap_or_default(),
737 output_schema_json: None,
738 capabilities: t.capabilities.clone(),
739 agentic_worker_metadata: None,
740 secret_requirements: t.secret_requirements.clone(),
741 }
742}
743
744impl ExtensionRuntime {
745 pub fn list_tools(
756 &self,
757 ext_id: &str,
758 ) -> Result<Vec<crate::types::ToolDefinition>, RuntimeError> {
759 use crate::host_bindings::exports::greentic::extension_design0_2_0::tools::ToolDefinition as WitToolDef;
760
761 let loaded = self
762 .loaded
763 .load()
764 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
765 .cloned()
766 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
767
768 if loaded.describe.api_version == "greentic.ai/v2" {
770 return Ok(loaded
771 .describe
772 .contributions
773 .tools
774 .iter()
775 .map(contribution_tool_to_definition)
776 .collect());
777 }
778
779 let (mut store, instance) = loaded
781 .build_store_and_instance(
782 &self.engine,
783 self.config.host_overrides.clone(),
784 &crate::host_ports::HostCallContext::default(),
785 )
786 .map_err(RuntimeError::Wasmtime)?;
787
788 let (iface_idx, iface_name) =
789 resolve_design_iface(&mut store, &instance, "greentic:extension-design/tools")?;
790 let func_idx = instance
791 .get_export_index(&mut store, Some(&iface_idx), "list-tools")
792 .ok_or_else(|| {
793 RuntimeError::Wasmtime(anyhow::anyhow!(
794 "interface '{iface_name}' does not export 'list-tools'"
795 ))
796 })?;
797
798 let func = instance
799 .get_typed_func::<(), (Vec<WitToolDef>,)>(&mut store, &func_idx)
800 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
801
802 let (defs,) = func
803 .call(&mut store, ())
804 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
805
806 Ok(defs
807 .into_iter()
808 .map(|d| crate::types::ToolDefinition {
809 name: d.name,
810 description: d.description,
811 input_schema_json: d.input_schema_json,
812 output_schema_json: d.output_schema_json,
813 capabilities: d.capabilities,
814 agentic_worker_metadata: d.agentic_worker_metadata,
815 secret_requirements: Vec::new(),
816 })
817 .collect())
818 }
819}
820
821impl ExtensionRuntime {
822 pub fn prompt_fragments(
827 &self,
828 ext_id: &str,
829 ) -> Result<Vec<crate::types::PromptFragment>, RuntimeError> {
830 use crate::host_bindings::exports::greentic::extension_design0_2_0::prompting::PromptFragment as WitFrag;
831
832 let loaded = self
833 .loaded
834 .load()
835 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
836 .cloned()
837 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
838
839 let (mut store, instance) = loaded
840 .build_store_and_instance(
841 &self.engine,
842 self.config.host_overrides.clone(),
843 &crate::host_ports::HostCallContext::default(),
844 )
845 .map_err(RuntimeError::Wasmtime)?;
846
847 let (iface_idx, iface_name) =
848 resolve_design_iface(&mut store, &instance, "greentic:extension-design/prompting")?;
849 let func_idx = instance
850 .get_export_index(&mut store, Some(&iface_idx), "system-prompt-fragments")
851 .ok_or_else(|| {
852 RuntimeError::Wasmtime(anyhow::anyhow!(
853 "interface '{iface_name}' does not export 'system-prompt-fragments'"
854 ))
855 })?;
856
857 let func = instance
858 .get_typed_func::<(), (Vec<WitFrag>,)>(&mut store, &func_idx)
859 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
860
861 let (frags,) = func
862 .call(&mut store, ())
863 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
864
865 Ok(frags
866 .into_iter()
867 .map(|f| crate::types::PromptFragment {
868 section: f.section,
869 content_markdown: f.content_markdown,
870 priority: f.priority,
871 })
872 .collect())
873 }
874}
875
876impl ExtensionRuntime {
877 pub fn knowledge_list(
882 &self,
883 ext_id: &str,
884 category_filter: Option<&str>,
885 ) -> Result<Vec<crate::types::KnowledgeEntrySummary>, RuntimeError> {
886 use crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::EntrySummary as WitSummary;
887
888 let loaded = self
889 .loaded
890 .load()
891 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
892 .cloned()
893 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
894
895 let (mut store, instance) = loaded
896 .build_store_and_instance(
897 &self.engine,
898 self.config.host_overrides.clone(),
899 &crate::host_ports::HostCallContext::default(),
900 )
901 .map_err(RuntimeError::Wasmtime)?;
902
903 let (iface_idx, iface_name) =
904 resolve_design_iface(&mut store, &instance, "greentic:extension-design/knowledge")?;
905 let func_idx = instance
906 .get_export_index(&mut store, Some(&iface_idx), "list-entries")
907 .ok_or_else(|| {
908 RuntimeError::Wasmtime(anyhow::anyhow!(
909 "interface '{iface_name}' does not export 'list-entries'"
910 ))
911 })?;
912
913 let func = instance
914 .get_typed_func::<(Option<String>,), (Vec<WitSummary>,)>(&mut store, &func_idx)
915 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
916
917 let (entries,) = func
918 .call(&mut store, (category_filter.map(String::from),))
919 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
920
921 Ok(entries.into_iter().map(wit_summary_to_host).collect())
922 }
923
924 pub fn knowledge_get(
932 &self,
933 ext_id: &str,
934 entry_id: &str,
935 ) -> Result<crate::types::KnowledgeEntry, RuntimeError> {
936 let loaded = self
937 .loaded
938 .load()
939 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
940 .cloned()
941 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
942
943 let (mut store, instance) = loaded
944 .build_store_and_instance(
945 &self.engine,
946 self.config.host_overrides.clone(),
947 &crate::host_ports::HostCallContext::default(),
948 )
949 .map_err(RuntimeError::Wasmtime)?;
950
951 let (iface_idx, iface_name, version) = resolve_iface_versions(
952 &mut store,
953 &instance,
954 "greentic:extension-design/knowledge",
955 DESIGN_VERSIONS,
956 )?;
957 let func_idx = instance
958 .get_export_index(&mut store, Some(&iface_idx), "get-entry")
959 .ok_or_else(|| {
960 RuntimeError::Wasmtime(anyhow::anyhow!(
961 "interface '{iface_name}' does not export 'get-entry'"
962 ))
963 })?;
964
965 let call_args = (entry_id.to_string(),);
966 let mapped: Result<crate::types::KnowledgeEntry, crate::types::HostExtensionError> =
967 if version == "0.3.0" {
968 use crate::host_bindings::design_v03::exports::greentic::extension_design0_3_0::knowledge::{
969 Entry as WitEntry, ExtensionError as E2,
970 };
971 let func = instance
972 .get_typed_func::<(String,), (Result<WitEntry, E2>,)>(&mut store, &func_idx)
973 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
974 let (r,) = func
975 .call(&mut store, call_args)
976 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
977 r.map(|e| crate::types::KnowledgeEntry {
978 id: e.id,
979 title: e.title,
980 category: e.category,
981 tags: e.tags,
982 content_json: e.content_json,
983 })
984 .map_err(crate::ext_error::from_design_v03)
985 } else {
986 use crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::{
987 Entry as WitEntry, ExtensionError as E1,
988 };
989 let func = instance
990 .get_typed_func::<(String,), (Result<WitEntry, E1>,)>(&mut store, &func_idx)
991 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
992 let (r,) = func
993 .call(&mut store, call_args)
994 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
995 r.map(|e| crate::types::KnowledgeEntry {
996 id: e.id,
997 title: e.title,
998 category: e.category,
999 tags: e.tags,
1000 content_json: e.content_json,
1001 })
1002 .map_err(crate::ext_error::from_design_v01)
1003 };
1004
1005 mapped.map_err(RuntimeError::Extension)
1006 }
1007
1008 pub fn knowledge_suggest(
1013 &self,
1014 ext_id: &str,
1015 query: &str,
1016 limit: u32,
1017 ) -> Result<Vec<crate::types::KnowledgeEntrySummary>, RuntimeError> {
1018 use crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::EntrySummary as WitSummary;
1019
1020 let loaded = self
1021 .loaded
1022 .load()
1023 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1024 .cloned()
1025 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1026
1027 let (mut store, instance) = loaded
1028 .build_store_and_instance(
1029 &self.engine,
1030 self.config.host_overrides.clone(),
1031 &crate::host_ports::HostCallContext::default(),
1032 )
1033 .map_err(RuntimeError::Wasmtime)?;
1034
1035 let (iface_idx, iface_name) =
1036 resolve_design_iface(&mut store, &instance, "greentic:extension-design/knowledge")?;
1037 let func_idx = instance
1038 .get_export_index(&mut store, Some(&iface_idx), "suggest-entries")
1039 .ok_or_else(|| {
1040 RuntimeError::Wasmtime(anyhow::anyhow!(
1041 "interface '{iface_name}' does not export 'suggest-entries'"
1042 ))
1043 })?;
1044
1045 let func = instance
1046 .get_typed_func::<(String, u32), (Vec<WitSummary>,)>(&mut store, &func_idx)
1047 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1048
1049 let (entries,) = func
1050 .call(&mut store, (query.to_string(), limit))
1051 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1052
1053 Ok(entries.into_iter().map(wit_summary_to_host).collect())
1054 }
1055}
1056
1057fn wit_summary_to_host(
1059 s: crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::EntrySummary,
1060) -> crate::types::KnowledgeEntrySummary {
1061 crate::types::KnowledgeEntrySummary {
1062 id: s.id,
1063 title: s.title,
1064 category: s.category,
1065 tags: s.tags,
1066 }
1067}
1068
1069impl ExtensionRuntime {
1070 pub fn validate_credentials(
1073 &self,
1074 ext_id: &str,
1075 target_id: &str,
1076 credentials_json: &str,
1077 ) -> Result<Vec<crate::types::Diagnostic>, RuntimeError> {
1078 use crate::host_bindings::deploy::exports::greentic::extension_deploy0_1_0::targets::Diagnostic as WitDiagnostic;
1079 use crate::host_bindings::deploy::greentic::extension_base0_1_0::types::Severity as WitSeverity;
1080
1081 let loaded = self
1082 .loaded
1083 .load()
1084 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1085 .cloned()
1086 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1087
1088 let (mut store, instance) = loaded
1089 .build_store_and_instance(
1090 &self.engine,
1091 self.config.host_overrides.clone(),
1092 &crate::host_ports::HostCallContext::default(),
1093 )
1094 .map_err(RuntimeError::Wasmtime)?;
1095
1096 let (iface_idx, iface_name, _version) = resolve_iface_versions(
1097 &mut store,
1098 &instance,
1099 "greentic:extension-deploy/targets",
1100 DEPLOY_VERSIONS,
1101 )?;
1102 let func_idx = instance
1103 .get_export_index(&mut store, Some(&iface_idx), "validate-credentials")
1104 .ok_or_else(|| {
1105 RuntimeError::Wasmtime(anyhow::anyhow!(
1106 "interface '{iface_name}' does not export 'validate-credentials'"
1107 ))
1108 })?;
1109
1110 let func = instance
1111 .get_typed_func::<(String, String), (Vec<WitDiagnostic>,)>(&mut store, &func_idx)
1112 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1113
1114 let (result,) = func
1115 .call(
1116 &mut store,
1117 (target_id.to_string(), credentials_json.to_string()),
1118 )
1119 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1120
1121 Ok(result
1122 .into_iter()
1123 .map(|d| crate::types::Diagnostic {
1124 severity: match d.severity {
1125 WitSeverity::Error => crate::types::Severity::Error,
1126 WitSeverity::Warning => crate::types::Severity::Warning,
1127 WitSeverity::Info => crate::types::Severity::Info,
1128 WitSeverity::Hint => crate::types::Severity::Hint,
1129 },
1130 code: d.code,
1131 message: d.message,
1132 path: d.path,
1133 })
1134 .collect())
1135 }
1136}
1137
1138impl ExtensionRuntime {
1139 pub fn credential_schema(&self, ext_id: &str, target_id: &str) -> Result<String, RuntimeError> {
1142 let loaded = self
1143 .loaded
1144 .load()
1145 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1146 .cloned()
1147 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1148
1149 let (mut store, instance) = loaded
1150 .build_store_and_instance(
1151 &self.engine,
1152 self.config.host_overrides.clone(),
1153 &crate::host_ports::HostCallContext::default(),
1154 )
1155 .map_err(RuntimeError::Wasmtime)?;
1156
1157 let (iface_idx, iface_name, version) = resolve_iface_versions(
1158 &mut store,
1159 &instance,
1160 "greentic:extension-deploy/targets",
1161 DEPLOY_VERSIONS,
1162 )?;
1163 let func_idx = instance
1164 .get_export_index(&mut store, Some(&iface_idx), "credential-schema")
1165 .ok_or_else(|| {
1166 RuntimeError::Wasmtime(anyhow::anyhow!(
1167 "interface '{iface_name}' does not export 'credential-schema'"
1168 ))
1169 })?;
1170
1171 let call_args = (target_id.to_string(),);
1172 let mapped: Result<String, crate::types::HostExtensionError> = if version == "0.2.0" {
1173 use crate::host_bindings::deploy_v02::greentic::extension_base0_2_0::types::ExtensionError as E2;
1174 let func = instance
1175 .get_typed_func::<(String,), (Result<String, E2>,)>(&mut store, &func_idx)
1176 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1177 let (r,) = func
1178 .call(&mut store, call_args)
1179 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1180 r.map_err(crate::ext_error::from_deploy_v02)
1181 } else {
1182 use crate::host_bindings::deploy::greentic::extension_base0_1_0::types::ExtensionError as E1;
1183 let func = instance
1184 .get_typed_func::<(String,), (Result<String, E1>,)>(&mut store, &func_idx)
1185 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1186 let (r,) = func
1187 .call(&mut store, call_args)
1188 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1189 r.map_err(crate::ext_error::from_deploy_v01)
1190 };
1191
1192 mapped.map_err(RuntimeError::Extension)
1193 }
1194}
1195
1196impl ExtensionRuntime {
1197 pub fn list_targets(
1201 &self,
1202 ext_id: &str,
1203 ) -> Result<Vec<crate::types::TargetSummary>, RuntimeError> {
1204 use crate::host_bindings::deploy::exports::greentic::extension_deploy0_1_0::targets::TargetSummary as WitTargetSummary;
1205
1206 let loaded = self
1207 .loaded
1208 .load()
1209 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1210 .cloned()
1211 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1212
1213 let (mut store, instance) = loaded
1214 .build_store_and_instance(
1215 &self.engine,
1216 self.config.host_overrides.clone(),
1217 &crate::host_ports::HostCallContext::default(),
1218 )
1219 .map_err(RuntimeError::Wasmtime)?;
1220
1221 let (iface_idx, iface_name, _version) = resolve_iface_versions(
1222 &mut store,
1223 &instance,
1224 "greentic:extension-deploy/targets",
1225 DEPLOY_VERSIONS,
1226 )?;
1227 let func_idx = instance
1228 .get_export_index(&mut store, Some(&iface_idx), "list-targets")
1229 .ok_or_else(|| {
1230 RuntimeError::Wasmtime(anyhow::anyhow!(
1231 "interface '{iface_name}' does not export 'list-targets'"
1232 ))
1233 })?;
1234
1235 let func = instance
1236 .get_typed_func::<(), (Vec<WitTargetSummary>,)>(&mut store, &func_idx)
1237 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1238
1239 let (result,) = func
1240 .call(&mut store, ())
1241 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1242
1243 Ok(result
1244 .into_iter()
1245 .map(|t| crate::types::TargetSummary {
1246 id: t.id,
1247 display_name: t.display_name,
1248 description: t.description,
1249 icon_path: t.icon_path,
1250 supports_rollback: t.supports_rollback,
1251 })
1252 .collect())
1253 }
1254}
1255
1256static LEGACY_CONTRACT_WARNED: std::sync::OnceLock<
1275 std::sync::Mutex<std::collections::HashSet<String>>,
1276> = std::sync::OnceLock::new();
1277
1278fn warn_if_legacy_contract(ext_id: &str, version: &str, newest: &str) {
1282 if version == newest {
1283 return;
1284 }
1285 let set = LEGACY_CONTRACT_WARNED
1286 .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
1287 let mut guard = match set.lock() {
1288 Ok(g) => g,
1289 Err(poisoned) => poisoned.into_inner(),
1290 };
1291 if guard.insert(ext_id.to_string()) {
1292 tracing::warn!(
1293 extension = %ext_id,
1294 contract = version,
1295 newest = newest,
1296 "extension uses a deprecated WIT contract version; rebuild against the current contract (unified 6-variant extension-error)"
1297 );
1298 }
1299}
1300
1301pub(crate) fn resolve_iface_versions(
1306 store: &mut wasmtime::Store<crate::host_state::HostState>,
1307 instance: &wasmtime::component::Instance,
1308 base: &str,
1309 versions: &[&'static str],
1310) -> Result<
1311 (
1312 wasmtime::component::ComponentExportIndex,
1313 String,
1314 &'static str,
1315 ),
1316 RuntimeError,
1317> {
1318 for &v in versions {
1319 let name = format!("{base}@{v}");
1320 if let Some(idx) = instance.get_export_index(&mut *store, None, &name) {
1321 return Ok((idx, name, v));
1322 }
1323 }
1324 Err(RuntimeError::Wasmtime(anyhow::anyhow!(
1325 "extension does not export interface '{base}' at any supported version ({versions:?})"
1326 )))
1327}
1328
1329const DESIGN_VERSIONS: &[&str] = &["0.3.0", "0.2.0", "0.1.0"];
1331pub(crate) const DEPLOY_VERSIONS: &[&str] = &["0.2.0", "0.1.0"];
1332const BUNDLE_VERSIONS: &[&str] = &["0.2.0", "0.1.0"];
1333const GUARDRAIL_VERSIONS: &[&str] = &["0.3.0"];
1335
1336fn resolve_design_iface(
1337 store: &mut wasmtime::Store<crate::host_state::HostState>,
1338 instance: &wasmtime::component::Instance,
1339 base: &str,
1340) -> Result<(wasmtime::component::ComponentExportIndex, String), RuntimeError> {
1341 resolve_iface_versions(store, instance, base, DESIGN_VERSIONS).map(|(idx, name, _)| (idx, name))
1342}
1343
1344fn find_extension_dir(p: &std::path::Path) -> Option<std::path::PathBuf> {
1345 let mut cur = p;
1346 loop {
1347 if cur.join("describe.json").exists() {
1348 return Some(cur.to_path_buf());
1349 }
1350 cur = cur.parent()?;
1351 }
1352}
1353
1354impl ExtensionRuntime {
1355 pub fn render_bundle(
1373 &self,
1374 ext_id: &str,
1375 recipe_id: &str,
1376 config_json: &str,
1377 session: crate::types::BundleSession,
1378 ) -> Result<crate::types::BundleArtifact, RuntimeError> {
1379 let loaded = self
1380 .loaded
1381 .load()
1382 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1383 .cloned()
1384 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1385
1386 let (mut store, instance) = loaded
1387 .build_store_and_instance(
1388 &self.engine,
1389 self.config.host_overrides.clone(),
1390 &crate::host_ports::HostCallContext::default(),
1391 )
1392 .map_err(RuntimeError::Wasmtime)?;
1393
1394 let (iface_idx, iface_name, version) = resolve_iface_versions(
1395 &mut store,
1396 &instance,
1397 "greentic:extension-bundle/bundling",
1398 BUNDLE_VERSIONS,
1399 )?;
1400 let func_idx = instance
1401 .get_export_index(&mut store, Some(&iface_idx), "render")
1402 .ok_or_else(|| {
1403 RuntimeError::Wasmtime(anyhow::anyhow!(
1404 "interface '{iface_name}' does not export 'render'"
1405 ))
1406 })?;
1407
1408 let mapped: Result<crate::types::BundleArtifact, crate::types::HostExtensionError> =
1409 if version == "0.2.0" {
1410 use crate::host_bindings::bundle_v02::exports::greentic::extension_bundle0_2_0::bundling::{
1411 BundleArtifact as WitBundleArtifact, DesignerSession as WitDesignerSession,
1412 };
1413 use crate::host_bindings::bundle_v02::greentic::extension_base0_2_0::types::ExtensionError as E2;
1414 let func = instance
1415 .get_typed_func::<
1416 (String, String, WitDesignerSession),
1417 (Result<WitBundleArtifact, E2>,),
1418 >(&mut store, &func_idx)
1419 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1420 let wit_session = WitDesignerSession {
1421 flows_json: session.flows_json,
1422 contents_json: session.contents_json,
1423 assets: session.assets,
1424 capabilities_used: session.capabilities_used,
1425 };
1426 let (r,) = func
1427 .call(
1428 &mut store,
1429 (recipe_id.to_string(), config_json.to_string(), wit_session),
1430 )
1431 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1432 r.map(|a| crate::types::BundleArtifact {
1433 filename: a.filename,
1434 bytes: a.bytes,
1435 sha256: a.sha256,
1436 })
1437 .map_err(crate::ext_error::from_bundle_v02)
1438 } else {
1439 use crate::host_bindings::bundle::exports::greentic::extension_bundle0_1_0::bundling::{
1440 BundleArtifact as WitBundleArtifact, DesignerSession as WitDesignerSession,
1441 };
1442 use crate::host_bindings::bundle::greentic::extension_base0_1_0::types::ExtensionError as E1;
1443 let func = instance
1444 .get_typed_func::<
1445 (String, String, WitDesignerSession),
1446 (Result<WitBundleArtifact, E1>,),
1447 >(&mut store, &func_idx)
1448 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1449 let wit_session = WitDesignerSession {
1450 flows_json: session.flows_json,
1451 contents_json: session.contents_json,
1452 assets: session.assets,
1453 capabilities_used: session.capabilities_used,
1454 };
1455 let (r,) = func
1456 .call(
1457 &mut store,
1458 (recipe_id.to_string(), config_json.to_string(), wit_session),
1459 )
1460 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1461 r.map(|a| crate::types::BundleArtifact {
1462 filename: a.filename,
1463 bytes: a.bytes,
1464 sha256: a.sha256,
1465 })
1466 .map_err(crate::ext_error::from_bundle_v01)
1467 };
1468
1469 mapped.map_err(RuntimeError::Extension)
1470 }
1471}
1472
1473#[cfg(test)]
1474mod deploy_tests {
1475 use super::*;
1476
1477 #[test]
1478 fn list_targets_returns_error_for_unknown_extension() {
1479 let tmp = tempfile::TempDir::new().unwrap();
1480 let config =
1481 RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1482 let rt = ExtensionRuntime::new(config).unwrap();
1483 let err = rt.list_targets("does-not-exist").unwrap_err();
1484 match err {
1485 RuntimeError::NotFound(id) => assert_eq!(id, "does-not-exist"),
1486 other => panic!("expected NotFound, got {other:?}"),
1487 }
1488 }
1489
1490 #[test]
1491 fn credential_schema_returns_error_for_unknown_extension() {
1492 let tmp = tempfile::TempDir::new().unwrap();
1493 let config =
1494 RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1495 let rt = ExtensionRuntime::new(config).unwrap();
1496 let err = rt
1497 .credential_schema("does-not-exist", "some-target")
1498 .unwrap_err();
1499 assert!(matches!(err, RuntimeError::NotFound(_)));
1500 }
1501
1502 #[test]
1503 fn validate_credentials_returns_error_for_unknown_extension() {
1504 let tmp = tempfile::TempDir::new().unwrap();
1505 let config =
1506 RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1507 let rt = ExtensionRuntime::new(config).unwrap();
1508 let err = rt
1509 .validate_credentials("does-not-exist", "target", r"{}")
1510 .unwrap_err();
1511 assert!(matches!(err, RuntimeError::NotFound(_)));
1512 }
1513
1514 #[test]
1515 fn render_bundle_returns_error_for_unknown_extension() {
1516 let tmp = tempfile::TempDir::new().unwrap();
1517 let config =
1518 RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1519 let rt = ExtensionRuntime::new(config).unwrap();
1520 let err = rt
1521 .render_bundle(
1522 "does-not-exist",
1523 "standard",
1524 "{}",
1525 crate::types::BundleSession::default(),
1526 )
1527 .unwrap_err();
1528 assert!(matches!(err, RuntimeError::NotFound(_)));
1529 }
1530
1531 #[test]
1532 fn for_test_constructs_runtime_with_no_extensions() {
1533 let runtime = ExtensionRuntime::for_test();
1534 assert!(
1536 runtime.loaded().is_empty(),
1537 "for_test runtime must have zero loaded extensions"
1538 );
1539 }
1540}