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