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