Skip to main content

mj_controller/
hel_controller.rs

1//! Controller-side lifecycle transitions and canonical-to-backend conversion.
2
3mod 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/// Machine-wide advisory lock for one controller data store. This prevents a
91/// dashboard, server, or CLI lifecycle command from concurrently acting as a
92/// second controller against the same SQLite state and relay sessions.
93#[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    /// Probe exclusivity without treating an owner that is still exiting as an error.
114    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    /// Start the sole production SQLite writer after controller exclusivity
144    /// has been established by this guard.
145    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        // Make release explicit. `File` also unlocks on close, but an explicit
153        // unlock keeps same-process handoff deterministic across platforms.
154        let _ = self.file.unlock();
155    }
156}
157
158/// The durable result of creating a quick bundle. The returned config is the
159/// same fresh config that was written, allowing a serving projection to publish
160/// the new bundle before acknowledging the request that created it.
161#[derive(Debug)]
162pub struct QuickBundleCreation {
163    pub config: HelConfig,
164    pub bundle_id: String,
165}
166
167/// Failure stages exposed to a viewer request without exposing the underlying
168/// filesystem/configuration error. The detailed error remains available to
169/// the caller for logs and terminal notices.
170#[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
193/// Create a quick bundle from a local repository or GitHub source and persist
194/// it as one serialized fresh-config transaction. Identical sources reuse the
195/// existing configured bundle, matching the terminal's behavior. The returned
196/// config is the same fresh config that was written, allowing a serving
197/// projection to publish the new bundle before acknowledging the request.
198pub 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
213/// Add a quick bundle to an already-loaded config. The helper still performs
214/// the local repository canonicalization/GitHub-source parsing, but callers
215/// that persist a config should use [`create_quick_bundle`] so concurrent saves
216/// cannot clobber one another.
217pub 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
247/// Create one bundle from one or more local repositories or GitHub sources.
248///
249/// All sources are interpreted and checked before the config transaction can
250/// write anything. An existing bundle is reused only when its repository set
251/// and primary repository exactly match the request; this keeps selecting one
252/// repository from a larger bundle from silently changing the wizard's choice.
253pub 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
268/// Add a bundle for all `sources` to an already-loaded config. The source
269/// interpretation is shared with the persisted [`create_bundle_from_sources`]
270/// entry point and the legacy quick-bundle helper.
271pub 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    // Build and validate a candidate before replacing the caller's config, so
295    // a later validation error cannot leave an in-memory partial mutation.
296    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
366/// Interpret a source once, including local Git canonicalization and GitHub
367/// parsing, so all creation paths use exactly the same source semantics.
368fn 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_against_config(&config)?;
477        Ok(Self { config, state })
478    }
479
480    pub fn reload(&mut self) -> Result<()> {
481        *self = Self::load()?;
482        Ok(())
483    }
484
485    fn persist_session_state(&self, session_id: &str) -> Result<()> {
486        match self.state.sessions.get(session_id) {
487            Some(session) => hel::hel_database::save_lifecycle_session(session),
488            None => hel::hel_database::delete_session(session_id),
489        }
490    }
491
492    fn persist_session_transition_or_restore(
493        &mut self,
494        session_id: &str,
495        previous: &SessionRecord,
496        context: &'static str,
497    ) -> Result<()> {
498        persist_session_record_transition_or_restore(
499            &mut self.state,
500            session_id,
501            previous,
502            context,
503            &hel::hel_database::save_lifecycle_session,
504        )
505    }
506
507    fn restore_prior_session_after_persistence_failure(
508        &mut self,
509        session_id: &str,
510        previous: &SessionRecord,
511        primary: anyhow::Error,
512    ) -> anyhow::Error {
513        restore_session_after_persistence_failure(
514            &mut self.state,
515            session_id,
516            previous,
517            primary,
518            hel::hel_database::save_lifecycle_session,
519        )
520    }
521
522    /// Resolve an entered path on its owning host. Call only from background work.
523    pub fn resolve_input_path(
524        &self,
525        target_id: &str,
526        path: &Path,
527        executor: &impl CommandExecutor,
528    ) -> Result<PathBuf> {
529        let target = self
530            .config
531            .targets
532            .get(target_id)
533            .context("Unknown path target")?;
534        resolve_target_input_path(target, path, executor)
535    }
536
537    /// Complete a mount source on the container engine host, preserving ~/ while editing.
538    pub fn complete_mount_source(
539        &self,
540        target_id: &str,
541        prefix: &str,
542        executor: &impl CommandExecutor,
543    ) -> Result<Vec<String>> {
544        let target = self
545            .config
546            .targets
547            .get(target_id)
548            .with_context(|| format!("unknown target template {target_id:?}"))?;
549        let home = if hel::hel_path_input::needs_home(Path::new(prefix))? {
550            Some(resolve_target_input_path(target, Path::new("~"), executor)?)
551        } else {
552            None
553        };
554        let expanded = hel::hel_path_input::expand_home(Path::new(prefix), home.as_deref())?;
555        let mut lookup = expanded.to_string_lossy().into_owned();
556        // Completion is a text protocol: a trailing separator requests children.
557        if prefix.ends_with('/') && !lookup.ends_with('/') {
558            lookup.push('/');
559        }
560        let candidates = match target {
561            TargetTemplate::LocalPodman { .. }
562            | TargetTemplate::LocalDocker { .. }
563            | TargetTemplate::AppleContainer { .. }
564            | TargetTemplate::AwsEc2 { .. } => hel_targets::local_directory_completions(&lookup),
565            TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
566                hel_targets::ssh_directory_completions(&backend_ssh(ssh), &lookup, executor)?
567            }
568            TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
569                bail!("resource path completion is unsupported for bare targets")
570            }
571        };
572        candidates
573            .into_iter()
574            .map(|candidate| {
575                let Some(home) = &home else {
576                    return Ok(candidate);
577                };
578                let suffix = Path::new(&candidate)
579                    .strip_prefix(home)
580                    .context("Completed path is outside the requested home")?;
581                let mut value = Path::new("~").join(suffix).to_string_lossy().into_owned();
582                if candidate.ends_with('/') && !value.ends_with('/') {
583                    value.push('/');
584                }
585                Ok(value)
586            })
587            .collect()
588    }
589
590    /// Verify a mount source on the host where Mjolnir will consume it, and report
591    /// the filesystem reason it must be attached read-only, if there is one.
592    ///
593    /// The probe runs in the same round trip as the existence check so the
594    /// editor learns both answers without a second wait. A probe that cannot
595    /// answer reports no reason: provisioning decides that authoritatively.
596    pub fn validate_mount_source(
597        &self,
598        target_id: &str,
599        source: &Path,
600        executor: &impl CommandExecutor,
601    ) -> Result<Option<String>> {
602        let target = self
603            .config
604            .targets
605            .get(target_id)
606            .with_context(|| format!("unknown target template {target_id:?}"))?;
607        let exists = match target {
608            TargetTemplate::LocalPodman { .. }
609            | TargetTemplate::LocalDocker { .. }
610            | TargetTemplate::AppleContainer { .. }
611            | TargetTemplate::AwsEc2 { .. } => std::fs::metadata(source)
612                .map(|metadata| metadata.is_dir())
613                .or_else(|error| {
614                    if error.kind() == std::io::ErrorKind::NotFound {
615                        Ok(false)
616                    } else {
617                        Err(error)
618                    }
619                })
620                .with_context(|| format!("inspect resource source {}", source.display()))?,
621            TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
622                hel_targets::ssh_directory_exists(&backend_ssh(ssh), source, executor)?
623            }
624            TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
625                bail!("resource attachments are unsupported for bare targets")
626            }
627        };
628        ensure!(
629            exists,
630            "source path {} does not exist or is not a directory",
631            source.display()
632        );
633        Ok(self.forced_read_only_reason(target, source, executor))
634    }
635
636    /// The `filesystem (reason)` label for a source the runtime cannot overlay.
637    fn forced_read_only_reason(
638        &self,
639        target: &TargetTemplate,
640        source: &Path,
641        executor: &impl CommandExecutor,
642    ) -> Option<String> {
643        let ssh = match target {
644            TargetTemplate::LocalPodman { .. } | TargetTemplate::LocalDocker { .. } => None,
645            TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
646                Some(backend_ssh(ssh))
647            }
648            // Apple Container already mounts read-only, and EC2 copies instead
649            // of mounting, so neither has an overlay to lose.
650            _ => return None,
651        };
652        let filesystem = hel_targets::probe_filesystem_types(
653            ssh.as_ref(),
654            std::slice::from_ref(&source.to_path_buf()),
655            executor,
656        )
657        .map_err(|error| {
658            tracing::debug!(
659                source = %source.display(),
660                error = format!("{error:#}"),
661                "could not probe the filesystem under a mount source"
662            );
663        })
664        .ok()?
665        .pop()?;
666        let reason = hel_targets::overlay_unsupported_filesystem(&filesystem)?;
667        Some(format!("{filesystem} ({reason})"))
668    }
669
670    fn fail_new_session_with_cleanup(
671        &mut self,
672        session_id: &str,
673        error: anyhow::Error,
674        executor: &impl CommandExecutor,
675    ) -> Result<anyhow::Error> {
676        let original = format!("{error:#}");
677        let cleanup_error = self
678            .cleanup_new_session_worktree_after_failure(session_id, executor)
679            .err()
680            .map(|cleanup_error| format!("{cleanup_error:#}"));
681        if let Some(cleanup_error) = &cleanup_error {
682            tracing::warn!(
683                session_id,
684                error = %cleanup_error,
685                "new-session worktree rollback reported a cleanup failure"
686            );
687        }
688        let failure = apply_failed_new_session_rollback(
689            &mut self.state,
690            session_id,
691            &original,
692            cleanup_error,
693        );
694        self.persist_session_state(session_id)?;
695        Ok(failure)
696    }
697
698    pub fn register_session_with_resources(
699        &mut self,
700        profile_id: &str,
701        bundle_id: &str,
702        target_id: &str,
703        title: impl Into<String>,
704        options: SessionLaunchOptions,
705    ) -> Result<String> {
706        let SessionLaunchOptions {
707            initial_prompt,
708            workspace_id,
709            additional_mounts,
710            allow_dirty_local: _,
711            resource_allocation,
712            project_directory,
713            session_title_override,
714        } = options;
715        let session_title_override = match session_title_override {
716            Some(title) => {
717                Some(normalize_session_title(&title).context("session name cannot be empty")?)
718            }
719            None => None,
720        };
721        let profile = self
722            .config
723            .profiles
724            .get(profile_id)
725            .with_context(|| format!("unknown profile {profile_id:?}"))?;
726        ensure!(profile.enabled, "profile {profile_id:?} is disabled");
727        let template = self
728            .config
729            .targets
730            .get(target_id)
731            .with_context(|| format!("unknown target template {target_id:?}"))?;
732        if project_directory.is_some() != is_bare_project_target(template) {
733            bail!("raw project directories require a bare target, and bare targets require one");
734        }
735        if let Some(path) = &project_directory
736            && (!path.is_absolute()
737                || path
738                    .components()
739                    .any(|part| part == std::path::Component::ParentDir))
740        {
741            bail!("bare project directory must be an absolute safe path");
742        }
743        let bundle = project_directory
744            .is_none()
745            .then(|| self.config.bundles.get(bundle_id))
746            .flatten();
747        if project_directory.is_none() && bundle.is_none() {
748            bail!("unknown bundle {bundle_id:?}");
749        }
750        if matches!(
751            profile.kind,
752            hel::hel_config::HarnessKind::Deepseek | hel::hel_config::HarnessKind::Muse
753        ) && (!additional_mounts.is_empty()
754            || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
755        {
756            bail!(
757                "{} ACP supports one workspace root; use a single-repository bundle without attached directories",
758                profile.kind.display_name()
759            );
760        }
761        if let Some(bundle) = bundle {
762            for repository in &bundle.repositories {
763                hel::hel_remote_git::resolve_repository(
764                    repository,
765                    &hel_targets::CancellableProcessExecutor::with_timeout(
766                        std::time::Duration::from_secs(15),
767                    ),
768                )
769                .with_context(|| format!("repository {:?}", repository.id))?;
770            }
771        }
772        validate_resource_allocation(template, resource_allocation.as_ref())?;
773        let selected_container_size =
774            selected_host_container_size(template, resource_allocation.as_ref());
775        if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
776            bail!("attached resources are unsupported for this target");
777        }
778        hel_targets::validate_additional_mounts(&additional_mounts)?;
779        let id = new_session_id()?;
780        let now = now();
781        let record = SessionRecord {
782            archived: false,
783            container_cpus: None,
784            container_memory: None,
785            id: id.clone(),
786            workspace_id,
787            title: title.into(),
788            harness_kind: profile.kind,
789            last_profile: profile_id.to_string(),
790            bundle_id: bundle_id.to_string(),
791            project_directory,
792            managed_worktree: None,
793            target_template_id: target_id.to_string(),
794            resource_allocation,
795            additional_mounts: additional_mounts.clone(),
796            state: SessionState::Provisioning,
797            target: None,
798            native_session_id: None,
799            acp_session_title: None,
800            session_title_override,
801            created_at: now.clone(),
802            updated_at: now,
803            viewed_through_event_ordinal: 0,
804            draft_input: initial_prompt.unwrap_or_default(),
805            last_error: None,
806            last_checkpoint_error: None,
807            checkpoint: None,
808        };
809        // Creation authors the whole record, so it writes the whole row. The
810        // record reaches memory only once it is durable: a session this process
811        // alone knows about is one the database can never resume or clean up.
812        if let Some((host, size)) = selected_container_size.as_ref() {
813            hel::hel_database::save_session_with_container_size(&record, host, *size)?;
814        } else {
815            hel::hel_database::save_session(&record)?;
816        }
817        self.state.sessions.insert(id.clone(), record);
818        if let Some((host, size)) = selected_container_size {
819            self.state.remember_container_size(&host, size);
820        }
821        if let Some(host) = mount_history_host(template) {
822            // Mount history only seeds the attach dialog's suggestions. The
823            // session row is already committed, so a failed suggestion write is
824            // reported rather than turned into a failed registration.
825            match hel::hel_database::remember_mount_sources(host, &additional_mounts) {
826                Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
827                Err(error) => tracing::warn!(
828                    session_id = id,
829                    error = format!("{error:#}"),
830                    "could not remember the attached resource directories for later suggestions"
831                ),
832            }
833        }
834        Ok(id)
835    }
836
837    pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
838        let title = normalize_session_title(title).context("session name cannot be empty")?;
839        ensure!(
840            self.state.sessions.contains_key(session_id),
841            "unknown session {session_id}"
842        );
843        let updated_at = now();
844        hel::hel_database::set_session_title_override(session_id, &title, &updated_at)?;
845        let record = self
846            .state
847            .sessions
848            .get_mut(session_id)
849            .expect("session was checked before updating its title");
850        record.session_title_override = Some(title.clone());
851        record.updated_at = updated_at;
852        Ok(title)
853    }
854
855    pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
856        hel::hel_config::validate_id("profile", new_id)?;
857        if old_id == new_id {
858            ensure!(
859                self.config.profiles.contains_key(old_id),
860                "unknown profile {old_id:?}"
861            );
862            return Ok(());
863        }
864        let journal = ConfigRenameJournal {
865            kind: ConfigRenameKind::Profile,
866            old_id: old_id.to_owned(),
867            new_id: new_id.to_owned(),
868        };
869        write_config_rename_journal(&journal)?;
870        let (config, ()) = match HelConfig::update(|config| {
871            ensure!(
872                config.profiles.contains_key(old_id),
873                "unknown profile {old_id:?}"
874            );
875            ensure!(
876                !config.profiles.contains_key(new_id),
877                "profile {new_id:?} already exists"
878            );
879            let profile = config
880                .profiles
881                .remove(old_id)
882                .expect("profile was checked in the transaction");
883            config.profiles.insert(new_id.to_owned(), profile);
884            if config.startup.profile.as_deref() == Some(old_id) {
885                config.startup.profile = Some(new_id.to_owned());
886            }
887            Ok(())
888        }) {
889            Ok(result) => result,
890            Err(error) => {
891                remove_config_rename_journal()
892                    .context("remove profile rename journal after config save failed")?;
893                return Err(error).context("save renamed profile configuration");
894            }
895        };
896        self.config = config;
897        hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
898        if let Err(error) = hel::hel_database::rename_profile_references(old_id, new_id) {
899            let restore = HelConfig::update(|config| {
900                let profile = config
901                    .profiles
902                    .remove(new_id)
903                    .with_context(|| format!("renamed profile {new_id:?} is missing"))?;
904                ensure!(
905                    !config.profiles.contains_key(old_id),
906                    "cannot restore profile rename: both {old_id:?} and {new_id:?} exist"
907                );
908                config.profiles.insert(old_id.to_owned(), profile);
909                if config.startup.profile.as_deref() == Some(new_id) {
910                    config.startup.profile = Some(old_id.to_owned());
911                }
912                Ok(())
913            });
914            let restored = match restore {
915                Ok((config, ())) => config,
916                Err(restore_error) => {
917                    return Err(error).context(format!(
918                        "rename profile references; additionally failed to restore config: {restore_error:#}"
919                    ));
920                }
921            };
922            self.config = restored;
923            if let Err(restore_error) = remove_config_rename_journal() {
924                return Err(error).context(format!(
925                    "rename profile references; additionally failed to remove rename journal: {restore_error:#}"
926                ));
927            }
928            return Err(error).context("rename profile references");
929        }
930        for session in self.state.sessions.values_mut() {
931            if session.last_profile == old_id {
932                session.last_profile = new_id.to_owned();
933            }
934        }
935        remove_config_rename_journal()?;
936        Ok(())
937    }
938
939    pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
940        hel::hel_config::validate_id("target template", new_id)?;
941        if old_id == new_id {
942            ensure!(
943                self.config.targets.contains_key(old_id),
944                "unknown target {old_id:?}"
945            );
946            return Ok(());
947        }
948        let journal = ConfigRenameJournal {
949            kind: ConfigRenameKind::Target,
950            old_id: old_id.to_owned(),
951            new_id: new_id.to_owned(),
952        };
953        write_config_rename_journal(&journal)?;
954        let (config, ()) = match HelConfig::update(|config| {
955            ensure!(
956                config.targets.contains_key(old_id),
957                "unknown target {old_id:?}"
958            );
959            ensure!(
960                !config.targets.contains_key(new_id),
961                "target {new_id:?} already exists"
962            );
963            let target = config
964                .targets
965                .remove(old_id)
966                .expect("target was checked in the transaction");
967            config.targets.insert(new_id.to_owned(), target);
968            if config.startup.target.as_deref() == Some(old_id) {
969                config.startup.target = Some(new_id.to_owned());
970            }
971            Ok(())
972        }) {
973            Ok(result) => result,
974            Err(error) => {
975                remove_config_rename_journal()
976                    .context("remove target rename journal after config save failed")?;
977                return Err(error).context("save renamed target configuration");
978            }
979        };
980        self.config = config;
981        hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
982        if let Err(error) = hel::hel_database::rename_target_references(old_id, new_id) {
983            let restore = HelConfig::update(|config| {
984                let target = config
985                    .targets
986                    .remove(new_id)
987                    .with_context(|| format!("renamed target {new_id:?} is missing"))?;
988                ensure!(
989                    !config.targets.contains_key(old_id),
990                    "cannot restore target rename: both {old_id:?} and {new_id:?} exist"
991                );
992                config.targets.insert(old_id.to_owned(), target);
993                if config.startup.target.as_deref() == Some(new_id) {
994                    config.startup.target = Some(old_id.to_owned());
995                }
996                Ok(())
997            });
998            let restored = match restore {
999                Ok((config, ())) => config,
1000                Err(restore_error) => {
1001                    return Err(error).context(format!(
1002                        "rename target references; additionally failed to restore config: {restore_error:#}"
1003                    ));
1004                }
1005            };
1006            self.config = restored;
1007            if let Err(restore_error) = remove_config_rename_journal() {
1008                return Err(error).context(format!(
1009                    "rename target references; additionally failed to remove rename journal: {restore_error:#}"
1010                ));
1011            }
1012            return Err(error).context("rename target references");
1013        }
1014        for session in self.state.sessions.values_mut() {
1015            if session.target_template_id == old_id {
1016                session.target_template_id = new_id.to_owned();
1017            }
1018        }
1019        remove_config_rename_journal()?;
1020        Ok(())
1021    }
1022
1023    /// Finish a profile/target id rename interrupted between the atomic config
1024    /// replacement and SQLite transaction. Each step is idempotent, so a
1025    /// second crash leaves the same intent available for the next startup.
1026    pub fn recover_config_id_rename() -> Result<bool> {
1027        let path = config_rename_journal_path();
1028        let body = match fs::read(&path) {
1029            Ok(body) => body,
1030            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1031            Err(error) => return Err(error).context(format!("read {}", path.display())),
1032        };
1033        let journal: ConfigRenameJournal =
1034            serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
1035        match journal.kind {
1036            ConfigRenameKind::Profile => {
1037                HelConfig::update(|config| {
1038                    finish_config_map_rename(
1039                        &mut config.profiles,
1040                        &journal.old_id,
1041                        &journal.new_id,
1042                        "profile",
1043                    )?;
1044                    Ok(())
1045                })?;
1046                hel::hel_database::rename_profile_references(&journal.old_id, &journal.new_id)?;
1047            }
1048            ConfigRenameKind::Target => {
1049                HelConfig::update(|config| {
1050                    finish_config_map_rename(
1051                        &mut config.targets,
1052                        &journal.old_id,
1053                        &journal.new_id,
1054                        "target",
1055                    )?;
1056                    Ok(())
1057                })?;
1058                hel::hel_database::rename_target_references(&journal.old_id, &journal.new_id)?;
1059            }
1060        }
1061        remove_config_rename_journal()?;
1062        Ok(true)
1063    }
1064
1065    /// Record the per-session container size overrides and attached
1066    /// directories. Nothing is applied to a running container: the values are
1067    /// read the next time the session's container is created.
1068    pub fn update_session_container_settings(
1069        &mut self,
1070        session_id: &str,
1071        cpus: Option<String>,
1072        memory: Option<String>,
1073        additional_mounts: Vec<hel_targets::AdditionalMount>,
1074        mount_history: Vec<std::path::PathBuf>,
1075    ) -> Result<()> {
1076        ensure!(
1077            self.state.sessions.contains_key(session_id),
1078            "unknown session {session_id}"
1079        );
1080        let cpus = cpus.filter(|value| !value.trim().is_empty());
1081        let memory = memory.filter(|value| !value.trim().is_empty());
1082        let updated_at = now();
1083        hel::hel_database::set_session_container_settings(
1084            session_id,
1085            cpus.as_deref(),
1086            memory.as_deref(),
1087            &additional_mounts,
1088            &updated_at,
1089        )?;
1090        if let Some(host) = self
1091            .config
1092            .targets
1093            .get(
1094                &self.state.sessions[session_id]
1095                    .target_template_id
1096                    .to_owned(),
1097            )
1098            .and_then(hel::hel_config::mount_history_host)
1099        {
1100            let host = host.to_owned();
1101            // The dialog owns the suggestion list, so forgetting a directory
1102            // there has to survive the mounts being remembered right after.
1103            hel::hel_database::replace_mount_history(&host, &mount_history)?;
1104            hel::hel_database::remember_mount_sources(&host, &additional_mounts)?;
1105            self.state.mount_history.insert(host.clone(), mount_history);
1106            self.state.remember_mount_sources(&host, &additional_mounts);
1107        }
1108        let record = self
1109            .state
1110            .sessions
1111            .get_mut(session_id)
1112            .expect("session was checked before updating its container settings");
1113        record.container_cpus = cpus;
1114        record.container_memory = memory;
1115        record.additional_mounts = additional_mounts;
1116        record.updated_at = updated_at;
1117        Ok(())
1118    }
1119}
1120
1121fn config_rename_journal_path() -> PathBuf {
1122    data_dir().join(CONFIG_RENAME_JOURNAL)
1123}
1124
1125fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
1126    let path = config_rename_journal_path();
1127    let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
1128    atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
1129}
1130
1131fn remove_config_rename_journal() -> Result<()> {
1132    let path = config_rename_journal_path();
1133    match fs::remove_file(&path) {
1134        Ok(()) => Ok(()),
1135        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1136        Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1137    }
1138}
1139
1140fn finish_config_map_rename<T>(
1141    entries: &mut BTreeMap<String, T>,
1142    old_id: &str,
1143    new_id: &str,
1144    kind: &str,
1145) -> Result<()> {
1146    if let Some(entry) = entries.remove(old_id) {
1147        ensure!(
1148            !entries.contains_key(new_id),
1149            "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
1150        );
1151        entries.insert(new_id.to_owned(), entry);
1152    } else {
1153        ensure!(
1154            entries.contains_key(new_id),
1155            "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
1156        );
1157    }
1158    Ok(())
1159}
1160
1161fn target_kind(locator: &hel_targets::TargetLocator) -> &'static str {
1162    match locator {
1163        hel_targets::TargetLocator::LocalBare { .. } => "local-bare",
1164        hel_targets::TargetLocator::LocalPodman { .. } => "local-podman",
1165        hel_targets::TargetLocator::LocalDocker { .. } => "local-docker",
1166        hel_targets::TargetLocator::AppleContainer { .. } => "apple-container",
1167        hel_targets::TargetLocator::AwsEc2 { .. } => "aws-ec2",
1168        hel_targets::TargetLocator::SshBare { .. } => "ssh-bare",
1169        hel_targets::TargetLocator::SshPodman { .. } => "ssh-podman",
1170        hel_targets::TargetLocator::SshDocker { .. } => "ssh-docker",
1171    }
1172}
1173
1174fn target_profile_home(
1175    locator: &hel_targets::TargetLocator,
1176    session_id: &str,
1177    profile: &hel::hel_config::HarnessProfile,
1178) -> String {
1179    let home = match locator {
1180        hel_targets::TargetLocator::LocalBare { .. } => profile.home.to_string_lossy().into_owned(),
1181        hel_targets::TargetLocator::LocalPodman { .. }
1182        | hel_targets::TargetLocator::LocalDocker { .. }
1183        | hel_targets::TargetLocator::AppleContainer { .. }
1184        | hel_targets::TargetLocator::SshPodman { .. }
1185        | hel_targets::TargetLocator::SshDocker { .. } => {
1186            format!("/var/lib/hel/profiles/{session_id}")
1187        }
1188        hel_targets::TargetLocator::AwsEc2 { .. } | hel_targets::TargetLocator::SshBare { .. } => {
1189            format!(".local/share/hel/profiles/{session_id}")
1190        }
1191    };
1192    if profile.kind == hel::hel_config::HarnessKind::Muse {
1193        let root = if matches!(locator, hel_targets::TargetLocator::LocalBare { .. }) {
1194            hel::hel_config::data_dir()
1195                .join("profiles")
1196                .join(session_id)
1197        } else {
1198            PathBuf::from(home)
1199        };
1200        root.join("muse").to_string_lossy().into_owned()
1201    } else {
1202        home
1203    }
1204}
1205
1206/// Resolve the login home on the machine that owns an editable path.
1207pub fn resolve_target_input_path(
1208    target: &TargetTemplate,
1209    path: &Path,
1210    executor: &impl CommandExecutor,
1211) -> Result<PathBuf> {
1212    if !hel::hel_path_input::needs_home(path)? {
1213        return Ok(path.to_path_buf());
1214    }
1215    match target {
1216        TargetTemplate::SshBare { ssh, .. }
1217        | TargetTemplate::SshPodman { ssh, .. }
1218        | TargetTemplate::SshDocker { ssh, .. } => {
1219            let mut ssh = ssh.clone();
1220            ssh.identity_file = ssh
1221                .identity_file
1222                .as_deref()
1223                .map(hel::hel_path_input::expand_local)
1224                .transpose()?;
1225            let command =
1226                ssh_command_spec(&backend_ssh(&ssh), ["sh", "-c", "printf '%s' \"$HOME\""])
1227                    .purpose("resolve remote home directory");
1228            let output = executor.execute(&command)?;
1229            anyhow::ensure!(
1230                output.status == 0,
1231                "Could not resolve remote home: {}",
1232                String::from_utf8_lossy(&output.stderr).trim()
1233            );
1234            let home =
1235                String::from_utf8(output.stdout).context("Remote home is not valid UTF-8")?;
1236            hel::hel_path_input::expand_home(path, Some(Path::new(&home)))
1237        }
1238        _ => hel::hel_path_input::expand_local(path),
1239    }
1240}
1241
1242pub(crate) fn backend_ssh(ssh: &SshConnection) -> SshTarget {
1243    let destination = match &ssh.user {
1244        Some(user) => format!("{user}@{}", ssh.host),
1245        None => ssh.host.clone(),
1246    };
1247    SshTarget {
1248        destination,
1249        ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
1250    }
1251}
1252
1253fn ssh_command_spec(
1254    ssh: &SshTarget,
1255    args: impl IntoIterator<Item = impl AsRef<str>>,
1256) -> CommandSpec {
1257    let remote = args
1258        .into_iter()
1259        .map(|arg| arg.as_ref().to_string())
1260        .collect::<Vec<_>>();
1261    let mut command_args = ssh.ssh_args.clone();
1262    command_args.push(ssh.destination.clone());
1263    command_args.push(hel_targets::join_remote_command(&remote));
1264    CommandSpec::new("ssh", command_args)
1265}
1266
1267fn scp_command_spec(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
1268    let mut args = ssh.ssh_args.clone();
1269    if recursive {
1270        args.push("-r".into());
1271    }
1272    args.push(source.to_string_lossy().into_owned());
1273    args.push(format!("{}:{remote}", ssh.destination));
1274    CommandSpec::new("scp", args)
1275}
1276
1277fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
1278    // Mjolnir drives ssh non-interactively from a TUI; a host-key or password
1279    // prompt would steal the terminal and wedge provisioning. BatchMode fails
1280    // fast instead of prompting, and accept-new trusts a first-seen host key
1281    // (fresh EC2 instances are always first-seen) while still rejecting
1282    // changed keys. User-supplied ssh_args come last so they can override.
1283    let mut result = vec![
1284        "-o".into(),
1285        "BatchMode=yes".into(),
1286        "-o".into(),
1287        "StrictHostKeyChecking=accept-new".into(),
1288        "-o".into(),
1289        "ConnectTimeout=15".into(),
1290    ];
1291    result.extend(args.iter().cloned());
1292    if let Some(identity) = identity {
1293        result.push("-i".into());
1294        result.push(identity.to_string_lossy().into_owned());
1295    }
1296    result
1297}
1298
1299fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
1300    let output = executor.execute(&command)?;
1301    if output.status != 0 {
1302        let detail = command_error_detail(&output.stderr);
1303        if detail.is_empty() {
1304            bail!("{} failed with status {}", command.purpose, output.status);
1305        }
1306        bail!("{detail}");
1307    }
1308    Ok(output)
1309}
1310
1311fn command_error_detail(stderr: &[u8]) -> String {
1312    let reported = String::from_utf8_lossy(stderr);
1313    let reported = reported.trim();
1314    let detail = reported
1315        .rsplit_once("\nCaused by:\n")
1316        .map_or(reported, |(_, causes)| causes);
1317    let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
1318    detail
1319        .lines()
1320        .map(|line| line.strip_prefix("    ").unwrap_or(line))
1321        .collect::<Vec<_>>()
1322        .join("\n")
1323        .trim()
1324        .to_owned()
1325}
1326
1327fn now() -> String {
1328    Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1329}
1330
1331fn restore_session_after_persistence_failure(
1332    state: &mut HelState,
1333    session_id: &str,
1334    previous: &SessionRecord,
1335    primary: anyhow::Error,
1336    persist: impl FnOnce(&SessionRecord) -> Result<()>,
1337) -> anyhow::Error {
1338    state
1339        .sessions
1340        .insert(session_id.to_owned(), previous.clone());
1341    let restored = state
1342        .sessions
1343        .get(session_id)
1344        .expect("restored session record disappeared");
1345    match persist(restored) {
1346        Ok(()) => primary,
1347        Err(error) => primary.context(format!(
1348            "restored prior session state in memory, but failed to persist the rollback: {error:#}"
1349        )),
1350    }
1351}
1352
1353fn persist_session_record_transition_or_restore(
1354    state: &mut HelState,
1355    session_id: &str,
1356    previous: &SessionRecord,
1357    context: &'static str,
1358    persist: &impl Fn(&SessionRecord) -> Result<()>,
1359) -> Result<()> {
1360    let result = persist(
1361        state
1362            .sessions
1363            .get(session_id)
1364            .expect("checkpoint session disappeared before persistence"),
1365    );
1366    match result {
1367        Ok(()) => Ok(()),
1368        Err(error) => Err(restore_session_after_persistence_failure(
1369            state,
1370            session_id,
1371            previous,
1372            error.context(context),
1373            persist,
1374        )),
1375    }
1376}
1377
1378#[cfg(test)]
1379mod tests {
1380    use std::collections::BTreeMap;
1381    use std::path::Path;
1382
1383    use hel::hel_config::{
1384        ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, HelConfig,
1385        ProjectBundle, ProjectRepository, TargetTemplate,
1386    };
1387    use hel::hel_state::HelState;
1388    use hel::hel_targets::ProcessExecutor;
1389
1390    use super::*;
1391
1392    /// One profile, one bundle with nothing checked out locally, and one
1393    /// container target, which is all `register_session_with_resources` reads.
1394    fn registration_config() -> HelConfig {
1395        let mut config = HelConfig::default();
1396        config.profiles.insert(
1397            "codex".into(),
1398            HarnessProfile {
1399                enabled: true,
1400                kind: HarnessKind::Codex,
1401                home: PathBuf::from("/home/dev/.codex"),
1402                environment: BTreeMap::new(),
1403                context_window_bytes: None,
1404            },
1405        );
1406        config.bundles.insert(
1407            "project".into(),
1408            ProjectBundle {
1409                primary_repo: "project".into(),
1410                repositories: vec![ProjectRepository {
1411                    id: "project".into(),
1412                    github: Some("owner/project".into()),
1413                    local: None,
1414                    destination: PathBuf::from("project"),
1415                    git_ref: None,
1416                }],
1417            },
1418        );
1419        config.targets.insert(
1420            "podman".into(),
1421            TargetTemplate::LocalPodman {
1422                container: ConfigContainer {
1423                    image: "example.invalid/hel-test:latest".into(),
1424                    pull_policy: Default::default(),
1425                    platform: None,
1426                    cpus: None,
1427                    memory: None,
1428                    environment: BTreeMap::new(),
1429                    workspace_storage: Default::default(),
1430                },
1431            },
1432        );
1433        config
1434    }
1435
1436    #[test]
1437    fn remote_completion_preserves_home_shorthand_and_trailing_separator() {
1438        struct CompletionExecutor;
1439        impl CommandExecutor for CompletionExecutor {
1440            fn execute(&self, command: &CommandSpec) -> Result<hel_targets::CommandOutput> {
1441                let script = command.args.last().unwrap();
1442                let stdout = if script.contains("$HOME") {
1443                    b"/remote".to_vec()
1444                } else {
1445                    assert!(script.contains("'/remote/cache/'"), "{script}");
1446                    b"/remote/cache/alpha/\n/remote/cache/alpine/\n".to_vec()
1447                };
1448                Ok(hel_targets::CommandOutput {
1449                    status: 0,
1450                    stdout,
1451                    stderr: Vec::new(),
1452                })
1453            }
1454        }
1455        let target: TargetTemplate =
1456            serde_json::from_str(r#"{"kind":"ssh-podman","host":"builder","image":"test"}"#)
1457                .unwrap();
1458        let mut config = HelConfig::default();
1459        config.targets.insert("remote".into(), target);
1460        let controller = Controller {
1461            config,
1462            state: HelState::default(),
1463        };
1464        let candidates = controller
1465            .complete_mount_source("remote", "~/cache/", &CompletionExecutor)
1466            .unwrap();
1467        assert_eq!(candidates, ["~/cache/alpha/", "~/cache/alpine/"]);
1468        assert_eq!(
1469            hel_targets::path_completion("~/cache/", &candidates).as_deref(),
1470            Some("~/cache/alp")
1471        );
1472    }
1473
1474    #[test]
1475    fn remote_path_resolution_uses_login_home_without_evaluating_suffix() {
1476        struct HomeExecutor {
1477            status: i32,
1478            home: &'static str,
1479        }
1480        impl CommandExecutor for HomeExecutor {
1481            fn execute(&self, command: &CommandSpec) -> Result<hel_targets::CommandOutput> {
1482                assert_eq!(command.program, "ssh");
1483                let script = command.args.last().unwrap();
1484                assert!(!script.contains("touch"));
1485                assert!(script.contains("$HOME"));
1486                Ok(hel_targets::CommandOutput {
1487                    status: self.status,
1488                    stdout: self.home.as_bytes().to_vec(),
1489                    stderr: b"home lookup failed".to_vec(),
1490                })
1491            }
1492        }
1493        let target: TargetTemplate = serde_json::from_str(
1494            r#"{"kind":"ssh-bare","host":"builder","permissions":"guardian"}"#,
1495        )
1496        .unwrap();
1497        let path = Path::new("~/資料/$(touch nope)");
1498        assert_eq!(
1499            resolve_target_input_path(
1500                &target,
1501                path,
1502                &HomeExecutor {
1503                    status: 0,
1504                    home: "/remote user"
1505                }
1506            )
1507            .unwrap(),
1508            Path::new("/remote user/資料/$(touch nope)")
1509        );
1510        assert!(
1511            resolve_target_input_path(
1512                &target,
1513                path,
1514                &HomeExecutor {
1515                    status: 1,
1516                    home: "/remote"
1517                }
1518            )
1519            .unwrap_err()
1520            .to_string()
1521            .contains("home lookup failed")
1522        );
1523        assert!(
1524            resolve_target_input_path(
1525                &target,
1526                path,
1527                &HomeExecutor {
1528                    status: 0,
1529                    home: ""
1530                }
1531            )
1532            .is_err()
1533        );
1534        assert!(
1535            resolve_target_input_path(
1536                &target,
1537                path,
1538                &HomeExecutor {
1539                    status: 0,
1540                    home: "relative"
1541                }
1542            )
1543            .is_err()
1544        );
1545    }
1546
1547    #[test]
1548    fn bundle_creation_combines_sources_with_first_primary_and_stable_collisions() {
1549        let mut config = HelConfig::default();
1550        let sources = vec!["example/app".into(), "other/app".into()];
1551
1552        let bundle_id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1553        let bundle = &config.bundles[&bundle_id];
1554        assert_eq!(bundle_id, "app");
1555        assert_eq!(bundle.primary_repo, "app");
1556        assert_eq!(
1557            bundle
1558                .repositories
1559                .iter()
1560                .map(|repository| repository.id.as_str())
1561                .collect::<Vec<_>>(),
1562            ["app", "app-2"]
1563        );
1564        assert_eq!(
1565            bundle
1566                .repositories
1567                .iter()
1568                .map(|repository| repository.destination.to_string_lossy().into_owned())
1569                .collect::<Vec<_>>(),
1570            ["app".to_owned(), "app-2".to_owned()]
1571        );
1572        assert_eq!(
1573            bundle.repositories[0].github.as_deref(),
1574            Some("example/app")
1575        );
1576        assert_eq!(bundle.repositories[1].github.as_deref(), Some("other/app"));
1577    }
1578
1579    #[test]
1580    fn bundle_creation_combines_local_and_github_sources_and_rejects_local_aliases() {
1581        let directory = tempfile::tempdir().unwrap();
1582        let root = directory.path().join("app");
1583        let output = hel::hel_subprocess::run_capturing_stdout(
1584            std::process::Command::new("git").arg("init").arg(&root),
1585        )
1586        .unwrap();
1587        assert!(output.status.success(), "{output:?}");
1588        let nested = root.join("nested");
1589        fs::create_dir(&nested).unwrap();
1590        let mut config = HelConfig::default();
1591        let sources = vec![root.to_str().unwrap().into(), "example/shared".into()];
1592        let id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1593        let bundle = &config.bundles[&id];
1594        assert_eq!(
1595            bundle.primary().unwrap().local,
1596            Some(root.canonicalize().unwrap())
1597        );
1598        assert_eq!(
1599            bundle.repositories[1].github.as_deref(),
1600            Some("example/shared")
1601        );
1602        let before = config.clone();
1603        let aliases = vec![
1604            root.to_str().unwrap().into(),
1605            nested.to_str().unwrap().into(),
1606        ];
1607        let error = create_bundle_from_sources_in_config(&mut config, &aliases).unwrap_err();
1608        assert!(
1609            error.to_string().contains("duplicate repository source"),
1610            "{error:#}"
1611        );
1612        assert_eq!(config, before);
1613    }
1614
1615    #[test]
1616    fn bundle_creation_rejects_duplicate_normalized_sources_atomically() {
1617        let mut config = HelConfig::default();
1618        let before = config.clone();
1619        let sources = vec![
1620            "example/app".into(),
1621            "https://github.com/EXAMPLE/APP.git".into(),
1622        ];
1623
1624        let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1625        assert!(error.to_string().contains("duplicate repository source"));
1626        assert_eq!(config, before);
1627    }
1628
1629    #[test]
1630    fn bundle_creation_validates_every_source_before_mutating_config() {
1631        let mut config = HelConfig::default();
1632        let before = config.clone();
1633        let invalid_directory = tempfile::tempdir().unwrap();
1634        let sources = vec![
1635            "example/app".into(),
1636            invalid_directory.path().to_string_lossy().into_owned(),
1637        ];
1638
1639        let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1640        assert!(error.to_string().contains("not a Git repository"));
1641        assert_eq!(config, before);
1642    }
1643
1644    #[test]
1645    fn bundle_creation_reuses_an_exact_source_set_and_rejects_obsolete_pins() {
1646        let mut config = HelConfig::default();
1647        config.bundles.insert(
1648            "all".into(),
1649            ProjectBundle {
1650                primary_repo: "app".into(),
1651                repositories: vec![
1652                    ProjectRepository {
1653                        id: "app".into(),
1654                        github: Some("example/app".into()),
1655                        local: None,
1656                        destination: "app".into(),
1657                        git_ref: None,
1658                    },
1659                    ProjectRepository {
1660                        id: "shared".into(),
1661                        github: Some("example/shared".into()),
1662                        local: None,
1663                        destination: "shared".into(),
1664                        git_ref: None,
1665                    },
1666                ],
1667            },
1668        );
1669
1670        let one_source = vec!["example/app".into()];
1671        let created = create_bundle_from_sources_in_config(&mut config, &one_source).unwrap();
1672        assert_eq!(created, "app");
1673        assert_eq!(config.bundles[&created].repositories.len(), 1);
1674        assert_eq!(
1675            create_bundle_from_sources_in_config(&mut config, &one_source).unwrap(),
1676            "app"
1677        );
1678
1679        let exact_sources = vec!["example/app".into(), "example/shared".into()];
1680        assert_eq!(
1681            create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap(),
1682            "all"
1683        );
1684        assert_eq!(config.bundles.len(), 2);
1685        config.bundles.get_mut("all").unwrap().repositories[0].git_ref = Some("release".into());
1686        let before = config.clone();
1687        let error = create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap_err();
1688        assert!(format!("{error:#}").contains("git_ref is no longer supported"));
1689        assert_eq!(config, before);
1690    }
1691
1692    fn launch_options(additional_mounts: Vec<AdditionalMount>) -> SessionLaunchOptions {
1693        SessionLaunchOptions {
1694            initial_prompt: None,
1695            workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1696            additional_mounts,
1697            allow_dirty_local: false,
1698            resource_allocation: None,
1699            project_directory: None,
1700            session_title_override: None,
1701        }
1702    }
1703
1704    #[test]
1705    fn registration_rejects_a_disabled_profile_before_persisting() {
1706        let mut config = registration_config();
1707        config.profiles.get_mut("codex").unwrap().enabled = false;
1708        let mut controller = Controller {
1709            config,
1710            state: HelState::default(),
1711        };
1712
1713        let error = controller
1714            .register_session_with_resources(
1715                "codex",
1716                "project",
1717                "podman",
1718                "disabled",
1719                launch_options(Vec::new()),
1720            )
1721            .unwrap_err();
1722
1723        assert!(error.to_string().contains("disabled"));
1724        assert!(controller.state.sessions.is_empty());
1725    }
1726
1727    #[test]
1728    fn deepseek_registration_rejects_more_than_one_workspace_root_before_persisting() {
1729        let mut config = registration_config();
1730        config.profiles.get_mut("codex").unwrap().kind = HarnessKind::Deepseek;
1731        let second = config.bundles["project"].repositories[0].clone();
1732        config
1733            .bundles
1734            .get_mut("project")
1735            .unwrap()
1736            .repositories
1737            .push(hel::hel_config::ProjectRepository {
1738                id: "second".into(),
1739                destination: "second".into(),
1740                ..second
1741            });
1742        let mut controller = Controller {
1743            config,
1744            state: HelState::default(),
1745        };
1746
1747        let error = controller
1748            .register_session_with_resources(
1749                "codex",
1750                "project",
1751                "podman",
1752                "unsupported",
1753                launch_options(Vec::new()),
1754            )
1755            .unwrap_err();
1756
1757        assert!(error.to_string().contains("one workspace root"));
1758        assert!(controller.state.sessions.is_empty());
1759    }
1760
1761    /// MJ_DATA_DIR is process-global, so every test that reaches the
1762    /// controller database runs in an exact child with its own data directory.
1763    fn run_registration_child(marker: &str, test: &str, data_directory: &Path) {
1764        let output = std::process::Command::new(std::env::current_exe().unwrap())
1765            .args([
1766                "--exact",
1767                &format!("hel_controller::tests::{test}"),
1768                "--nocapture",
1769            ])
1770            .env(marker, "1")
1771            .env("MJ_DATA_DIR", data_directory)
1772            .env("MJ_CONFIG_DIR", data_directory)
1773            .output()
1774            .unwrap();
1775        assert!(
1776            output.status.success(),
1777            "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1778            String::from_utf8_lossy(&output.stdout),
1779            String::from_utf8_lossy(&output.stderr)
1780        );
1781    }
1782
1783    #[test]
1784    fn registration_saves_the_initial_task_before_provisioning() {
1785        const MARKER: &str = "MJ_TEST_INITIAL_TASK_CHILD";
1786        if std::env::var_os(MARKER).is_none() {
1787            let directory = tempfile::tempdir().unwrap();
1788            run_registration_child(
1789                MARKER,
1790                "registration_saves_the_initial_task_before_provisioning",
1791                directory.path(),
1792            );
1793            return;
1794        }
1795        let _writer = hel::hel_database::install_isolated_test_writer();
1796        let mut controller = Controller {
1797            config: registration_config(),
1798            state: HelState::default(),
1799        };
1800        let prompt = format!(
1801            "Initial task\n{}\n\tPreserve indentation and λ",
1802            "x".repeat(70_000)
1803        );
1804        let mut options = launch_options(Vec::new());
1805        options.initial_prompt = Some(prompt.clone());
1806        let id = controller
1807            .register_session_with_resources("codex", "project", "podman", "fresh task", options)
1808            .unwrap();
1809        let saved = hel::hel_database::load_state().unwrap();
1810        assert_eq!(saved.sessions[&id].draft_input, prompt);
1811        assert_eq!(saved.sessions[&id].state, SessionState::Provisioning);
1812        hel::hel_database::set_session_draft_input(&id, "a newer draft").unwrap();
1813        hel::hel_database::clear_session_draft_input_if_matches(&id, &prompt).unwrap();
1814        assert_eq!(
1815            hel::hel_database::load_state().unwrap().sessions[&id].draft_input,
1816            "a newer draft"
1817        );
1818        hel::hel_database::clear_session_draft_input_if_matches(&id, "a newer draft").unwrap();
1819        assert!(
1820            hel::hel_database::load_state().unwrap().sessions[&id]
1821                .draft_input
1822                .is_empty()
1823        );
1824    }
1825
1826    const UNPERSISTABLE_SESSION_CHILD: &str = "MJ_TEST_UNPERSISTABLE_SESSION_CHILD";
1827
1828    const CONFIG_ID_RENAME_CHILD: &str = "MJ_TEST_CONFIG_ID_RENAME_CHILD";
1829
1830    #[test]
1831    fn configuration_id_rename_rewrites_durable_session_references() {
1832        if std::env::var_os(CONFIG_ID_RENAME_CHILD).is_none() {
1833            let directory = tempfile::tempdir().unwrap();
1834            run_registration_child(
1835                CONFIG_ID_RENAME_CHILD,
1836                "configuration_id_rename_rewrites_durable_session_references",
1837                directory.path(),
1838            );
1839            return;
1840        }
1841        // Alone in this child process, so it installs the one writer.
1842        let _writer = hel::hel_database::install_isolated_test_writer();
1843
1844        let mut controller = Controller {
1845            config: registration_config(),
1846            state: HelState::default(),
1847        };
1848        controller.config.startup.profile = Some("codex".into());
1849        controller.config.startup.target = Some("podman".into());
1850        controller.config.save().unwrap();
1851        let session_id = controller
1852            .register_session_with_resources(
1853                "codex",
1854                "project",
1855                "podman",
1856                "rename references",
1857                launch_options(Vec::new()),
1858            )
1859            .unwrap();
1860
1861        controller
1862            .rename_profile_id("codex", "codex-renamed")
1863            .unwrap();
1864        controller
1865            .rename_target_id("podman", "podman-renamed")
1866            .unwrap();
1867
1868        let loaded = Controller::load().unwrap();
1869        let session = &loaded.state.sessions[&session_id];
1870        assert_eq!(session.last_profile, "codex-renamed");
1871        assert_eq!(session.target_template_id, "podman-renamed");
1872        assert!(loaded.config.profiles.contains_key("codex-renamed"));
1873        assert!(loaded.config.targets.contains_key("podman-renamed"));
1874        assert_eq!(
1875            loaded.config.startup.profile.as_deref(),
1876            Some("codex-renamed")
1877        );
1878        assert_eq!(
1879            loaded.config.startup.target.as_deref(),
1880            Some("podman-renamed")
1881        );
1882        assert!(!config_rename_journal_path().exists());
1883    }
1884
1885    #[test]
1886    fn a_session_the_database_rejects_is_never_left_in_memory() {
1887        if std::env::var_os(UNPERSISTABLE_SESSION_CHILD).is_none() {
1888            let directory = tempfile::tempdir().unwrap();
1889            run_registration_child(
1890                UNPERSISTABLE_SESSION_CHILD,
1891                "a_session_the_database_rejects_is_never_left_in_memory",
1892                directory.path(),
1893            );
1894            return;
1895        }
1896        // Alone in this child process, so it installs the one writer.
1897        let _writer = hel::hel_database::install_isolated_test_writer();
1898
1899        let mut controller = Controller {
1900            config: registration_config(),
1901            state: HelState::default(),
1902        };
1903        // The store has to be healthy enough to open before it can reject a
1904        // write: this test is about a write the database refuses, not about a
1905        // store that cannot be opened at all, which now fails earlier and
1906        // louder when the writer is installed. The first registration builds
1907        // the schema the second one then loses.
1908        controller
1909            .register_session_with_resources(
1910                "codex",
1911                "project",
1912                "podman",
1913                "first",
1914                launch_options(Vec::new()),
1915            )
1916            .expect("a healthy store registers a session");
1917        rusqlite::Connection::open(hel::hel_database::database_path())
1918            .unwrap()
1919            .execute_batch("DROP TABLE sessions")
1920            .unwrap();
1921
1922        let error = controller
1923            .register_session_with_resources(
1924                "codex",
1925                "project",
1926                "podman",
1927                "unpersistable",
1928                launch_options(Vec::new()),
1929            )
1930            .expect_err("a store that rejects the write cannot register a session");
1931        assert!(
1932            format!("{error:#}").contains("sessions"),
1933            "unexpected error: {error:#}"
1934        );
1935        assert_eq!(
1936            controller.state.sessions.len(),
1937            1,
1938            "a session the database never accepted stayed in controller memory"
1939        );
1940        assert!(
1941            controller
1942                .state
1943                .sessions
1944                .values()
1945                .all(|session| session.title != "unpersistable"),
1946            "the rejected session is the one that stayed"
1947        );
1948    }
1949
1950    const MOUNT_HISTORY_FAILURE_CHILD: &str = "MJ_TEST_MOUNT_HISTORY_FAILURE_CHILD";
1951
1952    const CONTAINER_SIZE_HISTORY_CHILD: &str = "MJ_TEST_CONTAINER_SIZE_HISTORY_CHILD";
1953
1954    #[test]
1955    fn registration_remembers_launch_size_but_session_overrides_do_not_replace_it() {
1956        if std::env::var_os(CONTAINER_SIZE_HISTORY_CHILD).is_none() {
1957            let directory = tempfile::tempdir().unwrap();
1958            run_registration_child(
1959                CONTAINER_SIZE_HISTORY_CHILD,
1960                "registration_remembers_launch_size_but_session_overrides_do_not_replace_it",
1961                directory.path(),
1962            );
1963            return;
1964        }
1965        // Alone in this child process, so it installs the one writer.
1966        let _writer = hel::hel_database::install_isolated_test_writer();
1967
1968        let mut controller = Controller {
1969            config: registration_config(),
1970            state: HelState::default(),
1971        };
1972        let mut options = launch_options(Vec::new());
1973        options.resource_allocation = Some(SessionResourceAllocation::Container {
1974            cpus: 12,
1975            memory_bytes: 48 * 1024 * 1024 * 1024,
1976        });
1977        let id = controller
1978            .register_session_with_resources("codex", "project", "podman", "sized", options)
1979            .unwrap();
1980        let expected = HostContainerSize {
1981            cpus: 12,
1982            memory_bytes: 48 * 1024 * 1024 * 1024,
1983        };
1984        assert_eq!(controller.state.container_sizes["local"], expected);
1985        assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1986
1987        controller
1988            .update_session_container_settings(
1989                &id,
1990                Some("2".into()),
1991                Some("4g".into()),
1992                Vec::new(),
1993                Vec::new(),
1994            )
1995            .unwrap();
1996        assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1997    }
1998
1999    #[test]
2000    fn a_failed_mount_history_write_does_not_fail_the_registered_session() {
2001        if std::env::var_os(MOUNT_HISTORY_FAILURE_CHILD).is_none() {
2002            let directory = tempfile::tempdir().unwrap();
2003            run_registration_child(
2004                MOUNT_HISTORY_FAILURE_CHILD,
2005                "a_failed_mount_history_write_does_not_fail_the_registered_session",
2006                directory.path(),
2007            );
2008            return;
2009        }
2010        // Alone in this child process, so it installs the one writer.
2011        let _writer = hel::hel_database::install_isolated_test_writer();
2012
2013        let mut controller = Controller {
2014            config: registration_config(),
2015            state: HelState::default(),
2016        };
2017        // The first registration builds the schema this test then breaks.
2018        controller
2019            .register_session_with_resources(
2020                "codex",
2021                "project",
2022                "podman",
2023                "first",
2024                launch_options(Vec::new()),
2025            )
2026            .expect("a healthy store registers a session");
2027        let database = hel::hel_database::database_path();
2028        rusqlite::Connection::open(&database)
2029            .unwrap()
2030            .execute_batch("DROP TABLE mount_history")
2031            .unwrap();
2032
2033        let id = controller
2034            .register_session_with_resources(
2035                "codex",
2036                "project",
2037                "podman",
2038                "attached",
2039                launch_options(vec![AdditionalMount {
2040                    source: PathBuf::from("/host/models"),
2041                    destination: PathBuf::from("/mnt/models"),
2042                    read_only: false,
2043                }]),
2044            )
2045            .expect("a suggestion list that cannot be written must not fail a registration");
2046
2047        let stored: i64 = rusqlite::Connection::open(&database)
2048            .unwrap()
2049            .query_row(
2050                "SELECT count(*) FROM sessions WHERE session_id = ?1",
2051                [&id],
2052                |row| row.get(0),
2053            )
2054            .unwrap();
2055        assert_eq!(stored, 1, "the registered session was not committed");
2056        assert!(
2057            controller.state.mount_history.is_empty(),
2058            "controller memory remembered mount sources the database never stored"
2059        );
2060    }
2061
2062    #[test]
2063    fn command_errors_report_the_root_cause_without_worker_wrappers() {
2064        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";
2065
2066        assert_eq!(
2067            command_error_detail(stderr),
2068            "checkpoint base b41dc78 is absent from configured source\nrepository may have moved"
2069        );
2070    }
2071
2072    #[test]
2073    fn controller_store_lock_excludes_a_second_process_owner() {
2074        let directory = tempfile::tempdir().unwrap();
2075        let first = ControllerStoreGuard::acquire_at(directory.path()).unwrap();
2076        run_controller_lock_probe(directory.path(), true);
2077        drop(first);
2078        run_controller_lock_probe(directory.path(), false);
2079    }
2080    fn run_controller_lock_probe(directory: &Path, expect_locked: bool) {
2081        let output = std::process::Command::new(std::env::current_exe().unwrap())
2082            .args([
2083                "--exact",
2084                "hel_controller::tests::controller_store_lock_subprocess_probe",
2085                "--nocapture",
2086            ])
2087            .env("MJ_CONTROLLER_LOCK_PROBE", directory)
2088            .env(
2089                "MJ_CONTROLLER_LOCK_EXPECTED",
2090                if expect_locked { "locked" } else { "available" },
2091            )
2092            .output()
2093            .unwrap();
2094        assert!(
2095            output.status.success(),
2096            "controller lock subprocess failed:\nstdout:\n{}\nstderr:\n{}",
2097            String::from_utf8_lossy(&output.stdout),
2098            String::from_utf8_lossy(&output.stderr)
2099        );
2100    }
2101    #[test]
2102    fn controller_store_lock_subprocess_probe() {
2103        let Some(directory) = std::env::var_os("MJ_CONTROLLER_LOCK_PROBE") else {
2104            return;
2105        };
2106        let expected = std::env::var("MJ_CONTROLLER_LOCK_EXPECTED").unwrap();
2107        let acquired = ControllerStoreGuard::acquire_at(Path::new(&directory));
2108        match expected.as_str() {
2109            "locked" => {
2110                let error = acquired.expect_err("a second process acquired the controller store");
2111                assert!(error.to_string().contains("another Mjolnir controller"));
2112            }
2113            "available" => {
2114                acquired.expect("released controller store stayed locked");
2115            }
2116            value => panic!("unexpected lock probe expectation {value:?}"),
2117        }
2118    }
2119    #[test]
2120    fn local_mount_source_must_be_an_existing_directory() {
2121        let directory = tempfile::tempdir().unwrap();
2122        let file = directory.path().join("file");
2123        std::fs::write(&file, "not a directory").unwrap();
2124        let mut config = HelConfig::default();
2125        config.targets.insert(
2126            "local".into(),
2127            TargetTemplate::LocalPodman {
2128                container: ConfigContainer {
2129                    image: "ubuntu:24.04".into(),
2130                    pull_policy: Default::default(),
2131                    platform: None,
2132                    cpus: None,
2133                    memory: None,
2134                    environment: BTreeMap::new(),
2135                    workspace_storage: Default::default(),
2136                },
2137            },
2138        );
2139        let controller = Controller {
2140            config,
2141            state: HelState::default(),
2142        };
2143
2144        assert!(
2145            controller
2146                .validate_mount_source("local", directory.path(), &ProcessExecutor)
2147                .is_ok()
2148        );
2149        for invalid in [file, directory.path().join("missing")] {
2150            let error = controller
2151                .validate_mount_source("local", &invalid, &ProcessExecutor)
2152                .unwrap_err();
2153            assert!(
2154                error
2155                    .to_string()
2156                    .contains("does not exist or is not a directory")
2157            );
2158        }
2159    }
2160}