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