Skip to main content

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