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 pub task_pack: Option<String>,
242 pub task_pack_version: Option<String>,
243 pub split: String,
244 pub trials: usize,
245 pub success_rate: BTreeMap<String, f64>,
246 pub cost_usd_per_task: BTreeMap<String, f64>,
247 pub latency_s_per_task: BTreeMap<String, f64>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, Default)]
251#[serde(default, rename_all = "kebab-case")]
252pub struct Verify {
253 pub canary_tasks: usize,
254 pub min_success_rate: f64,
255 pub fingerprint_tolerance: f64,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, Default)]
259#[serde(default)]
260pub struct Provenance {
261 pub backend: String,
262 pub run: Option<String>,
263 #[serde(default)]
264 pub search: BTreeMap<String, serde_json::Value>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268#[serde(rename_all = "kebab-case")]
269pub struct Lockfile {
270 pub lock_version: i64,
271 pub generated_by: String,
272 pub generated_at: String,
273 pub manifest_hash: Option<String>,
274 #[serde(default)]
275 pub stack: BTreeMap<String, serde_json::Value>,
276 #[serde(default)]
277 pub llm: Vec<LLM>,
278 #[serde(default)]
279 pub decision: Decision,
280 #[serde(default)]
281 pub harness: Harness,
282 #[serde(default)]
283 pub tool: Vec<Tool>,
284 #[serde(default)]
285 pub skill: Vec<Skill>,
286 #[serde(default)]
287 pub instructions: Vec<Instructions>,
288 #[serde(default)]
289 pub policy: Policy,
290 #[serde(default)]
291 pub context: Context,
292 #[serde(default)]
293 pub evidence: Evidence,
294 #[serde(default)]
295 pub verify: Verify,
296 #[serde(default)]
297 pub provenance: Provenance,
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
300 pub native: Vec<Native>,
301 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub runtime: Vec<Runtime>,
303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
304 pub config: Vec<ConfigFile>,
305 #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 pub secret: Vec<SecretRef>,
307 #[serde(default, skip_serializing_if = "Option::is_none", rename = "base-lock")]
309 pub base_lock: Option<String>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub kind: Option<String>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub optimize: Option<serde_json::Value>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub localize: Option<serde_json::Value>,
317}
318
319impl Default for Lockfile {
320 fn default() -> Self {
321 Lockfile {
322 lock_version: LOCK_VERSION,
323 generated_by: format!("harness-lock {}", env!("CARGO_PKG_VERSION")),
324 generated_at: String::new(),
325 manifest_hash: None,
326 stack: BTreeMap::new(),
327 llm: Vec::new(),
328 decision: Decision::default(),
329 harness: Harness::default(),
330 tool: Vec::new(),
331 skill: Vec::new(),
332 instructions: Vec::new(),
333 policy: Policy::default(),
334 context: Context::default(),
335 evidence: Evidence::default(),
336 verify: Verify::default(),
337 provenance: Provenance::default(),
338 native: Vec::new(),
339 runtime: Vec::new(),
340 config: Vec::new(),
341 secret: Vec::new(),
342 base_lock: None,
343 kind: None,
344 optimize: None,
345 localize: None,
346 }
347 }
348}
349
350impl Lockfile {
351 pub fn load(path: &Path) -> Result<Lockfile> {
352 let text = std::fs::read_to_string(path)
353 .map_err(|e| Error::Lockfile(format!("cannot read {}: {e}", path.display())))?;
354 Lockfile::from_str(&text)
355 }
356
357 pub fn from_str(text: &str) -> Result<Lockfile> {
358 let body = text
360 .lines()
361 .filter(|l| !l.starts_with('#'))
362 .collect::<Vec<_>>()
363 .join("\n");
364 let lf: Lockfile =
365 toml::from_str(&body).map_err(|e| Error::Lockfile(format!("invalid harness.lock: {e}")))?;
366 if lf.lock_version != LOCK_VERSION {
367 return Err(Error::Lockfile(format!(
368 "unsupported lock-version {} (this build supports {})",
369 lf.lock_version, LOCK_VERSION
370 )));
371 }
372 Ok(lf)
373 }
374
375 pub fn save(&self, path: &Path) -> Result<()> {
376 if let Some(parent) = path.parent() {
377 std::fs::create_dir_all(parent)?;
378 }
379 let body = toml::to_string(self).map_err(|e| Error::Lockfile(e.to_string()))?;
380 std::fs::write(path, format!("{HEADER}\n{body}"))?;
381 Ok(())
382 }
383
384 pub fn is_pin(&self) -> bool {
386 self.kind.as_deref() == Some("pin")
387 }
388
389 pub fn default_path(&self) -> &str {
390 LOCKFILE_PATH
391 }
392
393 pub fn check_manifest(&self, live_hash: &str) -> Vec<String> {
395 match &self.manifest_hash {
396 None => vec!["lock has no manifest-hash".to_string()],
397 Some(pinned) => {
398 let pinned_hex = pinned.split(':').nth(1).unwrap_or(pinned);
399 let live_hex = live_hash.split(':').nth(1).unwrap_or(live_hash);
400 if pinned_hex == live_hex {
401 Vec::new()
402 } else {
403 vec![format!(
404 "manifest drift: lock pinned {pinned:?}, live {live_hash:?}"
405 )]
406 }
407 }
408 }
409 }
410
411 pub fn summary(&self) -> String {
412 let model = self.llm.first().map(|l| l.model.as_str()).unwrap_or("?");
413 let sr = self.evidence.success_rate.get("mean").unwrap_or(&f64::NAN);
414 format!(
415 "stack={:?} harness={} llm={} decision={} success-rate={:.2} trials={}",
416 self.stack.get("name").and_then(serde_json::Value::as_str).unwrap_or("agent"),
417 self.harness.name,
418 model,
419 self.decision.kind,
420 sr,
421 self.evidence.trials
422 )
423 }
424}