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