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        // Missing session dependencies must not lock users out of the tools
477        // needed to repair them. Operations validate the session they act on.
478        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    /// Resolve an entered path on its owning host. Call only from background work.
530    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    /// Complete a mount source on the container engine host, preserving ~/ while editing.
545    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        // Completion is a text protocol: a trailing separator requests children.
564        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    /// Verify a mount source on the host where Mjolnir will consume it, and report
598    /// the filesystem reason it must be attached read-only, if there is one.
599    ///
600    /// The probe runs in the same round trip as the existence check so the
601    /// editor learns both answers without a second wait. A probe that cannot
602    /// answer reports no reason: provisioning decides that authoritatively.
603    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    /// The `filesystem (reason)` label for a source the runtime cannot overlay.
644    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            // Apple Container already mounts read-only, and EC2 copies instead
656            // of mounting, so neither has an overlay to lose.
657            _ => 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        // Creation authors the whole record, so it writes the whole row. The
817        // record reaches memory only once it is durable: a session this process
818        // alone knows about is one the database can never resume or clean up.
819        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            // Mount history only seeds the attach dialog's suggestions. The
830            // session row is already committed, so a failed suggestion write is
831            // reported rather than turned into a failed registration.
832            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    /// Finish a profile/target id rename interrupted between the atomic config
1031    /// replacement and SQLite transaction. Each step is idempotent, so a
1032    /// second crash leaves the same intent available for the next startup.
1033    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    /// Record the per-session container size overrides and attached
1073    /// directories. Nothing is applied to a running container: the values are
1074    /// read the next time the session's container is created.
1075    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            // The dialog owns the suggestion list, so forgetting a directory
1109            // there has to survive the mounts being remembered right after.
1110            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
1213/// Resolve the login home on the machine that owns an editable path.
1214pub 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    // Mjolnir drives ssh non-interactively from a TUI; a host-key or password
1286    // prompt would steal the terminal and wedge provisioning. BatchMode fails
1287    // fast instead of prompting, and accept-new trusts a first-seen host key
1288    // (fresh EC2 instances are always first-seen) while still rejecting
1289    // changed keys. User-supplied ssh_args come last so they can override.
1290    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    /// One profile, one bundle with nothing checked out locally, and one
1400    /// container target, which is all `register_session_with_resources` reads.
1401    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    /// MJ_DATA_DIR is process-global, so every test that reaches the
1769    /// controller database runs in an exact child with its own data directory.
1770    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        // Loading must not turn a configuration problem into a persisted lifecycle failure.
1889        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        // Alone in this child process, so it installs the one writer.
1920        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        // Alone in this child process, so it installs the one writer.
1975        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        // The store has to be healthy enough to open before it can reject a
1982        // write: this test is about a write the database refuses, not about a
1983        // store that cannot be opened at all, which now fails earlier and
1984        // louder when the writer is installed. The first registration builds
1985        // the schema the second one then loses.
1986        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        // Alone in this child process, so it installs the one writer.
2044        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        // Alone in this child process, so it installs the one writer.
2089        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        // The first registration builds the schema this test then breaks.
2096        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}