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 provisioning;
9mod readiness;
10mod recovery_scan;
11mod resume;
12mod reviewer;
13#[cfg(test)]
14mod test_support;
15mod worker_binary;
16mod worker_restart;
17mod worktree;
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::fs::{self, File, OpenOptions};
21use std::path::{Path, PathBuf};
22
23use anyhow::{Context, Result, bail, ensure};
24use chrono::Utc;
25
26use hel::hel_config::{
27    HelConfig, ProjectBundle, ProjectRepository, SshConnection, TargetTemplate, atomic_write,
28    container_size_host, data_dir, is_bare_project_target, mount_history_host,
29};
30
31use crate::hel_import::{
32    RepositoryIdentity, bundle_matches, configured_bundle_for_local, configured_bundle_for_origin,
33    setup_style_id,
34};
35use crate::hel_setup::github_repository_from_origin;
36
37const CONFIG_RENAME_JOURNAL: &str = "config-rename.json";
38
39#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "snake_case")]
41enum ConfigRenameKind {
42    Profile,
43    Target,
44}
45
46#[derive(Debug, serde::Serialize, serde::Deserialize)]
47#[serde(deny_unknown_fields)]
48struct ConfigRenameJournal {
49    kind: ConfigRenameKind,
50    old_id: String,
51    new_id: String,
52}
53use hel::hel_local_git::dirty_local_repositories;
54use hel::hel_state::{
55    HelState, HostContainerSize, SessionRecord, SessionResourceAllocation, SessionState,
56    new_session_id, normalize_session_title,
57};
58use hel::hel_targets::{
59    self, AdditionalMount, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
60};
61
62pub(crate) use backend::controller_github_token;
63pub use backend::image_refresh_plan;
64use backend::validate_resource_allocation;
65use provisioning::apply_failed_new_session_rollback;
66pub(crate) use worker_binary::refresh_remote_worker_binary_if_stale;
67pub(crate) use worktree::path_exists_on_managed_target;
68
69pub use checkpoint::{
70    CheckpointArtifact, CheckpointDeferred, checkpoint_was_deferred,
71    reconcile_managed_checkpoint_archives,
72};
73pub use recovery_scan::{RecoveryCandidate, RecoveryScan};
74pub use resume::{
75    ResumeRepositorySourceMismatch, ResumeRepositorySourcePreflight, ResumeRepositorySourceReceipt,
76};
77pub use worker_binary::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
78pub use worker_restart::WorkerUpgradeOutcome;
79pub use worktree::{ResumePlan, local_project_repository, resume_compatibility};
80
81pub struct Controller {
82    pub config: HelConfig,
83    pub state: HelState,
84}
85
86/// Machine-wide advisory lock for one controller data store. This prevents a
87/// dashboard, server, or CLI lifecycle command from concurrently acting as a
88/// second controller against the same SQLite state and relay sessions.
89#[derive(Debug)]
90pub struct ControllerStoreGuard {
91    file: File,
92}
93
94impl ControllerStoreGuard {
95    pub fn acquire() -> Result<Self> {
96        let directory = data_dir();
97        Self::acquire_at(&directory)
98    }
99
100    fn acquire_at(directory: &Path) -> Result<Self> {
101        Self::try_acquire_at(directory)?.with_context(|| {
102            format!(
103                "another Mjolnir controller is already using {}; stop it before starting this command",
104                directory.display()
105            )
106        })
107    }
108
109    /// Probe exclusivity without treating an owner that is still exiting as an error.
110    pub fn try_acquire() -> Result<Option<Self>> {
111        Self::try_acquire_at(&data_dir())
112    }
113
114    fn try_acquire_at(directory: &Path) -> Result<Option<Self>> {
115        std::fs::create_dir_all(directory)
116            .with_context(|| format!("create controller data directory {}", directory.display()))?;
117        let path = directory.join("controller.lock");
118        let mut options = OpenOptions::new();
119        options.create(true).read(true).write(true);
120        #[cfg(unix)]
121        {
122            use std::os::unix::fs::OpenOptionsExt;
123            options.mode(0o600);
124        }
125        let file = options
126            .open(&path)
127            .with_context(|| format!("open controller lock {}", path.display()))?;
128        match file.try_lock() {
129            Ok(()) => {}
130            Err(std::fs::TryLockError::WouldBlock) => return Ok(None),
131            Err(std::fs::TryLockError::Error(error)) => {
132                return Err(error)
133                    .with_context(|| format!("lock controller store {}", directory.display()));
134            }
135        }
136        Ok(Some(Self { file }))
137    }
138
139    /// Start the sole production SQLite writer after controller exclusivity
140    /// has been established by this guard.
141    pub fn start_database_writer(&self) -> Result<hel::hel_database::DatabaseWriterOwner> {
142        hel::hel_database::start_database_writer()
143    }
144}
145
146impl Drop for ControllerStoreGuard {
147    fn drop(&mut self) {
148        // Make release explicit. `File` also unlocks on close, but an explicit
149        // unlock keeps same-process handoff deterministic across platforms.
150        let _ = self.file.unlock();
151    }
152}
153
154/// The durable result of creating a quick bundle. The returned config is the
155/// same fresh config that was written, allowing a serving projection to publish
156/// the new bundle before acknowledging the request that created it.
157#[derive(Debug)]
158pub struct QuickBundleCreation {
159    pub config: HelConfig,
160    pub bundle_id: String,
161}
162
163/// Failure stages exposed to a viewer request without exposing the underlying
164/// filesystem/configuration error. The detailed error remains available to
165/// the caller for logs and terminal notices.
166#[derive(Debug)]
167pub enum QuickBundleFailure {
168    InvalidSource(anyhow::Error),
169    Persistence(anyhow::Error),
170}
171
172impl std::fmt::Display for QuickBundleFailure {
173    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            Self::InvalidSource(error) => write!(formatter, "invalid repository source: {error}"),
176            Self::Persistence(error) => write!(formatter, "persist quick bundle: {error}"),
177        }
178    }
179}
180
181impl std::error::Error for QuickBundleFailure {
182    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
183        match self {
184            Self::InvalidSource(error) | Self::Persistence(error) => Some(error.root_cause()),
185        }
186    }
187}
188
189/// Create a quick bundle from a local repository or GitHub source and persist
190/// it as one serialized fresh-config transaction. Identical sources reuse the
191/// existing configured bundle, matching the terminal's behavior. The returned
192/// config is the same fresh config that was written, allowing a serving
193/// projection to publish the new bundle before acknowledging the request.
194pub fn create_quick_bundle(
195    source: &str,
196) -> std::result::Result<QuickBundleCreation, QuickBundleFailure> {
197    let (config, bundle_id) = HelConfig::update(|config| {
198        create_quick_bundle_in_config(config, source)
199            .map_err(|error| anyhow::Error::new(QuickBundleFailure::InvalidSource(error)))
200    })
201    .map_err(|error| {
202        error
203            .downcast::<QuickBundleFailure>()
204            .unwrap_or_else(QuickBundleFailure::Persistence)
205    })?;
206    Ok(QuickBundleCreation { config, bundle_id })
207}
208
209/// Add a quick bundle to an already-loaded config. The helper still performs
210/// the local repository canonicalization/GitHub-source parsing, but callers
211/// that persist a config should use [`create_quick_bundle`] so concurrent saves
212/// cannot clobber one another.
213pub fn create_quick_bundle_in_config(config: &mut HelConfig, source: &str) -> Result<String> {
214    let source = interpret_repository_source(source)?;
215    let existing = match &source.kind {
216        RepositorySourceKind::Local(root) => configured_bundle_for_local(config, root),
217        RepositorySourceKind::Github(repository) => {
218            configured_bundle_for_origin(config, repository)
219        }
220    };
221    if let Some(existing) = existing {
222        return Ok(existing);
223    }
224    let repository_id = setup_style_id(&source.name);
225    let mut bundle_id = repository_id.clone();
226    for suffix in 2_u32.. {
227        if !config.bundles.contains_key(&bundle_id) {
228            break;
229        }
230        bundle_id = format!("{repository_id}-{suffix}");
231    }
232    config.bundles.insert(
233        bundle_id.clone(),
234        ProjectBundle {
235            primary_repo: repository_id.clone(),
236            repositories: vec![source.into_project_repository(repository_id.clone())],
237        },
238    );
239    config.validate()?;
240    Ok(bundle_id)
241}
242
243/// Create one bundle from one or more local repositories or GitHub sources.
244///
245/// All sources are interpreted and checked before the config transaction can
246/// write anything. An existing bundle is reused only when its repository set
247/// and primary repository exactly match the request; this keeps selecting one
248/// repository from a larger bundle from silently changing the wizard's choice.
249pub fn create_bundle_from_sources(
250    sources: &[String],
251) -> std::result::Result<QuickBundleCreation, QuickBundleFailure> {
252    let (config, bundle_id) = HelConfig::update(|config| {
253        create_bundle_from_sources_in_config(config, sources)
254            .map_err(|error| anyhow::Error::new(QuickBundleFailure::InvalidSource(error)))
255    })
256    .map_err(|error| {
257        error
258            .downcast::<QuickBundleFailure>()
259            .unwrap_or_else(QuickBundleFailure::Persistence)
260    })?;
261    Ok(QuickBundleCreation { config, bundle_id })
262}
263
264/// Add a bundle for all `sources` to an already-loaded config. The source
265/// interpretation is shared with the persisted [`create_bundle_from_sources`]
266/// entry point and the legacy quick-bundle helper.
267pub fn create_bundle_from_sources_in_config(
268    config: &mut HelConfig,
269    sources: &[String],
270) -> Result<String> {
271    let sources = sources
272        .iter()
273        .map(|source| interpret_repository_source(source))
274        .collect::<Result<Vec<_>>>()?;
275    if sources.is_empty() {
276        bail!("at least one repository source is required");
277    }
278
279    let mut identities = BTreeSet::new();
280    for source in &sources {
281        if !identities.insert(source.identity()) {
282            bail!("duplicate repository source {:?}", source.display_name);
283        }
284    }
285
286    if let Some(existing) = exact_configured_bundle(config, &sources) {
287        return Ok(existing);
288    }
289
290    // Build and validate a candidate before replacing the caller's config, so
291    // a later validation error cannot leave an in-memory partial mutation.
292    let mut updated = config.clone();
293    let mut used_repository_ids = BTreeSet::new();
294    let mut repositories = Vec::with_capacity(sources.len());
295    for source in sources {
296        let base = setup_style_id(&source.name);
297        let repository_id = unique_id(&base, |candidate| used_repository_ids.contains(candidate));
298        used_repository_ids.insert(repository_id.clone());
299        repositories.push(source.into_project_repository(repository_id));
300    }
301    let primary_repo = repositories
302        .first()
303        .map(|repository| repository.id.clone())
304        .context("at least one repository source is required")?;
305    let bundle_id = unique_id(&primary_repo, |candidate| {
306        updated.bundles.contains_key(candidate)
307    });
308    updated.bundles.insert(
309        bundle_id.clone(),
310        ProjectBundle {
311            primary_repo,
312            repositories,
313        },
314    );
315    updated.validate()?;
316    *config = updated;
317    Ok(bundle_id)
318}
319
320#[derive(Debug, Clone)]
321enum RepositorySourceKind {
322    Github(crate::hel_setup::GithubRepository),
323    Local(PathBuf),
324}
325
326#[derive(Debug, Clone)]
327struct InterpretedRepositorySource {
328    display_name: String,
329    name: String,
330    kind: RepositorySourceKind,
331}
332
333impl InterpretedRepositorySource {
334    fn identity(&self) -> RepositoryIdentity {
335        match &self.kind {
336            RepositorySourceKind::Github(repository) => RepositoryIdentity::Github(
337                repository.owner.to_ascii_lowercase(),
338                repository.repository.to_ascii_lowercase(),
339            ),
340            RepositorySourceKind::Local(root) => RepositoryIdentity::Local(root.clone()),
341        }
342    }
343
344    fn into_project_repository(self, id: String) -> ProjectRepository {
345        let (github, local) = match self.kind {
346            RepositorySourceKind::Github(repository) => (
347                Some(format!("{}/{}", repository.owner, repository.repository)),
348                None,
349            ),
350            RepositorySourceKind::Local(root) => (None, Some(root)),
351        };
352        ProjectRepository {
353            id: id.clone(),
354            github,
355            local,
356            destination: PathBuf::from(id),
357            git_ref: None,
358        }
359    }
360}
361
362/// Interpret a source once, including local Git canonicalization and GitHub
363/// parsing, so all creation paths use exactly the same source semantics.
364fn interpret_repository_source(source: &str) -> Result<InterpretedRepositorySource> {
365    let source = source.trim();
366    if source.is_empty() {
367        bail!("repository source cannot be empty");
368    }
369    let candidate = Path::new(source);
370    if candidate.exists() {
371        let root = hel::hel_local_git::canonical_repository(candidate)?;
372        let name = root
373            .file_name()
374            .and_then(|name| name.to_str())
375            .context("local repository has no usable directory name")?
376            .to_owned();
377        return Ok(InterpretedRepositorySource {
378            display_name: source.to_owned(),
379            name,
380            kind: RepositorySourceKind::Local(root),
381        });
382    }
383    if candidate.is_absolute() || source.starts_with('.') || source.starts_with('~') {
384        bail!("local repository path {source:?} does not exist");
385    }
386    let repository = github_repository_from_origin(source).context(format!(
387        "{source:?} is not a GitHub owner/repository or URL"
388    ))?;
389    Ok(InterpretedRepositorySource {
390        display_name: source.to_owned(),
391        name: repository.repository.clone(),
392        kind: RepositorySourceKind::Github(repository),
393    })
394}
395
396fn exact_configured_bundle(
397    config: &HelConfig,
398    requested: &[InterpretedRepositorySource],
399) -> Option<String> {
400    let requested_identities = requested
401        .iter()
402        .map(InterpretedRepositorySource::identity)
403        .collect::<BTreeSet<_>>();
404    let primary = requested.first()?.identity();
405    config.bundles.iter().find_map(|(id, bundle)| {
406        if bundle.repositories.len() != requested.len()
407            || bundle
408                .repositories
409                .iter()
410                .any(|repository| repository.git_ref.is_some())
411        {
412            return None;
413        }
414        bundle_matches(bundle, &requested_identities, &primary).then(|| id.clone())
415    })
416}
417
418fn unique_id(base: &str, mut is_used: impl FnMut(&str) -> bool) -> String {
419    if !is_used(base) {
420        return base.to_owned();
421    }
422    for suffix in 2_u32.. {
423        let suffix = format!("-{suffix}");
424        let prefix_len = 64usize.saturating_sub(suffix.len());
425        let prefix = base.chars().take(prefix_len).collect::<String>();
426        let candidate = format!("{prefix}{suffix}");
427        if !is_used(&candidate) {
428            return candidate;
429        }
430    }
431    unreachable!("u32 repository/bundle id suffixes exhausted")
432}
433
434pub struct SessionLaunchOptions {
435    pub initial_prompt: Option<String>,
436    pub workspace_id: String,
437    pub additional_mounts: Vec<AdditionalMount>,
438    pub allow_dirty_local: bool,
439    pub resource_allocation: Option<SessionResourceAllocation>,
440    pub project_directory: Option<PathBuf>,
441    pub session_title_override: Option<String>,
442}
443
444pub struct SessionResumeOptions {
445    pub additional_mounts: Option<Vec<AdditionalMount>>,
446    pub resource_allocation: Option<SessionResourceAllocation>,
447    pub discard_queue: bool,
448}
449
450fn selected_host_container_size(
451    template: &TargetTemplate,
452    allocation: Option<&SessionResourceAllocation>,
453) -> Option<(String, HostContainerSize)> {
454    let host = container_size_host(template)?;
455    let SessionResourceAllocation::Container { cpus, memory_bytes } = allocation? else {
456        return None;
457    };
458    Some((
459        host.to_owned(),
460        HostContainerSize {
461            cpus: *cpus,
462            memory_bytes: *memory_bytes,
463        },
464    ))
465}
466
467impl Controller {
468    pub fn load() -> Result<Self> {
469        let config = HelConfig::load()?;
470        let state = HelState::load()?;
471        state.validate_against_config(&config)?;
472        Ok(Self { config, state })
473    }
474
475    pub fn reload(&mut self) -> Result<()> {
476        *self = Self::load()?;
477        Ok(())
478    }
479
480    fn persist_session_state(&self, session_id: &str) -> Result<()> {
481        match self.state.sessions.get(session_id) {
482            Some(session) => hel::hel_database::save_lifecycle_session(session),
483            None => hel::hel_database::delete_session(session_id),
484        }
485    }
486
487    fn persist_session_transition_or_restore(
488        &mut self,
489        session_id: &str,
490        previous: &SessionRecord,
491        context: &'static str,
492    ) -> Result<()> {
493        persist_session_record_transition_or_restore(
494            &mut self.state,
495            session_id,
496            previous,
497            context,
498            &hel::hel_database::save_lifecycle_session,
499        )
500    }
501
502    fn restore_prior_session_after_persistence_failure(
503        &mut self,
504        session_id: &str,
505        previous: &SessionRecord,
506        primary: anyhow::Error,
507    ) -> anyhow::Error {
508        restore_session_after_persistence_failure(
509            &mut self.state,
510            session_id,
511            previous,
512            primary,
513            hel::hel_database::save_lifecycle_session,
514        )
515    }
516
517    /// Complete a mount source at the same host that will run the container.
518    pub fn complete_mount_source(
519        &self,
520        target_id: &str,
521        prefix: &str,
522        executor: &impl CommandExecutor,
523    ) -> Result<Vec<String>> {
524        let target = self
525            .config
526            .targets
527            .get(target_id)
528            .with_context(|| format!("unknown target template {target_id:?}"))?;
529        match target {
530            TargetTemplate::LocalPodman { .. }
531            | TargetTemplate::LocalDocker { .. }
532            | TargetTemplate::AppleContainer { .. }
533            | TargetTemplate::AwsEc2 { .. } => Ok(hel_targets::local_directory_completions(prefix)),
534            TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
535                hel_targets::ssh_directory_completions(&backend_ssh(ssh), prefix, executor)
536            }
537            TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
538                bail!("resource path completion is unsupported for bare targets")
539            }
540        }
541    }
542
543    /// Verify a mount source on the host where Mjolnir will consume it, and report
544    /// the filesystem reason it must be attached read-only, if there is one.
545    ///
546    /// The probe runs in the same round trip as the existence check so the
547    /// editor learns both answers without a second wait. A probe that cannot
548    /// answer reports no reason: provisioning decides that authoritatively.
549    pub fn validate_mount_source(
550        &self,
551        target_id: &str,
552        source: &Path,
553        executor: &impl CommandExecutor,
554    ) -> Result<Option<String>> {
555        let target = self
556            .config
557            .targets
558            .get(target_id)
559            .with_context(|| format!("unknown target template {target_id:?}"))?;
560        let exists = match target {
561            TargetTemplate::LocalPodman { .. }
562            | TargetTemplate::LocalDocker { .. }
563            | TargetTemplate::AppleContainer { .. }
564            | TargetTemplate::AwsEc2 { .. } => std::fs::metadata(source)
565                .map(|metadata| metadata.is_dir())
566                .or_else(|error| {
567                    if error.kind() == std::io::ErrorKind::NotFound {
568                        Ok(false)
569                    } else {
570                        Err(error)
571                    }
572                })
573                .with_context(|| format!("inspect resource source {}", source.display()))?,
574            TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
575                hel_targets::ssh_directory_exists(&backend_ssh(ssh), source, executor)?
576            }
577            TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
578                bail!("resource attachments are unsupported for bare targets")
579            }
580        };
581        ensure!(
582            exists,
583            "source path {} does not exist or is not a directory",
584            source.display()
585        );
586        Ok(self.forced_read_only_reason(target, source, executor))
587    }
588
589    /// The `filesystem (reason)` label for a source the runtime cannot overlay.
590    fn forced_read_only_reason(
591        &self,
592        target: &TargetTemplate,
593        source: &Path,
594        executor: &impl CommandExecutor,
595    ) -> Option<String> {
596        let ssh = match target {
597            TargetTemplate::LocalPodman { .. } | TargetTemplate::LocalDocker { .. } => None,
598            TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
599                Some(backend_ssh(ssh))
600            }
601            // Apple Container already mounts read-only, and EC2 copies instead
602            // of mounting, so neither has an overlay to lose.
603            _ => return None,
604        };
605        let filesystem = hel_targets::probe_filesystem_types(
606            ssh.as_ref(),
607            std::slice::from_ref(&source.to_path_buf()),
608            executor,
609        )
610        .map_err(|error| {
611            tracing::debug!(
612                source = %source.display(),
613                error = format!("{error:#}"),
614                "could not probe the filesystem under a mount source"
615            );
616        })
617        .ok()?
618        .pop()?;
619        let reason = hel_targets::overlay_unsupported_filesystem(&filesystem)?;
620        Some(format!("{filesystem} ({reason})"))
621    }
622
623    fn fail_new_session_with_cleanup(
624        &mut self,
625        session_id: &str,
626        error: anyhow::Error,
627        executor: &impl CommandExecutor,
628    ) -> Result<anyhow::Error> {
629        let original = format!("{error:#}");
630        let cleanup_error = self
631            .cleanup_new_session_worktree_after_failure(session_id, executor)
632            .err()
633            .map(|cleanup_error| format!("{cleanup_error:#}"));
634        if let Some(cleanup_error) = &cleanup_error {
635            tracing::warn!(
636                session_id,
637                error = %cleanup_error,
638                "new-session worktree rollback reported a cleanup failure"
639            );
640        }
641        let failure = apply_failed_new_session_rollback(
642            &mut self.state,
643            session_id,
644            &original,
645            cleanup_error,
646        );
647        self.persist_session_state(session_id)?;
648        Ok(failure)
649    }
650
651    pub fn register_session_with_resources(
652        &mut self,
653        profile_id: &str,
654        bundle_id: &str,
655        target_id: &str,
656        title: impl Into<String>,
657        options: SessionLaunchOptions,
658    ) -> Result<String> {
659        let SessionLaunchOptions {
660            initial_prompt,
661            workspace_id,
662            additional_mounts,
663            allow_dirty_local,
664            resource_allocation,
665            project_directory,
666            session_title_override,
667        } = options;
668        let session_title_override = match session_title_override {
669            Some(title) => {
670                Some(normalize_session_title(&title).context("session name cannot be empty")?)
671            }
672            None => None,
673        };
674        let profile = self
675            .config
676            .profiles
677            .get(profile_id)
678            .with_context(|| format!("unknown profile {profile_id:?}"))?;
679        let template = self
680            .config
681            .targets
682            .get(target_id)
683            .with_context(|| format!("unknown target template {target_id:?}"))?;
684        if project_directory.is_some() != is_bare_project_target(template) {
685            bail!("raw project directories require a bare target, and bare targets require one");
686        }
687        if let Some(path) = &project_directory
688            && (!path.is_absolute()
689                || path
690                    .components()
691                    .any(|part| part == std::path::Component::ParentDir))
692        {
693            bail!("bare project directory must be an absolute safe path");
694        }
695        let bundle = project_directory
696            .is_none()
697            .then(|| self.config.bundles.get(bundle_id))
698            .flatten();
699        if project_directory.is_none() && bundle.is_none() {
700            bail!("unknown bundle {bundle_id:?}");
701        }
702        if matches!(
703            profile.kind,
704            hel::hel_config::HarnessKind::Deepseek | hel::hel_config::HarnessKind::Muse
705        ) && (!additional_mounts.is_empty()
706            || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
707        {
708            bail!(
709                "{} ACP supports one workspace root; use a single-repository bundle without attached directories",
710                profile.kind.display_name()
711            );
712        }
713        let dirty = bundle
714            .map(dirty_local_repositories)
715            .transpose()?
716            .unwrap_or_default();
717        if !allow_dirty_local && !dirty.is_empty() {
718            let repositories = dirty
719                .iter()
720                .map(|repository| format!("{} ({})", repository.path.display(), repository.summary))
721                .collect::<Vec<_>>()
722                .join(", ");
723            bail!(
724                "local repositories have uncommitted changes: {repositories}; explicit confirmation is required"
725            );
726        }
727        validate_resource_allocation(template, resource_allocation.as_ref())?;
728        let selected_container_size =
729            selected_host_container_size(template, resource_allocation.as_ref());
730        if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
731            bail!("attached resources are unsupported for this target");
732        }
733        hel_targets::validate_additional_mounts(&additional_mounts)?;
734        let id = new_session_id()?;
735        let now = now();
736        let record = SessionRecord {
737            archived: false,
738            container_cpus: None,
739            container_memory: None,
740            id: id.clone(),
741            workspace_id,
742            title: title.into(),
743            harness_kind: profile.kind,
744            last_profile: profile_id.to_string(),
745            bundle_id: bundle_id.to_string(),
746            project_directory,
747            managed_worktree: None,
748            target_template_id: target_id.to_string(),
749            resource_allocation,
750            additional_mounts: additional_mounts.clone(),
751            state: SessionState::Provisioning,
752            target: None,
753            native_session_id: None,
754            acp_session_title: None,
755            session_title_override,
756            created_at: now.clone(),
757            updated_at: now,
758            viewed_through_event_ordinal: 0,
759            draft_input: initial_prompt.unwrap_or_default(),
760            last_error: None,
761            last_checkpoint_error: None,
762            checkpoint: None,
763        };
764        // Creation authors the whole record, so it writes the whole row. The
765        // record reaches memory only once it is durable: a session this process
766        // alone knows about is one the database can never resume or clean up.
767        if let Some((host, size)) = selected_container_size.as_ref() {
768            hel::hel_database::save_session_with_container_size(&record, host, *size)?;
769        } else {
770            hel::hel_database::save_session(&record)?;
771        }
772        self.state.sessions.insert(id.clone(), record);
773        if let Some((host, size)) = selected_container_size {
774            self.state.remember_container_size(&host, size);
775        }
776        if let Some(host) = mount_history_host(template) {
777            // Mount history only seeds the attach dialog's suggestions. The
778            // session row is already committed, so a failed suggestion write is
779            // reported rather than turned into a failed registration.
780            match hel::hel_database::remember_mount_sources(host, &additional_mounts) {
781                Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
782                Err(error) => tracing::warn!(
783                    session_id = id,
784                    error = format!("{error:#}"),
785                    "could not remember the attached resource directories for later suggestions"
786                ),
787            }
788        }
789        Ok(id)
790    }
791
792    pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
793        let title = normalize_session_title(title).context("session name cannot be empty")?;
794        ensure!(
795            self.state.sessions.contains_key(session_id),
796            "unknown session {session_id}"
797        );
798        let updated_at = now();
799        hel::hel_database::set_session_title_override(session_id, &title, &updated_at)?;
800        let record = self
801            .state
802            .sessions
803            .get_mut(session_id)
804            .expect("session was checked before updating its title");
805        record.session_title_override = Some(title.clone());
806        record.updated_at = updated_at;
807        Ok(title)
808    }
809
810    pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
811        hel::hel_config::validate_id("profile", new_id)?;
812        if old_id == new_id {
813            ensure!(
814                self.config.profiles.contains_key(old_id),
815                "unknown profile {old_id:?}"
816            );
817            return Ok(());
818        }
819        let journal = ConfigRenameJournal {
820            kind: ConfigRenameKind::Profile,
821            old_id: old_id.to_owned(),
822            new_id: new_id.to_owned(),
823        };
824        write_config_rename_journal(&journal)?;
825        let (config, ()) = match HelConfig::update(|config| {
826            ensure!(
827                config.profiles.contains_key(old_id),
828                "unknown profile {old_id:?}"
829            );
830            ensure!(
831                !config.profiles.contains_key(new_id),
832                "profile {new_id:?} already exists"
833            );
834            let profile = config
835                .profiles
836                .remove(old_id)
837                .expect("profile was checked in the transaction");
838            config.profiles.insert(new_id.to_owned(), profile);
839            if config.startup.profile.as_deref() == Some(old_id) {
840                config.startup.profile = Some(new_id.to_owned());
841            }
842            Ok(())
843        }) {
844            Ok(result) => result,
845            Err(error) => {
846                remove_config_rename_journal()
847                    .context("remove profile rename journal after config save failed")?;
848                return Err(error).context("save renamed profile configuration");
849            }
850        };
851        self.config = config;
852        hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
853        if let Err(error) = hel::hel_database::rename_profile_references(old_id, new_id) {
854            let restore = HelConfig::update(|config| {
855                let profile = config
856                    .profiles
857                    .remove(new_id)
858                    .with_context(|| format!("renamed profile {new_id:?} is missing"))?;
859                ensure!(
860                    !config.profiles.contains_key(old_id),
861                    "cannot restore profile rename: both {old_id:?} and {new_id:?} exist"
862                );
863                config.profiles.insert(old_id.to_owned(), profile);
864                if config.startup.profile.as_deref() == Some(new_id) {
865                    config.startup.profile = Some(old_id.to_owned());
866                }
867                Ok(())
868            });
869            let restored = match restore {
870                Ok((config, ())) => config,
871                Err(restore_error) => {
872                    return Err(error).context(format!(
873                        "rename profile references; additionally failed to restore config: {restore_error:#}"
874                    ));
875                }
876            };
877            self.config = restored;
878            if let Err(restore_error) = remove_config_rename_journal() {
879                return Err(error).context(format!(
880                    "rename profile references; additionally failed to remove rename journal: {restore_error:#}"
881                ));
882            }
883            return Err(error).context("rename profile references");
884        }
885        for session in self.state.sessions.values_mut() {
886            if session.last_profile == old_id {
887                session.last_profile = new_id.to_owned();
888            }
889        }
890        remove_config_rename_journal()?;
891        Ok(())
892    }
893
894    pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
895        hel::hel_config::validate_id("target template", new_id)?;
896        if old_id == new_id {
897            ensure!(
898                self.config.targets.contains_key(old_id),
899                "unknown target {old_id:?}"
900            );
901            return Ok(());
902        }
903        let journal = ConfigRenameJournal {
904            kind: ConfigRenameKind::Target,
905            old_id: old_id.to_owned(),
906            new_id: new_id.to_owned(),
907        };
908        write_config_rename_journal(&journal)?;
909        let (config, ()) = match HelConfig::update(|config| {
910            ensure!(
911                config.targets.contains_key(old_id),
912                "unknown target {old_id:?}"
913            );
914            ensure!(
915                !config.targets.contains_key(new_id),
916                "target {new_id:?} already exists"
917            );
918            let target = config
919                .targets
920                .remove(old_id)
921                .expect("target was checked in the transaction");
922            config.targets.insert(new_id.to_owned(), target);
923            if config.startup.target.as_deref() == Some(old_id) {
924                config.startup.target = Some(new_id.to_owned());
925            }
926            Ok(())
927        }) {
928            Ok(result) => result,
929            Err(error) => {
930                remove_config_rename_journal()
931                    .context("remove target rename journal after config save failed")?;
932                return Err(error).context("save renamed target configuration");
933            }
934        };
935        self.config = config;
936        hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
937        if let Err(error) = hel::hel_database::rename_target_references(old_id, new_id) {
938            let restore = HelConfig::update(|config| {
939                let target = config
940                    .targets
941                    .remove(new_id)
942                    .with_context(|| format!("renamed target {new_id:?} is missing"))?;
943                ensure!(
944                    !config.targets.contains_key(old_id),
945                    "cannot restore target rename: both {old_id:?} and {new_id:?} exist"
946                );
947                config.targets.insert(old_id.to_owned(), target);
948                if config.startup.target.as_deref() == Some(new_id) {
949                    config.startup.target = Some(old_id.to_owned());
950                }
951                Ok(())
952            });
953            let restored = match restore {
954                Ok((config, ())) => config,
955                Err(restore_error) => {
956                    return Err(error).context(format!(
957                        "rename target references; additionally failed to restore config: {restore_error:#}"
958                    ));
959                }
960            };
961            self.config = restored;
962            if let Err(restore_error) = remove_config_rename_journal() {
963                return Err(error).context(format!(
964                    "rename target references; additionally failed to remove rename journal: {restore_error:#}"
965                ));
966            }
967            return Err(error).context("rename target references");
968        }
969        for session in self.state.sessions.values_mut() {
970            if session.target_template_id == old_id {
971                session.target_template_id = new_id.to_owned();
972            }
973        }
974        remove_config_rename_journal()?;
975        Ok(())
976    }
977
978    /// Finish a profile/target id rename interrupted between the atomic config
979    /// replacement and SQLite transaction. Each step is idempotent, so a
980    /// second crash leaves the same intent available for the next startup.
981    pub fn recover_config_id_rename() -> Result<bool> {
982        let path = config_rename_journal_path();
983        let body = match fs::read(&path) {
984            Ok(body) => body,
985            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
986            Err(error) => return Err(error).context(format!("read {}", path.display())),
987        };
988        let journal: ConfigRenameJournal =
989            serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
990        match journal.kind {
991            ConfigRenameKind::Profile => {
992                HelConfig::update(|config| {
993                    finish_config_map_rename(
994                        &mut config.profiles,
995                        &journal.old_id,
996                        &journal.new_id,
997                        "profile",
998                    )?;
999                    Ok(())
1000                })?;
1001                hel::hel_database::rename_profile_references(&journal.old_id, &journal.new_id)?;
1002            }
1003            ConfigRenameKind::Target => {
1004                HelConfig::update(|config| {
1005                    finish_config_map_rename(
1006                        &mut config.targets,
1007                        &journal.old_id,
1008                        &journal.new_id,
1009                        "target",
1010                    )?;
1011                    Ok(())
1012                })?;
1013                hel::hel_database::rename_target_references(&journal.old_id, &journal.new_id)?;
1014            }
1015        }
1016        remove_config_rename_journal()?;
1017        Ok(true)
1018    }
1019
1020    /// Record the per-session container size overrides and attached
1021    /// directories. Nothing is applied to a running container: the values are
1022    /// read the next time the session's container is created.
1023    pub fn update_session_container_settings(
1024        &mut self,
1025        session_id: &str,
1026        cpus: Option<String>,
1027        memory: Option<String>,
1028        additional_mounts: Vec<hel_targets::AdditionalMount>,
1029        mount_history: Vec<std::path::PathBuf>,
1030    ) -> Result<()> {
1031        ensure!(
1032            self.state.sessions.contains_key(session_id),
1033            "unknown session {session_id}"
1034        );
1035        let cpus = cpus.filter(|value| !value.trim().is_empty());
1036        let memory = memory.filter(|value| !value.trim().is_empty());
1037        let updated_at = now();
1038        hel::hel_database::set_session_container_settings(
1039            session_id,
1040            cpus.as_deref(),
1041            memory.as_deref(),
1042            &additional_mounts,
1043            &updated_at,
1044        )?;
1045        if let Some(host) = self
1046            .config
1047            .targets
1048            .get(
1049                &self.state.sessions[session_id]
1050                    .target_template_id
1051                    .to_owned(),
1052            )
1053            .and_then(hel::hel_config::mount_history_host)
1054        {
1055            let host = host.to_owned();
1056            // The dialog owns the suggestion list, so forgetting a directory
1057            // there has to survive the mounts being remembered right after.
1058            hel::hel_database::replace_mount_history(&host, &mount_history)?;
1059            hel::hel_database::remember_mount_sources(&host, &additional_mounts)?;
1060            self.state.mount_history.insert(host.clone(), mount_history);
1061            self.state.remember_mount_sources(&host, &additional_mounts);
1062        }
1063        let record = self
1064            .state
1065            .sessions
1066            .get_mut(session_id)
1067            .expect("session was checked before updating its container settings");
1068        record.container_cpus = cpus;
1069        record.container_memory = memory;
1070        record.additional_mounts = additional_mounts;
1071        record.updated_at = updated_at;
1072        Ok(())
1073    }
1074}
1075
1076fn config_rename_journal_path() -> PathBuf {
1077    data_dir().join(CONFIG_RENAME_JOURNAL)
1078}
1079
1080fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
1081    let path = config_rename_journal_path();
1082    let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
1083    atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
1084}
1085
1086fn remove_config_rename_journal() -> Result<()> {
1087    let path = config_rename_journal_path();
1088    match fs::remove_file(&path) {
1089        Ok(()) => Ok(()),
1090        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1091        Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1092    }
1093}
1094
1095fn finish_config_map_rename<T>(
1096    entries: &mut BTreeMap<String, T>,
1097    old_id: &str,
1098    new_id: &str,
1099    kind: &str,
1100) -> Result<()> {
1101    if let Some(entry) = entries.remove(old_id) {
1102        ensure!(
1103            !entries.contains_key(new_id),
1104            "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
1105        );
1106        entries.insert(new_id.to_owned(), entry);
1107    } else {
1108        ensure!(
1109            entries.contains_key(new_id),
1110            "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
1111        );
1112    }
1113    Ok(())
1114}
1115
1116fn target_kind(locator: &hel_targets::TargetLocator) -> &'static str {
1117    match locator {
1118        hel_targets::TargetLocator::LocalBare { .. } => "local-bare",
1119        hel_targets::TargetLocator::LocalPodman { .. } => "local-podman",
1120        hel_targets::TargetLocator::LocalDocker { .. } => "local-docker",
1121        hel_targets::TargetLocator::AppleContainer { .. } => "apple-container",
1122        hel_targets::TargetLocator::AwsEc2 { .. } => "aws-ec2",
1123        hel_targets::TargetLocator::SshBare { .. } => "ssh-bare",
1124        hel_targets::TargetLocator::SshPodman { .. } => "ssh-podman",
1125        hel_targets::TargetLocator::SshDocker { .. } => "ssh-docker",
1126    }
1127}
1128
1129fn target_profile_home(
1130    locator: &hel_targets::TargetLocator,
1131    session_id: &str,
1132    profile: &hel::hel_config::HarnessProfile,
1133) -> String {
1134    let home = match locator {
1135        hel_targets::TargetLocator::LocalBare { .. } => profile.home.to_string_lossy().into_owned(),
1136        hel_targets::TargetLocator::LocalPodman { .. }
1137        | hel_targets::TargetLocator::LocalDocker { .. }
1138        | hel_targets::TargetLocator::AppleContainer { .. }
1139        | hel_targets::TargetLocator::SshPodman { .. }
1140        | hel_targets::TargetLocator::SshDocker { .. } => {
1141            format!("/var/lib/hel/profiles/{session_id}")
1142        }
1143        hel_targets::TargetLocator::AwsEc2 { .. } | hel_targets::TargetLocator::SshBare { .. } => {
1144            format!(".local/share/hel/profiles/{session_id}")
1145        }
1146    };
1147    if profile.kind == hel::hel_config::HarnessKind::Muse {
1148        let root = if matches!(locator, hel_targets::TargetLocator::LocalBare { .. }) {
1149            hel::hel_config::data_dir()
1150                .join("profiles")
1151                .join(session_id)
1152        } else {
1153            PathBuf::from(home)
1154        };
1155        root.join("muse").to_string_lossy().into_owned()
1156    } else {
1157        home
1158    }
1159}
1160
1161pub(crate) fn backend_ssh(ssh: &SshConnection) -> SshTarget {
1162    let destination = match &ssh.user {
1163        Some(user) => format!("{user}@{}", ssh.host),
1164        None => ssh.host.clone(),
1165    };
1166    SshTarget {
1167        destination,
1168        ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
1169    }
1170}
1171
1172fn ssh_command_spec(
1173    ssh: &SshTarget,
1174    args: impl IntoIterator<Item = impl AsRef<str>>,
1175) -> CommandSpec {
1176    let remote = args
1177        .into_iter()
1178        .map(|arg| arg.as_ref().to_string())
1179        .collect::<Vec<_>>();
1180    let mut command_args = ssh.ssh_args.clone();
1181    command_args.push(ssh.destination.clone());
1182    command_args.push(hel_targets::join_remote_command(&remote));
1183    CommandSpec::new("ssh", command_args)
1184}
1185
1186fn scp_command_spec(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
1187    let mut args = ssh.ssh_args.clone();
1188    if recursive {
1189        args.push("-r".into());
1190    }
1191    args.push(source.to_string_lossy().into_owned());
1192    args.push(format!("{}:{remote}", ssh.destination));
1193    CommandSpec::new("scp", args)
1194}
1195
1196fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
1197    // Mjolnir drives ssh non-interactively from a TUI; a host-key or password
1198    // prompt would steal the terminal and wedge provisioning. BatchMode fails
1199    // fast instead of prompting, and accept-new trusts a first-seen host key
1200    // (fresh EC2 instances are always first-seen) while still rejecting
1201    // changed keys. User-supplied ssh_args come last so they can override.
1202    let mut result = vec![
1203        "-o".into(),
1204        "BatchMode=yes".into(),
1205        "-o".into(),
1206        "StrictHostKeyChecking=accept-new".into(),
1207        "-o".into(),
1208        "ConnectTimeout=15".into(),
1209    ];
1210    result.extend(args.iter().cloned());
1211    if let Some(identity) = identity {
1212        result.push("-i".into());
1213        result.push(identity.to_string_lossy().into_owned());
1214    }
1215    result
1216}
1217
1218fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
1219    let output = executor.execute(&command)?;
1220    if output.status != 0 {
1221        let detail = command_error_detail(&output.stderr);
1222        if detail.is_empty() {
1223            bail!("{} failed with status {}", command.purpose, output.status);
1224        }
1225        bail!("{detail}");
1226    }
1227    Ok(output)
1228}
1229
1230fn command_error_detail(stderr: &[u8]) -> String {
1231    let reported = String::from_utf8_lossy(stderr);
1232    let reported = reported.trim();
1233    let detail = reported
1234        .rsplit_once("\nCaused by:\n")
1235        .map_or(reported, |(_, causes)| causes);
1236    let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
1237    detail
1238        .lines()
1239        .map(|line| line.strip_prefix("    ").unwrap_or(line))
1240        .collect::<Vec<_>>()
1241        .join("\n")
1242        .trim()
1243        .to_owned()
1244}
1245
1246fn now() -> String {
1247    Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1248}
1249
1250fn restore_session_after_persistence_failure(
1251    state: &mut HelState,
1252    session_id: &str,
1253    previous: &SessionRecord,
1254    primary: anyhow::Error,
1255    persist: impl FnOnce(&SessionRecord) -> Result<()>,
1256) -> anyhow::Error {
1257    state
1258        .sessions
1259        .insert(session_id.to_owned(), previous.clone());
1260    let restored = state
1261        .sessions
1262        .get(session_id)
1263        .expect("restored session record disappeared");
1264    match persist(restored) {
1265        Ok(()) => primary,
1266        Err(error) => primary.context(format!(
1267            "restored prior session state in memory, but failed to persist the rollback: {error:#}"
1268        )),
1269    }
1270}
1271
1272fn persist_session_record_transition_or_restore(
1273    state: &mut HelState,
1274    session_id: &str,
1275    previous: &SessionRecord,
1276    context: &'static str,
1277    persist: &impl Fn(&SessionRecord) -> Result<()>,
1278) -> Result<()> {
1279    let result = persist(
1280        state
1281            .sessions
1282            .get(session_id)
1283            .expect("checkpoint session disappeared before persistence"),
1284    );
1285    match result {
1286        Ok(()) => Ok(()),
1287        Err(error) => Err(restore_session_after_persistence_failure(
1288            state,
1289            session_id,
1290            previous,
1291            error.context(context),
1292            persist,
1293        )),
1294    }
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299    use std::collections::BTreeMap;
1300    use std::path::Path;
1301
1302    use hel::hel_config::{
1303        ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, HelConfig,
1304        ProjectBundle, ProjectRepository, TargetTemplate,
1305    };
1306    use hel::hel_state::HelState;
1307    use hel::hel_targets::ProcessExecutor;
1308
1309    use super::*;
1310
1311    /// One profile, one bundle with nothing checked out locally, and one
1312    /// container target, which is all `register_session_with_resources` reads.
1313    fn registration_config() -> HelConfig {
1314        let mut config = HelConfig::default();
1315        config.profiles.insert(
1316            "codex".into(),
1317            HarnessProfile {
1318                kind: HarnessKind::Codex,
1319                home: PathBuf::from("/home/dev/.codex"),
1320                environment: BTreeMap::new(),
1321                context_window_bytes: None,
1322            },
1323        );
1324        config.bundles.insert(
1325            "project".into(),
1326            ProjectBundle {
1327                primary_repo: "project".into(),
1328                repositories: vec![ProjectRepository {
1329                    id: "project".into(),
1330                    github: Some("owner/project".into()),
1331                    local: None,
1332                    destination: PathBuf::from("project"),
1333                    git_ref: None,
1334                }],
1335            },
1336        );
1337        config.targets.insert(
1338            "podman".into(),
1339            TargetTemplate::LocalPodman {
1340                container: ConfigContainer {
1341                    image: "example.invalid/hel-test:latest".into(),
1342                    pull_policy: Default::default(),
1343                    platform: None,
1344                    cpus: None,
1345                    memory: None,
1346                    environment: BTreeMap::new(),
1347                    workspace_storage: Default::default(),
1348                },
1349            },
1350        );
1351        config
1352    }
1353
1354    #[test]
1355    fn bundle_creation_combines_sources_with_first_primary_and_stable_collisions() {
1356        let mut config = HelConfig::default();
1357        let sources = vec!["example/app".into(), "other/app".into()];
1358
1359        let bundle_id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1360        let bundle = &config.bundles[&bundle_id];
1361        assert_eq!(bundle_id, "app");
1362        assert_eq!(bundle.primary_repo, "app");
1363        assert_eq!(
1364            bundle
1365                .repositories
1366                .iter()
1367                .map(|repository| repository.id.as_str())
1368                .collect::<Vec<_>>(),
1369            ["app", "app-2"]
1370        );
1371        assert_eq!(
1372            bundle
1373                .repositories
1374                .iter()
1375                .map(|repository| repository.destination.to_string_lossy().into_owned())
1376                .collect::<Vec<_>>(),
1377            ["app".to_owned(), "app-2".to_owned()]
1378        );
1379        assert_eq!(
1380            bundle.repositories[0].github.as_deref(),
1381            Some("example/app")
1382        );
1383        assert_eq!(bundle.repositories[1].github.as_deref(), Some("other/app"));
1384    }
1385
1386    #[test]
1387    fn bundle_creation_combines_local_and_github_sources_and_rejects_local_aliases() {
1388        let directory = tempfile::tempdir().unwrap();
1389        let root = directory.path().join("app");
1390        let output = hel::hel_subprocess::run_capturing_stdout(
1391            std::process::Command::new("git").arg("init").arg(&root),
1392        )
1393        .unwrap();
1394        assert!(output.status.success(), "{output:?}");
1395        let nested = root.join("nested");
1396        fs::create_dir(&nested).unwrap();
1397        let mut config = HelConfig::default();
1398        let sources = vec![root.to_str().unwrap().into(), "example/shared".into()];
1399        let id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1400        let bundle = &config.bundles[&id];
1401        assert_eq!(
1402            bundle.primary().unwrap().local,
1403            Some(root.canonicalize().unwrap())
1404        );
1405        assert_eq!(
1406            bundle.repositories[1].github.as_deref(),
1407            Some("example/shared")
1408        );
1409        let before = config.clone();
1410        let aliases = vec![
1411            root.to_str().unwrap().into(),
1412            nested.to_str().unwrap().into(),
1413        ];
1414        let error = create_bundle_from_sources_in_config(&mut config, &aliases).unwrap_err();
1415        assert!(
1416            error.to_string().contains("duplicate repository source"),
1417            "{error:#}"
1418        );
1419        assert_eq!(config, before);
1420    }
1421
1422    #[test]
1423    fn bundle_creation_rejects_duplicate_normalized_sources_atomically() {
1424        let mut config = HelConfig::default();
1425        let before = config.clone();
1426        let sources = vec![
1427            "example/app".into(),
1428            "https://github.com/EXAMPLE/APP.git".into(),
1429        ];
1430
1431        let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1432        assert!(error.to_string().contains("duplicate repository source"));
1433        assert_eq!(config, before);
1434    }
1435
1436    #[test]
1437    fn bundle_creation_validates_every_source_before_mutating_config() {
1438        let mut config = HelConfig::default();
1439        let before = config.clone();
1440        let invalid_directory = tempfile::tempdir().unwrap();
1441        let sources = vec![
1442            "example/app".into(),
1443            invalid_directory.path().to_string_lossy().into_owned(),
1444        ];
1445
1446        let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1447        assert!(error.to_string().contains("not a Git repository"));
1448        assert_eq!(config, before);
1449    }
1450
1451    #[test]
1452    fn bundle_creation_reuses_only_an_exact_unpinned_source_set() {
1453        let mut config = HelConfig::default();
1454        config.bundles.insert(
1455            "all".into(),
1456            ProjectBundle {
1457                primary_repo: "app".into(),
1458                repositories: vec![
1459                    ProjectRepository {
1460                        id: "app".into(),
1461                        github: Some("example/app".into()),
1462                        local: None,
1463                        destination: "app".into(),
1464                        git_ref: None,
1465                    },
1466                    ProjectRepository {
1467                        id: "shared".into(),
1468                        github: Some("example/shared".into()),
1469                        local: None,
1470                        destination: "shared".into(),
1471                        git_ref: None,
1472                    },
1473                ],
1474            },
1475        );
1476
1477        let one_source = vec!["example/app".into()];
1478        let created = create_bundle_from_sources_in_config(&mut config, &one_source).unwrap();
1479        assert_eq!(created, "app");
1480        assert_eq!(config.bundles[&created].repositories.len(), 1);
1481        assert_eq!(
1482            create_bundle_from_sources_in_config(&mut config, &one_source).unwrap(),
1483            "app"
1484        );
1485
1486        let exact_sources = vec!["example/app".into(), "example/shared".into()];
1487        assert_eq!(
1488            create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap(),
1489            "all"
1490        );
1491        assert_eq!(config.bundles.len(), 2);
1492        config.bundles.get_mut("all").unwrap().repositories[0].git_ref = Some("release".into());
1493        let unpinned = create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap();
1494        assert_ne!(unpinned, "all");
1495        assert!(
1496            config.bundles[&unpinned]
1497                .repositories
1498                .iter()
1499                .all(|repo| repo.git_ref.is_none())
1500        );
1501    }
1502
1503    fn launch_options(additional_mounts: Vec<AdditionalMount>) -> SessionLaunchOptions {
1504        SessionLaunchOptions {
1505            initial_prompt: None,
1506            workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1507            additional_mounts,
1508            allow_dirty_local: false,
1509            resource_allocation: None,
1510            project_directory: None,
1511            session_title_override: None,
1512        }
1513    }
1514
1515    #[test]
1516    fn deepseek_registration_rejects_more_than_one_workspace_root_before_persisting() {
1517        let mut config = registration_config();
1518        config.profiles.get_mut("codex").unwrap().kind = HarnessKind::Deepseek;
1519        let second = config.bundles["project"].repositories[0].clone();
1520        config
1521            .bundles
1522            .get_mut("project")
1523            .unwrap()
1524            .repositories
1525            .push(hel::hel_config::ProjectRepository {
1526                id: "second".into(),
1527                destination: "second".into(),
1528                ..second
1529            });
1530        let mut controller = Controller {
1531            config,
1532            state: HelState::default(),
1533        };
1534
1535        let error = controller
1536            .register_session_with_resources(
1537                "codex",
1538                "project",
1539                "podman",
1540                "unsupported",
1541                launch_options(Vec::new()),
1542            )
1543            .unwrap_err();
1544
1545        assert!(error.to_string().contains("one workspace root"));
1546        assert!(controller.state.sessions.is_empty());
1547    }
1548
1549    /// MJ_DATA_DIR is process-global, so every test that reaches the
1550    /// controller database runs in an exact child with its own data directory.
1551    fn run_registration_child(marker: &str, test: &str, data_directory: &Path) {
1552        let output = std::process::Command::new(std::env::current_exe().unwrap())
1553            .args([
1554                "--exact",
1555                &format!("hel_controller::tests::{test}"),
1556                "--nocapture",
1557            ])
1558            .env(marker, "1")
1559            .env("MJ_DATA_DIR", data_directory)
1560            .env("MJ_CONFIG_DIR", data_directory)
1561            .output()
1562            .unwrap();
1563        assert!(
1564            output.status.success(),
1565            "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1566            String::from_utf8_lossy(&output.stdout),
1567            String::from_utf8_lossy(&output.stderr)
1568        );
1569    }
1570
1571    #[test]
1572    fn registration_saves_the_initial_task_before_provisioning() {
1573        const MARKER: &str = "MJ_TEST_INITIAL_TASK_CHILD";
1574        if std::env::var_os(MARKER).is_none() {
1575            let directory = tempfile::tempdir().unwrap();
1576            run_registration_child(
1577                MARKER,
1578                "registration_saves_the_initial_task_before_provisioning",
1579                directory.path(),
1580            );
1581            return;
1582        }
1583        let _writer = hel::hel_database::install_isolated_test_writer();
1584        let mut controller = Controller {
1585            config: registration_config(),
1586            state: HelState::default(),
1587        };
1588        let prompt = format!(
1589            "Initial task\n{}\n\tPreserve indentation and λ",
1590            "x".repeat(70_000)
1591        );
1592        let mut options = launch_options(Vec::new());
1593        options.initial_prompt = Some(prompt.clone());
1594        let id = controller
1595            .register_session_with_resources("codex", "project", "podman", "fresh task", options)
1596            .unwrap();
1597        let saved = hel::hel_database::load_state().unwrap();
1598        assert_eq!(saved.sessions[&id].draft_input, prompt);
1599        assert_eq!(saved.sessions[&id].state, SessionState::Provisioning);
1600        hel::hel_database::set_session_draft_input(&id, "a newer draft").unwrap();
1601        hel::hel_database::clear_session_draft_input_if_matches(&id, &prompt).unwrap();
1602        assert_eq!(
1603            hel::hel_database::load_state().unwrap().sessions[&id].draft_input,
1604            "a newer draft"
1605        );
1606        hel::hel_database::clear_session_draft_input_if_matches(&id, "a newer draft").unwrap();
1607        assert!(
1608            hel::hel_database::load_state().unwrap().sessions[&id]
1609                .draft_input
1610                .is_empty()
1611        );
1612    }
1613
1614    const UNPERSISTABLE_SESSION_CHILD: &str = "MJ_TEST_UNPERSISTABLE_SESSION_CHILD";
1615
1616    const CONFIG_ID_RENAME_CHILD: &str = "MJ_TEST_CONFIG_ID_RENAME_CHILD";
1617
1618    #[test]
1619    fn configuration_id_rename_rewrites_durable_session_references() {
1620        if std::env::var_os(CONFIG_ID_RENAME_CHILD).is_none() {
1621            let directory = tempfile::tempdir().unwrap();
1622            run_registration_child(
1623                CONFIG_ID_RENAME_CHILD,
1624                "configuration_id_rename_rewrites_durable_session_references",
1625                directory.path(),
1626            );
1627            return;
1628        }
1629        // Alone in this child process, so it installs the one writer.
1630        let _writer = hel::hel_database::install_isolated_test_writer();
1631
1632        let mut controller = Controller {
1633            config: registration_config(),
1634            state: HelState::default(),
1635        };
1636        controller.config.startup.profile = Some("codex".into());
1637        controller.config.startup.target = Some("podman".into());
1638        controller.config.save().unwrap();
1639        let session_id = controller
1640            .register_session_with_resources(
1641                "codex",
1642                "project",
1643                "podman",
1644                "rename references",
1645                launch_options(Vec::new()),
1646            )
1647            .unwrap();
1648
1649        controller
1650            .rename_profile_id("codex", "codex-renamed")
1651            .unwrap();
1652        controller
1653            .rename_target_id("podman", "podman-renamed")
1654            .unwrap();
1655
1656        let loaded = Controller::load().unwrap();
1657        let session = &loaded.state.sessions[&session_id];
1658        assert_eq!(session.last_profile, "codex-renamed");
1659        assert_eq!(session.target_template_id, "podman-renamed");
1660        assert!(loaded.config.profiles.contains_key("codex-renamed"));
1661        assert!(loaded.config.targets.contains_key("podman-renamed"));
1662        assert_eq!(
1663            loaded.config.startup.profile.as_deref(),
1664            Some("codex-renamed")
1665        );
1666        assert_eq!(
1667            loaded.config.startup.target.as_deref(),
1668            Some("podman-renamed")
1669        );
1670        assert!(!config_rename_journal_path().exists());
1671    }
1672
1673    #[test]
1674    fn a_session_the_database_rejects_is_never_left_in_memory() {
1675        if std::env::var_os(UNPERSISTABLE_SESSION_CHILD).is_none() {
1676            let directory = tempfile::tempdir().unwrap();
1677            run_registration_child(
1678                UNPERSISTABLE_SESSION_CHILD,
1679                "a_session_the_database_rejects_is_never_left_in_memory",
1680                directory.path(),
1681            );
1682            return;
1683        }
1684        // Alone in this child process, so it installs the one writer.
1685        let _writer = hel::hel_database::install_isolated_test_writer();
1686
1687        let mut controller = Controller {
1688            config: registration_config(),
1689            state: HelState::default(),
1690        };
1691        // The store has to be healthy enough to open before it can reject a
1692        // write: this test is about a write the database refuses, not about a
1693        // store that cannot be opened at all, which now fails earlier and
1694        // louder when the writer is installed. The first registration builds
1695        // the schema the second one then loses.
1696        controller
1697            .register_session_with_resources(
1698                "codex",
1699                "project",
1700                "podman",
1701                "first",
1702                launch_options(Vec::new()),
1703            )
1704            .expect("a healthy store registers a session");
1705        rusqlite::Connection::open(hel::hel_database::database_path())
1706            .unwrap()
1707            .execute_batch("DROP TABLE sessions")
1708            .unwrap();
1709
1710        let error = controller
1711            .register_session_with_resources(
1712                "codex",
1713                "project",
1714                "podman",
1715                "unpersistable",
1716                launch_options(Vec::new()),
1717            )
1718            .expect_err("a store that rejects the write cannot register a session");
1719        assert!(
1720            format!("{error:#}").contains("sessions"),
1721            "unexpected error: {error:#}"
1722        );
1723        assert_eq!(
1724            controller.state.sessions.len(),
1725            1,
1726            "a session the database never accepted stayed in controller memory"
1727        );
1728        assert!(
1729            controller
1730                .state
1731                .sessions
1732                .values()
1733                .all(|session| session.title != "unpersistable"),
1734            "the rejected session is the one that stayed"
1735        );
1736    }
1737
1738    const MOUNT_HISTORY_FAILURE_CHILD: &str = "MJ_TEST_MOUNT_HISTORY_FAILURE_CHILD";
1739
1740    const CONTAINER_SIZE_HISTORY_CHILD: &str = "MJ_TEST_CONTAINER_SIZE_HISTORY_CHILD";
1741
1742    #[test]
1743    fn registration_remembers_launch_size_but_session_overrides_do_not_replace_it() {
1744        if std::env::var_os(CONTAINER_SIZE_HISTORY_CHILD).is_none() {
1745            let directory = tempfile::tempdir().unwrap();
1746            run_registration_child(
1747                CONTAINER_SIZE_HISTORY_CHILD,
1748                "registration_remembers_launch_size_but_session_overrides_do_not_replace_it",
1749                directory.path(),
1750            );
1751            return;
1752        }
1753        // Alone in this child process, so it installs the one writer.
1754        let _writer = hel::hel_database::install_isolated_test_writer();
1755
1756        let mut controller = Controller {
1757            config: registration_config(),
1758            state: HelState::default(),
1759        };
1760        let mut options = launch_options(Vec::new());
1761        options.resource_allocation = Some(SessionResourceAllocation::Container {
1762            cpus: 12,
1763            memory_bytes: 48 * 1024 * 1024 * 1024,
1764        });
1765        let id = controller
1766            .register_session_with_resources("codex", "project", "podman", "sized", options)
1767            .unwrap();
1768        let expected = HostContainerSize {
1769            cpus: 12,
1770            memory_bytes: 48 * 1024 * 1024 * 1024,
1771        };
1772        assert_eq!(controller.state.container_sizes["local"], expected);
1773        assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1774
1775        controller
1776            .update_session_container_settings(
1777                &id,
1778                Some("2".into()),
1779                Some("4g".into()),
1780                Vec::new(),
1781                Vec::new(),
1782            )
1783            .unwrap();
1784        assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1785    }
1786
1787    #[test]
1788    fn a_failed_mount_history_write_does_not_fail_the_registered_session() {
1789        if std::env::var_os(MOUNT_HISTORY_FAILURE_CHILD).is_none() {
1790            let directory = tempfile::tempdir().unwrap();
1791            run_registration_child(
1792                MOUNT_HISTORY_FAILURE_CHILD,
1793                "a_failed_mount_history_write_does_not_fail_the_registered_session",
1794                directory.path(),
1795            );
1796            return;
1797        }
1798        // Alone in this child process, so it installs the one writer.
1799        let _writer = hel::hel_database::install_isolated_test_writer();
1800
1801        let mut controller = Controller {
1802            config: registration_config(),
1803            state: HelState::default(),
1804        };
1805        // The first registration builds the schema this test then breaks.
1806        controller
1807            .register_session_with_resources(
1808                "codex",
1809                "project",
1810                "podman",
1811                "first",
1812                launch_options(Vec::new()),
1813            )
1814            .expect("a healthy store registers a session");
1815        let database = hel::hel_database::database_path();
1816        rusqlite::Connection::open(&database)
1817            .unwrap()
1818            .execute_batch("DROP TABLE mount_history")
1819            .unwrap();
1820
1821        let id = controller
1822            .register_session_with_resources(
1823                "codex",
1824                "project",
1825                "podman",
1826                "attached",
1827                launch_options(vec![AdditionalMount {
1828                    source: PathBuf::from("/host/models"),
1829                    destination: PathBuf::from("/mnt/models"),
1830                    read_only: false,
1831                }]),
1832            )
1833            .expect("a suggestion list that cannot be written must not fail a registration");
1834
1835        let stored: i64 = rusqlite::Connection::open(&database)
1836            .unwrap()
1837            .query_row(
1838                "SELECT count(*) FROM sessions WHERE session_id = ?1",
1839                [&id],
1840                |row| row.get(0),
1841            )
1842            .unwrap();
1843        assert_eq!(stored, 1, "the registered session was not committed");
1844        assert!(
1845            controller.state.mount_history.is_empty(),
1846            "controller memory remembered mount sources the database never stored"
1847        );
1848    }
1849
1850    #[test]
1851    fn command_errors_report_the_root_cause_without_worker_wrappers() {
1852        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";
1853
1854        assert_eq!(
1855            command_error_detail(stderr),
1856            "checkpoint base b41dc78 is absent from configured source\nrepository may have moved"
1857        );
1858    }
1859
1860    #[test]
1861    fn controller_store_lock_excludes_a_second_process_owner() {
1862        let directory = tempfile::tempdir().unwrap();
1863        let first = ControllerStoreGuard::acquire_at(directory.path()).unwrap();
1864        run_controller_lock_probe(directory.path(), true);
1865        drop(first);
1866        run_controller_lock_probe(directory.path(), false);
1867    }
1868    fn run_controller_lock_probe(directory: &Path, expect_locked: bool) {
1869        let output = std::process::Command::new(std::env::current_exe().unwrap())
1870            .args([
1871                "--exact",
1872                "hel_controller::tests::controller_store_lock_subprocess_probe",
1873                "--nocapture",
1874            ])
1875            .env("MJ_CONTROLLER_LOCK_PROBE", directory)
1876            .env(
1877                "MJ_CONTROLLER_LOCK_EXPECTED",
1878                if expect_locked { "locked" } else { "available" },
1879            )
1880            .output()
1881            .unwrap();
1882        assert!(
1883            output.status.success(),
1884            "controller lock subprocess failed:\nstdout:\n{}\nstderr:\n{}",
1885            String::from_utf8_lossy(&output.stdout),
1886            String::from_utf8_lossy(&output.stderr)
1887        );
1888    }
1889    #[test]
1890    fn controller_store_lock_subprocess_probe() {
1891        let Some(directory) = std::env::var_os("MJ_CONTROLLER_LOCK_PROBE") else {
1892            return;
1893        };
1894        let expected = std::env::var("MJ_CONTROLLER_LOCK_EXPECTED").unwrap();
1895        let acquired = ControllerStoreGuard::acquire_at(Path::new(&directory));
1896        match expected.as_str() {
1897            "locked" => {
1898                let error = acquired.expect_err("a second process acquired the controller store");
1899                assert!(error.to_string().contains("another Mjolnir controller"));
1900            }
1901            "available" => {
1902                acquired.expect("released controller store stayed locked");
1903            }
1904            value => panic!("unexpected lock probe expectation {value:?}"),
1905        }
1906    }
1907    #[test]
1908    fn local_mount_source_must_be_an_existing_directory() {
1909        let directory = tempfile::tempdir().unwrap();
1910        let file = directory.path().join("file");
1911        std::fs::write(&file, "not a directory").unwrap();
1912        let mut config = HelConfig::default();
1913        config.targets.insert(
1914            "local".into(),
1915            TargetTemplate::LocalPodman {
1916                container: ConfigContainer {
1917                    image: "ubuntu:24.04".into(),
1918                    pull_policy: Default::default(),
1919                    platform: None,
1920                    cpus: None,
1921                    memory: None,
1922                    environment: BTreeMap::new(),
1923                    workspace_storage: Default::default(),
1924                },
1925            },
1926        );
1927        let controller = Controller {
1928            config,
1929            state: HelState::default(),
1930        };
1931
1932        assert!(
1933            controller
1934                .validate_mount_source("local", directory.path(), &ProcessExecutor)
1935                .is_ok()
1936        );
1937        for invalid in [file, directory.path().join("missing")] {
1938            let error = controller
1939                .validate_mount_source("local", &invalid, &ProcessExecutor)
1940                .unwrap_err();
1941            assert!(
1942                error
1943                    .to_string()
1944                    .contains("does not exist or is not a directory")
1945            );
1946        }
1947    }
1948}