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