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    #[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    // Pinned-lock sections (`hlock pin`, SPEC §7.8.4); empty in resolved locks.
301    #[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    /// Tune-lock-only fields (see §5.9 / `harness.tune.lock`).
310    #[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    /// What `hlock localize` replaced and how it chose (localized locks only).
317    #[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        // strip the leading comment header before parsing
361        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    /// True for locks written by `hlock pin` (no manifest, no evidence).
387    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    /// Vector of manifest-drift violation strings (empty means clean).
396    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}