| 1 | use std::collections::BTreeMap; |
| 2 | use std::path::PathBuf; |
| 3 | |
| 4 | use serde::{Deserialize, Serialize}; |
| 5 | |
| 6 | use super::{RUNTIME_CONTRACT_SCHEMA_VERSION, profile::ToolProfileManifest}; |
| 7 | |
| 8 | /// Reproducible runtime contract captured before an unattended or measured |
| 9 | /// run starts. Secret values never belong in this structure. |
| 10 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 11 | pub struct RunContractManifest { |
| 12 | pub schema_version: u32, |
| 13 | pub binary_version: String, |
| 14 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 15 | pub source_sha: Option<String>, |
| 16 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 17 | pub dirty_patch_hash: Option<String>, |
| 18 | pub workspace: PathBuf, |
| 19 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 20 | pub base_revision: Option<String>, |
| 21 | pub provider: String, |
| 22 | pub model: String, |
| 23 | pub route: String, |
| 24 | pub permission_posture: String, |
| 25 | pub sandbox_identity: String, |
| 26 | pub network_policy: String, |
| 27 | pub prompt_hash: String, |
| 28 | pub profile: ToolProfileManifest, |
| 29 | /// Stable hash by tool name. A resume must not silently continue with a |
| 30 | /// different model-visible schema. |
| 31 | pub tool_schema_hashes: BTreeMap<String, String>, |
| 32 | } |
| 33 | |
| 34 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 35 | pub struct ManifestMismatch { |
| 36 | pub field: String, |
| 37 | pub saved: String, |
| 38 | pub current: String, |
| 39 | } |
| 40 | |
| 41 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 42 | #[serde(rename_all = "snake_case")] |
| 43 | pub enum ResumeCompatibility { |
| 44 | Compatible, |
| 45 | ExplicitMigrationRequired(Vec<ManifestMismatch>), |
| 46 | } |
| 47 | |
| 48 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 49 | pub struct RunReadiness { |
| 50 | pub ready: bool, |
| 51 | #[serde(default)] |
| 52 | pub blockers: Vec<String>, |
| 53 | #[serde(default)] |
| 54 | pub warnings: Vec<String>, |
| 55 | } |
| 56 | |
| 57 | impl RunContractManifest { |
| 58 | pub fn validate(&self) -> Result<(), String> { |
| 59 | if self.schema_version != RUNTIME_CONTRACT_SCHEMA_VERSION { |
| 60 | return Err(format!( |
| 61 | "unsupported run manifest schema {}; expected {}", |
| 62 | self.schema_version, RUNTIME_CONTRACT_SCHEMA_VERSION |
| 63 | )); |
| 64 | } |
| 65 | self.profile.validate()?; |
| 66 | for (label, value) in [ |
| 67 | ("binary_version", self.binary_version.as_str()), |
| 68 | ("provider", self.provider.as_str()), |
| 69 | ("model", self.model.as_str()), |
| 70 | ("route", self.route.as_str()), |
| 71 | ("permission_posture", self.permission_posture.as_str()), |
| 72 | ("sandbox_identity", self.sandbox_identity.as_str()), |
| 73 | ("prompt_hash", self.prompt_hash.as_str()), |
| 74 | ] { |
| 75 | if value.trim().is_empty() { |
| 76 | return Err(format!("run manifest {label} cannot be empty")); |
| 77 | } |
| 78 | } |
| 79 | Ok(()) |
| 80 | } |
| 81 | |
| 82 | #[must_use] |
| 83 | pub fn readiness(&self) -> RunReadiness { |
| 84 | let mut blockers = Vec::new(); |
| 85 | let mut warnings = Vec::new(); |
| 86 | if let Err(error) = self.validate() { |
| 87 | blockers.push(error); |
| 88 | } |
| 89 | if self.source_sha.is_none() { |
| 90 | warnings.push("source SHA is not available".to_string()); |
| 91 | } |
| 92 | if self.dirty_patch_hash.is_none() { |
| 93 | warnings.push("dirty patch hash is not recorded".to_string()); |
| 94 | } |
| 95 | RunReadiness { |
| 96 | ready: blockers.is_empty(), |
| 97 | blockers, |
| 98 | warnings, |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | #[must_use] |
| 103 | pub fn compare_for_resume(&self, current: &Self) -> ResumeCompatibility { |
| 104 | let mut mismatches = Vec::new(); |
| 105 | compare_field( |
| 106 | &mut mismatches, |
| 107 | "schema_version", |
| 108 | self.schema_version, |
| 109 | current.schema_version, |
| 110 | ); |
| 111 | compare_field( |
| 112 | &mut mismatches, |
| 113 | "provider", |
| 114 | &self.provider, |
| 115 | ¤t.provider, |
| 116 | ); |
| 117 | compare_field(&mut mismatches, "model", &self.model, ¤t.model); |
| 118 | compare_field(&mut mismatches, "route", &self.route, ¤t.route); |
| 119 | compare_field( |
| 120 | &mut mismatches, |
| 121 | "permission_posture", |
| 122 | &self.permission_posture, |
| 123 | ¤t.permission_posture, |
| 124 | ); |
| 125 | compare_field( |
| 126 | &mut mismatches, |
| 127 | "sandbox_identity", |
| 128 | &self.sandbox_identity, |
| 129 | ¤t.sandbox_identity, |
| 130 | ); |
| 131 | compare_field( |
| 132 | &mut mismatches, |
| 133 | "prompt_hash", |
| 134 | &self.prompt_hash, |
| 135 | ¤t.prompt_hash, |
| 136 | ); |
| 137 | compare_field( |
| 138 | &mut mismatches, |
| 139 | "profile", |
| 140 | format!("{:?}", self.profile), |
| 141 | format!("{:?}", current.profile), |
| 142 | ); |
| 143 | compare_field( |
| 144 | &mut mismatches, |
| 145 | "tool_schema_hashes", |
| 146 | format!("{:?}", self.tool_schema_hashes), |
| 147 | format!("{:?}", current.tool_schema_hashes), |
| 148 | ); |
| 149 | if mismatches.is_empty() { |
| 150 | ResumeCompatibility::Compatible |
| 151 | } else { |
| 152 | ResumeCompatibility::ExplicitMigrationRequired(mismatches) |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | fn compare_field( |
| 158 | mismatches: &mut Vec<ManifestMismatch>, |
| 159 | field: &str, |
| 160 | saved: impl ToString, |
| 161 | current: impl ToString, |
| 162 | ) { |
| 163 | let saved = saved.to_string(); |
| 164 | let current = current.to_string(); |
| 165 | if saved != current { |
| 166 | mismatches.push(ManifestMismatch { |
| 167 | field: field.to_string(), |
| 168 | saved, |
| 169 | current, |
| 170 | }); |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | #[cfg(test)] |
| 175 | mod tests { |
| 176 | use std::collections::BTreeSet; |
| 177 | |
| 178 | use super::*; |
| 179 | use crate::core::runtime_contract::{ |
| 180 | profile::{AgentProfileCandidate, SemanticCapability, ToolActivationPolicy}, |
| 181 | terminal::TerminalProcessPolicy, |
| 182 | }; |
| 183 | |
| 184 | fn manifest() -> RunContractManifest { |
| 185 | let mut capabilities = BTreeSet::new(); |
| 186 | capabilities.insert(SemanticCapability::TypedTermination); |
| 187 | RunContractManifest { |
| 188 | schema_version: RUNTIME_CONTRACT_SCHEMA_VERSION, |
| 189 | binary_version: "0.8.68".to_string(), |
| 190 | source_sha: Some("abc".to_string()), |
| 191 | dirty_patch_hash: Some("patch".to_string()), |
| 192 | workspace: PathBuf::from("/workspace"), |
| 193 | base_revision: Some("base".to_string()), |
| 194 | provider: "example".to_string(), |
| 195 | model: "model".to_string(), |
| 196 | route: "api".to_string(), |
| 197 | permission_posture: "ask".to_string(), |
| 198 | sandbox_identity: "workspace_write".to_string(), |
| 199 | network_policy: "ask".to_string(), |
| 200 | prompt_hash: "prompt".to_string(), |
| 201 | profile: ToolProfileManifest { |
| 202 | schema_version: RUNTIME_CONTRACT_SCHEMA_VERSION, |
| 203 | candidate: AgentProfileCandidate::AdaptiveCore, |
| 204 | activation_policy: ToolActivationPolicy::DeferredSearch, |
| 205 | terminal_policy: TerminalProcessPolicy::Hybrid, |
| 206 | capabilities, |
| 207 | active_tools: BTreeSet::from(["tool_search".to_string()]), |
| 208 | deferred_tools: BTreeSet::from(["run_verifiers".to_string()]), |
| 209 | max_steps: 64, |
| 210 | max_wall_time_seconds: Some(900), |
| 211 | }, |
| 212 | tool_schema_hashes: BTreeMap::from([( |
| 213 | "tool_search".to_string(), |
| 214 | "schema-a".to_string(), |
| 215 | )]), |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn resume_fails_closed_on_tool_schema_drift() { |
| 221 | let saved = manifest(); |
| 222 | let mut current = saved.clone(); |
| 223 | current |
| 224 | .tool_schema_hashes |
| 225 | .insert("tool_search".to_string(), "schema-b".to_string()); |
| 226 | let ResumeCompatibility::ExplicitMigrationRequired(mismatches) = |
| 227 | saved.compare_for_resume(¤t) |
| 228 | else { |
| 229 | panic!("schema drift must require migration"); |
| 230 | }; |
| 231 | assert!( |
| 232 | mismatches |
| 233 | .iter() |
| 234 | .any(|mismatch| mismatch.field == "tool_schema_hashes") |
| 235 | ); |
| 236 | } |
| 237 | |
| 238 | #[test] |
| 239 | fn readiness_warns_without_source_identity_but_does_not_block() { |
| 240 | let mut value = manifest(); |
| 241 | value.source_sha = None; |
| 242 | let readiness = value.readiness(); |
| 243 | assert!(readiness.ready); |
| 244 | assert_eq!(readiness.warnings, vec!["source SHA is not available"]); |
| 245 | } |
| 246 | } |
| 247 |