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 profile.kind == mj_core::config::HarnessKind::Muse
770            && (!additional_mounts.is_empty()
771                || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
772        {
773            bail!(
774                "{} ACP supports one workspace root; use a single-repository bundle without attached directories",
775                profile.kind.display_name()
776            );
777        }
778        if let Some(bundle) = bundle {
779            for repository in &bundle.repositories {
780                mj_core::remote_git::resolve_repository(
781                    repository,
782                    &targets::CancellableProcessExecutor::with_timeout(
783                        std::time::Duration::from_secs(15),
784                    ),
785                )
786                .with_context(|| format!("repository {:?}", repository.id))?;
787            }
788        }
789        validate_resource_allocation(template, resource_allocation.as_ref())?;
790        let selected_container_size =
791            selected_host_container_size(template, resource_allocation.as_ref());
792        if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
793            bail!("attached resources are unsupported for this target");
794        }
795        targets::validate_additional_mounts(&additional_mounts)?;
796        let id = new_session_id()?;
797        let now = now();
798        let record = SessionRecord {
799            create_managed_worktree,
800            mjolnir_subagents,
801            archived: false,
802            container_cpus: None,
803            container_memory: None,
804            id: id.clone(),
805            workspace_id,
806            title: title.into(),
807            harness_kind: profile.kind,
808            last_profile: profile_id.to_string(),
809            bundle_id: bundle_id.to_string(),
810            project_directory,
811            managed_worktree: None,
812            target_template_id: target_id.to_string(),
813            resource_allocation,
814            additional_mounts: additional_mounts.clone(),
815            state: SessionState::Provisioning,
816            target: None,
817            native_session_id: None,
818            acp_session_title: None,
819            session_title_override,
820            created_at: now.clone(),
821            updated_at: now,
822            viewed_through_event_ordinal: 0,
823            draft_input: initial_prompt.unwrap_or_default(),
824            last_error: None,
825            last_checkpoint_error: None,
826            checkpoint: None,
827        };
828        // Creation authors the whole record, so it writes the whole row. The
829        // record reaches memory only once it is durable: a session this process
830        // alone knows about is one the database can never resume or clean up.
831        if let Some((host, size)) = selected_container_size.as_ref() {
832            crate::database::save_session_with_container_size(&record, host, *size)?;
833        } else {
834            crate::database::save_session(&record)?;
835        }
836        self.state.sessions.insert(id.clone(), record);
837        if let Some((host, size)) = selected_container_size {
838            self.state.remember_container_size(&host, size);
839        }
840        if let Some(host) = mount_history_host(template) {
841            // Mount history only seeds the attach dialog's suggestions. The
842            // session row is already committed, so a failed suggestion write is
843            // reported rather than turned into a failed registration.
844            match crate::database::remember_mount_sources(host, &additional_mounts) {
845                Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
846                Err(error) => tracing::warn!(
847                    session_id = id,
848                    error = format!("{error:#}"),
849                    "could not remember the attached resource directories for later suggestions"
850                ),
851            }
852        }
853        Ok(id)
854    }
855
856    pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
857        let title = normalize_session_title(title).context("session name cannot be empty")?;
858        ensure!(
859            self.state.sessions.contains_key(session_id),
860            "unknown session {session_id}"
861        );
862        let updated_at = now();
863        crate::database::set_session_title_override(session_id, &title, &updated_at)?;
864        let record = self
865            .state
866            .sessions
867            .get_mut(session_id)
868            .expect("session was checked before updating its title");
869        record.session_title_override = Some(title.clone());
870        record.updated_at = updated_at;
871        Ok(title)
872    }
873
874    pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
875        mj_core::config::validate_id("profile", new_id)?;
876        if old_id == new_id {
877            ensure!(
878                self.config.profiles.contains_key(old_id),
879                "unknown profile {old_id:?}"
880            );
881            return Ok(());
882        }
883        let journal = ConfigRenameJournal {
884            kind: ConfigRenameKind::Profile,
885            old_id: old_id.to_owned(),
886            new_id: new_id.to_owned(),
887        };
888        write_config_rename_journal(&journal)?;
889        let (config, ()) = match Config::update(|config| {
890            ensure!(
891                config.profiles.contains_key(old_id),
892                "unknown profile {old_id:?}"
893            );
894            ensure!(
895                !config.profiles.contains_key(new_id),
896                "profile {new_id:?} already exists"
897            );
898            let profile = config
899                .profiles
900                .remove(old_id)
901                .expect("profile was checked in the transaction");
902            config.profiles.insert(new_id.to_owned(), profile);
903            Ok(())
904        }) {
905            Ok(result) => result,
906            Err(error) => {
907                remove_config_rename_journal()
908                    .context("remove profile rename journal after config save failed")?;
909                return Err(error).context("save renamed profile configuration");
910            }
911        };
912        self.config = config;
913        mj_core::test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
914        if let Err(error) = crate::database::rename_profile_references(old_id, new_id) {
915            let restore = Config::update(|config| {
916                let profile = config
917                    .profiles
918                    .remove(new_id)
919                    .with_context(|| format!("renamed profile {new_id:?} is missing"))?;
920                ensure!(
921                    !config.profiles.contains_key(old_id),
922                    "cannot restore profile rename: both {old_id:?} and {new_id:?} exist"
923                );
924                config.profiles.insert(old_id.to_owned(), profile);
925                Ok(())
926            });
927            let restored = match restore {
928                Ok((config, ())) => config,
929                Err(restore_error) => {
930                    return Err(error).context(format!(
931                        "rename profile references; additionally failed to restore config: {restore_error:#}"
932                    ));
933                }
934            };
935            self.config = restored;
936            if let Err(restore_error) = remove_config_rename_journal() {
937                return Err(error).context(format!(
938                    "rename profile references; additionally failed to remove rename journal: {restore_error:#}"
939                ));
940            }
941            return Err(error).context("rename profile references");
942        }
943        for session in self.state.sessions.values_mut() {
944            if session.last_profile == old_id {
945                session.last_profile = new_id.to_owned();
946            }
947        }
948        remove_config_rename_journal()?;
949        Ok(())
950    }
951
952    pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
953        mj_core::config::validate_id("target template", new_id)?;
954        if old_id == new_id {
955            ensure!(
956                self.config.targets.contains_key(old_id),
957                "unknown target {old_id:?}"
958            );
959            return Ok(());
960        }
961        let journal = ConfigRenameJournal {
962            kind: ConfigRenameKind::Target,
963            old_id: old_id.to_owned(),
964            new_id: new_id.to_owned(),
965        };
966        write_config_rename_journal(&journal)?;
967        let (config, ()) = match Config::update(|config| {
968            ensure!(
969                config.targets.contains_key(old_id),
970                "unknown target {old_id:?}"
971            );
972            ensure!(
973                !config.targets.contains_key(new_id),
974                "target {new_id:?} already exists"
975            );
976            let target = config
977                .targets
978                .remove(old_id)
979                .expect("target was checked in the transaction");
980            config.targets.insert(new_id.to_owned(), target);
981            Ok(())
982        }) {
983            Ok(result) => result,
984            Err(error) => {
985                remove_config_rename_journal()
986                    .context("remove target rename journal after config save failed")?;
987                return Err(error).context("save renamed target configuration");
988            }
989        };
990        self.config = config;
991        mj_core::test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
992        if let Err(error) = crate::database::rename_target_references(old_id, new_id) {
993            let restore = Config::update(|config| {
994                let target = config
995                    .targets
996                    .remove(new_id)
997                    .with_context(|| format!("renamed target {new_id:?} is missing"))?;
998                ensure!(
999                    !config.targets.contains_key(old_id),
1000                    "cannot restore target rename: both {old_id:?} and {new_id:?} exist"
1001                );
1002                config.targets.insert(old_id.to_owned(), target);
1003                Ok(())
1004            });
1005            let restored = match restore {
1006                Ok((config, ())) => config,
1007                Err(restore_error) => {
1008                    return Err(error).context(format!(
1009                        "rename target references; additionally failed to restore config: {restore_error:#}"
1010                    ));
1011                }
1012            };
1013            self.config = restored;
1014            if let Err(restore_error) = remove_config_rename_journal() {
1015                return Err(error).context(format!(
1016                    "rename target references; additionally failed to remove rename journal: {restore_error:#}"
1017                ));
1018            }
1019            return Err(error).context("rename target references");
1020        }
1021        for session in self.state.sessions.values_mut() {
1022            if session.target_template_id == old_id {
1023                session.target_template_id = new_id.to_owned();
1024            }
1025        }
1026        remove_config_rename_journal()?;
1027        Ok(())
1028    }
1029
1030    /// Finish a profile/target id rename interrupted between the atomic config
1031    /// replacement and SQLite transaction. Each step is idempotent, so a
1032    /// second crash leaves the same intent available for the next startup.
1033    pub fn recover_config_id_rename() -> Result<bool> {
1034        let path = config_rename_journal_path();
1035        let body = match fs::read(&path) {
1036            Ok(body) => body,
1037            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1038            Err(error) => return Err(error).context(format!("read {}", path.display())),
1039        };
1040        let journal: ConfigRenameJournal =
1041            serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
1042        match journal.kind {
1043            ConfigRenameKind::Profile => {
1044                Config::update(|config| {
1045                    finish_config_map_rename(
1046                        &mut config.profiles,
1047                        &journal.old_id,
1048                        &journal.new_id,
1049                        "profile",
1050                    )?;
1051                    Ok(())
1052                })?;
1053                crate::database::rename_profile_references(&journal.old_id, &journal.new_id)?;
1054            }
1055            ConfigRenameKind::Target => {
1056                Config::update(|config| {
1057                    finish_config_map_rename(
1058                        &mut config.targets,
1059                        &journal.old_id,
1060                        &journal.new_id,
1061                        "target",
1062                    )?;
1063                    Ok(())
1064                })?;
1065                crate::database::rename_target_references(&journal.old_id, &journal.new_id)?;
1066            }
1067        }
1068        remove_config_rename_journal()?;
1069        Ok(true)
1070    }
1071
1072    /// Record the per-session container size overrides and attached
1073    /// directories. Nothing is applied to a running container: the values are
1074    /// read the next time the session's container is created.
1075    pub fn update_session_container_settings(
1076        &mut self,
1077        session_id: &str,
1078        cpus: Option<String>,
1079        memory: Option<String>,
1080        additional_mounts: Vec<targets::AdditionalMount>,
1081        mount_history: Vec<std::path::PathBuf>,
1082    ) -> Result<()> {
1083        ensure!(
1084            self.state.sessions.contains_key(session_id),
1085            "unknown session {session_id}"
1086        );
1087        let cpus = cpus.filter(|value| !value.trim().is_empty());
1088        let memory = memory.filter(|value| !value.trim().is_empty());
1089        let updated_at = now();
1090        crate::database::set_session_container_settings(
1091            session_id,
1092            cpus.as_deref(),
1093            memory.as_deref(),
1094            &additional_mounts,
1095            &updated_at,
1096        )?;
1097        if let Some(host) = self
1098            .config
1099            .targets
1100            .get(
1101                &self.state.sessions[session_id]
1102                    .target_template_id
1103                    .to_owned(),
1104            )
1105            .and_then(mj_core::config::mount_history_host)
1106        {
1107            let host = host.to_owned();
1108            // The dialog owns the suggestion list, so forgetting a directory
1109            // there has to survive the mounts being remembered right after.
1110            crate::database::replace_mount_history(&host, &mount_history)?;
1111            crate::database::remember_mount_sources(&host, &additional_mounts)?;
1112            self.state.mount_history.insert(host.clone(), mount_history);
1113            self.state.remember_mount_sources(&host, &additional_mounts);
1114        }
1115        let record = self
1116            .state
1117            .sessions
1118            .get_mut(session_id)
1119            .expect("session was checked before updating its container settings");
1120        record.container_cpus = cpus;
1121        record.container_memory = memory;
1122        record.additional_mounts = additional_mounts;
1123        record.updated_at = updated_at;
1124        Ok(())
1125    }
1126}
1127
1128fn config_rename_journal_path() -> PathBuf {
1129    data_dir().join(CONFIG_RENAME_JOURNAL)
1130}
1131
1132fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
1133    let path = config_rename_journal_path();
1134    let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
1135    atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
1136}
1137
1138fn remove_config_rename_journal() -> Result<()> {
1139    let path = config_rename_journal_path();
1140    match fs::remove_file(&path) {
1141        Ok(()) => Ok(()),
1142        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1143        Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1144    }
1145}
1146
1147fn finish_config_map_rename<T>(
1148    entries: &mut BTreeMap<String, T>,
1149    old_id: &str,
1150    new_id: &str,
1151    kind: &str,
1152) -> Result<()> {
1153    if let Some(entry) = entries.remove(old_id) {
1154        ensure!(
1155            !entries.contains_key(new_id),
1156            "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
1157        );
1158        entries.insert(new_id.to_owned(), entry);
1159    } else {
1160        ensure!(
1161            entries.contains_key(new_id),
1162            "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
1163        );
1164    }
1165    Ok(())
1166}
1167
1168fn target_kind(locator: &targets::TargetLocator) -> &'static str {
1169    match locator {
1170        targets::TargetLocator::LocalBare { .. } => "local-bare",
1171        targets::TargetLocator::LocalPodman { .. } => "local-podman",
1172        targets::TargetLocator::LocalDocker { .. } => "local-docker",
1173        targets::TargetLocator::AppleContainer { .. } => "apple-container",
1174        targets::TargetLocator::AwsEc2 { .. } => "aws-ec2",
1175        targets::TargetLocator::SshBare { .. } => "ssh-bare",
1176        targets::TargetLocator::SshPodman { .. } => "ssh-podman",
1177        targets::TargetLocator::SshDocker { .. } => "ssh-docker",
1178    }
1179}
1180
1181/// Whether this profile must run from a private staged copy of its home even on
1182/// a local bare target, where a session would otherwise use the profile home
1183/// directly.
1184///
1185/// A Codex profile with a custom model provider qualifies: Mjolnir generates the
1186/// provider's model catalog for each launch and points the staged `config.toml`
1187/// at it, and it must never write either into the user's own profile home.
1188pub(crate) fn requires_private_profile_home(profile: &mj_core::config::HarnessProfile) -> bool {
1189    profile.codex_provider().ok().flatten().is_some()
1190}
1191
1192fn target_profile_home(
1193    locator: &targets::TargetLocator,
1194    session_id: &str,
1195    profile: &mj_core::config::HarnessProfile,
1196) -> String {
1197    let home = match locator {
1198        targets::TargetLocator::LocalBare { worker_root }
1199            if profile.kind == mj_core::config::HarnessKind::Claude
1200                || requires_private_profile_home(profile) =>
1201        {
1202            Path::new(worker_root)
1203                .join("profile")
1204                .to_string_lossy()
1205                .into_owned()
1206        }
1207        targets::TargetLocator::LocalBare { .. } => profile.home.to_string_lossy().into_owned(),
1208        targets::TargetLocator::LocalPodman { .. }
1209        | targets::TargetLocator::LocalDocker { .. }
1210        | targets::TargetLocator::AppleContainer { .. }
1211        | targets::TargetLocator::SshPodman { .. }
1212        | targets::TargetLocator::SshDocker { .. } => {
1213            format!("/var/lib/hel/profiles/{session_id}")
1214        }
1215        targets::TargetLocator::AwsEc2 { .. } | targets::TargetLocator::SshBare { .. } => {
1216            format!(".local/share/hel/profiles/{session_id}")
1217        }
1218    };
1219    if profile.kind == mj_core::config::HarnessKind::Muse {
1220        let root = if matches!(locator, targets::TargetLocator::LocalBare { .. }) {
1221            mj_core::config::data_dir()
1222                .join("profiles")
1223                .join(session_id)
1224        } else {
1225            PathBuf::from(home)
1226        };
1227        root.join("muse").to_string_lossy().into_owned()
1228    } else {
1229        home
1230    }
1231}
1232
1233/// Resolve the login home on the machine that owns an editable path.
1234pub fn resolve_target_input_path(
1235    target: &TargetTemplate,
1236    path: &Path,
1237    executor: &impl CommandExecutor,
1238) -> Result<PathBuf> {
1239    if !mj_core::path_input::needs_home(path)? {
1240        return Ok(path.to_path_buf());
1241    }
1242    match target {
1243        TargetTemplate::SshBare { ssh, .. }
1244        | TargetTemplate::SshPodman { ssh, .. }
1245        | TargetTemplate::SshDocker { ssh, .. } => {
1246            let mut ssh = ssh.clone();
1247            ssh.identity_file = ssh
1248                .identity_file
1249                .as_deref()
1250                .map(mj_core::path_input::expand_local)
1251                .transpose()?;
1252            let command =
1253                ssh_command_spec(&backend_ssh(&ssh), ["sh", "-c", "printf '%s' \"$HOME\""])
1254                    .purpose("resolve remote home directory");
1255            let output = executor.execute(&command)?;
1256            anyhow::ensure!(
1257                output.status == 0,
1258                "Could not resolve remote home: {}",
1259                String::from_utf8_lossy(&output.stderr).trim()
1260            );
1261            let home =
1262                String::from_utf8(output.stdout).context("Remote home is not valid UTF-8")?;
1263            mj_core::path_input::expand_home(path, Some(Path::new(&home)))
1264        }
1265        _ => mj_core::path_input::expand_local(path),
1266    }
1267}
1268
1269pub(crate) fn backend_ssh(ssh: &SshConnection) -> SshTarget {
1270    let destination = match &ssh.user {
1271        Some(user) => format!("{user}@{}", ssh.host),
1272        None => ssh.host.clone(),
1273    };
1274    SshTarget {
1275        destination,
1276        ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
1277    }
1278}
1279
1280fn ssh_command_spec(
1281    ssh: &SshTarget,
1282    args: impl IntoIterator<Item = impl AsRef<str>>,
1283) -> CommandSpec {
1284    let remote = args
1285        .into_iter()
1286        .map(|arg| arg.as_ref().to_string())
1287        .collect::<Vec<_>>();
1288    let mut command_args = ssh.ssh_args.clone();
1289    command_args.push(ssh.destination.clone());
1290    command_args.push(targets::join_remote_command(&remote));
1291    CommandSpec::new("ssh", command_args).ssh_destination(ssh.destination.clone())
1292}
1293
1294fn scp_command_spec(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
1295    let mut args = ssh.ssh_args.clone();
1296    if recursive {
1297        args.push("-r".into());
1298    }
1299    args.push(source.to_string_lossy().into_owned());
1300    args.push(format!("{}:{remote}", ssh.destination));
1301    // `scp` opens its own connection to the same host, so it competes for the
1302    // same pre-auth budget and is admitted and retried the same way.
1303    CommandSpec::new("scp", args).ssh_destination(ssh.destination.clone())
1304}
1305
1306fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
1307    // Mjolnir drives ssh non-interactively from a TUI; a host-key or password
1308    // prompt would steal the terminal and wedge provisioning. BatchMode fails
1309    // fast instead of prompting, and accept-new trusts a first-seen host key
1310    // (fresh EC2 instances are always first-seen) while still rejecting
1311    // changed keys. User-supplied ssh_args come last so they can override.
1312    let mut result = vec![
1313        "-o".into(),
1314        "BatchMode=yes".into(),
1315        "-o".into(),
1316        "StrictHostKeyChecking=accept-new".into(),
1317        "-o".into(),
1318        "ConnectTimeout=15".into(),
1319    ];
1320    result.extend(args.iter().cloned());
1321    if let Some(identity) = identity {
1322        result.push("-i".into());
1323        result.push(identity.to_string_lossy().into_owned());
1324    }
1325    result
1326}
1327
1328fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
1329    let output = executor.execute(&command)?;
1330    if output.status != 0 {
1331        let detail = command_error_detail(&output.stderr);
1332        if detail.is_empty() {
1333            bail!("{} failed with status {}", command.purpose, output.status);
1334        }
1335        bail!("{detail}");
1336    }
1337    Ok(output)
1338}
1339
1340fn command_error_detail(stderr: &[u8]) -> String {
1341    let reported = String::from_utf8_lossy(stderr);
1342    let reported = reported.trim();
1343    let detail = reported
1344        .rsplit_once("\nCaused by:\n")
1345        .map_or(reported, |(_, causes)| causes);
1346    let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
1347    detail
1348        .lines()
1349        .map(|line| line.strip_prefix("    ").unwrap_or(line))
1350        .collect::<Vec<_>>()
1351        .join("\n")
1352        .trim()
1353        .to_owned()
1354}
1355
1356fn now() -> String {
1357    Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1358}
1359
1360fn restore_session_after_persistence_failure(
1361    state: &mut State,
1362    session_id: &str,
1363    previous: &SessionRecord,
1364    primary: anyhow::Error,
1365    persist: impl FnOnce(&SessionRecord) -> Result<()>,
1366) -> anyhow::Error {
1367    state
1368        .sessions
1369        .insert(session_id.to_owned(), previous.clone());
1370    let restored = state
1371        .sessions
1372        .get(session_id)
1373        .expect("restored session record disappeared");
1374    match persist(restored) {
1375        Ok(()) => primary,
1376        Err(error) => primary.context(format!(
1377            "restored prior session state in memory, but failed to persist the rollback: {error:#}"
1378        )),
1379    }
1380}
1381
1382fn persist_session_record_transition_or_restore(
1383    state: &mut State,
1384    session_id: &str,
1385    previous: &SessionRecord,
1386    context: &'static str,
1387    persist: &impl Fn(&SessionRecord) -> Result<()>,
1388) -> Result<()> {
1389    let result = persist(
1390        state
1391            .sessions
1392            .get(session_id)
1393            .expect("checkpoint session disappeared before persistence"),
1394    );
1395    match result {
1396        Ok(()) => Ok(()),
1397        Err(error) => Err(restore_session_after_persistence_failure(
1398            state,
1399            session_id,
1400            previous,
1401            error.context(context),
1402            persist,
1403        )),
1404    }
1405}
1406
1407pub fn config_only_controller(config: Config) -> Controller {
1408    Controller {
1409        config,
1410        state: State::default(),
1411    }
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416    use std::collections::BTreeMap;
1417    use std::path::Path;
1418
1419    use crate::targets::ProcessExecutor;
1420    use mj_core::config::{
1421        Config, ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, ProjectBundle,
1422        ProjectRepository, TargetTemplate,
1423    };
1424    use mj_core::state::State;
1425
1426    use super::*;
1427
1428    /// Every command that opens a connection to the host has to be admitted,
1429    /// `scp` included: it competes for the same pre-auth budget as `ssh`, and
1430    /// a connection the server drops carried no bytes, so it can be retried.
1431    #[test]
1432    fn ssh_and_scp_specs_are_both_tagged_with_the_connection_destination() {
1433        let ssh = SshTarget {
1434            destination: "build@10.0.0.1".into(),
1435            ssh_args: vec!["-o".into(), "BatchMode=yes".into()],
1436        };
1437
1438        let uploaded = scp_command_spec(&ssh, Path::new("/tmp/local"), "remote/path", true);
1439        let ran = ssh_command_spec(&ssh, ["true"]);
1440
1441        assert_eq!(uploaded.program, "scp");
1442        assert_eq!(uploaded.ssh_destination.as_deref(), Some("build@10.0.0.1"));
1443        assert_eq!(ran.ssh_destination.as_deref(), Some("build@10.0.0.1"));
1444    }
1445
1446    /// One profile, one bundle with nothing checked out locally, and one
1447    /// container target, which is all `register_session_with_resources` reads.
1448    fn registration_config() -> Config {
1449        let mut config = Config::default();
1450        config.profiles.insert(
1451            "codex".into(),
1452            HarnessProfile {
1453                enabled: true,
1454                kind: HarnessKind::Codex,
1455                home: PathBuf::from("/home/dev/.codex"),
1456                environment: BTreeMap::new(),
1457                context_window_bytes: None,
1458                guardian_review_model: None,
1459            },
1460        );
1461        config.bundles.insert(
1462            "project".into(),
1463            ProjectBundle {
1464                primary_repo: "project".into(),
1465                repositories: vec![ProjectRepository {
1466                    id: "project".into(),
1467                    github: Some("owner/project".into()),
1468                    local: None,
1469                    destination: PathBuf::from("project"),
1470                    git_ref: None,
1471                }],
1472            },
1473        );
1474        config.targets.insert(
1475            "podman".into(),
1476            TargetTemplate::LocalPodman {
1477                container: ConfigContainer {
1478                    image: "example.invalid/hel-test:latest".into(),
1479                    pull_policy: Default::default(),
1480                    platform: None,
1481                    cpus: None,
1482                    memory: None,
1483                    environment: BTreeMap::new(),
1484                    workspace_storage: Default::default(),
1485                },
1486            },
1487        );
1488        config
1489    }
1490
1491    #[test]
1492    fn remote_completion_preserves_home_shorthand_and_trailing_separator() {
1493        struct CompletionExecutor;
1494        impl CommandExecutor for CompletionExecutor {
1495            fn execute(&self, command: &CommandSpec) -> Result<targets::CommandOutput> {
1496                let script = command.args.last().unwrap();
1497                let stdout = if script.contains("$HOME") {
1498                    b"/remote".to_vec()
1499                } else {
1500                    assert!(script.contains("'/remote/cache/'"), "{script}");
1501                    b"/remote/cache/alpha/\n/remote/cache/alpine/\n".to_vec()
1502                };
1503                Ok(targets::CommandOutput {
1504                    status: 0,
1505                    stdout,
1506                    stderr: Vec::new(),
1507                })
1508            }
1509        }
1510        let target: TargetTemplate =
1511            serde_json::from_str(r#"{"kind":"ssh-podman","host":"builder","image":"test"}"#)
1512                .unwrap();
1513        let mut config = Config::default();
1514        config.targets.insert("remote".into(), target);
1515        let controller = Controller {
1516            config,
1517            state: State::default(),
1518        };
1519        let candidates = controller
1520            .complete_mount_source("remote", "~/cache/", &CompletionExecutor)
1521            .unwrap();
1522        assert_eq!(candidates, ["~/cache/alpha/", "~/cache/alpine/"]);
1523        assert_eq!(
1524            targets::path_completion("~/cache/", &candidates).as_deref(),
1525            Some("~/cache/alp")
1526        );
1527    }
1528
1529    #[test]
1530    fn remote_path_resolution_uses_login_home_without_evaluating_suffix() {
1531        struct HomeExecutor {
1532            status: i32,
1533            home: &'static str,
1534        }
1535        impl CommandExecutor for HomeExecutor {
1536            fn execute(&self, command: &CommandSpec) -> Result<targets::CommandOutput> {
1537                assert_eq!(command.program, "ssh");
1538                let script = command.args.last().unwrap();
1539                assert!(!script.contains("touch"));
1540                assert!(script.contains("$HOME"));
1541                Ok(targets::CommandOutput {
1542                    status: self.status,
1543                    stdout: self.home.as_bytes().to_vec(),
1544                    stderr: b"home lookup failed".to_vec(),
1545                })
1546            }
1547        }
1548        let target: TargetTemplate = serde_json::from_str(
1549            r#"{"kind":"ssh-bare","host":"builder","permissions":"guardian"}"#,
1550        )
1551        .unwrap();
1552        let path = Path::new("~/資料/$(touch nope)");
1553        assert_eq!(
1554            resolve_target_input_path(
1555                &target,
1556                path,
1557                &HomeExecutor {
1558                    status: 0,
1559                    home: "/remote user"
1560                }
1561            )
1562            .unwrap(),
1563            Path::new("/remote user/資料/$(touch nope)")
1564        );
1565        assert!(
1566            resolve_target_input_path(
1567                &target,
1568                path,
1569                &HomeExecutor {
1570                    status: 1,
1571                    home: "/remote"
1572                }
1573            )
1574            .unwrap_err()
1575            .to_string()
1576            .contains("home lookup failed")
1577        );
1578        assert!(
1579            resolve_target_input_path(
1580                &target,
1581                path,
1582                &HomeExecutor {
1583                    status: 0,
1584                    home: ""
1585                }
1586            )
1587            .is_err()
1588        );
1589        assert!(
1590            resolve_target_input_path(
1591                &target,
1592                path,
1593                &HomeExecutor {
1594                    status: 0,
1595                    home: "relative"
1596                }
1597            )
1598            .is_err()
1599        );
1600    }
1601
1602    #[test]
1603    fn bundle_creation_combines_sources_with_first_primary_and_stable_collisions() {
1604        let mut config = Config::default();
1605        let sources = vec!["example/app".into(), "other/app".into()];
1606
1607        let bundle_id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1608        let bundle = &config.bundles[&bundle_id];
1609        assert_eq!(bundle_id, "app");
1610        assert_eq!(bundle.primary_repo, "app");
1611        assert_eq!(
1612            bundle
1613                .repositories
1614                .iter()
1615                .map(|repository| repository.id.as_str())
1616                .collect::<Vec<_>>(),
1617            ["app", "app-2"]
1618        );
1619        assert_eq!(
1620            bundle
1621                .repositories
1622                .iter()
1623                .map(|repository| repository.destination.to_string_lossy().into_owned())
1624                .collect::<Vec<_>>(),
1625            ["app".to_owned(), "app-2".to_owned()]
1626        );
1627        assert_eq!(
1628            bundle.repositories[0].github.as_deref(),
1629            Some("example/app")
1630        );
1631        assert_eq!(bundle.repositories[1].github.as_deref(), Some("other/app"));
1632    }
1633
1634    #[test]
1635    fn bundle_creation_combines_local_and_github_sources_and_rejects_local_aliases() {
1636        let directory = tempfile::tempdir().unwrap();
1637        let root = directory.path().join("app");
1638        let output = mj_core::subprocess::run_capturing_stdout(
1639            std::process::Command::new("git").arg("init").arg(&root),
1640        )
1641        .unwrap();
1642        assert!(output.status.success(), "{output:?}");
1643        let nested = root.join("nested");
1644        fs::create_dir(&nested).unwrap();
1645        let mut config = Config::default();
1646        let sources = vec![root.to_str().unwrap().into(), "example/shared".into()];
1647        let id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1648        let bundle = &config.bundles[&id];
1649        assert_eq!(
1650            bundle.primary().unwrap().local,
1651            Some(root.canonicalize().unwrap())
1652        );
1653        assert_eq!(
1654            bundle.repositories[1].github.as_deref(),
1655            Some("example/shared")
1656        );
1657        let before = config.clone();
1658        let aliases = vec![
1659            root.to_str().unwrap().into(),
1660            nested.to_str().unwrap().into(),
1661        ];
1662        let error = create_bundle_from_sources_in_config(&mut config, &aliases).unwrap_err();
1663        assert!(
1664            error.to_string().contains("duplicate repository source"),
1665            "{error:#}"
1666        );
1667        assert_eq!(config, before);
1668    }
1669
1670    #[test]
1671    fn bundle_creation_rejects_duplicate_normalized_sources_atomically() {
1672        let mut config = Config::default();
1673        let before = config.clone();
1674        let sources = vec![
1675            "example/app".into(),
1676            "https://github.com/EXAMPLE/APP.git".into(),
1677        ];
1678
1679        let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1680        assert!(error.to_string().contains("duplicate repository source"));
1681        assert_eq!(config, before);
1682    }
1683
1684    #[test]
1685    fn bundle_creation_validates_every_source_before_mutating_config() {
1686        let mut config = Config::default();
1687        let before = config.clone();
1688        let invalid_directory = tempfile::tempdir().unwrap();
1689        let sources = vec![
1690            "example/app".into(),
1691            invalid_directory.path().to_string_lossy().into_owned(),
1692        ];
1693
1694        let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1695        assert!(error.to_string().contains("not a Git repository"));
1696        assert_eq!(config, before);
1697    }
1698
1699    #[test]
1700    fn bundle_creation_reuses_an_exact_source_set_and_rejects_obsolete_pins() {
1701        let mut config = Config::default();
1702        config.bundles.insert(
1703            "all".into(),
1704            ProjectBundle {
1705                primary_repo: "app".into(),
1706                repositories: vec![
1707                    ProjectRepository {
1708                        id: "app".into(),
1709                        github: Some("example/app".into()),
1710                        local: None,
1711                        destination: "app".into(),
1712                        git_ref: None,
1713                    },
1714                    ProjectRepository {
1715                        id: "shared".into(),
1716                        github: Some("example/shared".into()),
1717                        local: None,
1718                        destination: "shared".into(),
1719                        git_ref: None,
1720                    },
1721                ],
1722            },
1723        );
1724
1725        let one_source = vec!["example/app".into()];
1726        let created = create_bundle_from_sources_in_config(&mut config, &one_source).unwrap();
1727        assert_eq!(created, "app");
1728        assert_eq!(config.bundles[&created].repositories.len(), 1);
1729        assert_eq!(
1730            create_bundle_from_sources_in_config(&mut config, &one_source).unwrap(),
1731            "app"
1732        );
1733
1734        let exact_sources = vec!["example/app".into(), "example/shared".into()];
1735        assert_eq!(
1736            create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap(),
1737            "all"
1738        );
1739        assert_eq!(config.bundles.len(), 2);
1740        config.bundles.get_mut("all").unwrap().repositories[0].git_ref = Some("release".into());
1741        let before = config.clone();
1742        let error = create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap_err();
1743        assert!(format!("{error:#}").contains("git_ref is no longer supported"));
1744        assert_eq!(config, before);
1745    }
1746
1747    fn launch_options(additional_mounts: Vec<AdditionalMount>) -> SessionLaunchOptions {
1748        SessionLaunchOptions {
1749            mjolnir_subagents: None,
1750            create_managed_worktree: None,
1751            initial_prompt: None,
1752            workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1753            additional_mounts,
1754            allow_dirty_local: false,
1755            resource_allocation: None,
1756            project_directory: None,
1757            session_title_override: None,
1758        }
1759    }
1760
1761    #[test]
1762    fn registration_rejects_a_disabled_profile_before_persisting() {
1763        let mut config = registration_config();
1764        config.profiles.get_mut("codex").unwrap().enabled = false;
1765        let mut controller = Controller {
1766            config,
1767            state: State::default(),
1768        };
1769
1770        let error = controller
1771            .register_session_with_resources(
1772                "codex",
1773                "project",
1774                "podman",
1775                "disabled",
1776                launch_options(Vec::new()),
1777            )
1778            .unwrap_err();
1779
1780        assert!(error.to_string().contains("disabled"));
1781        assert!(controller.state.sessions.is_empty());
1782    }
1783
1784    #[test]
1785    fn muse_registration_rejects_more_than_one_workspace_root_before_persisting() {
1786        let mut config = registration_config();
1787        config.profiles.get_mut("codex").unwrap().kind = HarnessKind::Muse;
1788        let second = config.bundles["project"].repositories[0].clone();
1789        config
1790            .bundles
1791            .get_mut("project")
1792            .unwrap()
1793            .repositories
1794            .push(mj_core::config::ProjectRepository {
1795                id: "second".into(),
1796                destination: "second".into(),
1797                ..second
1798            });
1799        let mut controller = Controller {
1800            config,
1801            state: State::default(),
1802        };
1803
1804        let error = controller
1805            .register_session_with_resources(
1806                "codex",
1807                "project",
1808                "podman",
1809                "unsupported",
1810                launch_options(Vec::new()),
1811            )
1812            .unwrap_err();
1813
1814        assert!(error.to_string().contains("one workspace root"));
1815        assert!(controller.state.sessions.is_empty());
1816    }
1817
1818    /// MJ_DATA_DIR is process-global, so every test that reaches the
1819    /// controller database runs in an exact child with its own data directory.
1820    fn run_registration_child(marker: &str, test: &str, data_directory: &Path) {
1821        let output = std::process::Command::new(std::env::current_exe().unwrap())
1822            .args([
1823                "--exact",
1824                &format!("controller::tests::{test}"),
1825                "--nocapture",
1826            ])
1827            .env(marker, "1")
1828            .env("MJ_DATA_DIR", data_directory)
1829            .env("MJ_CONFIG_DIR", data_directory)
1830            .output()
1831            .unwrap();
1832        assert!(
1833            output.status.success(),
1834            "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1835            String::from_utf8_lossy(&output.stdout),
1836            String::from_utf8_lossy(&output.stderr)
1837        );
1838    }
1839
1840    #[test]
1841    fn registration_saves_the_initial_task_before_provisioning() {
1842        const MARKER: &str = "MJ_TEST_INITIAL_TASK_CHILD";
1843        if std::env::var_os(MARKER).is_none() {
1844            let directory = tempfile::tempdir().unwrap();
1845            run_registration_child(
1846                MARKER,
1847                "registration_saves_the_initial_task_before_provisioning",
1848                directory.path(),
1849            );
1850            return;
1851        }
1852        let _writer = crate::database::install_isolated_test_writer();
1853        let mut controller = Controller {
1854            config: registration_config(),
1855            state: State::default(),
1856        };
1857        let prompt = format!(
1858            "Initial task\n{}\n\tPreserve indentation and λ",
1859            "x".repeat(70_000)
1860        );
1861        let mut options = launch_options(Vec::new());
1862        options.initial_prompt = Some(prompt.clone());
1863        let id = controller
1864            .register_session_with_resources("codex", "project", "podman", "fresh task", options)
1865            .unwrap();
1866        let saved = crate::database::load_state().unwrap();
1867        assert_eq!(saved.sessions[&id].draft_input, prompt);
1868        assert_eq!(saved.sessions[&id].state, SessionState::Provisioning);
1869        crate::database::set_session_draft_input(&id, "a newer draft").unwrap();
1870        crate::database::clear_session_draft_input_if_matches(&id, &prompt).unwrap();
1871        assert_eq!(
1872            crate::database::load_state().unwrap().sessions[&id].draft_input,
1873            "a newer draft"
1874        );
1875        crate::database::clear_session_draft_input_if_matches(&id, "a newer draft").unwrap();
1876        assert!(
1877            crate::database::load_state().unwrap().sessions[&id]
1878                .draft_input
1879                .is_empty()
1880        );
1881    }
1882
1883    const UNPERSISTABLE_SESSION_CHILD: &str = "MJ_TEST_UNPERSISTABLE_SESSION_CHILD";
1884
1885    #[test]
1886    fn missing_bundle_does_not_block_controller_or_other_sessions() {
1887        const CHILD: &str = "MJ_TEST_MISSING_BUNDLE_CHILD";
1888        if std::env::var_os(CHILD).is_none() {
1889            let directory = tempfile::tempdir().unwrap();
1890            run_registration_child(
1891                CHILD,
1892                "missing_bundle_does_not_block_controller_or_other_sessions",
1893                directory.path(),
1894            );
1895            return;
1896        }
1897        let _writer = crate::database::install_isolated_test_writer();
1898        let mut controller = Controller {
1899            config: registration_config(),
1900            state: State::default(),
1901        };
1902        let bundle = controller.config.bundles["project"].clone();
1903        controller
1904            .config
1905            .bundles
1906            .insert("other".into(), bundle.clone());
1907        controller.config.save().unwrap();
1908        let affected = controller
1909            .register_session_with_resources(
1910                "codex",
1911                "project",
1912                "podman",
1913                "affected",
1914                launch_options(Vec::new()),
1915            )
1916            .unwrap();
1917        let healthy = controller
1918            .register_session_with_resources(
1919                "codex",
1920                "other",
1921                "podman",
1922                "healthy",
1923                launch_options(Vec::new()),
1924            )
1925            .unwrap();
1926        controller.config.bundles.remove("project");
1927        controller.config.save().unwrap();
1928        let loaded = Controller::load().unwrap();
1929        assert_eq!(loaded.state.sessions.len(), 2);
1930        assert!(
1931            loaded.state.sessions[&healthy]
1932                .configuration_issue(&loaded.config)
1933                .is_none()
1934        );
1935        let issue = loaded.reconnect_command(&affected).unwrap_err().to_string();
1936        assert!(issue.contains("missing bundle"), "{issue}");
1937        assert!(issue.contains("config.toml"), "{issue}");
1938        // Loading must not turn a configuration problem into a persisted lifecycle failure.
1939        assert_eq!(
1940            loaded.state.sessions[&affected],
1941            controller.state.sessions[&affected]
1942        );
1943        Config::update(|config| {
1944            config.bundles.insert("project".into(), bundle);
1945            Ok(())
1946        })
1947        .unwrap();
1948        let repaired = Controller::load().unwrap();
1949        assert!(
1950            repaired.state.sessions[&affected]
1951                .configuration_issue(&repaired.config)
1952                .is_none()
1953        );
1954    }
1955
1956    const CONFIG_ID_RENAME_CHILD: &str = "MJ_TEST_CONFIG_ID_RENAME_CHILD";
1957
1958    #[test]
1959    fn configuration_id_rename_rewrites_durable_session_references() {
1960        if std::env::var_os(CONFIG_ID_RENAME_CHILD).is_none() {
1961            let directory = tempfile::tempdir().unwrap();
1962            run_registration_child(
1963                CONFIG_ID_RENAME_CHILD,
1964                "configuration_id_rename_rewrites_durable_session_references",
1965                directory.path(),
1966            );
1967            return;
1968        }
1969        // Alone in this child process, so it installs the one writer.
1970        let _writer = crate::database::install_isolated_test_writer();
1971
1972        let mut controller = Controller {
1973            config: registration_config(),
1974            state: State::default(),
1975        };
1976        controller.config.save().unwrap();
1977        let session_id = controller
1978            .register_session_with_resources(
1979                "codex",
1980                "project",
1981                "podman",
1982                "rename references",
1983                launch_options(Vec::new()),
1984            )
1985            .unwrap();
1986
1987        controller
1988            .rename_profile_id("codex", "codex-renamed")
1989            .unwrap();
1990        controller
1991            .rename_target_id("podman", "podman-renamed")
1992            .unwrap();
1993
1994        let loaded = Controller::load().unwrap();
1995        let session = &loaded.state.sessions[&session_id];
1996        assert_eq!(session.last_profile, "codex-renamed");
1997        assert_eq!(session.target_template_id, "podman-renamed");
1998        assert!(loaded.config.profiles.contains_key("codex-renamed"));
1999        assert!(loaded.config.targets.contains_key("podman-renamed"));
2000        assert!(!config_rename_journal_path().exists());
2001    }
2002
2003    #[test]
2004    fn a_session_the_database_rejects_is_never_left_in_memory() {
2005        if std::env::var_os(UNPERSISTABLE_SESSION_CHILD).is_none() {
2006            let directory = tempfile::tempdir().unwrap();
2007            run_registration_child(
2008                UNPERSISTABLE_SESSION_CHILD,
2009                "a_session_the_database_rejects_is_never_left_in_memory",
2010                directory.path(),
2011            );
2012            return;
2013        }
2014        // Alone in this child process, so it installs the one writer.
2015        let _writer = crate::database::install_isolated_test_writer();
2016
2017        let mut controller = Controller {
2018            config: registration_config(),
2019            state: State::default(),
2020        };
2021        // The store has to be healthy enough to open before it can reject a
2022        // write: this test is about a write the database refuses, not about a
2023        // store that cannot be opened at all, which now fails earlier and
2024        // louder when the writer is installed. The first registration builds
2025        // the schema the second one then loses.
2026        controller
2027            .register_session_with_resources(
2028                "codex",
2029                "project",
2030                "podman",
2031                "first",
2032                launch_options(Vec::new()),
2033            )
2034            .expect("a healthy store registers a session");
2035        rusqlite::Connection::open(crate::database::database_path())
2036            .unwrap()
2037            .execute_batch("DROP TABLE sessions")
2038            .unwrap();
2039
2040        let error = controller
2041            .register_session_with_resources(
2042                "codex",
2043                "project",
2044                "podman",
2045                "unpersistable",
2046                launch_options(Vec::new()),
2047            )
2048            .expect_err("a store that rejects the write cannot register a session");
2049        assert!(
2050            format!("{error:#}").contains("sessions"),
2051            "unexpected error: {error:#}"
2052        );
2053        assert_eq!(
2054            controller.state.sessions.len(),
2055            1,
2056            "a session the database never accepted stayed in controller memory"
2057        );
2058        assert!(
2059            controller
2060                .state
2061                .sessions
2062                .values()
2063                .all(|session| session.title != "unpersistable"),
2064            "the rejected session is the one that stayed"
2065        );
2066    }
2067
2068    const MOUNT_HISTORY_FAILURE_CHILD: &str = "MJ_TEST_MOUNT_HISTORY_FAILURE_CHILD";
2069
2070    const CONTAINER_SIZE_HISTORY_CHILD: &str = "MJ_TEST_CONTAINER_SIZE_HISTORY_CHILD";
2071
2072    #[test]
2073    fn registration_remembers_launch_size_but_session_overrides_do_not_replace_it() {
2074        if std::env::var_os(CONTAINER_SIZE_HISTORY_CHILD).is_none() {
2075            let directory = tempfile::tempdir().unwrap();
2076            run_registration_child(
2077                CONTAINER_SIZE_HISTORY_CHILD,
2078                "registration_remembers_launch_size_but_session_overrides_do_not_replace_it",
2079                directory.path(),
2080            );
2081            return;
2082        }
2083        // Alone in this child process, so it installs the one writer.
2084        let _writer = crate::database::install_isolated_test_writer();
2085
2086        let mut controller = Controller {
2087            config: registration_config(),
2088            state: State::default(),
2089        };
2090        let mut options = launch_options(Vec::new());
2091        options.resource_allocation = Some(SessionResourceAllocation::Container {
2092            cpus: 12,
2093            memory_bytes: 48 * 1024 * 1024 * 1024,
2094        });
2095        let id = controller
2096            .register_session_with_resources("codex", "project", "podman", "sized", options)
2097            .unwrap();
2098        let expected = HostContainerSize {
2099            cpus: 12,
2100            memory_bytes: 48 * 1024 * 1024 * 1024,
2101        };
2102        assert_eq!(controller.state.container_sizes["local"], expected);
2103        assert_eq!(
2104            crate::database::load_state_migrating()
2105                .unwrap()
2106                .container_sizes["local"],
2107            expected
2108        );
2109
2110        controller
2111            .update_session_container_settings(
2112                &id,
2113                Some("2".into()),
2114                Some("4g".into()),
2115                Vec::new(),
2116                Vec::new(),
2117            )
2118            .unwrap();
2119        assert_eq!(
2120            crate::database::load_state_migrating()
2121                .unwrap()
2122                .container_sizes["local"],
2123            expected
2124        );
2125    }
2126
2127    #[test]
2128    fn a_failed_mount_history_write_does_not_fail_the_registered_session() {
2129        if std::env::var_os(MOUNT_HISTORY_FAILURE_CHILD).is_none() {
2130            let directory = tempfile::tempdir().unwrap();
2131            run_registration_child(
2132                MOUNT_HISTORY_FAILURE_CHILD,
2133                "a_failed_mount_history_write_does_not_fail_the_registered_session",
2134                directory.path(),
2135            );
2136            return;
2137        }
2138        // Alone in this child process, so it installs the one writer.
2139        let _writer = crate::database::install_isolated_test_writer();
2140
2141        let mut controller = Controller {
2142            config: registration_config(),
2143            state: State::default(),
2144        };
2145        // The first registration builds the schema this test then breaks.
2146        controller
2147            .register_session_with_resources(
2148                "codex",
2149                "project",
2150                "podman",
2151                "first",
2152                launch_options(Vec::new()),
2153            )
2154            .expect("a healthy store registers a session");
2155        let database = crate::database::database_path();
2156        rusqlite::Connection::open(&database)
2157            .unwrap()
2158            .execute_batch("DROP TABLE mount_history")
2159            .unwrap();
2160
2161        let id = controller
2162            .register_session_with_resources(
2163                "codex",
2164                "project",
2165                "podman",
2166                "attached",
2167                launch_options(vec![AdditionalMount {
2168                    source: PathBuf::from("/host/models"),
2169                    destination: PathBuf::from("/mnt/models"),
2170                    read_only: false,
2171                }]),
2172            )
2173            .expect("a suggestion list that cannot be written must not fail a registration");
2174
2175        let stored: i64 = rusqlite::Connection::open(&database)
2176            .unwrap()
2177            .query_row(
2178                "SELECT count(*) FROM sessions WHERE session_id = ?1",
2179                [&id],
2180                |row| row.get(0),
2181            )
2182            .unwrap();
2183        assert_eq!(stored, 1, "the registered session was not committed");
2184        assert!(
2185            controller.state.mount_history.is_empty(),
2186            "controller memory remembered mount sources the database never stored"
2187        );
2188    }
2189
2190    #[test]
2191    fn command_errors_report_the_root_cause_without_worker_wrappers() {
2192        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";
2193
2194        assert_eq!(
2195            command_error_detail(stderr),
2196            "checkpoint base b41dc78 is absent from configured source\nrepository may have moved"
2197        );
2198    }
2199
2200    #[test]
2201    fn controller_store_lock_excludes_a_second_process_owner() {
2202        let directory = tempfile::tempdir().unwrap();
2203        let first = ControllerStoreGuard::acquire_at(directory.path()).unwrap();
2204        run_controller_lock_probe(directory.path(), true);
2205        drop(first);
2206        run_controller_lock_probe(directory.path(), false);
2207    }
2208    fn run_controller_lock_probe(directory: &Path, expect_locked: bool) {
2209        let output = std::process::Command::new(std::env::current_exe().unwrap())
2210            .args([
2211                "--exact",
2212                "controller::tests::controller_store_lock_subprocess_probe",
2213                "--nocapture",
2214            ])
2215            .env("MJ_CONTROLLER_LOCK_PROBE", directory)
2216            .env(
2217                "MJ_CONTROLLER_LOCK_EXPECTED",
2218                if expect_locked { "locked" } else { "available" },
2219            )
2220            .output()
2221            .unwrap();
2222        assert!(
2223            output.status.success(),
2224            "controller lock subprocess failed:\nstdout:\n{}\nstderr:\n{}",
2225            String::from_utf8_lossy(&output.stdout),
2226            String::from_utf8_lossy(&output.stderr)
2227        );
2228    }
2229    #[test]
2230    fn controller_store_lock_subprocess_probe() {
2231        let Some(directory) = std::env::var_os("MJ_CONTROLLER_LOCK_PROBE") else {
2232            return;
2233        };
2234        let expected = std::env::var("MJ_CONTROLLER_LOCK_EXPECTED").unwrap();
2235        let acquired = ControllerStoreGuard::acquire_at(Path::new(&directory));
2236        match expected.as_str() {
2237            "locked" => {
2238                let error = acquired.expect_err("a second process acquired the controller store");
2239                assert!(error.to_string().contains("another Mjolnir controller"));
2240            }
2241            "available" => {
2242                acquired.expect("released controller store stayed locked");
2243            }
2244            value => panic!("unexpected lock probe expectation {value:?}"),
2245        }
2246    }
2247    #[test]
2248    fn local_mount_source_must_be_an_existing_directory() {
2249        let directory = tempfile::tempdir().unwrap();
2250        let file = directory.path().join("file");
2251        std::fs::write(&file, "not a directory").unwrap();
2252        let mut config = Config::default();
2253        config.targets.insert(
2254            "local".into(),
2255            TargetTemplate::LocalPodman {
2256                container: ConfigContainer {
2257                    image: "ubuntu:24.04".into(),
2258                    pull_policy: Default::default(),
2259                    platform: None,
2260                    cpus: None,
2261                    memory: None,
2262                    environment: BTreeMap::new(),
2263                    workspace_storage: Default::default(),
2264                },
2265            },
2266        );
2267        let controller = Controller {
2268            config,
2269            state: State::default(),
2270        };
2271
2272        assert!(
2273            controller
2274                .validate_mount_source("local", directory.path(), &ProcessExecutor)
2275                .is_ok()
2276        );
2277        for invalid in [file, directory.path().join("missing")] {
2278            let error = controller
2279                .validate_mount_source("local", &invalid, &ProcessExecutor)
2280                .unwrap_err();
2281            assert!(
2282                error
2283                    .to_string()
2284                    .contains("does not exist or is not a directory")
2285            );
2286        }
2287    }
2288}