Skip to main content

greentic_runner_desktop/
lib.rs

1use anyhow::{Context, Result, anyhow};
2use greentic_pack::reader::open_pack;
3use greentic_runner_host::RunnerWasiPolicy;
4use greentic_runner_host::config::{
5    FlowRetryConfig, HostConfig, OperatorPolicy, RateLimits, SecretsPolicy, StateStorePolicy,
6    WebhookPolicy,
7};
8use greentic_runner_host::pack::{ComponentResolution, FlowDescriptor, PackMetadata, PackRuntime};
9use greentic_runner_host::runner::engine::{ExecutionObserver, FlowContext, FlowEngine, NodeEvent};
10pub use greentic_runner_host::runner::mocks::{
11    HttpMock, HttpMockMode, KvMock, MocksConfig, SecretsMock, TelemetryMock, TimeMock, ToolsMock,
12};
13use greentic_runner_host::runner::mocks::{MockEventSink, MockLayer};
14use greentic_runner_host::secrets::{DynSecretsManager, default_manager};
15use greentic_runner_host::storage::{new_session_store, new_state_store};
16use greentic_runner_host::trace::TraceConfig;
17use greentic_runner_host::validate::ValidationConfig;
18use parking_lot::Mutex;
19use runner_core::normalize_under_root;
20use serde::{Deserialize, Serialize};
21use serde_json::{Map as JsonMap, Value, json};
22use std::collections::{BTreeMap, HashMap};
23use std::fmt;
24use std::fs::{self, File};
25use std::io::{BufWriter, Write};
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use std::time::Instant;
29use time::OffsetDateTime;
30use time::format_description::well_known::Rfc3339;
31use tokio::runtime::Runtime;
32use tracing::{info, warn};
33use uuid::Uuid;
34
35const PROVIDER_ID_DEV: &str = "greentic-dev";
36
37/// Hook invoked for each transcript event.
38pub type TranscriptHook = Arc<dyn Fn(&Value) + Send + Sync>;
39
40/// Configuration for emitting OTLP telemetry.
41#[derive(Clone, Debug, Serialize, Deserialize, Default)]
42pub struct OtlpHook {
43    pub endpoint: String,
44    #[serde(default)]
45    pub headers: Vec<(String, String)>,
46    #[serde(default)]
47    pub sample_all: bool,
48}
49
50/// Execution profile.
51#[derive(Clone, Debug)]
52#[non_exhaustive]
53pub enum Profile {
54    Dev(DevProfile),
55}
56
57impl Default for Profile {
58    fn default() -> Self {
59        Self::Dev(DevProfile::default())
60    }
61}
62
63/// Tunables for the developer profile.
64#[derive(Clone, Debug)]
65pub struct DevProfile {
66    pub tenant_id: String,
67    pub team_id: String,
68    pub user_id: String,
69    pub max_node_wall_time_ms: u64,
70    pub max_run_wall_time_ms: u64,
71}
72
73impl Default for DevProfile {
74    fn default() -> Self {
75        Self {
76            tenant_id: "local-dev".to_string(),
77            team_id: "default".to_string(),
78            user_id: "developer".to_string(),
79            max_node_wall_time_ms: 30_000,
80            max_run_wall_time_ms: 600_000,
81        }
82    }
83}
84
85/// Tenant context overrides supplied by callers.
86#[derive(Clone, Debug, Default)]
87pub struct TenantContext {
88    pub tenant_id: Option<String>,
89    pub team_id: Option<String>,
90    pub user_id: Option<String>,
91    pub session_id: Option<String>,
92}
93
94impl TenantContext {
95    pub fn default_local() -> Self {
96        Self {
97            tenant_id: Some("local-dev".into()),
98            team_id: Some("default".into()),
99            user_id: Some("developer".into()),
100            session_id: None,
101        }
102    }
103}
104
105/// User supplied options for a run.
106#[derive(Clone)]
107pub struct RunOptions {
108    pub profile: Profile,
109    pub entry_flow: Option<String>,
110    pub input: Value,
111    pub ctx: TenantContext,
112    pub transcript: Option<TranscriptHook>,
113    pub otlp: Option<OtlpHook>,
114    pub mocks: MocksConfig,
115    pub artifacts_dir: Option<PathBuf>,
116    pub signing: SigningPolicy,
117    pub components_dir: Option<PathBuf>,
118    pub components_map: HashMap<String, PathBuf>,
119    pub dist_offline: bool,
120    pub dist_cache_dir: Option<PathBuf>,
121    pub allow_missing_hash: bool,
122    /// Optional secrets manager override used by embedded runtimes that need
123    /// to share a caller-owned secrets backend with the runner.
124    pub secrets_manager: Option<DynSecretsManager>,
125    /// Optional cross-pack resolver for `provider.invoke` nodes that reference
126    /// providers in other packs (e.g., OAuth broker from a capability pack).
127    pub cross_pack_resolver:
128        Option<Arc<dyn greentic_runner_host::runner::engine::CrossPackResolver>>,
129}
130
131impl Default for RunOptions {
132    fn default() -> Self {
133        desktop_defaults()
134    }
135}
136
137impl fmt::Debug for RunOptions {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.debug_struct("RunOptions")
140            .field("profile", &self.profile)
141            .field("entry_flow", &self.entry_flow)
142            .field("input", &self.input)
143            .field("ctx", &self.ctx)
144            .field("transcript", &self.transcript.is_some())
145            .field("otlp", &self.otlp)
146            .field("mocks", &self.mocks)
147            .field("artifacts_dir", &self.artifacts_dir)
148            .field("signing", &self.signing)
149            .field("components_dir", &self.components_dir)
150            .field("components_map_len", &self.components_map.len())
151            .field("dist_offline", &self.dist_offline)
152            .field("dist_cache_dir", &self.dist_cache_dir)
153            .field("allow_missing_hash", &self.allow_missing_hash)
154            .field("secrets_manager", &self.secrets_manager.is_some())
155            .finish()
156    }
157}
158
159#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
160pub enum SigningPolicy {
161    Strict,
162    DevOk,
163}
164
165/// Convenience builder that retains a baseline `RunOptions`.
166#[derive(Clone, Debug)]
167pub struct Runner {
168    base: RunOptions,
169}
170
171impl Runner {
172    pub fn new() -> Self {
173        Self {
174            base: desktop_defaults(),
175        }
176    }
177
178    pub fn profile(mut self, profile: Profile) -> Self {
179        self.base.profile = profile;
180        self
181    }
182
183    pub fn with_mocks(mut self, mocks: MocksConfig) -> Self {
184        self.base.mocks = mocks;
185        self
186    }
187
188    pub fn configure(mut self, f: impl FnOnce(&mut RunOptions)) -> Self {
189        f(&mut self.base);
190        self
191    }
192
193    pub fn run_pack<P: AsRef<Path>>(&self, pack_path: P) -> Result<RunResult> {
194        run_pack_with_options(pack_path, self.base.clone())
195    }
196
197    pub fn run_pack_with<P: AsRef<Path>>(
198        &self,
199        pack_path: P,
200        f: impl FnOnce(&mut RunOptions),
201    ) -> Result<RunResult> {
202        let mut opts = self.base.clone();
203        f(&mut opts);
204        run_pack_with_options(pack_path, opts)
205    }
206
207    pub async fn run_pack_async<P: AsRef<Path>>(&self, pack_path: P) -> Result<RunResult> {
208        run_pack_with_options_async(pack_path, self.base.clone()).await
209    }
210
211    pub async fn run_pack_with_async<P: AsRef<Path>>(
212        &self,
213        pack_path: P,
214        f: impl FnOnce(&mut RunOptions),
215    ) -> Result<RunResult> {
216        let mut opts = self.base.clone();
217        f(&mut opts);
218        run_pack_with_options_async(pack_path, opts).await
219    }
220}
221
222impl Default for Runner {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228/// Execute a pack with the provided options.
229pub fn run_pack_with_options<P: AsRef<Path>>(pack_path: P, opts: RunOptions) -> Result<RunResult> {
230    let runtime = Runtime::new().context("failed to create tokio runtime")?;
231    runtime.block_on(run_pack_async(pack_path.as_ref(), opts))
232}
233
234/// Execute a pack with the provided options using the caller's async runtime.
235pub async fn run_pack_with_options_async<P: AsRef<Path>>(
236    pack_path: P,
237    opts: RunOptions,
238) -> Result<RunResult> {
239    run_pack_async(pack_path.as_ref(), opts).await
240}
241
242/// Reasonable defaults for local desktop invocations.
243pub fn desktop_defaults() -> RunOptions {
244    let otlp = std::env::var("OTLP_ENDPOINT")
245        .ok()
246        .map(|endpoint| OtlpHook {
247            endpoint,
248            headers: Vec::new(),
249            sample_all: true,
250        });
251    RunOptions {
252        profile: Profile::Dev(DevProfile::default()),
253        entry_flow: None,
254        input: json!({}),
255        ctx: TenantContext::default_local(),
256        transcript: None,
257        otlp,
258        mocks: MocksConfig::default(),
259        artifacts_dir: None,
260        signing: SigningPolicy::DevOk,
261        components_dir: None,
262        components_map: HashMap::new(),
263        dist_offline: false,
264        dist_cache_dir: None,
265        allow_missing_hash: false,
266        secrets_manager: None,
267        cross_pack_resolver: None,
268    }
269}
270
271fn resolve_secrets_manager(opts: &RunOptions) -> Result<DynSecretsManager> {
272    if let Some(manager) = opts.secrets_manager.clone() {
273        Ok(manager)
274    } else {
275        default_manager().context("failed to initialise secrets backend")
276    }
277}
278
279async fn run_pack_async(pack_path: &Path, opts: RunOptions) -> Result<RunResult> {
280    let pack_path = normalize_pack_path(pack_path)?;
281    let resolved_profile = resolve_profile(&opts.profile, &opts.ctx);
282    if let Some(otlp) = &opts.otlp {
283        apply_otlp_hook(otlp);
284    }
285
286    let directories = prepare_run_dirs(opts.artifacts_dir.clone())?;
287    info!(run_dir = %directories.root.display(), "prepared desktop run directory");
288
289    let mock_layer = Arc::new(MockLayer::new(opts.mocks.clone(), &directories.root)?);
290
291    let recorder = Arc::new(RunRecorder::new(
292        directories.clone(),
293        &resolved_profile,
294        None,
295        PackMetadata::fallback(&pack_path),
296        opts.transcript.clone(),
297    )?);
298
299    let mock_sink: Arc<dyn MockEventSink> = recorder.clone();
300    mock_layer.register_sink(mock_sink);
301
302    // Collect only the `host.http.client` booleans from the component manifest
303    // rather than cloning full `ComponentManifest` values — we just need to know
304    // whether any component opted in.
305    let mut pack_http_flags: Vec<bool> = Vec::new();
306    if pack_path.is_file() {
307        match open_pack(&pack_path, to_reader_policy(opts.signing)) {
308            Ok(load) => {
309                let meta = &load.manifest.meta;
310                recorder.update_pack_metadata(PackMetadata {
311                    pack_id: meta.pack_id.clone(),
312                    version: meta.version.to_string(),
313                    entry_flows: meta.entry_flows.clone(),
314                    secret_requirements: Vec::new(),
315                });
316                if let Some(manifest) = load.gpack_manifest.as_ref() {
317                    pack_http_flags = manifest
318                        .components
319                        .iter()
320                        .map(|component| {
321                            component
322                                .capabilities
323                                .host
324                                .http
325                                .as_ref()
326                                .is_some_and(|http| http.client)
327                        })
328                        .collect();
329                }
330            }
331            Err(err) => {
332                recorder.record_verify_event("error", &err.message)?;
333                if opts.signing == SigningPolicy::DevOk && is_signature_error(&err.message) {
334                    warn!(error = %err.message, "continuing despite signature error (dev policy)");
335                } else {
336                    return Err(anyhow!("pack verification failed: {}", err.message));
337                }
338            }
339        }
340    } else {
341        tracing::debug!(
342            path = %pack_path.display(),
343            "skipping pack verification for directory input"
344        );
345    }
346
347    let kill_switch = desktop_http_kill_switch_active();
348    let http_enabled = derive_http_enabled(&pack_http_flags, kill_switch);
349    let http_component_count = pack_http_flags.iter().filter(|flag| **flag).count();
350    if http_enabled {
351        info!(
352            total_component_count = pack_http_flags.len(),
353            http_component_count,
354            "enabling HTTP client gate based on component manifest capabilities"
355        );
356    } else if kill_switch {
357        info!(
358            env_var = DESKTOP_HTTP_DISABLE_ENV,
359            "HTTP client gate forced off by desktop kill-switch"
360        );
361    } else {
362        tracing::debug!(
363            total_component_count = pack_http_flags.len(),
364            "HTTP client gate disabled: no component manifest declares host.http.client"
365        );
366    }
367    let host_config = Arc::new(build_host_config(
368        &resolved_profile,
369        &directories,
370        http_enabled,
371    ));
372    let mut component_resolution = ComponentResolution::default();
373    if let Some(dir) = opts.components_dir.clone() {
374        component_resolution.materialized_root = Some(dir);
375    } else if pack_path.is_dir() {
376        component_resolution.materialized_root = Some(pack_path.clone());
377    }
378    component_resolution.overrides = opts.components_map.clone();
379    component_resolution.dist_offline = opts.dist_offline;
380    component_resolution.dist_cache_dir = opts.dist_cache_dir.clone();
381    component_resolution.allow_missing_hash = opts.allow_missing_hash;
382    let archive_source = if pack_path
383        .extension()
384        .and_then(|ext| ext.to_str())
385        .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
386        .unwrap_or(false)
387    {
388        Some(&pack_path)
389    } else {
390        None
391    };
392
393    let session_store = new_session_store();
394    let state_store = new_state_store();
395    let secrets_manager = resolve_secrets_manager(&opts)?;
396    let pack = Arc::new(
397        PackRuntime::load(
398            &pack_path,
399            Arc::clone(&host_config),
400            Some(Arc::clone(&mock_layer)),
401            archive_source.map(|p| p as &Path),
402            Some(Arc::clone(&session_store)),
403            Some(Arc::clone(&state_store)),
404            Arc::new(RunnerWasiPolicy::default()),
405            secrets_manager,
406            host_config.oauth_broker_config(),
407            false,
408            component_resolution,
409        )
410        .await
411        .with_context(|| format!("failed to load pack {}", pack_path.display()))?,
412    );
413    recorder.update_pack_metadata(pack.metadata().clone());
414
415    let flows = pack
416        .list_flows()
417        .await
418        .context("failed to enumerate flows")?;
419    let entry_flow_id = resolve_entry_flow(opts.entry_flow.clone(), pack.metadata(), &flows)?;
420    recorder.set_flow_id(&entry_flow_id);
421
422    let mut engine = FlowEngine::new(vec![Arc::clone(&pack)], Arc::clone(&host_config))
423        .await
424        .context("failed to prime flow engine")?;
425    if let Some(resolver) = opts.cross_pack_resolver.clone() {
426        engine.set_cross_pack_resolver(resolver);
427    }
428
429    let started_at = OffsetDateTime::now_utc();
430    let tenant_str = host_config.tenant.clone();
431    let session_id_owned = resolved_profile.session_id.clone();
432    let provider_id_owned = resolved_profile.provider_id.clone();
433    let recorder_ref: &RunRecorder = &recorder;
434    let mock_ref: &MockLayer = &mock_layer;
435    let ctx = FlowContext {
436        tenant: &tenant_str,
437        pack_id: pack.metadata().pack_id.as_str(),
438        flow_id: &entry_flow_id,
439        node_id: None,
440        tool: None,
441        action: Some("run_pack"),
442        session_id: Some(session_id_owned.as_str()),
443        provider_id: Some(provider_id_owned.as_str()),
444        retry_config: host_config.retry_config().into(),
445        attempt: 1,
446        observer: Some(recorder_ref),
447        mocks: Some(mock_ref),
448    };
449
450    let execution = engine.execute(ctx, opts.input.clone()).await;
451    let finished_at = OffsetDateTime::now_utc();
452
453    let status = match execution {
454        Ok(result) => match result.status {
455            greentic_runner_host::runner::engine::FlowStatus::Completed => RunCompletion::Ok,
456            greentic_runner_host::runner::engine::FlowStatus::Waiting(wait) => {
457                let reason = wait
458                    .reason
459                    .unwrap_or_else(|| "flow paused unexpectedly".to_string());
460                RunCompletion::Err(anyhow::anyhow!(reason))
461            }
462        },
463        Err(err) => RunCompletion::Err(err),
464    };
465
466    let result = recorder.finalise(status, started_at, finished_at)?;
467
468    let run_json_path = directories.root.join("run.json");
469    fs::write(&run_json_path, serde_json::to_vec_pretty(&result)?)
470        .with_context(|| format!("failed to write run summary {}", run_json_path.display()))?;
471
472    Ok(result)
473}
474
475fn apply_otlp_hook(hook: &OtlpHook) {
476    info!(
477        endpoint = %hook.endpoint,
478        sample_all = hook.sample_all,
479        headers = %hook.headers.len(),
480        "OTLP hook requested (set OTEL_* env vars before invoking run_pack)"
481    );
482}
483
484fn normalize_pack_path(path: &Path) -> Result<PathBuf> {
485    if path.is_absolute() {
486        let parent = path
487            .parent()
488            .ok_or_else(|| anyhow!("pack path {} has no parent", path.display()))?;
489        let root = parent
490            .canonicalize()
491            .with_context(|| format!("failed to canonicalize {}", parent.display()))?;
492        let file = path
493            .file_name()
494            .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
495        return normalize_under_root(&root, Path::new(file));
496    }
497
498    let cwd = std::env::current_dir().context("failed to resolve current directory")?;
499    let base = if let Some(parent) = path.parent() {
500        cwd.join(parent)
501    } else {
502        cwd
503    };
504    let root = base
505        .canonicalize()
506        .with_context(|| format!("failed to canonicalize {}", base.display()))?;
507    let file = path
508        .file_name()
509        .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
510    normalize_under_root(&root, Path::new(file))
511}
512
513fn prepare_run_dirs(root_override: Option<PathBuf>) -> Result<RunDirectories> {
514    let root = if let Some(dir) = root_override {
515        dir
516    } else {
517        let timestamp = OffsetDateTime::now_utc()
518            .format(&Rfc3339)
519            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
520            .replace(':', "-");
521        let short_id = Uuid::new_v4()
522            .to_string()
523            .chars()
524            .take(6)
525            .collect::<String>();
526        PathBuf::from(".greentic")
527            .join("runs")
528            .join(format!("{timestamp}_{short_id}"))
529    };
530
531    fs::create_dir_all(&root).with_context(|| format!("failed to create {}", root.display()))?;
532    let logs = root.join("logs");
533    let resolved = root.join("resolved_config");
534    fs::create_dir_all(&logs).with_context(|| format!("failed to create {}", logs.display()))?;
535    fs::create_dir_all(&resolved)
536        .with_context(|| format!("failed to create {}", resolved.display()))?;
537
538    Ok(RunDirectories {
539        root,
540        logs,
541        resolved,
542    })
543}
544
545fn resolve_entry_flow(
546    override_id: Option<String>,
547    metadata: &PackMetadata,
548    flows: &[FlowDescriptor],
549) -> Result<String> {
550    if let Some(flow) = override_id {
551        return Ok(flow);
552    }
553    if let Some(first) = metadata.entry_flows.first() {
554        return Ok(first.clone());
555    }
556    flows
557        .first()
558        .map(|f| f.id.clone())
559        .ok_or_else(|| anyhow!("pack does not declare any flows"))
560}
561
562fn is_signature_error(message: &str) -> bool {
563    message.to_ascii_lowercase().contains("signature")
564}
565
566fn to_reader_policy(policy: SigningPolicy) -> greentic_pack::reader::SigningPolicy {
567    match policy {
568        SigningPolicy::Strict => greentic_pack::reader::SigningPolicy::Strict,
569        SigningPolicy::DevOk => greentic_pack::reader::SigningPolicy::DevOk,
570    }
571}
572
573fn resolve_profile(profile: &Profile, ctx: &TenantContext) -> ResolvedProfile {
574    match profile {
575        Profile::Dev(dev) => ResolvedProfile {
576            tenant_id: ctx
577                .tenant_id
578                .clone()
579                .unwrap_or_else(|| dev.tenant_id.clone()),
580            team_id: ctx.team_id.clone().unwrap_or_else(|| dev.team_id.clone()),
581            user_id: ctx.user_id.clone().unwrap_or_else(|| dev.user_id.clone()),
582            session_id: ctx
583                .session_id
584                .clone()
585                .unwrap_or_else(|| Uuid::new_v4().to_string()),
586            provider_id: PROVIDER_ID_DEV.to_string(),
587            max_node_wall_time_ms: dev.max_node_wall_time_ms,
588            max_run_wall_time_ms: dev.max_run_wall_time_ms,
589        },
590    }
591}
592
593/// Environment variable that forces the HTTP client gate off regardless of
594/// what component manifests declare. Operators set this to `1` / `true` to
595/// disable outbound HTTP as a kill-switch when running the desktop profile.
596const DESKTOP_HTTP_DISABLE_ENV: &str = "GREENTIC_DESKTOP_HTTP_DISABLE";
597
598/// Derive whether the desktop profile must enable the HTTP client gate.
599///
600/// Returns `true` when at least one component flag is `true` and the
601/// kill-switch is not active. Packs opt in to HTTP by declaring
602/// `capabilities.host.http.client: true`; operators can force the gate off
603/// via [`DESKTOP_HTTP_DISABLE_ENV`] regardless of what the manifest says.
604fn derive_http_enabled(component_http_flags: &[bool], kill_switch: bool) -> bool {
605    !kill_switch && component_http_flags.iter().any(|flag| *flag)
606}
607
608/// Parse the raw value of [`DESKTOP_HTTP_DISABLE_ENV`] into a kill-switch bool.
609///
610/// Truthy values (`1`, `true`, `yes`, `on`, case- and whitespace-insensitive)
611/// activate the kill-switch. `None` or any other value leaves it inactive so
612/// the gate remains controlled by the component manifest.
613fn parse_kill_switch(raw: Option<&str>) -> bool {
614    matches!(
615        raw.map(|value| value.trim().to_ascii_lowercase())
616            .as_deref(),
617        Some("1" | "true" | "yes" | "on")
618    )
619}
620
621/// Read [`DESKTOP_HTTP_DISABLE_ENV`] from the process environment and
622/// interpret it via [`parse_kill_switch`].
623fn desktop_http_kill_switch_active() -> bool {
624    parse_kill_switch(std::env::var(DESKTOP_HTTP_DISABLE_ENV).ok().as_deref())
625}
626
627fn build_host_config(
628    profile: &ResolvedProfile,
629    dirs: &RunDirectories,
630    http_enabled: bool,
631) -> HostConfig {
632    HostConfig {
633        tenant: profile.tenant_id.clone(),
634        bindings_path: dirs.resolved.join("dev.bindings.yaml"),
635        flow_type_bindings: HashMap::new(),
636        rate_limits: RateLimits::default(),
637        retry: FlowRetryConfig::default(),
638        http_enabled,
639        secrets_policy: SecretsPolicy::allow_all(),
640        state_store_policy: StateStorePolicy::default(),
641        webhook_policy: WebhookPolicy::default(),
642        timers: Vec::new(),
643        oauth: None,
644        mocks: None,
645        pack_bindings: Vec::new(),
646        env_passthrough: Vec::new(),
647        trace: TraceConfig::from_env(),
648        validation: ValidationConfig::from_env(),
649        operator_policy: OperatorPolicy::allow_all(),
650    }
651}
652
653#[allow(dead_code)]
654#[derive(Clone, Debug)]
655struct ResolvedProfile {
656    tenant_id: String,
657    team_id: String,
658    user_id: String,
659    session_id: String,
660    provider_id: String,
661    max_node_wall_time_ms: u64,
662    max_run_wall_time_ms: u64,
663}
664
665#[derive(Clone, Debug, Serialize, Deserialize)]
666pub struct RunResult {
667    pub session_id: String,
668    pub pack_id: String,
669    pub pack_version: String,
670    pub flow_id: String,
671    pub started_at_utc: String,
672    pub finished_at_utc: String,
673    pub status: RunStatus,
674    pub error: Option<String>,
675    pub node_summaries: Vec<NodeSummary>,
676    pub failures: BTreeMap<String, NodeFailure>,
677    pub artifacts_dir: PathBuf,
678}
679
680#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
681pub enum RunStatus {
682    Success,
683    PartialFailure,
684    Failure,
685}
686
687#[derive(Clone, Debug, Serialize, Deserialize)]
688pub struct NodeSummary {
689    pub node_id: String,
690    pub component: String,
691    pub status: NodeStatus,
692    pub duration_ms: u64,
693}
694
695#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
696pub enum NodeStatus {
697    Ok,
698    Skipped,
699    Error,
700}
701
702#[derive(Clone, Debug, Serialize, Deserialize)]
703pub struct NodeFailure {
704    pub code: String,
705    pub message: String,
706    pub details: Value,
707    pub transcript_offsets: (u64, u64),
708    pub log_paths: Vec<PathBuf>,
709}
710
711#[derive(Clone)]
712struct RunDirectories {
713    root: PathBuf,
714    logs: PathBuf,
715    resolved: PathBuf,
716}
717
718enum RunCompletion {
719    Ok,
720    Err(anyhow::Error),
721}
722
723struct RunRecorder {
724    directories: RunDirectories,
725    profile: ResolvedProfile,
726    flow_id: Mutex<String>,
727    pack_meta: Mutex<PackMetadata>,
728    transcript: Mutex<TranscriptWriter>,
729    state: Mutex<RunRecorderState>,
730}
731
732impl RunRecorder {
733    fn new(
734        dirs: RunDirectories,
735        profile: &ResolvedProfile,
736        flow_id: Option<String>,
737        pack_meta: PackMetadata,
738        hook: Option<TranscriptHook>,
739    ) -> Result<Self> {
740        let transcript_path = dirs.root.join("transcript.jsonl");
741        let file = File::create(&transcript_path)
742            .with_context(|| format!("failed to open {}", transcript_path.display()))?;
743        Ok(Self {
744            directories: dirs,
745            profile: profile.clone(),
746            flow_id: Mutex::new(flow_id.unwrap_or_else(|| "unknown".into())),
747            pack_meta: Mutex::new(pack_meta),
748            transcript: Mutex::new(TranscriptWriter::new(BufWriter::new(file), hook)),
749            state: Mutex::new(RunRecorderState::default()),
750        })
751    }
752
753    fn finalise(
754        &self,
755        completion: RunCompletion,
756        started_at: OffsetDateTime,
757        finished_at: OffsetDateTime,
758    ) -> Result<RunResult> {
759        let status = match &completion {
760            RunCompletion::Ok => RunStatus::Success,
761            RunCompletion::Err(_) => RunStatus::Failure,
762        };
763
764        let mut runtime_error = None;
765        if let RunCompletion::Err(err) = completion {
766            warn!(error = %err, "pack execution failed");
767            eprintln!("pack execution failed: {err}");
768            runtime_error = Some(err.to_string());
769        }
770
771        let started = started_at
772            .format(&Rfc3339)
773            .unwrap_or_else(|_| started_at.to_string());
774        let finished = finished_at
775            .format(&Rfc3339)
776            .unwrap_or_else(|_| finished_at.to_string());
777
778        let state = self.state.lock();
779        let mut summaries = Vec::new();
780        let mut failures = BTreeMap::new();
781        for node_id in &state.order {
782            if let Some(record) = state.nodes.get(node_id) {
783                let duration = record.duration_ms.unwrap_or(0);
784                summaries.push(NodeSummary {
785                    node_id: node_id.clone(),
786                    component: record.component.clone(),
787                    status: record.status.clone(),
788                    duration_ms: duration,
789                });
790                if record.status == NodeStatus::Error {
791                    let start_offset = record.transcript_start.unwrap_or(0);
792                    let err_offset = record.transcript_error.unwrap_or(start_offset);
793                    failures.insert(
794                        node_id.clone(),
795                        NodeFailure {
796                            code: record
797                                .failure_code
798                                .clone()
799                                .unwrap_or_else(|| "component-failed".into()),
800                            message: record
801                                .failure_message
802                                .clone()
803                                .unwrap_or_else(|| "node failed".into()),
804                            details: record
805                                .failure_details
806                                .clone()
807                                .unwrap_or_else(|| json!({ "node": node_id })),
808                            transcript_offsets: (start_offset, err_offset),
809                            log_paths: record.log_paths.clone(),
810                        },
811                    );
812                }
813            }
814        }
815
816        let mut final_status = status;
817        if final_status == RunStatus::Success && !failures.is_empty() {
818            final_status = RunStatus::PartialFailure;
819        }
820
821        if let Some(error) = &runtime_error {
822            failures.insert(
823                "_runtime".into(),
824                NodeFailure {
825                    code: "runtime-error".into(),
826                    message: error.clone(),
827                    details: json!({ "stage": "execute" }),
828                    transcript_offsets: (0, 0),
829                    log_paths: Vec::new(),
830                },
831            );
832        }
833
834        let pack_meta = self.pack_meta.lock();
835        let flow_id = self.flow_id.lock().clone();
836        Ok(RunResult {
837            session_id: self.profile.session_id.clone(),
838            pack_id: pack_meta.pack_id.clone(),
839            pack_version: pack_meta.version.clone(),
840            flow_id,
841            started_at_utc: started,
842            finished_at_utc: finished,
843            status: final_status,
844            error: runtime_error,
845            node_summaries: summaries,
846            failures,
847            artifacts_dir: self.directories.root.clone(),
848        })
849    }
850
851    fn set_flow_id(&self, flow_id: &str) {
852        *self.flow_id.lock() = flow_id.to_string();
853    }
854
855    fn update_pack_metadata(&self, meta: PackMetadata) {
856        *self.pack_meta.lock() = meta;
857    }
858
859    fn current_flow_id(&self) -> String {
860        self.flow_id.lock().clone()
861    }
862
863    fn record_verify_event(&self, status: &str, message: &str) -> Result<()> {
864        let timestamp = OffsetDateTime::now_utc();
865        let event = json!({
866            "ts": timestamp
867                .format(&Rfc3339)
868                .unwrap_or_else(|_| timestamp.to_string()),
869            "session_id": self.profile.session_id,
870            "flow_id": self.current_flow_id(),
871            "node_id": Value::Null,
872            "component": "verify.pack",
873            "phase": "verify",
874            "status": status,
875            "inputs": Value::Null,
876            "outputs": Value::Null,
877            "error": json!({ "message": message }),
878            "metrics": Value::Null,
879            "schema_id": Value::Null,
880            "defaults_applied": Value::Null,
881            "redactions": Value::Array(Vec::new()),
882        });
883        self.transcript.lock().write(&event).map(|_| ())
884    }
885
886    fn write_mock_event(&self, capability: &str, provider: &str, payload: Value) -> Result<()> {
887        let timestamp = OffsetDateTime::now_utc();
888        let event = json!({
889            "ts": timestamp
890                .format(&Rfc3339)
891                .unwrap_or_else(|_| timestamp.to_string()),
892            "session_id": self.profile.session_id,
893            "flow_id": self.current_flow_id(),
894            "node_id": Value::Null,
895            "component": format!("mock::{capability}"),
896            "phase": "mock",
897            "status": "ok",
898            "inputs": json!({ "capability": capability, "provider": provider }),
899            "outputs": payload,
900            "error": Value::Null,
901            "metrics": Value::Null,
902            "schema_id": Value::Null,
903            "defaults_applied": Value::Null,
904            "redactions": Value::Array(Vec::new()),
905        });
906        self.transcript.lock().write(&event).map(|_| ())
907    }
908
909    fn handle_node_start(&self, event: &NodeEvent<'_>) -> Result<()> {
910        let timestamp = OffsetDateTime::now_utc();
911        let (redacted_payload, redactions) = redact_value(event.payload, "$.inputs.payload");
912        let inputs = json!({
913            "payload": redacted_payload,
914            "context": {
915                "tenant_id": self.profile.tenant_id.as_str(),
916                "team_id": self.profile.team_id.as_str(),
917                "user_id": self.profile.user_id.as_str(),
918            }
919        });
920        let flow_id = self.current_flow_id();
921        let event_json = build_transcript_event(TranscriptEventArgs {
922            profile: &self.profile,
923            flow_id: &flow_id,
924            node_id: event.node_id,
925            component: &event.node.component,
926            phase: "start",
927            status: "ok",
928            timestamp,
929            inputs,
930            outputs: Value::Null,
931            error: Value::Null,
932            redactions,
933        });
934        let (start_offset, _) = self.transcript.lock().write(&event_json)?;
935
936        let mut state = self.state.lock();
937        let node_key = event.node_id.to_string();
938        if !state.order.iter().any(|id| id == &node_key) {
939            state.order.push(node_key.clone());
940        }
941        let entry = state.nodes.entry(node_key).or_insert_with(|| {
942            NodeExecutionRecord::new(event.node.component.clone(), &self.directories)
943        });
944        entry.start_instant = Some(Instant::now());
945        entry.status = NodeStatus::Ok;
946        entry.transcript_start = Some(start_offset);
947        Ok(())
948    }
949
950    fn handle_node_end(&self, event: &NodeEvent<'_>, output: &Value) -> Result<()> {
951        let timestamp = OffsetDateTime::now_utc();
952        let (redacted_output, redactions) = redact_value(output, "$.outputs");
953        let flow_id = self.current_flow_id();
954        let event_json = build_transcript_event(TranscriptEventArgs {
955            profile: &self.profile,
956            flow_id: &flow_id,
957            node_id: event.node_id,
958            component: &event.node.component,
959            phase: "end",
960            status: "ok",
961            timestamp,
962            inputs: Value::Null,
963            outputs: redacted_output,
964            error: Value::Null,
965            redactions,
966        });
967        self.transcript.lock().write(&event_json)?;
968
969        let mut state = self.state.lock();
970        if let Some(entry) = state.nodes.get_mut(event.node_id)
971            && let Some(started) = entry.start_instant.take()
972        {
973            entry.duration_ms = Some(started.elapsed().as_millis() as u64);
974        }
975        Ok(())
976    }
977
978    fn handle_node_error(
979        &self,
980        event: &NodeEvent<'_>,
981        error: &dyn std::error::Error,
982    ) -> Result<()> {
983        let timestamp = OffsetDateTime::now_utc();
984        let error_message = error.to_string();
985        let error_json = json!({
986            "code": "component-failed",
987            "message": error_message,
988            "details": {
989                "node": event.node_id,
990            }
991        });
992        let flow_id = self.current_flow_id();
993        let event_json = build_transcript_event(TranscriptEventArgs {
994            profile: &self.profile,
995            flow_id: &flow_id,
996            node_id: event.node_id,
997            component: &event.node.component,
998            phase: "error",
999            status: "error",
1000            timestamp,
1001            inputs: Value::Null,
1002            outputs: Value::Null,
1003            error: error_json.clone(),
1004            redactions: Vec::new(),
1005        });
1006        let (_, end_offset) = self.transcript.lock().write(&event_json)?;
1007
1008        let mut state = self.state.lock();
1009        if let Some(entry) = state.nodes.get_mut(event.node_id) {
1010            entry.status = NodeStatus::Error;
1011            if let Some(started) = entry.start_instant.take() {
1012                entry.duration_ms = Some(started.elapsed().as_millis() as u64);
1013            }
1014            entry.transcript_error = Some(end_offset);
1015            entry.failure_code = Some("component-failed".to_string());
1016            entry.failure_message = Some(error_message.clone());
1017            entry.failure_details = Some(error_json);
1018            let log_path = self
1019                .directories
1020                .logs
1021                .join(format!("{}.stderr.log", sanitize_id(event.node_id)));
1022            if let Ok(mut file) = File::create(&log_path) {
1023                let _ = writeln!(file, "{error_message}");
1024            }
1025            entry.log_paths.push(log_path);
1026        }
1027        Ok(())
1028    }
1029}
1030
1031impl ExecutionObserver for RunRecorder {
1032    fn on_node_start(&self, event: &NodeEvent<'_>) {
1033        if let Err(err) = self.handle_node_start(event) {
1034            warn!(node = event.node_id, error = %err, "failed to record node start");
1035        }
1036    }
1037
1038    fn on_node_end(&self, event: &NodeEvent<'_>, output: &Value) {
1039        if let Err(err) = self.handle_node_end(event, output) {
1040            warn!(node = event.node_id, error = %err, "failed to record node end");
1041        }
1042    }
1043
1044    fn on_node_error(&self, event: &NodeEvent<'_>, error: &dyn std::error::Error) {
1045        if let Err(err) = self.handle_node_error(event, error) {
1046            warn!(node = event.node_id, error = %err, "failed to record node error");
1047        }
1048    }
1049}
1050
1051impl MockEventSink for RunRecorder {
1052    fn on_mock_event(&self, capability: &str, provider: &str, payload: &Value) {
1053        if let Err(err) = self.write_mock_event(capability, provider, payload.clone()) {
1054            warn!(?capability, ?provider, error = %err, "failed to record mock event");
1055        }
1056    }
1057}
1058
1059#[derive(Default)]
1060struct RunRecorderState {
1061    nodes: BTreeMap<String, NodeExecutionRecord>,
1062    order: Vec<String>,
1063}
1064
1065#[derive(Clone)]
1066struct NodeExecutionRecord {
1067    component: String,
1068    status: NodeStatus,
1069    duration_ms: Option<u64>,
1070    transcript_start: Option<u64>,
1071    transcript_error: Option<u64>,
1072    log_paths: Vec<PathBuf>,
1073    failure_code: Option<String>,
1074    failure_message: Option<String>,
1075    failure_details: Option<Value>,
1076    start_instant: Option<Instant>,
1077}
1078
1079impl NodeExecutionRecord {
1080    fn new(component: String, _dirs: &RunDirectories) -> Self {
1081        Self {
1082            component,
1083            status: NodeStatus::Ok,
1084            duration_ms: None,
1085            transcript_start: None,
1086            transcript_error: None,
1087            log_paths: Vec::new(),
1088            failure_code: None,
1089            failure_message: None,
1090            failure_details: None,
1091            start_instant: None,
1092        }
1093    }
1094}
1095
1096struct TranscriptWriter {
1097    writer: BufWriter<File>,
1098    offset: u64,
1099    hook: Option<TranscriptHook>,
1100}
1101
1102impl TranscriptWriter {
1103    fn new(writer: BufWriter<File>, hook: Option<TranscriptHook>) -> Self {
1104        Self {
1105            writer,
1106            offset: 0,
1107            hook,
1108        }
1109    }
1110
1111    fn write(&mut self, value: &Value) -> Result<(u64, u64)> {
1112        let line = serde_json::to_vec(value)?;
1113        let start = self.offset;
1114        self.writer.write_all(&line)?;
1115        self.writer.write_all(b"\n")?;
1116        self.writer.flush()?;
1117        self.offset += line.len() as u64 + 1;
1118        if let Some(hook) = &self.hook {
1119            hook(value);
1120        }
1121        Ok((start, self.offset))
1122    }
1123}
1124
1125struct TranscriptEventArgs<'a> {
1126    profile: &'a ResolvedProfile,
1127    flow_id: &'a str,
1128    node_id: &'a str,
1129    component: &'a str,
1130    phase: &'a str,
1131    status: &'a str,
1132    timestamp: OffsetDateTime,
1133    inputs: Value,
1134    outputs: Value,
1135    error: Value,
1136    redactions: Vec<String>,
1137}
1138
1139fn build_transcript_event(args: TranscriptEventArgs<'_>) -> Value {
1140    let ts = args
1141        .timestamp
1142        .format(&Rfc3339)
1143        .unwrap_or_else(|_| args.timestamp.to_string());
1144    json!({
1145        "ts": ts,
1146        "session_id": args.profile.session_id.as_str(),
1147        "flow_id": args.flow_id,
1148        "node_id": args.node_id,
1149        "component": args.component,
1150        "phase": args.phase,
1151        "status": args.status,
1152        "inputs": args.inputs,
1153        "outputs": args.outputs,
1154        "error": args.error,
1155        "metrics": {
1156            "duration_ms": null,
1157            "cpu_time_ms": null,
1158            "mem_peak_bytes": null,
1159        },
1160        "schema_id": Value::Null,
1161        "defaults_applied": Value::Array(Vec::new()),
1162        "redactions": args.redactions,
1163    })
1164}
1165
1166fn redact_value(value: &Value, base: &str) -> (Value, Vec<String>) {
1167    let mut paths = Vec::new();
1168    let redacted = redact_recursive(value, base, &mut paths);
1169    (redacted, paths)
1170}
1171
1172fn redact_recursive(value: &Value, path: &str, acc: &mut Vec<String>) -> Value {
1173    match value {
1174        Value::Object(map) => {
1175            let mut new_map = JsonMap::new();
1176            for (key, val) in map {
1177                let child_path = format!("{path}.{key}");
1178                if is_sensitive_key(key) {
1179                    acc.push(child_path);
1180                    new_map.insert(key.clone(), Value::String("__REDACTED__".into()));
1181                } else {
1182                    new_map.insert(key.clone(), redact_recursive(val, &child_path, acc));
1183                }
1184            }
1185            Value::Object(new_map)
1186        }
1187        Value::Array(items) => {
1188            let mut new_items = Vec::new();
1189            for (idx, item) in items.iter().enumerate() {
1190                let child_path = format!("{path}[{idx}]");
1191                new_items.push(redact_recursive(item, &child_path, acc));
1192            }
1193            Value::Array(new_items)
1194        }
1195        other => other.clone(),
1196    }
1197}
1198
1199fn is_sensitive_key(key: &str) -> bool {
1200    let lower = key.to_ascii_lowercase();
1201    const MARKERS: [&str; 5] = ["secret", "token", "password", "authorization", "cookie"];
1202    MARKERS.iter().any(|marker| lower.contains(marker))
1203}
1204
1205fn sanitize_id(value: &str) -> String {
1206    value
1207        .chars()
1208        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
1209        .collect()
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214    use super::*;
1215    use std::sync::Arc;
1216
1217    use async_trait::async_trait;
1218    use greentic_runner_host::secrets::DynSecretsManager;
1219    use greentic_secrets_lib::{SecretError, SecretsManager};
1220    use serde_json::json;
1221    use tempfile::TempDir;
1222
1223    fn sample_metadata() -> PackMetadata {
1224        PackMetadata {
1225            pack_id: "pack.demo".into(),
1226            version: "1.0.0".into(),
1227            entry_flows: vec!["entry.flow".into()],
1228            secret_requirements: Vec::new(),
1229        }
1230    }
1231
1232    fn sample_profile() -> ResolvedProfile {
1233        ResolvedProfile {
1234            tenant_id: "tenant".into(),
1235            team_id: "team".into(),
1236            user_id: "user".into(),
1237            session_id: "session-1".into(),
1238            provider_id: PROVIDER_ID_DEV.into(),
1239            max_node_wall_time_ms: 1000,
1240            max_run_wall_time_ms: 2000,
1241        }
1242    }
1243
1244    struct NoopSecretsManager;
1245
1246    #[async_trait]
1247    impl SecretsManager for NoopSecretsManager {
1248        async fn read(&self, path: &str) -> Result<Vec<u8>, SecretError> {
1249            Err(SecretError::NotFound(path.to_string()))
1250        }
1251
1252        async fn write(&self, _path: &str, _bytes: &[u8]) -> Result<(), SecretError> {
1253            Ok(())
1254        }
1255
1256        async fn delete(&self, _path: &str) -> Result<(), SecretError> {
1257            Ok(())
1258        }
1259    }
1260
1261    #[test]
1262    fn resolve_entry_flow_prefers_override_then_metadata_then_flows() {
1263        let metadata = sample_metadata();
1264        let flows = vec![FlowDescriptor {
1265            id: "fallback.flow".into(),
1266            flow_type: "message".into(),
1267            pack_id: "pack.demo".into(),
1268            profile: "default".into(),
1269            version: "1.0.0".into(),
1270            description: None,
1271        }];
1272
1273        assert_eq!(
1274            resolve_entry_flow(Some("manual.flow".into()), &metadata, &flows).unwrap(),
1275            "manual.flow"
1276        );
1277        assert_eq!(
1278            resolve_entry_flow(None, &metadata, &flows).unwrap(),
1279            "entry.flow"
1280        );
1281        assert!(
1282            resolve_entry_flow(
1283                None,
1284                &PackMetadata {
1285                    entry_flows: Vec::new(),
1286                    ..metadata
1287                },
1288                &flows
1289            )
1290            .is_ok()
1291        );
1292    }
1293
1294    #[test]
1295    fn normalize_pack_path_accepts_relative_files_inside_cwd() {
1296        let temp = tempfile::tempdir_in(std::env::current_dir().expect("cwd")).expect("tempdir");
1297        let pack = temp.path().join("demo.gtpack");
1298        fs::write(&pack, b"pack").expect("pack file");
1299        let relative = pack
1300            .strip_prefix(std::env::current_dir().expect("cwd"))
1301            .expect("relative")
1302            .to_path_buf();
1303
1304        let normalized = normalize_pack_path(&relative).expect("normalized path");
1305
1306        assert_eq!(normalized, pack.canonicalize().expect("canonical"));
1307    }
1308
1309    #[test]
1310    fn prepare_run_dirs_creates_logs_and_resolved_subdirs() {
1311        let temp = TempDir::new().expect("tempdir");
1312        let root = temp.path().join("artifacts");
1313        let dirs = prepare_run_dirs(Some(root.clone())).expect("run dirs");
1314
1315        assert_eq!(dirs.root, root);
1316        assert!(dirs.logs.is_dir());
1317        assert!(dirs.resolved.is_dir());
1318    }
1319
1320    #[test]
1321    fn redact_value_masks_nested_sensitive_fields() {
1322        let (redacted, paths) = redact_value(
1323            &json!({
1324                "token": "abc",
1325                "nested": { "authorization_header": "secret" },
1326                "items": [{ "password_hint": "nope" }]
1327            }),
1328            "$",
1329        );
1330
1331        assert_eq!(redacted["token"], "__REDACTED__");
1332        assert_eq!(redacted["nested"]["authorization_header"], "__REDACTED__");
1333        assert_eq!(redacted["items"][0]["password_hint"], "__REDACTED__");
1334        assert!(paths.contains(&"$.token".to_string()));
1335        assert!(paths.contains(&"$.nested.authorization_header".to_string()));
1336    }
1337
1338    #[test]
1339    fn helper_functions_preserve_ids_and_transcript_shape() {
1340        assert_eq!(sanitize_id("flow demo/1"), "flow-demo-1");
1341        assert!(is_signature_error("Signature verification failed"));
1342        assert_eq!(
1343            to_reader_policy(SigningPolicy::DevOk),
1344            greentic_pack::reader::SigningPolicy::DevOk
1345        );
1346
1347        let profile = sample_profile();
1348        let event = build_transcript_event(TranscriptEventArgs {
1349            profile: &profile,
1350            flow_id: "flow.demo",
1351            node_id: "node.demo",
1352            component: "component.demo",
1353            phase: "invoke",
1354            status: "ok",
1355            timestamp: OffsetDateTime::now_utc(),
1356            inputs: json!({"input": true}),
1357            outputs: json!({"output": true}),
1358            error: Value::Null,
1359            redactions: vec!["$.token".into()],
1360        });
1361        assert_eq!(event["session_id"], "session-1");
1362        assert_eq!(event["flow_id"], "flow.demo");
1363        assert_eq!(event["redactions"][0], "$.token");
1364    }
1365
1366    #[test]
1367    fn resolve_profile_uses_context_overrides() {
1368        let profile = resolve_profile(
1369            &Profile::Dev(DevProfile::default()),
1370            &TenantContext {
1371                tenant_id: Some("tenant-x".into()),
1372                team_id: Some("team-x".into()),
1373                user_id: Some("user-x".into()),
1374                session_id: Some("session-x".into()),
1375            },
1376        );
1377
1378        assert_eq!(profile.tenant_id, "tenant-x");
1379        assert_eq!(profile.team_id, "team-x");
1380        assert_eq!(profile.user_id, "user-x");
1381        assert_eq!(profile.session_id, "session-x");
1382        assert_eq!(profile.provider_id, PROVIDER_ID_DEV);
1383    }
1384
1385    #[test]
1386    fn desktop_defaults_and_runner_builder_preserve_overrides() {
1387        let defaults = desktop_defaults();
1388        assert_eq!(defaults.signing, SigningPolicy::DevOk);
1389        assert_eq!(defaults.ctx.tenant_id.as_deref(), Some("local-dev"));
1390
1391        let runner = Runner::new()
1392            .with_mocks(MocksConfig {
1393                telemetry: Some(TelemetryMock),
1394                ..MocksConfig::default()
1395            })
1396            .configure(|opts| {
1397                opts.entry_flow = Some("entry.flow".into());
1398                opts.input = json!({"demo": true});
1399            });
1400
1401        let rendered = format!("{:?}", runner.base);
1402        assert!(rendered.contains("entry.flow"));
1403        assert!(runner.base.mocks.telemetry.is_some());
1404        assert_eq!(runner.base.input, json!({"demo": true}));
1405    }
1406
1407    #[test]
1408    fn resolve_entry_flow_errors_when_pack_has_no_flows() {
1409        let metadata = PackMetadata {
1410            entry_flows: Vec::new(),
1411            ..sample_metadata()
1412        };
1413        assert!(resolve_entry_flow(None, &metadata, &[]).is_err());
1414    }
1415
1416    #[test]
1417    fn build_host_config_enables_local_dev_defaults() {
1418        let temp = TempDir::new().expect("tempdir");
1419        let dirs = prepare_run_dirs(Some(temp.path().join("run"))).expect("dirs");
1420        let config = build_host_config(&sample_profile(), &dirs, false);
1421
1422        assert_eq!(config.tenant, "tenant");
1423        assert!(!config.http_enabled);
1424        assert!(config.secrets_policy.is_allowed("any.secret"));
1425        assert!(
1426            config
1427                .operator_policy
1428                .allows_provider(Some("provider"), "provider")
1429        );
1430    }
1431
1432    #[test]
1433    fn build_host_config_honours_http_enabled_flag() {
1434        let temp = TempDir::new().expect("tempdir");
1435        let dirs = prepare_run_dirs(Some(temp.path().join("run"))).expect("dirs");
1436        let enabled = build_host_config(&sample_profile(), &dirs, true);
1437        assert!(
1438            enabled.http_enabled,
1439            "http_enabled should propagate when derived from manifest"
1440        );
1441
1442        let disabled = build_host_config(&sample_profile(), &dirs, false);
1443        assert!(!disabled.http_enabled);
1444    }
1445
1446    #[test]
1447    fn derive_http_enabled_true_when_any_component_declares_http_client() {
1448        assert!(derive_http_enabled(&[false, true], false));
1449    }
1450
1451    #[test]
1452    fn derive_http_enabled_false_when_no_component_declares_http_client() {
1453        assert!(!derive_http_enabled(&[false, false], false));
1454    }
1455
1456    #[test]
1457    fn derive_http_enabled_false_for_empty_components() {
1458        assert!(!derive_http_enabled(&[], false));
1459    }
1460
1461    #[test]
1462    fn derive_http_enabled_kill_switch_forces_disable() {
1463        assert!(
1464            !derive_http_enabled(&[true], true),
1465            "kill-switch must override positive manifest declaration"
1466        );
1467    }
1468
1469    #[test]
1470    fn parse_kill_switch_matches_truthy_and_falsy_inputs() {
1471        // (raw input, expected kill-switch active)
1472        let cases: &[(Option<&str>, bool)] = &[
1473            (Some("1"), true),
1474            (Some("true"), true),
1475            (Some("YES"), true),
1476            (Some("  on  "), true),
1477            (Some("On"), true),
1478            (Some("0"), false),
1479            (Some("false"), false),
1480            (Some("bogus"), false),
1481            (Some(""), false),
1482            (None, false),
1483        ];
1484
1485        for (raw, expected) in cases {
1486            assert_eq!(
1487                parse_kill_switch(*raw),
1488                *expected,
1489                "parse_kill_switch({raw:?}) should be {expected}"
1490            );
1491        }
1492    }
1493
1494    #[test]
1495    fn redact_value_leaves_non_sensitive_payloads_unchanged() {
1496        let original = json!({"public": {"value": 1}});
1497        let (redacted, paths) = redact_value(&original, "$");
1498        assert_eq!(redacted, original);
1499        assert!(paths.is_empty());
1500    }
1501
1502    #[test]
1503    fn resolve_secrets_manager_prefers_injected_override_even_in_prod() {
1504        let previous_env = std::env::var("GREENTIC_ENV").ok();
1505        unsafe {
1506            std::env::set_var("GREENTIC_ENV", "prod");
1507        }
1508
1509        let without_override = resolve_secrets_manager(&RunOptions::default());
1510        assert!(
1511            without_override.is_err(),
1512            "expected prod desktop default backend to be rejected"
1513        );
1514
1515        let injected: DynSecretsManager = Arc::new(NoopSecretsManager);
1516        let with_override = resolve_secrets_manager(&RunOptions {
1517            secrets_manager: Some(Arc::clone(&injected)),
1518            ..RunOptions::default()
1519        });
1520        assert!(
1521            with_override.is_ok(),
1522            "expected injected secrets manager to bypass desktop default backend"
1523        );
1524
1525        match previous_env {
1526            Some(value) => unsafe {
1527                std::env::set_var("GREENTIC_ENV", value);
1528            },
1529            None => unsafe {
1530                std::env::remove_var("GREENTIC_ENV");
1531            },
1532        }
1533    }
1534}