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