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    /// Base URL the harness sends this model's requests to (pinned locks).
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub endpoint: Option<String>,
27    /// Local inference runtime serving the model, matching a `[[runtime]]` name.
28    #[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    /// MCP servers: `stdio`, `http`, or `sse`.
83    #[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    /// Environment for the server; secret values are `${HLOCK_SECRET_…}` placeholders.
92    #[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    /// `user` or `project`: the config scope the server came from.
97    #[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    /// `user` or `project` (pinned locks).
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub scope: Option<String>,
124    /// Where the skill was installed, as a portable path (`{root}/skills/x`, `~/…`).
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub path: Option<String>,
127    /// Copy of the skill next to the lock (`.hlock/vendor/…`), for skills with no fetchable source.
128    #[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/// A harness-native component: plugin, package, extension, or marketplace (§5.3).
147#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
148#[serde(default)]
149pub struct Native {
150    /// Namespaced kind, e.g. `claude-code.plugin`, `pi.package`, `opencode.plugin`.
151    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    /// Extra harness-specific settings (e.g. a Pi package's extension filter).
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub config: Option<serde_json::Value>,
162}
163
164/// A local inference runtime and the models the harness uses on it (pinned locks).
165#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
166#[serde(default)]
167pub struct Runtime {
168    pub name: String,
169    /// `local` (this machine) or `network` (a self-hosted server on a private network).
170    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    /// Where the weights come from: `ollama:qwen3:8b`, `hf://org/repo`, …
183    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    /// Whether the weights were found in the runtime's local store at pin time.
189    pub present: bool,
190}
191
192/// A harness config file captured by `hlock pin`.
193#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
194#[serde(default)]
195pub struct ConfigFile {
196    /// The profile's file id (`settings`, `state`, …).
197    pub id: String,
198    pub path: String,
199    pub format: String,
200    /// `whole` | `pointers` | `record`
201    pub restore: String,
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub pointers: Vec<String>,
204    pub digest: Option<String>,
205    /// Redacted copy next to the lock that `sync` restores from.
206    pub vendored: Option<String>,
207}
208
209/// A secret `pin` replaced with a placeholder; `sync`/`launch` fill it from the environment.
210#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
211#[serde(default, rename_all = "kebab-case")]
212pub struct SecretRef {
213    /// The environment variable that supplies it (`HLOCK_SECRET_…`).
214    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    // Pinned-lock sections (`hlock pin`, SPEC §7.8.4); empty in resolved locks.
299    #[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    /// Optimization-only fields (see §5.9 / `harness.opt.lock`).
308    #[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    /// What `hlock localize` replaced and how it chose (localized locks only).
315    #[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        // strip the leading comment header before parsing
359        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    /// True for locks written by `hlock pin` (no manifest, no evidence).
385    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    /// Vector of manifest-drift violation strings (empty means clean).
394    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}