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