Skip to main content

mj_controller/
controller.rs

1//! Controller-side lifecycle transitions and canonical-to-backend conversion.
2
3mod backend;
4mod cache_host;
5pub(crate) mod checkpoint;
6mod git_cache;
7mod lifecycle;
8mod mbx;
9pub mod move_session;
10mod network_git;
11mod path_completion;
12pub mod profile_config;
13mod provisioning;
14mod readiness;
15mod recovery_scan;
16mod resume;
17mod reviewer;
18mod subagents;
19#[cfg(test)]
20pub(crate) mod test_support;
21pub mod update;
22mod worker_binary;
23mod worker_restart;
24mod worktree;
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::fs::{self, File, OpenOptions};
28use std::path::{Path, PathBuf};
29
30use anyhow::{Context, Result, bail, ensure};
31use chrono::Utc;
32
33use mj_core::config::{
34    Config, ProjectBundle, ProjectRepository, TargetTemplate, atomic_write, container_size_host,
35    data_dir, is_bare_project_target, mount_history_host,
36};
37
38use crate::import::{
39    RepositoryIdentity, bundle_matches, configured_bundle_for_local, configured_bundle_for_origin,
40    setup_style_id,
41};
42use crate::setup::github_repository_from_origin;
43
44const CONFIG_RENAME_JOURNAL: &str = "config-rename.json";
45
46#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
47#[serde(rename_all = "snake_case")]
48enum ConfigRenameKind {
49    Profile,
50    Target,
51}
52
53#[derive(Debug, serde::Serialize, serde::Deserialize)]
54#[serde(deny_unknown_fields)]
55struct ConfigRenameJournal {
56    kind: ConfigRenameKind,
57    old_id: String,
58    new_id: String,
59}
60use mj_core::state::{
61    HostContainerSize, SessionRecord, SessionResourceAllocation, SessionState, State,
62    new_session_id, normalize_session_title,
63};
64
65use crate::targets::{
66    self, AdditionalMount, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
67};
68
69pub(crate) use backend::controller_github_token;
70pub use backend::image_refresh_plan;
71use backend::validate_resource_allocation;
72pub use mbx::preview_build_cache;
73use provisioning::apply_failed_new_session_rollback;
74pub(crate) use worker_binary::refresh_remote_worker_binary_if_stale;
75pub(crate) use worktree::path_exists_on_managed_target;
76
77pub use checkpoint::{
78    CheckpointArtifact, CheckpointDeferred, IdleWorkspaceLease, SessionExportLayout,
79    checkpoint_was_deferred, reconcile_managed_checkpoint_archives,
80};
81pub use lifecycle::{BranchDisposition, has_nothing_to_checkpoint};
82pub use recovery_scan::{RecoveryCandidate, RecoveryScan};
83pub use resume::{
84    ResumeRepositorySourceMismatch, ResumeRepositorySourcePreflight, ResumeRepositorySourceReceipt,
85    raw_conversion_preview_for,
86};
87pub use reviewer::reviewer_stager;
88pub use subagents::RegisterSubagentRequest;
89pub use worker_binary::{
90    WorkerBinaryAvailability, native_worker_binary_prerequisite, pin_worker_binary_sources,
91    worker_binary_prerequisite_for_arch,
92};
93pub use worker_restart::WorkerUpgradeOutcome;
94pub use worktree::{ResumePlan, local_project_repository, resume_compatibility};
95
96pub struct Controller {
97    pub config: Config,
98    pub state: State,
99}
100
101/// Machine-wide advisory lock for one controller data store. This prevents a
102/// dashboard, server, or CLI lifecycle command from concurrently acting as a
103/// second controller against the same SQLite state and relay sessions.
104#[derive(Debug)]
105pub struct ControllerStoreGuard {
106    file: File,
107}
108
109impl ControllerStoreGuard {
110    pub fn acquire() -> Result<Self> {
111        let directory = data_dir();
112        Self::acquire_at(&directory)
113    }
114
115    fn acquire_at(directory: &Path) -> Result<Self> {
116        Self::try_acquire_at(directory)?.with_context(|| {
117            format!(
118                "another Mjolnir controller is already using {}; stop it before starting this command",
119                directory.display()
120            )
121        })
122    }
123
124    /// Probe exclusivity without treating an owner that is still exiting as an error.
125    pub fn try_acquire() -> Result<Option<Self>> {
126        Self::try_acquire_at(&data_dir())
127    }
128
129    fn try_acquire_at(directory: &Path) -> Result<Option<Self>> {
130        std::fs::create_dir_all(directory)
131            .with_context(|| format!("create controller data directory {}", directory.display()))?;
132        let path = directory.join("controller.lock");
133        let mut options = OpenOptions::new();
134        options.create(true).read(true).write(true);
135        #[cfg(unix)]
136        {
137            use std::os::unix::fs::OpenOptionsExt;
138            options.mode(0o600);
139        }
140        let file = options
141            .open(&path)
142            .with_context(|| format!("open controller lock {}", path.display()))?;
143        match file.try_lock() {
144            Ok(()) => {}
145            Err(std::fs::TryLockError::WouldBlock) => return Ok(None),
146            Err(std::fs::TryLockError::Error(error)) => {
147                return Err(error)
148                    .with_context(|| format!("lock controller store {}", directory.display()));
149            }
150        }
151        Ok(Some(Self { file }))
152    }
153
154    /// Start the sole production SQLite writer after controller exclusivity
155    /// has been established by this guard.
156    pub fn start_database_writer(&self) -> Result<crate::database::DatabaseWriterOwner> {
157        crate::database::start_database_writer()
158    }
159}
160
161impl Drop for ControllerStoreGuard {
162    fn drop(&mut self) {
163        // Make release explicit. `File` also unlocks on close, but an explicit
164        // unlock keeps same-process handoff deterministic across platforms.
165        let _ = self.file.unlock();
166    }
167}
168
169/// The durable result of creating a quick bundle. The returned config is the
170/// same fresh config that was written, allowing a serving projection to publish
171/// the new bundle before acknowledging the request that created it.
172#[derive(Debug)]
173pub struct QuickBundleCreation {
174    pub config: Config,
175    pub bundle_id: String,
176}
177
178/// Failure stages exposed to a viewer request without exposing the underlying
179/// filesystem/configuration error. The detailed error remains available to
180/// the caller for logs and terminal notices.
181#[derive(Debug)]
182pub enum QuickBundleFailure {
183    InvalidSource(anyhow::Error),
184    Persistence(anyhow::Error),
185}
186
187impl std::fmt::Display for QuickBundleFailure {
188    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        match self {
190            Self::InvalidSource(error) => write!(formatter, "invalid repository source: {error}"),
191            Self::Persistence(error) => write!(formatter, "persist quick bundle: {error}"),
192        }
193    }
194}
195
196impl std::error::Error for QuickBundleFailure {
197    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
198        match self {
199            Self::InvalidSource(error) | Self::Persistence(error) => Some(error.root_cause()),
200        }
201    }
202}
203
204/// Create a quick bundle from a local repository or GitHub source and persist
205/// it as one serialized fresh-config transaction. Identical sources reuse the
206/// existing configured bundle, matching the terminal's behavior. The returned
207/// config is the same fresh config that was written, allowing a serving
208/// projection to publish the new bundle before acknowledging the request.
209pub fn create_quick_bundle(
210    source: &str,
211) -> std::result::Result<QuickBundleCreation, QuickBundleFailure> {
212    let (config, bundle_id) = Config::update(|config| {
213        create_quick_bundle_in_config(config, source)
214            .map_err(|error| anyhow::Error::new(QuickBundleFailure::InvalidSource(error)))
215    })
216    .map_err(|error| {
217        error
218            .downcast::<QuickBundleFailure>()
219            .unwrap_or_else(QuickBundleFailure::Persistence)
220    })?;
221    Ok(QuickBundleCreation { config, bundle_id })
222}
223
224/// Add a quick bundle to an already-loaded config. The helper still performs
225/// the local repository canonicalization/GitHub-source parsing, but callers
226/// that persist a config should use [`create_quick_bundle`] so concurrent saves
227/// cannot clobber one another.
228pub fn create_quick_bundle_in_config(config: &mut Config, source: &str) -> Result<String> {
229    let source = interpret_repository_source(source)?;
230    let existing = match &source.kind {
231        RepositorySourceKind::Local(root) => configured_bundle_for_local(config, root),
232        RepositorySourceKind::Github(repository) => {
233            configured_bundle_for_origin(config, repository)
234        }
235    };
236    if let Some(existing) = existing {
237        return Ok(existing);
238    }
239    let repository_id = setup_style_id(&source.name);
240    let mut bundle_id = repository_id.clone();
241    for suffix in 2_u32.. {
242        if !config.bundles.contains_key(&bundle_id) {
243            break;
244        }
245        bundle_id = format!("{repository_id}-{suffix}");
246    }
247    config.bundles.insert(
248        bundle_id.clone(),
249        ProjectBundle {
250            primary_repo: repository_id.clone(),
251            repositories: vec![source.into_project_repository(repository_id.clone())],
252        },
253    );
254    config.validate()?;
255    Ok(bundle_id)
256}
257
258/// Create one bundle from one or more local repositories or GitHub sources.
259///
260/// All sources are interpreted and checked before the config transaction can
261/// write anything. An existing bundle is reused only when its repository set
262/// and primary repository exactly match the request; this keeps selecting one
263/// repository from a larger bundle from silently changing the wizard's choice.
264pub fn create_bundle_from_sources(
265    sources: &[String],
266) -> std::result::Result<QuickBundleCreation, QuickBundleFailure> {
267    let (config, bundle_id) = Config::update(|config| {
268        create_bundle_from_sources_in_config(config, sources)
269            .map_err(|error| anyhow::Error::new(QuickBundleFailure::InvalidSource(error)))
270    })
271    .map_err(|error| {
272        error
273            .downcast::<QuickBundleFailure>()
274            .unwrap_or_else(QuickBundleFailure::Persistence)
275    })?;
276    Ok(QuickBundleCreation { config, bundle_id })
277}
278
279/// Add a bundle for all `sources` to an already-loaded config. The source
280/// interpretation is shared with the persisted [`create_bundle_from_sources`]
281/// entry point and the legacy quick-bundle helper.
282pub fn create_bundle_from_sources_in_config(
283    config: &mut Config,
284    sources: &[String],
285) -> Result<String> {
286    let sources = sources
287        .iter()
288        .map(|source| interpret_repository_source(source))
289        .collect::<Result<Vec<_>>>()?;
290    if sources.is_empty() {
291        bail!("at least one repository source is required");
292    }
293
294    let mut identities = BTreeSet::new();
295    for source in &sources {
296        if !identities.insert(source.identity()) {
297            bail!("duplicate repository source {:?}", source.display_name);
298        }
299    }
300
301    if let Some(existing) = exact_configured_bundle(config, &sources) {
302        return Ok(existing);
303    }
304
305    // Build and validate a candidate before replacing the caller's config, so
306    // a later validation error cannot leave an in-memory partial mutation.
307    let mut updated = config.clone();
308    let mut used_repository_ids = BTreeSet::new();
309    let mut repositories = Vec::with_capacity(sources.len());
310    for source in sources {
311        let base = setup_style_id(&source.name);
312        let repository_id = unique_id(&base, |candidate| used_repository_ids.contains(candidate));
313        used_repository_ids.insert(repository_id.clone());
314        repositories.push(source.into_project_repository(repository_id));
315    }
316    let primary_repo = repositories
317        .first()
318        .map(|repository| repository.id.clone())
319        .context("at least one repository source is required")?;
320    let bundle_id = unique_id(&primary_repo, |candidate| {
321        updated.bundles.contains_key(candidate)
322    });
323    updated.bundles.insert(
324        bundle_id.clone(),
325        ProjectBundle {
326            primary_repo,
327            repositories,
328        },
329    );
330    updated.validate()?;
331    *config = updated;
332    Ok(bundle_id)
333}
334
335#[derive(Debug, Clone)]
336enum RepositorySourceKind {
337    Github(crate::setup::GithubRepository),
338    Local(PathBuf),
339}
340
341#[derive(Debug, Clone)]
342struct InterpretedRepositorySource {
343    display_name: String,
344    name: String,
345    kind: RepositorySourceKind,
346}
347
348impl InterpretedRepositorySource {
349    fn identity(&self) -> RepositoryIdentity {
350        match &self.kind {
351            RepositorySourceKind::Github(repository) => RepositoryIdentity::Github(
352                repository.owner.to_ascii_lowercase(),
353                repository.repository.to_ascii_lowercase(),
354            ),
355            RepositorySourceKind::Local(root) => RepositoryIdentity::Local(root.clone()),
356        }
357    }
358
359    fn into_project_repository(self, id: String) -> ProjectRepository {
360        let (github, local) = match self.kind {
361            RepositorySourceKind::Github(repository) => (
362                Some(format!("{}/{}", repository.owner, repository.repository)),
363                None,
364            ),
365            RepositorySourceKind::Local(root) => (None, Some(root)),
366        };
367        ProjectRepository {
368            id: id.clone(),
369            github,
370            local,
371            destination: PathBuf::from(id),
372            git_ref: None,
373        }
374    }
375}
376
377/// Interpret a source once, including local Git canonicalization and GitHub
378/// parsing, so all creation paths use exactly the same source semantics.
379fn interpret_repository_source(source: &str) -> Result<InterpretedRepositorySource> {
380    let source = source.trim();
381    if source.is_empty() {
382        bail!("repository source cannot be empty");
383    }
384    let expanded = mj_core::path_input::expand_local(Path::new(source))?;
385    let candidate = expanded.as_path();
386    if candidate.exists() {
387        let root = mj_core::local_git::canonical_repository(candidate)?;
388        let name = root
389            .file_name()
390            .and_then(|name| name.to_str())
391            .context("local repository has no usable directory name")?
392            .to_owned();
393        return Ok(InterpretedRepositorySource {
394            display_name: source.to_owned(),
395            name,
396            kind: RepositorySourceKind::Local(root),
397        });
398    }
399    if candidate.is_absolute() || source.starts_with('.') || source.starts_with('~') {
400        bail!("local repository path {source:?} does not exist");
401    }
402    let repository = github_repository_from_origin(source).context(format!(
403        "{source:?} is not a GitHub owner/repository or URL"
404    ))?;
405    Ok(InterpretedRepositorySource {
406        display_name: source.to_owned(),
407        name: repository.repository.clone(),
408        kind: RepositorySourceKind::Github(repository),
409    })
410}
411
412fn exact_configured_bundle(
413    config: &Config,
414    requested: &[InterpretedRepositorySource],
415) -> Option<String> {
416    let requested_identities = requested
417        .iter()
418        .map(InterpretedRepositorySource::identity)
419        .collect::<BTreeSet<_>>();
420    let primary = requested.first()?.identity();
421    config.bundles.iter().find_map(|(id, bundle)| {
422        if bundle.repositories.len() != requested.len()
423            || bundle
424                .repositories
425                .iter()
426                .any(|repository| repository.git_ref.is_some())
427        {
428            return None;
429        }
430        bundle_matches(bundle, &requested_identities, &primary).then(|| id.clone())
431    })
432}
433
434fn unique_id(base: &str, mut is_used: impl FnMut(&str) -> bool) -> String {
435    if !is_used(base) {
436        return base.to_owned();
437    }
438    for suffix in 2_u32.. {
439        let suffix = format!("-{suffix}");
440        let prefix_len = 64usize.saturating_sub(suffix.len());
441        let prefix = base.chars().take(prefix_len).collect::<String>();
442        let candidate = format!("{prefix}{suffix}");
443        if !is_used(&candidate) {
444            return candidate;
445        }
446    }
447    unreachable!("u32 repository/bundle id suffixes exhausted")
448}
449
450pub struct SessionLaunchOptions {
451    pub create_managed_worktree: Option<bool>,
452    pub mjolnir_subagents: Option<bool>,
453    pub initial_prompt: Option<String>,
454    pub workspace_id: String,
455    pub additional_mounts: Vec<AdditionalMount>,
456    pub resource_allocation: Option<SessionResourceAllocation>,
457    pub project_directory: Option<PathBuf>,
458    pub session_title_override: Option<String>,
459}
460
461pub struct SessionResumeOptions {
462    pub additional_mounts: Option<Vec<AdditionalMount>>,
463    pub resource_allocation: Option<SessionResourceAllocation>,
464    pub discard_queue: bool,
465}
466
467fn selected_host_container_size(
468    template: &TargetTemplate,
469    allocation: Option<&SessionResourceAllocation>,
470) -> Option<(String, HostContainerSize)> {
471    let host = container_size_host(template)?;
472    let SessionResourceAllocation::Container { cpus, memory_bytes } = allocation? else {
473        return None;
474    };
475    Some((
476        host.to_owned(),
477        HostContainerSize {
478            cpus: *cpus,
479            memory_bytes: *memory_bytes,
480        },
481    ))
482}
483
484impl Controller {
485    pub fn load() -> Result<Self> {
486        let config = Config::load()?;
487        let state = crate::database::load_state()?;
488        // Missing session dependencies must not lock users out of the tools
489        // needed to repair them. Operations validate the session they act on.
490        state.validate()?;
491        for session in state.sessions.values() {
492            if let Some(issue) = session.configuration_issue(&config) {
493                tracing::warn!(session_id = %session.id, "{issue}");
494            }
495        }
496        Ok(Self { config, state })
497    }
498
499    pub fn reload(&mut self) -> Result<()> {
500        *self = Self::load()?;
501        Ok(())
502    }
503
504    fn persist_session_state(&self, session_id: &str) -> Result<()> {
505        match self.state.sessions.get(session_id) {
506            Some(session) => crate::database::save_lifecycle_session(session),
507            None => crate::database::delete_session(session_id),
508        }
509    }
510
511    fn persist_session_transition_or_restore(
512        &mut self,
513        session_id: &str,
514        previous: &SessionRecord,
515        context: &'static str,
516    ) -> Result<()> {
517        persist_session_record_transition_or_restore(
518            &mut self.state,
519            session_id,
520            previous,
521            context,
522            &crate::database::save_lifecycle_session,
523        )
524    }
525
526    fn restore_prior_session_after_persistence_failure(
527        &mut self,
528        session_id: &str,
529        previous: &SessionRecord,
530        primary: anyhow::Error,
531    ) -> anyhow::Error {
532        restore_session_after_persistence_failure(
533            &mut self.state,
534            session_id,
535            previous,
536            primary,
537            crate::database::save_lifecycle_session,
538        )
539    }
540
541    /// Resolve an entered path on its owning host. Call only from background work.
542    pub fn resolve_input_path(
543        &self,
544        target_id: &str,
545        path: &Path,
546        executor: &impl CommandExecutor,
547    ) -> Result<PathBuf> {
548        let target = self
549            .config
550            .targets
551            .get(target_id)
552            .context("Unknown path target")?;
553        resolve_target_input_path(target, path, executor)
554    }
555
556    /// Verify a mount source on the host where Mjolnir will consume it, and report
557    /// the filesystem reason it must be attached read-only, if there is one.
558    ///
559    /// The probe runs in the same round trip as the existence check so the
560    /// editor learns both answers without a second wait. A probe that cannot
561    /// answer reports no reason: provisioning decides that authoritatively.
562    pub fn validate_mount_source(
563        &self,
564        target_id: &str,
565        source: &Path,
566        executor: &impl CommandExecutor,
567    ) -> Result<Option<String>> {
568        let target = self
569            .config
570            .targets
571            .get(target_id)
572            .with_context(|| format!("unknown target template {target_id:?}"))?;
573        let exists = match target {
574            TargetTemplate::LocalPodman { .. }
575            | TargetTemplate::LocalDocker { .. }
576            | TargetTemplate::AppleContainer { .. }
577            | TargetTemplate::AwsEc2 { .. } => std::fs::metadata(source)
578                .map(|metadata| metadata.is_dir())
579                .or_else(|error| {
580                    if error.kind() == std::io::ErrorKind::NotFound {
581                        Ok(false)
582                    } else {
583                        Err(error)
584                    }
585                })
586                .with_context(|| format!("inspect resource source {}", source.display()))?,
587            TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
588                targets::ssh_directory_exists(&SshTarget::from(ssh), source, executor)?
589            }
590            TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
591                bail!("resource attachments are unsupported for bare targets")
592            }
593        };
594        ensure!(
595            exists,
596            "source path {} does not exist or is not a directory",
597            source.display()
598        );
599        Ok(self.forced_read_only_reason(target, source, executor))
600    }
601
602    /// The `filesystem (reason)` label for a source the runtime cannot overlay.
603    fn forced_read_only_reason(
604        &self,
605        target: &TargetTemplate,
606        source: &Path,
607        executor: &impl CommandExecutor,
608    ) -> Option<String> {
609        let ssh = match target {
610            TargetTemplate::LocalPodman { .. } | TargetTemplate::LocalDocker { .. } => None,
611            TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
612                Some(SshTarget::from(ssh))
613            }
614            // Apple Container already mounts read-only, and EC2 copies instead
615            // of mounting, so neither has an overlay to lose.
616            _ => return None,
617        };
618        let filesystem = targets::probe_filesystem_types(
619            ssh.as_ref(),
620            std::slice::from_ref(&source.to_path_buf()),
621            executor,
622        )
623        .map_err(|error| {
624            tracing::debug!(
625                source = %source.display(),
626                error = format!("{error:#}"),
627                "could not probe the filesystem under a mount source"
628            );
629        })
630        .ok()?
631        .pop()?;
632        let reason = targets::overlay_unsupported_filesystem(&filesystem)?;
633        Some(format!("{filesystem} ({reason})"))
634    }
635
636    fn fail_new_session_with_cleanup(
637        &mut self,
638        session_id: &str,
639        error: anyhow::Error,
640        executor: &impl CommandExecutor,
641    ) -> Result<anyhow::Error> {
642        let original = provisioning::note_new_session_launch_failure(session_id, &error);
643        let cleanup_error = self
644            .cleanup_new_session_worktree_after_failure(session_id, executor)
645            .err()
646            .map(|cleanup_error| format!("{cleanup_error:#}"));
647        if let Some(cleanup_error) = &cleanup_error {
648            tracing::warn!(
649                session_id,
650                error = %cleanup_error,
651                "new-session worktree rollback reported a cleanup failure"
652            );
653        }
654        let failure = apply_failed_new_session_rollback(
655            &mut self.state,
656            session_id,
657            &original,
658            cleanup_error,
659        );
660        self.persist_session_state(session_id)?;
661        Ok(failure)
662    }
663
664    pub fn register_session_with_resources(
665        &mut self,
666        profile_id: &str,
667        bundle_id: &str,
668        target_id: &str,
669        title: impl Into<String>,
670        options: SessionLaunchOptions,
671    ) -> Result<String> {
672        let SessionLaunchOptions {
673            create_managed_worktree,
674            mjolnir_subagents,
675            initial_prompt,
676            workspace_id,
677            additional_mounts,
678            resource_allocation,
679            project_directory,
680            session_title_override,
681        } = options;
682        let session_title_override = match session_title_override {
683            Some(title) => {
684                Some(normalize_session_title(&title).context("session name cannot be empty")?)
685            }
686            None => None,
687        };
688        let profile = self
689            .config
690            .profiles
691            .get(profile_id)
692            .with_context(|| format!("unknown profile {profile_id:?}"))?;
693        ensure!(profile.enabled, "profile {profile_id:?} is disabled");
694        let template = self
695            .config
696            .targets
697            .get(target_id)
698            .with_context(|| format!("unknown target template {target_id:?}"))?;
699        if create_managed_worktree == Some(true) && !is_bare_project_target(template) {
700            bail!("managed worktree creation requires a bare Git project");
701        }
702        if project_directory.is_some() != is_bare_project_target(template) {
703            bail!("raw project directories require a bare target, and bare targets require one");
704        }
705        if let Some(path) = &project_directory
706            && (!path.is_absolute()
707                || path
708                    .components()
709                    .any(|part| part == std::path::Component::ParentDir))
710        {
711            bail!("bare project directory must be an absolute safe path");
712        }
713        let bundle = project_directory
714            .is_none()
715            .then(|| self.config.bundles.get(bundle_id))
716            .flatten();
717        if project_directory.is_none() && bundle.is_none() {
718            bail!("unknown bundle {bundle_id:?}");
719        }
720        if profile.kind == mj_core::config::HarnessKind::Muse
721            && (!additional_mounts.is_empty()
722                || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
723        {
724            bail!(
725                "{} ACP supports one workspace root; use a single-repository bundle without attached directories",
726                profile.kind.display_name()
727            );
728        }
729        if let Some(bundle) = bundle {
730            for repository in &bundle.repositories {
731                mj_core::remote_git::resolve_repository(
732                    repository,
733                    &targets::CancellableProcessExecutor::with_timeout(
734                        std::time::Duration::from_secs(15),
735                    ),
736                )
737                .with_context(|| format!("repository {:?}", repository.id))?;
738            }
739        }
740        validate_resource_allocation(template, resource_allocation.as_ref())?;
741        let selected_container_size =
742            selected_host_container_size(template, resource_allocation.as_ref());
743        if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
744            bail!("attached resources are unsupported for this target");
745        }
746        targets::validate_additional_mounts(&additional_mounts)?;
747        let id = new_session_id()?;
748        let now = now();
749        let record = SessionRecord {
750            build_cache: None,
751            create_managed_worktree,
752            mjolnir_subagents,
753            archived: false,
754            container_cpus: None,
755            container_memory: None,
756            // Recorded for every new session, container-backed or not, so a
757            // later move into a container already knows the path its checkout
758            // will occupy. Only sessions that predate per-session container
759            // workspaces leave it unset.
760            container_workspace: Some(targets::new_container_workspace(&id)?),
761            id: id.clone(),
762            workspace_id,
763            title: title.into(),
764            harness_kind: profile.kind,
765            last_profile: profile_id.to_string(),
766            bundle_id: bundle_id.to_string(),
767            project_directory,
768            managed_worktree: None,
769            target_template_id: target_id.to_string(),
770            resource_allocation,
771            additional_mounts: additional_mounts.clone(),
772            state: SessionState::Provisioning,
773            target: None,
774            native_session_id: None,
775            acp_session_title: None,
776            session_title_override,
777            created_at: now.clone(),
778            updated_at: now,
779            viewed_through_event_ordinal: 0,
780            draft_input: initial_prompt.unwrap_or_default(),
781            last_error: None,
782            last_checkpoint_error: None,
783            checkpoint: None,
784        };
785        // Creation authors the whole record, so it writes the whole row. The
786        // record reaches memory only once it is durable: a session this process
787        // alone knows about is one the database can never resume or clean up.
788        if let Some((host, size)) = selected_container_size.as_ref() {
789            crate::database::save_session_with_container_size(&record, host, *size)?;
790        } else {
791            crate::database::save_session(&record)?;
792        }
793        self.state.sessions.insert(id.clone(), record);
794        if let Some((host, size)) = selected_container_size {
795            self.state.remember_container_size(&host, size);
796        }
797        if let Some(host) = mount_history_host(template) {
798            // Mount history only seeds the attach dialog's suggestions. The
799            // session row is already committed, so a failed suggestion write is
800            // reported rather than turned into a failed registration.
801            match crate::database::remember_mount_sources(host, &additional_mounts) {
802                Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
803                Err(error) => tracing::warn!(
804                    session_id = id,
805                    error = format!("{error:#}"),
806                    "could not remember the attached resource directories for later suggestions"
807                ),
808            }
809        }
810        Ok(id)
811    }
812
813    pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
814        let title = normalize_session_title(title).context("session name cannot be empty")?;
815        ensure!(
816            self.state.sessions.contains_key(session_id),
817            "unknown session {session_id}"
818        );
819        let updated_at = now();
820        crate::database::set_session_title_override(session_id, &title, &updated_at)?;
821        let record = self
822            .state
823            .sessions
824            .get_mut(session_id)
825            .expect("session was checked before updating its title");
826        record.session_title_override = Some(title.clone());
827        record.updated_at = updated_at;
828        Ok(title)
829    }
830
831    pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
832        mj_core::config::validate_id("profile", new_id)?;
833        if old_id == new_id {
834            ensure!(
835                self.config.profiles.contains_key(old_id),
836                "unknown profile {old_id:?}"
837            );
838            return Ok(());
839        }
840        let journal = ConfigRenameJournal {
841            kind: ConfigRenameKind::Profile,
842            old_id: old_id.to_owned(),
843            new_id: new_id.to_owned(),
844        };
845        write_config_rename_journal(&journal)?;
846        let (config, ()) = match Config::update(|config| {
847            ensure!(
848                config.profiles.contains_key(old_id),
849                "unknown profile {old_id:?}"
850            );
851            ensure!(
852                !config.profiles.contains_key(new_id),
853                "profile {new_id:?} already exists"
854            );
855            let profile = config
856                .profiles
857                .remove(old_id)
858                .expect("profile was checked in the transaction");
859            config.profiles.insert(new_id.to_owned(), profile);
860            Ok(())
861        }) {
862            Ok(result) => result,
863            Err(error) => {
864                remove_config_rename_journal()
865                    .context("remove profile rename journal after config save failed")?;
866                return Err(error).context("save renamed profile configuration");
867            }
868        };
869        self.config = config;
870        mj_core::test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
871        if let Err(error) = crate::database::rename_profile_references(old_id, new_id) {
872            let restore = Config::update(|config| {
873                let profile = config
874                    .profiles
875                    .remove(new_id)
876                    .with_context(|| format!("renamed profile {new_id:?} is missing"))?;
877                ensure!(
878                    !config.profiles.contains_key(old_id),
879                    "cannot restore profile rename: both {old_id:?} and {new_id:?} exist"
880                );
881                config.profiles.insert(old_id.to_owned(), profile);
882                Ok(())
883            });
884            let restored = match restore {
885                Ok((config, ())) => config,
886                Err(restore_error) => {
887                    return Err(error).context(format!(
888                        "rename profile references; additionally failed to restore config: {restore_error:#}"
889                    ));
890                }
891            };
892            self.config = restored;
893            if let Err(restore_error) = remove_config_rename_journal() {
894                return Err(error).context(format!(
895                    "rename profile references; additionally failed to remove rename journal: {restore_error:#}"
896                ));
897            }
898            return Err(error).context("rename profile references");
899        }
900        for session in self.state.sessions.values_mut() {
901            if session.last_profile == old_id {
902                session.last_profile = new_id.to_owned();
903            }
904        }
905        remove_config_rename_journal()?;
906        Ok(())
907    }
908
909    pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
910        mj_core::config::validate_id("target template", new_id)?;
911        if old_id == new_id {
912            ensure!(
913                self.config.targets.contains_key(old_id),
914                "unknown target {old_id:?}"
915            );
916            return Ok(());
917        }
918        let journal = ConfigRenameJournal {
919            kind: ConfigRenameKind::Target,
920            old_id: old_id.to_owned(),
921            new_id: new_id.to_owned(),
922        };
923        write_config_rename_journal(&journal)?;
924        let (config, ()) = match Config::update(|config| {
925            ensure!(
926                config.targets.contains_key(old_id),
927                "unknown target {old_id:?}"
928            );
929            ensure!(
930                !config.targets.contains_key(new_id),
931                "target {new_id:?} already exists"
932            );
933            let target = config
934                .targets
935                .remove(old_id)
936                .expect("target was checked in the transaction");
937            config.targets.insert(new_id.to_owned(), target);
938            Ok(())
939        }) {
940            Ok(result) => result,
941            Err(error) => {
942                remove_config_rename_journal()
943                    .context("remove target rename journal after config save failed")?;
944                return Err(error).context("save renamed target configuration");
945            }
946        };
947        self.config = config;
948        mj_core::test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
949        if let Err(error) = crate::database::rename_target_references(old_id, new_id) {
950            let restore = Config::update(|config| {
951                let target = config
952                    .targets
953                    .remove(new_id)
954                    .with_context(|| format!("renamed target {new_id:?} is missing"))?;
955                ensure!(
956                    !config.targets.contains_key(old_id),
957                    "cannot restore target rename: both {old_id:?} and {new_id:?} exist"
958                );
959                config.targets.insert(old_id.to_owned(), target);
960                Ok(())
961            });
962            let restored = match restore {
963                Ok((config, ())) => config,
964                Err(restore_error) => {
965                    return Err(error).context(format!(
966                        "rename target references; additionally failed to restore config: {restore_error:#}"
967                    ));
968                }
969            };
970            self.config = restored;
971            if let Err(restore_error) = remove_config_rename_journal() {
972                return Err(error).context(format!(
973                    "rename target references; additionally failed to remove rename journal: {restore_error:#}"
974                ));
975            }
976            return Err(error).context("rename target references");
977        }
978        for session in self.state.sessions.values_mut() {
979            if session.target_template_id == old_id {
980                session.target_template_id = new_id.to_owned();
981            }
982        }
983        remove_config_rename_journal()?;
984        Ok(())
985    }
986
987    /// Finish a profile/target id rename interrupted between the atomic config
988    /// replacement and SQLite transaction. Each step is idempotent, so a
989    /// second crash leaves the same intent available for the next startup.
990    pub fn recover_config_id_rename() -> Result<bool> {
991        let path = config_rename_journal_path();
992        let body = match fs::read(&path) {
993            Ok(body) => body,
994            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
995            Err(error) => return Err(error).context(format!("read {}", path.display())),
996        };
997        let journal: ConfigRenameJournal =
998            serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
999        match journal.kind {
1000            ConfigRenameKind::Profile => {
1001                Config::update(|config| {
1002                    finish_config_map_rename(
1003                        &mut config.profiles,
1004                        &journal.old_id,
1005                        &journal.new_id,
1006                        "profile",
1007                    )?;
1008                    Ok(())
1009                })?;
1010                crate::database::rename_profile_references(&journal.old_id, &journal.new_id)?;
1011            }
1012            ConfigRenameKind::Target => {
1013                Config::update(|config| {
1014                    finish_config_map_rename(
1015                        &mut config.targets,
1016                        &journal.old_id,
1017                        &journal.new_id,
1018                        "target",
1019                    )?;
1020                    Ok(())
1021                })?;
1022                crate::database::rename_target_references(&journal.old_id, &journal.new_id)?;
1023            }
1024        }
1025        remove_config_rename_journal()?;
1026        Ok(true)
1027    }
1028
1029    /// Record the per-session container size overrides and attached
1030    /// directories. Nothing is applied to a running container: the values are
1031    /// read the next time the session's container is created.
1032    pub fn update_session_container_settings(
1033        &mut self,
1034        session_id: &str,
1035        cpus: Option<String>,
1036        memory: Option<String>,
1037        additional_mounts: Vec<targets::AdditionalMount>,
1038        mount_history: Vec<std::path::PathBuf>,
1039    ) -> Result<()> {
1040        ensure!(
1041            self.state.sessions.contains_key(session_id),
1042            "unknown session {session_id}"
1043        );
1044        let cpus = cpus.filter(|value| !value.trim().is_empty());
1045        let memory = memory.filter(|value| !value.trim().is_empty());
1046        let updated_at = now();
1047        crate::database::set_session_container_settings(
1048            session_id,
1049            cpus.as_deref(),
1050            memory.as_deref(),
1051            &additional_mounts,
1052            &updated_at,
1053        )?;
1054        if let Some(host) = self
1055            .config
1056            .targets
1057            .get(
1058                &self.state.sessions[session_id]
1059                    .target_template_id
1060                    .to_owned(),
1061            )
1062            .and_then(mj_core::config::mount_history_host)
1063        {
1064            let host = host.to_owned();
1065            // The dialog owns the suggestion list, so forgetting a directory
1066            // there has to survive the mounts being remembered right after.
1067            crate::database::replace_mount_history(&host, &mount_history)?;
1068            crate::database::remember_mount_sources(&host, &additional_mounts)?;
1069            self.state.mount_history.insert(host.clone(), mount_history);
1070            self.state.remember_mount_sources(&host, &additional_mounts);
1071        }
1072        let record = self
1073            .state
1074            .sessions
1075            .get_mut(session_id)
1076            .expect("session was checked before updating its container settings");
1077        record.container_cpus = cpus;
1078        record.container_memory = memory;
1079        record.additional_mounts = additional_mounts;
1080        record.updated_at = updated_at;
1081        Ok(())
1082    }
1083}
1084
1085fn config_rename_journal_path() -> PathBuf {
1086    data_dir().join(CONFIG_RENAME_JOURNAL)
1087}
1088
1089fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
1090    let path = config_rename_journal_path();
1091    let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
1092    atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
1093}
1094
1095fn remove_config_rename_journal() -> Result<()> {
1096    let path = config_rename_journal_path();
1097    match fs::remove_file(&path) {
1098        Ok(()) => Ok(()),
1099        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1100        Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1101    }
1102}
1103
1104fn finish_config_map_rename<T>(
1105    entries: &mut BTreeMap<String, T>,
1106    old_id: &str,
1107    new_id: &str,
1108    kind: &str,
1109) -> Result<()> {
1110    if let Some(entry) = entries.remove(old_id) {
1111        ensure!(
1112            !entries.contains_key(new_id),
1113            "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
1114        );
1115        entries.insert(new_id.to_owned(), entry);
1116    } else {
1117        ensure!(
1118            entries.contains_key(new_id),
1119            "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
1120        );
1121    }
1122    Ok(())
1123}
1124
1125/// Whether this profile must run from a private staged copy of its home even on
1126/// a local bare target, where a session would otherwise use the profile home
1127/// directly.
1128///
1129/// A Codex profile with a custom model provider qualifies: Mjolnir generates the
1130/// provider's model catalog for each launch and points the staged `config.toml`
1131/// at it, and it must never write either into the user's own profile home.
1132pub(crate) fn requires_private_profile_home(profile: &mj_core::config::HarnessProfile) -> bool {
1133    profile.codex_provider().ok().flatten().is_some()
1134}
1135
1136/// Whether this session's harness home belongs to the session rather than to
1137/// the profile.
1138///
1139/// Only such a session has anywhere to stage files into. Staging when this is
1140/// false would copy the daemon's staged profile straight over the user's own
1141/// harness configuration, and nothing would ever remove it again: teardown
1142/// removes what [`removable_profile_root`] names, which is nothing here.
1143pub(crate) fn session_owns_profile_home(
1144    locator: &targets::TargetLocator,
1145    session_id: &str,
1146    profile: &mj_core::config::HarnessProfile,
1147) -> bool {
1148    removable_profile_root(locator, session_id, profile).is_some()
1149}
1150
1151/// Whether a Claude session on a local bare target runs from a private staged
1152/// home. It can only do so where `CLAUDE_CONFIG_DIR` is what points Claude at
1153/// that home; on macOS the variable scopes nothing, so a private copy would be
1154/// a home Claude never reads. See
1155/// [`HarnessKind::scopes_home_with_environment`](mj_core::config::HarnessKind::scopes_home_with_environment).
1156fn claude_takes_a_private_home(
1157    profile: &mj_core::config::HarnessProfile,
1158    locator: &targets::TargetLocator,
1159) -> bool {
1160    profile.kind == mj_core::config::HarnessKind::Claude
1161        && profile
1162            .kind
1163            .scopes_home_with_environment(locator.harness_host())
1164}
1165
1166/// Where this session's harness reads and writes its profile inside the target.
1167///
1168/// Every case but one is the per-session root `removable_profile_root` names; a
1169/// profile that runs straight out of the user's own home has no per-session root
1170/// and uses that home. Muse keeps its state in a `muse` subdirectory of the
1171/// root, because its ACP adapter owns the directory it is given.
1172#[cfg(test)]
1173pub(crate) fn target_profile_home_for_test(
1174    locator: &targets::TargetLocator,
1175    session_id: &str,
1176    profile: &mj_core::config::HarnessProfile,
1177) -> String {
1178    target_profile_home(locator, session_id, profile)
1179}
1180
1181fn target_profile_home(
1182    locator: &targets::TargetLocator,
1183    session_id: &str,
1184    profile: &mj_core::config::HarnessProfile,
1185) -> String {
1186    let root = removable_profile_root(locator, session_id, profile)
1187        .unwrap_or_else(|| profile.home.to_string_lossy().into_owned());
1188    if profile.kind == mj_core::config::HarnessKind::Muse {
1189        PathBuf::from(root)
1190            .join("muse")
1191            .to_string_lossy()
1192            .into_owned()
1193    } else {
1194        root
1195    }
1196}
1197
1198/// The per-session profile directory an in-place harness replacement may delete,
1199/// or `None` when the session runs straight out of the user's own profile home.
1200///
1201/// This is the root that [`target_profile_home`] derives its answer from, not
1202/// that answer itself: a Muse session's home is a `muse` subdirectory of a
1203/// per-session root, and the whole root is what belongs to the session.
1204pub(super) fn removable_profile_root(
1205    locator: &targets::TargetLocator,
1206    session_id: &str,
1207    profile: &mj_core::config::HarnessProfile,
1208) -> Option<String> {
1209    match locator {
1210        targets::TargetLocator::LocalBare { worker_root } => {
1211            if profile.kind == mj_core::config::HarnessKind::Muse {
1212                Some(
1213                    mj_core::config::data_dir()
1214                        .join("profiles")
1215                        .join(session_id)
1216                        .to_string_lossy()
1217                        .into_owned(),
1218                )
1219            } else if claude_takes_a_private_home(profile, locator)
1220                || requires_private_profile_home(profile)
1221            {
1222                Some(
1223                    Path::new(worker_root)
1224                        .join("profile")
1225                        .to_string_lossy()
1226                        .into_owned(),
1227                )
1228            } else {
1229                // The session reads and writes the user's own profile home.
1230                // Nothing here belongs to the session, so nothing is removed.
1231                None
1232            }
1233        }
1234        targets::TargetLocator::LocalPodman { .. }
1235        | targets::TargetLocator::LocalDocker { .. }
1236        | targets::TargetLocator::AppleContainer { .. }
1237        | targets::TargetLocator::SshPodman { .. }
1238        | targets::TargetLocator::SshDocker { .. } => {
1239            Some(format!("/var/lib/hel/profiles/{session_id}"))
1240        }
1241        targets::TargetLocator::AwsEc2 { .. } | targets::TargetLocator::SshBare { .. } => {
1242            Some(format!(".local/share/hel/profiles/{session_id}"))
1243        }
1244    }
1245}
1246
1247/// Resolve the login home on the machine that owns an editable path.
1248pub fn resolve_target_input_path(
1249    target: &TargetTemplate,
1250    path: &Path,
1251    executor: &impl CommandExecutor,
1252) -> Result<PathBuf> {
1253    if !mj_core::path_input::needs_home(path)? {
1254        return Ok(path.to_path_buf());
1255    }
1256    let host = cache_host::CacheHost::for_path_target(target)?;
1257    mj_core::path_input::expand_home(path, Some(&host.home(executor)?))
1258}
1259
1260/// Resolve the login home on a configured machine, for the Settings screen's
1261/// path fields. A machine, not a runtime, is what owns a home directory.
1262pub fn resolve_machine_input_path(
1263    machine: &mj_core::config::Machine,
1264    path: &Path,
1265    executor: &impl CommandExecutor,
1266) -> Result<PathBuf> {
1267    if !mj_core::path_input::needs_home(path)? {
1268        return Ok(path.to_path_buf());
1269    }
1270    // An EC2 instance does not exist until a session starts, so the only home
1271    // this screen can resolve is this machine's.
1272    let host = cache_host::CacheHost::for_path_machine(machine)?;
1273    mj_core::path_input::expand_home(path, Some(&host.home(executor)?))
1274}
1275
1276fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
1277    let output = executor.execute(&command)?;
1278    if output.status != 0 {
1279        let detail = command_error_detail(&output.stderr);
1280        if detail.is_empty() {
1281            bail!("{} failed with status {}", command.purpose, output.status);
1282        }
1283        bail!("{detail}");
1284    }
1285    Ok(output)
1286}
1287
1288fn command_error_detail(stderr: &[u8]) -> String {
1289    let reported = String::from_utf8_lossy(stderr);
1290    let reported = reported.trim();
1291    let detail = reported
1292        .rsplit_once("\nCaused by:\n")
1293        .map_or(reported, |(_, causes)| causes);
1294    let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
1295    detail
1296        .lines()
1297        .map(|line| line.strip_prefix("    ").unwrap_or(line))
1298        .collect::<Vec<_>>()
1299        .join("\n")
1300        .trim()
1301        .to_owned()
1302}
1303
1304fn now() -> String {
1305    Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1306}
1307
1308fn restore_session_after_persistence_failure(
1309    state: &mut State,
1310    session_id: &str,
1311    previous: &SessionRecord,
1312    primary: anyhow::Error,
1313    persist: impl FnOnce(&SessionRecord) -> Result<()>,
1314) -> anyhow::Error {
1315    state
1316        .sessions
1317        .insert(session_id.to_owned(), previous.clone());
1318    let restored = state
1319        .sessions
1320        .get(session_id)
1321        .expect("restored session record disappeared");
1322    match persist(restored) {
1323        Ok(()) => primary,
1324        Err(error) => primary.context(format!(
1325            "restored prior session state in memory, but failed to persist the rollback: {error:#}"
1326        )),
1327    }
1328}
1329
1330fn persist_session_record_transition_or_restore(
1331    state: &mut State,
1332    session_id: &str,
1333    previous: &SessionRecord,
1334    context: &'static str,
1335    persist: &impl Fn(&SessionRecord) -> Result<()>,
1336) -> Result<()> {
1337    let result = persist(
1338        state
1339            .sessions
1340            .get(session_id)
1341            .expect("checkpoint session disappeared before persistence"),
1342    );
1343    match result {
1344        Ok(()) => Ok(()),
1345        Err(error) => Err(restore_session_after_persistence_failure(
1346            state,
1347            session_id,
1348            previous,
1349            error.context(context),
1350            persist,
1351        )),
1352    }
1353}
1354
1355pub fn config_only_controller(config: Config) -> Controller {
1356    Controller {
1357        config,
1358        state: State::default(),
1359    }
1360}
1361
1362#[cfg(test)]
1363mod tests;