Skip to main content

mj_client/
target.rs

1//! Pure target and resume compatibility contracts shared by control surfaces.
2
3use anyhow::{Result, bail};
4use mj_core::config::{Config, TargetTemplate, is_bare_project_target};
5use mj_core::state::{ManagedWorktreeTarget, SessionRecord};
6
7// Published from containers/Containerfile.agent-dev by
8// .github/workflows/publish-agent-dev-image.yml. It already carries Node, Rust,
9// Git, gh, and the pinned ACP bridges, so a first session does not have to
10// install them.
11pub const DEFAULT_IMAGE: &str = "ghcr.io/brokkai/mjolnir/agent-dev:latest";
12
13/// Convert a configured bare target to the durable target identity stored on
14/// managed worktrees.
15pub fn managed_worktree_target(template: &TargetTemplate) -> Result<ManagedWorktreeTarget> {
16    match template {
17        TargetTemplate::LocalBare => Ok(ManagedWorktreeTarget::Local),
18        TargetTemplate::SshBare { ssh, .. } => {
19            let destination = match &ssh.user {
20                Some(user) => format!("{user}@{}", ssh.host),
21                None => ssh.host.clone(),
22            };
23            // Keep this in lockstep with the controller's SSH backend. These
24            // options are part of the target identity because the managed
25            // worktree compares it when deciding whether a resume stays put.
26            let mut ssh_args = vec![
27                "-o".to_owned(),
28                "BatchMode=yes".to_owned(),
29                "-o".to_owned(),
30                "StrictHostKeyChecking=accept-new".to_owned(),
31                "-o".to_owned(),
32                "ConnectTimeout=15".to_owned(),
33            ];
34            ssh_args.extend(ssh.extra_args.iter().cloned());
35            if let Some(identity) = &ssh.identity_file {
36                ssh_args.push("-i".to_owned());
37                ssh_args.push(identity.to_string_lossy().into_owned());
38            }
39            Ok(ManagedWorktreeTarget::Ssh {
40                destination,
41                ssh_args,
42            })
43        }
44        _ => bail!("managed raw worktrees require a bare target"),
45    }
46}
47
48/// What a resume has to do to the session record before it provisions.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ResumePlan {
51    /// Keep the session in the representation it already has.
52    InPlace,
53    /// Move a raw checkout session into a workspace target as a bundle
54    /// session. Only a whole checkout on this machine reaches this plan; the
55    /// conversion still requires that checkout to have a network Git remote,
56    /// which only Git can answer.
57    RawToWorkspace,
58    /// Move a bundle session out of its workspace into a raw local worktree.
59    WorkspaceToRaw,
60}
61
62/// Whether `session` may resume on `target_id`, and what the resume must do to
63/// the session record. The error is shown to the person choosing the target, so
64/// it says where the session is tied down and what to pick instead.
65///
66/// This decides representation only. It performs no I/O, so it can run on every
67/// row of a target picker.
68pub fn resume_compatibility(
69    session: &SessionRecord,
70    config: &Config,
71    target_id: &str,
72) -> Result<ResumePlan, String> {
73    let Some(target) = config.targets.get(target_id) else {
74        return Err(format!("target {target_id} is no longer configured"));
75    };
76    let Some(project_directory) = &session.project_directory else {
77        if matches!(target, TargetTemplate::LocalBare) {
78            return workspace_to_raw_compatibility(session, config);
79        }
80        return Ok(ResumePlan::InPlace);
81    };
82    let directory = project_directory.display();
83    let Some(worktree) = &session.managed_worktree else {
84        let Some(previous) = config.targets.get(&session.target_template_id) else {
85            return Err(
86                "the bare target this session last used is no longer configured".to_owned(),
87            );
88        };
89        if is_bare_project_target(target) {
90            if matches!(previous, TargetTemplate::LocalBare)
91                == matches!(target, TargetTemplate::LocalBare)
92            {
93                return Ok(ResumePlan::InPlace);
94            }
95            return Err(format!(
96                "this session opens {directory} directly on its host; resume it on the same kind of bare target"
97            ));
98        }
99        // A checkout on this machine can become an isolated workspace: the
100        // resume re-snapshots it against its own network remote. Whether the
101        // recorded directory really is a whole checkout with such a remote
102        // needs Git, so the conversion plan decides that, not the picker.
103        if matches!(previous, TargetTemplate::LocalBare) {
104            return Ok(ResumePlan::RawToWorkspace);
105        }
106        return Err(format!(
107            "this session opens {directory} on an SSH host; resume it on a bare target there"
108        ));
109    };
110    match managed_worktree_target(target) {
111        Ok(resume_target) if resume_target == worktree.target => Ok(ResumePlan::InPlace),
112        Ok(_) => Err(format!(
113            "this session's working tree lives on {}; resume it there",
114            managed_worktree_location(&worktree.target)
115        )),
116        Err(_) if worktree.target != ManagedWorktreeTarget::Local => Err(format!(
117            "this session works directly in {directory} on {}; resume it on a bare target there",
118            managed_worktree_location(&worktree.target)
119        )),
120        // A whole managed worktree on this machine converts: the resume
121        // re-snapshots it against the owning checkout's network remote.
122        Err(_) if Some(&worktree.worktree_root) == session.project_directory.as_ref() => {
123            Ok(ResumePlan::RawToWorkspace)
124        }
125        Err(_) => Err(format!(
126            "this session opens {directory}, a subdirectory of its checkout; resume it on a bare target"
127        )),
128    }
129}
130
131/// Why a bundle session cannot resume on a local bare target. A bare target has
132/// no managed workspace to restore the bundle into.
133const BUNDLE_ON_LOCAL_BARE: &str = "this session was created from a project bundle; a local bare target only hosts raw project sessions — resume it on a container, SSH, or EC2 target";
134
135/// Whether a bundle session can leave its workspace for a checkout on this
136/// machine. Only a single repository already on this machine can become one.
137fn workspace_to_raw_compatibility(
138    session: &SessionRecord,
139    config: &Config,
140) -> Result<ResumePlan, String> {
141    let Some(bundle) = config.bundles.get(&session.bundle_id) else {
142        return Err(BUNDLE_ON_LOCAL_BARE.to_owned());
143    };
144    let [repository] = bundle.repositories.as_slice() else {
145        return Err(format!(
146            "this session's project has {} repositories; a local bare target holds one checkout — resume it on a container, SSH, or EC2 target",
147            bundle.repositories.len()
148        ));
149    };
150    if repository.local.is_none() {
151        return Err(
152            "this session's project came from GitHub; resume it on a container, SSH, or EC2 target"
153                .to_owned(),
154        );
155    }
156    Ok(ResumePlan::WorkspaceToRaw)
157}
158
159/// Where a managed worktree's checkout physically lives, in words a user
160/// reads.
161fn managed_worktree_location(target: &ManagedWorktreeTarget) -> String {
162    match target {
163        ManagedWorktreeTarget::Local => "this machine".to_owned(),
164        ManagedWorktreeTarget::Ssh { destination, .. } => destination.clone(),
165    }
166}