1use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16use std::time::{Duration, Instant};
17
18use serde_json::{json, Map, Value};
19
20use crate::adapters::{HarnessAdapter, RunOutcome};
21use crate::error::{Error, Result};
22use crate::lockfile::{Lockfile, Tool};
23use crate::profiles::{python_for, Ctx, HarnessProfile};
24use crate::search::Stack;
25use crate::tasks::Task;
26
27pub const ENTRY_SHIM: &str = include_str!("entry_shim.py");
29
30#[derive(Debug, Clone)]
32pub struct Invocation {
33 pub argv: Vec<String>,
34 pub env: BTreeMap<String, String>,
35 pub cwd: PathBuf,
36 pub output: PathBuf,
38}
39
40#[derive(Debug, Clone, Default, serde::Serialize)]
42pub struct RunResult {
43 pub success: bool,
44 pub exit_code: Option<i32>,
45 pub output: Option<Value>,
46 pub cost_usd: f64,
47 pub steps: u64,
48 pub latency_s: f64,
49 pub trace: Value,
50 pub log: String,
51}
52
53fn subst(s: &str, vars: &[(&str, &str)]) -> String {
54 vars.iter().fold(s.to_string(), |acc, (k, v)| acc.replace(&format!("{{{k}}}"), v))
55}
56
57fn fill_env(s: &str, ctx: &Ctx) -> String {
59 let mut out = s.to_string();
60 let mut start = 0;
61 while let Some(i) = out[start..].find("${") {
62 let i = start + i;
63 let Some(j) = out[i..].find('}') else { break };
64 let name = out[i + 2..i + j].to_string();
65 match ctx.var(&name) {
66 Some(v) => {
67 let v = v.to_string();
68 out.replace_range(i..i + j + 1, &v);
69 start = i + v.len();
70 }
71 None => start = i + j + 1,
72 }
73 }
74 out
75}
76
77pub fn mcp_config(tools: &[Tool], ctx: &Ctx) -> Value {
79 let mut servers = Map::new();
80 for t in tools.iter().filter(|t| t.kind == "mcp" && t.enabled != Some(false)) {
81 let fill_map = |m: &BTreeMap<String, String>| -> Value {
82 Value::Object(m.iter().map(|(k, v)| (k.clone(), json!(fill_env(v, ctx)))).collect())
83 };
84 let entry = match (t.transport.as_deref(), &t.command, &t.url) {
85 (Some("http") | Some("sse"), _, Some(url)) => {
86 let mut e = json!({"type": t.transport, "url": fill_env(url, ctx)});
87 if !t.headers.is_empty() {
88 e["headers"] = fill_map(&t.headers);
89 }
90 e
91 }
92 (_, Some(cmd), _) => {
93 let mut e = json!({"command": cmd, "args": t.args.iter().map(|a| fill_env(a, ctx)).collect::<Vec<_>>()});
94 if !t.env.is_empty() {
95 e["env"] = fill_map(&t.env);
96 }
97 e
98 }
99 _ => continue,
100 };
101 servers.insert(t.name.clone(), entry);
102 }
103 json!({ "mcpServers": servers })
104}
105
106pub fn invocation(
109 profile: &HarnessProfile,
110 lock: Option<&Lockfile>,
111 lock_path: Option<&Path>,
112 model: Option<&str>,
113 prompt: &str,
114 workspace: &Path,
115 seed: u64,
116 ctx: &Ctx,
117) -> Result<Invocation> {
118 let workspace = &if workspace.is_absolute() { workspace.to_path_buf() } else { ctx.cwd.join(workspace) };
120 let hl = workspace.join(".hlock");
121 std::fs::create_dir_all(&hl)?;
122 let output = hl.join("result.json");
123 let _ = std::fs::remove_file(&output);
124
125 let llm = lock.and_then(|l| l.llm.iter().find(|x| x.role == "default").or_else(|| l.llm.first()));
126 let model = model.map(str::to_string).or_else(|| llm.map(|l| format!("{}:{}", l.provider, l.model))).unwrap_or_default();
127 let base_url = llm.and_then(|l| l.endpoint.clone()).or_else(|| ctx.var("HL_LLM_BASE_URL").map(str::to_string));
128
129 let mut env: BTreeMap<String, String> = profile.run.env.clone();
130 env.insert("HLOCK_PROMPT".into(), prompt.into());
131 env.insert("HLOCK_MODEL".into(), model.clone());
132 env.insert("HLOCK_WORKSPACE".into(), workspace.to_string_lossy().into_owned());
133 env.insert("HLOCK_OUTPUT".into(), output.to_string_lossy().into_owned());
134 env.insert("HLOCK_SEED".into(), seed.to_string());
135 env.insert("HLOCK_HARNESS".into(), profile.id.clone());
136 if let Some(b) = &base_url {
137 env.insert("HLOCK_LLM_BASE_URL".into(), b.clone());
138 }
139 if let Some(p) = lock_path {
140 let abs = if p.is_absolute() { p.to_path_buf() } else { ctx.cwd.join(p) };
141 env.insert("HLOCK_LOCK".into(), abs.to_string_lossy().into_owned());
142 }
143 if let Some(l) = lock {
144 if l.tool.iter().any(|t| t.kind == "mcp") {
145 let path = hl.join("mcp.json");
146 std::fs::write(&path, serde_json::to_string_pretty(&mcp_config(&l.tool, ctx))?)?;
147 env.insert("HLOCK_MCP_CONFIG".into(), path.to_string_lossy().into_owned());
148 }
149 let lock_dir = lock_path.and_then(Path::parent).map(|d| if d.is_absolute() { d.to_path_buf() } else { ctx.cwd.join(d) }).unwrap_or_else(|| ctx.cwd.clone());
150 let skills: Vec<(String, PathBuf)> = l
151 .skill
152 .iter()
153 .filter_map(|s| {
154 let dir = s.vendored.as_ref().map(|v| lock_dir.join(v)).or_else(|| s.path.as_ref().map(|p| ctx.expand(p, None)))?;
155 dir.join("SKILL.md").is_file().then(|| (s.name.clone(), dir))
156 })
157 .collect();
158 if !skills.is_empty() {
159 let dir = hl.join("skills");
160 let _ = std::fs::remove_dir_all(&dir);
161 for (name, src) in skills {
162 copy_dir(&src, &dir.join(name));
163 }
164 env.insert("HLOCK_SKILLS_DIR".into(), dir.to_string_lossy().into_owned());
165 }
166 }
167
168 let vars = [("prompt", prompt), ("model", model.as_str()), ("workspace", &*workspace.to_string_lossy())];
169 let argv: Vec<String> = if let Some(ep) = &profile.run.entrypoint {
170 let py = python_for(profile.run.python.as_deref(), ctx).ok_or_else(|| Error::Profile("no Python interpreter found (set HL_PYTHON)".into()))?;
171 let pp = match ctx.var("PYTHONPATH") {
173 Some(old) => format!("{}{}{old}", ctx.cwd.display(), if cfg!(windows) { ";" } else { ":" }),
174 None => ctx.cwd.to_string_lossy().into_owned(),
175 };
176 env.insert("PYTHONPATH".into(), pp);
177 vec![py.to_string_lossy().into_owned(), "-c".into(), ENTRY_SHIM.into(), ep.clone()]
178 } else if !profile.run.command.is_empty() {
179 let mut a: Vec<String> = profile.run.command.iter().map(|x| subst(x, &vars)).collect();
180 if a[0].contains('/') && !Path::new(&a[0]).is_absolute() {
181 a[0] = ctx.cwd.join(&a[0]).to_string_lossy().into_owned();
182 }
183 a
184 } else {
185 return Err(Error::Profile(format!("harness {:?} has no entrypoint or command", profile.id)));
186 };
187 Ok(Invocation { argv, env, cwd: workspace.to_path_buf(), output })
188}
189
190pub fn run(inv: &Invocation, ctx: &Ctx, timeout: Duration) -> Result<RunResult> {
192 let start = Instant::now();
193 let mut child = Command::new(&inv.argv[0])
194 .args(&inv.argv[1..])
195 .current_dir(&inv.cwd)
196 .env_clear()
197 .envs(&ctx.env)
198 .envs(&inv.env)
199 .stdin(Stdio::null())
200 .stdout(Stdio::piped())
201 .stderr(Stdio::piped())
202 .spawn()
203 .map_err(|e| Error::Profile(format!("cannot run {}: {e}", inv.argv[0])))?;
204 let mut timed_out = false;
205 loop {
206 if child.try_wait()?.is_some() {
207 break;
208 }
209 if start.elapsed() > timeout {
210 let _ = child.kill();
211 timed_out = true;
212 break;
213 }
214 std::thread::sleep(Duration::from_millis(20));
215 }
216 let out = child.wait_with_output()?;
217 let mut log = String::from_utf8_lossy(&out.stdout).into_owned();
218 log.push_str(&String::from_utf8_lossy(&out.stderr));
219 let parsed: Option<Value> = std::fs::read_to_string(&inv.output).ok().and_then(|t| serde_json::from_str(&t).ok());
220 let exit_ok = out.status.success() && !timed_out;
221 let success = parsed.as_ref().and_then(|v| v.get("success")).and_then(Value::as_bool).unwrap_or(exit_ok) && !timed_out;
222 if timed_out {
223 log.push_str(&format!("\n[hlock] timed out after {}s", timeout.as_secs()));
224 }
225 Ok(RunResult {
226 success,
227 exit_code: out.status.code(),
228 cost_usd: parsed.as_ref().and_then(|v| v["cost_usd"].as_f64()).unwrap_or(0.0),
229 steps: parsed.as_ref().and_then(|v| v["steps"].as_u64()).unwrap_or(1),
230 trace: parsed.as_ref().and_then(|v| v.get("trace").cloned()).unwrap_or_else(|| json!([])),
231 latency_s: (start.elapsed().as_secs_f64() * 1e3).round() / 1e3,
232 output: parsed,
233 log,
234 })
235}
236
237fn copy_dir(src: &Path, dst: &Path) {
238 let _ = std::fs::create_dir_all(dst);
239 for e in std::fs::read_dir(src).into_iter().flatten().flatten() {
240 let (from, to) = (e.path(), dst.join(e.file_name()));
241 if from.is_dir() {
242 copy_dir(&from, &to);
243 } else {
244 let _ = std::fs::copy(&from, &to);
245 }
246 }
247}
248
249pub struct CommandAdapter {
251 profile: HarnessProfile,
252 ctx: Ctx,
253}
254
255impl CommandAdapter {
256 pub fn new(profile: HarnessProfile, ctx: Ctx) -> Self {
257 CommandAdapter { profile, ctx }
258 }
259}
260
261impl HarnessAdapter for CommandAdapter {
262 fn name(&self) -> &str {
263 &self.profile.id
264 }
265
266 fn run(&self, stack: &Stack, task: &Task, workspace: &Path, seed: u64) -> Result<RunOutcome> {
267 if let (Some(fixtures), Some(pack_dir)) = (&task.fixtures, &task.pack_dir) {
268 let src = pack_dir.join(fixtures);
269 if src.exists() {
270 copy_dir(&src, workspace);
271 }
272 }
273 let model = stack.llm.get("planner").or_else(|| stack.llm.values().next()).cloned();
274 let inv = invocation(&self.profile, None, None, model.as_deref(), &task.prompt_text, workspace, seed, &self.ctx)?;
275 let r = run(&inv, &self.ctx, Duration::from_secs(task.timeout_seconds.max(1)))?;
276 let mut trace = r.trace.clone();
277 if let Value::Array(a) = &mut trace {
278 let tail: Vec<&str> = r.log.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect();
279 a.push(json!({"type": "harness.exit", "harness": self.profile.id, "code": r.exit_code, "latency_s": r.latency_s, "log_tail": tail.join("\n")}));
280 }
281 Ok(RunOutcome { success: r.success, cost_usd: r.cost_usd, latency_s: r.latency_s, steps: r.steps, trace })
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn substitution_and_env_fill() {
291 assert_eq!(subst("--model={model} {prompt}", &[("model", "a:b"), ("prompt", "hi")]), "--model=a:b hi");
292 let ctx = Ctx { home: "/h".into(), cwd: "/w".into(), env: [("TOK".to_string(), "x".to_string())].into(), path: vec![] };
293 assert_eq!(fill_env("Bearer ${TOK} ${MISSING}", &ctx), "Bearer x ${MISSING}");
294 }
295
296 #[test]
297 fn mcp_document() {
298 let ctx = Ctx { home: "/h".into(), cwd: "/w".into(), env: [("HLOCK_SECRET_T".to_string(), "s3".to_string())].into(), path: vec![] };
299 let tools = vec![
300 Tool { name: "gh".into(), kind: "mcp".into(), transport: Some("stdio".into()), command: Some("npx".into()), args: vec!["pkg".into()], env: [("T".to_string(), "${HLOCK_SECRET_T}".to_string())].into(), ..Default::default() },
301 Tool { name: "remote".into(), kind: "mcp".into(), transport: Some("http".into()), url: Some("https://x/mcp".into()), ..Default::default() },
302 Tool { name: "off".into(), kind: "mcp".into(), command: Some("x".into()), enabled: Some(false), ..Default::default() },
303 ];
304 let v = mcp_config(&tools, &ctx);
305 assert_eq!(v["mcpServers"]["gh"]["env"]["T"], "s3");
306 assert_eq!(v["mcpServers"]["remote"]["type"], "http");
307 assert!(v["mcpServers"].get("off").is_none());
308 }
309}