1mod backend;
4mod checkpoint;
5mod git_cache;
6mod lifecycle;
7pub mod move_session;
8mod network_git;
9pub mod profile_config;
10mod provisioning;
11mod readiness;
12mod recovery_scan;
13mod resume;
14mod reviewer;
15mod subagents;
16#[cfg(test)]
17mod test_support;
18pub mod update;
19mod worker_binary;
20mod worker_restart;
21mod worktree;
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::fs::{self, File, OpenOptions};
25use std::path::{Path, PathBuf};
26
27use anyhow::{Context, Result, bail, ensure};
28use chrono::Utc;
29
30use mj_core::config::{
31 Config, ProjectBundle, ProjectRepository, SshConnection, TargetTemplate, atomic_write,
32 container_size_host, data_dir, is_bare_project_target, mount_history_host,
33};
34
35use crate::import::{
36 RepositoryIdentity, bundle_matches, configured_bundle_for_local, configured_bundle_for_origin,
37 setup_style_id,
38};
39use crate::setup::github_repository_from_origin;
40
41const CONFIG_RENAME_JOURNAL: &str = "config-rename.json";
42
43#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
44#[serde(rename_all = "snake_case")]
45enum ConfigRenameKind {
46 Profile,
47 Target,
48}
49
50#[derive(Debug, serde::Serialize, serde::Deserialize)]
51#[serde(deny_unknown_fields)]
52struct ConfigRenameJournal {
53 kind: ConfigRenameKind,
54 old_id: String,
55 new_id: String,
56}
57use mj_core::state::{
58 HostContainerSize, SessionRecord, SessionResourceAllocation, SessionState, State,
59 new_session_id, normalize_session_title,
60};
61
62use crate::targets::{
63 self, AdditionalMount, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
64};
65
66pub(crate) use backend::controller_github_token;
67pub use backend::image_refresh_plan;
68use backend::validate_resource_allocation;
69use provisioning::apply_failed_new_session_rollback;
70pub(crate) use worker_binary::refresh_remote_worker_binary_if_stale;
71pub(crate) use worktree::path_exists_on_managed_target;
72
73pub use checkpoint::{
74 CheckpointArtifact, CheckpointDeferred, IdleWorkspaceLease, SessionExportLayout,
75 checkpoint_was_deferred, reconcile_managed_checkpoint_archives,
76};
77pub use recovery_scan::{RecoveryCandidate, RecoveryScan};
78pub use resume::{
79 ResumeRepositorySourceMismatch, ResumeRepositorySourcePreflight, ResumeRepositorySourceReceipt,
80};
81pub use reviewer::reviewer_stager;
82pub use subagents::RegisterSubagentRequest;
83pub use worker_binary::{
84 WorkerBinaryAvailability, pin_worker_binary_sources, worker_binary_prerequisite_for_arch,
85};
86pub use worker_restart::WorkerUpgradeOutcome;
87pub use worktree::{ResumePlan, local_project_repository, resume_compatibility};
88
89pub struct Controller {
90 pub config: Config,
91 pub state: State,
92}
93
94#[derive(Debug)]
98pub struct ControllerStoreGuard {
99 file: File,
100}
101
102impl ControllerStoreGuard {
103 pub fn acquire() -> Result<Self> {
104 let directory = data_dir();
105 Self::acquire_at(&directory)
106 }
107
108 fn acquire_at(directory: &Path) -> Result<Self> {
109 Self::try_acquire_at(directory)?.with_context(|| {
110 format!(
111 "another Mjolnir controller is already using {}; stop it before starting this command",
112 directory.display()
113 )
114 })
115 }
116
117 pub fn try_acquire() -> Result<Option<Self>> {
119 Self::try_acquire_at(&data_dir())
120 }
121
122 fn try_acquire_at(directory: &Path) -> Result<Option<Self>> {
123 std::fs::create_dir_all(directory)
124 .with_context(|| format!("create controller data directory {}", directory.display()))?;
125 let path = directory.join("controller.lock");
126 let mut options = OpenOptions::new();
127 options.create(true).read(true).write(true);
128 #[cfg(unix)]
129 {
130 use std::os::unix::fs::OpenOptionsExt;
131 options.mode(0o600);
132 }
133 let file = options
134 .open(&path)
135 .with_context(|| format!("open controller lock {}", path.display()))?;
136 match file.try_lock() {
137 Ok(()) => {}
138 Err(std::fs::TryLockError::WouldBlock) => return Ok(None),
139 Err(std::fs::TryLockError::Error(error)) => {
140 return Err(error)
141 .with_context(|| format!("lock controller store {}", directory.display()));
142 }
143 }
144 Ok(Some(Self { file }))
145 }
146
147 pub fn start_database_writer(&self) -> Result<crate::database::DatabaseWriterOwner> {
150 crate::database::start_database_writer()
151 }
152}
153
154impl Drop for ControllerStoreGuard {
155 fn drop(&mut self) {
156 let _ = self.file.unlock();
159 }
160}
161
162#[derive(Debug)]
166pub struct QuickBundleCreation {
167 pub config: Config,
168 pub bundle_id: String,
169}
170
171#[derive(Debug)]
175pub enum QuickBundleFailure {
176 InvalidSource(anyhow::Error),
177 Persistence(anyhow::Error),
178}
179
180impl std::fmt::Display for QuickBundleFailure {
181 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 match self {
183 Self::InvalidSource(error) => write!(formatter, "invalid repository source: {error}"),
184 Self::Persistence(error) => write!(formatter, "persist quick bundle: {error}"),
185 }
186 }
187}
188
189impl std::error::Error for QuickBundleFailure {
190 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191 match self {
192 Self::InvalidSource(error) | Self::Persistence(error) => Some(error.root_cause()),
193 }
194 }
195}
196
197pub fn create_quick_bundle(
203 source: &str,
204) -> std::result::Result<QuickBundleCreation, QuickBundleFailure> {
205 let (config, bundle_id) = Config::update(|config| {
206 create_quick_bundle_in_config(config, source)
207 .map_err(|error| anyhow::Error::new(QuickBundleFailure::InvalidSource(error)))
208 })
209 .map_err(|error| {
210 error
211 .downcast::<QuickBundleFailure>()
212 .unwrap_or_else(QuickBundleFailure::Persistence)
213 })?;
214 Ok(QuickBundleCreation { config, bundle_id })
215}
216
217pub fn create_quick_bundle_in_config(config: &mut Config, source: &str) -> Result<String> {
222 let source = interpret_repository_source(source)?;
223 let existing = match &source.kind {
224 RepositorySourceKind::Local(root) => configured_bundle_for_local(config, root),
225 RepositorySourceKind::Github(repository) => {
226 configured_bundle_for_origin(config, repository)
227 }
228 };
229 if let Some(existing) = existing {
230 return Ok(existing);
231 }
232 let repository_id = setup_style_id(&source.name);
233 let mut bundle_id = repository_id.clone();
234 for suffix in 2_u32.. {
235 if !config.bundles.contains_key(&bundle_id) {
236 break;
237 }
238 bundle_id = format!("{repository_id}-{suffix}");
239 }
240 config.bundles.insert(
241 bundle_id.clone(),
242 ProjectBundle {
243 primary_repo: repository_id.clone(),
244 repositories: vec![source.into_project_repository(repository_id.clone())],
245 },
246 );
247 config.validate()?;
248 Ok(bundle_id)
249}
250
251pub fn create_bundle_from_sources(
258 sources: &[String],
259) -> std::result::Result<QuickBundleCreation, QuickBundleFailure> {
260 let (config, bundle_id) = Config::update(|config| {
261 create_bundle_from_sources_in_config(config, sources)
262 .map_err(|error| anyhow::Error::new(QuickBundleFailure::InvalidSource(error)))
263 })
264 .map_err(|error| {
265 error
266 .downcast::<QuickBundleFailure>()
267 .unwrap_or_else(QuickBundleFailure::Persistence)
268 })?;
269 Ok(QuickBundleCreation { config, bundle_id })
270}
271
272pub fn create_bundle_from_sources_in_config(
276 config: &mut Config,
277 sources: &[String],
278) -> Result<String> {
279 let sources = sources
280 .iter()
281 .map(|source| interpret_repository_source(source))
282 .collect::<Result<Vec<_>>>()?;
283 if sources.is_empty() {
284 bail!("at least one repository source is required");
285 }
286
287 let mut identities = BTreeSet::new();
288 for source in &sources {
289 if !identities.insert(source.identity()) {
290 bail!("duplicate repository source {:?}", source.display_name);
291 }
292 }
293
294 if let Some(existing) = exact_configured_bundle(config, &sources) {
295 return Ok(existing);
296 }
297
298 let mut updated = config.clone();
301 let mut used_repository_ids = BTreeSet::new();
302 let mut repositories = Vec::with_capacity(sources.len());
303 for source in sources {
304 let base = setup_style_id(&source.name);
305 let repository_id = unique_id(&base, |candidate| used_repository_ids.contains(candidate));
306 used_repository_ids.insert(repository_id.clone());
307 repositories.push(source.into_project_repository(repository_id));
308 }
309 let primary_repo = repositories
310 .first()
311 .map(|repository| repository.id.clone())
312 .context("at least one repository source is required")?;
313 let bundle_id = unique_id(&primary_repo, |candidate| {
314 updated.bundles.contains_key(candidate)
315 });
316 updated.bundles.insert(
317 bundle_id.clone(),
318 ProjectBundle {
319 primary_repo,
320 repositories,
321 },
322 );
323 updated.validate()?;
324 *config = updated;
325 Ok(bundle_id)
326}
327
328#[derive(Debug, Clone)]
329enum RepositorySourceKind {
330 Github(crate::setup::GithubRepository),
331 Local(PathBuf),
332}
333
334#[derive(Debug, Clone)]
335struct InterpretedRepositorySource {
336 display_name: String,
337 name: String,
338 kind: RepositorySourceKind,
339}
340
341impl InterpretedRepositorySource {
342 fn identity(&self) -> RepositoryIdentity {
343 match &self.kind {
344 RepositorySourceKind::Github(repository) => RepositoryIdentity::Github(
345 repository.owner.to_ascii_lowercase(),
346 repository.repository.to_ascii_lowercase(),
347 ),
348 RepositorySourceKind::Local(root) => RepositoryIdentity::Local(root.clone()),
349 }
350 }
351
352 fn into_project_repository(self, id: String) -> ProjectRepository {
353 let (github, local) = match self.kind {
354 RepositorySourceKind::Github(repository) => (
355 Some(format!("{}/{}", repository.owner, repository.repository)),
356 None,
357 ),
358 RepositorySourceKind::Local(root) => (None, Some(root)),
359 };
360 ProjectRepository {
361 id: id.clone(),
362 github,
363 local,
364 destination: PathBuf::from(id),
365 git_ref: None,
366 }
367 }
368}
369
370fn interpret_repository_source(source: &str) -> Result<InterpretedRepositorySource> {
373 let source = source.trim();
374 if source.is_empty() {
375 bail!("repository source cannot be empty");
376 }
377 let expanded = mj_core::path_input::expand_local(Path::new(source))?;
378 let candidate = expanded.as_path();
379 if candidate.exists() {
380 let root = mj_core::local_git::canonical_repository(candidate)?;
381 let name = root
382 .file_name()
383 .and_then(|name| name.to_str())
384 .context("local repository has no usable directory name")?
385 .to_owned();
386 return Ok(InterpretedRepositorySource {
387 display_name: source.to_owned(),
388 name,
389 kind: RepositorySourceKind::Local(root),
390 });
391 }
392 if candidate.is_absolute() || source.starts_with('.') || source.starts_with('~') {
393 bail!("local repository path {source:?} does not exist");
394 }
395 let repository = github_repository_from_origin(source).context(format!(
396 "{source:?} is not a GitHub owner/repository or URL"
397 ))?;
398 Ok(InterpretedRepositorySource {
399 display_name: source.to_owned(),
400 name: repository.repository.clone(),
401 kind: RepositorySourceKind::Github(repository),
402 })
403}
404
405fn exact_configured_bundle(
406 config: &Config,
407 requested: &[InterpretedRepositorySource],
408) -> Option<String> {
409 let requested_identities = requested
410 .iter()
411 .map(InterpretedRepositorySource::identity)
412 .collect::<BTreeSet<_>>();
413 let primary = requested.first()?.identity();
414 config.bundles.iter().find_map(|(id, bundle)| {
415 if bundle.repositories.len() != requested.len()
416 || bundle
417 .repositories
418 .iter()
419 .any(|repository| repository.git_ref.is_some())
420 {
421 return None;
422 }
423 bundle_matches(bundle, &requested_identities, &primary).then(|| id.clone())
424 })
425}
426
427fn unique_id(base: &str, mut is_used: impl FnMut(&str) -> bool) -> String {
428 if !is_used(base) {
429 return base.to_owned();
430 }
431 for suffix in 2_u32.. {
432 let suffix = format!("-{suffix}");
433 let prefix_len = 64usize.saturating_sub(suffix.len());
434 let prefix = base.chars().take(prefix_len).collect::<String>();
435 let candidate = format!("{prefix}{suffix}");
436 if !is_used(&candidate) {
437 return candidate;
438 }
439 }
440 unreachable!("u32 repository/bundle id suffixes exhausted")
441}
442
443pub struct SessionLaunchOptions {
444 pub create_managed_worktree: Option<bool>,
445 pub mjolnir_subagents: Option<bool>,
446 pub initial_prompt: Option<String>,
447 pub workspace_id: String,
448 pub additional_mounts: Vec<AdditionalMount>,
449 pub allow_dirty_local: bool,
450 pub resource_allocation: Option<SessionResourceAllocation>,
451 pub project_directory: Option<PathBuf>,
452 pub session_title_override: Option<String>,
453}
454
455pub struct SessionResumeOptions {
456 pub additional_mounts: Option<Vec<AdditionalMount>>,
457 pub resource_allocation: Option<SessionResourceAllocation>,
458 pub discard_queue: bool,
459}
460
461fn selected_host_container_size(
462 template: &TargetTemplate,
463 allocation: Option<&SessionResourceAllocation>,
464) -> Option<(String, HostContainerSize)> {
465 let host = container_size_host(template)?;
466 let SessionResourceAllocation::Container { cpus, memory_bytes } = allocation? else {
467 return None;
468 };
469 Some((
470 host.to_owned(),
471 HostContainerSize {
472 cpus: *cpus,
473 memory_bytes: *memory_bytes,
474 },
475 ))
476}
477
478impl Controller {
479 pub fn load() -> Result<Self> {
480 let config = Config::load()?;
481 let state = crate::database::load_state_migrating()?;
482 state.validate()?;
485 for session in state.sessions.values() {
486 if let Some(issue) = session.configuration_issue(&config) {
487 tracing::warn!(session_id = %session.id, "{issue}");
488 }
489 }
490 Ok(Self { config, state })
491 }
492
493 pub fn reload(&mut self) -> Result<()> {
494 *self = Self::load()?;
495 Ok(())
496 }
497
498 fn persist_session_state(&self, session_id: &str) -> Result<()> {
499 match self.state.sessions.get(session_id) {
500 Some(session) => crate::database::save_lifecycle_session(session),
501 None => crate::database::delete_session(session_id),
502 }
503 }
504
505 fn persist_session_transition_or_restore(
506 &mut self,
507 session_id: &str,
508 previous: &SessionRecord,
509 context: &'static str,
510 ) -> Result<()> {
511 persist_session_record_transition_or_restore(
512 &mut self.state,
513 session_id,
514 previous,
515 context,
516 &crate::database::save_lifecycle_session,
517 )
518 }
519
520 fn restore_prior_session_after_persistence_failure(
521 &mut self,
522 session_id: &str,
523 previous: &SessionRecord,
524 primary: anyhow::Error,
525 ) -> anyhow::Error {
526 restore_session_after_persistence_failure(
527 &mut self.state,
528 session_id,
529 previous,
530 primary,
531 crate::database::save_lifecycle_session,
532 )
533 }
534
535 pub fn resolve_input_path(
537 &self,
538 target_id: &str,
539 path: &Path,
540 executor: &impl CommandExecutor,
541 ) -> Result<PathBuf> {
542 let target = self
543 .config
544 .targets
545 .get(target_id)
546 .context("Unknown path target")?;
547 resolve_target_input_path(target, path, executor)
548 }
549
550 pub fn complete_mount_source(
552 &self,
553 target_id: &str,
554 prefix: &str,
555 executor: &impl CommandExecutor,
556 ) -> Result<Vec<String>> {
557 let target = self
558 .config
559 .targets
560 .get(target_id)
561 .with_context(|| format!("unknown target template {target_id:?}"))?;
562 let home = if mj_core::path_input::needs_home(Path::new(prefix))? {
563 Some(resolve_target_input_path(target, Path::new("~"), executor)?)
564 } else {
565 None
566 };
567 let expanded = mj_core::path_input::expand_home(Path::new(prefix), home.as_deref())?;
568 let mut lookup = expanded.to_string_lossy().into_owned();
569 if prefix.ends_with('/') && !lookup.ends_with('/') {
571 lookup.push('/');
572 }
573 let candidates = match target {
574 TargetTemplate::LocalPodman { .. }
575 | TargetTemplate::LocalDocker { .. }
576 | TargetTemplate::AppleContainer { .. }
577 | TargetTemplate::AwsEc2 { .. } => targets::local_directory_completions(&lookup),
578 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
579 targets::ssh_directory_completions(&backend_ssh(ssh), &lookup, executor)?
580 }
581 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
582 bail!("resource path completion is unsupported for bare targets")
583 }
584 };
585 candidates
586 .into_iter()
587 .map(|candidate| {
588 let Some(home) = &home else {
589 return Ok(candidate);
590 };
591 let suffix = Path::new(&candidate)
592 .strip_prefix(home)
593 .context("Completed path is outside the requested home")?;
594 let mut value = Path::new("~").join(suffix).to_string_lossy().into_owned();
595 if candidate.ends_with('/') && !value.ends_with('/') {
596 value.push('/');
597 }
598 Ok(value)
599 })
600 .collect()
601 }
602
603 pub fn validate_mount_source(
610 &self,
611 target_id: &str,
612 source: &Path,
613 executor: &impl CommandExecutor,
614 ) -> Result<Option<String>> {
615 let target = self
616 .config
617 .targets
618 .get(target_id)
619 .with_context(|| format!("unknown target template {target_id:?}"))?;
620 let exists = match target {
621 TargetTemplate::LocalPodman { .. }
622 | TargetTemplate::LocalDocker { .. }
623 | TargetTemplate::AppleContainer { .. }
624 | TargetTemplate::AwsEc2 { .. } => std::fs::metadata(source)
625 .map(|metadata| metadata.is_dir())
626 .or_else(|error| {
627 if error.kind() == std::io::ErrorKind::NotFound {
628 Ok(false)
629 } else {
630 Err(error)
631 }
632 })
633 .with_context(|| format!("inspect resource source {}", source.display()))?,
634 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
635 targets::ssh_directory_exists(&backend_ssh(ssh), source, executor)?
636 }
637 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
638 bail!("resource attachments are unsupported for bare targets")
639 }
640 };
641 ensure!(
642 exists,
643 "source path {} does not exist or is not a directory",
644 source.display()
645 );
646 Ok(self.forced_read_only_reason(target, source, executor))
647 }
648
649 fn forced_read_only_reason(
651 &self,
652 target: &TargetTemplate,
653 source: &Path,
654 executor: &impl CommandExecutor,
655 ) -> Option<String> {
656 let ssh = match target {
657 TargetTemplate::LocalPodman { .. } | TargetTemplate::LocalDocker { .. } => None,
658 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
659 Some(backend_ssh(ssh))
660 }
661 _ => return None,
664 };
665 let filesystem = targets::probe_filesystem_types(
666 ssh.as_ref(),
667 std::slice::from_ref(&source.to_path_buf()),
668 executor,
669 )
670 .map_err(|error| {
671 tracing::debug!(
672 source = %source.display(),
673 error = format!("{error:#}"),
674 "could not probe the filesystem under a mount source"
675 );
676 })
677 .ok()?
678 .pop()?;
679 let reason = targets::overlay_unsupported_filesystem(&filesystem)?;
680 Some(format!("{filesystem} ({reason})"))
681 }
682
683 fn fail_new_session_with_cleanup(
684 &mut self,
685 session_id: &str,
686 error: anyhow::Error,
687 executor: &impl CommandExecutor,
688 ) -> Result<anyhow::Error> {
689 let original = format!("{error:#}");
690 let cleanup_error = self
691 .cleanup_new_session_worktree_after_failure(session_id, executor)
692 .err()
693 .map(|cleanup_error| format!("{cleanup_error:#}"));
694 if let Some(cleanup_error) = &cleanup_error {
695 tracing::warn!(
696 session_id,
697 error = %cleanup_error,
698 "new-session worktree rollback reported a cleanup failure"
699 );
700 }
701 let failure = apply_failed_new_session_rollback(
702 &mut self.state,
703 session_id,
704 &original,
705 cleanup_error,
706 );
707 self.persist_session_state(session_id)?;
708 Ok(failure)
709 }
710
711 pub fn register_session_with_resources(
712 &mut self,
713 profile_id: &str,
714 bundle_id: &str,
715 target_id: &str,
716 title: impl Into<String>,
717 options: SessionLaunchOptions,
718 ) -> Result<String> {
719 let SessionLaunchOptions {
720 create_managed_worktree,
721 mjolnir_subagents,
722 initial_prompt,
723 workspace_id,
724 additional_mounts,
725 allow_dirty_local: _,
726 resource_allocation,
727 project_directory,
728 session_title_override,
729 } = options;
730 let session_title_override = match session_title_override {
731 Some(title) => {
732 Some(normalize_session_title(&title).context("session name cannot be empty")?)
733 }
734 None => None,
735 };
736 let profile = self
737 .config
738 .profiles
739 .get(profile_id)
740 .with_context(|| format!("unknown profile {profile_id:?}"))?;
741 ensure!(profile.enabled, "profile {profile_id:?} is disabled");
742 let template = self
743 .config
744 .targets
745 .get(target_id)
746 .with_context(|| format!("unknown target template {target_id:?}"))?;
747 if create_managed_worktree == Some(true) && !is_bare_project_target(template) {
748 bail!("managed worktree creation requires a bare Git project");
749 }
750 if project_directory.is_some() != is_bare_project_target(template) {
751 bail!("raw project directories require a bare target, and bare targets require one");
752 }
753 if let Some(path) = &project_directory
754 && (!path.is_absolute()
755 || path
756 .components()
757 .any(|part| part == std::path::Component::ParentDir))
758 {
759 bail!("bare project directory must be an absolute safe path");
760 }
761 let bundle = project_directory
762 .is_none()
763 .then(|| self.config.bundles.get(bundle_id))
764 .flatten();
765 if project_directory.is_none() && bundle.is_none() {
766 bail!("unknown bundle {bundle_id:?}");
767 }
768 if matches!(
769 profile.kind,
770 mj_core::config::HarnessKind::Deepseek | mj_core::config::HarnessKind::Muse
771 ) && (!additional_mounts.is_empty()
772 || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
773 {
774 bail!(
775 "{} ACP supports one workspace root; use a single-repository bundle without attached directories",
776 profile.kind.display_name()
777 );
778 }
779 if let Some(bundle) = bundle {
780 for repository in &bundle.repositories {
781 mj_core::remote_git::resolve_repository(
782 repository,
783 &targets::CancellableProcessExecutor::with_timeout(
784 std::time::Duration::from_secs(15),
785 ),
786 )
787 .with_context(|| format!("repository {:?}", repository.id))?;
788 }
789 }
790 validate_resource_allocation(template, resource_allocation.as_ref())?;
791 let selected_container_size =
792 selected_host_container_size(template, resource_allocation.as_ref());
793 if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
794 bail!("attached resources are unsupported for this target");
795 }
796 targets::validate_additional_mounts(&additional_mounts)?;
797 let id = new_session_id()?;
798 let now = now();
799 let record = SessionRecord {
800 create_managed_worktree,
801 mjolnir_subagents,
802 archived: false,
803 container_cpus: None,
804 container_memory: None,
805 id: id.clone(),
806 workspace_id,
807 title: title.into(),
808 harness_kind: profile.kind,
809 last_profile: profile_id.to_string(),
810 bundle_id: bundle_id.to_string(),
811 project_directory,
812 managed_worktree: None,
813 target_template_id: target_id.to_string(),
814 resource_allocation,
815 additional_mounts: additional_mounts.clone(),
816 state: SessionState::Provisioning,
817 target: None,
818 native_session_id: None,
819 acp_session_title: None,
820 session_title_override,
821 created_at: now.clone(),
822 updated_at: now,
823 viewed_through_event_ordinal: 0,
824 draft_input: initial_prompt.unwrap_or_default(),
825 last_error: None,
826 last_checkpoint_error: None,
827 checkpoint: None,
828 };
829 if let Some((host, size)) = selected_container_size.as_ref() {
833 crate::database::save_session_with_container_size(&record, host, *size)?;
834 } else {
835 crate::database::save_session(&record)?;
836 }
837 self.state.sessions.insert(id.clone(), record);
838 if let Some((host, size)) = selected_container_size {
839 self.state.remember_container_size(&host, size);
840 }
841 if let Some(host) = mount_history_host(template) {
842 match crate::database::remember_mount_sources(host, &additional_mounts) {
846 Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
847 Err(error) => tracing::warn!(
848 session_id = id,
849 error = format!("{error:#}"),
850 "could not remember the attached resource directories for later suggestions"
851 ),
852 }
853 }
854 Ok(id)
855 }
856
857 pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
858 let title = normalize_session_title(title).context("session name cannot be empty")?;
859 ensure!(
860 self.state.sessions.contains_key(session_id),
861 "unknown session {session_id}"
862 );
863 let updated_at = now();
864 crate::database::set_session_title_override(session_id, &title, &updated_at)?;
865 let record = self
866 .state
867 .sessions
868 .get_mut(session_id)
869 .expect("session was checked before updating its title");
870 record.session_title_override = Some(title.clone());
871 record.updated_at = updated_at;
872 Ok(title)
873 }
874
875 pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
876 mj_core::config::validate_id("profile", new_id)?;
877 if old_id == new_id {
878 ensure!(
879 self.config.profiles.contains_key(old_id),
880 "unknown profile {old_id:?}"
881 );
882 return Ok(());
883 }
884 let journal = ConfigRenameJournal {
885 kind: ConfigRenameKind::Profile,
886 old_id: old_id.to_owned(),
887 new_id: new_id.to_owned(),
888 };
889 write_config_rename_journal(&journal)?;
890 let (config, ()) = match Config::update(|config| {
891 ensure!(
892 config.profiles.contains_key(old_id),
893 "unknown profile {old_id:?}"
894 );
895 ensure!(
896 !config.profiles.contains_key(new_id),
897 "profile {new_id:?} already exists"
898 );
899 let profile = config
900 .profiles
901 .remove(old_id)
902 .expect("profile was checked in the transaction");
903 config.profiles.insert(new_id.to_owned(), profile);
904 Ok(())
905 }) {
906 Ok(result) => result,
907 Err(error) => {
908 remove_config_rename_journal()
909 .context("remove profile rename journal after config save failed")?;
910 return Err(error).context("save renamed profile configuration");
911 }
912 };
913 self.config = config;
914 mj_core::test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
915 if let Err(error) = crate::database::rename_profile_references(old_id, new_id) {
916 let restore = Config::update(|config| {
917 let profile = config
918 .profiles
919 .remove(new_id)
920 .with_context(|| format!("renamed profile {new_id:?} is missing"))?;
921 ensure!(
922 !config.profiles.contains_key(old_id),
923 "cannot restore profile rename: both {old_id:?} and {new_id:?} exist"
924 );
925 config.profiles.insert(old_id.to_owned(), profile);
926 Ok(())
927 });
928 let restored = match restore {
929 Ok((config, ())) => config,
930 Err(restore_error) => {
931 return Err(error).context(format!(
932 "rename profile references; additionally failed to restore config: {restore_error:#}"
933 ));
934 }
935 };
936 self.config = restored;
937 if let Err(restore_error) = remove_config_rename_journal() {
938 return Err(error).context(format!(
939 "rename profile references; additionally failed to remove rename journal: {restore_error:#}"
940 ));
941 }
942 return Err(error).context("rename profile references");
943 }
944 for session in self.state.sessions.values_mut() {
945 if session.last_profile == old_id {
946 session.last_profile = new_id.to_owned();
947 }
948 }
949 remove_config_rename_journal()?;
950 Ok(())
951 }
952
953 pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
954 mj_core::config::validate_id("target template", new_id)?;
955 if old_id == new_id {
956 ensure!(
957 self.config.targets.contains_key(old_id),
958 "unknown target {old_id:?}"
959 );
960 return Ok(());
961 }
962 let journal = ConfigRenameJournal {
963 kind: ConfigRenameKind::Target,
964 old_id: old_id.to_owned(),
965 new_id: new_id.to_owned(),
966 };
967 write_config_rename_journal(&journal)?;
968 let (config, ()) = match Config::update(|config| {
969 ensure!(
970 config.targets.contains_key(old_id),
971 "unknown target {old_id:?}"
972 );
973 ensure!(
974 !config.targets.contains_key(new_id),
975 "target {new_id:?} already exists"
976 );
977 let target = config
978 .targets
979 .remove(old_id)
980 .expect("target was checked in the transaction");
981 config.targets.insert(new_id.to_owned(), target);
982 Ok(())
983 }) {
984 Ok(result) => result,
985 Err(error) => {
986 remove_config_rename_journal()
987 .context("remove target rename journal after config save failed")?;
988 return Err(error).context("save renamed target configuration");
989 }
990 };
991 self.config = config;
992 mj_core::test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
993 if let Err(error) = crate::database::rename_target_references(old_id, new_id) {
994 let restore = Config::update(|config| {
995 let target = config
996 .targets
997 .remove(new_id)
998 .with_context(|| format!("renamed target {new_id:?} is missing"))?;
999 ensure!(
1000 !config.targets.contains_key(old_id),
1001 "cannot restore target rename: both {old_id:?} and {new_id:?} exist"
1002 );
1003 config.targets.insert(old_id.to_owned(), target);
1004 Ok(())
1005 });
1006 let restored = match restore {
1007 Ok((config, ())) => config,
1008 Err(restore_error) => {
1009 return Err(error).context(format!(
1010 "rename target references; additionally failed to restore config: {restore_error:#}"
1011 ));
1012 }
1013 };
1014 self.config = restored;
1015 if let Err(restore_error) = remove_config_rename_journal() {
1016 return Err(error).context(format!(
1017 "rename target references; additionally failed to remove rename journal: {restore_error:#}"
1018 ));
1019 }
1020 return Err(error).context("rename target references");
1021 }
1022 for session in self.state.sessions.values_mut() {
1023 if session.target_template_id == old_id {
1024 session.target_template_id = new_id.to_owned();
1025 }
1026 }
1027 remove_config_rename_journal()?;
1028 Ok(())
1029 }
1030
1031 pub fn recover_config_id_rename() -> Result<bool> {
1035 let path = config_rename_journal_path();
1036 let body = match fs::read(&path) {
1037 Ok(body) => body,
1038 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1039 Err(error) => return Err(error).context(format!("read {}", path.display())),
1040 };
1041 let journal: ConfigRenameJournal =
1042 serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
1043 match journal.kind {
1044 ConfigRenameKind::Profile => {
1045 Config::update(|config| {
1046 finish_config_map_rename(
1047 &mut config.profiles,
1048 &journal.old_id,
1049 &journal.new_id,
1050 "profile",
1051 )?;
1052 Ok(())
1053 })?;
1054 crate::database::rename_profile_references(&journal.old_id, &journal.new_id)?;
1055 }
1056 ConfigRenameKind::Target => {
1057 Config::update(|config| {
1058 finish_config_map_rename(
1059 &mut config.targets,
1060 &journal.old_id,
1061 &journal.new_id,
1062 "target",
1063 )?;
1064 Ok(())
1065 })?;
1066 crate::database::rename_target_references(&journal.old_id, &journal.new_id)?;
1067 }
1068 }
1069 remove_config_rename_journal()?;
1070 Ok(true)
1071 }
1072
1073 pub fn update_session_container_settings(
1077 &mut self,
1078 session_id: &str,
1079 cpus: Option<String>,
1080 memory: Option<String>,
1081 additional_mounts: Vec<targets::AdditionalMount>,
1082 mount_history: Vec<std::path::PathBuf>,
1083 ) -> Result<()> {
1084 ensure!(
1085 self.state.sessions.contains_key(session_id),
1086 "unknown session {session_id}"
1087 );
1088 let cpus = cpus.filter(|value| !value.trim().is_empty());
1089 let memory = memory.filter(|value| !value.trim().is_empty());
1090 let updated_at = now();
1091 crate::database::set_session_container_settings(
1092 session_id,
1093 cpus.as_deref(),
1094 memory.as_deref(),
1095 &additional_mounts,
1096 &updated_at,
1097 )?;
1098 if let Some(host) = self
1099 .config
1100 .targets
1101 .get(
1102 &self.state.sessions[session_id]
1103 .target_template_id
1104 .to_owned(),
1105 )
1106 .and_then(mj_core::config::mount_history_host)
1107 {
1108 let host = host.to_owned();
1109 crate::database::replace_mount_history(&host, &mount_history)?;
1112 crate::database::remember_mount_sources(&host, &additional_mounts)?;
1113 self.state.mount_history.insert(host.clone(), mount_history);
1114 self.state.remember_mount_sources(&host, &additional_mounts);
1115 }
1116 let record = self
1117 .state
1118 .sessions
1119 .get_mut(session_id)
1120 .expect("session was checked before updating its container settings");
1121 record.container_cpus = cpus;
1122 record.container_memory = memory;
1123 record.additional_mounts = additional_mounts;
1124 record.updated_at = updated_at;
1125 Ok(())
1126 }
1127}
1128
1129fn config_rename_journal_path() -> PathBuf {
1130 data_dir().join(CONFIG_RENAME_JOURNAL)
1131}
1132
1133fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
1134 let path = config_rename_journal_path();
1135 let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
1136 atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
1137}
1138
1139fn remove_config_rename_journal() -> Result<()> {
1140 let path = config_rename_journal_path();
1141 match fs::remove_file(&path) {
1142 Ok(()) => Ok(()),
1143 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1144 Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1145 }
1146}
1147
1148fn finish_config_map_rename<T>(
1149 entries: &mut BTreeMap<String, T>,
1150 old_id: &str,
1151 new_id: &str,
1152 kind: &str,
1153) -> Result<()> {
1154 if let Some(entry) = entries.remove(old_id) {
1155 ensure!(
1156 !entries.contains_key(new_id),
1157 "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
1158 );
1159 entries.insert(new_id.to_owned(), entry);
1160 } else {
1161 ensure!(
1162 entries.contains_key(new_id),
1163 "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
1164 );
1165 }
1166 Ok(())
1167}
1168
1169fn target_kind(locator: &targets::TargetLocator) -> &'static str {
1170 match locator {
1171 targets::TargetLocator::LocalBare { .. } => "local-bare",
1172 targets::TargetLocator::LocalPodman { .. } => "local-podman",
1173 targets::TargetLocator::LocalDocker { .. } => "local-docker",
1174 targets::TargetLocator::AppleContainer { .. } => "apple-container",
1175 targets::TargetLocator::AwsEc2 { .. } => "aws-ec2",
1176 targets::TargetLocator::SshBare { .. } => "ssh-bare",
1177 targets::TargetLocator::SshPodman { .. } => "ssh-podman",
1178 targets::TargetLocator::SshDocker { .. } => "ssh-docker",
1179 }
1180}
1181
1182fn target_profile_home(
1183 locator: &targets::TargetLocator,
1184 session_id: &str,
1185 profile: &mj_core::config::HarnessProfile,
1186) -> String {
1187 let home = match locator {
1188 targets::TargetLocator::LocalBare { worker_root }
1189 if profile.kind == mj_core::config::HarnessKind::Claude =>
1190 {
1191 Path::new(worker_root)
1192 .join("profile")
1193 .to_string_lossy()
1194 .into_owned()
1195 }
1196 targets::TargetLocator::LocalBare { .. } => profile.home.to_string_lossy().into_owned(),
1197 targets::TargetLocator::LocalPodman { .. }
1198 | targets::TargetLocator::LocalDocker { .. }
1199 | targets::TargetLocator::AppleContainer { .. }
1200 | targets::TargetLocator::SshPodman { .. }
1201 | targets::TargetLocator::SshDocker { .. } => {
1202 format!("/var/lib/hel/profiles/{session_id}")
1203 }
1204 targets::TargetLocator::AwsEc2 { .. } | targets::TargetLocator::SshBare { .. } => {
1205 format!(".local/share/hel/profiles/{session_id}")
1206 }
1207 };
1208 if profile.kind == mj_core::config::HarnessKind::Muse {
1209 let root = if matches!(locator, targets::TargetLocator::LocalBare { .. }) {
1210 mj_core::config::data_dir()
1211 .join("profiles")
1212 .join(session_id)
1213 } else {
1214 PathBuf::from(home)
1215 };
1216 root.join("muse").to_string_lossy().into_owned()
1217 } else if profile.kind == mj_core::config::HarnessKind::Zcode
1218 && !matches!(locator, targets::TargetLocator::LocalBare { .. })
1219 {
1220 Path::new(&home)
1221 .join(".zcode")
1222 .to_string_lossy()
1223 .into_owned()
1224 } else {
1225 home
1226 }
1227}
1228
1229pub fn resolve_target_input_path(
1231 target: &TargetTemplate,
1232 path: &Path,
1233 executor: &impl CommandExecutor,
1234) -> Result<PathBuf> {
1235 if !mj_core::path_input::needs_home(path)? {
1236 return Ok(path.to_path_buf());
1237 }
1238 match target {
1239 TargetTemplate::SshBare { ssh, .. }
1240 | TargetTemplate::SshPodman { ssh, .. }
1241 | TargetTemplate::SshDocker { ssh, .. } => {
1242 let mut ssh = ssh.clone();
1243 ssh.identity_file = ssh
1244 .identity_file
1245 .as_deref()
1246 .map(mj_core::path_input::expand_local)
1247 .transpose()?;
1248 let command =
1249 ssh_command_spec(&backend_ssh(&ssh), ["sh", "-c", "printf '%s' \"$HOME\""])
1250 .purpose("resolve remote home directory");
1251 let output = executor.execute(&command)?;
1252 anyhow::ensure!(
1253 output.status == 0,
1254 "Could not resolve remote home: {}",
1255 String::from_utf8_lossy(&output.stderr).trim()
1256 );
1257 let home =
1258 String::from_utf8(output.stdout).context("Remote home is not valid UTF-8")?;
1259 mj_core::path_input::expand_home(path, Some(Path::new(&home)))
1260 }
1261 _ => mj_core::path_input::expand_local(path),
1262 }
1263}
1264
1265pub(crate) fn backend_ssh(ssh: &SshConnection) -> SshTarget {
1266 let destination = match &ssh.user {
1267 Some(user) => format!("{user}@{}", ssh.host),
1268 None => ssh.host.clone(),
1269 };
1270 SshTarget {
1271 destination,
1272 ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
1273 }
1274}
1275
1276fn ssh_command_spec(
1277 ssh: &SshTarget,
1278 args: impl IntoIterator<Item = impl AsRef<str>>,
1279) -> CommandSpec {
1280 let remote = args
1281 .into_iter()
1282 .map(|arg| arg.as_ref().to_string())
1283 .collect::<Vec<_>>();
1284 let mut command_args = ssh.ssh_args.clone();
1285 command_args.push(ssh.destination.clone());
1286 command_args.push(targets::join_remote_command(&remote));
1287 CommandSpec::new("ssh", command_args)
1288}
1289
1290fn scp_command_spec(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
1291 let mut args = ssh.ssh_args.clone();
1292 if recursive {
1293 args.push("-r".into());
1294 }
1295 args.push(source.to_string_lossy().into_owned());
1296 args.push(format!("{}:{remote}", ssh.destination));
1297 CommandSpec::new("scp", args)
1298}
1299
1300fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
1301 let mut result = vec![
1307 "-o".into(),
1308 "BatchMode=yes".into(),
1309 "-o".into(),
1310 "StrictHostKeyChecking=accept-new".into(),
1311 "-o".into(),
1312 "ConnectTimeout=15".into(),
1313 ];
1314 result.extend(args.iter().cloned());
1315 if let Some(identity) = identity {
1316 result.push("-i".into());
1317 result.push(identity.to_string_lossy().into_owned());
1318 }
1319 result
1320}
1321
1322fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
1323 let output = executor.execute(&command)?;
1324 if output.status != 0 {
1325 let detail = command_error_detail(&output.stderr);
1326 if detail.is_empty() {
1327 bail!("{} failed with status {}", command.purpose, output.status);
1328 }
1329 bail!("{detail}");
1330 }
1331 Ok(output)
1332}
1333
1334fn command_error_detail(stderr: &[u8]) -> String {
1335 let reported = String::from_utf8_lossy(stderr);
1336 let reported = reported.trim();
1337 let detail = reported
1338 .rsplit_once("\nCaused by:\n")
1339 .map_or(reported, |(_, causes)| causes);
1340 let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
1341 detail
1342 .lines()
1343 .map(|line| line.strip_prefix(" ").unwrap_or(line))
1344 .collect::<Vec<_>>()
1345 .join("\n")
1346 .trim()
1347 .to_owned()
1348}
1349
1350fn now() -> String {
1351 Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1352}
1353
1354fn restore_session_after_persistence_failure(
1355 state: &mut State,
1356 session_id: &str,
1357 previous: &SessionRecord,
1358 primary: anyhow::Error,
1359 persist: impl FnOnce(&SessionRecord) -> Result<()>,
1360) -> anyhow::Error {
1361 state
1362 .sessions
1363 .insert(session_id.to_owned(), previous.clone());
1364 let restored = state
1365 .sessions
1366 .get(session_id)
1367 .expect("restored session record disappeared");
1368 match persist(restored) {
1369 Ok(()) => primary,
1370 Err(error) => primary.context(format!(
1371 "restored prior session state in memory, but failed to persist the rollback: {error:#}"
1372 )),
1373 }
1374}
1375
1376fn persist_session_record_transition_or_restore(
1377 state: &mut State,
1378 session_id: &str,
1379 previous: &SessionRecord,
1380 context: &'static str,
1381 persist: &impl Fn(&SessionRecord) -> Result<()>,
1382) -> Result<()> {
1383 let result = persist(
1384 state
1385 .sessions
1386 .get(session_id)
1387 .expect("checkpoint session disappeared before persistence"),
1388 );
1389 match result {
1390 Ok(()) => Ok(()),
1391 Err(error) => Err(restore_session_after_persistence_failure(
1392 state,
1393 session_id,
1394 previous,
1395 error.context(context),
1396 persist,
1397 )),
1398 }
1399}
1400
1401pub fn config_only_controller(config: Config) -> Controller {
1402 Controller {
1403 config,
1404 state: State::default(),
1405 }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use std::collections::BTreeMap;
1411 use std::path::Path;
1412
1413 use crate::targets::ProcessExecutor;
1414 use mj_core::config::{
1415 Config, ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, ProjectBundle,
1416 ProjectRepository, TargetTemplate,
1417 };
1418 use mj_core::state::State;
1419
1420 use super::*;
1421
1422 fn registration_config() -> Config {
1425 let mut config = Config::default();
1426 config.profiles.insert(
1427 "codex".into(),
1428 HarnessProfile {
1429 enabled: true,
1430 kind: HarnessKind::Codex,
1431 home: PathBuf::from("/home/dev/.codex"),
1432 environment: BTreeMap::new(),
1433 context_window_bytes: None,
1434 },
1435 );
1436 config.bundles.insert(
1437 "project".into(),
1438 ProjectBundle {
1439 primary_repo: "project".into(),
1440 repositories: vec![ProjectRepository {
1441 id: "project".into(),
1442 github: Some("owner/project".into()),
1443 local: None,
1444 destination: PathBuf::from("project"),
1445 git_ref: None,
1446 }],
1447 },
1448 );
1449 config.targets.insert(
1450 "podman".into(),
1451 TargetTemplate::LocalPodman {
1452 container: ConfigContainer {
1453 image: "example.invalid/hel-test:latest".into(),
1454 pull_policy: Default::default(),
1455 platform: None,
1456 cpus: None,
1457 memory: None,
1458 environment: BTreeMap::new(),
1459 workspace_storage: Default::default(),
1460 },
1461 },
1462 );
1463 config
1464 }
1465
1466 #[test]
1467 fn remote_completion_preserves_home_shorthand_and_trailing_separator() {
1468 struct CompletionExecutor;
1469 impl CommandExecutor for CompletionExecutor {
1470 fn execute(&self, command: &CommandSpec) -> Result<targets::CommandOutput> {
1471 let script = command.args.last().unwrap();
1472 let stdout = if script.contains("$HOME") {
1473 b"/remote".to_vec()
1474 } else {
1475 assert!(script.contains("'/remote/cache/'"), "{script}");
1476 b"/remote/cache/alpha/\n/remote/cache/alpine/\n".to_vec()
1477 };
1478 Ok(targets::CommandOutput {
1479 status: 0,
1480 stdout,
1481 stderr: Vec::new(),
1482 })
1483 }
1484 }
1485 let target: TargetTemplate =
1486 serde_json::from_str(r#"{"kind":"ssh-podman","host":"builder","image":"test"}"#)
1487 .unwrap();
1488 let mut config = Config::default();
1489 config.targets.insert("remote".into(), target);
1490 let controller = Controller {
1491 config,
1492 state: State::default(),
1493 };
1494 let candidates = controller
1495 .complete_mount_source("remote", "~/cache/", &CompletionExecutor)
1496 .unwrap();
1497 assert_eq!(candidates, ["~/cache/alpha/", "~/cache/alpine/"]);
1498 assert_eq!(
1499 targets::path_completion("~/cache/", &candidates).as_deref(),
1500 Some("~/cache/alp")
1501 );
1502 }
1503
1504 #[test]
1505 fn remote_path_resolution_uses_login_home_without_evaluating_suffix() {
1506 struct HomeExecutor {
1507 status: i32,
1508 home: &'static str,
1509 }
1510 impl CommandExecutor for HomeExecutor {
1511 fn execute(&self, command: &CommandSpec) -> Result<targets::CommandOutput> {
1512 assert_eq!(command.program, "ssh");
1513 let script = command.args.last().unwrap();
1514 assert!(!script.contains("touch"));
1515 assert!(script.contains("$HOME"));
1516 Ok(targets::CommandOutput {
1517 status: self.status,
1518 stdout: self.home.as_bytes().to_vec(),
1519 stderr: b"home lookup failed".to_vec(),
1520 })
1521 }
1522 }
1523 let target: TargetTemplate = serde_json::from_str(
1524 r#"{"kind":"ssh-bare","host":"builder","permissions":"guardian"}"#,
1525 )
1526 .unwrap();
1527 let path = Path::new("~/資料/$(touch nope)");
1528 assert_eq!(
1529 resolve_target_input_path(
1530 &target,
1531 path,
1532 &HomeExecutor {
1533 status: 0,
1534 home: "/remote user"
1535 }
1536 )
1537 .unwrap(),
1538 Path::new("/remote user/資料/$(touch nope)")
1539 );
1540 assert!(
1541 resolve_target_input_path(
1542 &target,
1543 path,
1544 &HomeExecutor {
1545 status: 1,
1546 home: "/remote"
1547 }
1548 )
1549 .unwrap_err()
1550 .to_string()
1551 .contains("home lookup failed")
1552 );
1553 assert!(
1554 resolve_target_input_path(
1555 &target,
1556 path,
1557 &HomeExecutor {
1558 status: 0,
1559 home: ""
1560 }
1561 )
1562 .is_err()
1563 );
1564 assert!(
1565 resolve_target_input_path(
1566 &target,
1567 path,
1568 &HomeExecutor {
1569 status: 0,
1570 home: "relative"
1571 }
1572 )
1573 .is_err()
1574 );
1575 }
1576
1577 #[test]
1578 fn bundle_creation_combines_sources_with_first_primary_and_stable_collisions() {
1579 let mut config = Config::default();
1580 let sources = vec!["example/app".into(), "other/app".into()];
1581
1582 let bundle_id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1583 let bundle = &config.bundles[&bundle_id];
1584 assert_eq!(bundle_id, "app");
1585 assert_eq!(bundle.primary_repo, "app");
1586 assert_eq!(
1587 bundle
1588 .repositories
1589 .iter()
1590 .map(|repository| repository.id.as_str())
1591 .collect::<Vec<_>>(),
1592 ["app", "app-2"]
1593 );
1594 assert_eq!(
1595 bundle
1596 .repositories
1597 .iter()
1598 .map(|repository| repository.destination.to_string_lossy().into_owned())
1599 .collect::<Vec<_>>(),
1600 ["app".to_owned(), "app-2".to_owned()]
1601 );
1602 assert_eq!(
1603 bundle.repositories[0].github.as_deref(),
1604 Some("example/app")
1605 );
1606 assert_eq!(bundle.repositories[1].github.as_deref(), Some("other/app"));
1607 }
1608
1609 #[test]
1610 fn bundle_creation_combines_local_and_github_sources_and_rejects_local_aliases() {
1611 let directory = tempfile::tempdir().unwrap();
1612 let root = directory.path().join("app");
1613 let output = mj_core::subprocess::run_capturing_stdout(
1614 std::process::Command::new("git").arg("init").arg(&root),
1615 )
1616 .unwrap();
1617 assert!(output.status.success(), "{output:?}");
1618 let nested = root.join("nested");
1619 fs::create_dir(&nested).unwrap();
1620 let mut config = Config::default();
1621 let sources = vec![root.to_str().unwrap().into(), "example/shared".into()];
1622 let id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1623 let bundle = &config.bundles[&id];
1624 assert_eq!(
1625 bundle.primary().unwrap().local,
1626 Some(root.canonicalize().unwrap())
1627 );
1628 assert_eq!(
1629 bundle.repositories[1].github.as_deref(),
1630 Some("example/shared")
1631 );
1632 let before = config.clone();
1633 let aliases = vec![
1634 root.to_str().unwrap().into(),
1635 nested.to_str().unwrap().into(),
1636 ];
1637 let error = create_bundle_from_sources_in_config(&mut config, &aliases).unwrap_err();
1638 assert!(
1639 error.to_string().contains("duplicate repository source"),
1640 "{error:#}"
1641 );
1642 assert_eq!(config, before);
1643 }
1644
1645 #[test]
1646 fn bundle_creation_rejects_duplicate_normalized_sources_atomically() {
1647 let mut config = Config::default();
1648 let before = config.clone();
1649 let sources = vec![
1650 "example/app".into(),
1651 "https://github.com/EXAMPLE/APP.git".into(),
1652 ];
1653
1654 let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1655 assert!(error.to_string().contains("duplicate repository source"));
1656 assert_eq!(config, before);
1657 }
1658
1659 #[test]
1660 fn bundle_creation_validates_every_source_before_mutating_config() {
1661 let mut config = Config::default();
1662 let before = config.clone();
1663 let invalid_directory = tempfile::tempdir().unwrap();
1664 let sources = vec![
1665 "example/app".into(),
1666 invalid_directory.path().to_string_lossy().into_owned(),
1667 ];
1668
1669 let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1670 assert!(error.to_string().contains("not a Git repository"));
1671 assert_eq!(config, before);
1672 }
1673
1674 #[test]
1675 fn bundle_creation_reuses_an_exact_source_set_and_rejects_obsolete_pins() {
1676 let mut config = Config::default();
1677 config.bundles.insert(
1678 "all".into(),
1679 ProjectBundle {
1680 primary_repo: "app".into(),
1681 repositories: vec![
1682 ProjectRepository {
1683 id: "app".into(),
1684 github: Some("example/app".into()),
1685 local: None,
1686 destination: "app".into(),
1687 git_ref: None,
1688 },
1689 ProjectRepository {
1690 id: "shared".into(),
1691 github: Some("example/shared".into()),
1692 local: None,
1693 destination: "shared".into(),
1694 git_ref: None,
1695 },
1696 ],
1697 },
1698 );
1699
1700 let one_source = vec!["example/app".into()];
1701 let created = create_bundle_from_sources_in_config(&mut config, &one_source).unwrap();
1702 assert_eq!(created, "app");
1703 assert_eq!(config.bundles[&created].repositories.len(), 1);
1704 assert_eq!(
1705 create_bundle_from_sources_in_config(&mut config, &one_source).unwrap(),
1706 "app"
1707 );
1708
1709 let exact_sources = vec!["example/app".into(), "example/shared".into()];
1710 assert_eq!(
1711 create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap(),
1712 "all"
1713 );
1714 assert_eq!(config.bundles.len(), 2);
1715 config.bundles.get_mut("all").unwrap().repositories[0].git_ref = Some("release".into());
1716 let before = config.clone();
1717 let error = create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap_err();
1718 assert!(format!("{error:#}").contains("git_ref is no longer supported"));
1719 assert_eq!(config, before);
1720 }
1721
1722 fn launch_options(additional_mounts: Vec<AdditionalMount>) -> SessionLaunchOptions {
1723 SessionLaunchOptions {
1724 mjolnir_subagents: None,
1725 create_managed_worktree: None,
1726 initial_prompt: None,
1727 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1728 additional_mounts,
1729 allow_dirty_local: false,
1730 resource_allocation: None,
1731 project_directory: None,
1732 session_title_override: None,
1733 }
1734 }
1735
1736 #[test]
1737 fn registration_rejects_a_disabled_profile_before_persisting() {
1738 let mut config = registration_config();
1739 config.profiles.get_mut("codex").unwrap().enabled = false;
1740 let mut controller = Controller {
1741 config,
1742 state: State::default(),
1743 };
1744
1745 let error = controller
1746 .register_session_with_resources(
1747 "codex",
1748 "project",
1749 "podman",
1750 "disabled",
1751 launch_options(Vec::new()),
1752 )
1753 .unwrap_err();
1754
1755 assert!(error.to_string().contains("disabled"));
1756 assert!(controller.state.sessions.is_empty());
1757 }
1758
1759 #[test]
1760 fn deepseek_registration_rejects_more_than_one_workspace_root_before_persisting() {
1761 let mut config = registration_config();
1762 config.profiles.get_mut("codex").unwrap().kind = HarnessKind::Deepseek;
1763 let second = config.bundles["project"].repositories[0].clone();
1764 config
1765 .bundles
1766 .get_mut("project")
1767 .unwrap()
1768 .repositories
1769 .push(mj_core::config::ProjectRepository {
1770 id: "second".into(),
1771 destination: "second".into(),
1772 ..second
1773 });
1774 let mut controller = Controller {
1775 config,
1776 state: State::default(),
1777 };
1778
1779 let error = controller
1780 .register_session_with_resources(
1781 "codex",
1782 "project",
1783 "podman",
1784 "unsupported",
1785 launch_options(Vec::new()),
1786 )
1787 .unwrap_err();
1788
1789 assert!(error.to_string().contains("one workspace root"));
1790 assert!(controller.state.sessions.is_empty());
1791 }
1792
1793 fn run_registration_child(marker: &str, test: &str, data_directory: &Path) {
1796 let output = std::process::Command::new(std::env::current_exe().unwrap())
1797 .args([
1798 "--exact",
1799 &format!("controller::tests::{test}"),
1800 "--nocapture",
1801 ])
1802 .env(marker, "1")
1803 .env("MJ_DATA_DIR", data_directory)
1804 .env("MJ_CONFIG_DIR", data_directory)
1805 .output()
1806 .unwrap();
1807 assert!(
1808 output.status.success(),
1809 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1810 String::from_utf8_lossy(&output.stdout),
1811 String::from_utf8_lossy(&output.stderr)
1812 );
1813 }
1814
1815 #[test]
1816 fn registration_saves_the_initial_task_before_provisioning() {
1817 const MARKER: &str = "MJ_TEST_INITIAL_TASK_CHILD";
1818 if std::env::var_os(MARKER).is_none() {
1819 let directory = tempfile::tempdir().unwrap();
1820 run_registration_child(
1821 MARKER,
1822 "registration_saves_the_initial_task_before_provisioning",
1823 directory.path(),
1824 );
1825 return;
1826 }
1827 let _writer = crate::database::install_isolated_test_writer();
1828 let mut controller = Controller {
1829 config: registration_config(),
1830 state: State::default(),
1831 };
1832 let prompt = format!(
1833 "Initial task\n{}\n\tPreserve indentation and λ",
1834 "x".repeat(70_000)
1835 );
1836 let mut options = launch_options(Vec::new());
1837 options.initial_prompt = Some(prompt.clone());
1838 let id = controller
1839 .register_session_with_resources("codex", "project", "podman", "fresh task", options)
1840 .unwrap();
1841 let saved = crate::database::load_state().unwrap();
1842 assert_eq!(saved.sessions[&id].draft_input, prompt);
1843 assert_eq!(saved.sessions[&id].state, SessionState::Provisioning);
1844 crate::database::set_session_draft_input(&id, "a newer draft").unwrap();
1845 crate::database::clear_session_draft_input_if_matches(&id, &prompt).unwrap();
1846 assert_eq!(
1847 crate::database::load_state().unwrap().sessions[&id].draft_input,
1848 "a newer draft"
1849 );
1850 crate::database::clear_session_draft_input_if_matches(&id, "a newer draft").unwrap();
1851 assert!(
1852 crate::database::load_state().unwrap().sessions[&id]
1853 .draft_input
1854 .is_empty()
1855 );
1856 }
1857
1858 const UNPERSISTABLE_SESSION_CHILD: &str = "MJ_TEST_UNPERSISTABLE_SESSION_CHILD";
1859
1860 #[test]
1861 fn missing_bundle_does_not_block_controller_or_other_sessions() {
1862 const CHILD: &str = "MJ_TEST_MISSING_BUNDLE_CHILD";
1863 if std::env::var_os(CHILD).is_none() {
1864 let directory = tempfile::tempdir().unwrap();
1865 run_registration_child(
1866 CHILD,
1867 "missing_bundle_does_not_block_controller_or_other_sessions",
1868 directory.path(),
1869 );
1870 return;
1871 }
1872 let _writer = crate::database::install_isolated_test_writer();
1873 let mut controller = Controller {
1874 config: registration_config(),
1875 state: State::default(),
1876 };
1877 let bundle = controller.config.bundles["project"].clone();
1878 controller
1879 .config
1880 .bundles
1881 .insert("other".into(), bundle.clone());
1882 controller.config.save().unwrap();
1883 let affected = controller
1884 .register_session_with_resources(
1885 "codex",
1886 "project",
1887 "podman",
1888 "affected",
1889 launch_options(Vec::new()),
1890 )
1891 .unwrap();
1892 let healthy = controller
1893 .register_session_with_resources(
1894 "codex",
1895 "other",
1896 "podman",
1897 "healthy",
1898 launch_options(Vec::new()),
1899 )
1900 .unwrap();
1901 controller.config.bundles.remove("project");
1902 controller.config.save().unwrap();
1903 let loaded = Controller::load().unwrap();
1904 assert_eq!(loaded.state.sessions.len(), 2);
1905 assert!(
1906 loaded.state.sessions[&healthy]
1907 .configuration_issue(&loaded.config)
1908 .is_none()
1909 );
1910 let issue = loaded.reconnect_command(&affected).unwrap_err().to_string();
1911 assert!(issue.contains("missing bundle"), "{issue}");
1912 assert!(issue.contains("config.toml"), "{issue}");
1913 assert_eq!(
1915 loaded.state.sessions[&affected],
1916 controller.state.sessions[&affected]
1917 );
1918 Config::update(|config| {
1919 config.bundles.insert("project".into(), bundle);
1920 Ok(())
1921 })
1922 .unwrap();
1923 let repaired = Controller::load().unwrap();
1924 assert!(
1925 repaired.state.sessions[&affected]
1926 .configuration_issue(&repaired.config)
1927 .is_none()
1928 );
1929 }
1930
1931 const CONFIG_ID_RENAME_CHILD: &str = "MJ_TEST_CONFIG_ID_RENAME_CHILD";
1932
1933 #[test]
1934 fn configuration_id_rename_rewrites_durable_session_references() {
1935 if std::env::var_os(CONFIG_ID_RENAME_CHILD).is_none() {
1936 let directory = tempfile::tempdir().unwrap();
1937 run_registration_child(
1938 CONFIG_ID_RENAME_CHILD,
1939 "configuration_id_rename_rewrites_durable_session_references",
1940 directory.path(),
1941 );
1942 return;
1943 }
1944 let _writer = crate::database::install_isolated_test_writer();
1946
1947 let mut controller = Controller {
1948 config: registration_config(),
1949 state: State::default(),
1950 };
1951 controller.config.save().unwrap();
1952 let session_id = controller
1953 .register_session_with_resources(
1954 "codex",
1955 "project",
1956 "podman",
1957 "rename references",
1958 launch_options(Vec::new()),
1959 )
1960 .unwrap();
1961
1962 controller
1963 .rename_profile_id("codex", "codex-renamed")
1964 .unwrap();
1965 controller
1966 .rename_target_id("podman", "podman-renamed")
1967 .unwrap();
1968
1969 let loaded = Controller::load().unwrap();
1970 let session = &loaded.state.sessions[&session_id];
1971 assert_eq!(session.last_profile, "codex-renamed");
1972 assert_eq!(session.target_template_id, "podman-renamed");
1973 assert!(loaded.config.profiles.contains_key("codex-renamed"));
1974 assert!(loaded.config.targets.contains_key("podman-renamed"));
1975 assert!(!config_rename_journal_path().exists());
1976 }
1977
1978 #[test]
1979 fn a_session_the_database_rejects_is_never_left_in_memory() {
1980 if std::env::var_os(UNPERSISTABLE_SESSION_CHILD).is_none() {
1981 let directory = tempfile::tempdir().unwrap();
1982 run_registration_child(
1983 UNPERSISTABLE_SESSION_CHILD,
1984 "a_session_the_database_rejects_is_never_left_in_memory",
1985 directory.path(),
1986 );
1987 return;
1988 }
1989 let _writer = crate::database::install_isolated_test_writer();
1991
1992 let mut controller = Controller {
1993 config: registration_config(),
1994 state: State::default(),
1995 };
1996 controller
2002 .register_session_with_resources(
2003 "codex",
2004 "project",
2005 "podman",
2006 "first",
2007 launch_options(Vec::new()),
2008 )
2009 .expect("a healthy store registers a session");
2010 rusqlite::Connection::open(crate::database::database_path())
2011 .unwrap()
2012 .execute_batch("DROP TABLE sessions")
2013 .unwrap();
2014
2015 let error = controller
2016 .register_session_with_resources(
2017 "codex",
2018 "project",
2019 "podman",
2020 "unpersistable",
2021 launch_options(Vec::new()),
2022 )
2023 .expect_err("a store that rejects the write cannot register a session");
2024 assert!(
2025 format!("{error:#}").contains("sessions"),
2026 "unexpected error: {error:#}"
2027 );
2028 assert_eq!(
2029 controller.state.sessions.len(),
2030 1,
2031 "a session the database never accepted stayed in controller memory"
2032 );
2033 assert!(
2034 controller
2035 .state
2036 .sessions
2037 .values()
2038 .all(|session| session.title != "unpersistable"),
2039 "the rejected session is the one that stayed"
2040 );
2041 }
2042
2043 const MOUNT_HISTORY_FAILURE_CHILD: &str = "MJ_TEST_MOUNT_HISTORY_FAILURE_CHILD";
2044
2045 const CONTAINER_SIZE_HISTORY_CHILD: &str = "MJ_TEST_CONTAINER_SIZE_HISTORY_CHILD";
2046
2047 #[test]
2048 fn registration_remembers_launch_size_but_session_overrides_do_not_replace_it() {
2049 if std::env::var_os(CONTAINER_SIZE_HISTORY_CHILD).is_none() {
2050 let directory = tempfile::tempdir().unwrap();
2051 run_registration_child(
2052 CONTAINER_SIZE_HISTORY_CHILD,
2053 "registration_remembers_launch_size_but_session_overrides_do_not_replace_it",
2054 directory.path(),
2055 );
2056 return;
2057 }
2058 let _writer = crate::database::install_isolated_test_writer();
2060
2061 let mut controller = Controller {
2062 config: registration_config(),
2063 state: State::default(),
2064 };
2065 let mut options = launch_options(Vec::new());
2066 options.resource_allocation = Some(SessionResourceAllocation::Container {
2067 cpus: 12,
2068 memory_bytes: 48 * 1024 * 1024 * 1024,
2069 });
2070 let id = controller
2071 .register_session_with_resources("codex", "project", "podman", "sized", options)
2072 .unwrap();
2073 let expected = HostContainerSize {
2074 cpus: 12,
2075 memory_bytes: 48 * 1024 * 1024 * 1024,
2076 };
2077 assert_eq!(controller.state.container_sizes["local"], expected);
2078 assert_eq!(
2079 crate::database::load_state_migrating()
2080 .unwrap()
2081 .container_sizes["local"],
2082 expected
2083 );
2084
2085 controller
2086 .update_session_container_settings(
2087 &id,
2088 Some("2".into()),
2089 Some("4g".into()),
2090 Vec::new(),
2091 Vec::new(),
2092 )
2093 .unwrap();
2094 assert_eq!(
2095 crate::database::load_state_migrating()
2096 .unwrap()
2097 .container_sizes["local"],
2098 expected
2099 );
2100 }
2101
2102 #[test]
2103 fn a_failed_mount_history_write_does_not_fail_the_registered_session() {
2104 if std::env::var_os(MOUNT_HISTORY_FAILURE_CHILD).is_none() {
2105 let directory = tempfile::tempdir().unwrap();
2106 run_registration_child(
2107 MOUNT_HISTORY_FAILURE_CHILD,
2108 "a_failed_mount_history_write_does_not_fail_the_registered_session",
2109 directory.path(),
2110 );
2111 return;
2112 }
2113 let _writer = crate::database::install_isolated_test_writer();
2115
2116 let mut controller = Controller {
2117 config: registration_config(),
2118 state: State::default(),
2119 };
2120 controller
2122 .register_session_with_resources(
2123 "codex",
2124 "project",
2125 "podman",
2126 "first",
2127 launch_options(Vec::new()),
2128 )
2129 .expect("a healthy store registers a session");
2130 let database = crate::database::database_path();
2131 rusqlite::Connection::open(&database)
2132 .unwrap()
2133 .execute_batch("DROP TABLE mount_history")
2134 .unwrap();
2135
2136 let id = controller
2137 .register_session_with_resources(
2138 "codex",
2139 "project",
2140 "podman",
2141 "attached",
2142 launch_options(vec![AdditionalMount {
2143 source: PathBuf::from("/host/models"),
2144 destination: PathBuf::from("/mnt/models"),
2145 read_only: false,
2146 }]),
2147 )
2148 .expect("a suggestion list that cannot be written must not fail a registration");
2149
2150 let stored: i64 = rusqlite::Connection::open(&database)
2151 .unwrap()
2152 .query_row(
2153 "SELECT count(*) FROM sessions WHERE session_id = ?1",
2154 [&id],
2155 |row| row.get(0),
2156 )
2157 .unwrap();
2158 assert_eq!(stored, 1, "the registered session was not committed");
2159 assert!(
2160 controller.state.mount_history.is_empty(),
2161 "controller memory remembered mount sources the database never stored"
2162 );
2163 }
2164
2165 #[test]
2166 fn command_errors_report_the_root_cause_without_worker_wrappers() {
2167 let stderr = b"Error: restore target checkpoint failed with status 1: Error: restore repository \"bifrost\"\n\nCaused by:\n checkpoint base b41dc78 is absent from configured source\n repository may have moved\n";
2168
2169 assert_eq!(
2170 command_error_detail(stderr),
2171 "checkpoint base b41dc78 is absent from configured source\nrepository may have moved"
2172 );
2173 }
2174
2175 #[test]
2176 fn controller_store_lock_excludes_a_second_process_owner() {
2177 let directory = tempfile::tempdir().unwrap();
2178 let first = ControllerStoreGuard::acquire_at(directory.path()).unwrap();
2179 run_controller_lock_probe(directory.path(), true);
2180 drop(first);
2181 run_controller_lock_probe(directory.path(), false);
2182 }
2183 fn run_controller_lock_probe(directory: &Path, expect_locked: bool) {
2184 let output = std::process::Command::new(std::env::current_exe().unwrap())
2185 .args([
2186 "--exact",
2187 "controller::tests::controller_store_lock_subprocess_probe",
2188 "--nocapture",
2189 ])
2190 .env("MJ_CONTROLLER_LOCK_PROBE", directory)
2191 .env(
2192 "MJ_CONTROLLER_LOCK_EXPECTED",
2193 if expect_locked { "locked" } else { "available" },
2194 )
2195 .output()
2196 .unwrap();
2197 assert!(
2198 output.status.success(),
2199 "controller lock subprocess failed:\nstdout:\n{}\nstderr:\n{}",
2200 String::from_utf8_lossy(&output.stdout),
2201 String::from_utf8_lossy(&output.stderr)
2202 );
2203 }
2204 #[test]
2205 fn controller_store_lock_subprocess_probe() {
2206 let Some(directory) = std::env::var_os("MJ_CONTROLLER_LOCK_PROBE") else {
2207 return;
2208 };
2209 let expected = std::env::var("MJ_CONTROLLER_LOCK_EXPECTED").unwrap();
2210 let acquired = ControllerStoreGuard::acquire_at(Path::new(&directory));
2211 match expected.as_str() {
2212 "locked" => {
2213 let error = acquired.expect_err("a second process acquired the controller store");
2214 assert!(error.to_string().contains("another Mjolnir controller"));
2215 }
2216 "available" => {
2217 acquired.expect("released controller store stayed locked");
2218 }
2219 value => panic!("unexpected lock probe expectation {value:?}"),
2220 }
2221 }
2222 #[test]
2223 fn local_mount_source_must_be_an_existing_directory() {
2224 let directory = tempfile::tempdir().unwrap();
2225 let file = directory.path().join("file");
2226 std::fs::write(&file, "not a directory").unwrap();
2227 let mut config = Config::default();
2228 config.targets.insert(
2229 "local".into(),
2230 TargetTemplate::LocalPodman {
2231 container: ConfigContainer {
2232 image: "ubuntu:24.04".into(),
2233 pull_policy: Default::default(),
2234 platform: None,
2235 cpus: None,
2236 memory: None,
2237 environment: BTreeMap::new(),
2238 workspace_storage: Default::default(),
2239 },
2240 },
2241 );
2242 let controller = Controller {
2243 config,
2244 state: State::default(),
2245 };
2246
2247 assert!(
2248 controller
2249 .validate_mount_source("local", directory.path(), &ProcessExecutor)
2250 .is_ok()
2251 );
2252 for invalid in [file, directory.path().join("missing")] {
2253 let error = controller
2254 .validate_mount_source("local", &invalid, &ProcessExecutor)
2255 .unwrap_err();
2256 assert!(
2257 error
2258 .to_string()
2259 .contains("does not exist or is not a directory")
2260 );
2261 }
2262 }
2263}