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::{Checkout, 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 use mj_core::config::DEFAULT_CONTAINER_IMAGE as DEFAULT_IMAGE;
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            // The same arguments the controller's SSH backend uses. Only the
24            // location they name (see `ManagedWorktreeTarget::same_location`)
25            // decides whether a resume stays put.
26            let ssh_args = mj_core::targets::ssh_args_with_identity(
27                &ssh.extra_args,
28                ssh.identity_file.as_deref(),
29            );
30            Ok(ManagedWorktreeTarget::Ssh {
31                destination,
32                ssh_args,
33            })
34        }
35        _ => bail!("managed raw worktrees require a bare target"),
36    }
37}
38
39/// What a resume has to do to the session record before it provisions.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum ResumePlan {
42    /// Keep the session in the representation it already has.
43    InPlace,
44    /// Move a raw checkout session into a workspace target as a bundle
45    /// session. Only a whole checkout on this machine reaches this plan; the
46    /// conversion still requires that checkout to have a network Git remote,
47    /// which only Git can answer.
48    RawToWorkspace,
49    /// Move a bundle session out of its workspace into a raw local worktree.
50    WorkspaceToRaw,
51}
52
53/// Whether `session` may resume on `target_id`, and what the resume must do to
54/// the session record. The error is shown to the person choosing the target, so
55/// it says where the session is tied down and what to pick instead.
56///
57/// This decides representation only. It performs no I/O, so it can run on every
58/// row of a target picker.
59pub fn resume_compatibility(
60    session: &SessionRecord,
61    config: &Config,
62    target_id: &str,
63) -> Result<ResumePlan, String> {
64    let checkout = session.checkout();
65    resume_compatibility_with_checkout(session, &checkout, config, target_id)
66}
67
68/// The same compatibility decision using a checkout resolved by State when
69/// the caller has the session's sub-agent relationship available.
70pub fn resume_compatibility_with_checkout(
71    session: &SessionRecord,
72    checkout: &Checkout<'_>,
73    config: &Config,
74    target_id: &str,
75) -> Result<ResumePlan, String> {
76    let Some(target) = config.targets.get(target_id) else {
77        return Err(format!("target {target_id} is no longer configured"));
78    };
79    let project_directory = checkout.project_directory();
80    let Some(project_directory) = project_directory else {
81        if matches!(target, TargetTemplate::LocalBare) {
82            return workspace_to_raw_compatibility(session, config);
83        }
84        return Ok(ResumePlan::InPlace);
85    };
86    let directory = project_directory.display();
87    let worktree = match checkout {
88        Checkout::ManagedWorktree { worktree, .. } => Some(*worktree),
89        Checkout::Attached { .. } | Checkout::Borrowed { .. } => None,
90        Checkout::ManagedWorkspace => None,
91    };
92    let Some(worktree) = worktree else {
93        let Some(previous) = config.targets.get(&session.target_template_id) else {
94            return Err(
95                "the bare target this session last used is no longer configured".to_owned(),
96            );
97        };
98        if is_bare_project_target(target) {
99            if matches!(previous, TargetTemplate::LocalBare)
100                == matches!(target, TargetTemplate::LocalBare)
101            {
102                return Ok(ResumePlan::InPlace);
103            }
104            return Err(format!(
105                "this session opens {directory} directly on its host; resume it on the same kind of bare target"
106            ));
107        }
108        // A checkout on this machine can become an isolated workspace: the
109        // resume re-snapshots it against its own network remote. Whether the
110        // recorded directory really is a whole checkout with such a remote
111        // needs Git, so the conversion plan decides that, not the picker.
112        if matches!(previous, TargetTemplate::LocalBare) {
113            return Ok(ResumePlan::RawToWorkspace);
114        }
115        return Err(format!(
116            "this session opens {directory} on an SSH host; resume it on a bare target there"
117        ));
118    };
119    match managed_worktree_target(target) {
120        Ok(resume_target) if resume_target.same_location(&worktree.target) => {
121            Ok(ResumePlan::InPlace)
122        }
123        Ok(_) => Err(format!(
124            "this session's working tree lives on {}; resume it there",
125            managed_worktree_location(&worktree.target)
126        )),
127        Err(_) if worktree.target != ManagedWorktreeTarget::Local => Err(format!(
128            "this session works directly in {directory} on {}; resume it on a bare target there",
129            managed_worktree_location(&worktree.target)
130        )),
131        // A whole managed worktree on this machine converts: the resume
132        // re-snapshots it against the owning checkout's network remote.
133        Err(_) if worktree.worktree_root == project_directory => Ok(ResumePlan::RawToWorkspace),
134        Err(_) => Err(format!(
135            "this session opens {directory}, a subdirectory of its checkout; resume it on a bare target"
136        )),
137    }
138}
139
140/// Why a bundle session cannot resume on a local bare target. A bare target has
141/// no managed workspace to restore the bundle into.
142const 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";
143
144/// Whether a bundle session can leave its workspace for a checkout on this
145/// machine. Only a single repository already on this machine can become one.
146fn workspace_to_raw_compatibility(
147    session: &SessionRecord,
148    config: &Config,
149) -> Result<ResumePlan, String> {
150    let Some(bundle) = session.project_bundle(config) else {
151        return Err(BUNDLE_ON_LOCAL_BARE.to_owned());
152    };
153    let [repository] = bundle.repositories.as_slice() else {
154        return Err(format!(
155            "this session's project has {} repositories; a local bare target holds one checkout — resume it on a container, SSH, or EC2 target",
156            bundle.repositories.len()
157        ));
158    };
159    if repository.local.is_none() {
160        return Err(
161            "this session's project came from GitHub; resume it on a container, SSH, or EC2 target"
162                .to_owned(),
163        );
164    }
165    Ok(ResumePlan::WorkspaceToRaw)
166}
167
168/// Where a managed worktree's checkout physically lives, in words a user
169/// reads.
170fn managed_worktree_location(target: &ManagedWorktreeTarget) -> String {
171    match target {
172        ManagedWorktreeTarget::Local => "this machine".to_owned(),
173        ManagedWorktreeTarget::Ssh { destination, .. } => destination.clone(),
174    }
175}