Skip to main content

harness_lock_core/
lockfile.rs

1//! The lockfile: `harness.lock`.
2
3use 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}
25
26impl Default for LLM {
27    fn default() -> Self {
28        LLM {
29            role: String::new(),
30            provider: "reference".into(),
31            model: String::new(),
32            resolved_from: None,
33            params: BTreeMap::new(),
34            fingerprint: None,
35        }
36    }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40#[serde(default)]
41pub struct Decision {
42    pub kind: String,
43    pub model: Option<String>,
44    pub revision: Option<String>,
45    #[serde(default)]
46    pub decisions: Vec<String>,
47    #[serde(default)]
48    pub thresholds: BTreeMap<String, f64>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52#[serde(default)]
53pub struct Harness {
54    pub name: String,
55    pub version: Option<String>,
56    pub source: Option<String>,
57    pub integrity: Option<String>,
58    pub adapter: Option<String>,
59    pub config: Option<String>,
60    pub pattern: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64#[serde(default)]
65pub struct Tool {
66    pub name: String,
67    pub kind: String,
68    pub source: Option<String>,
69    pub integrity: Option<String>,
70    #[serde(rename = "schema")]
71    pub tool_schema: Option<String>,
72    #[serde(default)]
73    pub permissions: Vec<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
77#[serde(default)]
78pub struct Skill {
79    pub name: String,
80    pub origin: String,
81    pub source: Option<String>,
82    pub digest: Option<String>,
83    pub registry: Option<String>,
84    #[serde(rename = "provided-by")]
85    pub provided_by: Option<String>,
86    #[serde(default = "default_true")]
87    pub enabled: bool,
88    #[serde(default)]
89    pub editable: bool,
90    #[serde(rename = "evidence-digest")]
91    pub evidence_digest: Option<String>,
92    #[serde(default)]
93    pub requires: Vec<String>,
94}
95
96fn default_true() -> bool {
97    true
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101#[serde(default)]
102pub struct Instructions {
103    pub name: String,
104    pub digest: Option<String>,
105    pub scope: String,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, Default)]
109#[serde(default, rename_all = "kebab-case")]
110pub struct Policy {
111    pub retries: u32,
112    pub tool_timeout_seconds: Option<u64>,
113    pub max_steps: u32,
114    pub max_cost_usd_per_task: Option<f64>,
115    pub sandbox: BTreeMap<String, serde_json::Value>,
116    pub human_approval: String,
117    pub risky_actions: Vec<String>,
118    pub tool_allowlist: Vec<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, Default)]
122#[serde(default)]
123pub struct Context {
124    pub isolation: String,
125    pub compaction: BTreeMap<String, serde_json::Value>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, Default)]
129#[serde(default, rename_all = "kebab-case")]
130pub struct Evidence {
131    pub task_pack: Option<String>,
132    pub task_pack_version: Option<String>,
133    pub split: String,
134    pub trials: usize,
135    pub success_rate: BTreeMap<String, f64>,
136    pub cost_usd_per_task: BTreeMap<String, f64>,
137    pub latency_s_per_task: BTreeMap<String, f64>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, Default)]
141#[serde(default, rename_all = "kebab-case")]
142pub struct Verify {
143    pub canary_tasks: usize,
144    pub min_success_rate: f64,
145    pub fingerprint_tolerance: f64,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, Default)]
149#[serde(default)]
150pub struct Provenance {
151    pub backend: String,
152    pub run: Option<String>,
153    #[serde(default)]
154    pub search: BTreeMap<String, serde_json::Value>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(rename_all = "kebab-case")]
159pub struct Lockfile {
160    pub lock_version: i64,
161    pub generated_by: String,
162    pub generated_at: String,
163    pub manifest_hash: Option<String>,
164    #[serde(default)]
165    pub stack: BTreeMap<String, serde_json::Value>,
166    #[serde(default)]
167    pub llm: Vec<LLM>,
168    #[serde(default)]
169    pub decision: Decision,
170    #[serde(default)]
171    pub harness: Harness,
172    #[serde(default)]
173    pub tool: Vec<Tool>,
174    #[serde(default)]
175    pub skill: Vec<Skill>,
176    #[serde(default)]
177    pub instructions: Vec<Instructions>,
178    #[serde(default)]
179    pub policy: Policy,
180    #[serde(default)]
181    pub context: Context,
182    #[serde(default)]
183    pub evidence: Evidence,
184    #[serde(default)]
185    pub verify: Verify,
186    #[serde(default)]
187    pub provenance: Provenance,
188    /// Optimization-only fields (see ยง5.9 / `harness.opt.lock`).
189    #[serde(default, skip_serializing_if = "Option::is_none", rename = "base-lock")]
190    pub base_lock: Option<String>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub kind: Option<String>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub optimize: Option<serde_json::Value>,
195}
196
197impl Default for Lockfile {
198    fn default() -> Self {
199        Lockfile {
200            lock_version: LOCK_VERSION,
201            generated_by: format!("harness-lock {}", env!("CARGO_PKG_VERSION")),
202            generated_at: String::new(),
203            manifest_hash: None,
204            stack: BTreeMap::new(),
205            llm: Vec::new(),
206            decision: Decision::default(),
207            harness: Harness::default(),
208            tool: Vec::new(),
209            skill: Vec::new(),
210            instructions: Vec::new(),
211            policy: Policy::default(),
212            context: Context::default(),
213            evidence: Evidence::default(),
214            verify: Verify::default(),
215            provenance: Provenance::default(),
216            base_lock: None,
217            kind: None,
218            optimize: None,
219        }
220    }
221}
222
223impl Lockfile {
224    pub fn load(path: &Path) -> Result<Lockfile> {
225        let text = std::fs::read_to_string(path)
226            .map_err(|e| Error::Lockfile(format!("cannot read {}: {e}", path.display())))?;
227        Lockfile::from_str(&text)
228    }
229
230    pub fn from_str(text: &str) -> Result<Lockfile> {
231        // strip the leading comment header before parsing
232        let body = text
233            .lines()
234            .filter(|l| !l.starts_with('#'))
235            .collect::<Vec<_>>()
236            .join("\n");
237        let lf: Lockfile =
238            toml::from_str(&body).map_err(|e| Error::Lockfile(format!("invalid harness.lock: {e}")))?;
239        if lf.lock_version != LOCK_VERSION {
240            return Err(Error::Lockfile(format!(
241                "unsupported lock-version {} (this build supports {})",
242                lf.lock_version, LOCK_VERSION
243            )));
244        }
245        Ok(lf)
246    }
247
248    pub fn save(&self, path: &Path) -> Result<()> {
249        if let Some(parent) = path.parent() {
250            std::fs::create_dir_all(parent)?;
251        }
252        let body = toml::to_string(self).map_err(|e| Error::Lockfile(e.to_string()))?;
253        std::fs::write(path, format!("{HEADER}\n{body}"))?;
254        Ok(())
255    }
256
257    pub fn default_path(&self) -> &str {
258        LOCKFILE_PATH
259    }
260
261    /// Vector of manifest-drift violation strings (empty means clean).
262    pub fn check_manifest(&self, live_hash: &str) -> Vec<String> {
263        match &self.manifest_hash {
264            None => vec!["lock has no manifest-hash".to_string()],
265            Some(pinned) => {
266                let pinned_hex = pinned.split(':').nth(1).unwrap_or(pinned);
267                let live_hex = live_hash.split(':').nth(1).unwrap_or(live_hash);
268                if pinned_hex == live_hex {
269                    Vec::new()
270                } else {
271                    vec![format!(
272                        "manifest drift: lock pinned {pinned:?}, live {live_hash:?}"
273                    )]
274                }
275            }
276        }
277    }
278
279    pub fn summary(&self) -> String {
280        let model = self.llm.first().map(|l| l.model.as_str()).unwrap_or("?");
281        let sr = self.evidence.success_rate.get("mean").unwrap_or(&f64::NAN);
282        format!(
283            "stack={:?} harness={} llm={} decision={} success-rate={:.2} trials={}",
284            self.stack.get("name").and_then(serde_json::Value::as_str).unwrap_or("agent"),
285            self.harness.name,
286            model,
287            self.decision.kind,
288            sr,
289            self.evidence.trials
290        )
291    }
292}