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 matches!(
770 profile.kind,
771 mj_core::config::HarnessKind::Deepseek | mj_core::config::HarnessKind::Muse
772 ) && (!additional_mounts.is_empty()
773 || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
774 {
775 bail!(
776 "{} ACP supports one workspace root; use a single-repository bundle without attached directories",
777 profile.kind.display_name()
778 );
779 }
780 if let Some(bundle) = bundle {
781 for repository in &bundle.repositories {
782 mj_core::remote_git::resolve_repository(
783 repository,
784 &targets::CancellableProcessExecutor::with_timeout(
785 std::time::Duration::from_secs(15),
786 ),
787 )
788 .with_context(|| format!("repository {:?}", repository.id))?;
789 }
790 }
791 validate_resource_allocation(template, resource_allocation.as_ref())?;
792 let selected_container_size =
793 selected_host_container_size(template, resource_allocation.as_ref());
794 if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
795 bail!("attached resources are unsupported for this target");
796 }
797 targets::validate_additional_mounts(&additional_mounts)?;
798 let id = new_session_id()?;
799 let now = now();
800 let record = SessionRecord {
801 create_managed_worktree,
802 mjolnir_subagents,
803 archived: false,
804 container_cpus: None,
805 container_memory: None,
806 id: id.clone(),
807 workspace_id,
808 title: title.into(),
809 harness_kind: profile.kind,
810 last_profile: profile_id.to_string(),
811 bundle_id: bundle_id.to_string(),
812 project_directory,
813 managed_worktree: None,
814 target_template_id: target_id.to_string(),
815 resource_allocation,
816 additional_mounts: additional_mounts.clone(),
817 state: SessionState::Provisioning,
818 target: None,
819 native_session_id: None,
820 acp_session_title: None,
821 session_title_override,
822 created_at: now.clone(),
823 updated_at: now,
824 viewed_through_event_ordinal: 0,
825 draft_input: initial_prompt.unwrap_or_default(),
826 last_error: None,
827 last_checkpoint_error: None,
828 checkpoint: None,
829 };
830 if let Some((host, size)) = selected_container_size.as_ref() {
834 crate::database::save_session_with_container_size(&record, host, *size)?;
835 } else {
836 crate::database::save_session(&record)?;
837 }
838 self.state.sessions.insert(id.clone(), record);
839 if let Some((host, size)) = selected_container_size {
840 self.state.remember_container_size(&host, size);
841 }
842 if let Some(host) = mount_history_host(template) {
843 match crate::database::remember_mount_sources(host, &additional_mounts) {
847 Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
848 Err(error) => tracing::warn!(
849 session_id = id,
850 error = format!("{error:#}"),
851 "could not remember the attached resource directories for later suggestions"
852 ),
853 }
854 }
855 Ok(id)
856 }
857
858 pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
859 let title = normalize_session_title(title).context("session name cannot be empty")?;
860 ensure!(
861 self.state.sessions.contains_key(session_id),
862 "unknown session {session_id}"
863 );
864 let updated_at = now();
865 crate::database::set_session_title_override(session_id, &title, &updated_at)?;
866 let record = self
867 .state
868 .sessions
869 .get_mut(session_id)
870 .expect("session was checked before updating its title");
871 record.session_title_override = Some(title.clone());
872 record.updated_at = updated_at;
873 Ok(title)
874 }
875
876 pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
877 mj_core::config::validate_id("profile", new_id)?;
878 if old_id == new_id {
879 ensure!(
880 self.config.profiles.contains_key(old_id),
881 "unknown profile {old_id:?}"
882 );
883 return Ok(());
884 }
885 let journal = ConfigRenameJournal {
886 kind: ConfigRenameKind::Profile,
887 old_id: old_id.to_owned(),
888 new_id: new_id.to_owned(),
889 };
890 write_config_rename_journal(&journal)?;
891 let (config, ()) = match Config::update(|config| {
892 ensure!(
893 config.profiles.contains_key(old_id),
894 "unknown profile {old_id:?}"
895 );
896 ensure!(
897 !config.profiles.contains_key(new_id),
898 "profile {new_id:?} already exists"
899 );
900 let profile = config
901 .profiles
902 .remove(old_id)
903 .expect("profile was checked in the transaction");
904 config.profiles.insert(new_id.to_owned(), profile);
905 Ok(())
906 }) {
907 Ok(result) => result,
908 Err(error) => {
909 remove_config_rename_journal()
910 .context("remove profile rename journal after config save failed")?;
911 return Err(error).context("save renamed profile configuration");
912 }
913 };
914 self.config = config;
915 mj_core::test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
916 if let Err(error) = crate::database::rename_profile_references(old_id, new_id) {
917 let restore = Config::update(|config| {
918 let profile = config
919 .profiles
920 .remove(new_id)
921 .with_context(|| format!("renamed profile {new_id:?} is missing"))?;
922 ensure!(
923 !config.profiles.contains_key(old_id),
924 "cannot restore profile rename: both {old_id:?} and {new_id:?} exist"
925 );
926 config.profiles.insert(old_id.to_owned(), profile);
927 Ok(())
928 });
929 let restored = match restore {
930 Ok((config, ())) => config,
931 Err(restore_error) => {
932 return Err(error).context(format!(
933 "rename profile references; additionally failed to restore config: {restore_error:#}"
934 ));
935 }
936 };
937 self.config = restored;
938 if let Err(restore_error) = remove_config_rename_journal() {
939 return Err(error).context(format!(
940 "rename profile references; additionally failed to remove rename journal: {restore_error:#}"
941 ));
942 }
943 return Err(error).context("rename profile references");
944 }
945 for session in self.state.sessions.values_mut() {
946 if session.last_profile == old_id {
947 session.last_profile = new_id.to_owned();
948 }
949 }
950 remove_config_rename_journal()?;
951 Ok(())
952 }
953
954 pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
955 mj_core::config::validate_id("target template", new_id)?;
956 if old_id == new_id {
957 ensure!(
958 self.config.targets.contains_key(old_id),
959 "unknown target {old_id:?}"
960 );
961 return Ok(());
962 }
963 let journal = ConfigRenameJournal {
964 kind: ConfigRenameKind::Target,
965 old_id: old_id.to_owned(),
966 new_id: new_id.to_owned(),
967 };
968 write_config_rename_journal(&journal)?;
969 let (config, ()) = match Config::update(|config| {
970 ensure!(
971 config.targets.contains_key(old_id),
972 "unknown target {old_id:?}"
973 );
974 ensure!(
975 !config.targets.contains_key(new_id),
976 "target {new_id:?} already exists"
977 );
978 let target = config
979 .targets
980 .remove(old_id)
981 .expect("target was checked in the transaction");
982 config.targets.insert(new_id.to_owned(), target);
983 Ok(())
984 }) {
985 Ok(result) => result,
986 Err(error) => {
987 remove_config_rename_journal()
988 .context("remove target rename journal after config save failed")?;
989 return Err(error).context("save renamed target configuration");
990 }
991 };
992 self.config = config;
993 mj_core::test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
994 if let Err(error) = crate::database::rename_target_references(old_id, new_id) {
995 let restore = Config::update(|config| {
996 let target = config
997 .targets
998 .remove(new_id)
999 .with_context(|| format!("renamed target {new_id:?} is missing"))?;
1000 ensure!(
1001 !config.targets.contains_key(old_id),
1002 "cannot restore target rename: both {old_id:?} and {new_id:?} exist"
1003 );
1004 config.targets.insert(old_id.to_owned(), target);
1005 Ok(())
1006 });
1007 let restored = match restore {
1008 Ok((config, ())) => config,
1009 Err(restore_error) => {
1010 return Err(error).context(format!(
1011 "rename target references; additionally failed to restore config: {restore_error:#}"
1012 ));
1013 }
1014 };
1015 self.config = restored;
1016 if let Err(restore_error) = remove_config_rename_journal() {
1017 return Err(error).context(format!(
1018 "rename target references; additionally failed to remove rename journal: {restore_error:#}"
1019 ));
1020 }
1021 return Err(error).context("rename target references");
1022 }
1023 for session in self.state.sessions.values_mut() {
1024 if session.target_template_id == old_id {
1025 session.target_template_id = new_id.to_owned();
1026 }
1027 }
1028 remove_config_rename_journal()?;
1029 Ok(())
1030 }
1031
1032 pub fn recover_config_id_rename() -> Result<bool> {
1036 let path = config_rename_journal_path();
1037 let body = match fs::read(&path) {
1038 Ok(body) => body,
1039 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1040 Err(error) => return Err(error).context(format!("read {}", path.display())),
1041 };
1042 let journal: ConfigRenameJournal =
1043 serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
1044 match journal.kind {
1045 ConfigRenameKind::Profile => {
1046 Config::update(|config| {
1047 finish_config_map_rename(
1048 &mut config.profiles,
1049 &journal.old_id,
1050 &journal.new_id,
1051 "profile",
1052 )?;
1053 Ok(())
1054 })?;
1055 crate::database::rename_profile_references(&journal.old_id, &journal.new_id)?;
1056 }
1057 ConfigRenameKind::Target => {
1058 Config::update(|config| {
1059 finish_config_map_rename(
1060 &mut config.targets,
1061 &journal.old_id,
1062 &journal.new_id,
1063 "target",
1064 )?;
1065 Ok(())
1066 })?;
1067 crate::database::rename_target_references(&journal.old_id, &journal.new_id)?;
1068 }
1069 }
1070 remove_config_rename_journal()?;
1071 Ok(true)
1072 }
1073
1074 pub fn update_session_container_settings(
1078 &mut self,
1079 session_id: &str,
1080 cpus: Option<String>,
1081 memory: Option<String>,
1082 additional_mounts: Vec<targets::AdditionalMount>,
1083 mount_history: Vec<std::path::PathBuf>,
1084 ) -> Result<()> {
1085 ensure!(
1086 self.state.sessions.contains_key(session_id),
1087 "unknown session {session_id}"
1088 );
1089 let cpus = cpus.filter(|value| !value.trim().is_empty());
1090 let memory = memory.filter(|value| !value.trim().is_empty());
1091 let updated_at = now();
1092 crate::database::set_session_container_settings(
1093 session_id,
1094 cpus.as_deref(),
1095 memory.as_deref(),
1096 &additional_mounts,
1097 &updated_at,
1098 )?;
1099 if let Some(host) = self
1100 .config
1101 .targets
1102 .get(
1103 &self.state.sessions[session_id]
1104 .target_template_id
1105 .to_owned(),
1106 )
1107 .and_then(mj_core::config::mount_history_host)
1108 {
1109 let host = host.to_owned();
1110 crate::database::replace_mount_history(&host, &mount_history)?;
1113 crate::database::remember_mount_sources(&host, &additional_mounts)?;
1114 self.state.mount_history.insert(host.clone(), mount_history);
1115 self.state.remember_mount_sources(&host, &additional_mounts);
1116 }
1117 let record = self
1118 .state
1119 .sessions
1120 .get_mut(session_id)
1121 .expect("session was checked before updating its container settings");
1122 record.container_cpus = cpus;
1123 record.container_memory = memory;
1124 record.additional_mounts = additional_mounts;
1125 record.updated_at = updated_at;
1126 Ok(())
1127 }
1128}
1129
1130fn config_rename_journal_path() -> PathBuf {
1131 data_dir().join(CONFIG_RENAME_JOURNAL)
1132}
1133
1134fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
1135 let path = config_rename_journal_path();
1136 let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
1137 atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
1138}
1139
1140fn remove_config_rename_journal() -> Result<()> {
1141 let path = config_rename_journal_path();
1142 match fs::remove_file(&path) {
1143 Ok(()) => Ok(()),
1144 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1145 Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1146 }
1147}
1148
1149fn finish_config_map_rename<T>(
1150 entries: &mut BTreeMap<String, T>,
1151 old_id: &str,
1152 new_id: &str,
1153 kind: &str,
1154) -> Result<()> {
1155 if let Some(entry) = entries.remove(old_id) {
1156 ensure!(
1157 !entries.contains_key(new_id),
1158 "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
1159 );
1160 entries.insert(new_id.to_owned(), entry);
1161 } else {
1162 ensure!(
1163 entries.contains_key(new_id),
1164 "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
1165 );
1166 }
1167 Ok(())
1168}
1169
1170fn target_kind(locator: &targets::TargetLocator) -> &'static str {
1171 match locator {
1172 targets::TargetLocator::LocalBare { .. } => "local-bare",
1173 targets::TargetLocator::LocalPodman { .. } => "local-podman",
1174 targets::TargetLocator::LocalDocker { .. } => "local-docker",
1175 targets::TargetLocator::AppleContainer { .. } => "apple-container",
1176 targets::TargetLocator::AwsEc2 { .. } => "aws-ec2",
1177 targets::TargetLocator::SshBare { .. } => "ssh-bare",
1178 targets::TargetLocator::SshPodman { .. } => "ssh-podman",
1179 targets::TargetLocator::SshDocker { .. } => "ssh-docker",
1180 }
1181}
1182
1183fn target_profile_home(
1184 locator: &targets::TargetLocator,
1185 session_id: &str,
1186 profile: &mj_core::config::HarnessProfile,
1187) -> String {
1188 let home = match locator {
1189 targets::TargetLocator::LocalBare { worker_root }
1190 if profile.kind == mj_core::config::HarnessKind::Claude =>
1191 {
1192 Path::new(worker_root)
1193 .join("profile")
1194 .to_string_lossy()
1195 .into_owned()
1196 }
1197 targets::TargetLocator::LocalBare { .. } => profile.home.to_string_lossy().into_owned(),
1198 targets::TargetLocator::LocalPodman { .. }
1199 | targets::TargetLocator::LocalDocker { .. }
1200 | targets::TargetLocator::AppleContainer { .. }
1201 | targets::TargetLocator::SshPodman { .. }
1202 | targets::TargetLocator::SshDocker { .. } => {
1203 format!("/var/lib/hel/profiles/{session_id}")
1204 }
1205 targets::TargetLocator::AwsEc2 { .. } | targets::TargetLocator::SshBare { .. } => {
1206 format!(".local/share/hel/profiles/{session_id}")
1207 }
1208 };
1209 if profile.kind == mj_core::config::HarnessKind::Muse {
1210 let root = if matches!(locator, targets::TargetLocator::LocalBare { .. }) {
1211 mj_core::config::data_dir()
1212 .join("profiles")
1213 .join(session_id)
1214 } else {
1215 PathBuf::from(home)
1216 };
1217 root.join("muse").to_string_lossy().into_owned()
1218 } else if profile.kind == mj_core::config::HarnessKind::Zcode
1219 && !matches!(locator, targets::TargetLocator::LocalBare { .. })
1220 {
1221 Path::new(&home)
1222 .join(".zcode")
1223 .to_string_lossy()
1224 .into_owned()
1225 } else {
1226 home
1227 }
1228}
1229
1230pub fn resolve_target_input_path(
1232 target: &TargetTemplate,
1233 path: &Path,
1234 executor: &impl CommandExecutor,
1235) -> Result<PathBuf> {
1236 if !mj_core::path_input::needs_home(path)? {
1237 return Ok(path.to_path_buf());
1238 }
1239 match target {
1240 TargetTemplate::SshBare { ssh, .. }
1241 | TargetTemplate::SshPodman { ssh, .. }
1242 | TargetTemplate::SshDocker { ssh, .. } => {
1243 let mut ssh = ssh.clone();
1244 ssh.identity_file = ssh
1245 .identity_file
1246 .as_deref()
1247 .map(mj_core::path_input::expand_local)
1248 .transpose()?;
1249 let command =
1250 ssh_command_spec(&backend_ssh(&ssh), ["sh", "-c", "printf '%s' \"$HOME\""])
1251 .purpose("resolve remote home directory");
1252 let output = executor.execute(&command)?;
1253 anyhow::ensure!(
1254 output.status == 0,
1255 "Could not resolve remote home: {}",
1256 String::from_utf8_lossy(&output.stderr).trim()
1257 );
1258 let home =
1259 String::from_utf8(output.stdout).context("Remote home is not valid UTF-8")?;
1260 mj_core::path_input::expand_home(path, Some(Path::new(&home)))
1261 }
1262 _ => mj_core::path_input::expand_local(path),
1263 }
1264}
1265
1266pub(crate) fn backend_ssh(ssh: &SshConnection) -> SshTarget {
1267 let destination = match &ssh.user {
1268 Some(user) => format!("{user}@{}", ssh.host),
1269 None => ssh.host.clone(),
1270 };
1271 SshTarget {
1272 destination,
1273 ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
1274 }
1275}
1276
1277fn ssh_command_spec(
1278 ssh: &SshTarget,
1279 args: impl IntoIterator<Item = impl AsRef<str>>,
1280) -> CommandSpec {
1281 let remote = args
1282 .into_iter()
1283 .map(|arg| arg.as_ref().to_string())
1284 .collect::<Vec<_>>();
1285 let mut command_args = ssh.ssh_args.clone();
1286 command_args.push(ssh.destination.clone());
1287 command_args.push(targets::join_remote_command(&remote));
1288 CommandSpec::new("ssh", command_args).ssh_destination(ssh.destination.clone())
1289}
1290
1291fn scp_command_spec(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
1292 let mut args = ssh.ssh_args.clone();
1293 if recursive {
1294 args.push("-r".into());
1295 }
1296 args.push(source.to_string_lossy().into_owned());
1297 args.push(format!("{}:{remote}", ssh.destination));
1298 CommandSpec::new("scp", args).ssh_destination(ssh.destination.clone())
1301}
1302
1303fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
1304 let mut result = vec![
1310 "-o".into(),
1311 "BatchMode=yes".into(),
1312 "-o".into(),
1313 "StrictHostKeyChecking=accept-new".into(),
1314 "-o".into(),
1315 "ConnectTimeout=15".into(),
1316 ];
1317 result.extend(args.iter().cloned());
1318 if let Some(identity) = identity {
1319 result.push("-i".into());
1320 result.push(identity.to_string_lossy().into_owned());
1321 }
1322 result
1323}
1324
1325fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
1326 let output = executor.execute(&command)?;
1327 if output.status != 0 {
1328 let detail = command_error_detail(&output.stderr);
1329 if detail.is_empty() {
1330 bail!("{} failed with status {}", command.purpose, output.status);
1331 }
1332 bail!("{detail}");
1333 }
1334 Ok(output)
1335}
1336
1337fn command_error_detail(stderr: &[u8]) -> String {
1338 let reported = String::from_utf8_lossy(stderr);
1339 let reported = reported.trim();
1340 let detail = reported
1341 .rsplit_once("\nCaused by:\n")
1342 .map_or(reported, |(_, causes)| causes);
1343 let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
1344 detail
1345 .lines()
1346 .map(|line| line.strip_prefix(" ").unwrap_or(line))
1347 .collect::<Vec<_>>()
1348 .join("\n")
1349 .trim()
1350 .to_owned()
1351}
1352
1353fn now() -> String {
1354 Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1355}
1356
1357fn restore_session_after_persistence_failure(
1358 state: &mut State,
1359 session_id: &str,
1360 previous: &SessionRecord,
1361 primary: anyhow::Error,
1362 persist: impl FnOnce(&SessionRecord) -> Result<()>,
1363) -> anyhow::Error {
1364 state
1365 .sessions
1366 .insert(session_id.to_owned(), previous.clone());
1367 let restored = state
1368 .sessions
1369 .get(session_id)
1370 .expect("restored session record disappeared");
1371 match persist(restored) {
1372 Ok(()) => primary,
1373 Err(error) => primary.context(format!(
1374 "restored prior session state in memory, but failed to persist the rollback: {error:#}"
1375 )),
1376 }
1377}
1378
1379fn persist_session_record_transition_or_restore(
1380 state: &mut State,
1381 session_id: &str,
1382 previous: &SessionRecord,
1383 context: &'static str,
1384 persist: &impl Fn(&SessionRecord) -> Result<()>,
1385) -> Result<()> {
1386 let result = persist(
1387 state
1388 .sessions
1389 .get(session_id)
1390 .expect("checkpoint session disappeared before persistence"),
1391 );
1392 match result {
1393 Ok(()) => Ok(()),
1394 Err(error) => Err(restore_session_after_persistence_failure(
1395 state,
1396 session_id,
1397 previous,
1398 error.context(context),
1399 persist,
1400 )),
1401 }
1402}
1403
1404pub fn config_only_controller(config: Config) -> Controller {
1405 Controller {
1406 config,
1407 state: State::default(),
1408 }
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413 use std::collections::BTreeMap;
1414 use std::path::Path;
1415
1416 use crate::targets::ProcessExecutor;
1417 use mj_core::config::{
1418 Config, ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, ProjectBundle,
1419 ProjectRepository, TargetTemplate,
1420 };
1421 use mj_core::state::State;
1422
1423 use super::*;
1424
1425 #[test]
1429 fn ssh_and_scp_specs_are_both_tagged_with_the_connection_destination() {
1430 let ssh = SshTarget {
1431 destination: "build@10.0.0.1".into(),
1432 ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
1433 };
1434
1435 let uploaded = scp_command_spec(&ssh, Path::new("/tmp/local"), "remote/path", true);
1436 let ran = ssh_command_spec(&ssh, ["true"]);
1437
1438 assert_eq!(uploaded.program, "scp");
1439 assert_eq!(uploaded.ssh_destination.as_deref(), Some("build@10.0.0.1"));
1440 assert_eq!(ran.ssh_destination.as_deref(), Some("build@10.0.0.1"));
1441 }
1442
1443 fn registration_config() -> Config {
1446 let mut config = Config::default();
1447 config.profiles.insert(
1448 "codex".into(),
1449 HarnessProfile {
1450 enabled: true,
1451 kind: HarnessKind::Codex,
1452 home: PathBuf::from("/home/dev/.codex"),
1453 environment: BTreeMap::new(),
1454 context_window_bytes: None,
1455 },
1456 );
1457 config.bundles.insert(
1458 "project".into(),
1459 ProjectBundle {
1460 primary_repo: "project".into(),
1461 repositories: vec![ProjectRepository {
1462 id: "project".into(),
1463 github: Some("owner/project".into()),
1464 local: None,
1465 destination: PathBuf::from("project"),
1466 git_ref: None,
1467 }],
1468 },
1469 );
1470 config.targets.insert(
1471 "podman".into(),
1472 TargetTemplate::LocalPodman {
1473 container: ConfigContainer {
1474 image: "example.invalid/hel-test:latest".into(),
1475 pull_policy: Default::default(),
1476 platform: None,
1477 cpus: None,
1478 memory: None,
1479 environment: BTreeMap::new(),
1480 workspace_storage: Default::default(),
1481 },
1482 },
1483 );
1484 config
1485 }
1486
1487 #[test]
1488 fn remote_completion_preserves_home_shorthand_and_trailing_separator() {
1489 struct CompletionExecutor;
1490 impl CommandExecutor for CompletionExecutor {
1491 fn execute(&self, command: &CommandSpec) -> Result<targets::CommandOutput> {
1492 let script = command.args.last().unwrap();
1493 let stdout = if script.contains("$HOME") {
1494 b"/remote".to_vec()
1495 } else {
1496 assert!(script.contains("'/remote/cache/'"), "{script}");
1497 b"/remote/cache/alpha/\n/remote/cache/alpine/\n".to_vec()
1498 };
1499 Ok(targets::CommandOutput {
1500 status: 0,
1501 stdout,
1502 stderr: Vec::new(),
1503 })
1504 }
1505 }
1506 let target: TargetTemplate =
1507 serde_json::from_str(r#"{"kind":"ssh-podman","host":"builder","image":"test"}"#)
1508 .unwrap();
1509 let mut config = Config::default();
1510 config.targets.insert("remote".into(), target);
1511 let controller = Controller {
1512 config,
1513 state: State::default(),
1514 };
1515 let candidates = controller
1516 .complete_mount_source("remote", "~/cache/", &CompletionExecutor)
1517 .unwrap();
1518 assert_eq!(candidates, ["~/cache/alpha/", "~/cache/alpine/"]);
1519 assert_eq!(
1520 targets::path_completion("~/cache/", &candidates).as_deref(),
1521 Some("~/cache/alp")
1522 );
1523 }
1524
1525 #[test]
1526 fn remote_path_resolution_uses_login_home_without_evaluating_suffix() {
1527 struct HomeExecutor {
1528 status: i32,
1529 home: &'static str,
1530 }
1531 impl CommandExecutor for HomeExecutor {
1532 fn execute(&self, command: &CommandSpec) -> Result<targets::CommandOutput> {
1533 assert_eq!(command.program, "ssh");
1534 let script = command.args.last().unwrap();
1535 assert!(!script.contains("touch"));
1536 assert!(script.contains("$HOME"));
1537 Ok(targets::CommandOutput {
1538 status: self.status,
1539 stdout: self.home.as_bytes().to_vec(),
1540 stderr: b"home lookup failed".to_vec(),
1541 })
1542 }
1543 }
1544 let target: TargetTemplate = serde_json::from_str(
1545 r#"{"kind":"ssh-bare","host":"builder","permissions":"guardian"}"#,
1546 )
1547 .unwrap();
1548 let path = Path::new("~/資料/$(touch nope)");
1549 assert_eq!(
1550 resolve_target_input_path(
1551 &target,
1552 path,
1553 &HomeExecutor {
1554 status: 0,
1555 home: "/remote user"
1556 }
1557 )
1558 .unwrap(),
1559 Path::new("/remote user/資料/$(touch nope)")
1560 );
1561 assert!(
1562 resolve_target_input_path(
1563 &target,
1564 path,
1565 &HomeExecutor {
1566 status: 1,
1567 home: "/remote"
1568 }
1569 )
1570 .unwrap_err()
1571 .to_string()
1572 .contains("home lookup failed")
1573 );
1574 assert!(
1575 resolve_target_input_path(
1576 &target,
1577 path,
1578 &HomeExecutor {
1579 status: 0,
1580 home: ""
1581 }
1582 )
1583 .is_err()
1584 );
1585 assert!(
1586 resolve_target_input_path(
1587 &target,
1588 path,
1589 &HomeExecutor {
1590 status: 0,
1591 home: "relative"
1592 }
1593 )
1594 .is_err()
1595 );
1596 }
1597
1598 #[test]
1599 fn bundle_creation_combines_sources_with_first_primary_and_stable_collisions() {
1600 let mut config = Config::default();
1601 let sources = vec!["example/app".into(), "other/app".into()];
1602
1603 let bundle_id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1604 let bundle = &config.bundles[&bundle_id];
1605 assert_eq!(bundle_id, "app");
1606 assert_eq!(bundle.primary_repo, "app");
1607 assert_eq!(
1608 bundle
1609 .repositories
1610 .iter()
1611 .map(|repository| repository.id.as_str())
1612 .collect::<Vec<_>>(),
1613 ["app", "app-2"]
1614 );
1615 assert_eq!(
1616 bundle
1617 .repositories
1618 .iter()
1619 .map(|repository| repository.destination.to_string_lossy().into_owned())
1620 .collect::<Vec<_>>(),
1621 ["app".to_owned(), "app-2".to_owned()]
1622 );
1623 assert_eq!(
1624 bundle.repositories[0].github.as_deref(),
1625 Some("example/app")
1626 );
1627 assert_eq!(bundle.repositories[1].github.as_deref(), Some("other/app"));
1628 }
1629
1630 #[test]
1631 fn bundle_creation_combines_local_and_github_sources_and_rejects_local_aliases() {
1632 let directory = tempfile::tempdir().unwrap();
1633 let root = directory.path().join("app");
1634 let output = mj_core::subprocess::run_capturing_stdout(
1635 std::process::Command::new("git").arg("init").arg(&root),
1636 )
1637 .unwrap();
1638 assert!(output.status.success(), "{output:?}");
1639 let nested = root.join("nested");
1640 fs::create_dir(&nested).unwrap();
1641 let mut config = Config::default();
1642 let sources = vec![root.to_str().unwrap().into(), "example/shared".into()];
1643 let id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1644 let bundle = &config.bundles[&id];
1645 assert_eq!(
1646 bundle.primary().unwrap().local,
1647 Some(root.canonicalize().unwrap())
1648 );
1649 assert_eq!(
1650 bundle.repositories[1].github.as_deref(),
1651 Some("example/shared")
1652 );
1653 let before = config.clone();
1654 let aliases = vec![
1655 root.to_str().unwrap().into(),
1656 nested.to_str().unwrap().into(),
1657 ];
1658 let error = create_bundle_from_sources_in_config(&mut config, &aliases).unwrap_err();
1659 assert!(
1660 error.to_string().contains("duplicate repository source"),
1661 "{error:#}"
1662 );
1663 assert_eq!(config, before);
1664 }
1665
1666 #[test]
1667 fn bundle_creation_rejects_duplicate_normalized_sources_atomically() {
1668 let mut config = Config::default();
1669 let before = config.clone();
1670 let sources = vec![
1671 "example/app".into(),
1672 "https://github.com/EXAMPLE/APP.git".into(),
1673 ];
1674
1675 let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1676 assert!(error.to_string().contains("duplicate repository source"));
1677 assert_eq!(config, before);
1678 }
1679
1680 #[test]
1681 fn bundle_creation_validates_every_source_before_mutating_config() {
1682 let mut config = Config::default();
1683 let before = config.clone();
1684 let invalid_directory = tempfile::tempdir().unwrap();
1685 let sources = vec![
1686 "example/app".into(),
1687 invalid_directory.path().to_string_lossy().into_owned(),
1688 ];
1689
1690 let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1691 assert!(error.to_string().contains("not a Git repository"));
1692 assert_eq!(config, before);
1693 }
1694
1695 #[test]
1696 fn bundle_creation_reuses_an_exact_source_set_and_rejects_obsolete_pins() {
1697 let mut config = Config::default();
1698 config.bundles.insert(
1699 "all".into(),
1700 ProjectBundle {
1701 primary_repo: "app".into(),
1702 repositories: vec![
1703 ProjectRepository {
1704 id: "app".into(),
1705 github: Some("example/app".into()),
1706 local: None,
1707 destination: "app".into(),
1708 git_ref: None,
1709 },
1710 ProjectRepository {
1711 id: "shared".into(),
1712 github: Some("example/shared".into()),
1713 local: None,
1714 destination: "shared".into(),
1715 git_ref: None,
1716 },
1717 ],
1718 },
1719 );
1720
1721 let one_source = vec!["example/app".into()];
1722 let created = create_bundle_from_sources_in_config(&mut config, &one_source).unwrap();
1723 assert_eq!(created, "app");
1724 assert_eq!(config.bundles[&created].repositories.len(), 1);
1725 assert_eq!(
1726 create_bundle_from_sources_in_config(&mut config, &one_source).unwrap(),
1727 "app"
1728 );
1729
1730 let exact_sources = vec!["example/app".into(), "example/shared".into()];
1731 assert_eq!(
1732 create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap(),
1733 "all"
1734 );
1735 assert_eq!(config.bundles.len(), 2);
1736 config.bundles.get_mut("all").unwrap().repositories[0].git_ref = Some("release".into());
1737 let before = config.clone();
1738 let error = create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap_err();
1739 assert!(format!("{error:#}").contains("git_ref is no longer supported"));
1740 assert_eq!(config, before);
1741 }
1742
1743 fn launch_options(additional_mounts: Vec<AdditionalMount>) -> SessionLaunchOptions {
1744 SessionLaunchOptions {
1745 mjolnir_subagents: None,
1746 create_managed_worktree: None,
1747 initial_prompt: None,
1748 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1749 additional_mounts,
1750 allow_dirty_local: false,
1751 resource_allocation: None,
1752 project_directory: None,
1753 session_title_override: None,
1754 }
1755 }
1756
1757 #[test]
1758 fn registration_rejects_a_disabled_profile_before_persisting() {
1759 let mut config = registration_config();
1760 config.profiles.get_mut("codex").unwrap().enabled = false;
1761 let mut controller = Controller {
1762 config,
1763 state: State::default(),
1764 };
1765
1766 let error = controller
1767 .register_session_with_resources(
1768 "codex",
1769 "project",
1770 "podman",
1771 "disabled",
1772 launch_options(Vec::new()),
1773 )
1774 .unwrap_err();
1775
1776 assert!(error.to_string().contains("disabled"));
1777 assert!(controller.state.sessions.is_empty());
1778 }
1779
1780 #[test]
1781 fn deepseek_registration_rejects_more_than_one_workspace_root_before_persisting() {
1782 let mut config = registration_config();
1783 config.profiles.get_mut("codex").unwrap().kind = HarnessKind::Deepseek;
1784 let second = config.bundles["project"].repositories[0].clone();
1785 config
1786 .bundles
1787 .get_mut("project")
1788 .unwrap()
1789 .repositories
1790 .push(mj_core::config::ProjectRepository {
1791 id: "second".into(),
1792 destination: "second".into(),
1793 ..second
1794 });
1795 let mut controller = Controller {
1796 config,
1797 state: State::default(),
1798 };
1799
1800 let error = controller
1801 .register_session_with_resources(
1802 "codex",
1803 "project",
1804 "podman",
1805 "unsupported",
1806 launch_options(Vec::new()),
1807 )
1808 .unwrap_err();
1809
1810 assert!(error.to_string().contains("one workspace root"));
1811 assert!(controller.state.sessions.is_empty());
1812 }
1813
1814 fn run_registration_child(marker: &str, test: &str, data_directory: &Path) {
1817 let output = std::process::Command::new(std::env::current_exe().unwrap())
1818 .args([
1819 "--exact",
1820 &format!("controller::tests::{test}"),
1821 "--nocapture",
1822 ])
1823 .env(marker, "1")
1824 .env("MJ_DATA_DIR", data_directory)
1825 .env("MJ_CONFIG_DIR", data_directory)
1826 .output()
1827 .unwrap();
1828 assert!(
1829 output.status.success(),
1830 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1831 String::from_utf8_lossy(&output.stdout),
1832 String::from_utf8_lossy(&output.stderr)
1833 );
1834 }
1835
1836 #[test]
1837 fn registration_saves_the_initial_task_before_provisioning() {
1838 const MARKER: &str = "MJ_TEST_INITIAL_TASK_CHILD";
1839 if std::env::var_os(MARKER).is_none() {
1840 let directory = tempfile::tempdir().unwrap();
1841 run_registration_child(
1842 MARKER,
1843 "registration_saves_the_initial_task_before_provisioning",
1844 directory.path(),
1845 );
1846 return;
1847 }
1848 let _writer = crate::database::install_isolated_test_writer();
1849 let mut controller = Controller {
1850 config: registration_config(),
1851 state: State::default(),
1852 };
1853 let prompt = format!(
1854 "Initial task\n{}\n\tPreserve indentation and λ",
1855 "x".repeat(70_000)
1856 );
1857 let mut options = launch_options(Vec::new());
1858 options.initial_prompt = Some(prompt.clone());
1859 let id = controller
1860 .register_session_with_resources("codex", "project", "podman", "fresh task", options)
1861 .unwrap();
1862 let saved = crate::database::load_state().unwrap();
1863 assert_eq!(saved.sessions[&id].draft_input, prompt);
1864 assert_eq!(saved.sessions[&id].state, SessionState::Provisioning);
1865 crate::database::set_session_draft_input(&id, "a newer draft").unwrap();
1866 crate::database::clear_session_draft_input_if_matches(&id, &prompt).unwrap();
1867 assert_eq!(
1868 crate::database::load_state().unwrap().sessions[&id].draft_input,
1869 "a newer draft"
1870 );
1871 crate::database::clear_session_draft_input_if_matches(&id, "a newer draft").unwrap();
1872 assert!(
1873 crate::database::load_state().unwrap().sessions[&id]
1874 .draft_input
1875 .is_empty()
1876 );
1877 }
1878
1879 const UNPERSISTABLE_SESSION_CHILD: &str = "MJ_TEST_UNPERSISTABLE_SESSION_CHILD";
1880
1881 #[test]
1882 fn missing_bundle_does_not_block_controller_or_other_sessions() {
1883 const CHILD: &str = "MJ_TEST_MISSING_BUNDLE_CHILD";
1884 if std::env::var_os(CHILD).is_none() {
1885 let directory = tempfile::tempdir().unwrap();
1886 run_registration_child(
1887 CHILD,
1888 "missing_bundle_does_not_block_controller_or_other_sessions",
1889 directory.path(),
1890 );
1891 return;
1892 }
1893 let _writer = crate::database::install_isolated_test_writer();
1894 let mut controller = Controller {
1895 config: registration_config(),
1896 state: State::default(),
1897 };
1898 let bundle = controller.config.bundles["project"].clone();
1899 controller
1900 .config
1901 .bundles
1902 .insert("other".into(), bundle.clone());
1903 controller.config.save().unwrap();
1904 let affected = controller
1905 .register_session_with_resources(
1906 "codex",
1907 "project",
1908 "podman",
1909 "affected",
1910 launch_options(Vec::new()),
1911 )
1912 .unwrap();
1913 let healthy = controller
1914 .register_session_with_resources(
1915 "codex",
1916 "other",
1917 "podman",
1918 "healthy",
1919 launch_options(Vec::new()),
1920 )
1921 .unwrap();
1922 controller.config.bundles.remove("project");
1923 controller.config.save().unwrap();
1924 let loaded = Controller::load().unwrap();
1925 assert_eq!(loaded.state.sessions.len(), 2);
1926 assert!(
1927 loaded.state.sessions[&healthy]
1928 .configuration_issue(&loaded.config)
1929 .is_none()
1930 );
1931 let issue = loaded.reconnect_command(&affected).unwrap_err().to_string();
1932 assert!(issue.contains("missing bundle"), "{issue}");
1933 assert!(issue.contains("config.toml"), "{issue}");
1934 assert_eq!(
1936 loaded.state.sessions[&affected],
1937 controller.state.sessions[&affected]
1938 );
1939 Config::update(|config| {
1940 config.bundles.insert("project".into(), bundle);
1941 Ok(())
1942 })
1943 .unwrap();
1944 let repaired = Controller::load().unwrap();
1945 assert!(
1946 repaired.state.sessions[&affected]
1947 .configuration_issue(&repaired.config)
1948 .is_none()
1949 );
1950 }
1951
1952 const CONFIG_ID_RENAME_CHILD: &str = "MJ_TEST_CONFIG_ID_RENAME_CHILD";
1953
1954 #[test]
1955 fn configuration_id_rename_rewrites_durable_session_references() {
1956 if std::env::var_os(CONFIG_ID_RENAME_CHILD).is_none() {
1957 let directory = tempfile::tempdir().unwrap();
1958 run_registration_child(
1959 CONFIG_ID_RENAME_CHILD,
1960 "configuration_id_rename_rewrites_durable_session_references",
1961 directory.path(),
1962 );
1963 return;
1964 }
1965 let _writer = crate::database::install_isolated_test_writer();
1967
1968 let mut controller = Controller {
1969 config: registration_config(),
1970 state: State::default(),
1971 };
1972 controller.config.save().unwrap();
1973 let session_id = controller
1974 .register_session_with_resources(
1975 "codex",
1976 "project",
1977 "podman",
1978 "rename references",
1979 launch_options(Vec::new()),
1980 )
1981 .unwrap();
1982
1983 controller
1984 .rename_profile_id("codex", "codex-renamed")
1985 .unwrap();
1986 controller
1987 .rename_target_id("podman", "podman-renamed")
1988 .unwrap();
1989
1990 let loaded = Controller::load().unwrap();
1991 let session = &loaded.state.sessions[&session_id];
1992 assert_eq!(session.last_profile, "codex-renamed");
1993 assert_eq!(session.target_template_id, "podman-renamed");
1994 assert!(loaded.config.profiles.contains_key("codex-renamed"));
1995 assert!(loaded.config.targets.contains_key("podman-renamed"));
1996 assert!(!config_rename_journal_path().exists());
1997 }
1998
1999 #[test]
2000 fn a_session_the_database_rejects_is_never_left_in_memory() {
2001 if std::env::var_os(UNPERSISTABLE_SESSION_CHILD).is_none() {
2002 let directory = tempfile::tempdir().unwrap();
2003 run_registration_child(
2004 UNPERSISTABLE_SESSION_CHILD,
2005 "a_session_the_database_rejects_is_never_left_in_memory",
2006 directory.path(),
2007 );
2008 return;
2009 }
2010 let _writer = crate::database::install_isolated_test_writer();
2012
2013 let mut controller = Controller {
2014 config: registration_config(),
2015 state: State::default(),
2016 };
2017 controller
2023 .register_session_with_resources(
2024 "codex",
2025 "project",
2026 "podman",
2027 "first",
2028 launch_options(Vec::new()),
2029 )
2030 .expect("a healthy store registers a session");
2031 rusqlite::Connection::open(crate::database::database_path())
2032 .unwrap()
2033 .execute_batch("DROP TABLE sessions")
2034 .unwrap();
2035
2036 let error = controller
2037 .register_session_with_resources(
2038 "codex",
2039 "project",
2040 "podman",
2041 "unpersistable",
2042 launch_options(Vec::new()),
2043 )
2044 .expect_err("a store that rejects the write cannot register a session");
2045 assert!(
2046 format!("{error:#}").contains("sessions"),
2047 "unexpected error: {error:#}"
2048 );
2049 assert_eq!(
2050 controller.state.sessions.len(),
2051 1,
2052 "a session the database never accepted stayed in controller memory"
2053 );
2054 assert!(
2055 controller
2056 .state
2057 .sessions
2058 .values()
2059 .all(|session| session.title != "unpersistable"),
2060 "the rejected session is the one that stayed"
2061 );
2062 }
2063
2064 const MOUNT_HISTORY_FAILURE_CHILD: &str = "MJ_TEST_MOUNT_HISTORY_FAILURE_CHILD";
2065
2066 const CONTAINER_SIZE_HISTORY_CHILD: &str = "MJ_TEST_CONTAINER_SIZE_HISTORY_CHILD";
2067
2068 #[test]
2069 fn registration_remembers_launch_size_but_session_overrides_do_not_replace_it() {
2070 if std::env::var_os(CONTAINER_SIZE_HISTORY_CHILD).is_none() {
2071 let directory = tempfile::tempdir().unwrap();
2072 run_registration_child(
2073 CONTAINER_SIZE_HISTORY_CHILD,
2074 "registration_remembers_launch_size_but_session_overrides_do_not_replace_it",
2075 directory.path(),
2076 );
2077 return;
2078 }
2079 let _writer = crate::database::install_isolated_test_writer();
2081
2082 let mut controller = Controller {
2083 config: registration_config(),
2084 state: State::default(),
2085 };
2086 let mut options = launch_options(Vec::new());
2087 options.resource_allocation = Some(SessionResourceAllocation::Container {
2088 cpus: 12,
2089 memory_bytes: 48 * 1024 * 1024 * 1024,
2090 });
2091 let id = controller
2092 .register_session_with_resources("codex", "project", "podman", "sized", options)
2093 .unwrap();
2094 let expected = HostContainerSize {
2095 cpus: 12,
2096 memory_bytes: 48 * 1024 * 1024 * 1024,
2097 };
2098 assert_eq!(controller.state.container_sizes["local"], expected);
2099 assert_eq!(
2100 crate::database::load_state_migrating()
2101 .unwrap()
2102 .container_sizes["local"],
2103 expected
2104 );
2105
2106 controller
2107 .update_session_container_settings(
2108 &id,
2109 Some("2".into()),
2110 Some("4g".into()),
2111 Vec::new(),
2112 Vec::new(),
2113 )
2114 .unwrap();
2115 assert_eq!(
2116 crate::database::load_state_migrating()
2117 .unwrap()
2118 .container_sizes["local"],
2119 expected
2120 );
2121 }
2122
2123 #[test]
2124 fn a_failed_mount_history_write_does_not_fail_the_registered_session() {
2125 if std::env::var_os(MOUNT_HISTORY_FAILURE_CHILD).is_none() {
2126 let directory = tempfile::tempdir().unwrap();
2127 run_registration_child(
2128 MOUNT_HISTORY_FAILURE_CHILD,
2129 "a_failed_mount_history_write_does_not_fail_the_registered_session",
2130 directory.path(),
2131 );
2132 return;
2133 }
2134 let _writer = crate::database::install_isolated_test_writer();
2136
2137 let mut controller = Controller {
2138 config: registration_config(),
2139 state: State::default(),
2140 };
2141 controller
2143 .register_session_with_resources(
2144 "codex",
2145 "project",
2146 "podman",
2147 "first",
2148 launch_options(Vec::new()),
2149 )
2150 .expect("a healthy store registers a session");
2151 let database = crate::database::database_path();
2152 rusqlite::Connection::open(&database)
2153 .unwrap()
2154 .execute_batch("DROP TABLE mount_history")
2155 .unwrap();
2156
2157 let id = controller
2158 .register_session_with_resources(
2159 "codex",
2160 "project",
2161 "podman",
2162 "attached",
2163 launch_options(vec![AdditionalMount {
2164 source: PathBuf::from("/host/models"),
2165 destination: PathBuf::from("/mnt/models"),
2166 read_only: false,
2167 }]),
2168 )
2169 .expect("a suggestion list that cannot be written must not fail a registration");
2170
2171 let stored: i64 = rusqlite::Connection::open(&database)
2172 .unwrap()
2173 .query_row(
2174 "SELECT count(*) FROM sessions WHERE session_id = ?1",
2175 [&id],
2176 |row| row.get(0),
2177 )
2178 .unwrap();
2179 assert_eq!(stored, 1, "the registered session was not committed");
2180 assert!(
2181 controller.state.mount_history.is_empty(),
2182 "controller memory remembered mount sources the database never stored"
2183 );
2184 }
2185
2186 #[test]
2187 fn command_errors_report_the_root_cause_without_worker_wrappers() {
2188 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";
2189
2190 assert_eq!(
2191 command_error_detail(stderr),
2192 "checkpoint base b41dc78 is absent from configured source\nrepository may have moved"
2193 );
2194 }
2195
2196 #[test]
2197 fn controller_store_lock_excludes_a_second_process_owner() {
2198 let directory = tempfile::tempdir().unwrap();
2199 let first = ControllerStoreGuard::acquire_at(directory.path()).unwrap();
2200 run_controller_lock_probe(directory.path(), true);
2201 drop(first);
2202 run_controller_lock_probe(directory.path(), false);
2203 }
2204 fn run_controller_lock_probe(directory: &Path, expect_locked: bool) {
2205 let output = std::process::Command::new(std::env::current_exe().unwrap())
2206 .args([
2207 "--exact",
2208 "controller::tests::controller_store_lock_subprocess_probe",
2209 "--nocapture",
2210 ])
2211 .env("MJ_CONTROLLER_LOCK_PROBE", directory)
2212 .env(
2213 "MJ_CONTROLLER_LOCK_EXPECTED",
2214 if expect_locked { "locked" } else { "available" },
2215 )
2216 .output()
2217 .unwrap();
2218 assert!(
2219 output.status.success(),
2220 "controller lock subprocess failed:\nstdout:\n{}\nstderr:\n{}",
2221 String::from_utf8_lossy(&output.stdout),
2222 String::from_utf8_lossy(&output.stderr)
2223 );
2224 }
2225 #[test]
2226 fn controller_store_lock_subprocess_probe() {
2227 let Some(directory) = std::env::var_os("MJ_CONTROLLER_LOCK_PROBE") else {
2228 return;
2229 };
2230 let expected = std::env::var("MJ_CONTROLLER_LOCK_EXPECTED").unwrap();
2231 let acquired = ControllerStoreGuard::acquire_at(Path::new(&directory));
2232 match expected.as_str() {
2233 "locked" => {
2234 let error = acquired.expect_err("a second process acquired the controller store");
2235 assert!(error.to_string().contains("another Mjolnir controller"));
2236 }
2237 "available" => {
2238 acquired.expect("released controller store stayed locked");
2239 }
2240 value => panic!("unexpected lock probe expectation {value:?}"),
2241 }
2242 }
2243 #[test]
2244 fn local_mount_source_must_be_an_existing_directory() {
2245 let directory = tempfile::tempdir().unwrap();
2246 let file = directory.path().join("file");
2247 std::fs::write(&file, "not a directory").unwrap();
2248 let mut config = Config::default();
2249 config.targets.insert(
2250 "local".into(),
2251 TargetTemplate::LocalPodman {
2252 container: ConfigContainer {
2253 image: "ubuntu:24.04".into(),
2254 pull_policy: Default::default(),
2255 platform: None,
2256 cpus: None,
2257 memory: None,
2258 environment: BTreeMap::new(),
2259 workspace_storage: Default::default(),
2260 },
2261 },
2262 );
2263 let controller = Controller {
2264 config,
2265 state: State::default(),
2266 };
2267
2268 assert!(
2269 controller
2270 .validate_mount_source("local", directory.path(), &ProcessExecutor)
2271 .is_ok()
2272 );
2273 for invalid in [file, directory.path().join("missing")] {
2274 let error = controller
2275 .validate_mount_source("local", &invalid, &ProcessExecutor)
2276 .unwrap_err();
2277 assert!(
2278 error
2279 .to_string()
2280 .contains("does not exist or is not a directory")
2281 );
2282 }
2283 }
2284}