返回 CodeWhale
manifest.rs
根目录 / crates / tui / src / core / runtime_contract / manifest.rs
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 &current.provider,
116 );
117 compare_field(&mut mismatches, "model", &self.model, &current.model);
118 compare_field(&mut mismatches, "route", &self.route, &current.route);
119 compare_field(
120 &mut mismatches,
121 "permission_posture",
122 &self.permission_posture,
123 &current.permission_posture,
124 );
125 compare_field(
126 &mut mismatches,
127 "sandbox_identity",
128 &self.sandbox_identity,
129 &current.sandbox_identity,
130 );
131 compare_field(
132 &mut mismatches,
133 "prompt_hash",
134 &self.prompt_hash,
135 &current.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(&current)
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
247 lines RUST