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::{git, land};
9
10/// Durable operator selection. Only the recovery command creates this record.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct Selection {
13    /// Existing pull request in this repository.
14    pub url: String,
15    /// Task-scoped public branch.
16    pub branch: String,
17    /// Complete implementation and initial publication lease.
18    pub head: String,
19    /// Criteria the operator approved, retained without rewriting them.
20    pub instruction: String,
21    /// Approval generation: a later selection does not resume an earlier run.
22    pub approved_at: jiff::Timestamp,
23}
24
25const RETAIN_SOURCES: &str = "retain-recovery-sources";
26
27/// Preserve every historical run's source artifacts before releasing a recovery task.
28/// A sidecar survives stale run-state saves and applies even to older run schemas.
29pub fn retain_sources(task: &crate::queue::Task, home: &Path) -> Result<()> {
30    for id in task.runs.iter().collect::<std::collections::BTreeSet<_>>() {
31        ensure!(
32            crate::run::is_run_id(id),
33            "invalid historical run id; refusing source retention"
34        );
35        let dir = home.join("runs").join(id);
36        ensure!(
37            dir.is_dir(),
38            "historical run {id} is missing; cannot retain its sources"
39        );
40        std::fs::write(dir.join(RETAIN_SOURCES), &task.id)
41            .with_context(|| format!("retaining sources of historical run {id}"))?;
42    }
43    Ok(())
44}
45
46pub(crate) fn sources_retained(home: &Path, id: &str) -> bool {
47    home.join("runs")
48        .join(id)
49        .join(RETAIN_SOURCES)
50        .try_exists()
51        .unwrap_or(true)
52}
53
54/// A partially created recovery is retained and linked to its task for inspection.
55#[derive(Debug)]
56pub(crate) struct StartupFailed {
57    pub run: String,
58    pub cause: String,
59}
60
61impl std::fmt::Display for StartupFailed {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(
64            f,
65            "recovery run {} could not start: {}",
66            self.run, self.cause
67        )
68    }
69}
70impl std::error::Error for StartupFailed {}
71
72impl Selection {
73    /// Confirm task identity and immutable criteria before starting work.
74    pub fn check_task(&self, task: &str, instruction: &str) -> Result<()> {
75        ensure!(
76            self.branch == format!("magi/task-{task}"),
77            "recovery head belongs to another task"
78        );
79        ensure!(
80            self.instruction == instruction,
81            "recovery criteria changed; select the public head again"
82        );
83        ensure!(
84            self.head.len() == 40 && self.head.bytes().all(|b| b.is_ascii_hexdigit()),
85            "recovery requires a full commit SHA"
86        );
87        Ok(())
88    }
89
90    /// Read-only forge validation plus fetch; no local branch is moved or pushed.
91    pub async fn verify(&self, repo: &Path, remote: &str, base: &str, lease: &str) -> Result<()> {
92        let pr_repo = land::slug_of_pr_url(&self.url).context("invalid recovery PR URL")?;
93        for args in [
94            vec!["remote", "get-url", "--all", remote],
95            vec!["remote", "get-url", "--push", "--all", remote],
96        ] {
97            let urls = git::git(repo, &args).await?;
98            ensure!(
99                !urls.trim().is_empty(),
100                "recovery remote has no repository URL"
101            );
102            for url in urls.lines() {
103                let remote_repo =
104                    remote_slug(url).context("cannot prove recovery remote repository identity")?;
105                land::ensure_same_repo(&remote_repo, &pr_repo)?;
106            }
107        }
108        let found = land::find_open_pr(repo, &self.branch, base).await?;
109        ensure!(
110            matches!(found, land::OpenPr::One { ref url, .. } if url == &self.url),
111            "selected PR is not the unique open task PR in this repository"
112        );
113        let (ok, raw) = land::gh(
114            repo,
115            &[
116                "pr".into(),
117                "view".into(),
118                self.url.clone(),
119                "--json".into(),
120                "headRefName,headRefOid,baseRefName,state,isCrossRepository".into(),
121            ],
122        )
123        .await?;
124        ensure!(ok, "could not verify selected PR: {raw}");
125        validate_view(&raw, &self.branch, base, lease)?;
126        let fetched = git::fetch(repo, remote, &self.branch).await?;
127        ensure!(
128            fetched.ok(),
129            "fetch selected public head failed: {}",
130            fetched.stderr
131        );
132        let fetched_head =
133            git::rev_parse(repo, &format!("refs/remotes/{remote}/{}", self.branch)).await?;
134        ensure!(
135            fetched_head == lease,
136            "public head moved; select and approve its new head before recovery"
137        );
138        ensure!(
139            git::remote_head(repo, remote, &self.branch)
140                .await?
141                .as_deref()
142                == Some(lease),
143            "public head lease changed during recovery"
144        );
145        Ok(())
146    }
147}
148
149// Require a recognizable forge URL rather than guessing through local paths or SSH aliases.
150fn remote_slug(url: &str) -> Option<String> {
151    let url = url
152        .trim()
153        .trim_end_matches('/')
154        .strip_suffix(".git")
155        .unwrap_or(url.trim().trim_end_matches('/'));
156    let (host, path) = if let Some(rest) = url
157        .strip_prefix("https://")
158        .or_else(|| url.strip_prefix("http://"))
159        .or_else(|| url.strip_prefix("ssh://"))
160    {
161        let (authority, path) = rest.split_once('/')?;
162        (authority.rsplit('@').next()?, path)
163    } else {
164        let (authority, path) = url.split_once(':')?;
165        let (_, host) = authority.split_once('@')?;
166        (host, path)
167    };
168    let mut parts = path.split('/');
169    let owner = parts.next()?;
170    let repo = parts.next()?;
171    if host.is_empty() || owner.is_empty() || repo.is_empty() || parts.next().is_some() {
172        return None;
173    }
174    Some(format!("{host}/{owner}/{repo}"))
175}
176
177fn validate_view(raw: &str, branch: &str, base: &str, lease: &str) -> Result<()> {
178    #[derive(Deserialize)]
179    #[serde(rename_all = "camelCase")]
180    struct View {
181        head_ref_name: String,
182        head_ref_oid: String,
183        base_ref_name: String,
184        state: String,
185        is_cross_repository: bool,
186    }
187    let view: View = serde_json::from_str(raw).context("selected public head metadata")?;
188    ensure!(
189        view.state == "OPEN"
190            && !view.is_cross_repository
191            && view.head_ref_name == branch
192            && view.base_ref_name == base,
193        "selected PR ownership or base changed"
194    );
195    ensure!(
196        view.head_ref_oid == lease,
197        "selected public head moved; refusing recovery"
198    );
199    Ok(())
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn selected_source_requires_the_same_fetch_and_push_repository() {
208        for remote in [
209            "https://example.test/team/project.git",
210            "git@example.test:team/project.git",
211            "ssh://git@example.test/team/project.git",
212        ] {
213            assert_eq!(
214                remote_slug(remote).as_deref(),
215                Some("example.test/team/project")
216            );
217        }
218        assert!(remote_slug("/tmp/another.git").is_none());
219        assert!(
220            land::ensure_same_repo(
221                &remote_slug("git@example.test:team/other.git").unwrap(),
222                "example.test/team/project"
223            )
224            .is_err()
225        );
226        assert!(
227            land::ensure_same_repo(
228                &remote_slug("git@other.test:team/project.git").unwrap(),
229                "example.test/team/project"
230            )
231            .is_err()
232        );
233    }
234
235    #[test]
236    fn selected_head_refuses_moved_or_foreign_public_heads() {
237        let raw = r#"{"headRefName":"magi/task-t","headRefOid":"aaa","baseRefName":"main","state":"OPEN","isCrossRepository":false}"#;
238        assert!(validate_view(raw, "magi/task-t", "main", "aaa").is_ok());
239        assert!(validate_view(raw, "magi/task-t", "main", "bbb").is_err());
240        assert!(validate_view(raw, "magi/task-other", "main", "aaa").is_err());
241        assert!(validate_view(raw, "magi/task-t", "other", "aaa").is_err());
242        assert!(
243            validate_view(&raw.replace("false", "true"), "magi/task-t", "main", "aaa").is_err()
244        );
245        assert!(
246            validate_view(&raw.replace("OPEN", "CLOSED"), "magi/task-t", "main", "aaa").is_err()
247        );
248    }
249
250    #[test]
251    fn selected_head_is_bound_to_authored_criteria_and_task() {
252        let selected = Selection {
253            url: "url".into(),
254            branch: "magi/task-t".into(),
255            head: "a".repeat(40),
256            instruction: "approved".into(),
257            approved_at: jiff::Timestamp::now(),
258        };
259        assert!(selected.check_task("t", "approved").is_ok());
260        assert!(
261            selected
262                .check_task("t", "prose mentioning another candidate")
263                .is_err()
264        );
265        assert!(selected.check_task("other", "approved").is_err());
266    }
267}