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}
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 #[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 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 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}