1use anyhow::{Result, bail};
4use mj_core::config::{Config, TargetTemplate, is_bare_project_target};
5use mj_core::state::{ManagedWorktreeTarget, SessionRecord};
6
7pub const DEFAULT_IMAGE: &str = "ghcr.io/brokkai/mjolnir/agent-dev:latest";
12
13pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ResumePlan {
51 InPlace,
53 RawToWorkspace,
58 WorkspaceToRaw,
60}
61
62pub 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 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 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
131const 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
135fn 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
159fn managed_worktree_location(target: &ManagedWorktreeTarget) -> String {
162 match target {
163 ManagedWorktreeTarget::Local => "this machine".to_owned(),
164 ManagedWorktreeTarget::Ssh { destination, .. } => destination.clone(),
165 }
166}