use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::{LOCK_VERSION, LOCKFILE_PATH};
pub const HEADER: &str = "# This file is generated by harness-lock. Do not edit by hand.";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LLM {
pub role: String,
pub provider: String,
pub model: String,
#[serde(rename = "resolved-from")]
pub resolved_from: Option<String>,
#[serde(default)]
pub params: BTreeMap<String, serde_json::Value>,
pub fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
}
impl Default for LLM {
fn default() -> Self {
LLM {
role: String::new(),
provider: "reference".into(),
model: String::new(),
resolved_from: None,
params: BTreeMap::new(),
fingerprint: None,
endpoint: None,
runtime: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Decision {
pub kind: String,
pub model: Option<String>,
pub revision: Option<String>,
#[serde(default)]
pub decisions: Vec<String>,
#[serde(default)]
pub thresholds: BTreeMap<String, f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Harness {
pub name: String,
pub version: Option<String>,
pub source: Option<String>,
pub integrity: Option<String>,
pub adapter: Option<String>,
pub config: Option<String>,
pub pattern: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Tool {
pub name: String,
pub kind: String,
pub source: Option<String>,
pub integrity: Option<String>,
#[serde(rename = "schema")]
pub tool_schema: Option<String>,
#[serde(default)]
pub permissions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Skill {
pub name: String,
pub origin: String,
pub source: Option<String>,
pub digest: Option<String>,
pub registry: Option<String>,
#[serde(rename = "provided-by")]
pub provided_by: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub editable: bool,
#[serde(rename = "evidence-digest")]
pub evidence_digest: Option<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendored: Option<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Instructions {
pub name: String,
pub digest: Option<String>,
pub scope: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendored: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct Native {
pub kind: String,
pub name: String,
pub version: Option<String>,
pub source: Option<String>,
pub revision: Option<String>,
pub integrity: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct Runtime {
pub name: String,
pub location: String,
pub version: Option<String>,
pub source: Option<String>,
pub endpoint: Option<String>,
#[serde(default)]
pub model: Vec<RuntimeModel>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct RuntimeModel {
pub id: String,
pub source: Option<String>,
pub revision: Option<String>,
pub digest: Option<String>,
#[serde(rename = "size-bytes")]
pub size_bytes: Option<u64>,
pub present: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct ConfigFile {
pub id: String,
pub path: String,
pub format: String,
pub restore: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pointers: Vec<String>,
pub digest: Option<String>,
pub vendored: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default, rename_all = "kebab-case")]
pub struct SecretRef {
pub name: String,
pub used_by: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct Policy {
pub retries: u32,
pub tool_timeout_seconds: Option<u64>,
pub max_steps: u32,
pub max_cost_usd_per_task: Option<f64>,
pub sandbox: BTreeMap<String, serde_json::Value>,
pub human_approval: String,
pub risky_actions: Vec<String>,
pub tool_allowlist: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Context {
pub isolation: String,
pub compaction: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct Evidence {
#[serde(alias = "task-pack")]
pub eval_pack: Option<String>,
#[serde(alias = "task-pack-version")]
pub eval_pack_version: Option<String>,
pub split: String,
pub trials: usize,
pub success_rate: BTreeMap<String, f64>,
pub cost_usd_per_task: BTreeMap<String, f64>,
pub latency_s_per_task: BTreeMap<String, f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct Verify {
pub canary_tasks: usize,
pub min_success_rate: f64,
pub fingerprint_tolerance: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Provenance {
pub backend: String,
pub run: Option<String>,
#[serde(default)]
pub search: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Lockfile {
pub lock_version: i64,
pub generated_by: String,
pub generated_at: String,
pub manifest_hash: Option<String>,
#[serde(default)]
pub stack: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub llm: Vec<LLM>,
#[serde(default)]
pub decision: Decision,
#[serde(default)]
pub harness: Harness,
#[serde(default)]
pub tool: Vec<Tool>,
#[serde(default)]
pub skill: Vec<Skill>,
#[serde(default)]
pub instructions: Vec<Instructions>,
#[serde(default)]
pub policy: Policy,
#[serde(default)]
pub context: Context,
#[serde(default)]
pub evidence: Evidence,
#[serde(default)]
pub verify: Verify,
#[serde(default)]
pub provenance: Provenance,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub native: Vec<Native>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runtime: Vec<Runtime>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config: Vec<ConfigFile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secret: Vec<SecretRef>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "base-lock")]
pub base_lock: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tune: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub localize: Option<serde_json::Value>,
}
impl Default for Lockfile {
fn default() -> Self {
Lockfile {
lock_version: LOCK_VERSION,
generated_by: format!("harness-lock {}", env!("CARGO_PKG_VERSION")),
generated_at: String::new(),
manifest_hash: None,
stack: BTreeMap::new(),
llm: Vec::new(),
decision: Decision::default(),
harness: Harness::default(),
tool: Vec::new(),
skill: Vec::new(),
instructions: Vec::new(),
policy: Policy::default(),
context: Context::default(),
evidence: Evidence::default(),
verify: Verify::default(),
provenance: Provenance::default(),
native: Vec::new(),
runtime: Vec::new(),
config: Vec::new(),
secret: Vec::new(),
base_lock: None,
kind: None,
tune: None,
localize: None,
}
}
}
impl Lockfile {
pub fn load(path: &Path) -> Result<Lockfile> {
let text = std::fs::read_to_string(path)
.map_err(|e| Error::Lockfile(format!("cannot read {}: {e}", path.display())))?;
Lockfile::from_str(&text)
}
pub fn from_str(text: &str) -> Result<Lockfile> {
let body = text
.lines()
.filter(|l| !l.starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
let lf: Lockfile =
toml::from_str(&body).map_err(|e| Error::Lockfile(format!("invalid harness.lock: {e}")))?;
if lf.lock_version != LOCK_VERSION {
return Err(Error::Lockfile(format!(
"unsupported lock-version {} (this build supports {})",
lf.lock_version, LOCK_VERSION
)));
}
Ok(lf)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string(self).map_err(|e| Error::Lockfile(e.to_string()))?;
std::fs::write(path, format!("{HEADER}\n{body}"))?;
Ok(())
}
pub fn is_pin(&self) -> bool {
self.kind.as_deref() == Some("pin")
}
pub fn default_path(&self) -> &str {
LOCKFILE_PATH
}
pub fn check_manifest(&self, live_hash: &str) -> Vec<String> {
match &self.manifest_hash {
None => vec!["lock has no manifest-hash".to_string()],
Some(pinned) => {
let pinned_hex = pinned.split(':').nth(1).unwrap_or(pinned);
let live_hex = live_hash.split(':').nth(1).unwrap_or(live_hash);
if pinned_hex == live_hex {
Vec::new()
} else {
vec![format!(
"manifest drift: lock pinned {pinned:?}, live {live_hash:?}"
)]
}
}
}
}
pub fn summary(&self) -> String {
let model = self.llm.first().map(|l| l.model.as_str()).unwrap_or("?");
let sr = self.evidence.success_rate.get("mean").unwrap_or(&f64::NAN);
format!(
"stack={:?} harness={} llm={} decision={} success-rate={:.2} trials={}",
self.stack.get("name").and_then(serde_json::Value::as_str).unwrap_or("agent"),
self.harness.name,
model,
self.decision.kind,
sr,
self.evidence.trials
)
}
}