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