greentic_runner_host/
pack.rs

1use std::collections::{BTreeMap, HashMap};
2use std::fs::File;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::Arc;
7
8use crate::component_api::component::greentic::component::control::Host as ComponentControlHost;
9use crate::component_api::{
10    ComponentPre, control, node::ExecCtx as ComponentExecCtx, node::InvokeResult, node::NodeError,
11};
12use crate::imports::register_all;
13use crate::oauth::{OAuthBrokerConfig, OAuthBrokerHost, OAuthHostContext};
14use crate::runtime_wasmtime::{Component, Engine, Linker, ResourceTable, WasmResult};
15use anyhow::{Context, Result, anyhow, bail};
16use greentic_interfaces_host::host_import::v0_2 as host_import_v0_2;
17use greentic_interfaces_host::host_import::v0_2::greentic::host_import::imports::{
18    HttpRequest as LegacyHttpRequest, HttpResponse as LegacyHttpResponse,
19    IfaceError as LegacyIfaceError, TenantCtx as LegacyTenantCtx,
20};
21use greentic_interfaces_host::host_import::v0_6::{
22    self as host_import_v0_6, iface_types, state, types,
23};
24use greentic_session::SessionKey as StoreSessionKey;
25use greentic_types::{
26    EnvId, Flow, FlowId, SessionCursor as StoreSessionCursor, SessionData,
27    StateKey as StoreStateKey, TeamId, TenantCtx as TypesTenantCtx, TenantId, UserId,
28    decode_pack_manifest,
29};
30use once_cell::sync::Lazy;
31use parking_lot::Mutex;
32use reqwest::blocking::Client as BlockingClient;
33use runner_core::normalize_under_root;
34use serde::{Deserialize, Serialize};
35use serde_cbor;
36use serde_json::{self, Value};
37use tokio::fs;
38use wasmparser::{Parser, Payload};
39use wasmtime::StoreContextMut;
40use zip::ZipArchive;
41
42use crate::runner::flow_adapter::{FlowIR, flow_ir_to_flow};
43use crate::runner::mocks::{HttpDecision, HttpMockRequest, HttpMockResponse, MockLayer};
44
45use crate::config::HostConfig;
46use crate::secrets::{DynSecretsManager, read_secret_blocking};
47use crate::storage::state::STATE_PREFIX;
48use crate::storage::{DynSessionStore, DynStateStore};
49use crate::verify;
50use crate::wasi::RunnerWasiPolicy;
51use tracing::warn;
52use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
53
54#[cfg(test)]
55use greentic_flow::model::FlowDoc;
56
57#[allow(dead_code)]
58pub struct PackRuntime {
59    /// Component artifact path (wasm file).
60    path: PathBuf,
61    /// Optional archive (.gtpack) used to load flows/manifests.
62    archive_path: Option<PathBuf>,
63    config: Arc<HostConfig>,
64    engine: Engine,
65    metadata: PackMetadata,
66    manifest: Option<greentic_types::PackManifest>,
67    mocks: Option<Arc<MockLayer>>,
68    flows: Option<PackFlows>,
69    components: HashMap<String, PackComponent>,
70    http_client: Arc<BlockingClient>,
71    pre_cache: Mutex<HashMap<String, ComponentPre<ComponentState>>>,
72    session_store: Option<DynSessionStore>,
73    state_store: Option<DynStateStore>,
74    wasi_policy: Arc<RunnerWasiPolicy>,
75    secrets: DynSecretsManager,
76    oauth_config: Option<OAuthBrokerConfig>,
77}
78
79struct PackComponent {
80    #[allow(dead_code)]
81    name: String,
82    #[allow(dead_code)]
83    version: String,
84    component: Component,
85}
86
87fn build_blocking_client() -> BlockingClient {
88    std::thread::spawn(|| BlockingClient::builder().build().expect("blocking client"))
89        .join()
90        .expect("client build thread panicked")
91}
92
93fn normalize_pack_path(path: &Path) -> Result<(PathBuf, PathBuf)> {
94    let (root, candidate) = if path.is_absolute() {
95        let parent = path
96            .parent()
97            .ok_or_else(|| anyhow!("pack path {} has no parent", path.display()))?;
98        let root = parent
99            .canonicalize()
100            .with_context(|| format!("failed to canonicalize {}", parent.display()))?;
101        let file = path
102            .file_name()
103            .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
104        (root, PathBuf::from(file))
105    } else {
106        let cwd = std::env::current_dir().context("failed to resolve current directory")?;
107        let base = if let Some(parent) = path.parent() {
108            cwd.join(parent)
109        } else {
110            cwd
111        };
112        let root = base
113            .canonicalize()
114            .with_context(|| format!("failed to canonicalize {}", base.display()))?;
115        let file = path
116            .file_name()
117            .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
118        (root, PathBuf::from(file))
119    };
120    let safe = normalize_under_root(&root, &candidate)?;
121    Ok((root, safe))
122}
123
124static HTTP_CLIENT: Lazy<Arc<BlockingClient>> = Lazy::new(|| Arc::new(build_blocking_client()));
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct FlowDescriptor {
128    pub id: String,
129    #[serde(rename = "type")]
130    pub flow_type: String,
131    pub profile: String,
132    pub version: String,
133    #[serde(default)]
134    pub description: Option<String>,
135}
136
137pub struct HostState {
138    config: Arc<HostConfig>,
139    http_client: Arc<BlockingClient>,
140    default_env: String,
141    session_store: Option<DynSessionStore>,
142    state_store: Option<DynStateStore>,
143    mocks: Option<Arc<MockLayer>>,
144    secrets: DynSecretsManager,
145    oauth_config: Option<OAuthBrokerConfig>,
146    oauth_host: OAuthBrokerHost,
147}
148
149impl HostState {
150    #[allow(clippy::default_constructed_unit_structs)]
151    pub fn new(
152        config: Arc<HostConfig>,
153        http_client: Arc<BlockingClient>,
154        mocks: Option<Arc<MockLayer>>,
155        session_store: Option<DynSessionStore>,
156        state_store: Option<DynStateStore>,
157        secrets: DynSecretsManager,
158        oauth_config: Option<OAuthBrokerConfig>,
159    ) -> Result<Self> {
160        let default_env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
161        Ok(Self {
162            config,
163            http_client,
164            default_env,
165            session_store,
166            state_store,
167            mocks,
168            secrets,
169            oauth_config,
170            oauth_host: OAuthBrokerHost::default(),
171        })
172    }
173
174    pub fn get_secret(&self, key: &str) -> Result<String> {
175        if !self.config.secrets_policy.is_allowed(key) {
176            bail!("secret {key} is not permitted by bindings policy");
177        }
178        if let Some(mock) = &self.mocks
179            && let Some(value) = mock.secrets_lookup(key)
180        {
181            return Ok(value);
182        }
183        let bytes = read_secret_blocking(&self.secrets, key)
184            .context("failed to read secret from manager")?;
185        let value = String::from_utf8(bytes).context("secret value is not valid UTF-8")?;
186        Ok(value)
187    }
188
189    fn tenant_ctx_from_v6(&self, ctx: Option<types::TenantCtx>) -> Result<TypesTenantCtx> {
190        let tenant_raw = ctx
191            .as_ref()
192            .map(|ctx| ctx.tenant.clone())
193            .unwrap_or_else(|| self.config.tenant.clone());
194        let tenant_id = TenantId::from_str(&tenant_raw)
195            .with_context(|| format!("invalid tenant id `{tenant_raw}`"))?;
196        let env_id = EnvId::from_str(&self.default_env)
197            .unwrap_or_else(|_| EnvId::from_str("local").expect("default env must be valid"));
198        let mut tenant_ctx = TypesTenantCtx::new(env_id, tenant_id);
199        if let Some(ctx) = ctx {
200            if let Some(team) = ctx.team {
201                let team_id =
202                    TeamId::from_str(&team).with_context(|| format!("invalid team id `{team}`"))?;
203                tenant_ctx = tenant_ctx.with_team(Some(team_id));
204            }
205            if let Some(user) = ctx.user {
206                let user_id =
207                    UserId::from_str(&user).with_context(|| format!("invalid user id `{user}`"))?;
208                tenant_ctx = tenant_ctx.with_user(Some(user_id));
209            }
210            if let Some(flow) = ctx.flow_id {
211                tenant_ctx = tenant_ctx.with_flow(flow);
212            }
213            if let Some(node) = ctx.node_id {
214                tenant_ctx = tenant_ctx.with_node(node);
215            }
216            if let Some(provider) = ctx.provider_id {
217                tenant_ctx = tenant_ctx.with_provider(provider);
218            }
219            if let Some(session) = ctx.session_id {
220                tenant_ctx = tenant_ctx.with_session(session);
221            }
222            tenant_ctx.trace_id = ctx.trace_id;
223        }
224        Ok(tenant_ctx)
225    }
226
227    fn session_store_handle(&self) -> Result<DynSessionStore, types::IfaceError> {
228        self.session_store
229            .as_ref()
230            .cloned()
231            .ok_or(types::IfaceError::Unavailable)
232    }
233
234    fn state_store_handle(&self) -> Result<DynStateStore, types::IfaceError> {
235        self.state_store
236            .as_ref()
237            .cloned()
238            .ok_or(types::IfaceError::Unavailable)
239    }
240
241    fn ensure_user(ctx: &TypesTenantCtx) -> Result<UserId, types::IfaceError> {
242        ctx.user_id
243            .clone()
244            .or_else(|| ctx.user.clone())
245            .ok_or(types::IfaceError::InvalidArg)
246    }
247
248    fn ensure_flow(ctx: &TypesTenantCtx) -> Result<FlowId, types::IfaceError> {
249        let flow = ctx.flow_id().ok_or(types::IfaceError::InvalidArg)?;
250        FlowId::from_str(flow).map_err(|_| types::IfaceError::InvalidArg)
251    }
252
253    fn cursor_from_iface(cursor: iface_types::SessionCursor) -> StoreSessionCursor {
254        let mut store_cursor = StoreSessionCursor::new(cursor.node_pointer);
255        if let Some(reason) = cursor.wait_reason {
256            store_cursor = store_cursor.with_wait_reason(reason);
257        }
258        if let Some(marker) = cursor.outbox_marker {
259            store_cursor = store_cursor.with_outbox_marker(marker);
260        }
261        store_cursor
262    }
263}
264
265fn load_manifest_and_flows(path: &Path) -> Result<(greentic_types::PackManifest, PackFlows)> {
266    let mut archive = ZipArchive::new(File::open(path)?)
267        .with_context(|| format!("{} is not a valid gtpack", path.display()))?;
268    let bytes = read_entry(&mut archive, "manifest.cbor")
269        .with_context(|| format!("missing manifest.cbor in {}", path.display()))?;
270    let manifest = decode_pack_manifest(&bytes)
271        .context("failed to decode pack manifest from manifest.cbor")?;
272    let cache = PackFlows::from_manifest(manifest.clone());
273    Ok((manifest, cache))
274}
275
276pub struct ComponentState {
277    host: HostState,
278    wasi_ctx: WasiCtx,
279    resource_table: ResourceTable,
280}
281
282impl ComponentState {
283    pub fn new(host: HostState, policy: Arc<RunnerWasiPolicy>) -> Result<Self> {
284        let wasi_ctx = policy
285            .instantiate()
286            .context("failed to build WASI context")?;
287        Ok(Self {
288            host,
289            wasi_ctx,
290            resource_table: ResourceTable::new(),
291        })
292    }
293
294    fn host_mut(&mut self) -> &mut HostState {
295        &mut self.host
296    }
297}
298
299impl control::Host for ComponentState {
300    fn should_cancel(&mut self) -> bool {
301        false
302    }
303
304    fn yield_now(&mut self) {
305        // no-op cooperative yield
306    }
307}
308
309fn add_component_control_to_linker(linker: &mut Linker<ComponentState>) -> wasmtime::Result<()> {
310    let mut inst = linker.instance("greentic:component/control@0.4.0")?;
311    inst.func_wrap(
312        "should-cancel",
313        |mut caller: StoreContextMut<'_, ComponentState>, (): ()| {
314            let host = caller.data_mut();
315            Ok((ComponentControlHost::should_cancel(host),))
316        },
317    )?;
318    inst.func_wrap(
319        "yield-now",
320        |mut caller: StoreContextMut<'_, ComponentState>, (): ()| {
321            let host = caller.data_mut();
322            ComponentControlHost::yield_now(host);
323            Ok(())
324        },
325    )?;
326    Ok(())
327}
328
329impl OAuthHostContext for ComponentState {
330    fn tenant_id(&self) -> &str {
331        &self.host.config.tenant
332    }
333
334    fn env(&self) -> &str {
335        &self.host.default_env
336    }
337
338    fn oauth_broker_host(&mut self) -> &mut OAuthBrokerHost {
339        &mut self.host.oauth_host
340    }
341
342    fn oauth_config(&self) -> Option<&OAuthBrokerConfig> {
343        self.host.oauth_config.as_ref()
344    }
345}
346
347impl WasiView for ComponentState {
348    fn ctx(&mut self) -> WasiCtxView<'_> {
349        WasiCtxView {
350            ctx: &mut self.wasi_ctx,
351            table: &mut self.resource_table,
352        }
353    }
354}
355
356#[allow(unsafe_code)]
357unsafe impl Send for ComponentState {}
358#[allow(unsafe_code)]
359unsafe impl Sync for ComponentState {}
360
361impl host_import_v0_6::HostImports for ComponentState {
362    fn secrets_get(
363        &mut self,
364        key: String,
365        ctx: Option<types::TenantCtx>,
366    ) -> WasmResult<Result<String, types::IfaceError>> {
367        host_import_v0_6::HostImports::secrets_get(self.host_mut(), key, ctx)
368    }
369
370    fn telemetry_emit(
371        &mut self,
372        span_json: String,
373        ctx: Option<types::TenantCtx>,
374    ) -> WasmResult<()> {
375        host_import_v0_6::HostImports::telemetry_emit(self.host_mut(), span_json, ctx)
376    }
377
378    fn http_fetch(
379        &mut self,
380        req: host_import_v0_6::http::HttpRequest,
381        ctx: Option<types::TenantCtx>,
382    ) -> WasmResult<Result<host_import_v0_6::http::HttpResponse, types::IfaceError>> {
383        host_import_v0_6::HostImports::http_fetch(self.host_mut(), req, ctx)
384    }
385
386    fn mcp_exec(
387        &mut self,
388        component: String,
389        action: String,
390        args_json: String,
391        ctx: Option<types::TenantCtx>,
392    ) -> WasmResult<Result<String, types::IfaceError>> {
393        host_import_v0_6::HostImports::mcp_exec(self.host_mut(), component, action, args_json, ctx)
394    }
395
396    fn state_get(
397        &mut self,
398        key: iface_types::StateKey,
399        ctx: Option<types::TenantCtx>,
400    ) -> WasmResult<Result<String, types::IfaceError>> {
401        host_import_v0_6::HostImports::state_get(self.host_mut(), key, ctx)
402    }
403
404    fn state_set(
405        &mut self,
406        key: iface_types::StateKey,
407        value_json: String,
408        ctx: Option<types::TenantCtx>,
409    ) -> WasmResult<Result<state::OpAck, types::IfaceError>> {
410        host_import_v0_6::HostImports::state_set(self.host_mut(), key, value_json, ctx)
411    }
412
413    fn session_update(
414        &mut self,
415        cursor: iface_types::SessionCursor,
416        ctx: Option<types::TenantCtx>,
417    ) -> WasmResult<Result<String, types::IfaceError>> {
418        host_import_v0_6::HostImports::session_update(self.host_mut(), cursor, ctx)
419    }
420}
421
422impl host_import_v0_2::HostImports for ComponentState {
423    fn secrets_get(
424        &mut self,
425        key: String,
426        ctx: Option<LegacyTenantCtx>,
427    ) -> WasmResult<Result<String, LegacyIfaceError>> {
428        host_import_v0_2::HostImports::secrets_get(self.host_mut(), key, ctx)
429    }
430
431    fn telemetry_emit(
432        &mut self,
433        span_json: String,
434        ctx: Option<LegacyTenantCtx>,
435    ) -> WasmResult<()> {
436        host_import_v0_2::HostImports::telemetry_emit(self.host_mut(), span_json, ctx)
437    }
438
439    fn tool_invoke(
440        &mut self,
441        tool: String,
442        action: String,
443        args_json: String,
444        ctx: Option<LegacyTenantCtx>,
445    ) -> WasmResult<Result<String, LegacyIfaceError>> {
446        host_import_v0_2::HostImports::tool_invoke(self.host_mut(), tool, action, args_json, ctx)
447    }
448
449    fn http_fetch(
450        &mut self,
451        req: host_import_v0_2::greentic::host_import::imports::HttpRequest,
452        ctx: Option<host_import_v0_2::greentic::host_import::imports::TenantCtx>,
453    ) -> WasmResult<
454        Result<
455            host_import_v0_2::greentic::host_import::imports::HttpResponse,
456            host_import_v0_2::greentic::host_import::imports::IfaceError,
457        >,
458    > {
459        host_import_v0_2::HostImports::http_fetch(self.host_mut(), req, ctx)
460    }
461}
462
463impl host_import_v0_6::HostImports for HostState {
464    fn secrets_get(
465        &mut self,
466        key: String,
467        _ctx: Option<types::TenantCtx>,
468    ) -> WasmResult<Result<String, types::IfaceError>> {
469        Ok(self.get_secret(&key).map_err(|err| {
470            tracing::warn!(secret = %key, error = %err, "secret lookup denied");
471            types::IfaceError::Denied
472        }))
473    }
474
475    fn telemetry_emit(
476        &mut self,
477        span_json: String,
478        _ctx: Option<types::TenantCtx>,
479    ) -> WasmResult<()> {
480        if let Some(mock) = &self.mocks
481            && mock.telemetry_drain(&[("span_json", span_json.as_str())])
482        {
483            return Ok(());
484        }
485        tracing::info!(span = %span_json, "telemetry emit from pack");
486        Ok(())
487    }
488
489    fn http_fetch(
490        &mut self,
491        req: host_import_v0_6::http::HttpRequest,
492        _ctx: Option<types::TenantCtx>,
493    ) -> WasmResult<Result<host_import_v0_6::http::HttpResponse, types::IfaceError>> {
494        let legacy_req = LegacyHttpRequest {
495            method: req.method,
496            url: req.url,
497            headers_json: req.headers_json,
498            body: req.body,
499        };
500        match host_import_v0_2::HostImports::http_fetch(self, legacy_req, None)? {
501            Ok(resp) => Ok(Ok(host_import_v0_6::http::HttpResponse {
502                status: resp.status,
503                headers_json: resp.headers_json,
504                body: resp.body,
505            })),
506            Err(err) => Ok(Err(map_legacy_error(err))),
507        }
508    }
509
510    fn mcp_exec(
511        &mut self,
512        component: String,
513        action: String,
514        args_json: String,
515        ctx: Option<types::TenantCtx>,
516    ) -> WasmResult<Result<String, types::IfaceError>> {
517        let _ = (component, action, args_json, ctx);
518        tracing::warn!("mcp.exec requested but MCP bridge is removed; returning unavailable");
519        Ok(Err(types::IfaceError::Unavailable))
520    }
521
522    fn state_get(
523        &mut self,
524        key: iface_types::StateKey,
525        ctx: Option<types::TenantCtx>,
526    ) -> WasmResult<Result<String, types::IfaceError>> {
527        let store = match self.state_store_handle() {
528            Ok(store) => store,
529            Err(err) => return Ok(Err(err)),
530        };
531        let tenant_ctx = match self.tenant_ctx_from_v6(ctx) {
532            Ok(ctx) => ctx,
533            Err(err) => {
534                tracing::warn!(error = %err, "invalid tenant context for state.get");
535                return Ok(Err(types::IfaceError::InvalidArg));
536            }
537        };
538        let key = StoreStateKey::from(key);
539        match store.get_json(&tenant_ctx, STATE_PREFIX, &key, None) {
540            Ok(Some(value)) => {
541                let result = if let Some(text) = value.as_str() {
542                    text.to_string()
543                } else {
544                    serde_json::to_string(&value).unwrap_or_else(|_| value.to_string())
545                };
546                Ok(Ok(result))
547            }
548            Ok(None) => Ok(Err(types::IfaceError::NotFound)),
549            Err(err) => {
550                tracing::warn!(error = %err, "state.get failed");
551                Ok(Err(types::IfaceError::Internal))
552            }
553        }
554    }
555
556    fn state_set(
557        &mut self,
558        key: iface_types::StateKey,
559        value_json: String,
560        ctx: Option<types::TenantCtx>,
561    ) -> WasmResult<Result<state::OpAck, types::IfaceError>> {
562        let store = match self.state_store_handle() {
563            Ok(store) => store,
564            Err(err) => return Ok(Err(err)),
565        };
566        let tenant_ctx = match self.tenant_ctx_from_v6(ctx) {
567            Ok(ctx) => ctx,
568            Err(err) => {
569                tracing::warn!(error = %err, "invalid tenant context for state.set");
570                return Ok(Err(types::IfaceError::InvalidArg));
571            }
572        };
573        let key = StoreStateKey::from(key);
574        let value = serde_json::from_str(&value_json).unwrap_or(Value::String(value_json));
575        match store.set_json(&tenant_ctx, STATE_PREFIX, &key, None, &value, None) {
576            Ok(()) => Ok(Ok(state::OpAck::Ok)),
577            Err(err) => {
578                tracing::warn!(error = %err, "state.set failed");
579                Ok(Err(types::IfaceError::Internal))
580            }
581        }
582    }
583
584    fn session_update(
585        &mut self,
586        cursor: iface_types::SessionCursor,
587        ctx: Option<types::TenantCtx>,
588    ) -> WasmResult<Result<String, types::IfaceError>> {
589        let store = match self.session_store_handle() {
590            Ok(store) => store,
591            Err(err) => return Ok(Err(err)),
592        };
593        let tenant_ctx = match self.tenant_ctx_from_v6(ctx) {
594            Ok(ctx) => ctx,
595            Err(err) => {
596                tracing::warn!(error = %err, "invalid tenant context for session.update");
597                return Ok(Err(types::IfaceError::InvalidArg));
598            }
599        };
600        let user = match Self::ensure_user(&tenant_ctx) {
601            Ok(user) => user,
602            Err(err) => return Ok(Err(err)),
603        };
604        let flow_id = match Self::ensure_flow(&tenant_ctx) {
605            Ok(flow) => flow,
606            Err(err) => return Ok(Err(err)),
607        };
608        let cursor = Self::cursor_from_iface(cursor);
609        let payload = SessionData {
610            tenant_ctx: tenant_ctx.clone(),
611            flow_id,
612            cursor: cursor.clone(),
613            context_json: serde_json::json!({
614                "node_pointer": cursor.node_pointer,
615                "wait_reason": cursor.wait_reason,
616                "outbox_marker": cursor.outbox_marker,
617            })
618            .to_string(),
619        };
620        if let Some(existing) = tenant_ctx.session_id() {
621            let key = StoreSessionKey::from(existing.to_string());
622            if let Err(err) = store.update_session(&key, payload) {
623                tracing::error!(error = %err, "failed to update session snapshot");
624                return Ok(Err(types::IfaceError::Internal));
625            }
626            return Ok(Ok(existing.to_string()));
627        }
628        match store.find_by_user(&tenant_ctx, &user) {
629            Ok(Some((key, _))) => {
630                if let Err(err) = store.update_session(&key, payload) {
631                    tracing::error!(error = %err, "failed to update existing user session");
632                    return Ok(Err(types::IfaceError::Internal));
633                }
634                return Ok(Ok(key.to_string()));
635            }
636            Ok(None) => {}
637            Err(err) => {
638                tracing::error!(error = %err, "session lookup failed");
639                return Ok(Err(types::IfaceError::Internal));
640            }
641        }
642        let key = match store.create_session(&tenant_ctx, payload.clone()) {
643            Ok(key) => key,
644            Err(err) => {
645                tracing::error!(error = %err, "failed to create session");
646                return Ok(Err(types::IfaceError::Internal));
647            }
648        };
649        let ctx_with_session = tenant_ctx.with_session(key.to_string());
650        let updated_payload = SessionData {
651            tenant_ctx: ctx_with_session.clone(),
652            ..payload
653        };
654        if let Err(err) = store.update_session(&key, updated_payload) {
655            tracing::warn!(error = %err, "failed to stamp session id after create");
656        }
657        Ok(Ok(key.to_string()))
658    }
659}
660
661impl host_import_v0_2::HostImports for HostState {
662    fn secrets_get(
663        &mut self,
664        key: String,
665        _ctx: Option<LegacyTenantCtx>,
666    ) -> WasmResult<Result<String, LegacyIfaceError>> {
667        Ok(self.get_secret(&key).map_err(|err| {
668            tracing::warn!(secret = %key, error = %err, "secret lookup denied");
669            LegacyIfaceError::Denied
670        }))
671    }
672
673    fn telemetry_emit(
674        &mut self,
675        span_json: String,
676        _ctx: Option<LegacyTenantCtx>,
677    ) -> WasmResult<()> {
678        if let Some(mock) = &self.mocks
679            && mock.telemetry_drain(&[("span_json", span_json.as_str())])
680        {
681            return Ok(());
682        }
683        tracing::info!(span = %span_json, "telemetry emit from pack");
684        Ok(())
685    }
686
687    fn tool_invoke(
688        &mut self,
689        tool: String,
690        action: String,
691        args_json: String,
692        ctx: Option<LegacyTenantCtx>,
693    ) -> WasmResult<Result<String, LegacyIfaceError>> {
694        let _ = (tool, action, args_json, ctx);
695        tracing::warn!("tool invoke requested but MCP bridge is removed; returning unavailable");
696        Ok(Err(LegacyIfaceError::Unavailable))
697    }
698
699    fn http_fetch(
700        &mut self,
701        req: LegacyHttpRequest,
702        _ctx: Option<LegacyTenantCtx>,
703    ) -> WasmResult<Result<LegacyHttpResponse, LegacyIfaceError>> {
704        if !self.config.http_enabled {
705            tracing::warn!(url = %req.url, "http fetch denied by policy");
706            return Ok(Err(LegacyIfaceError::Denied));
707        }
708
709        let mut mock_state = None;
710        let raw_body = req.body.clone();
711        if let Some(mock) = &self.mocks
712            && let Ok(meta) = HttpMockRequest::new(
713                &req.method,
714                &req.url,
715                raw_body.as_deref().map(|body| body.as_bytes()),
716            )
717        {
718            match mock.http_begin(&meta) {
719                HttpDecision::Mock(response) => {
720                    let http = LegacyHttpResponse::from(&response);
721                    return Ok(Ok(http));
722                }
723                HttpDecision::Deny(reason) => {
724                    tracing::warn!(url = %req.url, reason = %reason, "http fetch blocked by mocks");
725                    return Ok(Err(LegacyIfaceError::Denied));
726                }
727                HttpDecision::Passthrough { record } => {
728                    mock_state = Some((meta, record));
729                }
730            }
731        }
732
733        let method = req.method.parse().unwrap_or(reqwest::Method::GET);
734        let mut builder = self.http_client.request(method, &req.url);
735
736        if let Some(headers_json) = req.headers_json.as_ref() {
737            match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(headers_json) {
738                Ok(map) => {
739                    for (key, value) in map {
740                        if let Some(val) = value.as_str()
741                            && let Ok(header) =
742                                reqwest::header::HeaderName::from_bytes(key.as_bytes())
743                            && let Ok(header_value) = reqwest::header::HeaderValue::from_str(val)
744                        {
745                            builder = builder.header(header, header_value);
746                        }
747                    }
748                }
749                Err(err) => {
750                    tracing::warn!(error = %err, "failed to parse headers for http.fetch");
751                }
752            }
753        }
754
755        if let Some(body) = raw_body.clone() {
756            builder = builder.body(body);
757        }
758
759        let response = match builder.send() {
760            Ok(resp) => resp,
761            Err(err) => {
762                tracing::error!(url = %req.url, error = %err, "http fetch failed");
763                return Ok(Err(LegacyIfaceError::Unavailable));
764            }
765        };
766
767        let status = response.status().as_u16();
768        let headers_map = response
769            .headers()
770            .iter()
771            .map(|(k, v)| {
772                (
773                    k.as_str().to_string(),
774                    v.to_str().unwrap_or_default().to_string(),
775                )
776            })
777            .collect::<BTreeMap<_, _>>();
778        let headers_json = serde_json::to_string(&headers_map).ok();
779        let body = response.text().ok();
780
781        if let Some((meta, true)) = mock_state.take()
782            && let Some(mock) = &self.mocks
783        {
784            let recorded = HttpMockResponse::new(status, headers_map.clone(), body.clone());
785            mock.http_record(&meta, &recorded);
786        }
787
788        Ok(Ok(LegacyHttpResponse {
789            status,
790            headers_json,
791            body,
792        }))
793    }
794}
795
796impl PackRuntime {
797    #[allow(clippy::too_many_arguments)]
798    pub async fn load(
799        path: impl AsRef<Path>,
800        config: Arc<HostConfig>,
801        mocks: Option<Arc<MockLayer>>,
802        archive_source: Option<&Path>,
803        session_store: Option<DynSessionStore>,
804        state_store: Option<DynStateStore>,
805        wasi_policy: Arc<RunnerWasiPolicy>,
806        secrets: DynSecretsManager,
807        oauth_config: Option<OAuthBrokerConfig>,
808        verify_archive: bool,
809    ) -> Result<Self> {
810        let path = path.as_ref();
811        let (_pack_root, safe_path) = normalize_pack_path(path)?;
812        let is_component = safe_path
813            .extension()
814            .and_then(|ext| ext.to_str())
815            .map(|ext| ext.eq_ignore_ascii_case("wasm"))
816            .unwrap_or(false);
817        let archive_hint_path = if let Some(source) = archive_source {
818            let (_, normalized) = normalize_pack_path(source)?;
819            Some(normalized)
820        } else if is_component {
821            None
822        } else {
823            Some(safe_path.clone())
824        };
825        let archive_hint = archive_hint_path.as_deref();
826        if verify_archive {
827            let verify_target = archive_hint.unwrap_or(&safe_path);
828            verify::verify_pack(verify_target).await?;
829            tracing::info!(pack_path = %verify_target.display(), "pack verification complete");
830        }
831        let engine = Engine::default();
832        let wasm_bytes = fs::read(&safe_path).await?;
833        let mut metadata = PackMetadata::from_wasm(&wasm_bytes)
834            .unwrap_or_else(|| PackMetadata::fallback(&safe_path));
835        let mut manifest = None;
836        let flows = if let Some(archive_path) = archive_hint {
837            match load_manifest_and_flows(archive_path) {
838                Ok((m, cache)) => {
839                    metadata = cache.metadata.clone();
840                    manifest = Some(m);
841                    Some(cache)
842                }
843                Err(err) => {
844                    warn!(error = %err, pack = %archive_path.display(), "failed to parse pack manifest; skipping flows");
845                    None
846                }
847            }
848        } else {
849            None
850        };
851        let components = if let Some(archive_path) = archive_hint {
852            match load_components_from_archive(&engine, archive_path, manifest.as_ref()) {
853                Ok(map) => map,
854                Err(err) => {
855                    warn!(error = %err, pack = %archive_path.display(), "failed to load components from archive");
856                    HashMap::new()
857                }
858            }
859        } else if is_component {
860            let name = safe_path
861                .file_stem()
862                .map(|s| s.to_string_lossy().to_string())
863                .unwrap_or_else(|| "component".to_string());
864            let component = Component::from_binary(&engine, &wasm_bytes)?;
865            let mut map = HashMap::new();
866            map.insert(
867                name.clone(),
868                PackComponent {
869                    name,
870                    version: metadata.version.clone(),
871                    component,
872                },
873            );
874            map
875        } else {
876            HashMap::new()
877        };
878        let http_client = Arc::clone(&HTTP_CLIENT);
879        Ok(Self {
880            path: safe_path,
881            archive_path: archive_hint.map(Path::to_path_buf),
882            config,
883            engine,
884            metadata,
885            manifest,
886            mocks,
887            flows,
888            components,
889            http_client,
890            pre_cache: Mutex::new(HashMap::new()),
891            session_store,
892            state_store,
893            wasi_policy,
894            secrets,
895            oauth_config,
896        })
897    }
898
899    pub async fn list_flows(&self) -> Result<Vec<FlowDescriptor>> {
900        if let Some(cache) = &self.flows {
901            return Ok(cache.descriptors.clone());
902        }
903        if let Some(manifest) = &self.manifest {
904            let descriptors = manifest
905                .flows
906                .iter()
907                .map(|flow| FlowDescriptor {
908                    id: flow.id.as_str().to_string(),
909                    flow_type: flow_kind_to_str(flow.kind).to_string(),
910                    profile: manifest.pack_id.as_str().to_string(),
911                    version: manifest.version.to_string(),
912                    description: None,
913                })
914                .collect();
915            return Ok(descriptors);
916        }
917        Ok(Vec::new())
918    }
919
920    #[allow(dead_code)]
921    pub async fn run_flow(
922        &self,
923        _flow_id: &str,
924        _input: serde_json::Value,
925    ) -> Result<serde_json::Value> {
926        // TODO: dispatch flow execution via Wasmtime
927        Ok(serde_json::json!({}))
928    }
929
930    pub async fn invoke_component(
931        &self,
932        component_ref: &str,
933        ctx: ComponentExecCtx,
934        operation: &str,
935        _config_json: Option<String>,
936        input_json: String,
937    ) -> Result<Value> {
938        let pack_component = self
939            .components
940            .get(component_ref)
941            .with_context(|| format!("component '{component_ref}' not found in pack"))?;
942
943        let pre = if let Some(pre) = self.pre_cache.lock().get(component_ref).cloned() {
944            pre
945        } else {
946            let mut linker = Linker::new(&self.engine);
947            register_all(&mut linker, self.oauth_config.is_some())?;
948            add_component_control_to_linker(&mut linker)?;
949            let pre = ComponentPre::new(
950                linker
951                    .instantiate_pre(&pack_component.component)
952                    .map_err(|err| anyhow!(err))?,
953            )
954            .map_err(|err| anyhow!(err))?;
955            self.pre_cache
956                .lock()
957                .insert(component_ref.to_string(), pre.clone());
958            pre
959        };
960
961        let host_state = HostState::new(
962            Arc::clone(&self.config),
963            Arc::clone(&self.http_client),
964            self.mocks.clone(),
965            self.session_store.clone(),
966            self.state_store.clone(),
967            Arc::clone(&self.secrets),
968            self.oauth_config.clone(),
969        )?;
970        let store_state = ComponentState::new(host_state, Arc::clone(&self.wasi_policy))?;
971        let mut store = wasmtime::Store::new(&self.engine, store_state);
972        let bindings = pre
973            .instantiate_async(&mut store)
974            .await
975            .map_err(|err| anyhow!(err))?;
976        let node = bindings.greentic_component_node();
977
978        let result = node.call_invoke(&mut store, &ctx, operation, &input_json)?;
979
980        match result {
981            InvokeResult::Ok(body) => {
982                if body.is_empty() {
983                    return Ok(Value::Null);
984                }
985                serde_json::from_str(&body).or_else(|_| Ok(Value::String(body)))
986            }
987            InvokeResult::Err(NodeError {
988                code,
989                message,
990                retryable,
991                backoff_ms,
992                details,
993            }) => {
994                let mut obj = serde_json::Map::new();
995                obj.insert("ok".into(), Value::Bool(false));
996                let mut error = serde_json::Map::new();
997                error.insert("code".into(), Value::String(code));
998                error.insert("message".into(), Value::String(message));
999                error.insert("retryable".into(), Value::Bool(retryable));
1000                if let Some(backoff) = backoff_ms {
1001                    error.insert("backoff_ms".into(), Value::Number(backoff.into()));
1002                }
1003                if let Some(details) = details {
1004                    error.insert(
1005                        "details".into(),
1006                        serde_json::from_str(&details).unwrap_or(Value::String(details)),
1007                    );
1008                }
1009                obj.insert("error".into(), Value::Object(error));
1010                Ok(Value::Object(obj))
1011            }
1012        }
1013    }
1014
1015    pub fn load_flow(&self, flow_id: &str) -> Result<Flow> {
1016        if let Some(cache) = &self.flows {
1017            return cache
1018                .flows
1019                .get(flow_id)
1020                .cloned()
1021                .ok_or_else(|| anyhow!("flow '{flow_id}' not found in pack"));
1022        }
1023        if let Some(manifest) = &self.manifest {
1024            let entry = manifest
1025                .flows
1026                .iter()
1027                .find(|f| f.id.as_str() == flow_id)
1028                .ok_or_else(|| anyhow!("flow '{flow_id}' not found in manifest"))?;
1029            return Ok(entry.flow.clone());
1030        }
1031        bail!("flow '{flow_id}' not available (pack exports disabled)")
1032    }
1033
1034    pub fn metadata(&self) -> &PackMetadata {
1035        &self.metadata
1036    }
1037
1038    pub fn for_component_test(
1039        components: Vec<(String, PathBuf)>,
1040        flows: HashMap<String, FlowIR>,
1041        config: Arc<HostConfig>,
1042    ) -> Result<Self> {
1043        let engine = Engine::default();
1044        let mut component_map = HashMap::new();
1045        for (name, path) in components {
1046            if !path.exists() {
1047                bail!("component artifact missing: {}", path.display());
1048            }
1049            let wasm_bytes = std::fs::read(&path)?;
1050            let component = Component::from_binary(&engine, &wasm_bytes)
1051                .with_context(|| format!("failed to compile component {}", path.display()))?;
1052            component_map.insert(
1053                name.clone(),
1054                PackComponent {
1055                    name,
1056                    version: "0.0.0".into(),
1057                    component,
1058                },
1059            );
1060        }
1061
1062        let mut flow_map = HashMap::new();
1063        let mut descriptors = Vec::new();
1064        for (id, ir) in flows {
1065            let flow_type = ir.flow_type.clone();
1066            let flow = flow_ir_to_flow(ir)?;
1067            flow_map.insert(id.clone(), flow);
1068            descriptors.push(FlowDescriptor {
1069                id: id.clone(),
1070                flow_type,
1071                profile: "test".into(),
1072                version: "0.0.0".into(),
1073                description: None,
1074            });
1075        }
1076        let flows_cache = PackFlows {
1077            descriptors: descriptors.clone(),
1078            flows: flow_map,
1079            metadata: PackMetadata::fallback(Path::new("component-test")),
1080        };
1081
1082        Ok(Self {
1083            path: PathBuf::new(),
1084            archive_path: None,
1085            config,
1086            engine,
1087            metadata: PackMetadata::fallback(Path::new("component-test")),
1088            manifest: None,
1089            mocks: None,
1090            flows: Some(flows_cache),
1091            components: component_map,
1092            http_client: Arc::clone(&HTTP_CLIENT),
1093            pre_cache: Mutex::new(HashMap::new()),
1094            session_store: None,
1095            state_store: None,
1096            wasi_policy: Arc::new(RunnerWasiPolicy::new()),
1097            secrets: crate::secrets::default_manager(),
1098            oauth_config: None,
1099        })
1100    }
1101}
1102
1103fn map_legacy_error(err: LegacyIfaceError) -> types::IfaceError {
1104    match err {
1105        LegacyIfaceError::InvalidArg => types::IfaceError::InvalidArg,
1106        LegacyIfaceError::NotFound => types::IfaceError::NotFound,
1107        LegacyIfaceError::Denied => types::IfaceError::Denied,
1108        LegacyIfaceError::Unavailable => types::IfaceError::Unavailable,
1109        LegacyIfaceError::Internal => types::IfaceError::Internal,
1110    }
1111}
1112
1113struct PackFlows {
1114    descriptors: Vec<FlowDescriptor>,
1115    flows: HashMap<String, Flow>,
1116    metadata: PackMetadata,
1117}
1118
1119impl PackFlows {
1120    fn from_manifest(manifest: greentic_types::PackManifest) -> Self {
1121        let descriptors = manifest
1122            .flows
1123            .iter()
1124            .map(|entry| FlowDescriptor {
1125                id: entry.id.as_str().to_string(),
1126                flow_type: flow_kind_to_str(entry.kind).to_string(),
1127                profile: manifest.pack_id.as_str().to_string(),
1128                version: manifest.version.to_string(),
1129                description: None,
1130            })
1131            .collect();
1132        let mut flows = HashMap::new();
1133        for entry in &manifest.flows {
1134            flows.insert(entry.id.as_str().to_string(), entry.flow.clone());
1135        }
1136        Self {
1137            metadata: PackMetadata::from_manifest(&manifest),
1138            descriptors,
1139            flows,
1140        }
1141    }
1142}
1143
1144fn flow_kind_to_str(kind: greentic_types::FlowKind) -> &'static str {
1145    match kind {
1146        greentic_types::FlowKind::Messaging => "messaging",
1147        greentic_types::FlowKind::Event => "event",
1148        greentic_types::FlowKind::ComponentConfig => "component-config",
1149        greentic_types::FlowKind::Job => "job",
1150        greentic_types::FlowKind::Http => "http",
1151    }
1152}
1153
1154fn read_entry(archive: &mut ZipArchive<File>, name: &str) -> Result<Vec<u8>> {
1155    let mut file = archive
1156        .by_name(name)
1157        .with_context(|| format!("entry {name} missing from archive"))?;
1158    let mut buf = Vec::new();
1159    file.read_to_end(&mut buf)?;
1160    Ok(buf)
1161}
1162
1163#[cfg(test)]
1164fn normalize_flow_doc(mut doc: FlowDoc) -> FlowDoc {
1165    for node in doc.nodes.values_mut() {
1166        if node.component.is_empty()
1167            && let Some((component_ref, payload)) = node.raw.iter().next()
1168        {
1169            if component_ref.starts_with("emit.") {
1170                node.component = component_ref.clone();
1171                node.payload = payload.clone();
1172                node.raw.clear();
1173                continue;
1174            }
1175            let (target_component, operation, input, config) =
1176                infer_component_exec(payload, component_ref);
1177            let mut payload_obj = serde_json::Map::new();
1178            // component.exec is meta; ensure the payload carries the actual target component.
1179            payload_obj.insert("component".into(), Value::String(target_component));
1180            payload_obj.insert("operation".into(), Value::String(operation));
1181            payload_obj.insert("input".into(), input);
1182            if let Some(cfg) = config {
1183                payload_obj.insert("config".into(), cfg);
1184            }
1185            node.component = "component.exec".to_string();
1186            node.payload = Value::Object(payload_obj);
1187        }
1188    }
1189    doc
1190}
1191
1192#[cfg(test)]
1193fn infer_component_exec(
1194    payload: &Value,
1195    component_ref: &str,
1196) -> (String, String, Value, Option<Value>) {
1197    let default_op = if component_ref.starts_with("templating.") {
1198        "render"
1199    } else {
1200        "invoke"
1201    }
1202    .to_string();
1203
1204    if let Value::Object(map) = payload {
1205        let op = map
1206            .get("op")
1207            .or_else(|| map.get("operation"))
1208            .and_then(Value::as_str)
1209            .map(|s| s.to_string())
1210            .unwrap_or_else(|| default_op.clone());
1211
1212        let mut input = map.clone();
1213        let config = input.remove("config");
1214        let component = input
1215            .get("component")
1216            .or_else(|| input.get("component_ref"))
1217            .and_then(Value::as_str)
1218            .map(|s| s.to_string())
1219            .unwrap_or_else(|| component_ref.to_string());
1220        input.remove("component");
1221        input.remove("component_ref");
1222        input.remove("op");
1223        input.remove("operation");
1224        return (component, op, Value::Object(input), config);
1225    }
1226
1227    (component_ref.to_string(), default_op, payload.clone(), None)
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use super::*;
1233    use greentic_flow::model::{FlowDoc, NodeDoc};
1234    use serde_json::json;
1235    use std::collections::BTreeMap;
1236
1237    #[test]
1238    fn normalizes_raw_component_to_component_exec() {
1239        let mut nodes = BTreeMap::new();
1240        let mut raw = BTreeMap::new();
1241        raw.insert(
1242            "templating.handlebars".into(),
1243            json!({ "template": "Hi {{name}}" }),
1244        );
1245        nodes.insert(
1246            "start".into(),
1247            NodeDoc {
1248                raw,
1249                routing: json!([{"out": true}]),
1250                ..Default::default()
1251            },
1252        );
1253        let doc = FlowDoc {
1254            id: "welcome".into(),
1255            title: None,
1256            description: None,
1257            flow_type: "messaging".into(),
1258            start: Some("start".into()),
1259            parameters: json!({}),
1260            tags: Vec::new(),
1261            entrypoints: BTreeMap::new(),
1262            nodes,
1263        };
1264
1265        let normalized = normalize_flow_doc(doc);
1266        let node = normalized.nodes.get("start").expect("node exists");
1267        assert_eq!(node.component, "component.exec");
1268        assert!(node.raw.is_empty() || node.raw.contains_key("templating.handlebars"));
1269        let payload = node.payload.as_object().expect("payload object");
1270        assert_eq!(
1271            payload.get("component"),
1272            Some(&Value::String("templating.handlebars".into()))
1273        );
1274        assert_eq!(
1275            payload.get("operation"),
1276            Some(&Value::String("render".into()))
1277        );
1278        let input = payload.get("input").unwrap();
1279        assert_eq!(input, &json!({ "template": "Hi {{name}}" }));
1280    }
1281}
1282
1283fn load_components_from_archive(
1284    engine: &Engine,
1285    path: &Path,
1286    manifest: Option<&greentic_types::PackManifest>,
1287) -> Result<HashMap<String, PackComponent>> {
1288    let mut archive = ZipArchive::new(File::open(path)?)
1289        .with_context(|| format!("{} is not a valid gtpack", path.display()))?;
1290    let mut components = HashMap::new();
1291    if let Some(manifest) = manifest {
1292        for entry in &manifest.components {
1293            let file_name = format!("components/{}.wasm", entry.id.as_str());
1294            let bytes = read_entry(&mut archive, &file_name)
1295                .with_context(|| format!("missing component {}", file_name))?;
1296            let component = Component::from_binary(engine, &bytes)
1297                .with_context(|| format!("failed to compile component {}", entry.id.as_str()))?;
1298            components.insert(
1299                entry.id.as_str().to_string(),
1300                PackComponent {
1301                    name: entry.id.as_str().to_string(),
1302                    version: entry.version.to_string(),
1303                    component,
1304                },
1305            );
1306        }
1307    }
1308    Ok(components)
1309}
1310
1311#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1312pub struct PackMetadata {
1313    pub pack_id: String,
1314    pub version: String,
1315    #[serde(default)]
1316    pub entry_flows: Vec<String>,
1317}
1318
1319impl PackMetadata {
1320    fn from_wasm(bytes: &[u8]) -> Option<Self> {
1321        let parser = Parser::new(0);
1322        for payload in parser.parse_all(bytes) {
1323            let payload = payload.ok()?;
1324            match payload {
1325                Payload::CustomSection(section) => {
1326                    if section.name() == "greentic.manifest"
1327                        && let Ok(meta) = Self::from_bytes(section.data())
1328                    {
1329                        return Some(meta);
1330                    }
1331                }
1332                Payload::DataSection(reader) => {
1333                    for segment in reader.into_iter().flatten() {
1334                        if let Ok(meta) = Self::from_bytes(segment.data) {
1335                            return Some(meta);
1336                        }
1337                    }
1338                }
1339                _ => {}
1340            }
1341        }
1342        None
1343    }
1344
1345    fn from_bytes(bytes: &[u8]) -> Result<Self, serde_cbor::Error> {
1346        #[derive(Deserialize)]
1347        struct RawManifest {
1348            pack_id: String,
1349            version: String,
1350            #[serde(default)]
1351            entry_flows: Vec<String>,
1352            #[serde(default)]
1353            flows: Vec<RawFlow>,
1354        }
1355
1356        #[derive(Deserialize)]
1357        struct RawFlow {
1358            id: String,
1359        }
1360
1361        let manifest: RawManifest = serde_cbor::from_slice(bytes)?;
1362        let mut entry_flows = if manifest.entry_flows.is_empty() {
1363            manifest.flows.iter().map(|f| f.id.clone()).collect()
1364        } else {
1365            manifest.entry_flows.clone()
1366        };
1367        entry_flows.retain(|id| !id.is_empty());
1368        Ok(Self {
1369            pack_id: manifest.pack_id,
1370            version: manifest.version,
1371            entry_flows,
1372        })
1373    }
1374
1375    pub fn fallback(path: &Path) -> Self {
1376        let pack_id = path
1377            .file_stem()
1378            .map(|s| s.to_string_lossy().into_owned())
1379            .unwrap_or_else(|| "unknown-pack".to_string());
1380        Self {
1381            pack_id,
1382            version: "0.0.0".to_string(),
1383            entry_flows: Vec::new(),
1384        }
1385    }
1386
1387    pub fn from_manifest(manifest: &greentic_types::PackManifest) -> Self {
1388        let entry_flows = manifest
1389            .flows
1390            .iter()
1391            .map(|flow| flow.id.as_str().to_string())
1392            .collect::<Vec<_>>();
1393        Self {
1394            pack_id: manifest.pack_id.as_str().to_string(),
1395            version: manifest.version.to_string(),
1396            entry_flows,
1397        }
1398    }
1399}
1400
1401impl From<&HttpMockResponse> for LegacyHttpResponse {
1402    fn from(value: &HttpMockResponse) -> Self {
1403        let headers_json = serde_json::to_string(&value.headers).ok();
1404        Self {
1405            status: value.status,
1406            headers_json,
1407            body: value.body.clone(),
1408        }
1409    }
1410}