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;
7mod provisioning;
8mod readiness;
9mod recovery_scan;
10mod resume;
11mod reviewer;
12#[cfg(test)]
13mod test_support;
14mod worker_binary;
15mod worker_restart;
16mod worktree;
17
18use std::collections::BTreeMap;
19use std::fs::{self, File, OpenOptions};
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result, bail, ensure};
23use chrono::Utc;
24
25use hel::hel_config::{
26    HelConfig, SshConnection, TargetTemplate, atomic_write, config_path, container_size_host,
27    data_dir, is_bare_project_target, mount_history_host,
28};
29
30const CONFIG_RENAME_JOURNAL: &str = "config-rename.json";
31
32#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "snake_case")]
34enum ConfigRenameKind {
35    Profile,
36    Target,
37}
38
39#[derive(Debug, serde::Serialize, serde::Deserialize)]
40#[serde(deny_unknown_fields)]
41struct ConfigRenameJournal {
42    kind: ConfigRenameKind,
43    old_id: String,
44    new_id: String,
45}
46use hel::hel_local_git::dirty_local_repositories;
47use hel::hel_state::{
48    HelState, HostContainerSize, SessionRecord, SessionResourceAllocation, SessionState,
49    new_session_id, normalize_session_title,
50};
51use hel::hel_targets::{
52    self, AdditionalMount, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
53};
54
55pub(crate) use backend::controller_github_token;
56pub use backend::image_refresh_plan;
57use backend::validate_resource_allocation;
58use provisioning::apply_failed_new_session_rollback;
59pub(crate) use worker_binary::refresh_remote_worker_binary_if_stale;
60
61pub use checkpoint::{
62    CheckpointArtifact, CheckpointDeferred, checkpoint_was_deferred,
63    reconcile_managed_checkpoint_archives,
64};
65pub use recovery_scan::{RecoveryCandidate, RecoveryScan};
66pub use resume::{
67    ResumeRepositorySourceMismatch, ResumeRepositorySourcePreflight, ResumeRepositorySourceReceipt,
68};
69pub use worker_binary::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
70pub use worker_restart::WorkerUpgradeOutcome;
71pub use worktree::{ResumePlan, resume_compatibility};
72
73pub struct Controller {
74    pub config: HelConfig,
75    pub state: HelState,
76}
77
78/// Machine-wide advisory lock for one controller data store. This prevents a
79/// dashboard, server, or CLI lifecycle command from concurrently acting as a
80/// second controller against the same SQLite state and relay sessions.
81#[derive(Debug)]
82pub struct ControllerStoreGuard {
83    file: File,
84}
85
86impl ControllerStoreGuard {
87    pub fn acquire() -> Result<Self> {
88        let directory = data_dir();
89        Self::acquire_at(&directory)
90    }
91
92    fn acquire_at(directory: &Path) -> Result<Self> {
93        std::fs::create_dir_all(directory)
94            .with_context(|| format!("create controller data directory {}", directory.display()))?;
95        let path = directory.join("controller.lock");
96        let mut options = OpenOptions::new();
97        options.create(true).read(true).write(true);
98        #[cfg(unix)]
99        {
100            use std::os::unix::fs::OpenOptionsExt;
101            options.mode(0o600);
102        }
103        let file = options
104            .open(&path)
105            .with_context(|| format!("open controller lock {}", path.display()))?;
106        match file.try_lock() {
107            Ok(()) => {}
108            Err(std::fs::TryLockError::WouldBlock) => bail!(
109                "another Mjolnir controller is already using {}; stop it before starting this command",
110                directory.display()
111            ),
112            Err(std::fs::TryLockError::Error(error)) => {
113                return Err(error)
114                    .with_context(|| format!("lock controller store {}", directory.display()));
115            }
116        }
117        Ok(Self { file })
118    }
119
120    /// Start the sole production SQLite writer after controller exclusivity
121    /// has been established by this guard.
122    pub fn start_database_writer(&self) -> Result<hel::hel_database::DatabaseWriterOwner> {
123        hel::hel_database::start_database_writer()
124    }
125}
126
127impl Drop for ControllerStoreGuard {
128    fn drop(&mut self) {
129        // Make release explicit. `File` also unlocks on close, but an explicit
130        // unlock keeps same-process handoff deterministic across platforms.
131        let _ = self.file.unlock();
132    }
133}
134
135pub struct SessionLaunchOptions {
136    pub workspace_id: String,
137    pub additional_mounts: Vec<AdditionalMount>,
138    pub allow_dirty_local: bool,
139    pub resource_allocation: Option<SessionResourceAllocation>,
140    pub project_directory: Option<PathBuf>,
141    pub session_title_override: Option<String>,
142}
143
144pub struct SessionResumeOptions {
145    pub additional_mounts: Option<Vec<AdditionalMount>>,
146    pub resource_allocation: Option<SessionResourceAllocation>,
147    pub discard_queue: bool,
148}
149
150fn selected_host_container_size(
151    template: &TargetTemplate,
152    allocation: Option<&SessionResourceAllocation>,
153) -> Option<(String, HostContainerSize)> {
154    let host = container_size_host(template)?;
155    let SessionResourceAllocation::Container { cpus, memory_bytes } = allocation? else {
156        return None;
157    };
158    Some((
159        host.to_owned(),
160        HostContainerSize {
161            cpus: *cpus,
162            memory_bytes: *memory_bytes,
163        },
164    ))
165}
166
167impl Controller {
168    pub fn load() -> Result<Self> {
169        let config = HelConfig::load()?;
170        let state = HelState::load()?;
171        state.validate_against_config(&config)?;
172        Ok(Self { config, state })
173    }
174
175    pub fn reload(&mut self) -> Result<()> {
176        *self = Self::load()?;
177        Ok(())
178    }
179
180    fn persist_session_state(&self, session_id: &str) -> Result<()> {
181        match self.state.sessions.get(session_id) {
182            Some(session) => hel::hel_database::save_lifecycle_session(session),
183            None => hel::hel_database::delete_session(session_id),
184        }
185    }
186
187    fn persist_session_transition_or_restore(
188        &mut self,
189        session_id: &str,
190        previous: &SessionRecord,
191        context: &'static str,
192    ) -> Result<()> {
193        persist_session_record_transition_or_restore(
194            &mut self.state,
195            session_id,
196            previous,
197            context,
198            &hel::hel_database::save_lifecycle_session,
199        )
200    }
201
202    fn restore_prior_session_after_persistence_failure(
203        &mut self,
204        session_id: &str,
205        previous: &SessionRecord,
206        primary: anyhow::Error,
207    ) -> anyhow::Error {
208        restore_session_after_persistence_failure(
209            &mut self.state,
210            session_id,
211            previous,
212            primary,
213            hel::hel_database::save_lifecycle_session,
214        )
215    }
216
217    /// Complete a mount source at the same host that will run the container.
218    pub fn complete_mount_source(
219        &self,
220        target_id: &str,
221        prefix: &str,
222        executor: &impl CommandExecutor,
223    ) -> Result<Vec<String>> {
224        let target = self
225            .config
226            .targets
227            .get(target_id)
228            .with_context(|| format!("unknown target template {target_id:?}"))?;
229        match target {
230            TargetTemplate::LocalPodman { .. }
231            | TargetTemplate::LocalDocker { .. }
232            | TargetTemplate::AppleContainer { .. }
233            | TargetTemplate::AwsEc2 { .. } => Ok(hel_targets::local_directory_completions(prefix)),
234            TargetTemplate::SshPodman { ssh, .. } => {
235                hel_targets::ssh_directory_completions(&backend_ssh(ssh), prefix, executor)
236            }
237            TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
238                bail!("resource path completion is unsupported for bare targets")
239            }
240        }
241    }
242
243    /// Verify a mount source on the host where Mjolnir will consume it, and report
244    /// the filesystem reason it must be attached read-only, if there is one.
245    ///
246    /// The probe runs in the same round trip as the existence check so the
247    /// editor learns both answers without a second wait. A probe that cannot
248    /// answer reports no reason: provisioning decides that authoritatively.
249    pub fn validate_mount_source(
250        &self,
251        target_id: &str,
252        source: &Path,
253        executor: &impl CommandExecutor,
254    ) -> Result<Option<String>> {
255        let target = self
256            .config
257            .targets
258            .get(target_id)
259            .with_context(|| format!("unknown target template {target_id:?}"))?;
260        let exists = match target {
261            TargetTemplate::LocalPodman { .. }
262            | TargetTemplate::LocalDocker { .. }
263            | TargetTemplate::AppleContainer { .. }
264            | TargetTemplate::AwsEc2 { .. } => std::fs::metadata(source)
265                .map(|metadata| metadata.is_dir())
266                .or_else(|error| {
267                    if error.kind() == std::io::ErrorKind::NotFound {
268                        Ok(false)
269                    } else {
270                        Err(error)
271                    }
272                })
273                .with_context(|| format!("inspect resource source {}", source.display()))?,
274            TargetTemplate::SshPodman { ssh, .. } => {
275                hel_targets::ssh_directory_exists(&backend_ssh(ssh), source, executor)?
276            }
277            TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
278                bail!("resource attachments are unsupported for bare targets")
279            }
280        };
281        ensure!(
282            exists,
283            "source path {} does not exist or is not a directory",
284            source.display()
285        );
286        Ok(self.forced_read_only_reason(target, source, executor))
287    }
288
289    /// The `filesystem (reason)` label for a source the runtime cannot overlay.
290    fn forced_read_only_reason(
291        &self,
292        target: &TargetTemplate,
293        source: &Path,
294        executor: &impl CommandExecutor,
295    ) -> Option<String> {
296        let ssh = match target {
297            TargetTemplate::LocalPodman { .. } | TargetTemplate::LocalDocker { .. } => None,
298            TargetTemplate::SshPodman { ssh, .. } => Some(backend_ssh(ssh)),
299            // Apple Container already mounts read-only, and EC2 copies instead
300            // of mounting, so neither has an overlay to lose.
301            _ => return None,
302        };
303        let filesystem = hel_targets::probe_filesystem_types(
304            ssh.as_ref(),
305            std::slice::from_ref(&source.to_path_buf()),
306            executor,
307        )
308        .map_err(|error| {
309            tracing::debug!(
310                source = %source.display(),
311                error = format!("{error:#}"),
312                "could not probe the filesystem under a mount source"
313            );
314        })
315        .ok()?
316        .pop()?;
317        let reason = hel_targets::overlay_unsupported_filesystem(&filesystem)?;
318        Some(format!("{filesystem} ({reason})"))
319    }
320
321    fn fail_new_session_with_cleanup(
322        &mut self,
323        session_id: &str,
324        error: anyhow::Error,
325        executor: &impl CommandExecutor,
326    ) -> Result<anyhow::Error> {
327        let original = format!("{error:#}");
328        let cleanup_error = self
329            .cleanup_new_session_worktree_after_failure(session_id, executor)
330            .err()
331            .map(|cleanup_error| format!("{cleanup_error:#}"));
332        if let Some(cleanup_error) = &cleanup_error {
333            tracing::warn!(
334                session_id,
335                error = %cleanup_error,
336                "new-session worktree rollback reported a cleanup failure"
337            );
338        }
339        let failure = apply_failed_new_session_rollback(
340            &mut self.state,
341            session_id,
342            &original,
343            cleanup_error,
344        );
345        self.persist_session_state(session_id)?;
346        Ok(failure)
347    }
348
349    pub fn register_session_with_resources(
350        &mut self,
351        profile_id: &str,
352        bundle_id: &str,
353        target_id: &str,
354        title: impl Into<String>,
355        options: SessionLaunchOptions,
356    ) -> Result<String> {
357        let SessionLaunchOptions {
358            workspace_id,
359            additional_mounts,
360            allow_dirty_local,
361            resource_allocation,
362            project_directory,
363            session_title_override,
364        } = options;
365        let session_title_override = match session_title_override {
366            Some(title) => {
367                Some(normalize_session_title(&title).context("session name cannot be empty")?)
368            }
369            None => None,
370        };
371        let profile = self
372            .config
373            .profiles
374            .get(profile_id)
375            .with_context(|| format!("unknown profile {profile_id:?}"))?;
376        let template = self
377            .config
378            .targets
379            .get(target_id)
380            .with_context(|| format!("unknown target template {target_id:?}"))?;
381        if project_directory.is_some() != is_bare_project_target(template) {
382            bail!("raw project directories require a bare target, and bare targets require one");
383        }
384        if let Some(path) = &project_directory
385            && (!path.is_absolute()
386                || path
387                    .components()
388                    .any(|part| part == std::path::Component::ParentDir))
389        {
390            bail!("bare project directory must be an absolute safe path");
391        }
392        let bundle = project_directory
393            .is_none()
394            .then(|| self.config.bundles.get(bundle_id))
395            .flatten();
396        if project_directory.is_none() && bundle.is_none() {
397            bail!("unknown bundle {bundle_id:?}");
398        }
399        if profile.kind == hel::hel_config::HarnessKind::Deepseek
400            && (!additional_mounts.is_empty()
401                || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
402        {
403            bail!(
404                "DeepSeek Harness ACP supports one workspace root; use a single-repository bundle without attached directories"
405            );
406        }
407        let dirty = bundle
408            .map(dirty_local_repositories)
409            .transpose()?
410            .unwrap_or_default();
411        if !allow_dirty_local && !dirty.is_empty() {
412            let repositories = dirty
413                .iter()
414                .map(|repository| format!("{} ({})", repository.path.display(), repository.summary))
415                .collect::<Vec<_>>()
416                .join(", ");
417            bail!(
418                "local repositories have uncommitted changes: {repositories}; explicit confirmation is required"
419            );
420        }
421        validate_resource_allocation(template, resource_allocation.as_ref())?;
422        let selected_container_size =
423            selected_host_container_size(template, resource_allocation.as_ref());
424        if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
425            bail!("attached resources are unsupported for this target");
426        }
427        hel_targets::validate_additional_mounts(&additional_mounts)?;
428        let id = new_session_id()?;
429        let now = now();
430        let record = SessionRecord {
431            archived: false,
432            container_cpus: None,
433            container_memory: None,
434            id: id.clone(),
435            workspace_id,
436            title: title.into(),
437            harness_kind: profile.kind,
438            last_profile: profile_id.to_string(),
439            bundle_id: bundle_id.to_string(),
440            project_directory,
441            managed_worktree: None,
442            target_template_id: target_id.to_string(),
443            resource_allocation,
444            additional_mounts: additional_mounts.clone(),
445            state: SessionState::Provisioning,
446            target: None,
447            native_session_id: None,
448            acp_session_title: None,
449            session_title_override,
450            created_at: now.clone(),
451            updated_at: now,
452            viewed_through_event_ordinal: 0,
453            draft_input: String::new(),
454            last_error: None,
455            last_checkpoint_error: None,
456            checkpoint: None,
457        };
458        // Creation authors the whole record, so it writes the whole row. The
459        // record reaches memory only once it is durable: a session this process
460        // alone knows about is one the database can never resume or clean up.
461        if let Some((host, size)) = selected_container_size.as_ref() {
462            hel::hel_database::save_session_with_container_size(&record, host, *size)?;
463        } else {
464            hel::hel_database::save_session(&record)?;
465        }
466        self.state.sessions.insert(id.clone(), record);
467        if let Some((host, size)) = selected_container_size {
468            self.state.remember_container_size(&host, size);
469        }
470        if let Some(host) = mount_history_host(template) {
471            // Mount history only seeds the attach dialog's suggestions. The
472            // session row is already committed, so a failed suggestion write is
473            // reported rather than turned into a failed registration.
474            match hel::hel_database::remember_mount_sources(host, &additional_mounts) {
475                Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
476                Err(error) => tracing::warn!(
477                    session_id = id,
478                    error = format!("{error:#}"),
479                    "could not remember the attached resource directories for later suggestions"
480                ),
481            }
482        }
483        Ok(id)
484    }
485
486    pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
487        let title = normalize_session_title(title).context("session name cannot be empty")?;
488        ensure!(
489            self.state.sessions.contains_key(session_id),
490            "unknown session {session_id}"
491        );
492        let updated_at = now();
493        hel::hel_database::set_session_title_override(session_id, &title, &updated_at)?;
494        let record = self
495            .state
496            .sessions
497            .get_mut(session_id)
498            .expect("session was checked before updating its title");
499        record.session_title_override = Some(title.clone());
500        record.updated_at = updated_at;
501        Ok(title)
502    }
503
504    pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
505        hel::hel_config::validate_id("profile", new_id)?;
506        ensure!(
507            self.config.profiles.contains_key(old_id),
508            "unknown profile {old_id:?}"
509        );
510        ensure!(
511            old_id == new_id || !self.config.profiles.contains_key(new_id),
512            "profile {new_id:?} already exists"
513        );
514        if old_id == new_id {
515            return Ok(());
516        }
517        let journal = ConfigRenameJournal {
518            kind: ConfigRenameKind::Profile,
519            old_id: old_id.to_owned(),
520            new_id: new_id.to_owned(),
521        };
522        write_config_rename_journal(&journal)?;
523        let previous = self.config.clone();
524        let profile = self
525            .config
526            .profiles
527            .remove(old_id)
528            .expect("profile checked");
529        self.config.profiles.insert(new_id.to_owned(), profile);
530        if let Err(error) = self.config.save() {
531            self.config = previous;
532            remove_config_rename_journal()
533                .context("remove profile rename journal after config save failed")?;
534            return Err(error).context("save renamed profile configuration");
535        }
536        hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
537        if let Err(error) = hel::hel_database::rename_profile_references(old_id, new_id) {
538            if let Err(restore_error) = previous.save() {
539                return Err(error).context(format!(
540                    "rename profile references; additionally failed to restore config: {restore_error:#}"
541                ));
542            }
543            self.config = previous;
544            remove_config_rename_journal()?;
545            return Err(error).context("rename profile references");
546        }
547        for session in self.state.sessions.values_mut() {
548            if session.last_profile == old_id {
549                session.last_profile = new_id.to_owned();
550            }
551        }
552        remove_config_rename_journal()?;
553        Ok(())
554    }
555
556    pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
557        hel::hel_config::validate_id("target template", new_id)?;
558        ensure!(
559            self.config.targets.contains_key(old_id),
560            "unknown target {old_id:?}"
561        );
562        ensure!(
563            old_id == new_id || !self.config.targets.contains_key(new_id),
564            "target {new_id:?} already exists"
565        );
566        if old_id == new_id {
567            return Ok(());
568        }
569        let journal = ConfigRenameJournal {
570            kind: ConfigRenameKind::Target,
571            old_id: old_id.to_owned(),
572            new_id: new_id.to_owned(),
573        };
574        write_config_rename_journal(&journal)?;
575        let previous = self.config.clone();
576        let target = self.config.targets.remove(old_id).expect("target checked");
577        self.config.targets.insert(new_id.to_owned(), target);
578        if let Err(error) = self.config.save() {
579            self.config = previous;
580            remove_config_rename_journal()
581                .context("remove target rename journal after config save failed")?;
582            return Err(error).context("save renamed target configuration");
583        }
584        hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
585        if let Err(error) = hel::hel_database::rename_target_references(old_id, new_id) {
586            if let Err(restore_error) = previous.save() {
587                return Err(error).context(format!(
588                    "rename target references; additionally failed to restore config: {restore_error:#}"
589                ));
590            }
591            self.config = previous;
592            remove_config_rename_journal()?;
593            return Err(error).context("rename target references");
594        }
595        for session in self.state.sessions.values_mut() {
596            if session.target_template_id == old_id {
597                session.target_template_id = new_id.to_owned();
598            }
599        }
600        remove_config_rename_journal()?;
601        Ok(())
602    }
603
604    /// Finish a profile/target id rename interrupted between the atomic config
605    /// replacement and SQLite transaction. Each step is idempotent, so a
606    /// second crash leaves the same intent available for the next startup.
607    pub fn recover_config_id_rename() -> Result<bool> {
608        let path = config_rename_journal_path();
609        let body = match fs::read(&path) {
610            Ok(body) => body,
611            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
612            Err(error) => return Err(error).context(format!("read {}", path.display())),
613        };
614        let journal: ConfigRenameJournal =
615            serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
616        let mut config = HelConfig::load_from(&config_path())?;
617        match journal.kind {
618            ConfigRenameKind::Profile => {
619                finish_config_map_rename(
620                    &mut config.profiles,
621                    &journal.old_id,
622                    &journal.new_id,
623                    "profile",
624                )?;
625                config.save()?;
626                hel::hel_database::rename_profile_references(&journal.old_id, &journal.new_id)?;
627            }
628            ConfigRenameKind::Target => {
629                finish_config_map_rename(
630                    &mut config.targets,
631                    &journal.old_id,
632                    &journal.new_id,
633                    "target",
634                )?;
635                config.save()?;
636                hel::hel_database::rename_target_references(&journal.old_id, &journal.new_id)?;
637            }
638        }
639        remove_config_rename_journal()?;
640        Ok(true)
641    }
642
643    /// Record the per-session container size overrides and attached
644    /// directories. Nothing is applied to a running container: the values are
645    /// read the next time the session's container is created.
646    pub fn update_session_container_settings(
647        &mut self,
648        session_id: &str,
649        cpus: Option<String>,
650        memory: Option<String>,
651        additional_mounts: Vec<hel_targets::AdditionalMount>,
652        mount_history: Vec<std::path::PathBuf>,
653    ) -> Result<()> {
654        ensure!(
655            self.state.sessions.contains_key(session_id),
656            "unknown session {session_id}"
657        );
658        let cpus = cpus.filter(|value| !value.trim().is_empty());
659        let memory = memory.filter(|value| !value.trim().is_empty());
660        let updated_at = now();
661        hel::hel_database::set_session_container_settings(
662            session_id,
663            cpus.as_deref(),
664            memory.as_deref(),
665            &additional_mounts,
666            &updated_at,
667        )?;
668        if let Some(host) = self
669            .config
670            .targets
671            .get(
672                &self.state.sessions[session_id]
673                    .target_template_id
674                    .to_owned(),
675            )
676            .and_then(hel::hel_config::mount_history_host)
677        {
678            let host = host.to_owned();
679            // The dialog owns the suggestion list, so forgetting a directory
680            // there has to survive the mounts being remembered right after.
681            hel::hel_database::replace_mount_history(&host, &mount_history)?;
682            hel::hel_database::remember_mount_sources(&host, &additional_mounts)?;
683            self.state.mount_history.insert(host.clone(), mount_history);
684            self.state.remember_mount_sources(&host, &additional_mounts);
685        }
686        let record = self
687            .state
688            .sessions
689            .get_mut(session_id)
690            .expect("session was checked before updating its container settings");
691        record.container_cpus = cpus;
692        record.container_memory = memory;
693        record.additional_mounts = additional_mounts;
694        record.updated_at = updated_at;
695        Ok(())
696    }
697}
698
699fn config_rename_journal_path() -> PathBuf {
700    data_dir().join(CONFIG_RENAME_JOURNAL)
701}
702
703fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
704    let path = config_rename_journal_path();
705    let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
706    atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
707}
708
709fn remove_config_rename_journal() -> Result<()> {
710    let path = config_rename_journal_path();
711    match fs::remove_file(&path) {
712        Ok(()) => Ok(()),
713        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
714        Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
715    }
716}
717
718fn finish_config_map_rename<T>(
719    entries: &mut BTreeMap<String, T>,
720    old_id: &str,
721    new_id: &str,
722    kind: &str,
723) -> Result<()> {
724    if let Some(entry) = entries.remove(old_id) {
725        ensure!(
726            !entries.contains_key(new_id),
727            "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
728        );
729        entries.insert(new_id.to_owned(), entry);
730    } else {
731        ensure!(
732            entries.contains_key(new_id),
733            "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
734        );
735    }
736    Ok(())
737}
738
739fn target_kind(locator: &hel_targets::TargetLocator) -> &'static str {
740    match locator {
741        hel_targets::TargetLocator::LocalBare { .. } => "local-bare",
742        hel_targets::TargetLocator::LocalPodman { .. } => "local-podman",
743        hel_targets::TargetLocator::LocalDocker { .. } => "local-docker",
744        hel_targets::TargetLocator::AppleContainer { .. } => "apple-container",
745        hel_targets::TargetLocator::AwsEc2 { .. } => "aws-ec2",
746        hel_targets::TargetLocator::SshBare { .. } => "ssh-bare",
747        hel_targets::TargetLocator::SshPodman { .. } => "ssh-podman",
748    }
749}
750
751fn target_profile_home(
752    locator: &hel_targets::TargetLocator,
753    session_id: &str,
754    profile: &hel::hel_config::HarnessProfile,
755) -> String {
756    match locator {
757        hel_targets::TargetLocator::LocalBare { .. } => profile.home.to_string_lossy().into_owned(),
758        hel_targets::TargetLocator::LocalPodman { .. }
759        | hel_targets::TargetLocator::LocalDocker { .. }
760        | hel_targets::TargetLocator::AppleContainer { .. }
761        | hel_targets::TargetLocator::SshPodman { .. } => {
762            format!("/var/lib/hel/profiles/{session_id}")
763        }
764        hel_targets::TargetLocator::AwsEc2 { .. } | hel_targets::TargetLocator::SshBare { .. } => {
765            format!(".local/share/hel/profiles/{session_id}")
766        }
767    }
768}
769
770pub(crate) fn backend_ssh(ssh: &SshConnection) -> SshTarget {
771    let destination = match &ssh.user {
772        Some(user) => format!("{user}@{}", ssh.host),
773        None => ssh.host.clone(),
774    };
775    SshTarget {
776        destination,
777        ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
778    }
779}
780
781fn ssh_command_spec(
782    ssh: &SshTarget,
783    args: impl IntoIterator<Item = impl AsRef<str>>,
784) -> CommandSpec {
785    let remote = args
786        .into_iter()
787        .map(|arg| arg.as_ref().to_string())
788        .collect::<Vec<_>>();
789    let mut command_args = ssh.ssh_args.clone();
790    command_args.push(ssh.destination.clone());
791    command_args.push(hel_targets::join_remote_command(&remote));
792    CommandSpec::new("ssh", command_args)
793}
794
795fn scp_command_spec(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
796    let mut args = ssh.ssh_args.clone();
797    if recursive {
798        args.push("-r".into());
799    }
800    args.push(source.to_string_lossy().into_owned());
801    args.push(format!("{}:{remote}", ssh.destination));
802    CommandSpec::new("scp", args)
803}
804
805fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
806    // Mjolnir drives ssh non-interactively from a TUI; a host-key or password
807    // prompt would steal the terminal and wedge provisioning. BatchMode fails
808    // fast instead of prompting, and accept-new trusts a first-seen host key
809    // (fresh EC2 instances are always first-seen) while still rejecting
810    // changed keys. User-supplied ssh_args come last so they can override.
811    let mut result = vec![
812        "-o".into(),
813        "BatchMode=yes".into(),
814        "-o".into(),
815        "StrictHostKeyChecking=accept-new".into(),
816        "-o".into(),
817        "ConnectTimeout=15".into(),
818    ];
819    result.extend(args.iter().cloned());
820    if let Some(identity) = identity {
821        result.push("-i".into());
822        result.push(identity.to_string_lossy().into_owned());
823    }
824    result
825}
826
827fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
828    let output = executor.execute(&command)?;
829    if output.status != 0 {
830        let detail = command_error_detail(&output.stderr);
831        if detail.is_empty() {
832            bail!("{} failed with status {}", command.purpose, output.status);
833        }
834        bail!("{detail}");
835    }
836    Ok(output)
837}
838
839fn command_error_detail(stderr: &[u8]) -> String {
840    let reported = String::from_utf8_lossy(stderr);
841    let reported = reported.trim();
842    let detail = reported
843        .rsplit_once("\nCaused by:\n")
844        .map_or(reported, |(_, causes)| causes);
845    let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
846    detail
847        .lines()
848        .map(|line| line.strip_prefix("    ").unwrap_or(line))
849        .collect::<Vec<_>>()
850        .join("\n")
851        .trim()
852        .to_owned()
853}
854
855fn now() -> String {
856    Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
857}
858
859fn restore_session_after_persistence_failure(
860    state: &mut HelState,
861    session_id: &str,
862    previous: &SessionRecord,
863    primary: anyhow::Error,
864    persist: impl FnOnce(&SessionRecord) -> Result<()>,
865) -> anyhow::Error {
866    state
867        .sessions
868        .insert(session_id.to_owned(), previous.clone());
869    let restored = state
870        .sessions
871        .get(session_id)
872        .expect("restored session record disappeared");
873    match persist(restored) {
874        Ok(()) => primary,
875        Err(error) => primary.context(format!(
876            "restored prior session state in memory, but failed to persist the rollback: {error:#}"
877        )),
878    }
879}
880
881fn persist_session_record_transition_or_restore(
882    state: &mut HelState,
883    session_id: &str,
884    previous: &SessionRecord,
885    context: &'static str,
886    persist: &impl Fn(&SessionRecord) -> Result<()>,
887) -> Result<()> {
888    let result = persist(
889        state
890            .sessions
891            .get(session_id)
892            .expect("checkpoint session disappeared before persistence"),
893    );
894    match result {
895        Ok(()) => Ok(()),
896        Err(error) => Err(restore_session_after_persistence_failure(
897            state,
898            session_id,
899            previous,
900            error.context(context),
901            persist,
902        )),
903    }
904}
905
906#[cfg(test)]
907mod tests {
908    use std::collections::BTreeMap;
909    use std::path::Path;
910
911    use hel::hel_config::{
912        ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, HelConfig,
913        ProjectBundle, ProjectRepository, TargetTemplate,
914    };
915    use hel::hel_state::HelState;
916    use hel::hel_targets::ProcessExecutor;
917
918    use super::*;
919
920    /// One profile, one bundle with nothing checked out locally, and one
921    /// container target, which is all `register_session_with_resources` reads.
922    fn registration_config() -> HelConfig {
923        let mut config = HelConfig::default();
924        config.profiles.insert(
925            "codex".into(),
926            HarnessProfile {
927                kind: HarnessKind::Codex,
928                home: PathBuf::from("/home/dev/.codex"),
929                executable: None,
930                environment: BTreeMap::new(),
931                context_window_bytes: None,
932            },
933        );
934        config.bundles.insert(
935            "project".into(),
936            ProjectBundle {
937                primary_repo: "project".into(),
938                repositories: vec![ProjectRepository {
939                    id: "project".into(),
940                    github: Some("owner/project".into()),
941                    local: None,
942                    destination: PathBuf::from("project"),
943                    git_ref: None,
944                }],
945            },
946        );
947        config.targets.insert(
948            "podman".into(),
949            TargetTemplate::LocalPodman {
950                container: ConfigContainer {
951                    image: "example.invalid/hel-test:latest".into(),
952                    pull_policy: Default::default(),
953                    platform: None,
954                    cpus: None,
955                    memory: None,
956                    environment: BTreeMap::new(),
957                    workspace_storage: Default::default(),
958                },
959            },
960        );
961        config
962    }
963
964    fn launch_options(additional_mounts: Vec<AdditionalMount>) -> SessionLaunchOptions {
965        SessionLaunchOptions {
966            workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
967            additional_mounts,
968            allow_dirty_local: false,
969            resource_allocation: None,
970            project_directory: None,
971            session_title_override: None,
972        }
973    }
974
975    #[test]
976    fn deepseek_registration_rejects_more_than_one_workspace_root_before_persisting() {
977        let mut config = registration_config();
978        config.profiles.get_mut("codex").unwrap().kind = HarnessKind::Deepseek;
979        let second = config.bundles["project"].repositories[0].clone();
980        config
981            .bundles
982            .get_mut("project")
983            .unwrap()
984            .repositories
985            .push(hel::hel_config::ProjectRepository {
986                id: "second".into(),
987                destination: "second".into(),
988                ..second
989            });
990        let mut controller = Controller {
991            config,
992            state: HelState::default(),
993        };
994
995        let error = controller
996            .register_session_with_resources(
997                "codex",
998                "project",
999                "podman",
1000                "unsupported",
1001                launch_options(Vec::new()),
1002            )
1003            .unwrap_err();
1004
1005        assert!(error.to_string().contains("one workspace root"));
1006        assert!(controller.state.sessions.is_empty());
1007    }
1008
1009    /// MJ_DATA_DIR is process-global, so every test that reaches the
1010    /// controller database runs in an exact child with its own data directory.
1011    fn run_registration_child(marker: &str, test: &str, data_directory: &Path) {
1012        let output = std::process::Command::new(std::env::current_exe().unwrap())
1013            .args([
1014                "--exact",
1015                &format!("hel_controller::tests::{test}"),
1016                "--nocapture",
1017            ])
1018            .env(marker, "1")
1019            .env("MJ_DATA_DIR", data_directory)
1020            .env("MJ_CONFIG_DIR", data_directory)
1021            .output()
1022            .unwrap();
1023        assert!(
1024            output.status.success(),
1025            "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1026            String::from_utf8_lossy(&output.stdout),
1027            String::from_utf8_lossy(&output.stderr)
1028        );
1029    }
1030
1031    const UNPERSISTABLE_SESSION_CHILD: &str = "MJ_TEST_UNPERSISTABLE_SESSION_CHILD";
1032
1033    const CONFIG_ID_RENAME_CHILD: &str = "MJ_TEST_CONFIG_ID_RENAME_CHILD";
1034
1035    #[test]
1036    fn configuration_id_rename_rewrites_durable_session_references() {
1037        if std::env::var_os(CONFIG_ID_RENAME_CHILD).is_none() {
1038            let directory = tempfile::tempdir().unwrap();
1039            run_registration_child(
1040                CONFIG_ID_RENAME_CHILD,
1041                "configuration_id_rename_rewrites_durable_session_references",
1042                directory.path(),
1043            );
1044            return;
1045        }
1046        // Alone in this child process, so it installs the one writer.
1047        let _writer = hel::hel_database::install_isolated_test_writer();
1048
1049        let mut controller = Controller {
1050            config: registration_config(),
1051            state: HelState::default(),
1052        };
1053        controller.config.save().unwrap();
1054        let session_id = controller
1055            .register_session_with_resources(
1056                "codex",
1057                "project",
1058                "podman",
1059                "rename references",
1060                launch_options(Vec::new()),
1061            )
1062            .unwrap();
1063
1064        controller
1065            .rename_profile_id("codex", "codex-renamed")
1066            .unwrap();
1067        controller
1068            .rename_target_id("podman", "podman-renamed")
1069            .unwrap();
1070
1071        let loaded = Controller::load().unwrap();
1072        let session = &loaded.state.sessions[&session_id];
1073        assert_eq!(session.last_profile, "codex-renamed");
1074        assert_eq!(session.target_template_id, "podman-renamed");
1075        assert!(loaded.config.profiles.contains_key("codex-renamed"));
1076        assert!(loaded.config.targets.contains_key("podman-renamed"));
1077        assert!(!config_rename_journal_path().exists());
1078    }
1079
1080    #[test]
1081    fn a_session_the_database_rejects_is_never_left_in_memory() {
1082        if std::env::var_os(UNPERSISTABLE_SESSION_CHILD).is_none() {
1083            let directory = tempfile::tempdir().unwrap();
1084            run_registration_child(
1085                UNPERSISTABLE_SESSION_CHILD,
1086                "a_session_the_database_rejects_is_never_left_in_memory",
1087                directory.path(),
1088            );
1089            return;
1090        }
1091        // Alone in this child process, so it installs the one writer.
1092        let _writer = hel::hel_database::install_isolated_test_writer();
1093
1094        let mut controller = Controller {
1095            config: registration_config(),
1096            state: HelState::default(),
1097        };
1098        // The store has to be healthy enough to open before it can reject a
1099        // write: this test is about a write the database refuses, not about a
1100        // store that cannot be opened at all, which now fails earlier and
1101        // louder when the writer is installed. The first registration builds
1102        // the schema the second one then loses.
1103        controller
1104            .register_session_with_resources(
1105                "codex",
1106                "project",
1107                "podman",
1108                "first",
1109                launch_options(Vec::new()),
1110            )
1111            .expect("a healthy store registers a session");
1112        rusqlite::Connection::open(hel::hel_database::database_path())
1113            .unwrap()
1114            .execute_batch("DROP TABLE sessions")
1115            .unwrap();
1116
1117        let error = controller
1118            .register_session_with_resources(
1119                "codex",
1120                "project",
1121                "podman",
1122                "unpersistable",
1123                launch_options(Vec::new()),
1124            )
1125            .expect_err("a store that rejects the write cannot register a session");
1126        assert!(
1127            format!("{error:#}").contains("sessions"),
1128            "unexpected error: {error:#}"
1129        );
1130        assert_eq!(
1131            controller.state.sessions.len(),
1132            1,
1133            "a session the database never accepted stayed in controller memory"
1134        );
1135        assert!(
1136            controller
1137                .state
1138                .sessions
1139                .values()
1140                .all(|session| session.title != "unpersistable"),
1141            "the rejected session is the one that stayed"
1142        );
1143    }
1144
1145    const MOUNT_HISTORY_FAILURE_CHILD: &str = "MJ_TEST_MOUNT_HISTORY_FAILURE_CHILD";
1146
1147    const CONTAINER_SIZE_HISTORY_CHILD: &str = "MJ_TEST_CONTAINER_SIZE_HISTORY_CHILD";
1148
1149    #[test]
1150    fn registration_remembers_launch_size_but_session_overrides_do_not_replace_it() {
1151        if std::env::var_os(CONTAINER_SIZE_HISTORY_CHILD).is_none() {
1152            let directory = tempfile::tempdir().unwrap();
1153            run_registration_child(
1154                CONTAINER_SIZE_HISTORY_CHILD,
1155                "registration_remembers_launch_size_but_session_overrides_do_not_replace_it",
1156                directory.path(),
1157            );
1158            return;
1159        }
1160        // Alone in this child process, so it installs the one writer.
1161        let _writer = hel::hel_database::install_isolated_test_writer();
1162
1163        let mut controller = Controller {
1164            config: registration_config(),
1165            state: HelState::default(),
1166        };
1167        let mut options = launch_options(Vec::new());
1168        options.resource_allocation = Some(SessionResourceAllocation::Container {
1169            cpus: 12,
1170            memory_bytes: 48 * 1024 * 1024 * 1024,
1171        });
1172        let id = controller
1173            .register_session_with_resources("codex", "project", "podman", "sized", options)
1174            .unwrap();
1175        let expected = HostContainerSize {
1176            cpus: 12,
1177            memory_bytes: 48 * 1024 * 1024 * 1024,
1178        };
1179        assert_eq!(controller.state.container_sizes["local"], expected);
1180        assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1181
1182        controller
1183            .update_session_container_settings(
1184                &id,
1185                Some("2".into()),
1186                Some("4g".into()),
1187                Vec::new(),
1188                Vec::new(),
1189            )
1190            .unwrap();
1191        assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1192    }
1193
1194    #[test]
1195    fn a_failed_mount_history_write_does_not_fail_the_registered_session() {
1196        if std::env::var_os(MOUNT_HISTORY_FAILURE_CHILD).is_none() {
1197            let directory = tempfile::tempdir().unwrap();
1198            run_registration_child(
1199                MOUNT_HISTORY_FAILURE_CHILD,
1200                "a_failed_mount_history_write_does_not_fail_the_registered_session",
1201                directory.path(),
1202            );
1203            return;
1204        }
1205        // Alone in this child process, so it installs the one writer.
1206        let _writer = hel::hel_database::install_isolated_test_writer();
1207
1208        let mut controller = Controller {
1209            config: registration_config(),
1210            state: HelState::default(),
1211        };
1212        // The first registration builds the schema this test then breaks.
1213        controller
1214            .register_session_with_resources(
1215                "codex",
1216                "project",
1217                "podman",
1218                "first",
1219                launch_options(Vec::new()),
1220            )
1221            .expect("a healthy store registers a session");
1222        let database = hel::hel_database::database_path();
1223        rusqlite::Connection::open(&database)
1224            .unwrap()
1225            .execute_batch("DROP TABLE mount_history")
1226            .unwrap();
1227
1228        let id = controller
1229            .register_session_with_resources(
1230                "codex",
1231                "project",
1232                "podman",
1233                "attached",
1234                launch_options(vec![AdditionalMount {
1235                    source: PathBuf::from("/host/models"),
1236                    destination: PathBuf::from("/mnt/models"),
1237                    read_only: false,
1238                }]),
1239            )
1240            .expect("a suggestion list that cannot be written must not fail a registration");
1241
1242        let stored: i64 = rusqlite::Connection::open(&database)
1243            .unwrap()
1244            .query_row(
1245                "SELECT count(*) FROM sessions WHERE session_id = ?1",
1246                [&id],
1247                |row| row.get(0),
1248            )
1249            .unwrap();
1250        assert_eq!(stored, 1, "the registered session was not committed");
1251        assert!(
1252            controller.state.mount_history.is_empty(),
1253            "controller memory remembered mount sources the database never stored"
1254        );
1255    }
1256
1257    #[test]
1258    fn command_errors_report_the_root_cause_without_worker_wrappers() {
1259        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";
1260
1261        assert_eq!(
1262            command_error_detail(stderr),
1263            "checkpoint base b41dc78 is absent from configured source\nrepository may have moved"
1264        );
1265    }
1266
1267    #[test]
1268    fn controller_store_lock_excludes_a_second_process_owner() {
1269        let directory = tempfile::tempdir().unwrap();
1270        let first = ControllerStoreGuard::acquire_at(directory.path()).unwrap();
1271        run_controller_lock_probe(directory.path(), true);
1272        drop(first);
1273        run_controller_lock_probe(directory.path(), false);
1274    }
1275    fn run_controller_lock_probe(directory: &Path, expect_locked: bool) {
1276        let output = std::process::Command::new(std::env::current_exe().unwrap())
1277            .args([
1278                "--exact",
1279                "hel_controller::tests::controller_store_lock_subprocess_probe",
1280                "--nocapture",
1281            ])
1282            .env("MJ_CONTROLLER_LOCK_PROBE", directory)
1283            .env(
1284                "MJ_CONTROLLER_LOCK_EXPECTED",
1285                if expect_locked { "locked" } else { "available" },
1286            )
1287            .output()
1288            .unwrap();
1289        assert!(
1290            output.status.success(),
1291            "controller lock subprocess failed:\nstdout:\n{}\nstderr:\n{}",
1292            String::from_utf8_lossy(&output.stdout),
1293            String::from_utf8_lossy(&output.stderr)
1294        );
1295    }
1296    #[test]
1297    fn controller_store_lock_subprocess_probe() {
1298        let Some(directory) = std::env::var_os("MJ_CONTROLLER_LOCK_PROBE") else {
1299            return;
1300        };
1301        let expected = std::env::var("MJ_CONTROLLER_LOCK_EXPECTED").unwrap();
1302        let acquired = ControllerStoreGuard::acquire_at(Path::new(&directory));
1303        match expected.as_str() {
1304            "locked" => {
1305                let error = acquired.expect_err("a second process acquired the controller store");
1306                assert!(error.to_string().contains("another Mjolnir controller"));
1307            }
1308            "available" => {
1309                acquired.expect("released controller store stayed locked");
1310            }
1311            value => panic!("unexpected lock probe expectation {value:?}"),
1312        }
1313    }
1314    #[test]
1315    fn local_mount_source_must_be_an_existing_directory() {
1316        let directory = tempfile::tempdir().unwrap();
1317        let file = directory.path().join("file");
1318        std::fs::write(&file, "not a directory").unwrap();
1319        let mut config = HelConfig::default();
1320        config.targets.insert(
1321            "local".into(),
1322            TargetTemplate::LocalPodman {
1323                container: ConfigContainer {
1324                    image: "ubuntu:24.04".into(),
1325                    pull_policy: Default::default(),
1326                    platform: None,
1327                    cpus: None,
1328                    memory: None,
1329                    environment: BTreeMap::new(),
1330                    workspace_storage: Default::default(),
1331                },
1332            },
1333        );
1334        let controller = Controller {
1335            config,
1336            state: HelState::default(),
1337        };
1338
1339        assert!(
1340            controller
1341                .validate_mount_source("local", directory.path(), &ProcessExecutor)
1342                .is_ok()
1343        );
1344        for invalid in [file, directory.path().join("missing")] {
1345            let error = controller
1346                .validate_mount_source("local", &invalid, &ProcessExecutor)
1347                .unwrap_err();
1348            assert!(
1349                error
1350                    .to_string()
1351                    .contains("does not exist or is not a directory")
1352            );
1353        }
1354    }
1355}