greentic_dev/
pack_run.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::io::{self, IsTerminal, Write};
4use std::path::{Path, PathBuf};
5
6use anyhow::bail;
7use anyhow::{Context, Result, anyhow};
8use greentic_flow::flow_bundle::load_and_validate_bundle;
9use greentic_runner::desktop::{
10    HttpMock, HttpMockMode, MocksConfig, OtlpHook, Runner, SigningPolicy, ToolsMock,
11};
12use serde_json::{Value as JsonValue, json};
13use serde_yaml_bw as serde_yaml;
14
15#[derive(Debug, Clone)]
16pub struct PackRunConfig<'a> {
17    pub pack_path: &'a Path,
18    pub entry: Option<String>,
19    pub input: Option<String>,
20    pub policy: RunPolicy,
21    pub otlp: Option<String>,
22    pub allow_hosts: Option<Vec<String>>,
23    pub mocks: MockSetting,
24    pub artifacts_dir: Option<&'a Path>,
25}
26
27#[derive(Debug, Clone, Copy)]
28pub enum RunPolicy {
29    Strict,
30    DevOk,
31}
32
33#[derive(Debug, Clone, Copy)]
34pub enum MockSetting {
35    On,
36    Off,
37}
38
39pub fn run(config: PackRunConfig<'_>) -> Result<()> {
40    // Print runner diagnostics even if the caller did not configure tracing.
41    let _ = tracing_subscriber::fmt::try_init();
42
43    // Ensure Wasmtime cache/config paths live inside the workspace so sandboxed runs can create them.
44    if std::env::var_os("HOME").is_none() || std::env::var_os("WASMTIME_CACHE_DIR").is_none() {
45        let workspace = std::env::current_dir().context("failed to resolve workspace root")?;
46        let home = workspace.join(".greentic").join("wasmtime-home");
47        let cache_dir = home
48            .join("Library")
49            .join("Caches")
50            .join("BytecodeAlliance.wasmtime");
51        let config_dir = home
52            .join("Library")
53            .join("Application Support")
54            .join("wasmtime");
55        fs::create_dir_all(&cache_dir)
56            .with_context(|| format!("failed to create {}", cache_dir.display()))?;
57        fs::create_dir_all(&config_dir)
58            .with_context(|| format!("failed to create {}", config_dir.display()))?;
59        // SAFETY: we scope HOME and cache dir to a workspace-local directory to avoid
60        // writing outside the sandbox; this only affects the child Wasmtime engine.
61        unsafe {
62            std::env::set_var("HOME", &home);
63            std::env::set_var("WASMTIME_CACHE_DIR", &cache_dir);
64        }
65    }
66
67    let input_value = parse_input(config.input)?;
68    let otlp_hook = config.otlp.map(|endpoint| OtlpHook {
69        endpoint,
70        headers: Vec::new(),
71        sample_all: true,
72    });
73    let allow_hosts = config.allow_hosts.unwrap_or_default();
74    let mocks_config = build_mocks_config(config.mocks, allow_hosts)?;
75
76    let artifacts_override = config.artifacts_dir.map(|dir| dir.to_path_buf());
77    if let Some(dir) = &artifacts_override {
78        fs::create_dir_all(dir)
79            .with_context(|| format!("failed to create artifacts directory {}", dir.display()))?;
80    }
81
82    let runner = Runner::new();
83    let run_result = runner
84        .run_pack_with(config.pack_path, |opts| {
85            opts.entry_flow = config.entry.clone();
86            opts.input = input_value.clone();
87            opts.signing = signing_policy(config.policy);
88            if let Some(hook) = otlp_hook.clone() {
89                opts.otlp = Some(hook);
90            }
91            opts.mocks = mocks_config.clone();
92            opts.artifacts_dir = artifacts_override.clone();
93        })
94        .context("pack execution failed")?;
95
96    let value = serde_json::to_value(&run_result).context("failed to render run result JSON")?;
97    let status = value
98        .get("status")
99        .and_then(|v| v.as_str())
100        .unwrap_or_default();
101    let rendered =
102        serde_json::to_string_pretty(&value).context("failed to render run result JSON")?;
103    println!("{rendered}");
104
105    if status == "Failure" || status == "PartialFailure" {
106        let err = value
107            .get("error")
108            .and_then(|v| v.as_str())
109            .unwrap_or("pack run returned failure status");
110        bail!("pack run failed: {err}");
111    }
112
113    Ok(())
114}
115
116fn parse_input(input: Option<String>) -> Result<JsonValue> {
117    if let Some(raw) = input {
118        if raw.trim().is_empty() {
119            return Ok(json!({}));
120        }
121        serde_json::from_str(&raw).context("failed to parse --input JSON")
122    } else {
123        Ok(json!({}))
124    }
125}
126
127fn build_mocks_config(setting: MockSetting, allow_hosts: Vec<String>) -> Result<MocksConfig> {
128    let mut config = MocksConfig {
129        net_allowlist: allow_hosts
130            .into_iter()
131            .map(|host| host.trim().to_ascii_lowercase())
132            .filter(|host| !host.is_empty())
133            .collect(),
134        ..MocksConfig::default()
135    };
136
137    if matches!(setting, MockSetting::On) {
138        config.http = Some(HttpMock {
139            record_replay_dir: None,
140            mode: HttpMockMode::RecordReplay,
141            rewrites: Vec::new(),
142        });
143
144        let tools_dir = PathBuf::from(".greentic").join("mocks").join("tools");
145        fs::create_dir_all(&tools_dir)
146            .with_context(|| format!("failed to create {}", tools_dir.display()))?;
147        config.mcp_tools = Some(ToolsMock {
148            directory: None,
149            script_dir: Some(tools_dir),
150            short_circuit: true,
151        });
152    }
153
154    Ok(config)
155}
156
157fn signing_policy(policy: RunPolicy) -> SigningPolicy {
158    match policy {
159        RunPolicy::Strict => SigningPolicy::Strict,
160        RunPolicy::DevOk => SigningPolicy::DevOk,
161    }
162}
163
164/// Run a config flow and return the final payload as a JSON string.
165#[allow(dead_code)]
166pub fn run_config_flow(flow_path: &Path) -> Result<String> {
167    let source = std::fs::read_to_string(flow_path)
168        .with_context(|| format!("failed to read config flow {}", flow_path.display()))?;
169    // Validate against embedded schema to catch malformed flows.
170    load_and_validate_bundle(&source, Some(flow_path)).context("config flow validation failed")?;
171
172    let doc: serde_yaml::Value = serde_yaml::from_str(&source)
173        .with_context(|| format!("invalid YAML in {}", flow_path.display()))?;
174    let nodes = doc
175        .get("nodes")
176        .and_then(|v| v.as_mapping())
177        .ok_or_else(|| anyhow!("config flow missing nodes map"))?;
178
179    let mut current = nodes
180        .iter()
181        .next()
182        .map(|(k, _)| k.as_str().unwrap_or_default().to_string())
183        .ok_or_else(|| anyhow!("config flow has no nodes to execute"))?;
184    let mut state: BTreeMap<String, String> = BTreeMap::new();
185    let mut visited: BTreeSet<String> = BTreeSet::new();
186    let is_tty = io::stdin().is_terminal();
187
188    loop {
189        if !visited.insert(current.clone()) {
190            bail!("config flow routing loop detected at {}", current);
191        }
192
193        let node_val = nodes
194            .get(serde_yaml::Value::String(current.clone(), None))
195            .ok_or_else(|| anyhow!("node `{current}` not found in config flow"))?;
196        let mapping = node_val
197            .as_mapping()
198            .ok_or_else(|| anyhow!("node `{current}` is not a mapping"))?;
199
200        // questions node
201        if let Some(fields) = mapping
202            .get(serde_yaml::Value::String("questions".to_string(), None))
203            .and_then(|q| {
204                q.as_mapping()
205                    .and_then(|m| m.get(serde_yaml::Value::String("fields".to_string(), None)))
206            })
207            .and_then(|v| v.as_sequence())
208        {
209            for field in fields {
210                let Some(field_map) = field.as_mapping() else {
211                    continue;
212                };
213                let id = field_map
214                    .get(serde_yaml::Value::String("id".to_string(), None))
215                    .and_then(|v| v.as_str())
216                    .unwrap_or("")
217                    .to_string();
218                if id.is_empty() {
219                    continue;
220                }
221                let prompt = field_map
222                    .get(serde_yaml::Value::String("prompt".to_string(), None))
223                    .and_then(|v| v.as_str())
224                    .unwrap_or(&id);
225                let default = field_map
226                    .get(serde_yaml::Value::String("default".to_string(), None))
227                    .and_then(|v| v.as_str())
228                    .unwrap_or("");
229                let value = if is_tty {
230                    print!("{prompt} [{default}]: ");
231                    let _ = io::stdout().flush();
232                    let mut buf = String::new();
233                    io::stdin().read_line(&mut buf).ok();
234                    let trimmed = buf.trim();
235                    if trimmed.is_empty() {
236                        default.to_string()
237                    } else {
238                        trimmed.to_string()
239                    }
240                } else {
241                    default.to_string()
242                };
243                state.insert(id, value);
244            }
245        }
246
247        // template string path
248        if let Some(template) = mapping
249            .get(serde_yaml::Value::String("template".to_string(), None))
250            .and_then(|v| v.as_str())
251        {
252            let mut rendered = template.to_string();
253            for (k, v) in &state {
254                let needle = format!("{{{{state.{k}}}}}");
255                rendered = rendered.replace(&needle, v);
256            }
257            return Ok(rendered);
258        }
259
260        // payload with node_id/node
261        if let Some(payload) = mapping.get(serde_yaml::Value::String("payload".to_string(), None)) {
262            let json_str = serde_json::to_string(&serde_yaml::from_value::<serde_json::Value>(
263                payload.clone(),
264            )?)
265            .context("failed to render config flow payload")?;
266            return Ok(json_str);
267        }
268
269        // follow routing if present
270        if let Some(next) = mapping
271            .get(serde_yaml::Value::String("routing".to_string(), None))
272            .and_then(|r| r.as_sequence())
273            .and_then(|seq| seq.first())
274            .and_then(|entry| {
275                entry
276                    .as_mapping()
277                    .and_then(|m| m.get(serde_yaml::Value::String("to".to_string(), None)))
278                    .and_then(|v| v.as_str())
279            })
280        {
281            current = next.to_string();
282            continue;
283        }
284
285        bail!("config flow ended without producing template or payload");
286    }
287}