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