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    /// Missing on old records: requires an explicit new selection before work.
47    #[serde(default)]
48    pub disposition: Option<Disposition>,
49    /// Criteria the operator approved, retained without rewriting them.
50    pub instruction: String,
51    /// Approval generation: a later selection does not resume an earlier run.
52    pub approved_at: jiff::Timestamp,
53}
54
55const RETAIN_SOURCES: &str = "retain-recovery-sources";
56
57/// Preserve every historical run's source artifacts before releasing a recovery task.
58/// A sidecar survives stale run-state saves and applies even to older run schemas.
59pub fn retain_sources(task: &crate::queue::Task, home: &Path) -> Result<()> {
60    for id in task.runs.iter().collect::<std::collections::BTreeSet<_>>() {
61        ensure!(
62            crate::run::is_run_id(id),
63            "invalid historical run id; refusing source retention"
64        );
65        let dir = home.join("runs").join(id);
66        ensure!(
67            dir.is_dir(),
68            "historical run {id} is missing; cannot retain its sources"
69        );
70        std::fs::write(dir.join(RETAIN_SOURCES), &task.id)
71            .with_context(|| format!("retaining sources of historical run {id}"))?;
72    }
73    Ok(())
74}
75
76pub(crate) fn sources_retained(home: &Path, id: &str) -> bool {
77    home.join("runs")
78        .join(id)
79        .join(RETAIN_SOURCES)
80        .try_exists()
81        .unwrap_or(true)
82}
83
84/// A partially created recovery is retained and linked to its task for inspection.
85#[derive(Debug)]
86pub(crate) struct StartupFailed {
87    pub run: String,
88    pub cause: String,
89}
90
91impl std::fmt::Display for StartupFailed {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(
94            f,
95            "recovery run {} could not start: {}",
96            self.run, self.cause
97        )
98    }
99}
100impl std::error::Error for StartupFailed {}
101
102impl Selection {
103    /// Immutable implementation commit, distinct from the public update lease.
104    pub fn source(&self) -> &str {
105        self.source_head.as_deref().unwrap_or(&self.head)
106    }
107
108    /// Reconcile only the delivery controls; preserve all other resolved settings.
109    pub fn configure(&self, config: &mut Config) -> Result<()> {
110        match self.disposition {
111            Some(Disposition::Land) => {
112                ensure!(
113                    config.graph.reviewers > 0 && config.graph.review_rounds > 0,
114                    "delivery requires at least one reviewer and review round"
115                );
116                ensure!(
117                    !config.verify.gate.is_empty()
118                        && config.verify.gate.iter().all(|s| !s.trim().is_empty()),
119                    "delivery requires nonempty verify.gate commands; configure the repository's documented gate in a machine/repository layer or recover-pr --config <complete-config>"
120                );
121                ensure!(
122                    !config.verify.e2e.is_empty()
123                        && config.verify.e2e.iter().all(|s| !s.trim().is_empty()),
124                    "delivery requires nonempty verify.e2e commands; configure the repository's required real-machine checks before recover-pr --disposition land"
125                );
126                config.merge.mode = MergeMode::Pr;
127                config.graph.land = true;
128                config.graph.land_approval = true;
129            }
130            Some(Disposition::Review) => {
131                config.merge.mode = MergeMode::None;
132                config.graph.land = false;
133            }
134            None => anyhow::bail!(
135                "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)"
136            ),
137        }
138        Ok(())
139    }
140
141    /// Prove the adopted fix contains the selected public implementation.
142    pub async fn verify_source(&self, repo: &Path) -> Result<()> {
143        let source = git::rev_parse(repo, &format!("{}^{{commit}}", self.source())).await?;
144        ensure!(
145            source == self.source(),
146            "source-head must resolve to its full commit SHA"
147        );
148        ensure!(
149            git::is_ancestor(repo, &self.head, &source).await,
150            "source-head does not preserve the selected public implementation; select a descendant commit"
151        );
152        Ok(())
153    }
154
155    /// Confirm task identity and immutable criteria before starting work.
156    pub fn check_task(&self, task: &str, instruction: &str) -> Result<()> {
157        ensure!(
158            self.branch == format!("magi/task-{task}"),
159            "recovery head belongs to another task"
160        );
161        ensure!(
162            self.instruction == instruction,
163            "recovery criteria changed; select the public head again"
164        );
165        ensure!(
166            [self.head.as_str(), self.source()]
167                .iter()
168                .all(|sha| sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit())),
169            "recovery requires a full commit SHA"
170        );
171        Ok(())
172    }
173
174    /// Read-only forge validation plus fetch; no local branch is moved or pushed.
175    pub async fn verify(&self, repo: &Path, remote: &str, base: &str, lease: &str) -> Result<()> {
176        let pr_repo = land::slug_of_pr_url(&self.url).context("invalid recovery PR URL")?;
177        for args in [
178            vec!["remote", "get-url", "--all", remote],
179            vec!["remote", "get-url", "--push", "--all", remote],
180        ] {
181            let urls = git::git(repo, &args).await?;
182            ensure!(
183                !urls.trim().is_empty(),
184                "recovery remote has no repository URL"
185            );
186            for url in urls.lines() {
187                let remote_repo =
188                    remote_slug(url).context("cannot prove recovery remote repository identity")?;
189                land::ensure_same_repo(&remote_repo, &pr_repo)?;
190            }
191        }
192        let found = land::find_open_pr(repo, &self.branch, base).await?;
193        ensure!(
194            matches!(found, land::OpenPr::One { ref url, .. } if url == &self.url),
195            "selected PR is not the unique open task PR in this repository"
196        );
197        let (ok, raw) = land::gh(
198            repo,
199            &[
200                "pr".into(),
201                "view".into(),
202                self.url.clone(),
203                "--json".into(),
204                "headRefName,headRefOid,baseRefName,state,isCrossRepository".into(),
205            ],
206        )
207        .await?;
208        ensure!(ok, "could not verify selected PR: {raw}");
209        validate_view(&raw, &self.branch, base, lease)?;
210        let fetched = git::fetch(repo, remote, &self.branch).await?;
211        ensure!(
212            fetched.ok(),
213            "fetch selected public head failed: {}",
214            fetched.stderr
215        );
216        let fetched_head =
217            git::rev_parse(repo, &format!("refs/remotes/{remote}/{}", self.branch)).await?;
218        ensure!(
219            fetched_head == lease,
220            "public head moved; select and approve its new head before recovery"
221        );
222        ensure!(
223            git::remote_head(repo, remote, &self.branch)
224                .await?
225                .as_deref()
226                == Some(lease),
227            "public head lease changed during recovery"
228        );
229        Ok(())
230    }
231}
232
233// Require a recognizable forge URL rather than guessing through local paths or SSH aliases.
234fn remote_slug(url: &str) -> Option<String> {
235    let url = url
236        .trim()
237        .trim_end_matches('/')
238        .strip_suffix(".git")
239        .unwrap_or(url.trim().trim_end_matches('/'));
240    let (host, path) = if let Some(rest) = url
241        .strip_prefix("https://")
242        .or_else(|| url.strip_prefix("http://"))
243        .or_else(|| url.strip_prefix("ssh://"))
244    {
245        let (authority, path) = rest.split_once('/')?;
246        (authority.rsplit('@').next()?, path)
247    } else {
248        let (authority, path) = url.split_once(':')?;
249        let (_, host) = authority.split_once('@')?;
250        (host, path)
251    };
252    let mut parts = path.split('/');
253    let owner = parts.next()?;
254    let repo = parts.next()?;
255    if host.is_empty() || owner.is_empty() || repo.is_empty() || parts.next().is_some() {
256        return None;
257    }
258    Some(format!("{host}/{owner}/{repo}"))
259}
260
261fn validate_view(raw: &str, branch: &str, base: &str, lease: &str) -> Result<()> {
262    #[derive(Deserialize)]
263    #[serde(rename_all = "camelCase")]
264    struct View {
265        head_ref_name: String,
266        head_ref_oid: String,
267        base_ref_name: String,
268        state: String,
269        is_cross_repository: bool,
270    }
271    let view: View = serde_json::from_str(raw).context("selected public head metadata")?;
272    ensure!(
273        view.state == "OPEN"
274            && !view.is_cross_repository
275            && view.head_ref_name == branch
276            && view.base_ref_name == base,
277        "selected PR ownership or base changed"
278    );
279    ensure!(
280        view.head_ref_oid == lease,
281        "selected public head moved; refusing recovery"
282    );
283    Ok(())
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn recovery_uses_resolved_layers_and_preserves_unrelated_overrides() {
292        let dir = tempfile::tempdir().unwrap();
293        let machine = dir.path().join("machine.toml");
294        let repo = dir.path().join("repository.toml");
295        std::fs::write(&machine, "[verify]\ngate = ['documented gate']\ne2e = ['real checks']\n[graph]\nland = false\nland_approval = false\n").unwrap();
296        std::fs::write(&repo, "[merge]\nmode = 'none'\nbase = 'trunk'\n").unwrap();
297        let mut config = Config::load_layers(&[machine, repo]).unwrap();
298        let overrides = crate::queue::RunOverrides {
299            merge: Some("none".into()),
300            reviewers: Some(2),
301            ..Default::default()
302        };
303        overrides.apply(&mut config);
304        let mut selection = Selection {
305            url: "url".into(),
306            branch: "magi/task-t".into(),
307            head: "a".repeat(40),
308            source_head: None,
309            disposition: Some(Disposition::Land),
310            instruction: "criteria".into(),
311            approved_at: jiff::Timestamp::now(),
312        };
313        selection.configure(&mut config).unwrap();
314        assert_eq!(config.merge.mode, MergeMode::Pr);
315        assert_eq!(config.graph.reviewers, 2);
316        assert_eq!(config.merge.base.as_deref(), Some("trunk"));
317        assert_eq!(config.verify.gate, ["documented gate"]);
318        assert_eq!(config.verify.e2e, ["real checks"]);
319        assert!(config.graph.land && config.graph.land_approval);
320        let explicit = dir.path().join("explicit.toml");
321        std::fs::write(&explicit, "[merge]\nbase = 'other'\n").unwrap();
322        let (mut replacement, paths) = Config::discover(dir.path(), Some(&explicit)).unwrap();
323        assert_eq!(paths, [explicit]);
324        assert!(
325            selection
326                .configure(&mut replacement)
327                .unwrap_err()
328                .to_string()
329                .contains("verify.gate")
330        );
331        replacement.verify.gate = vec![" ".into()];
332        assert!(selection.configure(&mut replacement).is_err());
333        selection.disposition = Some(Disposition::Review);
334        selection.configure(&mut replacement).unwrap();
335        assert_eq!(replacement.merge.mode, MergeMode::None);
336        assert!(!replacement.graph.land);
337    }
338
339    #[test]
340    fn selected_source_requires_the_same_fetch_and_push_repository() {
341        for remote in [
342            "https://example.test/team/project.git",
343            "git@example.test:team/project.git",
344            "ssh://git@example.test/team/project.git",
345        ] {
346            assert_eq!(
347                remote_slug(remote).as_deref(),
348                Some("example.test/team/project")
349            );
350        }
351        assert!(remote_slug("/tmp/another.git").is_none());
352        assert!(
353            land::ensure_same_repo(
354                &remote_slug("git@example.test:team/other.git").unwrap(),
355                "example.test/team/project"
356            )
357            .is_err()
358        );
359        assert!(
360            land::ensure_same_repo(
361                &remote_slug("git@other.test:team/project.git").unwrap(),
362                "example.test/team/project"
363            )
364            .is_err()
365        );
366    }
367
368    #[test]
369    fn selected_head_refuses_moved_or_foreign_public_heads() {
370        let raw = r#"{"headRefName":"magi/task-t","headRefOid":"aaa","baseRefName":"main","state":"OPEN","isCrossRepository":false}"#;
371        assert!(validate_view(raw, "magi/task-t", "main", "aaa").is_ok());
372        assert!(validate_view(raw, "magi/task-t", "main", "bbb").is_err());
373        assert!(validate_view(raw, "magi/task-other", "main", "aaa").is_err());
374        assert!(validate_view(raw, "magi/task-t", "other", "aaa").is_err());
375        assert!(
376            validate_view(&raw.replace("false", "true"), "magi/task-t", "main", "aaa").is_err()
377        );
378        assert!(
379            validate_view(&raw.replace("OPEN", "CLOSED"), "magi/task-t", "main", "aaa").is_err()
380        );
381    }
382
383    #[test]
384    fn selected_head_is_bound_to_authored_criteria_and_task() {
385        let selected = Selection {
386            url: "url".into(),
387            branch: "magi/task-t".into(),
388            head: "a".repeat(40),
389            source_head: None,
390            disposition: Some(Disposition::Review),
391            instruction: "approved".into(),
392            approved_at: jiff::Timestamp::now(),
393        };
394        assert!(selected.check_task("t", "approved").is_ok());
395        assert!(
396            selected
397                .check_task("t", "prose mentioning another candidate")
398                .is_err()
399        );
400        assert!(selected.check_task("other", "approved").is_err());
401    }
402}