1use std::path::Path;
4
5use anyhow::{Context, Result, ensure};
6use serde::{Deserialize, Serialize};
7
8use crate::{
9 config::{Config, MergeMode},
10 git, land,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
15#[serde(rename_all = "snake_case")]
16pub enum Disposition {
17 Land,
19 Review,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ConfigEvidence {
26 pub layers: Vec<std::path::PathBuf>,
28 pub overrides: Option<crate::queue::RunOverrides>,
30 pub daemon_merge: Option<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct Selection {
37 pub url: String,
39 pub branch: String,
41 pub head: String,
43 #[serde(default)]
45 pub source_head: Option<String>,
46 #[serde(default)]
48 pub source_run: Option<String>,
49 #[serde(default)]
51 pub source_proof: Option<Box<SourceProof>>,
52 #[serde(default)]
54 pub disposition: Option<Disposition>,
55 pub instruction: String,
57 pub approved_at: jiff::Timestamp,
59}
60
61mod lineage;
62pub use lineage::SourceProof;
63
64const RETAIN_SOURCES: &str = "retain-recovery-sources";
65
66pub fn retain_sources(task: &crate::queue::Task, home: &Path) -> Result<()> {
69 for id in task.runs.iter().collect::<std::collections::BTreeSet<_>>() {
70 ensure!(
71 crate::run::is_run_id(id),
72 "invalid historical run id; refusing source retention"
73 );
74 let dir = home.join("runs").join(id);
75 ensure!(
76 dir.is_dir(),
77 "historical run {id} is missing; cannot retain its sources"
78 );
79 std::fs::write(dir.join(RETAIN_SOURCES), &task.id)
80 .with_context(|| format!("retaining sources of historical run {id}"))?;
81 }
82 Ok(())
83}
84
85pub(crate) fn sources_retained(home: &Path, id: &str) -> bool {
86 home.join("runs")
87 .join(id)
88 .join(RETAIN_SOURCES)
89 .try_exists()
90 .unwrap_or(true)
91}
92
93#[derive(Debug)]
95pub(crate) struct StartupFailed {
96 pub run: String,
97 pub cause: String,
98}
99
100impl std::fmt::Display for StartupFailed {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 write!(
103 f,
104 "recovery run {} could not start: {}",
105 self.run, self.cause
106 )
107 }
108}
109impl std::error::Error for StartupFailed {}
110
111impl Selection {
112 pub fn source(&self) -> &str {
114 self.source_head.as_deref().unwrap_or(&self.head)
115 }
116
117 pub fn configure(&self, config: &mut Config) -> Result<()> {
119 match self.disposition {
120 Some(Disposition::Land) => {
121 ensure!(
122 config.graph.reviewers > 0 && config.graph.review_rounds > 0,
123 "delivery requires at least one reviewer and review round"
124 );
125 ensure!(
126 !config.verify.gate.is_empty()
127 && config.verify.gate.iter().all(|s| !s.trim().is_empty()),
128 "delivery requires nonempty verify.gate commands; configure the repository's documented gate in a machine/repository layer or recover-pr --config <complete-config>"
129 );
130 ensure!(
131 !config.verify.e2e.is_empty()
132 && config.verify.e2e.iter().all(|s| !s.trim().is_empty()),
133 "delivery requires nonempty verify.e2e commands; configure the repository's required real-machine checks before recover-pr --disposition land"
134 );
135 config.merge.mode = MergeMode::Pr;
136 config.graph.land = true;
137 config.graph.land_approval = true;
138 }
139 Some(Disposition::Review) => {
140 config.merge.mode = MergeMode::None;
141 config.graph.land = false;
142 }
143 None => anyhow::bail!(
144 "recovery disposition is missing; select the public head again with recover-pr --disposition land or --disposition review (and --source-head <full-SHA> to preserve a reviewed fix)"
145 ),
146 }
147 Ok(())
148 }
149
150 pub async fn verify_source(&self, repo: &Path) -> Result<()> {
152 if self.source_proof.is_some() {
153 self.verify_source_under(repo, &crate::run::home()).await
154 } else {
155 self.verify_source_under(repo, Path::new("")).await
156 }
157 }
158
159 async fn verify_source_under(&self, repo: &Path, home: &Path) -> Result<()> {
160 let source = git::rev_parse(repo, &format!("{}^{{commit}}", self.source())).await?;
161 ensure!(
162 source == self.source(),
163 "source-head must resolve to its full commit SHA"
164 );
165 if let Some(proof) = &self.source_proof {
166 let actual = Box::pin(lineage::prove(self, repo, home, &proof.current_base)).await?;
168 ensure!(
169 &actual == proof.as_ref(),
170 "saved source proof changed; select the source again"
171 );
172 } else {
173 ensure!(
174 git::is_ancestor(repo, &self.head, &source).await,
175 "source-head does not preserve the selected public implementation by ancestry; supply --source-run <retained-run-id> for a recorded launcher rebase"
176 );
177 ensure!(
178 self.source_run.is_none(),
179 "source-run requires a saved source proof; select again"
180 );
181 }
182 Ok(())
183 }
184
185 pub async fn prepare_source(
187 &mut self,
188 repo: &Path,
189 home: &Path,
190 current_base: &str,
191 ) -> Result<()> {
192 ensure!(
193 self.source_proof.is_none(),
194 "source proof is already selected; refusing to replace saved evidence"
195 );
196 if self.source_run.is_some() {
197 self.source_proof = Some(Box::new(
198 Box::pin(lineage::prove(self, repo, home, current_base)).await?,
199 ));
200 Ok(())
201 } else {
202 self.verify_source(repo).await
203 }
204 }
205
206 pub fn check_task(&self, task: &str, instruction: &str) -> Result<()> {
208 ensure!(
209 self.branch == format!("magi/task-{task}"),
210 "recovery head belongs to another task"
211 );
212 ensure!(
213 self.instruction == instruction,
214 "recovery criteria changed; select the public head again"
215 );
216 ensure!(
217 [self.head.as_str(), self.source()]
218 .iter()
219 .all(|sha| sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit())),
220 "recovery requires a full commit SHA"
221 );
222 Ok(())
223 }
224
225 pub async fn verify(&self, repo: &Path, remote: &str, base: &str, lease: &str) -> Result<()> {
227 let pr_repo = land::slug_of_pr_url(&self.url).context("invalid recovery PR URL")?;
228 for args in [
229 vec!["remote", "get-url", "--all", remote],
230 vec!["remote", "get-url", "--push", "--all", remote],
231 ] {
232 let urls = git::git(repo, &args).await?;
233 ensure!(
234 !urls.trim().is_empty(),
235 "recovery remote has no repository URL"
236 );
237 for url in urls.lines() {
238 let remote_repo =
239 remote_slug(url).context("cannot prove recovery remote repository identity")?;
240 land::ensure_same_repo(&remote_repo, &pr_repo)?;
241 }
242 }
243 let found = land::find_open_pr(repo, &self.branch, base).await?;
244 ensure!(
245 matches!(found, land::OpenPr::One { ref url, .. } if url == &self.url),
246 "selected PR is not the unique open task PR in this repository"
247 );
248 let (ok, raw) = land::gh(
249 repo,
250 &[
251 "pr".into(),
252 "view".into(),
253 self.url.clone(),
254 "--json".into(),
255 "headRefName,headRefOid,baseRefName,state,isCrossRepository".into(),
256 ],
257 )
258 .await?;
259 ensure!(ok, "could not verify selected PR: {raw}");
260 validate_view(&raw, &self.branch, base, lease)?;
261 let fetched = git::fetch(repo, remote, &self.branch).await?;
262 ensure!(
263 fetched.ok(),
264 "fetch selected public head failed: {}",
265 fetched.stderr
266 );
267 let fetched_head =
268 git::rev_parse(repo, &format!("refs/remotes/{remote}/{}", self.branch)).await?;
269 ensure!(
270 fetched_head == lease,
271 "public head moved; select and approve its new head before recovery"
272 );
273 ensure!(
274 git::remote_head(repo, remote, &self.branch)
275 .await?
276 .as_deref()
277 == Some(lease),
278 "public head lease changed during recovery"
279 );
280 Ok(())
281 }
282}
283
284fn remote_slug(url: &str) -> Option<String> {
286 let url = url
287 .trim()
288 .trim_end_matches('/')
289 .strip_suffix(".git")
290 .unwrap_or(url.trim().trim_end_matches('/'));
291 let (host, path) = if let Some(rest) = url
292 .strip_prefix("https://")
293 .or_else(|| url.strip_prefix("http://"))
294 .or_else(|| url.strip_prefix("ssh://"))
295 {
296 let (authority, path) = rest.split_once('/')?;
297 (authority.rsplit('@').next()?, path)
298 } else {
299 let (authority, path) = url.split_once(':')?;
300 let (_, host) = authority.split_once('@')?;
301 (host, path)
302 };
303 let mut parts = path.split('/');
304 let owner = parts.next()?;
305 let repo = parts.next()?;
306 if host.is_empty() || owner.is_empty() || repo.is_empty() || parts.next().is_some() {
307 return None;
308 }
309 Some(format!("{host}/{owner}/{repo}"))
310}
311
312fn validate_view(raw: &str, branch: &str, base: &str, lease: &str) -> Result<()> {
313 #[derive(Deserialize)]
314 #[serde(rename_all = "camelCase")]
315 struct View {
316 head_ref_name: String,
317 head_ref_oid: String,
318 base_ref_name: String,
319 state: String,
320 is_cross_repository: bool,
321 }
322 let view: View = serde_json::from_str(raw).context("selected public head metadata")?;
323 ensure!(
324 view.state == "OPEN"
325 && !view.is_cross_repository
326 && view.head_ref_name == branch
327 && view.base_ref_name == base,
328 "selected PR ownership or base changed"
329 );
330 ensure!(
331 view.head_ref_oid == lease,
332 "selected public head moved; refusing recovery"
333 );
334 Ok(())
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn recovery_uses_resolved_layers_and_preserves_unrelated_overrides() {
343 let dir = tempfile::tempdir().unwrap();
344 let machine = dir.path().join("machine.toml");
345 let repo = dir.path().join("repository.toml");
346 std::fs::write(&machine, "[verify]\ngate = ['documented gate']\ne2e = ['real checks']\n[graph]\nland = false\nland_approval = false\n").unwrap();
347 std::fs::write(&repo, "[merge]\nmode = 'none'\nbase = 'trunk'\n").unwrap();
348 let mut config = Config::load_layers(&[machine, repo]).unwrap();
349 let overrides = crate::queue::RunOverrides {
350 merge: Some("none".into()),
351 reviewers: Some(2),
352 ..Default::default()
353 };
354 overrides.apply(&mut config);
355 let mut selection = Selection {
356 url: "url".into(),
357 branch: "magi/task-t".into(),
358 head: "a".repeat(40),
359 source_head: None,
360 source_run: None,
361 source_proof: None,
362 disposition: Some(Disposition::Land),
363 instruction: "criteria".into(),
364 approved_at: jiff::Timestamp::now(),
365 };
366 selection.configure(&mut config).unwrap();
367 assert_eq!(config.merge.mode, MergeMode::Pr);
368 assert_eq!(config.graph.reviewers, 2);
369 assert_eq!(config.merge.base.as_deref(), Some("trunk"));
370 assert_eq!(config.verify.gate, ["documented gate"]);
371 assert_eq!(config.verify.e2e, ["real checks"]);
372 assert!(config.graph.land && config.graph.land_approval);
373 let explicit = dir.path().join("explicit.toml");
374 std::fs::write(&explicit, "[merge]\nbase = 'other'\n").unwrap();
375 let (mut replacement, paths) = Config::discover(dir.path(), Some(&explicit)).unwrap();
376 assert_eq!(paths, [explicit]);
377 assert!(
378 selection
379 .configure(&mut replacement)
380 .unwrap_err()
381 .to_string()
382 .contains("verify.gate")
383 );
384 replacement.verify.gate = vec![" ".into()];
385 assert!(selection.configure(&mut replacement).is_err());
386 selection.disposition = Some(Disposition::Review);
387 selection.configure(&mut replacement).unwrap();
388 assert_eq!(replacement.merge.mode, MergeMode::None);
389 assert!(!replacement.graph.land);
390 }
391
392 #[test]
393 fn selected_source_requires_the_same_fetch_and_push_repository() {
394 for remote in [
395 "https://example.test/team/project.git",
396 "git@example.test:team/project.git",
397 "ssh://git@example.test/team/project.git",
398 ] {
399 assert_eq!(
400 remote_slug(remote).as_deref(),
401 Some("example.test/team/project")
402 );
403 }
404 assert!(remote_slug("/tmp/another.git").is_none());
405 assert!(
406 land::ensure_same_repo(
407 &remote_slug("git@example.test:team/other.git").unwrap(),
408 "example.test/team/project"
409 )
410 .is_err()
411 );
412 assert!(
413 land::ensure_same_repo(
414 &remote_slug("git@other.test:team/project.git").unwrap(),
415 "example.test/team/project"
416 )
417 .is_err()
418 );
419 }
420
421 #[test]
422 fn selected_head_refuses_moved_or_foreign_public_heads() {
423 let raw = r#"{"headRefName":"magi/task-t","headRefOid":"aaa","baseRefName":"main","state":"OPEN","isCrossRepository":false}"#;
424 assert!(validate_view(raw, "magi/task-t", "main", "aaa").is_ok());
425 assert!(validate_view(raw, "magi/task-t", "main", "bbb").is_err());
426 assert!(validate_view(raw, "magi/task-other", "main", "aaa").is_err());
427 assert!(validate_view(raw, "magi/task-t", "other", "aaa").is_err());
428 assert!(
429 validate_view(&raw.replace("false", "true"), "magi/task-t", "main", "aaa").is_err()
430 );
431 assert!(
432 validate_view(&raw.replace("OPEN", "CLOSED"), "magi/task-t", "main", "aaa").is_err()
433 );
434 }
435
436 #[test]
437 fn selected_head_is_bound_to_authored_criteria_and_task() {
438 let selected = Selection {
439 url: "url".into(),
440 branch: "magi/task-t".into(),
441 head: "a".repeat(40),
442 source_head: None,
443 source_run: None,
444 source_proof: None,
445 disposition: Some(Disposition::Review),
446 instruction: "approved".into(),
447 approved_at: jiff::Timestamp::now(),
448 };
449 assert!(selected.check_task("t", "approved").is_ok());
450 assert!(
451 selected
452 .check_task("t", "prose mentioning another candidate")
453 .is_err()
454 );
455 assert!(selected.check_task("other", "approved").is_err());
456 }
457}