1mod backend;
4mod checkpoint;
5mod git_cache;
6mod lifecycle;
7pub mod move_session;
8mod network_git;
9pub mod profile_config;
10mod provisioning;
11mod readiness;
12mod recovery_scan;
13mod resume;
14mod reviewer;
15mod subagents;
16#[cfg(test)]
17mod test_support;
18pub mod update;
19mod worker_binary;
20mod worker_restart;
21mod worktree;
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::fs::{self, File, OpenOptions};
25use std::path::{Path, PathBuf};
26
27use anyhow::{Context, Result, bail, ensure};
28use chrono::Utc;
29
30use mj_core::config::{
31 Config, ProjectBundle, ProjectRepository, SshConnection, TargetTemplate, atomic_write,
32 container_size_host, data_dir, is_bare_project_target, mount_history_host,
33};
34
35use crate::import::{
36 RepositoryIdentity, bundle_matches, configured_bundle_for_local, configured_bundle_for_origin,
37 setup_style_id,
38};
39use crate::setup::github_repository_from_origin;
40
41const CONFIG_RENAME_JOURNAL: &str = "config-rename.json";
42
43#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
44#[serde(rename_all = "snake_case")]
45enum ConfigRenameKind {
46 Profile,
47 Target,
48}
49
50#[derive(Debug, serde::Serialize, serde::Deserialize)]
51#[serde(deny_unknown_fields)]
52struct ConfigRenameJournal {
53 kind: ConfigRenameKind,
54 old_id: String,
55 new_id: String,
56}
57use mj_core::state::{
58 HostContainerSize, SessionRecord, SessionResourceAllocation, SessionState, State,
59 new_session_id, normalize_session_title,
60};
61
62use crate::targets::{
63 self, AdditionalMount, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
64};
65
66pub(crate) use backend::controller_github_token;
67pub use backend::image_refresh_plan;
68use backend::validate_resource_allocation;
69use provisioning::apply_failed_new_session_rollback;
70pub(crate) use worker_binary::refresh_remote_worker_binary_if_stale;
71pub(crate) use worktree::path_exists_on_managed_target;
72
73pub use checkpoint::{
74 CheckpointArtifact, CheckpointDeferred, IdleWorkspaceLease, SessionExportLayout,
75 checkpoint_was_deferred, reconcile_managed_checkpoint_archives,
76};
77pub use recovery_scan::{RecoveryCandidate, RecoveryScan};
78pub use resume::{
79 ResumeRepositorySourceMismatch, ResumeRepositorySourcePreflight, ResumeRepositorySourceReceipt,
80};
81pub use reviewer::reviewer_stager;
82pub use subagents::RegisterSubagentRequest;
83pub use worker_binary::{
84 WorkerBinaryAvailability, pin_worker_binary_sources, worker_binary_prerequisite_for_arch,
85};
86pub use worker_restart::WorkerUpgradeOutcome;
87pub use worktree::{ResumePlan, local_project_repository, resume_compatibility};
88
89pub struct Controller {
90 pub config: Config,
91 pub state: State,
92}
93
94#[derive(Debug)]
98pub struct ControllerStoreGuard {
99 file: File,
100}
101
102impl ControllerStoreGuard {
103 pub fn acquire() -> Result<Self> {
104 let directory = data_dir();
105 Self::acquire_at(&directory)
106 }
107
108 fn acquire_at(directory: &Path) -> Result<Self> {
109 Self::try_acquire_at(directory)?.with_context(|| {
110 format!(
111 "another Mjolnir controller is already using {}; stop it before starting this command",
112 directory.display()
113 )
114 })
115 }
116
117 pub fn try_acquire() -> Result<Option<Self>> {
119 Self::try_acquire_at(&data_dir())
120 }
121
122 fn try_acquire_at(directory: &Path) -> Result<Option<Self>> {
123 std::fs::create_dir_all(directory)
124 .with_context(|| format!("create controller data directory {}", directory.display()))?;
125 let path = directory.join("controller.lock");
126 let mut options = OpenOptions::new();
127 options.create(true).read(true).write(true);
128 #[cfg(unix)]
129 {
130 use std::os::unix::fs::OpenOptionsExt;
131 options.mode(0o600);
132 }
133 let file = options
134 .open(&path)
135 .with_context(|| format!("open controller lock {}", path.display()))?;
136 match file.try_lock() {
137 Ok(()) => {}
138 Err(std::fs::TryLockError::WouldBlock) => return Ok(None),
139 Err(std::fs::TryLockError::Error(error)) => {
140 return Err(error)
141 .with_context(|| format!("lock controller store {}", directory.display()));
142 }
143 }
144 Ok(Some(Self { file }))
145 }
146
147 pub fn start_database_writer(&self) -> Result<crate::database::DatabaseWriterOwner> {
150 crate::database::start_database_writer()
151 }
152}
153
154impl Drop for ControllerStoreGuard {
155 fn drop(&mut self) {
156 let _ = self.file.unlock();
159 }
160}
161
162#[derive(Debug)]
166pub struct QuickBundleCreation {
167 pub config: Config,
168 pub bundle_id: String,
169}
170
171#[derive(Debug)]
175pub enum QuickBundleFailure {
176 InvalidSource(anyhow::Error),
177 Persistence(anyhow::Error),
178}
179
180impl std::fmt::Display for QuickBundleFailure {
181 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 match self {
183 Self::InvalidSource(error) => write!(formatter, "invalid repository source: {error}"),
184 Self::Persistence(error) => write!(formatter, "persist quick bundle: {error}"),
185 }
186 }
187}
188
189impl std::error::Error for QuickBundleFailure {
190 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191 match self {
192 Self::InvalidSource(error) | Self::Persistence(error) => Some(error.root_cause()),
193 }
194 }
195}
196
197pub fn create_quick_bundle(
203 source: &str,
204) -> std::result::Result<QuickBundleCreation, QuickBundleFailure> {
205 let (config, bundle_id) = Config::update(|config| {
206 create_quick_bundle_in_config(config, source)
207 .map_err(|error| anyhow::Error::new(QuickBundleFailure::InvalidSource(error)))
208 })
209 .map_err(|error| {
210 error
211 .downcast::<QuickBundleFailure>()
212 .unwrap_or_else(QuickBundleFailure::Persistence)
213 })?;
214 Ok(QuickBundleCreation { config, bundle_id })
215}
216
217pub fn create_quick_bundle_in_config(config: &mut Config, source: &str) -> Result<String> {
222 let source = interpret_repository_source(source)?;
223 let existing = match &source.kind {
224 RepositorySourceKind::Local(root) => configured_bundle_for_local(config, root),
225 RepositorySourceKind::Github(repository) => {
226 configured_bundle_for_origin(config, repository)
227 }
228 };
229 if let Some(existing) = existing {
230 return Ok(existing);
231 }
232 let repository_id = setup_style_id(&source.name);
233 let mut bundle_id = repository_id.clone();
234 for suffix in 2_u32.. {
235 if !config.bundles.contains_key(&bundle_id) {
236 break;
237 }
238 bundle_id = format!("{repository_id}-{suffix}");
239 }
240 config.bundles.insert(
241 bundle_id.clone(),
242 ProjectBundle {
243 primary_repo: repository_id.clone(),
244 repositories: vec![source.into_project_repository(repository_id.clone())],
245 },
246 );
247 config.validate()?;
248 Ok(bundle_id)
249}
250
251pub fn create_bundle_from_sources(
258 sources: &[String],
259) -> std::result::Result<QuickBundleCreation, QuickBundleFailure> {
260 let (config, bundle_id) = Config::update(|config| {
261 create_bundle_from_sources_in_config(config, sources)
262 .map_err(|error| anyhow::Error::new(QuickBundleFailure::InvalidSource(error)))
263 })
264 .map_err(|error| {
265 error
266 .downcast::<QuickBundleFailure>()
267 .unwrap_or_else(QuickBundleFailure::Persistence)
268 })?;
269 Ok(QuickBundleCreation { config, bundle_id })
270}
271
272pub fn create_bundle_from_sources_in_config(
276 config: &mut Config,
277 sources: &[String],
278) -> Result<String> {
279 let sources = sources
280 .iter()
281 .map(|source| interpret_repository_source(source))
282 .collect::<Result<Vec<_>>>()?;
283 if sources.is_empty() {
284 bail!("at least one repository source is required");
285 }
286
287 let mut identities = BTreeSet::new();
288 for source in &sources {
289 if !identities.insert(source.identity()) {
290 bail!("duplicate repository source {:?}", source.display_name);
291 }
292 }
293
294 if let Some(existing) = exact_configured_bundle(config, &sources) {
295 return Ok(existing);
296 }
297
298 let mut updated = config.clone();
301 let mut used_repository_ids = BTreeSet::new();
302 let mut repositories = Vec::with_capacity(sources.len());
303 for source in sources {
304 let base = setup_style_id(&source.name);
305 let repository_id = unique_id(&base, |candidate| used_repository_ids.contains(candidate));
306 used_repository_ids.insert(repository_id.clone());
307 repositories.push(source.into_project_repository(repository_id));
308 }
309 let primary_repo = repositories
310 .first()
311 .map(|repository| repository.id.clone())
312 .context("at least one repository source is required")?;
313 let bundle_id = unique_id(&primary_repo, |candidate| {
314 updated.bundles.contains_key(candidate)
315 });
316 updated.bundles.insert(
317 bundle_id.clone(),
318 ProjectBundle {
319 primary_repo,
320 repositories,
321 },
322 );
323 updated.validate()?;
324 *config = updated;
325 Ok(bundle_id)
326}
327
328#[derive(Debug, Clone)]
329enum RepositorySourceKind {
330 Github(crate::setup::GithubRepository),
331 Local(PathBuf),
332}
333
334#[derive(Debug, Clone)]
335struct InterpretedRepositorySource {
336 display_name: String,
337 name: String,
338 kind: RepositorySourceKind,
339}
340
341impl InterpretedRepositorySource {
342 fn identity(&self) -> RepositoryIdentity {
343 match &self.kind {
344 RepositorySourceKind::Github(repository) => RepositoryIdentity::Github(
345 repository.owner.to_ascii_lowercase(),
346 repository.repository.to_ascii_lowercase(),
347 ),
348 RepositorySourceKind::Local(root) => RepositoryIdentity::Local(root.clone()),
349 }
350 }
351
352 fn into_project_repository(self, id: String) -> ProjectRepository {
353 let (github, local) = match self.kind {
354 RepositorySourceKind::Github(repository) => (
355 Some(format!("{}/{}", repository.owner, repository.repository)),
356 None,
357 ),
358 RepositorySourceKind::Local(root) => (None, Some(root)),
359 };
360 ProjectRepository {
361 id: id.clone(),
362 github,
363 local,
364 destination: PathBuf::from(id),
365 git_ref: None,
366 }
367 }
368}
369
370fn interpret_repository_source(source: &str) -> Result<InterpretedRepositorySource> {
373 let source = source.trim();
374 if source.is_empty() {
375 bail!("repository source cannot be empty");
376 }
377 let expanded = mj_core::path_input::expand_local(Path::new(source))?;
378 let candidate = expanded.as_path();
379 if candidate.exists() {
380 let root = mj_core::local_git::canonical_repository(candidate)?;
381 let name = root
382 .file_name()
383 .and_then(|name| name.to_str())
384 .context("local repository has no usable directory name")?
385 .to_owned();
386 return Ok(InterpretedRepositorySource {
387 display_name: source.to_owned(),
388 name,
389 kind: RepositorySourceKind::Local(root),
390 });
391 }
392 if candidate.is_absolute() || source.starts_with('.') || source.starts_with('~') {
393 bail!("local repository path {source:?} does not exist");
394 }
395 let repository = github_repository_from_origin(source).context(format!(
396 "{source:?} is not a GitHub owner/repository or URL"
397 ))?;
398 Ok(InterpretedRepositorySource {
399 display_name: source.to_owned(),
400 name: repository.repository.clone(),
401 kind: RepositorySourceKind::Github(repository),
402 })
403}
404
405fn exact_configured_bundle(
406 config: &Config,
407 requested: &[InterpretedRepositorySource],
408) -> Option<String> {
409 let requested_identities = requested
410 .iter()
411 .map(InterpretedRepositorySource::identity)
412 .collect::<BTreeSet<_>>();
413 let primary = requested.first()?.identity();
414 config.bundles.iter().find_map(|(id, bundle)| {
415 if bundle.repositories.len() != requested.len()
416 || bundle
417 .repositories
418 .iter()
419 .any(|repository| repository.git_ref.is_some())
420 {
421 return None;
422 }
423 bundle_matches(bundle, &requested_identities, &primary).then(|| id.clone())
424 })
425}
426
427fn unique_id(base: &str, mut is_used: impl FnMut(&str) -> bool) -> String {
428 if !is_used(base) {
429 return base.to_owned();
430 }
431 for suffix in 2_u32.. {
432 let suffix = format!("-{suffix}");
433 let prefix_len = 64usize.saturating_sub(suffix.len());
434 let prefix = base.chars().take(prefix_len).collect::<String>();
435 let candidate = format!("{prefix}{suffix}");
436 if !is_used(&candidate) {
437 return candidate;
438 }
439 }
440 unreachable!("u32 repository/bundle id suffixes exhausted")
441}
442
443pub struct SessionLaunchOptions {
444 pub create_managed_worktree: Option<bool>,
445 pub initial_prompt: Option<String>,
446 pub workspace_id: String,
447 pub additional_mounts: Vec<AdditionalMount>,
448 pub allow_dirty_local: bool,
449 pub resource_allocation: Option<SessionResourceAllocation>,
450 pub project_directory: Option<PathBuf>,
451 pub session_title_override: Option<String>,
452}
453
454pub struct SessionResumeOptions {
455 pub additional_mounts: Option<Vec<AdditionalMount>>,
456 pub resource_allocation: Option<SessionResourceAllocation>,
457 pub discard_queue: bool,
458}
459
460fn selected_host_container_size(
461 template: &TargetTemplate,
462 allocation: Option<&SessionResourceAllocation>,
463) -> Option<(String, HostContainerSize)> {
464 let host = container_size_host(template)?;
465 let SessionResourceAllocation::Container { cpus, memory_bytes } = allocation? else {
466 return None;
467 };
468 Some((
469 host.to_owned(),
470 HostContainerSize {
471 cpus: *cpus,
472 memory_bytes: *memory_bytes,
473 },
474 ))
475}
476
477impl Controller {
478 pub fn load() -> Result<Self> {
479 let config = Config::load()?;
480 let state = crate::database::load_state_migrating()?;
481 state.validate()?;
484 for session in state.sessions.values() {
485 if let Some(issue) = session.configuration_issue(&config) {
486 tracing::warn!(session_id = %session.id, "{issue}");
487 }
488 }
489 Ok(Self { config, state })
490 }
491
492 pub fn reload(&mut self) -> Result<()> {
493 *self = Self::load()?;
494 Ok(())
495 }
496
497 fn persist_session_state(&self, session_id: &str) -> Result<()> {
498 match self.state.sessions.get(session_id) {
499 Some(session) => crate::database::save_lifecycle_session(session),
500 None => crate::database::delete_session(session_id),
501 }
502 }
503
504 fn persist_session_transition_or_restore(
505 &mut self,
506 session_id: &str,
507 previous: &SessionRecord,
508 context: &'static str,
509 ) -> Result<()> {
510 persist_session_record_transition_or_restore(
511 &mut self.state,
512 session_id,
513 previous,
514 context,
515 &crate::database::save_lifecycle_session,
516 )
517 }
518
519 fn restore_prior_session_after_persistence_failure(
520 &mut self,
521 session_id: &str,
522 previous: &SessionRecord,
523 primary: anyhow::Error,
524 ) -> anyhow::Error {
525 restore_session_after_persistence_failure(
526 &mut self.state,
527 session_id,
528 previous,
529 primary,
530 crate::database::save_lifecycle_session,
531 )
532 }
533
534 pub fn resolve_input_path(
536 &self,
537 target_id: &str,
538 path: &Path,
539 executor: &impl CommandExecutor,
540 ) -> Result<PathBuf> {
541 let target = self
542 .config
543 .targets
544 .get(target_id)
545 .context("Unknown path target")?;
546 resolve_target_input_path(target, path, executor)
547 }
548
549 pub fn complete_mount_source(
551 &self,
552 target_id: &str,
553 prefix: &str,
554 executor: &impl CommandExecutor,
555 ) -> Result<Vec<String>> {
556 let target = self
557 .config
558 .targets
559 .get(target_id)
560 .with_context(|| format!("unknown target template {target_id:?}"))?;
561 let home = if mj_core::path_input::needs_home(Path::new(prefix))? {
562 Some(resolve_target_input_path(target, Path::new("~"), executor)?)
563 } else {
564 None
565 };
566 let expanded = mj_core::path_input::expand_home(Path::new(prefix), home.as_deref())?;
567 let mut lookup = expanded.to_string_lossy().into_owned();
568 if prefix.ends_with('/') && !lookup.ends_with('/') {
570 lookup.push('/');
571 }
572 let candidates = match target {
573 TargetTemplate::LocalPodman { .. }
574 | TargetTemplate::LocalDocker { .. }
575 | TargetTemplate::AppleContainer { .. }
576 | TargetTemplate::AwsEc2 { .. } => targets::local_directory_completions(&lookup),
577 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
578 targets::ssh_directory_completions(&backend_ssh(ssh), &lookup, executor)?
579 }
580 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
581 bail!("resource path completion is unsupported for bare targets")
582 }
583 };
584 candidates
585 .into_iter()
586 .map(|candidate| {
587 let Some(home) = &home else {
588 return Ok(candidate);
589 };
590 let suffix = Path::new(&candidate)
591 .strip_prefix(home)
592 .context("Completed path is outside the requested home")?;
593 let mut value = Path::new("~").join(suffix).to_string_lossy().into_owned();
594 if candidate.ends_with('/') && !value.ends_with('/') {
595 value.push('/');
596 }
597 Ok(value)
598 })
599 .collect()
600 }
601
602 pub fn validate_mount_source(
609 &self,
610 target_id: &str,
611 source: &Path,
612 executor: &impl CommandExecutor,
613 ) -> Result<Option<String>> {
614 let target = self
615 .config
616 .targets
617 .get(target_id)
618 .with_context(|| format!("unknown target template {target_id:?}"))?;
619 let exists = match target {
620 TargetTemplate::LocalPodman { .. }
621 | TargetTemplate::LocalDocker { .. }
622 | TargetTemplate::AppleContainer { .. }
623 | TargetTemplate::AwsEc2 { .. } => std::fs::metadata(source)
624 .map(|metadata| metadata.is_dir())
625 .or_else(|error| {
626 if error.kind() == std::io::ErrorKind::NotFound {
627 Ok(false)
628 } else {
629 Err(error)
630 }
631 })
632 .with_context(|| format!("inspect resource source {}", source.display()))?,
633 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
634 targets::ssh_directory_exists(&backend_ssh(ssh), source, executor)?
635 }
636 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
637 bail!("resource attachments are unsupported for bare targets")
638 }
639 };
640 ensure!(
641 exists,
642 "source path {} does not exist or is not a directory",
643 source.display()
644 );
645 Ok(self.forced_read_only_reason(target, source, executor))
646 }
647
648 fn forced_read_only_reason(
650 &self,
651 target: &TargetTemplate,
652 source: &Path,
653 executor: &impl CommandExecutor,
654 ) -> Option<String> {
655 let ssh = match target {
656 TargetTemplate::LocalPodman { .. } | TargetTemplate::LocalDocker { .. } => None,
657 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
658 Some(backend_ssh(ssh))
659 }
660 _ => return None,
663 };
664 let filesystem = targets::probe_filesystem_types(
665 ssh.as_ref(),
666 std::slice::from_ref(&source.to_path_buf()),
667 executor,
668 )
669 .map_err(|error| {
670 tracing::debug!(
671 source = %source.display(),
672 error = format!("{error:#}"),
673 "could not probe the filesystem under a mount source"
674 );
675 })
676 .ok()?
677 .pop()?;
678 let reason = targets::overlay_unsupported_filesystem(&filesystem)?;
679 Some(format!("{filesystem} ({reason})"))
680 }
681
682 fn fail_new_session_with_cleanup(
683 &mut self,
684 session_id: &str,
685 error: anyhow::Error,
686 executor: &impl CommandExecutor,
687 ) -> Result<anyhow::Error> {
688 let original = format!("{error:#}");
689 let cleanup_error = self
690 .cleanup_new_session_worktree_after_failure(session_id, executor)
691 .err()
692 .map(|cleanup_error| format!("{cleanup_error:#}"));
693 if let Some(cleanup_error) = &cleanup_error {
694 tracing::warn!(
695 session_id,
696 error = %cleanup_error,
697 "new-session worktree rollback reported a cleanup failure"
698 );
699 }
700 let failure = apply_failed_new_session_rollback(
701 &mut self.state,
702 session_id,
703 &original,
704 cleanup_error,
705 );
706 self.persist_session_state(session_id)?;
707 Ok(failure)
708 }
709
710 pub fn register_session_with_resources(
711 &mut self,
712 profile_id: &str,
713 bundle_id: &str,
714 target_id: &str,
715 title: impl Into<String>,
716 options: SessionLaunchOptions,
717 ) -> Result<String> {
718 let SessionLaunchOptions {
719 create_managed_worktree,
720 initial_prompt,
721 workspace_id,
722 additional_mounts,
723 allow_dirty_local: _,
724 resource_allocation,
725 project_directory,
726 session_title_override,
727 } = options;
728 let session_title_override = match session_title_override {
729 Some(title) => {
730 Some(normalize_session_title(&title).context("session name cannot be empty")?)
731 }
732 None => None,
733 };
734 let profile = self
735 .config
736 .profiles
737 .get(profile_id)
738 .with_context(|| format!("unknown profile {profile_id:?}"))?;
739 ensure!(profile.enabled, "profile {profile_id:?} is disabled");
740 let template = self
741 .config
742 .targets
743 .get(target_id)
744 .with_context(|| format!("unknown target template {target_id:?}"))?;
745 if create_managed_worktree == Some(true) && !is_bare_project_target(template) {
746 bail!("managed worktree creation requires a bare Git project");
747 }
748 if project_directory.is_some() != is_bare_project_target(template) {
749 bail!("raw project directories require a bare target, and bare targets require one");
750 }
751 if let Some(path) = &project_directory
752 && (!path.is_absolute()
753 || path
754 .components()
755 .any(|part| part == std::path::Component::ParentDir))
756 {
757 bail!("bare project directory must be an absolute safe path");
758 }
759 let bundle = project_directory
760 .is_none()
761 .then(|| self.config.bundles.get(bundle_id))
762 .flatten();
763 if project_directory.is_none() && bundle.is_none() {
764 bail!("unknown bundle {bundle_id:?}");
765 }
766 if matches!(
767 profile.kind,
768 mj_core::config::HarnessKind::Deepseek | mj_core::config::HarnessKind::Muse
769 ) && (!additional_mounts.is_empty()
770 || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
771 {
772 bail!(
773 "{} ACP supports one workspace root; use a single-repository bundle without attached directories",
774 profile.kind.display_name()
775 );
776 }
777 if let Some(bundle) = bundle {
778 for repository in &bundle.repositories {
779 mj_core::remote_git::resolve_repository(
780 repository,
781 &targets::CancellableProcessExecutor::with_timeout(
782 std::time::Duration::from_secs(15),
783 ),
784 )
785 .with_context(|| format!("repository {:?}", repository.id))?;
786 }
787 }
788 validate_resource_allocation(template, resource_allocation.as_ref())?;
789 let selected_container_size =
790 selected_host_container_size(template, resource_allocation.as_ref());
791 if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
792 bail!("attached resources are unsupported for this target");
793 }
794 targets::validate_additional_mounts(&additional_mounts)?;
795 let id = new_session_id()?;
796 let now = now();
797 let record = SessionRecord {
798 create_managed_worktree,
799 archived: false,
800 container_cpus: None,
801 container_memory: None,
802 id: id.clone(),
803 workspace_id,
804 title: title.into(),
805 harness_kind: profile.kind,
806 last_profile: profile_id.to_string(),
807 bundle_id: bundle_id.to_string(),
808 project_directory,
809 managed_worktree: None,
810 target_template_id: target_id.to_string(),
811 resource_allocation,
812 additional_mounts: additional_mounts.clone(),
813 state: SessionState::Provisioning,
814 target: None,
815 native_session_id: None,
816 acp_session_title: None,
817 session_title_override,
818 created_at: now.clone(),
819 updated_at: now,
820 viewed_through_event_ordinal: 0,
821 draft_input: initial_prompt.unwrap_or_default(),
822 last_error: None,
823 last_checkpoint_error: None,
824 checkpoint: None,
825 };
826 if let Some((host, size)) = selected_container_size.as_ref() {
830 crate::database::save_session_with_container_size(&record, host, *size)?;
831 } else {
832 crate::database::save_session(&record)?;
833 }
834 self.state.sessions.insert(id.clone(), record);
835 if let Some((host, size)) = selected_container_size {
836 self.state.remember_container_size(&host, size);
837 }
838 if let Some(host) = mount_history_host(template) {
839 match crate::database::remember_mount_sources(host, &additional_mounts) {
843 Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
844 Err(error) => tracing::warn!(
845 session_id = id,
846 error = format!("{error:#}"),
847 "could not remember the attached resource directories for later suggestions"
848 ),
849 }
850 }
851 Ok(id)
852 }
853
854 pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
855 let title = normalize_session_title(title).context("session name cannot be empty")?;
856 ensure!(
857 self.state.sessions.contains_key(session_id),
858 "unknown session {session_id}"
859 );
860 let updated_at = now();
861 crate::database::set_session_title_override(session_id, &title, &updated_at)?;
862 let record = self
863 .state
864 .sessions
865 .get_mut(session_id)
866 .expect("session was checked before updating its title");
867 record.session_title_override = Some(title.clone());
868 record.updated_at = updated_at;
869 Ok(title)
870 }
871
872 pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
873 mj_core::config::validate_id("profile", new_id)?;
874 if old_id == new_id {
875 ensure!(
876 self.config.profiles.contains_key(old_id),
877 "unknown profile {old_id:?}"
878 );
879 return Ok(());
880 }
881 let journal = ConfigRenameJournal {
882 kind: ConfigRenameKind::Profile,
883 old_id: old_id.to_owned(),
884 new_id: new_id.to_owned(),
885 };
886 write_config_rename_journal(&journal)?;
887 let (config, ()) = match Config::update(|config| {
888 ensure!(
889 config.profiles.contains_key(old_id),
890 "unknown profile {old_id:?}"
891 );
892 ensure!(
893 !config.profiles.contains_key(new_id),
894 "profile {new_id:?} already exists"
895 );
896 let profile = config
897 .profiles
898 .remove(old_id)
899 .expect("profile was checked in the transaction");
900 config.profiles.insert(new_id.to_owned(), profile);
901 Ok(())
902 }) {
903 Ok(result) => result,
904 Err(error) => {
905 remove_config_rename_journal()
906 .context("remove profile rename journal after config save failed")?;
907 return Err(error).context("save renamed profile configuration");
908 }
909 };
910 self.config = config;
911 mj_core::test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
912 if let Err(error) = crate::database::rename_profile_references(old_id, new_id) {
913 let restore = Config::update(|config| {
914 let profile = config
915 .profiles
916 .remove(new_id)
917 .with_context(|| format!("renamed profile {new_id:?} is missing"))?;
918 ensure!(
919 !config.profiles.contains_key(old_id),
920 "cannot restore profile rename: both {old_id:?} and {new_id:?} exist"
921 );
922 config.profiles.insert(old_id.to_owned(), profile);
923 Ok(())
924 });
925 let restored = match restore {
926 Ok((config, ())) => config,
927 Err(restore_error) => {
928 return Err(error).context(format!(
929 "rename profile references; additionally failed to restore config: {restore_error:#}"
930 ));
931 }
932 };
933 self.config = restored;
934 if let Err(restore_error) = remove_config_rename_journal() {
935 return Err(error).context(format!(
936 "rename profile references; additionally failed to remove rename journal: {restore_error:#}"
937 ));
938 }
939 return Err(error).context("rename profile references");
940 }
941 for session in self.state.sessions.values_mut() {
942 if session.last_profile == old_id {
943 session.last_profile = new_id.to_owned();
944 }
945 }
946 remove_config_rename_journal()?;
947 Ok(())
948 }
949
950 pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
951 mj_core::config::validate_id("target template", new_id)?;
952 if old_id == new_id {
953 ensure!(
954 self.config.targets.contains_key(old_id),
955 "unknown target {old_id:?}"
956 );
957 return Ok(());
958 }
959 let journal = ConfigRenameJournal {
960 kind: ConfigRenameKind::Target,
961 old_id: old_id.to_owned(),
962 new_id: new_id.to_owned(),
963 };
964 write_config_rename_journal(&journal)?;
965 let (config, ()) = match Config::update(|config| {
966 ensure!(
967 config.targets.contains_key(old_id),
968 "unknown target {old_id:?}"
969 );
970 ensure!(
971 !config.targets.contains_key(new_id),
972 "target {new_id:?} already exists"
973 );
974 let target = config
975 .targets
976 .remove(old_id)
977 .expect("target was checked in the transaction");
978 config.targets.insert(new_id.to_owned(), target);
979 Ok(())
980 }) {
981 Ok(result) => result,
982 Err(error) => {
983 remove_config_rename_journal()
984 .context("remove target rename journal after config save failed")?;
985 return Err(error).context("save renamed target configuration");
986 }
987 };
988 self.config = config;
989 mj_core::test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
990 if let Err(error) = crate::database::rename_target_references(old_id, new_id) {
991 let restore = Config::update(|config| {
992 let target = config
993 .targets
994 .remove(new_id)
995 .with_context(|| format!("renamed target {new_id:?} is missing"))?;
996 ensure!(
997 !config.targets.contains_key(old_id),
998 "cannot restore target rename: both {old_id:?} and {new_id:?} exist"
999 );
1000 config.targets.insert(old_id.to_owned(), target);
1001 Ok(())
1002 });
1003 let restored = match restore {
1004 Ok((config, ())) => config,
1005 Err(restore_error) => {
1006 return Err(error).context(format!(
1007 "rename target references; additionally failed to restore config: {restore_error:#}"
1008 ));
1009 }
1010 };
1011 self.config = restored;
1012 if let Err(restore_error) = remove_config_rename_journal() {
1013 return Err(error).context(format!(
1014 "rename target references; additionally failed to remove rename journal: {restore_error:#}"
1015 ));
1016 }
1017 return Err(error).context("rename target references");
1018 }
1019 for session in self.state.sessions.values_mut() {
1020 if session.target_template_id == old_id {
1021 session.target_template_id = new_id.to_owned();
1022 }
1023 }
1024 remove_config_rename_journal()?;
1025 Ok(())
1026 }
1027
1028 pub fn recover_config_id_rename() -> Result<bool> {
1032 let path = config_rename_journal_path();
1033 let body = match fs::read(&path) {
1034 Ok(body) => body,
1035 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1036 Err(error) => return Err(error).context(format!("read {}", path.display())),
1037 };
1038 let journal: ConfigRenameJournal =
1039 serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
1040 match journal.kind {
1041 ConfigRenameKind::Profile => {
1042 Config::update(|config| {
1043 finish_config_map_rename(
1044 &mut config.profiles,
1045 &journal.old_id,
1046 &journal.new_id,
1047 "profile",
1048 )?;
1049 Ok(())
1050 })?;
1051 crate::database::rename_profile_references(&journal.old_id, &journal.new_id)?;
1052 }
1053 ConfigRenameKind::Target => {
1054 Config::update(|config| {
1055 finish_config_map_rename(
1056 &mut config.targets,
1057 &journal.old_id,
1058 &journal.new_id,
1059 "target",
1060 )?;
1061 Ok(())
1062 })?;
1063 crate::database::rename_target_references(&journal.old_id, &journal.new_id)?;
1064 }
1065 }
1066 remove_config_rename_journal()?;
1067 Ok(true)
1068 }
1069
1070 pub fn update_session_container_settings(
1074 &mut self,
1075 session_id: &str,
1076 cpus: Option<String>,
1077 memory: Option<String>,
1078 additional_mounts: Vec<targets::AdditionalMount>,
1079 mount_history: Vec<std::path::PathBuf>,
1080 ) -> Result<()> {
1081 ensure!(
1082 self.state.sessions.contains_key(session_id),
1083 "unknown session {session_id}"
1084 );
1085 let cpus = cpus.filter(|value| !value.trim().is_empty());
1086 let memory = memory.filter(|value| !value.trim().is_empty());
1087 let updated_at = now();
1088 crate::database::set_session_container_settings(
1089 session_id,
1090 cpus.as_deref(),
1091 memory.as_deref(),
1092 &additional_mounts,
1093 &updated_at,
1094 )?;
1095 if let Some(host) = self
1096 .config
1097 .targets
1098 .get(
1099 &self.state.sessions[session_id]
1100 .target_template_id
1101 .to_owned(),
1102 )
1103 .and_then(mj_core::config::mount_history_host)
1104 {
1105 let host = host.to_owned();
1106 crate::database::replace_mount_history(&host, &mount_history)?;
1109 crate::database::remember_mount_sources(&host, &additional_mounts)?;
1110 self.state.mount_history.insert(host.clone(), mount_history);
1111 self.state.remember_mount_sources(&host, &additional_mounts);
1112 }
1113 let record = self
1114 .state
1115 .sessions
1116 .get_mut(session_id)
1117 .expect("session was checked before updating its container settings");
1118 record.container_cpus = cpus;
1119 record.container_memory = memory;
1120 record.additional_mounts = additional_mounts;
1121 record.updated_at = updated_at;
1122 Ok(())
1123 }
1124}
1125
1126fn config_rename_journal_path() -> PathBuf {
1127 data_dir().join(CONFIG_RENAME_JOURNAL)
1128}
1129
1130fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
1131 let path = config_rename_journal_path();
1132 let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
1133 atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
1134}
1135
1136fn remove_config_rename_journal() -> Result<()> {
1137 let path = config_rename_journal_path();
1138 match fs::remove_file(&path) {
1139 Ok(()) => Ok(()),
1140 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1141 Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1142 }
1143}
1144
1145fn finish_config_map_rename<T>(
1146 entries: &mut BTreeMap<String, T>,
1147 old_id: &str,
1148 new_id: &str,
1149 kind: &str,
1150) -> Result<()> {
1151 if let Some(entry) = entries.remove(old_id) {
1152 ensure!(
1153 !entries.contains_key(new_id),
1154 "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
1155 );
1156 entries.insert(new_id.to_owned(), entry);
1157 } else {
1158 ensure!(
1159 entries.contains_key(new_id),
1160 "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
1161 );
1162 }
1163 Ok(())
1164}
1165
1166fn target_kind(locator: &targets::TargetLocator) -> &'static str {
1167 match locator {
1168 targets::TargetLocator::LocalBare { .. } => "local-bare",
1169 targets::TargetLocator::LocalPodman { .. } => "local-podman",
1170 targets::TargetLocator::LocalDocker { .. } => "local-docker",
1171 targets::TargetLocator::AppleContainer { .. } => "apple-container",
1172 targets::TargetLocator::AwsEc2 { .. } => "aws-ec2",
1173 targets::TargetLocator::SshBare { .. } => "ssh-bare",
1174 targets::TargetLocator::SshPodman { .. } => "ssh-podman",
1175 targets::TargetLocator::SshDocker { .. } => "ssh-docker",
1176 }
1177}
1178
1179fn target_profile_home(
1180 locator: &targets::TargetLocator,
1181 session_id: &str,
1182 profile: &mj_core::config::HarnessProfile,
1183) -> String {
1184 let home = match locator {
1185 targets::TargetLocator::LocalBare { worker_root }
1186 if profile.kind == mj_core::config::HarnessKind::Claude =>
1187 {
1188 Path::new(worker_root)
1189 .join("profile")
1190 .to_string_lossy()
1191 .into_owned()
1192 }
1193 targets::TargetLocator::LocalBare { .. } => profile.home.to_string_lossy().into_owned(),
1194 targets::TargetLocator::LocalPodman { .. }
1195 | targets::TargetLocator::LocalDocker { .. }
1196 | targets::TargetLocator::AppleContainer { .. }
1197 | targets::TargetLocator::SshPodman { .. }
1198 | targets::TargetLocator::SshDocker { .. } => {
1199 format!("/var/lib/hel/profiles/{session_id}")
1200 }
1201 targets::TargetLocator::AwsEc2 { .. } | targets::TargetLocator::SshBare { .. } => {
1202 format!(".local/share/hel/profiles/{session_id}")
1203 }
1204 };
1205 if profile.kind == mj_core::config::HarnessKind::Muse {
1206 let root = if matches!(locator, targets::TargetLocator::LocalBare { .. }) {
1207 mj_core::config::data_dir()
1208 .join("profiles")
1209 .join(session_id)
1210 } else {
1211 PathBuf::from(home)
1212 };
1213 root.join("muse").to_string_lossy().into_owned()
1214 } else if profile.kind == mj_core::config::HarnessKind::Zcode
1215 && !matches!(locator, targets::TargetLocator::LocalBare { .. })
1216 {
1217 Path::new(&home)
1218 .join(".zcode")
1219 .to_string_lossy()
1220 .into_owned()
1221 } else {
1222 home
1223 }
1224}
1225
1226pub fn resolve_target_input_path(
1228 target: &TargetTemplate,
1229 path: &Path,
1230 executor: &impl CommandExecutor,
1231) -> Result<PathBuf> {
1232 if !mj_core::path_input::needs_home(path)? {
1233 return Ok(path.to_path_buf());
1234 }
1235 match target {
1236 TargetTemplate::SshBare { ssh, .. }
1237 | TargetTemplate::SshPodman { ssh, .. }
1238 | TargetTemplate::SshDocker { ssh, .. } => {
1239 let mut ssh = ssh.clone();
1240 ssh.identity_file = ssh
1241 .identity_file
1242 .as_deref()
1243 .map(mj_core::path_input::expand_local)
1244 .transpose()?;
1245 let command =
1246 ssh_command_spec(&backend_ssh(&ssh), ["sh", "-c", "printf '%s' \"$HOME\""])
1247 .purpose("resolve remote home directory");
1248 let output = executor.execute(&command)?;
1249 anyhow::ensure!(
1250 output.status == 0,
1251 "Could not resolve remote home: {}",
1252 String::from_utf8_lossy(&output.stderr).trim()
1253 );
1254 let home =
1255 String::from_utf8(output.stdout).context("Remote home is not valid UTF-8")?;
1256 mj_core::path_input::expand_home(path, Some(Path::new(&home)))
1257 }
1258 _ => mj_core::path_input::expand_local(path),
1259 }
1260}
1261
1262pub(crate) fn backend_ssh(ssh: &SshConnection) -> SshTarget {
1263 let destination = match &ssh.user {
1264 Some(user) => format!("{user}@{}", ssh.host),
1265 None => ssh.host.clone(),
1266 };
1267 SshTarget {
1268 destination,
1269 ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
1270 }
1271}
1272
1273fn ssh_command_spec(
1274 ssh: &SshTarget,
1275 args: impl IntoIterator<Item = impl AsRef<str>>,
1276) -> CommandSpec {
1277 let remote = args
1278 .into_iter()
1279 .map(|arg| arg.as_ref().to_string())
1280 .collect::<Vec<_>>();
1281 let mut command_args = ssh.ssh_args.clone();
1282 command_args.push(ssh.destination.clone());
1283 command_args.push(targets::join_remote_command(&remote));
1284 CommandSpec::new("ssh", command_args)
1285}
1286
1287fn scp_command_spec(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
1288 let mut args = ssh.ssh_args.clone();
1289 if recursive {
1290 args.push("-r".into());
1291 }
1292 args.push(source.to_string_lossy().into_owned());
1293 args.push(format!("{}:{remote}", ssh.destination));
1294 CommandSpec::new("scp", args)
1295}
1296
1297fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
1298 let mut result = vec![
1304 "-o".into(),
1305 "BatchMode=yes".into(),
1306 "-o".into(),
1307 "StrictHostKeyChecking=accept-new".into(),
1308 "-o".into(),
1309 "ConnectTimeout=15".into(),
1310 ];
1311 result.extend(args.iter().cloned());
1312 if let Some(identity) = identity {
1313 result.push("-i".into());
1314 result.push(identity.to_string_lossy().into_owned());
1315 }
1316 result
1317}
1318
1319fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
1320 let output = executor.execute(&command)?;
1321 if output.status != 0 {
1322 let detail = command_error_detail(&output.stderr);
1323 if detail.is_empty() {
1324 bail!("{} failed with status {}", command.purpose, output.status);
1325 }
1326 bail!("{detail}");
1327 }
1328 Ok(output)
1329}
1330
1331fn command_error_detail(stderr: &[u8]) -> String {
1332 let reported = String::from_utf8_lossy(stderr);
1333 let reported = reported.trim();
1334 let detail = reported
1335 .rsplit_once("\nCaused by:\n")
1336 .map_or(reported, |(_, causes)| causes);
1337 let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
1338 detail
1339 .lines()
1340 .map(|line| line.strip_prefix(" ").unwrap_or(line))
1341 .collect::<Vec<_>>()
1342 .join("\n")
1343 .trim()
1344 .to_owned()
1345}
1346
1347fn now() -> String {
1348 Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1349}
1350
1351fn restore_session_after_persistence_failure(
1352 state: &mut State,
1353 session_id: &str,
1354 previous: &SessionRecord,
1355 primary: anyhow::Error,
1356 persist: impl FnOnce(&SessionRecord) -> Result<()>,
1357) -> anyhow::Error {
1358 state
1359 .sessions
1360 .insert(session_id.to_owned(), previous.clone());
1361 let restored = state
1362 .sessions
1363 .get(session_id)
1364 .expect("restored session record disappeared");
1365 match persist(restored) {
1366 Ok(()) => primary,
1367 Err(error) => primary.context(format!(
1368 "restored prior session state in memory, but failed to persist the rollback: {error:#}"
1369 )),
1370 }
1371}
1372
1373fn persist_session_record_transition_or_restore(
1374 state: &mut State,
1375 session_id: &str,
1376 previous: &SessionRecord,
1377 context: &'static str,
1378 persist: &impl Fn(&SessionRecord) -> Result<()>,
1379) -> Result<()> {
1380 let result = persist(
1381 state
1382 .sessions
1383 .get(session_id)
1384 .expect("checkpoint session disappeared before persistence"),
1385 );
1386 match result {
1387 Ok(()) => Ok(()),
1388 Err(error) => Err(restore_session_after_persistence_failure(
1389 state,
1390 session_id,
1391 previous,
1392 error.context(context),
1393 persist,
1394 )),
1395 }
1396}
1397
1398pub fn config_only_controller(config: Config) -> Controller {
1399 Controller {
1400 config,
1401 state: State::default(),
1402 }
1403}
1404
1405#[cfg(test)]
1406mod tests {
1407 use std::collections::BTreeMap;
1408 use std::path::Path;
1409
1410 use crate::targets::ProcessExecutor;
1411 use mj_core::config::{
1412 Config, ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, ProjectBundle,
1413 ProjectRepository, TargetTemplate,
1414 };
1415 use mj_core::state::State;
1416
1417 use super::*;
1418
1419 fn registration_config() -> Config {
1422 let mut config = Config::default();
1423 config.profiles.insert(
1424 "codex".into(),
1425 HarnessProfile {
1426 enabled: true,
1427 kind: HarnessKind::Codex,
1428 home: PathBuf::from("/home/dev/.codex"),
1429 environment: BTreeMap::new(),
1430 context_window_bytes: None,
1431 },
1432 );
1433 config.bundles.insert(
1434 "project".into(),
1435 ProjectBundle {
1436 primary_repo: "project".into(),
1437 repositories: vec![ProjectRepository {
1438 id: "project".into(),
1439 github: Some("owner/project".into()),
1440 local: None,
1441 destination: PathBuf::from("project"),
1442 git_ref: None,
1443 }],
1444 },
1445 );
1446 config.targets.insert(
1447 "podman".into(),
1448 TargetTemplate::LocalPodman {
1449 container: ConfigContainer {
1450 image: "example.invalid/hel-test:latest".into(),
1451 pull_policy: Default::default(),
1452 platform: None,
1453 cpus: None,
1454 memory: None,
1455 environment: BTreeMap::new(),
1456 workspace_storage: Default::default(),
1457 },
1458 },
1459 );
1460 config
1461 }
1462
1463 #[test]
1464 fn remote_completion_preserves_home_shorthand_and_trailing_separator() {
1465 struct CompletionExecutor;
1466 impl CommandExecutor for CompletionExecutor {
1467 fn execute(&self, command: &CommandSpec) -> Result<targets::CommandOutput> {
1468 let script = command.args.last().unwrap();
1469 let stdout = if script.contains("$HOME") {
1470 b"/remote".to_vec()
1471 } else {
1472 assert!(script.contains("'/remote/cache/'"), "{script}");
1473 b"/remote/cache/alpha/\n/remote/cache/alpine/\n".to_vec()
1474 };
1475 Ok(targets::CommandOutput {
1476 status: 0,
1477 stdout,
1478 stderr: Vec::new(),
1479 })
1480 }
1481 }
1482 let target: TargetTemplate =
1483 serde_json::from_str(r#"{"kind":"ssh-podman","host":"builder","image":"test"}"#)
1484 .unwrap();
1485 let mut config = Config::default();
1486 config.targets.insert("remote".into(), target);
1487 let controller = Controller {
1488 config,
1489 state: State::default(),
1490 };
1491 let candidates = controller
1492 .complete_mount_source("remote", "~/cache/", &CompletionExecutor)
1493 .unwrap();
1494 assert_eq!(candidates, ["~/cache/alpha/", "~/cache/alpine/"]);
1495 assert_eq!(
1496 targets::path_completion("~/cache/", &candidates).as_deref(),
1497 Some("~/cache/alp")
1498 );
1499 }
1500
1501 #[test]
1502 fn remote_path_resolution_uses_login_home_without_evaluating_suffix() {
1503 struct HomeExecutor {
1504 status: i32,
1505 home: &'static str,
1506 }
1507 impl CommandExecutor for HomeExecutor {
1508 fn execute(&self, command: &CommandSpec) -> Result<targets::CommandOutput> {
1509 assert_eq!(command.program, "ssh");
1510 let script = command.args.last().unwrap();
1511 assert!(!script.contains("touch"));
1512 assert!(script.contains("$HOME"));
1513 Ok(targets::CommandOutput {
1514 status: self.status,
1515 stdout: self.home.as_bytes().to_vec(),
1516 stderr: b"home lookup failed".to_vec(),
1517 })
1518 }
1519 }
1520 let target: TargetTemplate = serde_json::from_str(
1521 r#"{"kind":"ssh-bare","host":"builder","permissions":"guardian"}"#,
1522 )
1523 .unwrap();
1524 let path = Path::new("~/資料/$(touch nope)");
1525 assert_eq!(
1526 resolve_target_input_path(
1527 &target,
1528 path,
1529 &HomeExecutor {
1530 status: 0,
1531 home: "/remote user"
1532 }
1533 )
1534 .unwrap(),
1535 Path::new("/remote user/資料/$(touch nope)")
1536 );
1537 assert!(
1538 resolve_target_input_path(
1539 &target,
1540 path,
1541 &HomeExecutor {
1542 status: 1,
1543 home: "/remote"
1544 }
1545 )
1546 .unwrap_err()
1547 .to_string()
1548 .contains("home lookup failed")
1549 );
1550 assert!(
1551 resolve_target_input_path(
1552 &target,
1553 path,
1554 &HomeExecutor {
1555 status: 0,
1556 home: ""
1557 }
1558 )
1559 .is_err()
1560 );
1561 assert!(
1562 resolve_target_input_path(
1563 &target,
1564 path,
1565 &HomeExecutor {
1566 status: 0,
1567 home: "relative"
1568 }
1569 )
1570 .is_err()
1571 );
1572 }
1573
1574 #[test]
1575 fn bundle_creation_combines_sources_with_first_primary_and_stable_collisions() {
1576 let mut config = Config::default();
1577 let sources = vec!["example/app".into(), "other/app".into()];
1578
1579 let bundle_id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1580 let bundle = &config.bundles[&bundle_id];
1581 assert_eq!(bundle_id, "app");
1582 assert_eq!(bundle.primary_repo, "app");
1583 assert_eq!(
1584 bundle
1585 .repositories
1586 .iter()
1587 .map(|repository| repository.id.as_str())
1588 .collect::<Vec<_>>(),
1589 ["app", "app-2"]
1590 );
1591 assert_eq!(
1592 bundle
1593 .repositories
1594 .iter()
1595 .map(|repository| repository.destination.to_string_lossy().into_owned())
1596 .collect::<Vec<_>>(),
1597 ["app".to_owned(), "app-2".to_owned()]
1598 );
1599 assert_eq!(
1600 bundle.repositories[0].github.as_deref(),
1601 Some("example/app")
1602 );
1603 assert_eq!(bundle.repositories[1].github.as_deref(), Some("other/app"));
1604 }
1605
1606 #[test]
1607 fn bundle_creation_combines_local_and_github_sources_and_rejects_local_aliases() {
1608 let directory = tempfile::tempdir().unwrap();
1609 let root = directory.path().join("app");
1610 let output = mj_core::subprocess::run_capturing_stdout(
1611 std::process::Command::new("git").arg("init").arg(&root),
1612 )
1613 .unwrap();
1614 assert!(output.status.success(), "{output:?}");
1615 let nested = root.join("nested");
1616 fs::create_dir(&nested).unwrap();
1617 let mut config = Config::default();
1618 let sources = vec![root.to_str().unwrap().into(), "example/shared".into()];
1619 let id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1620 let bundle = &config.bundles[&id];
1621 assert_eq!(
1622 bundle.primary().unwrap().local,
1623 Some(root.canonicalize().unwrap())
1624 );
1625 assert_eq!(
1626 bundle.repositories[1].github.as_deref(),
1627 Some("example/shared")
1628 );
1629 let before = config.clone();
1630 let aliases = vec![
1631 root.to_str().unwrap().into(),
1632 nested.to_str().unwrap().into(),
1633 ];
1634 let error = create_bundle_from_sources_in_config(&mut config, &aliases).unwrap_err();
1635 assert!(
1636 error.to_string().contains("duplicate repository source"),
1637 "{error:#}"
1638 );
1639 assert_eq!(config, before);
1640 }
1641
1642 #[test]
1643 fn bundle_creation_rejects_duplicate_normalized_sources_atomically() {
1644 let mut config = Config::default();
1645 let before = config.clone();
1646 let sources = vec![
1647 "example/app".into(),
1648 "https://github.com/EXAMPLE/APP.git".into(),
1649 ];
1650
1651 let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1652 assert!(error.to_string().contains("duplicate repository source"));
1653 assert_eq!(config, before);
1654 }
1655
1656 #[test]
1657 fn bundle_creation_validates_every_source_before_mutating_config() {
1658 let mut config = Config::default();
1659 let before = config.clone();
1660 let invalid_directory = tempfile::tempdir().unwrap();
1661 let sources = vec![
1662 "example/app".into(),
1663 invalid_directory.path().to_string_lossy().into_owned(),
1664 ];
1665
1666 let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1667 assert!(error.to_string().contains("not a Git repository"));
1668 assert_eq!(config, before);
1669 }
1670
1671 #[test]
1672 fn bundle_creation_reuses_an_exact_source_set_and_rejects_obsolete_pins() {
1673 let mut config = Config::default();
1674 config.bundles.insert(
1675 "all".into(),
1676 ProjectBundle {
1677 primary_repo: "app".into(),
1678 repositories: vec![
1679 ProjectRepository {
1680 id: "app".into(),
1681 github: Some("example/app".into()),
1682 local: None,
1683 destination: "app".into(),
1684 git_ref: None,
1685 },
1686 ProjectRepository {
1687 id: "shared".into(),
1688 github: Some("example/shared".into()),
1689 local: None,
1690 destination: "shared".into(),
1691 git_ref: None,
1692 },
1693 ],
1694 },
1695 );
1696
1697 let one_source = vec!["example/app".into()];
1698 let created = create_bundle_from_sources_in_config(&mut config, &one_source).unwrap();
1699 assert_eq!(created, "app");
1700 assert_eq!(config.bundles[&created].repositories.len(), 1);
1701 assert_eq!(
1702 create_bundle_from_sources_in_config(&mut config, &one_source).unwrap(),
1703 "app"
1704 );
1705
1706 let exact_sources = vec!["example/app".into(), "example/shared".into()];
1707 assert_eq!(
1708 create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap(),
1709 "all"
1710 );
1711 assert_eq!(config.bundles.len(), 2);
1712 config.bundles.get_mut("all").unwrap().repositories[0].git_ref = Some("release".into());
1713 let before = config.clone();
1714 let error = create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap_err();
1715 assert!(format!("{error:#}").contains("git_ref is no longer supported"));
1716 assert_eq!(config, before);
1717 }
1718
1719 fn launch_options(additional_mounts: Vec<AdditionalMount>) -> SessionLaunchOptions {
1720 SessionLaunchOptions {
1721 create_managed_worktree: None,
1722 initial_prompt: None,
1723 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1724 additional_mounts,
1725 allow_dirty_local: false,
1726 resource_allocation: None,
1727 project_directory: None,
1728 session_title_override: None,
1729 }
1730 }
1731
1732 #[test]
1733 fn registration_rejects_a_disabled_profile_before_persisting() {
1734 let mut config = registration_config();
1735 config.profiles.get_mut("codex").unwrap().enabled = false;
1736 let mut controller = Controller {
1737 config,
1738 state: State::default(),
1739 };
1740
1741 let error = controller
1742 .register_session_with_resources(
1743 "codex",
1744 "project",
1745 "podman",
1746 "disabled",
1747 launch_options(Vec::new()),
1748 )
1749 .unwrap_err();
1750
1751 assert!(error.to_string().contains("disabled"));
1752 assert!(controller.state.sessions.is_empty());
1753 }
1754
1755 #[test]
1756 fn deepseek_registration_rejects_more_than_one_workspace_root_before_persisting() {
1757 let mut config = registration_config();
1758 config.profiles.get_mut("codex").unwrap().kind = HarnessKind::Deepseek;
1759 let second = config.bundles["project"].repositories[0].clone();
1760 config
1761 .bundles
1762 .get_mut("project")
1763 .unwrap()
1764 .repositories
1765 .push(mj_core::config::ProjectRepository {
1766 id: "second".into(),
1767 destination: "second".into(),
1768 ..second
1769 });
1770 let mut controller = Controller {
1771 config,
1772 state: State::default(),
1773 };
1774
1775 let error = controller
1776 .register_session_with_resources(
1777 "codex",
1778 "project",
1779 "podman",
1780 "unsupported",
1781 launch_options(Vec::new()),
1782 )
1783 .unwrap_err();
1784
1785 assert!(error.to_string().contains("one workspace root"));
1786 assert!(controller.state.sessions.is_empty());
1787 }
1788
1789 fn run_registration_child(marker: &str, test: &str, data_directory: &Path) {
1792 let output = std::process::Command::new(std::env::current_exe().unwrap())
1793 .args([
1794 "--exact",
1795 &format!("controller::tests::{test}"),
1796 "--nocapture",
1797 ])
1798 .env(marker, "1")
1799 .env("MJ_DATA_DIR", data_directory)
1800 .env("MJ_CONFIG_DIR", data_directory)
1801 .output()
1802 .unwrap();
1803 assert!(
1804 output.status.success(),
1805 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1806 String::from_utf8_lossy(&output.stdout),
1807 String::from_utf8_lossy(&output.stderr)
1808 );
1809 }
1810
1811 #[test]
1812 fn registration_saves_the_initial_task_before_provisioning() {
1813 const MARKER: &str = "MJ_TEST_INITIAL_TASK_CHILD";
1814 if std::env::var_os(MARKER).is_none() {
1815 let directory = tempfile::tempdir().unwrap();
1816 run_registration_child(
1817 MARKER,
1818 "registration_saves_the_initial_task_before_provisioning",
1819 directory.path(),
1820 );
1821 return;
1822 }
1823 let _writer = crate::database::install_isolated_test_writer();
1824 let mut controller = Controller {
1825 config: registration_config(),
1826 state: State::default(),
1827 };
1828 let prompt = format!(
1829 "Initial task\n{}\n\tPreserve indentation and λ",
1830 "x".repeat(70_000)
1831 );
1832 let mut options = launch_options(Vec::new());
1833 options.initial_prompt = Some(prompt.clone());
1834 let id = controller
1835 .register_session_with_resources("codex", "project", "podman", "fresh task", options)
1836 .unwrap();
1837 let saved = crate::database::load_state().unwrap();
1838 assert_eq!(saved.sessions[&id].draft_input, prompt);
1839 assert_eq!(saved.sessions[&id].state, SessionState::Provisioning);
1840 crate::database::set_session_draft_input(&id, "a newer draft").unwrap();
1841 crate::database::clear_session_draft_input_if_matches(&id, &prompt).unwrap();
1842 assert_eq!(
1843 crate::database::load_state().unwrap().sessions[&id].draft_input,
1844 "a newer draft"
1845 );
1846 crate::database::clear_session_draft_input_if_matches(&id, "a newer draft").unwrap();
1847 assert!(
1848 crate::database::load_state().unwrap().sessions[&id]
1849 .draft_input
1850 .is_empty()
1851 );
1852 }
1853
1854 const UNPERSISTABLE_SESSION_CHILD: &str = "MJ_TEST_UNPERSISTABLE_SESSION_CHILD";
1855
1856 #[test]
1857 fn missing_bundle_does_not_block_controller_or_other_sessions() {
1858 const CHILD: &str = "MJ_TEST_MISSING_BUNDLE_CHILD";
1859 if std::env::var_os(CHILD).is_none() {
1860 let directory = tempfile::tempdir().unwrap();
1861 run_registration_child(
1862 CHILD,
1863 "missing_bundle_does_not_block_controller_or_other_sessions",
1864 directory.path(),
1865 );
1866 return;
1867 }
1868 let _writer = crate::database::install_isolated_test_writer();
1869 let mut controller = Controller {
1870 config: registration_config(),
1871 state: State::default(),
1872 };
1873 let bundle = controller.config.bundles["project"].clone();
1874 controller
1875 .config
1876 .bundles
1877 .insert("other".into(), bundle.clone());
1878 controller.config.save().unwrap();
1879 let affected = controller
1880 .register_session_with_resources(
1881 "codex",
1882 "project",
1883 "podman",
1884 "affected",
1885 launch_options(Vec::new()),
1886 )
1887 .unwrap();
1888 let healthy = controller
1889 .register_session_with_resources(
1890 "codex",
1891 "other",
1892 "podman",
1893 "healthy",
1894 launch_options(Vec::new()),
1895 )
1896 .unwrap();
1897 controller.config.bundles.remove("project");
1898 controller.config.save().unwrap();
1899 let loaded = Controller::load().unwrap();
1900 assert_eq!(loaded.state.sessions.len(), 2);
1901 assert!(
1902 loaded.state.sessions[&healthy]
1903 .configuration_issue(&loaded.config)
1904 .is_none()
1905 );
1906 let issue = loaded.reconnect_command(&affected).unwrap_err().to_string();
1907 assert!(issue.contains("missing bundle"), "{issue}");
1908 assert!(issue.contains("config.toml"), "{issue}");
1909 assert_eq!(
1911 loaded.state.sessions[&affected],
1912 controller.state.sessions[&affected]
1913 );
1914 Config::update(|config| {
1915 config.bundles.insert("project".into(), bundle);
1916 Ok(())
1917 })
1918 .unwrap();
1919 let repaired = Controller::load().unwrap();
1920 assert!(
1921 repaired.state.sessions[&affected]
1922 .configuration_issue(&repaired.config)
1923 .is_none()
1924 );
1925 }
1926
1927 const CONFIG_ID_RENAME_CHILD: &str = "MJ_TEST_CONFIG_ID_RENAME_CHILD";
1928
1929 #[test]
1930 fn configuration_id_rename_rewrites_durable_session_references() {
1931 if std::env::var_os(CONFIG_ID_RENAME_CHILD).is_none() {
1932 let directory = tempfile::tempdir().unwrap();
1933 run_registration_child(
1934 CONFIG_ID_RENAME_CHILD,
1935 "configuration_id_rename_rewrites_durable_session_references",
1936 directory.path(),
1937 );
1938 return;
1939 }
1940 let _writer = crate::database::install_isolated_test_writer();
1942
1943 let mut controller = Controller {
1944 config: registration_config(),
1945 state: State::default(),
1946 };
1947 controller.config.save().unwrap();
1948 let session_id = controller
1949 .register_session_with_resources(
1950 "codex",
1951 "project",
1952 "podman",
1953 "rename references",
1954 launch_options(Vec::new()),
1955 )
1956 .unwrap();
1957
1958 controller
1959 .rename_profile_id("codex", "codex-renamed")
1960 .unwrap();
1961 controller
1962 .rename_target_id("podman", "podman-renamed")
1963 .unwrap();
1964
1965 let loaded = Controller::load().unwrap();
1966 let session = &loaded.state.sessions[&session_id];
1967 assert_eq!(session.last_profile, "codex-renamed");
1968 assert_eq!(session.target_template_id, "podman-renamed");
1969 assert!(loaded.config.profiles.contains_key("codex-renamed"));
1970 assert!(loaded.config.targets.contains_key("podman-renamed"));
1971 assert!(!config_rename_journal_path().exists());
1972 }
1973
1974 #[test]
1975 fn a_session_the_database_rejects_is_never_left_in_memory() {
1976 if std::env::var_os(UNPERSISTABLE_SESSION_CHILD).is_none() {
1977 let directory = tempfile::tempdir().unwrap();
1978 run_registration_child(
1979 UNPERSISTABLE_SESSION_CHILD,
1980 "a_session_the_database_rejects_is_never_left_in_memory",
1981 directory.path(),
1982 );
1983 return;
1984 }
1985 let _writer = crate::database::install_isolated_test_writer();
1987
1988 let mut controller = Controller {
1989 config: registration_config(),
1990 state: State::default(),
1991 };
1992 controller
1998 .register_session_with_resources(
1999 "codex",
2000 "project",
2001 "podman",
2002 "first",
2003 launch_options(Vec::new()),
2004 )
2005 .expect("a healthy store registers a session");
2006 rusqlite::Connection::open(crate::database::database_path())
2007 .unwrap()
2008 .execute_batch("DROP TABLE sessions")
2009 .unwrap();
2010
2011 let error = controller
2012 .register_session_with_resources(
2013 "codex",
2014 "project",
2015 "podman",
2016 "unpersistable",
2017 launch_options(Vec::new()),
2018 )
2019 .expect_err("a store that rejects the write cannot register a session");
2020 assert!(
2021 format!("{error:#}").contains("sessions"),
2022 "unexpected error: {error:#}"
2023 );
2024 assert_eq!(
2025 controller.state.sessions.len(),
2026 1,
2027 "a session the database never accepted stayed in controller memory"
2028 );
2029 assert!(
2030 controller
2031 .state
2032 .sessions
2033 .values()
2034 .all(|session| session.title != "unpersistable"),
2035 "the rejected session is the one that stayed"
2036 );
2037 }
2038
2039 const MOUNT_HISTORY_FAILURE_CHILD: &str = "MJ_TEST_MOUNT_HISTORY_FAILURE_CHILD";
2040
2041 const CONTAINER_SIZE_HISTORY_CHILD: &str = "MJ_TEST_CONTAINER_SIZE_HISTORY_CHILD";
2042
2043 #[test]
2044 fn registration_remembers_launch_size_but_session_overrides_do_not_replace_it() {
2045 if std::env::var_os(CONTAINER_SIZE_HISTORY_CHILD).is_none() {
2046 let directory = tempfile::tempdir().unwrap();
2047 run_registration_child(
2048 CONTAINER_SIZE_HISTORY_CHILD,
2049 "registration_remembers_launch_size_but_session_overrides_do_not_replace_it",
2050 directory.path(),
2051 );
2052 return;
2053 }
2054 let _writer = crate::database::install_isolated_test_writer();
2056
2057 let mut controller = Controller {
2058 config: registration_config(),
2059 state: State::default(),
2060 };
2061 let mut options = launch_options(Vec::new());
2062 options.resource_allocation = Some(SessionResourceAllocation::Container {
2063 cpus: 12,
2064 memory_bytes: 48 * 1024 * 1024 * 1024,
2065 });
2066 let id = controller
2067 .register_session_with_resources("codex", "project", "podman", "sized", options)
2068 .unwrap();
2069 let expected = HostContainerSize {
2070 cpus: 12,
2071 memory_bytes: 48 * 1024 * 1024 * 1024,
2072 };
2073 assert_eq!(controller.state.container_sizes["local"], expected);
2074 assert_eq!(
2075 crate::database::load_state_migrating()
2076 .unwrap()
2077 .container_sizes["local"],
2078 expected
2079 );
2080
2081 controller
2082 .update_session_container_settings(
2083 &id,
2084 Some("2".into()),
2085 Some("4g".into()),
2086 Vec::new(),
2087 Vec::new(),
2088 )
2089 .unwrap();
2090 assert_eq!(
2091 crate::database::load_state_migrating()
2092 .unwrap()
2093 .container_sizes["local"],
2094 expected
2095 );
2096 }
2097
2098 #[test]
2099 fn a_failed_mount_history_write_does_not_fail_the_registered_session() {
2100 if std::env::var_os(MOUNT_HISTORY_FAILURE_CHILD).is_none() {
2101 let directory = tempfile::tempdir().unwrap();
2102 run_registration_child(
2103 MOUNT_HISTORY_FAILURE_CHILD,
2104 "a_failed_mount_history_write_does_not_fail_the_registered_session",
2105 directory.path(),
2106 );
2107 return;
2108 }
2109 let _writer = crate::database::install_isolated_test_writer();
2111
2112 let mut controller = Controller {
2113 config: registration_config(),
2114 state: State::default(),
2115 };
2116 controller
2118 .register_session_with_resources(
2119 "codex",
2120 "project",
2121 "podman",
2122 "first",
2123 launch_options(Vec::new()),
2124 )
2125 .expect("a healthy store registers a session");
2126 let database = crate::database::database_path();
2127 rusqlite::Connection::open(&database)
2128 .unwrap()
2129 .execute_batch("DROP TABLE mount_history")
2130 .unwrap();
2131
2132 let id = controller
2133 .register_session_with_resources(
2134 "codex",
2135 "project",
2136 "podman",
2137 "attached",
2138 launch_options(vec![AdditionalMount {
2139 source: PathBuf::from("/host/models"),
2140 destination: PathBuf::from("/mnt/models"),
2141 read_only: false,
2142 }]),
2143 )
2144 .expect("a suggestion list that cannot be written must not fail a registration");
2145
2146 let stored: i64 = rusqlite::Connection::open(&database)
2147 .unwrap()
2148 .query_row(
2149 "SELECT count(*) FROM sessions WHERE session_id = ?1",
2150 [&id],
2151 |row| row.get(0),
2152 )
2153 .unwrap();
2154 assert_eq!(stored, 1, "the registered session was not committed");
2155 assert!(
2156 controller.state.mount_history.is_empty(),
2157 "controller memory remembered mount sources the database never stored"
2158 );
2159 }
2160
2161 #[test]
2162 fn command_errors_report_the_root_cause_without_worker_wrappers() {
2163 let stderr = b"Error: restore target checkpoint failed with status 1: Error: restore repository \"bifrost\"\n\nCaused by:\n checkpoint base b41dc78 is absent from configured source\n repository may have moved\n";
2164
2165 assert_eq!(
2166 command_error_detail(stderr),
2167 "checkpoint base b41dc78 is absent from configured source\nrepository may have moved"
2168 );
2169 }
2170
2171 #[test]
2172 fn controller_store_lock_excludes_a_second_process_owner() {
2173 let directory = tempfile::tempdir().unwrap();
2174 let first = ControllerStoreGuard::acquire_at(directory.path()).unwrap();
2175 run_controller_lock_probe(directory.path(), true);
2176 drop(first);
2177 run_controller_lock_probe(directory.path(), false);
2178 }
2179 fn run_controller_lock_probe(directory: &Path, expect_locked: bool) {
2180 let output = std::process::Command::new(std::env::current_exe().unwrap())
2181 .args([
2182 "--exact",
2183 "controller::tests::controller_store_lock_subprocess_probe",
2184 "--nocapture",
2185 ])
2186 .env("MJ_CONTROLLER_LOCK_PROBE", directory)
2187 .env(
2188 "MJ_CONTROLLER_LOCK_EXPECTED",
2189 if expect_locked { "locked" } else { "available" },
2190 )
2191 .output()
2192 .unwrap();
2193 assert!(
2194 output.status.success(),
2195 "controller lock subprocess failed:\nstdout:\n{}\nstderr:\n{}",
2196 String::from_utf8_lossy(&output.stdout),
2197 String::from_utf8_lossy(&output.stderr)
2198 );
2199 }
2200 #[test]
2201 fn controller_store_lock_subprocess_probe() {
2202 let Some(directory) = std::env::var_os("MJ_CONTROLLER_LOCK_PROBE") else {
2203 return;
2204 };
2205 let expected = std::env::var("MJ_CONTROLLER_LOCK_EXPECTED").unwrap();
2206 let acquired = ControllerStoreGuard::acquire_at(Path::new(&directory));
2207 match expected.as_str() {
2208 "locked" => {
2209 let error = acquired.expect_err("a second process acquired the controller store");
2210 assert!(error.to_string().contains("another Mjolnir controller"));
2211 }
2212 "available" => {
2213 acquired.expect("released controller store stayed locked");
2214 }
2215 value => panic!("unexpected lock probe expectation {value:?}"),
2216 }
2217 }
2218 #[test]
2219 fn local_mount_source_must_be_an_existing_directory() {
2220 let directory = tempfile::tempdir().unwrap();
2221 let file = directory.path().join("file");
2222 std::fs::write(&file, "not a directory").unwrap();
2223 let mut config = Config::default();
2224 config.targets.insert(
2225 "local".into(),
2226 TargetTemplate::LocalPodman {
2227 container: ConfigContainer {
2228 image: "ubuntu:24.04".into(),
2229 pull_policy: Default::default(),
2230 platform: None,
2231 cpus: None,
2232 memory: None,
2233 environment: BTreeMap::new(),
2234 workspace_storage: Default::default(),
2235 },
2236 },
2237 );
2238 let controller = Controller {
2239 config,
2240 state: State::default(),
2241 };
2242
2243 assert!(
2244 controller
2245 .validate_mount_source("local", directory.path(), &ProcessExecutor)
2246 .is_ok()
2247 );
2248 for invalid in [file, directory.path().join("missing")] {
2249 let error = controller
2250 .validate_mount_source("local", &invalid, &ProcessExecutor)
2251 .unwrap_err();
2252 assert!(
2253 error
2254 .to_string()
2255 .contains("does not exist or is not a directory")
2256 );
2257 }
2258 }
2259}