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