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