Skip to main content

greentic_flow/
wizard_ops.rs

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                // Keep a minimal WASI context; this still provides the imports
270                // expected by components that read CLI env/args.
271                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                // C4.1 added `greentic:runtime-config@1.0.0`. The wizard ops
375                // host does not expose runtime config — wizard flows are a
376                // build-time authoring surface, not a deployment runtime.
377                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}