1mod backend;
4mod checkpoint;
5mod git_cache;
6mod lifecycle;
7mod provisioning;
8mod readiness;
9mod recovery_scan;
10mod resume;
11mod reviewer;
12#[cfg(test)]
13mod test_support;
14mod worker_binary;
15mod worker_restart;
16mod worktree;
17
18use std::collections::BTreeMap;
19use std::fs::{self, File, OpenOptions};
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result, bail, ensure};
23use chrono::Utc;
24
25use hel::hel_config::{
26 HelConfig, SshConnection, TargetTemplate, atomic_write, config_path, container_size_host,
27 data_dir, is_bare_project_target, mount_history_host,
28};
29
30const CONFIG_RENAME_JOURNAL: &str = "config-rename.json";
31
32#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "snake_case")]
34enum ConfigRenameKind {
35 Profile,
36 Target,
37}
38
39#[derive(Debug, serde::Serialize, serde::Deserialize)]
40#[serde(deny_unknown_fields)]
41struct ConfigRenameJournal {
42 kind: ConfigRenameKind,
43 old_id: String,
44 new_id: String,
45}
46use hel::hel_local_git::dirty_local_repositories;
47use hel::hel_state::{
48 HelState, HostContainerSize, SessionRecord, SessionResourceAllocation, SessionState,
49 new_session_id, normalize_session_title,
50};
51use hel::hel_targets::{
52 self, AdditionalMount, CommandExecutor, CommandOutput, CommandSpec, SshTarget,
53};
54
55pub(crate) use backend::controller_github_token;
56pub use backend::image_refresh_plan;
57use backend::validate_resource_allocation;
58use provisioning::apply_failed_new_session_rollback;
59pub(crate) use worker_binary::refresh_remote_worker_binary_if_stale;
60
61pub use checkpoint::{
62 CheckpointArtifact, CheckpointDeferred, checkpoint_was_deferred,
63 reconcile_managed_checkpoint_archives,
64};
65pub use recovery_scan::{RecoveryCandidate, RecoveryScan};
66pub use resume::{
67 ResumeRepositorySourceMismatch, ResumeRepositorySourcePreflight, ResumeRepositorySourceReceipt,
68};
69pub use worker_binary::{WorkerBinaryAvailability, worker_binary_prerequisite_for_arch};
70pub use worker_restart::WorkerUpgradeOutcome;
71pub use worktree::{ResumePlan, resume_compatibility};
72
73pub struct Controller {
74 pub config: HelConfig,
75 pub state: HelState,
76}
77
78#[derive(Debug)]
82pub struct ControllerStoreGuard {
83 file: File,
84}
85
86impl ControllerStoreGuard {
87 pub fn acquire() -> Result<Self> {
88 let directory = data_dir();
89 Self::acquire_at(&directory)
90 }
91
92 fn acquire_at(directory: &Path) -> Result<Self> {
93 std::fs::create_dir_all(directory)
94 .with_context(|| format!("create controller data directory {}", directory.display()))?;
95 let path = directory.join("controller.lock");
96 let mut options = OpenOptions::new();
97 options.create(true).read(true).write(true);
98 #[cfg(unix)]
99 {
100 use std::os::unix::fs::OpenOptionsExt;
101 options.mode(0o600);
102 }
103 let file = options
104 .open(&path)
105 .with_context(|| format!("open controller lock {}", path.display()))?;
106 match file.try_lock() {
107 Ok(()) => {}
108 Err(std::fs::TryLockError::WouldBlock) => bail!(
109 "another Mjolnir controller is already using {}; stop it before starting this command",
110 directory.display()
111 ),
112 Err(std::fs::TryLockError::Error(error)) => {
113 return Err(error)
114 .with_context(|| format!("lock controller store {}", directory.display()));
115 }
116 }
117 Ok(Self { file })
118 }
119
120 pub fn start_database_writer(&self) -> Result<hel::hel_database::DatabaseWriterOwner> {
123 hel::hel_database::start_database_writer()
124 }
125}
126
127impl Drop for ControllerStoreGuard {
128 fn drop(&mut self) {
129 let _ = self.file.unlock();
132 }
133}
134
135pub struct SessionLaunchOptions {
136 pub workspace_id: String,
137 pub additional_mounts: Vec<AdditionalMount>,
138 pub allow_dirty_local: bool,
139 pub resource_allocation: Option<SessionResourceAllocation>,
140 pub project_directory: Option<PathBuf>,
141 pub session_title_override: Option<String>,
142}
143
144pub struct SessionResumeOptions {
145 pub additional_mounts: Option<Vec<AdditionalMount>>,
146 pub resource_allocation: Option<SessionResourceAllocation>,
147 pub discard_queue: bool,
148}
149
150fn selected_host_container_size(
151 template: &TargetTemplate,
152 allocation: Option<&SessionResourceAllocation>,
153) -> Option<(String, HostContainerSize)> {
154 let host = container_size_host(template)?;
155 let SessionResourceAllocation::Container { cpus, memory_bytes } = allocation? else {
156 return None;
157 };
158 Some((
159 host.to_owned(),
160 HostContainerSize {
161 cpus: *cpus,
162 memory_bytes: *memory_bytes,
163 },
164 ))
165}
166
167impl Controller {
168 pub fn load() -> Result<Self> {
169 let config = HelConfig::load()?;
170 let state = HelState::load()?;
171 state.validate_against_config(&config)?;
172 Ok(Self { config, state })
173 }
174
175 pub fn reload(&mut self) -> Result<()> {
176 *self = Self::load()?;
177 Ok(())
178 }
179
180 fn persist_session_state(&self, session_id: &str) -> Result<()> {
181 match self.state.sessions.get(session_id) {
182 Some(session) => hel::hel_database::save_lifecycle_session(session),
183 None => hel::hel_database::delete_session(session_id),
184 }
185 }
186
187 fn persist_session_transition_or_restore(
188 &mut self,
189 session_id: &str,
190 previous: &SessionRecord,
191 context: &'static str,
192 ) -> Result<()> {
193 persist_session_record_transition_or_restore(
194 &mut self.state,
195 session_id,
196 previous,
197 context,
198 &hel::hel_database::save_lifecycle_session,
199 )
200 }
201
202 fn restore_prior_session_after_persistence_failure(
203 &mut self,
204 session_id: &str,
205 previous: &SessionRecord,
206 primary: anyhow::Error,
207 ) -> anyhow::Error {
208 restore_session_after_persistence_failure(
209 &mut self.state,
210 session_id,
211 previous,
212 primary,
213 hel::hel_database::save_lifecycle_session,
214 )
215 }
216
217 pub fn complete_mount_source(
219 &self,
220 target_id: &str,
221 prefix: &str,
222 executor: &impl CommandExecutor,
223 ) -> Result<Vec<String>> {
224 let target = self
225 .config
226 .targets
227 .get(target_id)
228 .with_context(|| format!("unknown target template {target_id:?}"))?;
229 match target {
230 TargetTemplate::LocalPodman { .. }
231 | TargetTemplate::LocalDocker { .. }
232 | TargetTemplate::AppleContainer { .. }
233 | TargetTemplate::AwsEc2 { .. } => Ok(hel_targets::local_directory_completions(prefix)),
234 TargetTemplate::SshPodman { ssh, .. } => {
235 hel_targets::ssh_directory_completions(&backend_ssh(ssh), prefix, executor)
236 }
237 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
238 bail!("resource path completion is unsupported for bare targets")
239 }
240 }
241 }
242
243 pub fn validate_mount_source(
250 &self,
251 target_id: &str,
252 source: &Path,
253 executor: &impl CommandExecutor,
254 ) -> Result<Option<String>> {
255 let target = self
256 .config
257 .targets
258 .get(target_id)
259 .with_context(|| format!("unknown target template {target_id:?}"))?;
260 let exists = match target {
261 TargetTemplate::LocalPodman { .. }
262 | TargetTemplate::LocalDocker { .. }
263 | TargetTemplate::AppleContainer { .. }
264 | TargetTemplate::AwsEc2 { .. } => std::fs::metadata(source)
265 .map(|metadata| metadata.is_dir())
266 .or_else(|error| {
267 if error.kind() == std::io::ErrorKind::NotFound {
268 Ok(false)
269 } else {
270 Err(error)
271 }
272 })
273 .with_context(|| format!("inspect resource source {}", source.display()))?,
274 TargetTemplate::SshPodman { ssh, .. } => {
275 hel_targets::ssh_directory_exists(&backend_ssh(ssh), source, executor)?
276 }
277 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
278 bail!("resource attachments are unsupported for bare targets")
279 }
280 };
281 ensure!(
282 exists,
283 "source path {} does not exist or is not a directory",
284 source.display()
285 );
286 Ok(self.forced_read_only_reason(target, source, executor))
287 }
288
289 fn forced_read_only_reason(
291 &self,
292 target: &TargetTemplate,
293 source: &Path,
294 executor: &impl CommandExecutor,
295 ) -> Option<String> {
296 let ssh = match target {
297 TargetTemplate::LocalPodman { .. } | TargetTemplate::LocalDocker { .. } => None,
298 TargetTemplate::SshPodman { ssh, .. } => Some(backend_ssh(ssh)),
299 _ => return None,
302 };
303 let filesystem = hel_targets::probe_filesystem_types(
304 ssh.as_ref(),
305 std::slice::from_ref(&source.to_path_buf()),
306 executor,
307 )
308 .map_err(|error| {
309 tracing::debug!(
310 source = %source.display(),
311 error = format!("{error:#}"),
312 "could not probe the filesystem under a mount source"
313 );
314 })
315 .ok()?
316 .pop()?;
317 let reason = hel_targets::overlay_unsupported_filesystem(&filesystem)?;
318 Some(format!("{filesystem} ({reason})"))
319 }
320
321 fn fail_new_session_with_cleanup(
322 &mut self,
323 session_id: &str,
324 error: anyhow::Error,
325 executor: &impl CommandExecutor,
326 ) -> Result<anyhow::Error> {
327 let original = format!("{error:#}");
328 let cleanup_error = self
329 .cleanup_new_session_worktree_after_failure(session_id, executor)
330 .err()
331 .map(|cleanup_error| format!("{cleanup_error:#}"));
332 if let Some(cleanup_error) = &cleanup_error {
333 tracing::warn!(
334 session_id,
335 error = %cleanup_error,
336 "new-session worktree rollback reported a cleanup failure"
337 );
338 }
339 let failure = apply_failed_new_session_rollback(
340 &mut self.state,
341 session_id,
342 &original,
343 cleanup_error,
344 );
345 self.persist_session_state(session_id)?;
346 Ok(failure)
347 }
348
349 pub fn register_session_with_resources(
350 &mut self,
351 profile_id: &str,
352 bundle_id: &str,
353 target_id: &str,
354 title: impl Into<String>,
355 options: SessionLaunchOptions,
356 ) -> Result<String> {
357 let SessionLaunchOptions {
358 workspace_id,
359 additional_mounts,
360 allow_dirty_local,
361 resource_allocation,
362 project_directory,
363 session_title_override,
364 } = options;
365 let session_title_override = match session_title_override {
366 Some(title) => {
367 Some(normalize_session_title(&title).context("session name cannot be empty")?)
368 }
369 None => None,
370 };
371 let profile = self
372 .config
373 .profiles
374 .get(profile_id)
375 .with_context(|| format!("unknown profile {profile_id:?}"))?;
376 let template = self
377 .config
378 .targets
379 .get(target_id)
380 .with_context(|| format!("unknown target template {target_id:?}"))?;
381 if project_directory.is_some() != is_bare_project_target(template) {
382 bail!("raw project directories require a bare target, and bare targets require one");
383 }
384 if let Some(path) = &project_directory
385 && (!path.is_absolute()
386 || path
387 .components()
388 .any(|part| part == std::path::Component::ParentDir))
389 {
390 bail!("bare project directory must be an absolute safe path");
391 }
392 let bundle = project_directory
393 .is_none()
394 .then(|| self.config.bundles.get(bundle_id))
395 .flatten();
396 if project_directory.is_none() && bundle.is_none() {
397 bail!("unknown bundle {bundle_id:?}");
398 }
399 if profile.kind == hel::hel_config::HarnessKind::Deepseek
400 && (!additional_mounts.is_empty()
401 || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
402 {
403 bail!(
404 "DeepSeek Harness ACP supports one workspace root; use a single-repository bundle without attached directories"
405 );
406 }
407 let dirty = bundle
408 .map(dirty_local_repositories)
409 .transpose()?
410 .unwrap_or_default();
411 if !allow_dirty_local && !dirty.is_empty() {
412 let repositories = dirty
413 .iter()
414 .map(|repository| format!("{} ({})", repository.path.display(), repository.summary))
415 .collect::<Vec<_>>()
416 .join(", ");
417 bail!(
418 "local repositories have uncommitted changes: {repositories}; explicit confirmation is required"
419 );
420 }
421 validate_resource_allocation(template, resource_allocation.as_ref())?;
422 let selected_container_size =
423 selected_host_container_size(template, resource_allocation.as_ref());
424 if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
425 bail!("attached resources are unsupported for this target");
426 }
427 hel_targets::validate_additional_mounts(&additional_mounts)?;
428 let id = new_session_id()?;
429 let now = now();
430 let record = SessionRecord {
431 archived: false,
432 container_cpus: None,
433 container_memory: None,
434 id: id.clone(),
435 workspace_id,
436 title: title.into(),
437 harness_kind: profile.kind,
438 last_profile: profile_id.to_string(),
439 bundle_id: bundle_id.to_string(),
440 project_directory,
441 managed_worktree: None,
442 target_template_id: target_id.to_string(),
443 resource_allocation,
444 additional_mounts: additional_mounts.clone(),
445 state: SessionState::Provisioning,
446 target: None,
447 native_session_id: None,
448 acp_session_title: None,
449 session_title_override,
450 created_at: now.clone(),
451 updated_at: now,
452 viewed_through_event_ordinal: 0,
453 draft_input: String::new(),
454 last_error: None,
455 last_checkpoint_error: None,
456 checkpoint: None,
457 };
458 if let Some((host, size)) = selected_container_size.as_ref() {
462 hel::hel_database::save_session_with_container_size(&record, host, *size)?;
463 } else {
464 hel::hel_database::save_session(&record)?;
465 }
466 self.state.sessions.insert(id.clone(), record);
467 if let Some((host, size)) = selected_container_size {
468 self.state.remember_container_size(&host, size);
469 }
470 if let Some(host) = mount_history_host(template) {
471 match hel::hel_database::remember_mount_sources(host, &additional_mounts) {
475 Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
476 Err(error) => tracing::warn!(
477 session_id = id,
478 error = format!("{error:#}"),
479 "could not remember the attached resource directories for later suggestions"
480 ),
481 }
482 }
483 Ok(id)
484 }
485
486 pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
487 let title = normalize_session_title(title).context("session name cannot be empty")?;
488 ensure!(
489 self.state.sessions.contains_key(session_id),
490 "unknown session {session_id}"
491 );
492 let updated_at = now();
493 hel::hel_database::set_session_title_override(session_id, &title, &updated_at)?;
494 let record = self
495 .state
496 .sessions
497 .get_mut(session_id)
498 .expect("session was checked before updating its title");
499 record.session_title_override = Some(title.clone());
500 record.updated_at = updated_at;
501 Ok(title)
502 }
503
504 pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
505 hel::hel_config::validate_id("profile", new_id)?;
506 ensure!(
507 self.config.profiles.contains_key(old_id),
508 "unknown profile {old_id:?}"
509 );
510 ensure!(
511 old_id == new_id || !self.config.profiles.contains_key(new_id),
512 "profile {new_id:?} already exists"
513 );
514 if old_id == new_id {
515 return Ok(());
516 }
517 let journal = ConfigRenameJournal {
518 kind: ConfigRenameKind::Profile,
519 old_id: old_id.to_owned(),
520 new_id: new_id.to_owned(),
521 };
522 write_config_rename_journal(&journal)?;
523 let previous = self.config.clone();
524 let profile = self
525 .config
526 .profiles
527 .remove(old_id)
528 .expect("profile checked");
529 self.config.profiles.insert(new_id.to_owned(), profile);
530 if let Err(error) = self.config.save() {
531 self.config = previous;
532 remove_config_rename_journal()
533 .context("remove profile rename journal after config save failed")?;
534 return Err(error).context("save renamed profile configuration");
535 }
536 hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
537 if let Err(error) = hel::hel_database::rename_profile_references(old_id, new_id) {
538 if let Err(restore_error) = previous.save() {
539 return Err(error).context(format!(
540 "rename profile references; additionally failed to restore config: {restore_error:#}"
541 ));
542 }
543 self.config = previous;
544 remove_config_rename_journal()?;
545 return Err(error).context("rename profile references");
546 }
547 for session in self.state.sessions.values_mut() {
548 if session.last_profile == old_id {
549 session.last_profile = new_id.to_owned();
550 }
551 }
552 remove_config_rename_journal()?;
553 Ok(())
554 }
555
556 pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
557 hel::hel_config::validate_id("target template", new_id)?;
558 ensure!(
559 self.config.targets.contains_key(old_id),
560 "unknown target {old_id:?}"
561 );
562 ensure!(
563 old_id == new_id || !self.config.targets.contains_key(new_id),
564 "target {new_id:?} already exists"
565 );
566 if old_id == new_id {
567 return Ok(());
568 }
569 let journal = ConfigRenameJournal {
570 kind: ConfigRenameKind::Target,
571 old_id: old_id.to_owned(),
572 new_id: new_id.to_owned(),
573 };
574 write_config_rename_journal(&journal)?;
575 let previous = self.config.clone();
576 let target = self.config.targets.remove(old_id).expect("target checked");
577 self.config.targets.insert(new_id.to_owned(), target);
578 if let Err(error) = self.config.save() {
579 self.config = previous;
580 remove_config_rename_journal()
581 .context("remove target rename journal after config save failed")?;
582 return Err(error).context("save renamed target configuration");
583 }
584 hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
585 if let Err(error) = hel::hel_database::rename_target_references(old_id, new_id) {
586 if let Err(restore_error) = previous.save() {
587 return Err(error).context(format!(
588 "rename target references; additionally failed to restore config: {restore_error:#}"
589 ));
590 }
591 self.config = previous;
592 remove_config_rename_journal()?;
593 return Err(error).context("rename target references");
594 }
595 for session in self.state.sessions.values_mut() {
596 if session.target_template_id == old_id {
597 session.target_template_id = new_id.to_owned();
598 }
599 }
600 remove_config_rename_journal()?;
601 Ok(())
602 }
603
604 pub fn recover_config_id_rename() -> Result<bool> {
608 let path = config_rename_journal_path();
609 let body = match fs::read(&path) {
610 Ok(body) => body,
611 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
612 Err(error) => return Err(error).context(format!("read {}", path.display())),
613 };
614 let journal: ConfigRenameJournal =
615 serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
616 let mut config = HelConfig::load_from(&config_path())?;
617 match journal.kind {
618 ConfigRenameKind::Profile => {
619 finish_config_map_rename(
620 &mut config.profiles,
621 &journal.old_id,
622 &journal.new_id,
623 "profile",
624 )?;
625 config.save()?;
626 hel::hel_database::rename_profile_references(&journal.old_id, &journal.new_id)?;
627 }
628 ConfigRenameKind::Target => {
629 finish_config_map_rename(
630 &mut config.targets,
631 &journal.old_id,
632 &journal.new_id,
633 "target",
634 )?;
635 config.save()?;
636 hel::hel_database::rename_target_references(&journal.old_id, &journal.new_id)?;
637 }
638 }
639 remove_config_rename_journal()?;
640 Ok(true)
641 }
642
643 pub fn update_session_container_settings(
647 &mut self,
648 session_id: &str,
649 cpus: Option<String>,
650 memory: Option<String>,
651 additional_mounts: Vec<hel_targets::AdditionalMount>,
652 mount_history: Vec<std::path::PathBuf>,
653 ) -> Result<()> {
654 ensure!(
655 self.state.sessions.contains_key(session_id),
656 "unknown session {session_id}"
657 );
658 let cpus = cpus.filter(|value| !value.trim().is_empty());
659 let memory = memory.filter(|value| !value.trim().is_empty());
660 let updated_at = now();
661 hel::hel_database::set_session_container_settings(
662 session_id,
663 cpus.as_deref(),
664 memory.as_deref(),
665 &additional_mounts,
666 &updated_at,
667 )?;
668 if let Some(host) = self
669 .config
670 .targets
671 .get(
672 &self.state.sessions[session_id]
673 .target_template_id
674 .to_owned(),
675 )
676 .and_then(hel::hel_config::mount_history_host)
677 {
678 let host = host.to_owned();
679 hel::hel_database::replace_mount_history(&host, &mount_history)?;
682 hel::hel_database::remember_mount_sources(&host, &additional_mounts)?;
683 self.state.mount_history.insert(host.clone(), mount_history);
684 self.state.remember_mount_sources(&host, &additional_mounts);
685 }
686 let record = self
687 .state
688 .sessions
689 .get_mut(session_id)
690 .expect("session was checked before updating its container settings");
691 record.container_cpus = cpus;
692 record.container_memory = memory;
693 record.additional_mounts = additional_mounts;
694 record.updated_at = updated_at;
695 Ok(())
696 }
697}
698
699fn config_rename_journal_path() -> PathBuf {
700 data_dir().join(CONFIG_RENAME_JOURNAL)
701}
702
703fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
704 let path = config_rename_journal_path();
705 let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
706 atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
707}
708
709fn remove_config_rename_journal() -> Result<()> {
710 let path = config_rename_journal_path();
711 match fs::remove_file(&path) {
712 Ok(()) => Ok(()),
713 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
714 Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
715 }
716}
717
718fn finish_config_map_rename<T>(
719 entries: &mut BTreeMap<String, T>,
720 old_id: &str,
721 new_id: &str,
722 kind: &str,
723) -> Result<()> {
724 if let Some(entry) = entries.remove(old_id) {
725 ensure!(
726 !entries.contains_key(new_id),
727 "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
728 );
729 entries.insert(new_id.to_owned(), entry);
730 } else {
731 ensure!(
732 entries.contains_key(new_id),
733 "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
734 );
735 }
736 Ok(())
737}
738
739fn target_kind(locator: &hel_targets::TargetLocator) -> &'static str {
740 match locator {
741 hel_targets::TargetLocator::LocalBare { .. } => "local-bare",
742 hel_targets::TargetLocator::LocalPodman { .. } => "local-podman",
743 hel_targets::TargetLocator::LocalDocker { .. } => "local-docker",
744 hel_targets::TargetLocator::AppleContainer { .. } => "apple-container",
745 hel_targets::TargetLocator::AwsEc2 { .. } => "aws-ec2",
746 hel_targets::TargetLocator::SshBare { .. } => "ssh-bare",
747 hel_targets::TargetLocator::SshPodman { .. } => "ssh-podman",
748 }
749}
750
751fn target_profile_home(
752 locator: &hel_targets::TargetLocator,
753 session_id: &str,
754 profile: &hel::hel_config::HarnessProfile,
755) -> String {
756 match locator {
757 hel_targets::TargetLocator::LocalBare { .. } => profile.home.to_string_lossy().into_owned(),
758 hel_targets::TargetLocator::LocalPodman { .. }
759 | hel_targets::TargetLocator::LocalDocker { .. }
760 | hel_targets::TargetLocator::AppleContainer { .. }
761 | hel_targets::TargetLocator::SshPodman { .. } => {
762 format!("/var/lib/hel/profiles/{session_id}")
763 }
764 hel_targets::TargetLocator::AwsEc2 { .. } | hel_targets::TargetLocator::SshBare { .. } => {
765 format!(".local/share/hel/profiles/{session_id}")
766 }
767 }
768}
769
770pub(crate) fn backend_ssh(ssh: &SshConnection) -> SshTarget {
771 let destination = match &ssh.user {
772 Some(user) => format!("{user}@{}", ssh.host),
773 None => ssh.host.clone(),
774 };
775 SshTarget {
776 destination,
777 ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
778 }
779}
780
781fn ssh_command_spec(
782 ssh: &SshTarget,
783 args: impl IntoIterator<Item = impl AsRef<str>>,
784) -> CommandSpec {
785 let remote = args
786 .into_iter()
787 .map(|arg| arg.as_ref().to_string())
788 .collect::<Vec<_>>();
789 let mut command_args = ssh.ssh_args.clone();
790 command_args.push(ssh.destination.clone());
791 command_args.push(hel_targets::join_remote_command(&remote));
792 CommandSpec::new("ssh", command_args)
793}
794
795fn scp_command_spec(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
796 let mut args = ssh.ssh_args.clone();
797 if recursive {
798 args.push("-r".into());
799 }
800 args.push(source.to_string_lossy().into_owned());
801 args.push(format!("{}:{remote}", ssh.destination));
802 CommandSpec::new("scp", args)
803}
804
805fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
806 let mut result = vec![
812 "-o".into(),
813 "BatchMode=yes".into(),
814 "-o".into(),
815 "StrictHostKeyChecking=accept-new".into(),
816 "-o".into(),
817 "ConnectTimeout=15".into(),
818 ];
819 result.extend(args.iter().cloned());
820 if let Some(identity) = identity {
821 result.push("-i".into());
822 result.push(identity.to_string_lossy().into_owned());
823 }
824 result
825}
826
827fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
828 let output = executor.execute(&command)?;
829 if output.status != 0 {
830 let detail = command_error_detail(&output.stderr);
831 if detail.is_empty() {
832 bail!("{} failed with status {}", command.purpose, output.status);
833 }
834 bail!("{detail}");
835 }
836 Ok(output)
837}
838
839fn command_error_detail(stderr: &[u8]) -> String {
840 let reported = String::from_utf8_lossy(stderr);
841 let reported = reported.trim();
842 let detail = reported
843 .rsplit_once("\nCaused by:\n")
844 .map_or(reported, |(_, causes)| causes);
845 let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
846 detail
847 .lines()
848 .map(|line| line.strip_prefix(" ").unwrap_or(line))
849 .collect::<Vec<_>>()
850 .join("\n")
851 .trim()
852 .to_owned()
853}
854
855fn now() -> String {
856 Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
857}
858
859fn restore_session_after_persistence_failure(
860 state: &mut HelState,
861 session_id: &str,
862 previous: &SessionRecord,
863 primary: anyhow::Error,
864 persist: impl FnOnce(&SessionRecord) -> Result<()>,
865) -> anyhow::Error {
866 state
867 .sessions
868 .insert(session_id.to_owned(), previous.clone());
869 let restored = state
870 .sessions
871 .get(session_id)
872 .expect("restored session record disappeared");
873 match persist(restored) {
874 Ok(()) => primary,
875 Err(error) => primary.context(format!(
876 "restored prior session state in memory, but failed to persist the rollback: {error:#}"
877 )),
878 }
879}
880
881fn persist_session_record_transition_or_restore(
882 state: &mut HelState,
883 session_id: &str,
884 previous: &SessionRecord,
885 context: &'static str,
886 persist: &impl Fn(&SessionRecord) -> Result<()>,
887) -> Result<()> {
888 let result = persist(
889 state
890 .sessions
891 .get(session_id)
892 .expect("checkpoint session disappeared before persistence"),
893 );
894 match result {
895 Ok(()) => Ok(()),
896 Err(error) => Err(restore_session_after_persistence_failure(
897 state,
898 session_id,
899 previous,
900 error.context(context),
901 persist,
902 )),
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use std::collections::BTreeMap;
909 use std::path::Path;
910
911 use hel::hel_config::{
912 ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, HelConfig,
913 ProjectBundle, ProjectRepository, TargetTemplate,
914 };
915 use hel::hel_state::HelState;
916 use hel::hel_targets::ProcessExecutor;
917
918 use super::*;
919
920 fn registration_config() -> HelConfig {
923 let mut config = HelConfig::default();
924 config.profiles.insert(
925 "codex".into(),
926 HarnessProfile {
927 kind: HarnessKind::Codex,
928 home: PathBuf::from("/home/dev/.codex"),
929 executable: None,
930 environment: BTreeMap::new(),
931 context_window_bytes: None,
932 },
933 );
934 config.bundles.insert(
935 "project".into(),
936 ProjectBundle {
937 primary_repo: "project".into(),
938 repositories: vec![ProjectRepository {
939 id: "project".into(),
940 github: Some("owner/project".into()),
941 local: None,
942 destination: PathBuf::from("project"),
943 git_ref: None,
944 }],
945 },
946 );
947 config.targets.insert(
948 "podman".into(),
949 TargetTemplate::LocalPodman {
950 container: ConfigContainer {
951 image: "example.invalid/hel-test:latest".into(),
952 pull_policy: Default::default(),
953 platform: None,
954 cpus: None,
955 memory: None,
956 environment: BTreeMap::new(),
957 workspace_storage: Default::default(),
958 },
959 },
960 );
961 config
962 }
963
964 fn launch_options(additional_mounts: Vec<AdditionalMount>) -> SessionLaunchOptions {
965 SessionLaunchOptions {
966 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
967 additional_mounts,
968 allow_dirty_local: false,
969 resource_allocation: None,
970 project_directory: None,
971 session_title_override: None,
972 }
973 }
974
975 #[test]
976 fn deepseek_registration_rejects_more_than_one_workspace_root_before_persisting() {
977 let mut config = registration_config();
978 config.profiles.get_mut("codex").unwrap().kind = HarnessKind::Deepseek;
979 let second = config.bundles["project"].repositories[0].clone();
980 config
981 .bundles
982 .get_mut("project")
983 .unwrap()
984 .repositories
985 .push(hel::hel_config::ProjectRepository {
986 id: "second".into(),
987 destination: "second".into(),
988 ..second
989 });
990 let mut controller = Controller {
991 config,
992 state: HelState::default(),
993 };
994
995 let error = controller
996 .register_session_with_resources(
997 "codex",
998 "project",
999 "podman",
1000 "unsupported",
1001 launch_options(Vec::new()),
1002 )
1003 .unwrap_err();
1004
1005 assert!(error.to_string().contains("one workspace root"));
1006 assert!(controller.state.sessions.is_empty());
1007 }
1008
1009 fn run_registration_child(marker: &str, test: &str, data_directory: &Path) {
1012 let output = std::process::Command::new(std::env::current_exe().unwrap())
1013 .args([
1014 "--exact",
1015 &format!("hel_controller::tests::{test}"),
1016 "--nocapture",
1017 ])
1018 .env(marker, "1")
1019 .env("MJ_DATA_DIR", data_directory)
1020 .env("MJ_CONFIG_DIR", data_directory)
1021 .output()
1022 .unwrap();
1023 assert!(
1024 output.status.success(),
1025 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1026 String::from_utf8_lossy(&output.stdout),
1027 String::from_utf8_lossy(&output.stderr)
1028 );
1029 }
1030
1031 const UNPERSISTABLE_SESSION_CHILD: &str = "MJ_TEST_UNPERSISTABLE_SESSION_CHILD";
1032
1033 const CONFIG_ID_RENAME_CHILD: &str = "MJ_TEST_CONFIG_ID_RENAME_CHILD";
1034
1035 #[test]
1036 fn configuration_id_rename_rewrites_durable_session_references() {
1037 if std::env::var_os(CONFIG_ID_RENAME_CHILD).is_none() {
1038 let directory = tempfile::tempdir().unwrap();
1039 run_registration_child(
1040 CONFIG_ID_RENAME_CHILD,
1041 "configuration_id_rename_rewrites_durable_session_references",
1042 directory.path(),
1043 );
1044 return;
1045 }
1046 let _writer = hel::hel_database::install_isolated_test_writer();
1048
1049 let mut controller = Controller {
1050 config: registration_config(),
1051 state: HelState::default(),
1052 };
1053 controller.config.save().unwrap();
1054 let session_id = controller
1055 .register_session_with_resources(
1056 "codex",
1057 "project",
1058 "podman",
1059 "rename references",
1060 launch_options(Vec::new()),
1061 )
1062 .unwrap();
1063
1064 controller
1065 .rename_profile_id("codex", "codex-renamed")
1066 .unwrap();
1067 controller
1068 .rename_target_id("podman", "podman-renamed")
1069 .unwrap();
1070
1071 let loaded = Controller::load().unwrap();
1072 let session = &loaded.state.sessions[&session_id];
1073 assert_eq!(session.last_profile, "codex-renamed");
1074 assert_eq!(session.target_template_id, "podman-renamed");
1075 assert!(loaded.config.profiles.contains_key("codex-renamed"));
1076 assert!(loaded.config.targets.contains_key("podman-renamed"));
1077 assert!(!config_rename_journal_path().exists());
1078 }
1079
1080 #[test]
1081 fn a_session_the_database_rejects_is_never_left_in_memory() {
1082 if std::env::var_os(UNPERSISTABLE_SESSION_CHILD).is_none() {
1083 let directory = tempfile::tempdir().unwrap();
1084 run_registration_child(
1085 UNPERSISTABLE_SESSION_CHILD,
1086 "a_session_the_database_rejects_is_never_left_in_memory",
1087 directory.path(),
1088 );
1089 return;
1090 }
1091 let _writer = hel::hel_database::install_isolated_test_writer();
1093
1094 let mut controller = Controller {
1095 config: registration_config(),
1096 state: HelState::default(),
1097 };
1098 controller
1104 .register_session_with_resources(
1105 "codex",
1106 "project",
1107 "podman",
1108 "first",
1109 launch_options(Vec::new()),
1110 )
1111 .expect("a healthy store registers a session");
1112 rusqlite::Connection::open(hel::hel_database::database_path())
1113 .unwrap()
1114 .execute_batch("DROP TABLE sessions")
1115 .unwrap();
1116
1117 let error = controller
1118 .register_session_with_resources(
1119 "codex",
1120 "project",
1121 "podman",
1122 "unpersistable",
1123 launch_options(Vec::new()),
1124 )
1125 .expect_err("a store that rejects the write cannot register a session");
1126 assert!(
1127 format!("{error:#}").contains("sessions"),
1128 "unexpected error: {error:#}"
1129 );
1130 assert_eq!(
1131 controller.state.sessions.len(),
1132 1,
1133 "a session the database never accepted stayed in controller memory"
1134 );
1135 assert!(
1136 controller
1137 .state
1138 .sessions
1139 .values()
1140 .all(|session| session.title != "unpersistable"),
1141 "the rejected session is the one that stayed"
1142 );
1143 }
1144
1145 const MOUNT_HISTORY_FAILURE_CHILD: &str = "MJ_TEST_MOUNT_HISTORY_FAILURE_CHILD";
1146
1147 const CONTAINER_SIZE_HISTORY_CHILD: &str = "MJ_TEST_CONTAINER_SIZE_HISTORY_CHILD";
1148
1149 #[test]
1150 fn registration_remembers_launch_size_but_session_overrides_do_not_replace_it() {
1151 if std::env::var_os(CONTAINER_SIZE_HISTORY_CHILD).is_none() {
1152 let directory = tempfile::tempdir().unwrap();
1153 run_registration_child(
1154 CONTAINER_SIZE_HISTORY_CHILD,
1155 "registration_remembers_launch_size_but_session_overrides_do_not_replace_it",
1156 directory.path(),
1157 );
1158 return;
1159 }
1160 let _writer = hel::hel_database::install_isolated_test_writer();
1162
1163 let mut controller = Controller {
1164 config: registration_config(),
1165 state: HelState::default(),
1166 };
1167 let mut options = launch_options(Vec::new());
1168 options.resource_allocation = Some(SessionResourceAllocation::Container {
1169 cpus: 12,
1170 memory_bytes: 48 * 1024 * 1024 * 1024,
1171 });
1172 let id = controller
1173 .register_session_with_resources("codex", "project", "podman", "sized", options)
1174 .unwrap();
1175 let expected = HostContainerSize {
1176 cpus: 12,
1177 memory_bytes: 48 * 1024 * 1024 * 1024,
1178 };
1179 assert_eq!(controller.state.container_sizes["local"], expected);
1180 assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1181
1182 controller
1183 .update_session_container_settings(
1184 &id,
1185 Some("2".into()),
1186 Some("4g".into()),
1187 Vec::new(),
1188 Vec::new(),
1189 )
1190 .unwrap();
1191 assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1192 }
1193
1194 #[test]
1195 fn a_failed_mount_history_write_does_not_fail_the_registered_session() {
1196 if std::env::var_os(MOUNT_HISTORY_FAILURE_CHILD).is_none() {
1197 let directory = tempfile::tempdir().unwrap();
1198 run_registration_child(
1199 MOUNT_HISTORY_FAILURE_CHILD,
1200 "a_failed_mount_history_write_does_not_fail_the_registered_session",
1201 directory.path(),
1202 );
1203 return;
1204 }
1205 let _writer = hel::hel_database::install_isolated_test_writer();
1207
1208 let mut controller = Controller {
1209 config: registration_config(),
1210 state: HelState::default(),
1211 };
1212 controller
1214 .register_session_with_resources(
1215 "codex",
1216 "project",
1217 "podman",
1218 "first",
1219 launch_options(Vec::new()),
1220 )
1221 .expect("a healthy store registers a session");
1222 let database = hel::hel_database::database_path();
1223 rusqlite::Connection::open(&database)
1224 .unwrap()
1225 .execute_batch("DROP TABLE mount_history")
1226 .unwrap();
1227
1228 let id = controller
1229 .register_session_with_resources(
1230 "codex",
1231 "project",
1232 "podman",
1233 "attached",
1234 launch_options(vec![AdditionalMount {
1235 source: PathBuf::from("/host/models"),
1236 destination: PathBuf::from("/mnt/models"),
1237 read_only: false,
1238 }]),
1239 )
1240 .expect("a suggestion list that cannot be written must not fail a registration");
1241
1242 let stored: i64 = rusqlite::Connection::open(&database)
1243 .unwrap()
1244 .query_row(
1245 "SELECT count(*) FROM sessions WHERE session_id = ?1",
1246 [&id],
1247 |row| row.get(0),
1248 )
1249 .unwrap();
1250 assert_eq!(stored, 1, "the registered session was not committed");
1251 assert!(
1252 controller.state.mount_history.is_empty(),
1253 "controller memory remembered mount sources the database never stored"
1254 );
1255 }
1256
1257 #[test]
1258 fn command_errors_report_the_root_cause_without_worker_wrappers() {
1259 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";
1260
1261 assert_eq!(
1262 command_error_detail(stderr),
1263 "checkpoint base b41dc78 is absent from configured source\nrepository may have moved"
1264 );
1265 }
1266
1267 #[test]
1268 fn controller_store_lock_excludes_a_second_process_owner() {
1269 let directory = tempfile::tempdir().unwrap();
1270 let first = ControllerStoreGuard::acquire_at(directory.path()).unwrap();
1271 run_controller_lock_probe(directory.path(), true);
1272 drop(first);
1273 run_controller_lock_probe(directory.path(), false);
1274 }
1275 fn run_controller_lock_probe(directory: &Path, expect_locked: bool) {
1276 let output = std::process::Command::new(std::env::current_exe().unwrap())
1277 .args([
1278 "--exact",
1279 "hel_controller::tests::controller_store_lock_subprocess_probe",
1280 "--nocapture",
1281 ])
1282 .env("MJ_CONTROLLER_LOCK_PROBE", directory)
1283 .env(
1284 "MJ_CONTROLLER_LOCK_EXPECTED",
1285 if expect_locked { "locked" } else { "available" },
1286 )
1287 .output()
1288 .unwrap();
1289 assert!(
1290 output.status.success(),
1291 "controller lock subprocess failed:\nstdout:\n{}\nstderr:\n{}",
1292 String::from_utf8_lossy(&output.stdout),
1293 String::from_utf8_lossy(&output.stderr)
1294 );
1295 }
1296 #[test]
1297 fn controller_store_lock_subprocess_probe() {
1298 let Some(directory) = std::env::var_os("MJ_CONTROLLER_LOCK_PROBE") else {
1299 return;
1300 };
1301 let expected = std::env::var("MJ_CONTROLLER_LOCK_EXPECTED").unwrap();
1302 let acquired = ControllerStoreGuard::acquire_at(Path::new(&directory));
1303 match expected.as_str() {
1304 "locked" => {
1305 let error = acquired.expect_err("a second process acquired the controller store");
1306 assert!(error.to_string().contains("another Mjolnir controller"));
1307 }
1308 "available" => {
1309 acquired.expect("released controller store stayed locked");
1310 }
1311 value => panic!("unexpected lock probe expectation {value:?}"),
1312 }
1313 }
1314 #[test]
1315 fn local_mount_source_must_be_an_existing_directory() {
1316 let directory = tempfile::tempdir().unwrap();
1317 let file = directory.path().join("file");
1318 std::fs::write(&file, "not a directory").unwrap();
1319 let mut config = HelConfig::default();
1320 config.targets.insert(
1321 "local".into(),
1322 TargetTemplate::LocalPodman {
1323 container: ConfigContainer {
1324 image: "ubuntu:24.04".into(),
1325 pull_policy: Default::default(),
1326 platform: None,
1327 cpus: None,
1328 memory: None,
1329 environment: BTreeMap::new(),
1330 workspace_storage: Default::default(),
1331 },
1332 },
1333 );
1334 let controller = Controller {
1335 config,
1336 state: HelState::default(),
1337 };
1338
1339 assert!(
1340 controller
1341 .validate_mount_source("local", directory.path(), &ProcessExecutor)
1342 .is_ok()
1343 );
1344 for invalid in [file, directory.path().join("missing")] {
1345 let error = controller
1346 .validate_mount_source("local", &invalid, &ProcessExecutor)
1347 .unwrap_err();
1348 assert!(
1349 error
1350 .to_string()
1351 .contains("does not exist or is not a directory")
1352 );
1353 }
1354 }
1355}