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