Skip to main content

magi/
pr_recovery.rs

1//! Explicit recovery of a task's public implementation, independent of historical candidates.
2
3use 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/// Explicit purpose of an existing-PR recovery; never inferred from task prose.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
15#[serde(rename_all = "snake_case")]
16pub enum Disposition {
17    /// Validate, publish to the selected PR and request fresh owner-approved landing.
18    Land,
19    /// Finish after local review, without public delivery.
20    Review,
21}
22
23/// Inputs of the single configuration load used to start a recovery.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ConfigEvidence {
26    /// Ordered configuration inputs; remote cache paths include the immutable source commit.
27    pub layers: Vec<std::path::PathBuf>,
28    /// Task options applied after loading the layers.
29    pub overrides: Option<crate::queue::RunOverrides>,
30    /// Daemon merge override, only when this task inherits daemon options.
31    pub daemon_merge: Option<String>,
32}
33
34/// Durable operator selection. Only the recovery command creates this record.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct Selection {
37    /// Existing pull request in this repository.
38    pub url: String,
39    /// Task-scoped public branch.
40    pub branch: String,
41    /// Initial public publication lease, independent of the implementation source.
42    pub head: String,
43    /// Full immutable source commit; older selections used the public lease.
44    #[serde(default)]
45    pub source_head: Option<String>,
46    /// Historical run explicitly selected to prove a launcher rebase.
47    #[serde(default)]
48    pub source_run: Option<String>,
49    /// Immutable proof, recomputed before starting or resuming work.
50    #[serde(default)]
51    pub source_proof: Option<Box<SourceProof>>,
52    /// Missing on old records: requires an explicit new selection before work.
53    #[serde(default)]
54    pub disposition: Option<Disposition>,
55    /// Criteria the operator approved, retained without rewriting them.
56    pub instruction: String,
57    /// Approval generation: a later selection does not resume an earlier run.
58    pub approved_at: jiff::Timestamp,
59}
60
61mod lineage;
62pub use lineage::SourceProof;
63
64const RETAIN_SOURCES: &str = "retain-recovery-sources";
65
66/// Preserve every historical run's source artifacts before releasing a recovery task.
67/// A sidecar survives stale run-state saves and applies even to older run schemas.
68pub 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
85/// Return the retention reason rather than silently treating fold as successful.
86pub(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    // A confirmed merge admits fold preflight even while the queue is held.
91    // fold_run must still prove each deletion target under its ownership and
92    // output locks. Ready/Failed/Superseded and PR creation alone do not qualify.
93    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        // Queue completion does not prove that every historical candidate
102        // landed. In particular, Superseded and Failed-without-tally runs
103        // may still own independent implementations. Keep those sources
104        // until recovery transfers them or the merged-source preflight above
105        // can prove incorporation; Done is not deletion authority.
106        .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/// A partially created recovery is retained and linked to its task for inspection.
124#[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    /// Immutable implementation commit, distinct from the public update lease.
143    pub fn source(&self) -> &str {
144        self.source_head.as_deref().unwrap_or(&self.head)
145    }
146
147    /// Reconcile only the delivery controls; preserve all other resolved settings.
148    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    /// Prove the adopted fix contains the selected public implementation.
181    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            // Historical state is large; keep replay out of the caller's future storage.
197            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    /// Select and persist evidence without changing the implementation or public lease.
216    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    /// Confirm task identity and immutable criteria before starting work.
237    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    /// Read-only forge validation plus fetch; no local branch is moved or pushed.
256    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
314// Require a recognizable forge URL rather than guessing through local paths or SSH aliases.
315fn 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}