1use std::collections::BTreeMap;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Error, Result};
9use crate::{LOCK_VERSION, LOCKFILE_PATH};
10
11pub const HEADER: &str = "# This file is generated by harness-lock. Do not edit by hand.";
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(default)]
15pub struct LLM {
16 pub role: String,
17 pub provider: String,
18 pub model: String,
19 #[serde(rename = "resolved-from")]
20 pub resolved_from: Option<String>,
21 #[serde(default)]
22 pub params: BTreeMap<String, serde_json::Value>,
23 pub fingerprint: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub endpoint: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub runtime: Option<String>,
30}
31
32impl Default for LLM {
33 fn default() -> Self {
34 LLM {
35 role: String::new(),
36 provider: "reference".into(),
37 model: String::new(),
38 resolved_from: None,
39 params: BTreeMap::new(),
40 fingerprint: None,
41 endpoint: None,
42 runtime: None,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48#[serde(default)]
49pub struct Decision {
50 pub kind: String,
51 pub model: Option<String>,
52 pub revision: Option<String>,
53 #[serde(default)]
54 pub decisions: Vec<String>,
55 #[serde(default)]
56 pub thresholds: BTreeMap<String, f64>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60#[serde(default)]
61pub struct Harness {
62 pub name: String,
63 pub version: Option<String>,
64 pub source: Option<String>,
65 pub integrity: Option<String>,
66 pub adapter: Option<String>,
67 pub config: Option<String>,
68 pub pattern: Option<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72#[serde(default)]
73pub struct Tool {
74 pub name: String,
75 pub kind: String,
76 pub source: Option<String>,
77 pub integrity: Option<String>,
78 #[serde(rename = "schema")]
79 pub tool_schema: Option<String>,
80 #[serde(default)]
81 pub permissions: Vec<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub transport: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub command: Option<String>,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub args: Vec<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub url: Option<String>,
91 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
93 pub env: BTreeMap<String, String>,
94 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
95 pub headers: BTreeMap<String, String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub scope: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub enabled: Option<bool>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
104#[serde(default)]
105pub struct Skill {
106 pub name: String,
107 pub origin: String,
108 pub source: Option<String>,
109 pub digest: Option<String>,
110 pub registry: Option<String>,
111 #[serde(rename = "provided-by")]
112 pub provided_by: Option<String>,
113 #[serde(default = "default_true")]
114 pub enabled: bool,
115 #[serde(default)]
116 pub editable: bool,
117 #[serde(rename = "evidence-digest")]
118 pub evidence_digest: Option<String>,
119 #[serde(default)]
120 pub requires: Vec<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub scope: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub path: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub vendored: Option<String>,
130}
131
132fn default_true() -> bool {
133 true
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137#[serde(default)]
138pub struct Instructions {
139 pub name: String,
140 pub digest: Option<String>,
141 pub scope: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub vendored: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
148#[serde(default)]
149pub struct Native {
150 pub kind: String,
152 pub name: String,
153 pub version: Option<String>,
154 pub source: Option<String>,
155 pub revision: Option<String>,
156 pub integrity: Option<String>,
157 #[serde(default = "default_true")]
158 pub enabled: bool,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub config: Option<serde_json::Value>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
166#[serde(default)]
167pub struct Runtime {
168 pub name: String,
169 pub location: String,
171 pub version: Option<String>,
172 pub source: Option<String>,
173 pub endpoint: Option<String>,
174 #[serde(default)]
175 pub model: Vec<RuntimeModel>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
179#[serde(default)]
180pub struct RuntimeModel {
181 pub id: String,
182 pub source: Option<String>,
184 pub revision: Option<String>,
185 pub digest: Option<String>,
186 #[serde(rename = "size-bytes")]
187 pub size_bytes: Option<u64>,
188 pub present: bool,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
194#[serde(default)]
195pub struct ConfigFile {
196 pub id: String,
198 pub path: String,
199 pub format: String,
200 pub restore: String,
202 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub pointers: Vec<String>,
204 pub digest: Option<String>,
205 pub vendored: Option<String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
211#[serde(default, rename_all = "kebab-case")]
212pub struct SecretRef {
213 pub name: String,
215 pub used_by: String,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, Default)]
219#[serde(default, rename_all = "kebab-case")]
220pub struct Policy {
221 pub retries: u32,
222 pub tool_timeout_seconds: Option<u64>,
223 pub max_steps: u32,
224 pub max_cost_usd_per_task: Option<f64>,
225 pub sandbox: BTreeMap<String, serde_json::Value>,
226 pub human_approval: String,
227 pub risky_actions: Vec<String>,
228 pub tool_allowlist: Vec<String>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, Default)]
232#[serde(default)]
233pub struct Context {
234 pub isolation: String,
235 pub compaction: BTreeMap<String, serde_json::Value>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize, Default)]
239#[serde(default, rename_all = "kebab-case")]
240pub struct Evidence {
241 #[serde(alias = "task-pack")]
242 pub eval_pack: Option<String>,
243 #[serde(alias = "task-pack-version")]
244 pub eval_pack_version: Option<String>,
245 pub split: String,
246 pub trials: usize,
247 pub success_rate: BTreeMap<String, f64>,
248 pub cost_usd_per_task: BTreeMap<String, f64>,
249 pub latency_s_per_task: BTreeMap<String, f64>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize, Default)]
253#[serde(default, rename_all = "kebab-case")]
254pub struct Verify {
255 pub canary_tasks: usize,
256 pub min_success_rate: f64,
257 pub fingerprint_tolerance: f64,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, Default)]
261#[serde(default)]
262pub struct Provenance {
263 pub backend: String,
264 pub run: Option<String>,
265 #[serde(default)]
266 pub search: BTreeMap<String, serde_json::Value>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(rename_all = "kebab-case")]
271pub struct Lockfile {
272 pub lock_version: i64,
273 pub generated_by: String,
274 pub generated_at: String,
275 pub manifest_hash: Option<String>,
276 #[serde(default)]
277 pub stack: BTreeMap<String, serde_json::Value>,
278 #[serde(default)]
279 pub llm: Vec<LLM>,
280 #[serde(default)]
281 pub decision: Decision,
282 #[serde(default)]
283 pub harness: Harness,
284 #[serde(default)]
285 pub tool: Vec<Tool>,
286 #[serde(default)]
287 pub skill: Vec<Skill>,
288 #[serde(default)]
289 pub instructions: Vec<Instructions>,
290 #[serde(default)]
291 pub policy: Policy,
292 #[serde(default)]
293 pub context: Context,
294 #[serde(default)]
295 pub evidence: Evidence,
296 #[serde(default)]
297 pub verify: Verify,
298 #[serde(default)]
299 pub provenance: Provenance,
300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub native: Vec<Native>,
303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
304 pub runtime: Vec<Runtime>,
305 #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 pub config: Vec<ConfigFile>,
307 #[serde(default, skip_serializing_if = "Vec::is_empty")]
308 pub secret: Vec<SecretRef>,
309 #[serde(default, skip_serializing_if = "Option::is_none", rename = "base-lock")]
311 pub base_lock: Option<String>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub kind: Option<String>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub tune: Option<serde_json::Value>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub localize: Option<serde_json::Value>,
319}
320
321impl Default for Lockfile {
322 fn default() -> Self {
323 Lockfile {
324 lock_version: LOCK_VERSION,
325 generated_by: format!("harness-lock {}", env!("CARGO_PKG_VERSION")),
326 generated_at: String::new(),
327 manifest_hash: None,
328 stack: BTreeMap::new(),
329 llm: Vec::new(),
330 decision: Decision::default(),
331 harness: Harness::default(),
332 tool: Vec::new(),
333 skill: Vec::new(),
334 instructions: Vec::new(),
335 policy: Policy::default(),
336 context: Context::default(),
337 evidence: Evidence::default(),
338 verify: Verify::default(),
339 provenance: Provenance::default(),
340 native: Vec::new(),
341 runtime: Vec::new(),
342 config: Vec::new(),
343 secret: Vec::new(),
344 base_lock: None,
345 kind: None,
346 tune: None,
347 localize: None,
348 }
349 }
350}
351
352impl Lockfile {
353 pub fn load(path: &Path) -> Result<Lockfile> {
354 let text = std::fs::read_to_string(path)
355 .map_err(|e| Error::Lockfile(format!("cannot read {}: {e}", path.display())))?;
356 Lockfile::from_str(&text)
357 }
358
359 pub fn from_str(text: &str) -> Result<Lockfile> {
360 let body = text
362 .lines()
363 .filter(|l| !l.starts_with('#'))
364 .collect::<Vec<_>>()
365 .join("\n");
366 let lf: Lockfile =
367 toml::from_str(&body).map_err(|e| Error::Lockfile(format!("invalid harness.lock: {e}")))?;
368 if lf.lock_version != LOCK_VERSION {
369 return Err(Error::Lockfile(format!(
370 "unsupported lock-version {} (this build supports {})",
371 lf.lock_version, LOCK_VERSION
372 )));
373 }
374 Ok(lf)
375 }
376
377 pub fn save(&self, path: &Path) -> Result<()> {
378 if let Some(parent) = path.parent() {
379 std::fs::create_dir_all(parent)?;
380 }
381 let body = toml::to_string(self).map_err(|e| Error::Lockfile(e.to_string()))?;
382 std::fs::write(path, format!("{HEADER}\n{body}"))?;
383 Ok(())
384 }
385
386 pub fn is_pin(&self) -> bool {
388 self.kind.as_deref() == Some("pin")
389 }
390
391 pub fn default_path(&self) -> &str {
392 LOCKFILE_PATH
393 }
394
395 pub fn check_manifest(&self, live_hash: &str) -> Vec<String> {
397 match &self.manifest_hash {
398 None => vec!["lock has no manifest-hash".to_string()],
399 Some(pinned) => {
400 let pinned_hex = pinned.split(':').nth(1).unwrap_or(pinned);
401 let live_hex = live_hash.split(':').nth(1).unwrap_or(live_hash);
402 if pinned_hex == live_hex {
403 Vec::new()
404 } else {
405 vec![format!(
406 "manifest drift: lock pinned {pinned:?}, live {live_hash:?}"
407 )]
408 }
409 }
410 }
411 }
412
413 pub fn summary(&self) -> String {
414 let model = self.llm.first().map(|l| l.model.as_str()).unwrap_or("?");
415 let sr = self.evidence.success_rate.get("mean").unwrap_or(&f64::NAN);
416 format!(
417 "stack={:?} harness={} llm={} decision={} success-rate={:.2} trials={}",
418 self.stack.get("name").and_then(serde_json::Value::as_str).unwrap_or("agent"),
419 self.harness.name,
420 model,
421 self.decision.kind,
422 sr,
423 self.evidence.trials
424 )
425 }
426}