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