1use std::collections::{BTreeMap, HashMap};
2
3use anyhow::{Result, anyhow};
4use serde_json::Value as JsonValue;
5
6use crate::i18n::{I18nCatalog, resolve_text};
7use greentic_interfaces_host::component_v0_6::exports::greentic::component::node::{
8 ComponentDescriptor, SchemaSource,
9};
10use greentic_types::cbor::canonical;
11use greentic_types::schemas::component::v0_6_0::{ComponentQaSpec, QaMode, QuestionKind};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum WizardAbi {
15 V6,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum WizardMode {
20 Default,
21 Setup,
22 Update,
23 Remove,
24}
25
26impl WizardMode {
27 pub fn as_str(self) -> &'static str {
28 match self {
29 WizardMode::Default => "default",
30 WizardMode::Setup => "setup",
31 WizardMode::Update => "update",
32 WizardMode::Remove => "remove",
33 }
34 }
35
36 pub fn as_qa_mode(self) -> QaMode {
37 match self {
38 WizardMode::Default => QaMode::Default,
39 WizardMode::Setup => QaMode::Setup,
40 WizardMode::Update => QaMode::Update,
41 WizardMode::Remove => QaMode::Remove,
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
47pub struct WizardOutput {
48 pub abi: WizardAbi,
49 pub describe_cbor: Vec<u8>,
50 pub descriptor: Option<ComponentDescriptor>,
51 pub qa_spec_cbor: Vec<u8>,
52 pub answers_cbor: Vec<u8>,
53 pub config_cbor: Vec<u8>,
54}
55
56#[cfg(not(target_arch = "wasm32"))]
57pub struct WizardSpecOutput {
58 pub abi: WizardAbi,
59 pub describe_cbor: Vec<u8>,
60 pub descriptor: Option<ComponentDescriptor>,
61 pub qa_spec_cbor: Vec<u8>,
62 pub answers_schema_cbor: Option<Vec<u8>>,
63}
64
65#[cfg(not(target_arch = "wasm32"))]
66#[allow(unsafe_code)]
67mod host {
68 use super::*;
69 use std::sync::{Arc, OnceLock};
70
71 use crate::cache::{ArtifactKey, CacheConfig, CacheManager, CpuPolicy, EngineProfile};
72 use greentic_interfaces_host::component_v0_6::exports::greentic::component::node as canonical_node;
73 use greentic_interfaces_wasmtime::host_helpers::v1::{
74 self, HostFns,
75 http_client::{
76 HttpClientErrorV1_1, HttpClientHostV1_1, RequestOptionsV1_1, RequestV1_1, ResponseV1_1,
77 TenantCtxV1_1,
78 },
79 oauth_broker::OAuthBrokerHost,
80 runner_host_http::RunnerHostHttp,
81 runner_host_kv::RunnerHostKv,
82 secrets_store::{SecretsErrorV1_1, SecretsStoreHostV1_1},
83 state_store::{
84 OpAck, StateKey, StateStoreError, StateStoreHost, TenantCtx as StateTenantCtx,
85 },
86 telemetry_logger::{
87 OpAck as TelemetryOpAck, SpanContext, TelemetryLoggerError, TelemetryLoggerHost,
88 TenantCtx,
89 },
90 };
91 use wasmtime::component::{Component, Linker};
92 use wasmtime::component::{ResourceTable, Val};
93 use wasmtime::{Config, Engine, Store, StoreContextMut};
94 use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
95
96 mod runtime {
97 pub use greentic_interfaces_host::component_v0_6::exports::greentic::component::node;
98 pub use greentic_interfaces_host::component_v0_6::greentic::types_core::core;
99 pub type RuntimeComponent = greentic_interfaces_host::component_v0_6::ComponentV0V6V0;
100 }
101
102 struct HostState {
103 wasi: WasiCtx,
104 table: ResourceTable,
105 http_client: OfflineHttpClient,
106 oauth_broker: OfflineOAuthBroker,
107 runner_http: OfflineRunnerHostHttp,
108 runner_kv: OfflineRunnerHostKv,
109 telemetry_logger: NoopTelemetryLogger,
110 state_store: NoopStateStore,
111 secrets_store: NoopSecretsStore,
112 }
113
114 struct NoopStateStore;
115 struct NoopTelemetryLogger;
116 struct NoopSecretsStore;
117 struct OfflineHttpClient;
118 struct OfflineOAuthBroker;
119 struct OfflineRunnerHostHttp;
120 struct OfflineRunnerHostKv;
121
122 struct WizardRuntimeCache {
123 engine: Engine,
124 component_cache: CacheManager,
125 async_runtime: tokio::runtime::Runtime,
126 }
127
128 impl StateStoreHost for NoopStateStore {
129 fn read(
130 &mut self,
131 _key: StateKey,
132 _ctx: Option<StateTenantCtx>,
133 ) -> std::result::Result<Vec<u8>, StateStoreError> {
134 Ok(Vec::new())
135 }
136
137 fn write(
138 &mut self,
139 _key: StateKey,
140 _bytes: Vec<u8>,
141 _ctx: Option<StateTenantCtx>,
142 ) -> std::result::Result<OpAck, StateStoreError> {
143 Ok(OpAck::Ok)
144 }
145
146 fn delete(
147 &mut self,
148 _key: StateKey,
149 _ctx: Option<StateTenantCtx>,
150 ) -> std::result::Result<OpAck, StateStoreError> {
151 Ok(OpAck::Ok)
152 }
153 }
154
155 impl TelemetryLoggerHost for NoopTelemetryLogger {
156 fn log(
157 &mut self,
158 _span: SpanContext,
159 _fields: wasmtime::component::__internal::Vec<(
160 wasmtime::component::__internal::String,
161 wasmtime::component::__internal::String,
162 )>,
163 _ctx: Option<TenantCtx>,
164 ) -> std::result::Result<TelemetryOpAck, TelemetryLoggerError> {
165 Ok(TelemetryOpAck::Ok)
166 }
167 }
168
169 impl SecretsStoreHostV1_1 for NoopSecretsStore {
170 fn get(
171 &mut self,
172 _key: wasmtime::component::__internal::String,
173 ) -> std::result::Result<Option<wasmtime::component::__internal::Vec<u8>>, SecretsErrorV1_1>
174 {
175 Ok(None)
176 }
177
178 fn put(
179 &mut self,
180 _key: wasmtime::component::__internal::String,
181 _value: wasmtime::component::__internal::Vec<u8>,
182 ) {
183 }
184 }
185
186 impl HttpClientHostV1_1 for OfflineHttpClient {
187 fn send(
188 &mut self,
189 _req: RequestV1_1,
190 _opts: Option<RequestOptionsV1_1>,
191 _ctx: Option<TenantCtxV1_1>,
192 ) -> std::result::Result<ResponseV1_1, HttpClientErrorV1_1> {
193 Ok(ResponseV1_1 {
194 status: 204,
195 headers: Vec::new(),
196 body: None,
197 })
198 }
199 }
200
201 impl OAuthBrokerHost for OfflineOAuthBroker {
202 fn get_consent_url(
203 &mut self,
204 _provider_id: wasmtime::component::__internal::String,
205 _subject: wasmtime::component::__internal::String,
206 _scopes: wasmtime::component::__internal::Vec<wasmtime::component::__internal::String>,
207 _redirect_path: wasmtime::component::__internal::String,
208 _extra_json: wasmtime::component::__internal::String,
209 ) -> wasmtime::component::__internal::String {
210 "offline://oauth-disabled".into()
211 }
212
213 fn exchange_code(
214 &mut self,
215 _provider_id: wasmtime::component::__internal::String,
216 _subject: wasmtime::component::__internal::String,
217 _code: wasmtime::component::__internal::String,
218 _redirect_path: wasmtime::component::__internal::String,
219 ) -> wasmtime::component::__internal::String {
220 String::new()
221 }
222
223 fn get_token(
224 &mut self,
225 _provider_id: wasmtime::component::__internal::String,
226 _subject: wasmtime::component::__internal::String,
227 _scopes: wasmtime::component::__internal::Vec<wasmtime::component::__internal::String>,
228 ) -> wasmtime::component::__internal::String {
229 String::new()
230 }
231 }
232
233 impl RunnerHostHttp for OfflineRunnerHostHttp {
234 fn request(
235 &mut self,
236 _method: wasmtime::component::__internal::String,
237 _url: wasmtime::component::__internal::String,
238 _headers: wasmtime::component::__internal::Vec<wasmtime::component::__internal::String>,
239 _body: Option<wasmtime::component::__internal::Vec<u8>>,
240 ) -> std::result::Result<
241 wasmtime::component::__internal::Vec<u8>,
242 wasmtime::component::__internal::String,
243 > {
244 Ok(Vec::new())
245 }
246 }
247
248 impl RunnerHostKv for OfflineRunnerHostKv {
249 fn get(
250 &mut self,
251 _ns: wasmtime::component::__internal::String,
252 _key: wasmtime::component::__internal::String,
253 ) -> Option<wasmtime::component::__internal::String> {
254 None
255 }
256
257 fn put(
258 &mut self,
259 _ns: wasmtime::component::__internal::String,
260 _key: wasmtime::component::__internal::String,
261 _val: wasmtime::component::__internal::String,
262 ) {
263 }
264 }
265
266 impl HostState {
267 fn new() -> Self {
268 Self {
269 wasi: WasiCtxBuilder::new().build(),
272 table: ResourceTable::new(),
273 http_client: OfflineHttpClient,
274 oauth_broker: OfflineOAuthBroker,
275 runner_http: OfflineRunnerHostHttp,
276 runner_kv: OfflineRunnerHostKv,
277 telemetry_logger: NoopTelemetryLogger,
278 state_store: NoopStateStore,
279 secrets_store: NoopSecretsStore,
280 }
281 }
282 }
283
284 impl WasiView for HostState {
285 fn ctx(&mut self) -> WasiCtxView<'_> {
286 WasiCtxView {
287 ctx: &mut self.wasi,
288 table: &mut self.table,
289 }
290 }
291 }
292
293 fn build_engine() -> Result<Engine> {
294 let mut config = Config::new();
295 config.wasm_component_model(true);
296 Engine::new(&config).map_err(|err| anyhow!("init wasm engine: {err}"))
297 }
298
299 fn wizard_runtime_cache() -> Result<&'static WizardRuntimeCache> {
300 static RUNTIME: OnceLock<Result<WizardRuntimeCache, String>> = OnceLock::new();
301 let runtime =
302 RUNTIME.get_or_init(|| WizardRuntimeCache::new().map_err(|err| format!("{err:#}")));
303 runtime
304 .as_ref()
305 .map_err(|message| anyhow!("init wizard wasm runtime cache: {message}"))
306 }
307
308 impl WizardRuntimeCache {
309 fn new() -> Result<Self> {
310 let engine = build_engine()?;
311 let profile =
312 EngineProfile::from_engine(&engine, CpuPolicy::Native, "default".to_string());
313 let component_cache = CacheManager::new(CacheConfig::default(), profile);
314 let async_runtime = tokio::runtime::Runtime::new()
315 .map_err(|err| anyhow!("init wizard cache async runtime: {err}"))?;
316 Ok(Self {
317 engine,
318 component_cache,
319 async_runtime,
320 })
321 }
322 }
323
324 fn wizard_engine() -> Result<&'static Engine> {
325 Ok(&wizard_runtime_cache()?.engine)
326 }
327
328 fn compute_sha256_digest_for(bytes: &[u8]) -> String {
329 use sha2::Digest as _;
330
331 let mut hasher = sha2::Sha256::new();
332 hasher.update(bytes);
333 let digest = hasher.finalize();
334 format!(
335 "sha256:{}",
336 digest
337 .iter()
338 .map(|byte| format!("{byte:02x}"))
339 .collect::<String>()
340 )
341 }
342
343 fn load_component_cached(wasm_bytes: &[u8]) -> Result<Arc<Component>> {
344 let runtime = wizard_runtime_cache()?;
345 let key = ArtifactKey::new(
346 runtime.component_cache.engine_profile_id().to_string(),
347 compute_sha256_digest_for(wasm_bytes),
348 );
349 let fut = runtime
350 .component_cache
351 .get_component(&runtime.engine, &key, || Ok(wasm_bytes.to_vec()));
352 if let Ok(handle) = tokio::runtime::Handle::try_current() {
353 tokio::task::block_in_place(|| handle.block_on(fut))
354 } else {
355 runtime.async_runtime.block_on(fut)
356 }
357 }
358
359 fn add_wasi_imports(linker: &mut Linker<HostState>) -> Result<()> {
360 wasmtime_wasi::p2::add_to_linker_sync(linker)
361 .map_err(|err| anyhow!("link wasi imports: {err}"))?;
362 v1::add_all_v1_to_linker(
363 linker,
364 HostFns {
365 http_client_v1_1: Some(|state: &mut HostState| &mut state.http_client),
366 http_client: None,
367 oauth_broker: Some(|state: &mut HostState| &mut state.oauth_broker),
368 runner_host_http: Some(|state: &mut HostState| &mut state.runner_http),
369 runner_host_kv: Some(|state: &mut HostState| &mut state.runner_kv),
370 telemetry_logger: Some(|state: &mut HostState| &mut state.telemetry_logger),
371 state_store: Some(|state: &mut HostState| &mut state.state_store),
372 secrets_store_v1_1: Some(|state: &mut HostState| &mut state.secrets_store),
373 secrets_store: None,
374 runtime_config: None,
378 },
379 )
380 .map_err(|err| anyhow!("link Greentic v1 host imports: {err}"))?;
381 add_wasi_cli_environment_0_2_3_compat(linker)?;
382 Ok(())
383 }
384
385 fn add_wasi_cli_environment_0_2_3_compat(linker: &mut Linker<HostState>) -> Result<()> {
386 let mut inst = linker
387 .instance("wasi:cli/environment@0.2.3")
388 .map_err(|err| anyhow!("link wasi:cli/environment@0.2.3 import: {err}"))?;
389 inst.func_wrap(
390 "get-environment",
391 |_caller: StoreContextMut<'_, HostState>,
392 (): ()|
393 -> wasmtime::Result<(Vec<(String, String)>,)> { Ok((Vec::new(),)) },
394 )
395 .map_err(|err| anyhow!("link wasi:cli/environment@0.2.3.get-environment: {err}"))?;
396 inst.func_wrap(
397 "get-arguments",
398 |_caller: StoreContextMut<'_, HostState>, (): ()| -> wasmtime::Result<(Vec<String>,)> {
399 Ok((Vec::new(),))
400 },
401 )
402 .map_err(|err| anyhow!("link wasi:cli/environment@0.2.3.get-arguments: {err}"))?;
403 inst.func_wrap(
404 "initial-cwd",
405 |_caller: StoreContextMut<'_, HostState>,
406 (): ()|
407 -> wasmtime::Result<(Option<String>,)> { Ok((None,)) },
408 )
409 .map_err(|err| anyhow!("link wasi:cli/environment@0.2.3.initial-cwd: {err}"))?;
410 Ok(())
411 }
412
413 fn add_control_imports(linker: &mut Linker<HostState>) -> Result<()> {
414 let mut inst = linker
415 .instance("greentic:component/control@0.6.0")
416 .map_err(|err| anyhow!("link control import: {err}"))?;
417 inst.func_wrap(
418 "should-cancel",
419 |_caller: StoreContextMut<'_, HostState>, (): ()| -> wasmtime::Result<(bool,)> {
420 Ok((false,))
421 },
422 )
423 .map_err(|err| anyhow!("link control.should-cancel: {err}"))?;
424 inst.func_wrap(
425 "yield-now",
426 |_caller: StoreContextMut<'_, HostState>, (): ()| -> wasmtime::Result<()> { Ok(()) },
427 )
428 .map_err(|err| anyhow!("link control.yield-now: {err}"))?;
429 Ok(())
430 }
431
432 fn schema_source_to_cbor(source: &SchemaSource, label: &str) -> Result<Vec<u8>> {
433 match source {
434 SchemaSource::InlineCbor(bytes) => Ok(bytes.clone()),
435 SchemaSource::CborSchemaId(id) => Err(anyhow!(
436 "{label} uses cbor-schema-id '{id}', but greentic-flow requires inline-cbor for wizard execution"
437 )),
438 SchemaSource::RefPackPath(path) => Err(anyhow!(
439 "{label} uses ref-pack-path '{path}', but greentic-flow requires inline-cbor for wizard execution"
440 )),
441 SchemaSource::RefUri(uri) => Err(anyhow!(
442 "{label} uses ref-uri '{uri}', but greentic-flow requires inline-cbor for wizard execution"
443 )),
444 }
445 }
446
447 pub(super) fn extract_setup_contract(
448 descriptor: &ComponentDescriptor,
449 ) -> Result<(Vec<u8>, Option<Vec<u8>>)> {
450 let qa_ref = crate::component_setup::qa_spec_ref(descriptor)
451 .ok_or_else(|| anyhow!("component descriptor missing setup.qa-spec"))?;
452 let qa_spec_cbor = schema_source_to_cbor(qa_ref, "setup.qa-spec")?;
453 let answers_schema_cbor = crate::component_setup::answers_schema_ref(descriptor)
454 .map(|source| schema_source_to_cbor(source, "setup.answers-schema"))
455 .transpose()?;
456 Ok((qa_spec_cbor, answers_schema_cbor))
457 }
458
459 pub(super) fn ensure_setup_apply_answers_op(descriptor: &ComponentDescriptor) -> Result<()> {
460 if descriptor
461 .ops
462 .iter()
463 .any(|op| op.name == "setup.apply_answers")
464 {
465 return Ok(());
466 }
467 Err(anyhow!(
468 "component descriptor does not advertise required op 'setup.apply_answers'"
469 ))
470 }
471
472 fn invoke_envelope(payload_cbor: Vec<u8>) -> runtime::node::InvocationEnvelope {
473 runtime::node::InvocationEnvelope {
474 ctx: runtime::core::TenantCtx {
475 tenant_id: "local".to_string(),
476 team_id: None,
477 user_id: None,
478 env_id: "local".to_string(),
479 trace_id: "trace-local".to_string(),
480 correlation_id: "corr-local".to_string(),
481 deadline_ms: 0,
482 attempt: 0,
483 idempotency_key: None,
484 i18n_id: "en-US".to_string(),
485 },
486 flow_id: "wizard-flow".to_string(),
487 step_id: "wizard-step".to_string(),
488 component_id: "component".to_string(),
489 attempt: 0,
490 payload_cbor,
491 metadata_cbor: None,
492 }
493 }
494
495 fn convert_schema_source(source: runtime::node::SchemaSource) -> canonical_node::SchemaSource {
496 match source {
497 runtime::node::SchemaSource::CborSchemaId(id) => {
498 canonical_node::SchemaSource::CborSchemaId(id)
499 }
500 runtime::node::SchemaSource::InlineCbor(bytes) => {
501 canonical_node::SchemaSource::InlineCbor(bytes)
502 }
503 runtime::node::SchemaSource::RefPackPath(path) => {
504 canonical_node::SchemaSource::RefPackPath(path)
505 }
506 runtime::node::SchemaSource::RefUri(uri) => canonical_node::SchemaSource::RefUri(uri),
507 }
508 }
509
510 fn convert_io_schema(schema: runtime::node::IoSchema) -> canonical_node::IoSchema {
511 canonical_node::IoSchema {
512 schema: convert_schema_source(schema.schema),
513 content_type: schema.content_type,
514 schema_version: schema.schema_version,
515 }
516 }
517
518 fn convert_example(example: runtime::node::Example) -> canonical_node::Example {
519 canonical_node::Example {
520 title: example.title,
521 input_cbor: example.input_cbor,
522 output_cbor: example.output_cbor,
523 }
524 }
525
526 fn convert_op(op: runtime::node::Op) -> canonical_node::Op {
527 canonical_node::Op {
528 name: op.name,
529 summary: op.summary,
530 input: convert_io_schema(op.input),
531 output: convert_io_schema(op.output),
532 examples: op.examples.into_iter().map(convert_example).collect(),
533 }
534 }
535
536 fn convert_schema_ref(schema: runtime::node::SchemaRef) -> canonical_node::SchemaRef {
537 canonical_node::SchemaRef {
538 id: schema.id,
539 content_type: schema.content_type,
540 blake3_hash: schema.blake3_hash,
541 version: schema.version,
542 bytes: schema.bytes,
543 uri: schema.uri,
544 }
545 }
546
547 fn convert_setup_example(example: runtime::node::SetupExample) -> canonical_node::SetupExample {
548 canonical_node::SetupExample {
549 title: example.title,
550 answers_cbor: example.answers_cbor,
551 }
552 }
553
554 fn convert_setup_output(output: runtime::node::SetupOutput) -> canonical_node::SetupOutput {
555 match output {
556 runtime::node::SetupOutput::ConfigOnly => canonical_node::SetupOutput::ConfigOnly,
557 runtime::node::SetupOutput::TemplateScaffold(scaffold) => {
558 canonical_node::SetupOutput::TemplateScaffold(
559 canonical_node::SetupTemplateScaffold {
560 template_ref: scaffold.template_ref,
561 output_layout: scaffold.output_layout,
562 },
563 )
564 }
565 }
566 }
567
568 fn convert_setup_contract(
569 contract: runtime::node::SetupContract,
570 ) -> canonical_node::SetupContract {
571 canonical_node::SetupContract {
572 qa_spec: convert_schema_source(contract.qa_spec),
573 answers_schema: convert_schema_source(contract.answers_schema),
574 examples: contract
575 .examples
576 .into_iter()
577 .map(convert_setup_example)
578 .collect(),
579 outputs: contract
580 .outputs
581 .into_iter()
582 .map(convert_setup_output)
583 .collect(),
584 }
585 }
586
587 fn convert_descriptor(descriptor: runtime::node::ComponentDescriptor) -> ComponentDescriptor {
588 ComponentDescriptor {
589 name: descriptor.name,
590 version: descriptor.version,
591 summary: descriptor.summary,
592 capabilities: descriptor.capabilities,
593 ops: descriptor.ops.into_iter().map(convert_op).collect(),
594 schemas: descriptor
595 .schemas
596 .into_iter()
597 .map(convert_schema_ref)
598 .collect(),
599 setup: descriptor.setup.map(convert_setup_contract),
600 }
601 }
602
603 pub(super) fn setup_apply_payload(
604 mode: WizardMode,
605 current_config: &[u8],
606 answers: &[u8],
607 ) -> Result<Vec<u8>> {
608 use ciborium::value::Value as CValue;
609
610 let current = if matches!(mode, WizardMode::Update | WizardMode::Remove) {
611 CValue::Bytes(current_config.to_vec())
612 } else {
613 CValue::Null
614 };
615 let answers_value = if matches!(
616 mode,
617 WizardMode::Default | WizardMode::Setup | WizardMode::Update
618 ) {
619 CValue::Bytes(answers.to_vec())
620 } else {
621 CValue::Null
622 };
623
624 let value = CValue::Map(vec![
625 (
626 CValue::Text("mode".to_string()),
627 CValue::Text(mode.as_str().to_string()),
628 ),
629 (CValue::Text("current_config_cbor".to_string()), current),
630 (CValue::Text("answers_cbor".to_string()), answers_value),
631 (CValue::Text("metadata_cbor".to_string()), CValue::Null),
632 ]);
633
634 let mut out = Vec::new();
635 ciborium::ser::into_writer(&value, &mut out)
636 .map_err(|err| anyhow!("encode setup.apply_answers payload: {err}"))?;
637 Ok(out)
638 }
639
640 fn invoke_setup_apply(
641 wasm_bytes: &[u8],
642 mode: WizardMode,
643 current_config: &[u8],
644 answers: &[u8],
645 ) -> Result<Vec<u8>> {
646 let engine = wizard_engine()?;
647 let component = load_component_cached(wasm_bytes)?;
648 let mut linker: Linker<HostState> = Linker::new(engine);
649 add_wasi_imports(&mut linker)?;
650 add_control_imports(&mut linker)?;
651 let mut store = Store::new(engine, HostState::new());
652 let api = runtime::RuntimeComponent::instantiate(&mut store, component.as_ref(), &linker)
653 .map_err(|err| anyhow!("instantiate canonical component world: {err}"))?;
654 let node = api.greentic_component_node();
655
656 let payload_cbor = setup_apply_payload(mode, current_config, answers)?;
657 let envelope = invoke_envelope(payload_cbor);
658 let result = node
659 .call_invoke(&mut store, "setup.apply_answers", &envelope)
660 .map_err(|err| anyhow!("call invoke(setup.apply_answers): {err}"))?;
661
662 let runtime::node::InvocationResult {
663 ok,
664 output_cbor,
665 output_metadata_cbor: _,
666 } = result.map_err(|err| anyhow!("invoke returned node error: {}", err.message))?;
667
668 if !ok {
669 return Err(anyhow!(
670 "invoke(setup.apply_answers) returned ok=false with no node error"
671 ));
672 }
673
674 Ok(output_cbor)
675 }
676
677 pub(super) fn descriptor_mode_name(mode: WizardMode) -> &'static str {
678 match mode {
679 WizardMode::Default => "default",
680 WizardMode::Setup => "setup",
681 WizardMode::Update => "update",
682 WizardMode::Remove => "remove",
683 }
684 }
685
686 pub(super) fn is_missing_node_instance_error(err: &anyhow::Error) -> bool {
687 format!("{err:#}").contains("no exported instance named `greentic:component/node@0.6.0`")
688 }
689
690 pub(super) fn is_missing_setup_contract_error(err: &anyhow::Error) -> bool {
691 let msg = format!("{err:#}");
692 msg.contains("component descriptor missing setup.qa-spec")
693 || msg.contains(
694 "component descriptor does not advertise required op 'setup.apply_answers'",
695 )
696 }
697
698 pub(super) fn is_missing_setup_apply_error(err: &anyhow::Error) -> bool {
699 format!("{err:#}").contains("setup.apply_answers")
700 }
701
702 fn instantiate_root(
703 wasm_bytes: &[u8],
704 add_control: bool,
705 ) -> Result<(Store<HostState>, wasmtime::component::Instance)> {
706 let engine = wizard_engine()?;
707 let component = load_component_cached(wasm_bytes)?;
708 let mut linker: Linker<HostState> = Linker::new(engine);
709 add_wasi_imports(&mut linker)?;
710 if add_control {
711 add_control_imports(&mut linker)?;
712 }
713 let mut store = Store::new(engine, HostState::new());
714 let instance = linker
715 .instantiate(&mut store, component.as_ref())
716 .map_err(|err| anyhow!("instantiate component root world: {err}"))?;
717 Ok((store, instance))
718 }
719
720 fn find_export_index(
721 store: &mut Store<HostState>,
722 instance: &wasmtime::component::Instance,
723 parent: Option<&wasmtime::component::ComponentExportIndex>,
724 names: &[&str],
725 ) -> Option<wasmtime::component::ComponentExportIndex> {
726 for name in names {
727 if let Some(index) = instance.get_export_index(&mut *store, parent, name) {
728 return Some(index);
729 }
730 }
731 None
732 }
733
734 fn fetch_descriptor_spec(wasm_bytes: &[u8], mode: WizardMode) -> Result<WizardSpecOutput> {
735 let (mut store, instance) = instantiate_root(wasm_bytes, false)?;
736 let descriptor_instance = find_export_index(
737 &mut store,
738 &instance,
739 None,
740 &[
741 "component-descriptor",
742 "greentic:component/component-descriptor",
743 "greentic:component/component-descriptor@0.6.0",
744 ],
745 );
746 let describe_cbor = if let Some(descriptor_instance) = descriptor_instance {
747 let describe_export = find_export_index(
748 &mut store,
749 &instance,
750 Some(&descriptor_instance),
751 &[
752 "describe",
753 "greentic:component/component-descriptor@0.6.0#describe",
754 ],
755 );
756 if let Some(describe_export) = describe_export {
757 let describe_func = instance
758 .get_typed_func::<(), (Vec<u8>,)>(&mut store, &describe_export)
759 .map_err(|err| anyhow!("lookup component-descriptor.describe: {err}"))?;
760 let (describe_cbor,) = describe_func
761 .call(&mut store, ())
762 .map_err(|err| anyhow!("call component-descriptor.describe: {err}"))?;
763 describe_cbor
764 } else {
765 Vec::new()
766 }
767 } else {
768 Vec::new()
769 };
770
771 let qa_instance = find_export_index(
772 &mut store,
773 &instance,
774 None,
775 &[
776 "component-qa",
777 "greentic:component/component-qa",
778 "greentic:component/component-qa@0.6.0",
779 ],
780 )
781 .ok_or_else(|| anyhow!("missing exported component-qa instance"))?;
782 let qa_spec_export = find_export_index(
783 &mut store,
784 &instance,
785 Some(&qa_instance),
786 &["qa-spec", "greentic:component/component-qa@0.6.0#qa-spec"],
787 )
788 .ok_or_else(|| anyhow!("missing exported component-qa.qa-spec function"))?;
789 let qa_spec_cbor = call_exported_bytes(
790 &mut store,
791 &instance,
792 &qa_spec_export,
793 &[Val::Enum(descriptor_mode_name(mode).to_string())],
794 "component-qa.qa-spec",
795 )?;
796
797 Ok(WizardSpecOutput {
798 abi: WizardAbi::V6,
799 describe_cbor,
800 descriptor: None,
801 qa_spec_cbor,
802 answers_schema_cbor: None,
803 })
804 }
805
806 fn apply_descriptor_answers(
807 wasm_bytes: &[u8],
808 mode: WizardMode,
809 current_config: &[u8],
810 answers: &[u8],
811 ) -> Result<Vec<u8>> {
812 let (mut store, instance) = instantiate_root(wasm_bytes, false)?;
813 let qa_instance = find_export_index(
814 &mut store,
815 &instance,
816 None,
817 &[
818 "component-qa",
819 "greentic:component/component-qa",
820 "greentic:component/component-qa@0.6.0",
821 ],
822 )
823 .ok_or_else(|| anyhow!("missing exported component-qa instance"))?;
824 let apply_export = find_export_index(
825 &mut store,
826 &instance,
827 Some(&qa_instance),
828 &[
829 "apply-answers",
830 "greentic:component/component-qa@0.6.0#apply-answers",
831 ],
832 )
833 .ok_or_else(|| anyhow!("missing exported component-qa.apply-answers function"))?;
834 call_exported_bytes(
835 &mut store,
836 &instance,
837 &apply_export,
838 &[
839 Val::Enum(descriptor_mode_name(mode).to_string()),
840 bytes_to_val(current_config),
841 bytes_to_val(answers),
842 ],
843 "component-qa.apply-answers",
844 )
845 }
846
847 pub(super) fn bytes_to_val(bytes: &[u8]) -> Val {
848 Val::List(bytes.iter().copied().map(Val::U8).collect())
849 }
850
851 pub(super) fn val_to_bytes(value: &Val) -> Result<Vec<u8>> {
852 match value {
853 Val::List(values) => values
854 .iter()
855 .map(|value| match value {
856 Val::U8(byte) => Ok(*byte),
857 other => Err(anyhow!("expected list<u8> item, got {other:?}")),
858 })
859 .collect(),
860 other => Err(anyhow!("expected list<u8> result, got {other:?}")),
861 }
862 }
863
864 fn call_exported_bytes(
865 store: &mut Store<HostState>,
866 instance: &wasmtime::component::Instance,
867 export: &wasmtime::component::ComponentExportIndex,
868 params: &[Val],
869 label: &str,
870 ) -> Result<Vec<u8>> {
871 let func = instance
872 .get_func(&mut *store, export)
873 .ok_or_else(|| anyhow!("lookup {label}: function export not found"))?;
874 let mut results = [Val::Bool(false)];
875 func.call(&mut *store, params, &mut results)
876 .map_err(|err| anyhow!("call {label}: {err}"))?;
877 val_to_bytes(&results[0]).map_err(|err| anyhow!("{label} returned invalid bytes: {err}"))
878 }
879
880 pub fn fetch_wizard_spec(wasm_bytes: &[u8], _mode: WizardMode) -> Result<WizardSpecOutput> {
881 let engine = wizard_engine()?;
882 let component = load_component_cached(wasm_bytes)?;
883 let mut linker: Linker<HostState> = Linker::new(engine);
884 add_wasi_imports(&mut linker)?;
885 add_control_imports(&mut linker)?;
886 let mut store = Store::new(engine, HostState::new());
887 let api =
888 match runtime::RuntimeComponent::instantiate(&mut store, component.as_ref(), &linker) {
889 Ok(api) => api,
890 Err(err) => {
891 let err = anyhow!("instantiate canonical component world: {err}");
892 if is_missing_node_instance_error(&err) {
893 return fetch_descriptor_spec(wasm_bytes, _mode);
894 }
895 return Err(err);
896 }
897 };
898 let node = api.greentic_component_node();
899
900 let descriptor = node
901 .call_describe(&mut store)
902 .map(convert_descriptor)
903 .map_err(|err| anyhow!("call describe: {err}"))?;
904 let (qa_spec_cbor, answers_schema_cbor) = match extract_setup_contract(&descriptor)
905 .and_then(|(qa_spec_cbor, answers_schema_cbor)| {
906 ensure_setup_apply_answers_op(&descriptor)?;
907 Ok((qa_spec_cbor, answers_schema_cbor))
908 }) {
909 Ok(values) => values,
910 Err(err) if is_missing_setup_contract_error(&err) => {
911 return fetch_descriptor_spec(wasm_bytes, _mode);
912 }
913 Err(err) => return Err(err),
914 };
915
916 Ok(WizardSpecOutput {
917 abi: WizardAbi::V6,
918 describe_cbor: Vec::new(),
919 descriptor: Some(descriptor),
920 qa_spec_cbor,
921 answers_schema_cbor,
922 })
923 }
924
925 pub fn apply_wizard_answers(
926 wasm_bytes: &[u8],
927 _abi: WizardAbi,
928 mode: WizardMode,
929 current_config: &[u8],
930 answers: &[u8],
931 ) -> Result<Vec<u8>> {
932 match invoke_setup_apply(wasm_bytes, mode, current_config, answers) {
933 Ok(config) if setup_apply_result_requires_descriptor_fallback(&config) => {
934 apply_descriptor_answers(wasm_bytes, mode, current_config, answers)
935 }
936 Ok(config) => Ok(config),
937 Err(err)
938 if is_missing_node_instance_error(&err)
939 || is_missing_setup_apply_error(&err)
940 || is_setup_apply_descriptor_fallback_error(&err) =>
941 {
942 apply_descriptor_answers(wasm_bytes, mode, current_config, answers)
943 }
944 Err(err) => Err(err),
945 }
946 }
947
948 pub(super) fn is_setup_apply_descriptor_fallback_error(err: &anyhow::Error) -> bool {
949 let message = err.to_string().to_ascii_lowercase();
950 message.contains("ac_schema_invalid")
951 || message.contains("failed to decode cbor")
952 || message.contains("decode cbor")
953 || message.contains("invalid type: byte array, expected any valid json value")
954 || (message.contains("byte array") && message.contains("expected any valid json value"))
955 }
956
957 pub(super) fn setup_apply_result_requires_descriptor_fallback(config_cbor: &[u8]) -> bool {
958 let Ok(config_json) = super::cbor_to_json(config_cbor) else {
959 return false;
960 };
961 let Some(error) = config_json.get("error").and_then(|value| value.as_object()) else {
962 return false;
963 };
964
965 let mut combined = String::new();
966 if let Some(code) = error.get("code").and_then(|value| value.as_str()) {
967 combined.push_str(code);
968 combined.push(' ');
969 }
970 if let Some(message) = error.get("message").and_then(|value| value.as_str()) {
971 combined.push_str(message);
972 combined.push(' ');
973 }
974 if let Some(details) = error.get("details").and_then(|value| value.as_str()) {
975 combined.push_str(details);
976 }
977
978 is_setup_apply_descriptor_fallback_error(&anyhow::anyhow!(combined))
979 }
980
981 #[cfg(test)]
982 pub(super) fn wizard_cache_metrics() -> Result<crate::cache::CacheMetricsSnapshot> {
983 Ok(wizard_runtime_cache()?.component_cache.metrics())
984 }
985
986 #[cfg(test)]
987 pub(super) fn load_cached_component_for_tests(wasm_bytes: &[u8]) -> Result<()> {
988 let _ = load_component_cached(wasm_bytes)?;
989 Ok(())
990 }
991
992 pub fn run_wizard_ops(
993 wasm_bytes: &[u8],
994 mode: WizardMode,
995 current_config: &[u8],
996 answers: &[u8],
997 ) -> Result<WizardOutput> {
998 let spec = fetch_wizard_spec(wasm_bytes, mode)?;
999 let config_cbor =
1000 apply_wizard_answers(wasm_bytes, spec.abi, mode, current_config, answers)?;
1001 Ok(WizardOutput {
1002 abi: spec.abi,
1003 describe_cbor: spec.describe_cbor,
1004 descriptor: spec.descriptor,
1005 qa_spec_cbor: spec.qa_spec_cbor,
1006 answers_cbor: answers.to_vec(),
1007 config_cbor,
1008 })
1009 }
1010
1011 #[cfg(test)]
1012 mod host_helper_tests {
1013 use super::*;
1014
1015 #[test]
1016 fn schema_source_to_cbor_accepts_inline_and_rejects_references() {
1017 assert_eq!(
1018 schema_source_to_cbor(&SchemaSource::InlineCbor(vec![1, 2]), "qa-spec").unwrap(),
1019 vec![1, 2]
1020 );
1021 assert!(
1022 schema_source_to_cbor(&SchemaSource::CborSchemaId("schema-id".into()), "qa")
1023 .is_err()
1024 );
1025 assert!(
1026 schema_source_to_cbor(&SchemaSource::RefPackPath("pack/path".into()), "qa")
1027 .is_err()
1028 );
1029 assert!(
1030 schema_source_to_cbor(
1031 &SchemaSource::RefUri("https://example.invalid".into()),
1032 "qa"
1033 )
1034 .is_err()
1035 );
1036 }
1037
1038 #[test]
1039 fn convert_runtime_descriptor_helpers_preserve_fields() {
1040 let io_schema = runtime::node::IoSchema {
1041 schema: runtime::node::SchemaSource::CborSchemaId("input-schema".to_string()),
1042 content_type: "application/cbor".to_string(),
1043 schema_version: Some("1".to_string()),
1044 };
1045 let example = runtime::node::Example {
1046 title: "demo".to_string(),
1047 input_cbor: vec![4],
1048 output_cbor: vec![5],
1049 };
1050 let op = runtime::node::Op {
1051 name: "run".to_string(),
1052 summary: Some("summary".to_string()),
1053 input: io_schema.clone(),
1054 output: runtime::node::IoSchema {
1055 schema: runtime::node::SchemaSource::RefPackPath("schemas/output.cbor".into()),
1056 content_type: "application/cbor".to_string(),
1057 schema_version: Some("2".to_string()),
1058 },
1059 examples: vec![example],
1060 };
1061 let schema_ref = runtime::node::SchemaRef {
1062 id: "schema-id".to_string(),
1063 content_type: "application/json".to_string(),
1064 blake3_hash: "hash".to_string(),
1065 version: "1".to_string(),
1066 bytes: Some(vec![9]),
1067 uri: Some("https://example.invalid/schema".to_string()),
1068 };
1069 let setup = runtime::node::SetupContract {
1070 qa_spec: runtime::node::SchemaSource::RefUri(
1071 "https://example.invalid/qa-spec".to_string(),
1072 ),
1073 answers_schema: runtime::node::SchemaSource::InlineCbor(vec![7]),
1074 examples: vec![runtime::node::SetupExample {
1075 title: "setup".to_string(),
1076 answers_cbor: vec![8],
1077 }],
1078 outputs: vec![
1079 runtime::node::SetupOutput::ConfigOnly,
1080 runtime::node::SetupOutput::TemplateScaffold(
1081 runtime::node::SetupTemplateScaffold {
1082 template_ref: "template".to_string(),
1083 output_layout: Some("layout".to_string()),
1084 },
1085 ),
1086 ],
1087 };
1088
1089 let descriptor = convert_descriptor(runtime::node::ComponentDescriptor {
1090 name: "component".to_string(),
1091 version: "0.1.0".to_string(),
1092 summary: Some("summary".to_string()),
1093 capabilities: vec!["http".to_string()],
1094 ops: vec![op],
1095 schemas: vec![schema_ref],
1096 setup: Some(setup),
1097 });
1098
1099 assert_eq!(descriptor.name, "component");
1100 assert_eq!(descriptor.ops[0].name, "run");
1101 assert_eq!(descriptor.ops[0].examples.len(), 1);
1102 assert!(matches!(
1103 descriptor.ops[0].input.schema,
1104 canonical_node::SchemaSource::CborSchemaId(ref id) if id == "input-schema"
1105 ));
1106 assert!(matches!(
1107 descriptor.ops[0].output.schema,
1108 canonical_node::SchemaSource::RefPackPath(ref path) if path == "schemas/output.cbor"
1109 ));
1110 assert_eq!(descriptor.schemas[0].id, "schema-id");
1111 assert_eq!(descriptor.setup.as_ref().unwrap().examples.len(), 1);
1112 assert_eq!(descriptor.setup.as_ref().unwrap().outputs.len(), 2);
1113 assert!(matches!(
1114 descriptor.setup.as_ref().unwrap().qa_spec,
1115 canonical_node::SchemaSource::RefUri(ref uri)
1116 if uri == "https://example.invalid/qa-spec"
1117 ));
1118 assert!(matches!(
1119 descriptor.setup.as_ref().unwrap().answers_schema,
1120 canonical_node::SchemaSource::InlineCbor(ref bytes) if bytes == &vec![7]
1121 ));
1122 }
1123
1124 #[test]
1125 fn invoke_envelope_sets_local_context_defaults() {
1126 let envelope = invoke_envelope(vec![1, 2, 3]);
1127 assert_eq!(envelope.flow_id, "wizard-flow");
1128 assert_eq!(envelope.step_id, "wizard-step");
1129 assert_eq!(envelope.payload_cbor, vec![1, 2, 3]);
1130 assert_eq!(envelope.ctx.tenant_id, "local");
1131 assert_eq!(envelope.ctx.i18n_id, "en-US");
1132 }
1133
1134 #[test]
1135 fn setup_payload_and_error_helpers_cover_null_and_negative_paths() {
1136 let payload = setup_apply_payload(super::super::WizardMode::Remove, &[0xaa], &[0xbb])
1137 .expect("payload");
1138 let decoded: ciborium::value::Value =
1139 ciborium::de::from_reader(payload.as_slice()).expect("decode payload");
1140 let ciborium::value::Value::Map(entries) = decoded else {
1141 panic!("expected cbor map");
1142 };
1143 assert!(entries.iter().any(|(key, value)| {
1144 matches!(key, ciborium::value::Value::Text(text) if text == "answers_cbor")
1145 && matches!(value, ciborium::value::Value::Null)
1146 }));
1147
1148 let invalid_list =
1149 wasmtime::component::Val::List(vec![wasmtime::component::Val::Bool(true)]);
1150 assert!(val_to_bytes(&invalid_list).is_err());
1151 assert!(!is_missing_node_instance_error(&anyhow::anyhow!(
1152 "different error"
1153 )));
1154 assert!(!is_missing_setup_apply_error(&anyhow::anyhow!(
1155 "different error"
1156 )));
1157 assert!(!is_missing_setup_contract_error(&anyhow::anyhow!(
1158 "different error"
1159 )));
1160 }
1161
1162 #[test]
1163 fn setup_payload_default_mode_omits_current_config_but_keeps_answers() {
1164 let payload = setup_apply_payload(super::super::WizardMode::Default, &[0xaa], &[0xbb])
1165 .expect("payload");
1166 let decoded: ciborium::value::Value =
1167 ciborium::de::from_reader(payload.as_slice()).expect("decode payload");
1168 let ciborium::value::Value::Map(entries) = decoded else {
1169 panic!("expected cbor map");
1170 };
1171 assert!(entries.iter().any(|(key, value)| {
1172 matches!(key, ciborium::value::Value::Text(text) if text == "current_config_cbor")
1173 && matches!(value, ciborium::value::Value::Null)
1174 }));
1175 assert!(entries.iter().any(|(key, value)| {
1176 matches!(key, ciborium::value::Value::Text(text) if text == "answers_cbor")
1177 && matches!(value, ciborium::value::Value::Bytes(bytes) if bytes == &vec![0xbb])
1178 }));
1179 }
1180 }
1181}
1182
1183#[cfg(not(target_arch = "wasm32"))]
1184pub use host::{apply_wizard_answers, fetch_wizard_spec, run_wizard_ops};
1185
1186#[cfg(all(test, not(target_arch = "wasm32")))]
1187mod tests {
1188 use std::fs;
1189 use std::path::PathBuf;
1190
1191 use ciborium::value::Value as CValue;
1192 use greentic_distributor_client::{CachePolicy, DistClient, ResolvePolicy};
1193 use greentic_interfaces_host::component_v0_6::exports::greentic::component::node::{
1194 ComponentDescriptor, IoSchema, Op, SchemaSource, SetupContract,
1195 };
1196 use serde::Deserialize;
1197
1198 fn adaptive_card_wasm_bytes() -> Option<Vec<u8>> {
1199 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1200 .join("../component-adaptive-card/dist/component_adaptive_card__0_6_0.wasm");
1201 if !path.exists() {
1202 return None;
1203 }
1204 Some(fs::read(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display())))
1205 }
1206
1207 #[derive(Deserialize)]
1208 struct FrequentComponentEntry {
1209 id: String,
1210 component_ref: String,
1211 }
1212
1213 #[derive(Deserialize)]
1214 struct FrequentComponentsCatalog {
1215 components: Vec<FrequentComponentEntry>,
1216 }
1217
1218 fn frequent_component_ref(id: &str) -> Option<String> {
1219 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("frequent-components.json");
1220 let raw = fs::read_to_string(&path).ok()?;
1221 let catalog: FrequentComponentsCatalog = serde_json::from_str(&raw).ok()?;
1222 catalog
1223 .components
1224 .into_iter()
1225 .find(|entry| entry.id == id)
1226 .map(|entry| entry.component_ref)
1227 }
1228
1229 fn frequent_component_wasm_bytes(id: &str) -> Option<Vec<u8>> {
1230 let reference = frequent_component_ref(id)?;
1231 let runtime = tokio::runtime::Runtime::new().ok()?;
1232 let client = DistClient::new(Default::default());
1233 let source = client.parse_source(&reference).ok()?;
1234 let descriptor = runtime
1235 .block_on(client.resolve(source, ResolvePolicy))
1236 .ok()?;
1237 let resolved = runtime
1238 .block_on(client.fetch(&descriptor, CachePolicy))
1239 .ok()?;
1240 let cache_path = resolved.cache_path?;
1241 let bytes = fs::read(&cache_path).ok()?;
1242 if bytes.is_empty() {
1243 return None;
1244 }
1245 Some(bytes)
1246 }
1247
1248 #[test]
1249 fn setup_apply_fallback_classifier_matches_json_byte_array_error() {
1250 let err = anyhow::anyhow!(
1251 "AC_SCHEMA_INVALID: invalid type: byte array, expected any valid JSON value"
1252 );
1253 assert!(super::host::is_setup_apply_descriptor_fallback_error(&err));
1254 }
1255
1256 #[test]
1257 fn setup_apply_fallback_classifier_matches_decode_cbor_error() {
1258 let err = anyhow::anyhow!("call invoke: failed to decode cbor payload");
1259 assert!(super::host::is_setup_apply_descriptor_fallback_error(&err));
1260 }
1261
1262 #[test]
1263 fn setup_apply_fallback_classifier_ignores_unrelated_errors() {
1264 let err = anyhow::anyhow!("call invoke: permission denied");
1265 assert!(!super::host::is_setup_apply_descriptor_fallback_error(&err));
1266 }
1267
1268 #[test]
1269 fn setup_apply_fallback_classifier_matches_error_payload_cbor() {
1270 let payload = serde_json::json!({
1271 "error": {
1272 "code": "AC_SCHEMA_INVALID",
1273 "message": "Invalid CBOR invocation",
1274 "details": "invalid input: failed to decode cbor: CBOR decode failed: Semantic(None, \"invalid type: byte array, expected any valid JSON value\")"
1275 }
1276 });
1277 let cbor = super::json_to_cbor(&payload).expect("payload cbor");
1278 assert!(super::host::setup_apply_result_requires_descriptor_fallback(&cbor));
1279 }
1280
1281 #[test]
1282 fn setup_apply_result_fallback_classifier_ignores_normal_payloads_and_garbage() {
1283 let ok_payload = super::json_to_cbor(&serde_json::json!({ "ok": true })).unwrap();
1284 assert!(!super::host::setup_apply_result_requires_descriptor_fallback(&ok_payload));
1285 assert!(!super::host::setup_apply_result_requires_descriptor_fallback(b"not-cbor"));
1286 }
1287
1288 #[test]
1289 fn setup_contract_helpers_require_inline_cbor_and_setup_apply_op() {
1290 let descriptor = ComponentDescriptor {
1291 name: "component".to_string(),
1292 version: "0.1.0".to_string(),
1293 summary: None,
1294 capabilities: Vec::new(),
1295 ops: vec![Op {
1296 name: "setup.apply_answers".to_string(),
1297 summary: None,
1298 input: IoSchema {
1299 schema: SchemaSource::InlineCbor(vec![1]),
1300 content_type: "application/cbor".to_string(),
1301 schema_version: None,
1302 },
1303 output: IoSchema {
1304 schema: SchemaSource::InlineCbor(vec![2]),
1305 content_type: "application/cbor".to_string(),
1306 schema_version: None,
1307 },
1308 examples: Vec::new(),
1309 }],
1310 schemas: Vec::new(),
1311 setup: Some(SetupContract {
1312 qa_spec: SchemaSource::InlineCbor(vec![1, 2, 3]),
1313 answers_schema: SchemaSource::InlineCbor(vec![4, 5, 6]),
1314 examples: Vec::new(),
1315 outputs: Vec::new(),
1316 }),
1317 };
1318
1319 let (qa_spec, answers_schema) = super::host::extract_setup_contract(&descriptor).unwrap();
1320 assert_eq!(qa_spec, vec![1, 2, 3]);
1321 assert_eq!(answers_schema, Some(vec![4, 5, 6]));
1322 super::host::ensure_setup_apply_answers_op(&descriptor).unwrap();
1323
1324 let bad_descriptor = ComponentDescriptor {
1325 setup: Some(SetupContract {
1326 qa_spec: SchemaSource::RefUri("https://example.invalid/schema".to_string()),
1327 answers_schema: SchemaSource::InlineCbor(vec![1]),
1328 examples: Vec::new(),
1329 outputs: Vec::new(),
1330 }),
1331 ops: Vec::new(),
1332 ..descriptor
1333 };
1334 assert!(super::host::extract_setup_contract(&bad_descriptor).is_err());
1335 assert!(super::host::ensure_setup_apply_answers_op(&bad_descriptor).is_err());
1336 }
1337
1338 #[test]
1339 fn setup_payload_and_byte_value_helpers_encode_expected_shapes() {
1340 let payload =
1341 super::host::setup_apply_payload(super::WizardMode::Update, &[0xaa], &[0xbb, 0xcc])
1342 .expect("payload");
1343 let decoded: CValue =
1344 ciborium::de::from_reader(payload.as_slice()).expect("decode payload");
1345 let CValue::Map(entries) = decoded else {
1346 panic!("expected cbor map");
1347 };
1348 assert!(entries.iter().any(|(key, value)| {
1349 matches!(key, CValue::Text(text) if text == "mode")
1350 && matches!(value, CValue::Text(mode) if mode == "update")
1351 }));
1352 assert!(entries.iter().any(|(key, value)| {
1353 matches!(key, CValue::Text(text) if text == "answers_cbor")
1354 && matches!(value, CValue::Bytes(bytes) if bytes == &vec![0xbb, 0xcc])
1355 }));
1356
1357 let val = super::host::bytes_to_val(&[1, 2, 3]);
1358 assert_eq!(super::host::val_to_bytes(&val).unwrap(), vec![1, 2, 3]);
1359 let err = super::host::val_to_bytes(&wasmtime::component::Val::Bool(true))
1360 .expect_err("non-byte list should fail");
1361 assert!(format!("{err}").contains("expected list<u8> result"));
1362 }
1363
1364 #[test]
1365 fn wizard_host_error_classifiers_match_expected_messages() {
1366 assert_eq!(
1367 super::host::descriptor_mode_name(super::WizardMode::Default),
1368 "default"
1369 );
1370 assert_eq!(
1371 super::host::descriptor_mode_name(super::WizardMode::Setup),
1372 "setup"
1373 );
1374 assert_eq!(
1375 super::host::descriptor_mode_name(super::WizardMode::Update),
1376 "update"
1377 );
1378 assert_eq!(
1379 super::host::descriptor_mode_name(super::WizardMode::Remove),
1380 "remove"
1381 );
1382 assert!(super::host::is_missing_node_instance_error(
1383 &anyhow::anyhow!("no exported instance named `greentic:component/node@0.6.0`")
1384 ));
1385 assert!(super::host::is_missing_setup_contract_error(
1386 &anyhow::anyhow!("component descriptor missing setup.qa-spec")
1387 ));
1388 assert!(super::host::is_missing_setup_apply_error(&anyhow::anyhow!(
1389 "missing setup.apply_answers function"
1390 )));
1391 }
1392
1393 #[test]
1394 fn wizard_mode_maps_to_expected_strings_and_qa_modes() {
1395 assert_eq!(super::WizardMode::Default.as_str(), "default");
1396 assert_eq!(super::WizardMode::Setup.as_str(), "setup");
1397 assert_eq!(super::WizardMode::Update.as_str(), "update");
1398 assert_eq!(super::WizardMode::Remove.as_str(), "remove");
1399
1400 assert_eq!(
1401 super::WizardMode::Default.as_qa_mode(),
1402 greentic_types::schemas::component::v0_6_0::QaMode::Default
1403 );
1404 assert_eq!(
1405 super::WizardMode::Setup.as_qa_mode(),
1406 greentic_types::schemas::component::v0_6_0::QaMode::Setup
1407 );
1408 assert_eq!(
1409 super::WizardMode::Update.as_qa_mode(),
1410 greentic_types::schemas::component::v0_6_0::QaMode::Update
1411 );
1412 assert_eq!(
1413 super::WizardMode::Remove.as_qa_mode(),
1414 greentic_types::schemas::component::v0_6_0::QaMode::Remove
1415 );
1416 }
1417
1418 #[test]
1419 fn wizard_component_cache_reuses_compiled_artifact() {
1420 let Some(wasm_bytes) = adaptive_card_wasm_bytes() else {
1421 return;
1422 };
1423
1424 super::host::load_cached_component_for_tests(&wasm_bytes).expect("first cached load");
1425 let after_first = super::host::wizard_cache_metrics().expect("first metrics");
1426
1427 super::host::load_cached_component_for_tests(&wasm_bytes).expect("second cached load");
1428 let after_second = super::host::wizard_cache_metrics().expect("second metrics");
1429
1430 assert_eq!(
1431 after_second.compiles, after_first.compiles,
1432 "second load should not trigger another compile"
1433 );
1434 assert!(
1435 after_second.memory_hits > after_first.memory_hits
1436 || after_second.disk_hits > after_first.disk_hits,
1437 "second load should hit memory or disk cache"
1438 );
1439 }
1440
1441 #[test]
1442 fn frequent_http_component_no_longer_fails_with_missing_http_linker_import() {
1443 let Some(wasm_bytes) = frequent_component_wasm_bytes("http") else {
1444 return;
1445 };
1446
1447 let message = match super::host::fetch_wizard_spec(&wasm_bytes, super::WizardMode::Default)
1448 {
1449 Ok(_) => return,
1450 Err(err) => format!("{err:#}"),
1451 };
1452 assert!(
1453 !message.contains("matching implementation was not found in the linker"),
1454 "expected greentic-flow host linker fix to be active, got: {message}"
1455 );
1456 assert!(
1457 !message.contains("greentic:http/http-client@1.1.0"),
1458 "expected http client host import to be linked, got: {message}"
1459 );
1460 }
1461
1462 #[test]
1463 fn frequent_llm_component_no_longer_fails_with_missing_linker_implementations() {
1464 let Some(wasm_bytes) = frequent_component_wasm_bytes("llm-openai") else {
1465 return;
1466 };
1467
1468 let message = match super::host::fetch_wizard_spec(&wasm_bytes, super::WizardMode::Default)
1469 {
1470 Ok(_) => return,
1471 Err(err) => format!("{err:#}"),
1472 };
1473 assert!(
1474 !message.contains("matching implementation was not found in the linker"),
1475 "expected greentic-flow host linker fix to be active, got: {message}"
1476 );
1477 }
1478
1479 #[test]
1480 fn public_wizard_entrypoints_fail_cleanly_for_invalid_component_bytes() {
1481 assert!(super::fetch_wizard_spec(b"not-a-component", super::WizardMode::Default).is_err());
1482 assert!(
1483 super::apply_wizard_answers(
1484 b"not-a-component",
1485 super::WizardAbi::V6,
1486 super::WizardMode::Default,
1487 &[],
1488 &[],
1489 )
1490 .is_err()
1491 );
1492 assert!(
1493 super::run_wizard_ops(b"not-a-component", super::WizardMode::Default, &[], &[])
1494 .is_err()
1495 );
1496 }
1497}
1498
1499#[cfg(target_arch = "wasm32")]
1500pub fn run_wizard_ops(
1501 _wasm_bytes: &[u8],
1502 _mode: WizardMode,
1503 _current_config: &[u8],
1504 _answers: &[u8],
1505) -> Result<WizardOutput> {
1506 Err(anyhow!("setup ops not supported on wasm targets"))
1507}
1508
1509pub fn decode_component_qa_spec(qa_spec_cbor: &[u8], mode: WizardMode) -> Result<ComponentQaSpec> {
1510 let decoded: Result<ComponentQaSpec> =
1511 canonical::from_cbor(qa_spec_cbor).map_err(|err| anyhow!("decode qa-spec cbor: {err}"));
1512 if let Ok(spec) = decoded {
1513 return Ok(spec);
1514 }
1515
1516 let legacy_json = std::str::from_utf8(qa_spec_cbor)
1517 .ok()
1518 .map(|s| s.trim())
1519 .filter(|s| !s.is_empty());
1520 if let Some(raw) = legacy_json {
1521 let adapted =
1522 greentic_types::adapters::component_v0_5_0_to_v0_6_0::adapt_component_qa_spec_json(
1523 mode.as_qa_mode(),
1524 raw,
1525 )
1526 .map_err(|err| anyhow!("adapt legacy qa-spec json: {err}"))?;
1527 let spec: ComponentQaSpec = canonical::from_cbor(adapted.as_slice())
1528 .map_err(|err| anyhow!("decode adapted qa-spec: {err}"))?;
1529 return Ok(spec);
1530 }
1531
1532 Err(anyhow!("qa-spec payload is not valid CBOR or legacy JSON"))
1533}
1534
1535pub fn answers_to_cbor(answers: &HashMap<String, JsonValue>) -> Result<Vec<u8>> {
1536 let mut map = serde_json::Map::new();
1537 for (k, v) in answers {
1538 map.insert(k.clone(), v.clone());
1539 }
1540 let json = JsonValue::Object(map);
1541 let bytes = canonical::to_canonical_cbor(&json)
1542 .map_err(|err| anyhow!("encode answers as canonical cbor: {err}"))?;
1543 Ok(bytes)
1544}
1545
1546pub fn json_to_cbor(value: &JsonValue) -> Result<Vec<u8>> {
1547 let bytes = canonical::to_canonical_cbor(value)
1548 .map_err(|err| anyhow!("encode json as canonical cbor: {err}"))?;
1549 Ok(bytes)
1550}
1551
1552pub fn cbor_to_json(bytes: &[u8]) -> Result<JsonValue> {
1553 let value: ciborium::value::Value =
1554 ciborium::de::from_reader(bytes).map_err(|err| anyhow!("decode cbor: {err}"))?;
1555 cbor_value_to_json(&value)
1556}
1557
1558pub fn cbor_value_to_json(value: &ciborium::value::Value) -> Result<JsonValue> {
1559 use ciborium::value::Value as CValue;
1560 Ok(match value {
1561 CValue::Null => JsonValue::Null,
1562 CValue::Bool(b) => JsonValue::Bool(*b),
1563 CValue::Integer(i) => {
1564 if let Ok(v) = i64::try_from(*i) {
1565 JsonValue::Number(v.into())
1566 } else {
1567 let wide: i128 = (*i).into();
1568 JsonValue::String(wide.to_string())
1569 }
1570 }
1571 CValue::Float(f) => {
1572 let num = serde_json::Number::from_f64(*f)
1573 .ok_or_else(|| anyhow!("float out of range for json"))?;
1574 JsonValue::Number(num)
1575 }
1576 CValue::Text(s) => JsonValue::String(s.clone()),
1577 CValue::Bytes(b) => {
1578 JsonValue::Array(b.iter().map(|v| JsonValue::Number((*v).into())).collect())
1579 }
1580 CValue::Array(items) => {
1581 let mut out = Vec::with_capacity(items.len());
1582 for item in items {
1583 out.push(cbor_value_to_json(item)?);
1584 }
1585 JsonValue::Array(out)
1586 }
1587 CValue::Map(entries) => {
1588 let mut map = serde_json::Map::new();
1589 for (k, v) in entries {
1590 let key = match k {
1591 CValue::Text(s) => s.clone(),
1592 other => return Err(anyhow!("non-string map key in cbor: {other:?}")),
1593 };
1594 map.insert(key, cbor_value_to_json(v)?);
1595 }
1596 JsonValue::Object(map)
1597 }
1598 CValue::Tag(_, inner) => cbor_value_to_json(inner)?,
1599 _ => return Err(anyhow!("unsupported cbor value")),
1600 })
1601}
1602
1603pub fn qa_spec_to_questions(
1604 spec: &ComponentQaSpec,
1605 catalog: &I18nCatalog,
1606 locale: &str,
1607) -> Vec<crate::questions::Question> {
1608 let mut out = Vec::new();
1609 for question in &spec.questions {
1610 let prompt = resolve_text(&question.label, catalog, locale);
1611 let default = question
1612 .default
1613 .as_ref()
1614 .and_then(|value| cbor_value_to_json(value).ok());
1615
1616 let (kind, choices) = match &question.kind {
1617 QuestionKind::Text => (crate::questions::QuestionKind::String, Vec::new()),
1618 QuestionKind::Number => (crate::questions::QuestionKind::Float, Vec::new()),
1619 QuestionKind::Bool => (crate::questions::QuestionKind::Bool, Vec::new()),
1620 QuestionKind::InlineJson { .. } => (crate::questions::QuestionKind::String, Vec::new()),
1621 QuestionKind::AssetRef { .. } => (crate::questions::QuestionKind::String, Vec::new()),
1622 QuestionKind::Choice { options } => {
1623 let mut values = Vec::new();
1624 for option in options {
1625 values.push(JsonValue::String(option.value.clone()));
1626 }
1627 (crate::questions::QuestionKind::Choice, values)
1628 }
1629 };
1630
1631 out.push(crate::questions::Question {
1632 id: question.id.clone(),
1633 prompt,
1634 kind,
1635 required: question.required,
1636 default,
1637 choices,
1638 show_if: None,
1639 writes_to: None,
1640 });
1641 }
1642 out
1643}
1644
1645pub fn merge_default_answers(spec: &ComponentQaSpec, seed: &mut HashMap<String, JsonValue>) {
1646 for (key, value) in &spec.defaults {
1647 if seed.contains_key(key) {
1648 continue;
1649 }
1650 if let Ok(json_value) = cbor_value_to_json(value) {
1651 seed.insert(key.clone(), json_value);
1652 }
1653 }
1654}
1655
1656pub fn ensure_answers_object(answers: &serde_json::Value) -> Result<()> {
1657 if matches!(answers, serde_json::Value::Object(_)) {
1658 return Ok(());
1659 }
1660 Err(anyhow!("answers must be a JSON object"))
1661}
1662
1663pub fn empty_cbor_map() -> Vec<u8> {
1664 vec![0xa0]
1665}
1666
1667pub fn describe_exports_for_meta(_abi: WizardAbi) -> Vec<String> {
1668 vec!["describe".to_string(), "invoke".to_string()]
1669}
1670
1671pub fn abi_version_from_abi(_abi: WizardAbi) -> String {
1672 "0.6.0".to_string()
1673}
1674
1675pub fn canonicalize_answers_map(answers: &serde_json::Map<String, JsonValue>) -> Result<Vec<u8>> {
1676 let mut map = BTreeMap::new();
1677 for (k, v) in answers {
1678 map.insert(k.clone(), v.clone());
1679 }
1680 let bytes =
1681 canonical::to_canonical_cbor(&map).map_err(|err| anyhow!("canonicalize answers: {err}"))?;
1682 Ok(bytes)
1683}