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