1mod 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#[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 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 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 let _ = self.file.unlock();
165 }
166}
167
168#[derive(Debug)]
172pub struct QuickBundleCreation {
173 pub config: Config,
174 pub bundle_id: String,
175}
176
177#[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
203pub 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
223pub 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
257pub 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
278pub 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 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
376fn 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 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 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 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 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 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 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 _ => 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 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 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 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 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 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 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
1177pub(crate) fn requires_private_profile_home(profile: &mj_core::config::HarnessProfile) -> bool {
1185 profile.codex_provider().ok().flatten().is_some()
1186}
1187
1188pub(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
1203fn 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#[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
1250pub(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 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
1299pub 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
1316pub 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 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;