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