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_value: serde_json::Value = serde_json::from_str(&raw)?;
258 let describe = crate::loaded::describe_from_value(describe_value)
259 .map_err(|e| RuntimeError::Wasmtime(anyhow::anyhow!("read describe.json: {e}")))?;
260 greentic_extension_sdk_contract::verify_describe_self_consistent(&describe).map_err(
267 |e| RuntimeError::SignatureInvalid {
268 extension_id: describe.metadata.id.clone(),
269 reason: e.to_string(),
270 },
271 )?;
272 let pub_prefix = describe.signature.as_ref().map_or_else(
273 || "?".to_string(),
274 |s| s.public_key.chars().take(16).collect::<String>(),
275 );
276 tracing::info!(
277 extension_id = %describe.metadata.id,
278 key_prefix = %pub_prefix,
279 "extension signature verified"
280 );
281 Self::verify_dir_manifest(dir, &describe)?;
282 Ok(())
283 }
284
285 fn verify_dir_manifest(
301 dir: &std::path::Path,
302 describe: &greentic_extension_sdk_contract::DescribeJson,
303 ) -> Result<(), RuntimeError> {
304 use sha2::{Digest, Sha256};
305 let extension_id = describe.metadata.id.as_str();
306 let manifest_path = dir.join(greentic_extension_sdk_contract::MANIFEST_ENTRY_NAME);
307 if !manifest_path.exists() {
308 return Err(RuntimeError::SignatureInvalid {
309 extension_id: extension_id.to_string(),
310 reason: "manifest.json absent — refusing to load an extension without a \
311 whole-archive integrity ledger (set GREENTIC_EXT_ALLOW_UNSIGNED \
312 with the dev-allow-unsigned build for local dev)"
313 .to_string(),
314 });
315 }
316 let raw = std::fs::read(&manifest_path)?;
317 greentic_extension_sdk_contract::verify_manifest_binding(describe, &raw).map_err(|e| {
321 RuntimeError::SignatureInvalid {
322 extension_id: extension_id.to_string(),
323 reason: format!("manifest binding: {e}"),
324 }
325 })?;
326 let manifest: greentic_extension_sdk_contract::Manifest = serde_json::from_slice(&raw)
327 .map_err(|e| RuntimeError::SignatureInvalid {
328 extension_id: extension_id.to_string(),
329 reason: format!("manifest.json parse: {e}"),
330 })?;
331 if manifest.schema != greentic_extension_sdk_contract::MANIFEST_SCHEMA_V1 {
332 return Err(RuntimeError::SignatureInvalid {
333 extension_id: extension_id.to_string(),
334 reason: format!("manifest schema unsupported: {}", manifest.schema),
335 });
336 }
337 for entry in &manifest.entries {
338 let path = dir.join(&entry.path);
339 if !path.exists() {
340 return Err(RuntimeError::SignatureInvalid {
341 extension_id: extension_id.to_string(),
342 reason: format!("manifest lists missing file: {}", entry.path),
343 });
344 }
345 let bytes = std::fs::read(&path)?;
346 let computed = format!("{:x}", Sha256::digest(&bytes));
347 if computed != entry.sha256 {
348 return Err(RuntimeError::SignatureInvalid {
349 extension_id: extension_id.to_string(),
350 reason: format!(
351 "manifest sha256 mismatch for {}: expected {} got {}",
352 entry.path, entry.sha256, computed
353 ),
354 });
355 }
356 }
357 tracing::info!(
358 extension_id = %extension_id,
359 entries = manifest.entries.len(),
360 "whole-archive manifest verified"
361 );
362 Ok(())
363 }
364
365 pub fn start_watcher(self: Arc<Self>) -> Result<WatcherGuard, RuntimeError> {
370 let mut paths: Vec<std::path::PathBuf> =
371 self.config.paths.all().into_iter().cloned().collect();
372 if let Some(home) = self.config.paths.home()
377 && home.exists()
378 && !paths.iter().any(|p| p == home)
379 {
380 paths.push(home.to_path_buf());
381 }
382 let (rx, watch_handle) = crate::watcher::watch(&paths)?;
383 let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
384 let this = self.clone();
385 let join = std::thread::spawn(move || {
386 let _watch_handle = watch_handle;
389 loop {
390 match stop_rx.try_recv() {
392 Ok(()) | Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
393 Err(std::sync::mpsc::TryRecvError::Empty) => {}
394 }
395 match rx.recv_timeout(std::time::Duration::from_millis(200)) {
396 Ok(event) => {
397 if let Err(e) = this.handle_fs_event(&event) {
398 tracing::warn!(error = %e, "hot reload failed");
399 }
400 }
401 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
402 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
403 }
404 }
405 });
406 Ok(WatcherGuard {
407 stop_tx: Some(stop_tx),
408 join: Some(join),
409 })
410 }
411
412 fn handle_fs_event(&self, event: &crate::watcher::FsEvent) -> Result<(), RuntimeError> {
413 use crate::watcher::FsEvent;
414 let path = match event {
415 FsEvent::Added(p) | FsEvent::Modified(p) | FsEvent::Removed(p) => p.clone(),
416 };
417
418 if path.file_name().is_some_and(|n| n == STATE_FILENAME) {
423 let _ = self.events.send(RuntimeEvent::StateFileChanged);
424 return Ok(());
425 }
426
427 let ext_dir = find_extension_dir(&path);
428 match event {
429 FsEvent::Removed(_) => {
430 if let Some(dir) = ext_dir {
431 self.handle_removal(&dir);
432 }
433 }
434 FsEvent::Added(_) | FsEvent::Modified(_) => {
435 if let Some(dir) = ext_dir {
436 self.handle_added_or_modified(&dir)?;
437 }
438 }
439 }
440 Ok(())
441 }
442
443 fn handle_removal(&self, dir: &std::path::Path) {
444 let current = self.loaded.load();
445 let Some((id, _)) = current.iter().find(|(_, v)| v.source_dir == dir) else {
446 return;
447 };
448 let id = id.clone();
449 let mut new_map = (**current).clone();
450 new_map.remove(&id);
451 self.loaded.store(Arc::new(new_map));
452 let _ = self.events.send(RuntimeEvent::ExtensionRemoved(id));
453 }
454
455 fn handle_added_or_modified(&self, dir: &std::path::Path) -> Result<(), RuntimeError> {
456 let loaded = crate::loaded::LoadedExtension::load_from_dir(&self.engine, dir)?;
457 let id = loaded.id.clone();
458 let mut new_map = (**self.loaded.load()).clone();
459 let prev_version = new_map
460 .get(&id)
461 .map(|e| e.describe.metadata.version.clone());
462 new_map.insert(id.clone(), Arc::new(loaded));
463 self.loaded.store(Arc::new(new_map));
464 let event = match prev_version {
465 Some(prev) => RuntimeEvent::ExtensionUpdated {
466 id,
467 prev_version: prev,
468 },
469 None => RuntimeEvent::ExtensionInstalled(id),
470 };
471 let _ = self.events.send(event);
472 Ok(())
473 }
474}
475
476impl ExtensionRuntime {
477 pub fn invoke_tool(
484 &self,
485 ext_id: &str,
486 tool_name: &str,
487 args_json: &str,
488 ) -> Result<String, RuntimeError> {
489 self.invoke_tool_ctx(
490 ext_id,
491 tool_name,
492 args_json,
493 &crate::host_ports::HostCallContext::default(),
494 )
495 }
496
497 pub fn invoke_tool_ctx(
502 &self,
503 ext_id: &str,
504 tool_name: &str,
505 args_json: &str,
506 ctx: &crate::host_ports::HostCallContext,
507 ) -> Result<String, RuntimeError> {
508 let loaded = self
509 .loaded
510 .load()
511 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
512 .cloned()
513 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
514
515 let (mut store, instance) = loaded
516 .build_store_and_instance(&self.engine, self.config.host_overrides.clone(), ctx)
517 .map_err(RuntimeError::Wasmtime)?;
518
519 let (iface_idx, iface_name, version) = resolve_iface_versions(
527 &mut store,
528 &instance,
529 "greentic:extension-design/tools",
530 DESIGN_VERSIONS,
531 )?;
532 warn_if_legacy_contract(ext_id, version, DESIGN_VERSIONS[0]);
533 let func_idx = instance
534 .get_export_index(&mut store, Some(&iface_idx), "invoke-tool")
535 .ok_or_else(|| {
536 RuntimeError::Wasmtime(anyhow::anyhow!(
537 "interface '{iface_name}' does not export 'invoke-tool'"
538 ))
539 })?;
540
541 let call_args = (tool_name.to_string(), args_json.to_string());
542 let mapped: Result<String, crate::types::HostExtensionError> = if version == "0.3.0" {
544 use crate::host_bindings::design_v03::greentic::extension_base0_2_0::types::ExtensionError as E2;
545 let func = instance
546 .get_typed_func::<(String, String), (Result<String, E2>,)>(&mut store, &func_idx)
547 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
548 let (r,) = func
549 .call(&mut store, call_args)
550 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
551 r.map_err(crate::ext_error::from_design_v03)
552 } else {
553 use crate::host_bindings::greentic::extension_base0_1_0::types::ExtensionError as E1;
554 let func = instance
555 .get_typed_func::<(String, String), (Result<String, E1>,)>(&mut store, &func_idx)
556 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
557 let (r,) = func
558 .call(&mut store, call_args)
559 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
560 r.map_err(crate::ext_error::from_design_v01)
561 };
562
563 mapped.map_err(RuntimeError::Extension)
564 }
565}
566
567impl ExtensionRuntime {
568 pub fn evaluate_guardrail(
601 &self,
602 ext_id: &str,
603 input_json: &str,
604 ) -> Result<String, RuntimeError> {
605 let loaded = self
606 .loaded
607 .load()
608 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
609 .cloned()
610 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
611
612 let (mut store, instance) = loaded
613 .build_store_and_instance(
614 &self.engine,
615 self.config.host_overrides.clone(),
616 &crate::host_ports::HostCallContext::default(),
617 )
618 .map_err(RuntimeError::Wasmtime)?;
619
620 let (iface_idx, iface_name, _version) = resolve_iface_versions(
622 &mut store,
623 &instance,
624 "greentic:extension-design/guardrail",
625 GUARDRAIL_VERSIONS,
626 )?;
627
628 let func_idx = instance
629 .get_export_index(&mut store, Some(&iface_idx), "evaluate")
630 .ok_or_else(|| {
631 RuntimeError::Wasmtime(anyhow::anyhow!(
632 "interface '{iface_name}' does not export 'evaluate'"
633 ))
634 })?;
635
636 let wire =
637 crate::guardrail_map::call_evaluate(&mut store, &instance, &func_idx, input_json)?;
638
639 serde_json::to_string(&wire).map_err(|e| RuntimeError::Wasmtime(e.into()))
640 }
641}
642
643impl ExtensionRuntime {
644 pub fn validate_content(
658 &self,
659 ext_id: &str,
660 content_type: &str,
661 content_json: &str,
662 ) -> Result<crate::types::ValidateResult, RuntimeError> {
663 use crate::host_bindings::exports::greentic::extension_design0_2_0::validation::{
664 Diagnostic as WitDiagnostic, ValidateResult as WitValidateResult,
665 };
666 use crate::host_bindings::greentic::extension_base0_1_0::types::Severity as WitSeverity;
667
668 let loaded = self
669 .loaded
670 .load()
671 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
672 .cloned()
673 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
674
675 let (mut store, instance) = loaded
676 .build_store_and_instance(
677 &self.engine,
678 self.config.host_overrides.clone(),
679 &crate::host_ports::HostCallContext::default(),
680 )
681 .map_err(RuntimeError::Wasmtime)?;
682
683 let (iface_idx, iface_name) = resolve_design_iface(
684 &mut store,
685 &instance,
686 "greentic:extension-design/validation",
687 )?;
688 let func_idx = instance
689 .get_export_index(&mut store, Some(&iface_idx), "validate-content")
690 .ok_or_else(|| {
691 RuntimeError::Wasmtime(anyhow::anyhow!(
692 "interface '{iface_name}' does not export 'validate-content'"
693 ))
694 })?;
695
696 let func = instance
697 .get_typed_func::<(String, String), (WitValidateResult,)>(&mut store, &func_idx)
698 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
699
700 let (result,) = func
701 .call(
702 &mut store,
703 (content_type.to_string(), content_json.to_string()),
704 )
705 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
706
707 let diagnostics = result
708 .diagnostics
709 .into_iter()
710 .map(|d: WitDiagnostic| crate::types::Diagnostic {
711 severity: match d.severity {
712 WitSeverity::Error => crate::types::Severity::Error,
713 WitSeverity::Warning => crate::types::Severity::Warning,
714 WitSeverity::Info => crate::types::Severity::Info,
715 WitSeverity::Hint => crate::types::Severity::Hint,
716 },
717 code: d.code,
718 message: d.message,
719 path: d.path,
720 })
721 .collect();
722
723 Ok(crate::types::ValidateResult {
724 valid: result.valid,
725 diagnostics,
726 })
727 }
728}
729
730#[must_use]
738pub fn contribution_tool_to_definition(
739 t: &greentic_extension_sdk_contract::describe::contributions::Tool,
740) -> crate::types::ToolDefinition {
741 crate::types::ToolDefinition {
742 name: t.name.clone(),
743 description: t.description.clone().unwrap_or_default(),
744 input_schema_json: t.input_schema.clone().unwrap_or_default(),
745 output_schema_json: None,
746 capabilities: t.capabilities.clone(),
747 agentic_worker_metadata: None,
748 secret_requirements: t.secret_requirements.clone(),
749 }
750}
751
752impl ExtensionRuntime {
753 pub fn list_tools(
764 &self,
765 ext_id: &str,
766 ) -> Result<Vec<crate::types::ToolDefinition>, RuntimeError> {
767 use crate::host_bindings::exports::greentic::extension_design0_2_0::tools::ToolDefinition as WitToolDef;
768
769 let loaded = self
770 .loaded
771 .load()
772 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
773 .cloned()
774 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
775
776 if loaded.describe.api_version == "greentic.ai/v2" {
778 return Ok(loaded
779 .describe
780 .contributions
781 .tools
782 .iter()
783 .map(contribution_tool_to_definition)
784 .collect());
785 }
786
787 let (mut store, instance) = loaded
789 .build_store_and_instance(
790 &self.engine,
791 self.config.host_overrides.clone(),
792 &crate::host_ports::HostCallContext::default(),
793 )
794 .map_err(RuntimeError::Wasmtime)?;
795
796 let (iface_idx, iface_name) =
797 resolve_design_iface(&mut store, &instance, "greentic:extension-design/tools")?;
798 let func_idx = instance
799 .get_export_index(&mut store, Some(&iface_idx), "list-tools")
800 .ok_or_else(|| {
801 RuntimeError::Wasmtime(anyhow::anyhow!(
802 "interface '{iface_name}' does not export 'list-tools'"
803 ))
804 })?;
805
806 let func = instance
807 .get_typed_func::<(), (Vec<WitToolDef>,)>(&mut store, &func_idx)
808 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
809
810 let (defs,) = func
811 .call(&mut store, ())
812 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
813
814 Ok(defs
815 .into_iter()
816 .map(|d| crate::types::ToolDefinition {
817 name: d.name,
818 description: d.description,
819 input_schema_json: d.input_schema_json,
820 output_schema_json: d.output_schema_json,
821 capabilities: d.capabilities,
822 agentic_worker_metadata: d.agentic_worker_metadata,
823 secret_requirements: Vec::new(),
824 })
825 .collect())
826 }
827}
828
829impl ExtensionRuntime {
830 pub fn prompt_fragments(
835 &self,
836 ext_id: &str,
837 ) -> Result<Vec<crate::types::PromptFragment>, RuntimeError> {
838 use crate::host_bindings::exports::greentic::extension_design0_2_0::prompting::PromptFragment as WitFrag;
839
840 let loaded = self
841 .loaded
842 .load()
843 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
844 .cloned()
845 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
846
847 let (mut store, instance) = loaded
848 .build_store_and_instance(
849 &self.engine,
850 self.config.host_overrides.clone(),
851 &crate::host_ports::HostCallContext::default(),
852 )
853 .map_err(RuntimeError::Wasmtime)?;
854
855 let (iface_idx, iface_name) =
856 resolve_design_iface(&mut store, &instance, "greentic:extension-design/prompting")?;
857 let func_idx = instance
858 .get_export_index(&mut store, Some(&iface_idx), "system-prompt-fragments")
859 .ok_or_else(|| {
860 RuntimeError::Wasmtime(anyhow::anyhow!(
861 "interface '{iface_name}' does not export 'system-prompt-fragments'"
862 ))
863 })?;
864
865 let func = instance
866 .get_typed_func::<(), (Vec<WitFrag>,)>(&mut store, &func_idx)
867 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
868
869 let (frags,) = func
870 .call(&mut store, ())
871 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
872
873 Ok(frags
874 .into_iter()
875 .map(|f| crate::types::PromptFragment {
876 section: f.section,
877 content_markdown: f.content_markdown,
878 priority: f.priority,
879 })
880 .collect())
881 }
882}
883
884impl ExtensionRuntime {
885 pub fn knowledge_list(
890 &self,
891 ext_id: &str,
892 category_filter: Option<&str>,
893 ) -> Result<Vec<crate::types::KnowledgeEntrySummary>, RuntimeError> {
894 use crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::EntrySummary as WitSummary;
895
896 let loaded = self
897 .loaded
898 .load()
899 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
900 .cloned()
901 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
902
903 let (mut store, instance) = loaded
904 .build_store_and_instance(
905 &self.engine,
906 self.config.host_overrides.clone(),
907 &crate::host_ports::HostCallContext::default(),
908 )
909 .map_err(RuntimeError::Wasmtime)?;
910
911 let (iface_idx, iface_name) =
912 resolve_design_iface(&mut store, &instance, "greentic:extension-design/knowledge")?;
913 let func_idx = instance
914 .get_export_index(&mut store, Some(&iface_idx), "list-entries")
915 .ok_or_else(|| {
916 RuntimeError::Wasmtime(anyhow::anyhow!(
917 "interface '{iface_name}' does not export 'list-entries'"
918 ))
919 })?;
920
921 let func = instance
922 .get_typed_func::<(Option<String>,), (Vec<WitSummary>,)>(&mut store, &func_idx)
923 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
924
925 let (entries,) = func
926 .call(&mut store, (category_filter.map(String::from),))
927 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
928
929 Ok(entries.into_iter().map(wit_summary_to_host).collect())
930 }
931
932 pub fn knowledge_get(
940 &self,
941 ext_id: &str,
942 entry_id: &str,
943 ) -> Result<crate::types::KnowledgeEntry, RuntimeError> {
944 let loaded = self
945 .loaded
946 .load()
947 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
948 .cloned()
949 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
950
951 let (mut store, instance) = loaded
952 .build_store_and_instance(
953 &self.engine,
954 self.config.host_overrides.clone(),
955 &crate::host_ports::HostCallContext::default(),
956 )
957 .map_err(RuntimeError::Wasmtime)?;
958
959 let (iface_idx, iface_name, version) = resolve_iface_versions(
960 &mut store,
961 &instance,
962 "greentic:extension-design/knowledge",
963 DESIGN_VERSIONS,
964 )?;
965 let func_idx = instance
966 .get_export_index(&mut store, Some(&iface_idx), "get-entry")
967 .ok_or_else(|| {
968 RuntimeError::Wasmtime(anyhow::anyhow!(
969 "interface '{iface_name}' does not export 'get-entry'"
970 ))
971 })?;
972
973 let call_args = (entry_id.to_string(),);
974 let mapped: Result<crate::types::KnowledgeEntry, crate::types::HostExtensionError> =
975 if version == "0.3.0" {
976 use crate::host_bindings::design_v03::exports::greentic::extension_design0_3_0::knowledge::{
977 Entry as WitEntry, ExtensionError as E2,
978 };
979 let func = instance
980 .get_typed_func::<(String,), (Result<WitEntry, E2>,)>(&mut store, &func_idx)
981 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
982 let (r,) = func
983 .call(&mut store, call_args)
984 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
985 r.map(|e| crate::types::KnowledgeEntry {
986 id: e.id,
987 title: e.title,
988 category: e.category,
989 tags: e.tags,
990 content_json: e.content_json,
991 })
992 .map_err(crate::ext_error::from_design_v03)
993 } else {
994 use crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::{
995 Entry as WitEntry, ExtensionError as E1,
996 };
997 let func = instance
998 .get_typed_func::<(String,), (Result<WitEntry, E1>,)>(&mut store, &func_idx)
999 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1000 let (r,) = func
1001 .call(&mut store, call_args)
1002 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1003 r.map(|e| crate::types::KnowledgeEntry {
1004 id: e.id,
1005 title: e.title,
1006 category: e.category,
1007 tags: e.tags,
1008 content_json: e.content_json,
1009 })
1010 .map_err(crate::ext_error::from_design_v01)
1011 };
1012
1013 mapped.map_err(RuntimeError::Extension)
1014 }
1015
1016 pub fn knowledge_suggest(
1021 &self,
1022 ext_id: &str,
1023 query: &str,
1024 limit: u32,
1025 ) -> Result<Vec<crate::types::KnowledgeEntrySummary>, RuntimeError> {
1026 use crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::EntrySummary as WitSummary;
1027
1028 let loaded = self
1029 .loaded
1030 .load()
1031 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1032 .cloned()
1033 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1034
1035 let (mut store, instance) = loaded
1036 .build_store_and_instance(
1037 &self.engine,
1038 self.config.host_overrides.clone(),
1039 &crate::host_ports::HostCallContext::default(),
1040 )
1041 .map_err(RuntimeError::Wasmtime)?;
1042
1043 let (iface_idx, iface_name) =
1044 resolve_design_iface(&mut store, &instance, "greentic:extension-design/knowledge")?;
1045 let func_idx = instance
1046 .get_export_index(&mut store, Some(&iface_idx), "suggest-entries")
1047 .ok_or_else(|| {
1048 RuntimeError::Wasmtime(anyhow::anyhow!(
1049 "interface '{iface_name}' does not export 'suggest-entries'"
1050 ))
1051 })?;
1052
1053 let func = instance
1054 .get_typed_func::<(String, u32), (Vec<WitSummary>,)>(&mut store, &func_idx)
1055 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1056
1057 let (entries,) = func
1058 .call(&mut store, (query.to_string(), limit))
1059 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1060
1061 Ok(entries.into_iter().map(wit_summary_to_host).collect())
1062 }
1063}
1064
1065fn wit_summary_to_host(
1067 s: crate::host_bindings::exports::greentic::extension_design0_2_0::knowledge::EntrySummary,
1068) -> crate::types::KnowledgeEntrySummary {
1069 crate::types::KnowledgeEntrySummary {
1070 id: s.id,
1071 title: s.title,
1072 category: s.category,
1073 tags: s.tags,
1074 }
1075}
1076
1077impl ExtensionRuntime {
1078 pub fn validate_credentials(
1081 &self,
1082 ext_id: &str,
1083 target_id: &str,
1084 credentials_json: &str,
1085 ) -> Result<Vec<crate::types::Diagnostic>, RuntimeError> {
1086 use crate::host_bindings::deploy::exports::greentic::extension_deploy0_1_0::targets::Diagnostic as WitDiagnostic;
1087 use crate::host_bindings::deploy::greentic::extension_base0_1_0::types::Severity as WitSeverity;
1088
1089 let loaded = self
1090 .loaded
1091 .load()
1092 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1093 .cloned()
1094 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1095
1096 let (mut store, instance) = loaded
1097 .build_store_and_instance(
1098 &self.engine,
1099 self.config.host_overrides.clone(),
1100 &crate::host_ports::HostCallContext::default(),
1101 )
1102 .map_err(RuntimeError::Wasmtime)?;
1103
1104 let (iface_idx, iface_name, _version) = resolve_iface_versions(
1105 &mut store,
1106 &instance,
1107 "greentic:extension-deploy/targets",
1108 DEPLOY_VERSIONS,
1109 )?;
1110 let func_idx = instance
1111 .get_export_index(&mut store, Some(&iface_idx), "validate-credentials")
1112 .ok_or_else(|| {
1113 RuntimeError::Wasmtime(anyhow::anyhow!(
1114 "interface '{iface_name}' does not export 'validate-credentials'"
1115 ))
1116 })?;
1117
1118 let func = instance
1119 .get_typed_func::<(String, String), (Vec<WitDiagnostic>,)>(&mut store, &func_idx)
1120 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1121
1122 let (result,) = func
1123 .call(
1124 &mut store,
1125 (target_id.to_string(), credentials_json.to_string()),
1126 )
1127 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1128
1129 Ok(result
1130 .into_iter()
1131 .map(|d| crate::types::Diagnostic {
1132 severity: match d.severity {
1133 WitSeverity::Error => crate::types::Severity::Error,
1134 WitSeverity::Warning => crate::types::Severity::Warning,
1135 WitSeverity::Info => crate::types::Severity::Info,
1136 WitSeverity::Hint => crate::types::Severity::Hint,
1137 },
1138 code: d.code,
1139 message: d.message,
1140 path: d.path,
1141 })
1142 .collect())
1143 }
1144}
1145
1146impl ExtensionRuntime {
1147 pub fn credential_schema(&self, ext_id: &str, target_id: &str) -> Result<String, RuntimeError> {
1150 let loaded = self
1151 .loaded
1152 .load()
1153 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1154 .cloned()
1155 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1156
1157 let (mut store, instance) = loaded
1158 .build_store_and_instance(
1159 &self.engine,
1160 self.config.host_overrides.clone(),
1161 &crate::host_ports::HostCallContext::default(),
1162 )
1163 .map_err(RuntimeError::Wasmtime)?;
1164
1165 let (iface_idx, iface_name, version) = resolve_iface_versions(
1166 &mut store,
1167 &instance,
1168 "greentic:extension-deploy/targets",
1169 DEPLOY_VERSIONS,
1170 )?;
1171 let func_idx = instance
1172 .get_export_index(&mut store, Some(&iface_idx), "credential-schema")
1173 .ok_or_else(|| {
1174 RuntimeError::Wasmtime(anyhow::anyhow!(
1175 "interface '{iface_name}' does not export 'credential-schema'"
1176 ))
1177 })?;
1178
1179 let call_args = (target_id.to_string(),);
1180 let mapped: Result<String, crate::types::HostExtensionError> = if version == "0.2.0" {
1181 use crate::host_bindings::deploy_v02::greentic::extension_base0_2_0::types::ExtensionError as E2;
1182 let func = instance
1183 .get_typed_func::<(String,), (Result<String, E2>,)>(&mut store, &func_idx)
1184 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1185 let (r,) = func
1186 .call(&mut store, call_args)
1187 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1188 r.map_err(crate::ext_error::from_deploy_v02)
1189 } else {
1190 use crate::host_bindings::deploy::greentic::extension_base0_1_0::types::ExtensionError as E1;
1191 let func = instance
1192 .get_typed_func::<(String,), (Result<String, E1>,)>(&mut store, &func_idx)
1193 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1194 let (r,) = func
1195 .call(&mut store, call_args)
1196 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1197 r.map_err(crate::ext_error::from_deploy_v01)
1198 };
1199
1200 mapped.map_err(RuntimeError::Extension)
1201 }
1202}
1203
1204impl ExtensionRuntime {
1205 pub fn list_targets(
1209 &self,
1210 ext_id: &str,
1211 ) -> Result<Vec<crate::types::TargetSummary>, RuntimeError> {
1212 use crate::host_bindings::deploy::exports::greentic::extension_deploy0_1_0::targets::TargetSummary as WitTargetSummary;
1213
1214 let loaded = self
1215 .loaded
1216 .load()
1217 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1218 .cloned()
1219 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1220
1221 let (mut store, instance) = loaded
1222 .build_store_and_instance(
1223 &self.engine,
1224 self.config.host_overrides.clone(),
1225 &crate::host_ports::HostCallContext::default(),
1226 )
1227 .map_err(RuntimeError::Wasmtime)?;
1228
1229 let (iface_idx, iface_name, _version) = resolve_iface_versions(
1230 &mut store,
1231 &instance,
1232 "greentic:extension-deploy/targets",
1233 DEPLOY_VERSIONS,
1234 )?;
1235 let func_idx = instance
1236 .get_export_index(&mut store, Some(&iface_idx), "list-targets")
1237 .ok_or_else(|| {
1238 RuntimeError::Wasmtime(anyhow::anyhow!(
1239 "interface '{iface_name}' does not export 'list-targets'"
1240 ))
1241 })?;
1242
1243 let func = instance
1244 .get_typed_func::<(), (Vec<WitTargetSummary>,)>(&mut store, &func_idx)
1245 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1246
1247 let (result,) = func
1248 .call(&mut store, ())
1249 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1250
1251 Ok(result
1252 .into_iter()
1253 .map(|t| crate::types::TargetSummary {
1254 id: t.id,
1255 display_name: t.display_name,
1256 description: t.description,
1257 icon_path: t.icon_path,
1258 supports_rollback: t.supports_rollback,
1259 })
1260 .collect())
1261 }
1262}
1263
1264static LEGACY_CONTRACT_WARNED: std::sync::OnceLock<
1283 std::sync::Mutex<std::collections::HashSet<String>>,
1284> = std::sync::OnceLock::new();
1285
1286fn warn_if_legacy_contract(ext_id: &str, version: &str, newest: &str) {
1290 if version == newest {
1291 return;
1292 }
1293 let set = LEGACY_CONTRACT_WARNED
1294 .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
1295 let mut guard = match set.lock() {
1296 Ok(g) => g,
1297 Err(poisoned) => poisoned.into_inner(),
1298 };
1299 if guard.insert(ext_id.to_string()) {
1300 tracing::warn!(
1301 extension = %ext_id,
1302 contract = version,
1303 newest = newest,
1304 "extension uses a deprecated WIT contract version; rebuild against the current contract (unified 6-variant extension-error)"
1305 );
1306 }
1307}
1308
1309pub(crate) fn resolve_iface_versions(
1314 store: &mut wasmtime::Store<crate::host_state::HostState>,
1315 instance: &wasmtime::component::Instance,
1316 base: &str,
1317 versions: &[&'static str],
1318) -> Result<
1319 (
1320 wasmtime::component::ComponentExportIndex,
1321 String,
1322 &'static str,
1323 ),
1324 RuntimeError,
1325> {
1326 for &v in versions {
1327 let name = format!("{base}@{v}");
1328 if let Some(idx) = instance.get_export_index(&mut *store, None, &name) {
1329 return Ok((idx, name, v));
1330 }
1331 }
1332 Err(RuntimeError::Wasmtime(anyhow::anyhow!(
1333 "extension does not export interface '{base}' at any supported version ({versions:?})"
1334 )))
1335}
1336
1337const DESIGN_VERSIONS: &[&str] = &["0.3.0", "0.2.0", "0.1.0"];
1339pub(crate) const DEPLOY_VERSIONS: &[&str] = &["0.2.0", "0.1.0"];
1340const BUNDLE_VERSIONS: &[&str] = &["0.2.0", "0.1.0"];
1341const GUARDRAIL_VERSIONS: &[&str] = &["0.3.0"];
1343
1344fn resolve_design_iface(
1345 store: &mut wasmtime::Store<crate::host_state::HostState>,
1346 instance: &wasmtime::component::Instance,
1347 base: &str,
1348) -> Result<(wasmtime::component::ComponentExportIndex, String), RuntimeError> {
1349 resolve_iface_versions(store, instance, base, DESIGN_VERSIONS).map(|(idx, name, _)| (idx, name))
1350}
1351
1352fn find_extension_dir(p: &std::path::Path) -> Option<std::path::PathBuf> {
1353 let mut cur = p;
1354 loop {
1355 if cur.join("describe.json").exists() {
1356 return Some(cur.to_path_buf());
1357 }
1358 cur = cur.parent()?;
1359 }
1360}
1361
1362impl ExtensionRuntime {
1363 pub fn render_bundle(
1381 &self,
1382 ext_id: &str,
1383 recipe_id: &str,
1384 config_json: &str,
1385 session: crate::types::BundleSession,
1386 ) -> Result<crate::types::BundleArtifact, RuntimeError> {
1387 let loaded = self
1388 .loaded
1389 .load()
1390 .get(&crate::loaded::ExtensionId(ext_id.to_string()))
1391 .cloned()
1392 .ok_or_else(|| RuntimeError::NotFound(ext_id.to_string()))?;
1393
1394 let (mut store, instance) = loaded
1395 .build_store_and_instance(
1396 &self.engine,
1397 self.config.host_overrides.clone(),
1398 &crate::host_ports::HostCallContext::default(),
1399 )
1400 .map_err(RuntimeError::Wasmtime)?;
1401
1402 let (iface_idx, iface_name, version) = resolve_iface_versions(
1403 &mut store,
1404 &instance,
1405 "greentic:extension-bundle/bundling",
1406 BUNDLE_VERSIONS,
1407 )?;
1408 let func_idx = instance
1409 .get_export_index(&mut store, Some(&iface_idx), "render")
1410 .ok_or_else(|| {
1411 RuntimeError::Wasmtime(anyhow::anyhow!(
1412 "interface '{iface_name}' does not export 'render'"
1413 ))
1414 })?;
1415
1416 let mapped: Result<crate::types::BundleArtifact, crate::types::HostExtensionError> =
1417 if version == "0.2.0" {
1418 use crate::host_bindings::bundle_v02::exports::greentic::extension_bundle0_2_0::bundling::{
1419 BundleArtifact as WitBundleArtifact, DesignerSession as WitDesignerSession,
1420 };
1421 use crate::host_bindings::bundle_v02::greentic::extension_base0_2_0::types::ExtensionError as E2;
1422 let func = instance
1423 .get_typed_func::<
1424 (String, String, WitDesignerSession),
1425 (Result<WitBundleArtifact, E2>,),
1426 >(&mut store, &func_idx)
1427 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1428 let wit_session = WitDesignerSession {
1429 flows_json: session.flows_json,
1430 contents_json: session.contents_json,
1431 assets: session.assets,
1432 capabilities_used: session.capabilities_used,
1433 };
1434 let (r,) = func
1435 .call(
1436 &mut store,
1437 (recipe_id.to_string(), config_json.to_string(), wit_session),
1438 )
1439 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1440 r.map(|a| crate::types::BundleArtifact {
1441 filename: a.filename,
1442 bytes: a.bytes,
1443 sha256: a.sha256,
1444 })
1445 .map_err(crate::ext_error::from_bundle_v02)
1446 } else {
1447 use crate::host_bindings::bundle::exports::greentic::extension_bundle0_1_0::bundling::{
1448 BundleArtifact as WitBundleArtifact, DesignerSession as WitDesignerSession,
1449 };
1450 use crate::host_bindings::bundle::greentic::extension_base0_1_0::types::ExtensionError as E1;
1451 let func = instance
1452 .get_typed_func::<
1453 (String, String, WitDesignerSession),
1454 (Result<WitBundleArtifact, E1>,),
1455 >(&mut store, &func_idx)
1456 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1457 let wit_session = WitDesignerSession {
1458 flows_json: session.flows_json,
1459 contents_json: session.contents_json,
1460 assets: session.assets,
1461 capabilities_used: session.capabilities_used,
1462 };
1463 let (r,) = func
1464 .call(
1465 &mut store,
1466 (recipe_id.to_string(), config_json.to_string(), wit_session),
1467 )
1468 .map_err(|e| RuntimeError::Wasmtime(e.into()))?;
1469 r.map(|a| crate::types::BundleArtifact {
1470 filename: a.filename,
1471 bytes: a.bytes,
1472 sha256: a.sha256,
1473 })
1474 .map_err(crate::ext_error::from_bundle_v01)
1475 };
1476
1477 mapped.map_err(RuntimeError::Extension)
1478 }
1479}
1480
1481#[cfg(test)]
1482mod deploy_tests {
1483 use super::*;
1484
1485 #[test]
1486 fn list_targets_returns_error_for_unknown_extension() {
1487 let tmp = tempfile::TempDir::new().unwrap();
1488 let config =
1489 RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1490 let rt = ExtensionRuntime::new(config).unwrap();
1491 let err = rt.list_targets("does-not-exist").unwrap_err();
1492 match err {
1493 RuntimeError::NotFound(id) => assert_eq!(id, "does-not-exist"),
1494 other => panic!("expected NotFound, got {other:?}"),
1495 }
1496 }
1497
1498 #[test]
1499 fn credential_schema_returns_error_for_unknown_extension() {
1500 let tmp = tempfile::TempDir::new().unwrap();
1501 let config =
1502 RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1503 let rt = ExtensionRuntime::new(config).unwrap();
1504 let err = rt
1505 .credential_schema("does-not-exist", "some-target")
1506 .unwrap_err();
1507 assert!(matches!(err, RuntimeError::NotFound(_)));
1508 }
1509
1510 #[test]
1511 fn validate_credentials_returns_error_for_unknown_extension() {
1512 let tmp = tempfile::TempDir::new().unwrap();
1513 let config =
1514 RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1515 let rt = ExtensionRuntime::new(config).unwrap();
1516 let err = rt
1517 .validate_credentials("does-not-exist", "target", r"{}")
1518 .unwrap_err();
1519 assert!(matches!(err, RuntimeError::NotFound(_)));
1520 }
1521
1522 #[test]
1523 fn render_bundle_returns_error_for_unknown_extension() {
1524 let tmp = tempfile::TempDir::new().unwrap();
1525 let config =
1526 RuntimeConfig::from_paths(crate::DiscoveryPaths::new(tmp.path().to_path_buf()));
1527 let rt = ExtensionRuntime::new(config).unwrap();
1528 let err = rt
1529 .render_bundle(
1530 "does-not-exist",
1531 "standard",
1532 "{}",
1533 crate::types::BundleSession::default(),
1534 )
1535 .unwrap_err();
1536 assert!(matches!(err, RuntimeError::NotFound(_)));
1537 }
1538
1539 #[test]
1540 fn for_test_constructs_runtime_with_no_extensions() {
1541 let runtime = ExtensionRuntime::for_test();
1542 assert!(
1544 runtime.loaded().is_empty(),
1545 "for_test runtime must have zero loaded extensions"
1546 );
1547 }
1548}