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 expanded = hel::hel_path_input::expand_local(Path::new(source))?;
374 let candidate = expanded.as_path();
375 if candidate.exists() {
376 let root = hel::hel_local_git::canonical_repository(candidate)?;
377 let name = root
378 .file_name()
379 .and_then(|name| name.to_str())
380 .context("local repository has no usable directory name")?
381 .to_owned();
382 return Ok(InterpretedRepositorySource {
383 display_name: source.to_owned(),
384 name,
385 kind: RepositorySourceKind::Local(root),
386 });
387 }
388 if candidate.is_absolute() || source.starts_with('.') || source.starts_with('~') {
389 bail!("local repository path {source:?} does not exist");
390 }
391 let repository = github_repository_from_origin(source).context(format!(
392 "{source:?} is not a GitHub owner/repository or URL"
393 ))?;
394 Ok(InterpretedRepositorySource {
395 display_name: source.to_owned(),
396 name: repository.repository.clone(),
397 kind: RepositorySourceKind::Github(repository),
398 })
399}
400
401fn exact_configured_bundle(
402 config: &HelConfig,
403 requested: &[InterpretedRepositorySource],
404) -> Option<String> {
405 let requested_identities = requested
406 .iter()
407 .map(InterpretedRepositorySource::identity)
408 .collect::<BTreeSet<_>>();
409 let primary = requested.first()?.identity();
410 config.bundles.iter().find_map(|(id, bundle)| {
411 if bundle.repositories.len() != requested.len()
412 || bundle
413 .repositories
414 .iter()
415 .any(|repository| repository.git_ref.is_some())
416 {
417 return None;
418 }
419 bundle_matches(bundle, &requested_identities, &primary).then(|| id.clone())
420 })
421}
422
423fn unique_id(base: &str, mut is_used: impl FnMut(&str) -> bool) -> String {
424 if !is_used(base) {
425 return base.to_owned();
426 }
427 for suffix in 2_u32.. {
428 let suffix = format!("-{suffix}");
429 let prefix_len = 64usize.saturating_sub(suffix.len());
430 let prefix = base.chars().take(prefix_len).collect::<String>();
431 let candidate = format!("{prefix}{suffix}");
432 if !is_used(&candidate) {
433 return candidate;
434 }
435 }
436 unreachable!("u32 repository/bundle id suffixes exhausted")
437}
438
439pub struct SessionLaunchOptions {
440 pub initial_prompt: Option<String>,
441 pub workspace_id: String,
442 pub additional_mounts: Vec<AdditionalMount>,
443 pub allow_dirty_local: bool,
444 pub resource_allocation: Option<SessionResourceAllocation>,
445 pub project_directory: Option<PathBuf>,
446 pub session_title_override: Option<String>,
447}
448
449pub struct SessionResumeOptions {
450 pub additional_mounts: Option<Vec<AdditionalMount>>,
451 pub resource_allocation: Option<SessionResourceAllocation>,
452 pub discard_queue: bool,
453}
454
455fn selected_host_container_size(
456 template: &TargetTemplate,
457 allocation: Option<&SessionResourceAllocation>,
458) -> Option<(String, HostContainerSize)> {
459 let host = container_size_host(template)?;
460 let SessionResourceAllocation::Container { cpus, memory_bytes } = allocation? else {
461 return None;
462 };
463 Some((
464 host.to_owned(),
465 HostContainerSize {
466 cpus: *cpus,
467 memory_bytes: *memory_bytes,
468 },
469 ))
470}
471
472impl Controller {
473 pub fn load() -> Result<Self> {
474 let config = HelConfig::load()?;
475 let state = HelState::load()?;
476 state.validate_against_config(&config)?;
477 Ok(Self { config, state })
478 }
479
480 pub fn reload(&mut self) -> Result<()> {
481 *self = Self::load()?;
482 Ok(())
483 }
484
485 fn persist_session_state(&self, session_id: &str) -> Result<()> {
486 match self.state.sessions.get(session_id) {
487 Some(session) => hel::hel_database::save_lifecycle_session(session),
488 None => hel::hel_database::delete_session(session_id),
489 }
490 }
491
492 fn persist_session_transition_or_restore(
493 &mut self,
494 session_id: &str,
495 previous: &SessionRecord,
496 context: &'static str,
497 ) -> Result<()> {
498 persist_session_record_transition_or_restore(
499 &mut self.state,
500 session_id,
501 previous,
502 context,
503 &hel::hel_database::save_lifecycle_session,
504 )
505 }
506
507 fn restore_prior_session_after_persistence_failure(
508 &mut self,
509 session_id: &str,
510 previous: &SessionRecord,
511 primary: anyhow::Error,
512 ) -> anyhow::Error {
513 restore_session_after_persistence_failure(
514 &mut self.state,
515 session_id,
516 previous,
517 primary,
518 hel::hel_database::save_lifecycle_session,
519 )
520 }
521
522 pub fn resolve_input_path(
524 &self,
525 target_id: &str,
526 path: &Path,
527 executor: &impl CommandExecutor,
528 ) -> Result<PathBuf> {
529 let target = self
530 .config
531 .targets
532 .get(target_id)
533 .context("Unknown path target")?;
534 resolve_target_input_path(target, path, executor)
535 }
536
537 pub fn complete_mount_source(
539 &self,
540 target_id: &str,
541 prefix: &str,
542 executor: &impl CommandExecutor,
543 ) -> Result<Vec<String>> {
544 let target = self
545 .config
546 .targets
547 .get(target_id)
548 .with_context(|| format!("unknown target template {target_id:?}"))?;
549 let home = if hel::hel_path_input::needs_home(Path::new(prefix))? {
550 Some(resolve_target_input_path(target, Path::new("~"), executor)?)
551 } else {
552 None
553 };
554 let expanded = hel::hel_path_input::expand_home(Path::new(prefix), home.as_deref())?;
555 let mut lookup = expanded.to_string_lossy().into_owned();
556 if prefix.ends_with('/') && !lookup.ends_with('/') {
558 lookup.push('/');
559 }
560 let candidates = match target {
561 TargetTemplate::LocalPodman { .. }
562 | TargetTemplate::LocalDocker { .. }
563 | TargetTemplate::AppleContainer { .. }
564 | TargetTemplate::AwsEc2 { .. } => hel_targets::local_directory_completions(&lookup),
565 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
566 hel_targets::ssh_directory_completions(&backend_ssh(ssh), &lookup, executor)?
567 }
568 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
569 bail!("resource path completion is unsupported for bare targets")
570 }
571 };
572 candidates
573 .into_iter()
574 .map(|candidate| {
575 let Some(home) = &home else {
576 return Ok(candidate);
577 };
578 let suffix = Path::new(&candidate)
579 .strip_prefix(home)
580 .context("Completed path is outside the requested home")?;
581 let mut value = Path::new("~").join(suffix).to_string_lossy().into_owned();
582 if candidate.ends_with('/') && !value.ends_with('/') {
583 value.push('/');
584 }
585 Ok(value)
586 })
587 .collect()
588 }
589
590 pub fn validate_mount_source(
597 &self,
598 target_id: &str,
599 source: &Path,
600 executor: &impl CommandExecutor,
601 ) -> Result<Option<String>> {
602 let target = self
603 .config
604 .targets
605 .get(target_id)
606 .with_context(|| format!("unknown target template {target_id:?}"))?;
607 let exists = match target {
608 TargetTemplate::LocalPodman { .. }
609 | TargetTemplate::LocalDocker { .. }
610 | TargetTemplate::AppleContainer { .. }
611 | TargetTemplate::AwsEc2 { .. } => std::fs::metadata(source)
612 .map(|metadata| metadata.is_dir())
613 .or_else(|error| {
614 if error.kind() == std::io::ErrorKind::NotFound {
615 Ok(false)
616 } else {
617 Err(error)
618 }
619 })
620 .with_context(|| format!("inspect resource source {}", source.display()))?,
621 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
622 hel_targets::ssh_directory_exists(&backend_ssh(ssh), source, executor)?
623 }
624 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => {
625 bail!("resource attachments are unsupported for bare targets")
626 }
627 };
628 ensure!(
629 exists,
630 "source path {} does not exist or is not a directory",
631 source.display()
632 );
633 Ok(self.forced_read_only_reason(target, source, executor))
634 }
635
636 fn forced_read_only_reason(
638 &self,
639 target: &TargetTemplate,
640 source: &Path,
641 executor: &impl CommandExecutor,
642 ) -> Option<String> {
643 let ssh = match target {
644 TargetTemplate::LocalPodman { .. } | TargetTemplate::LocalDocker { .. } => None,
645 TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
646 Some(backend_ssh(ssh))
647 }
648 _ => return None,
651 };
652 let filesystem = hel_targets::probe_filesystem_types(
653 ssh.as_ref(),
654 std::slice::from_ref(&source.to_path_buf()),
655 executor,
656 )
657 .map_err(|error| {
658 tracing::debug!(
659 source = %source.display(),
660 error = format!("{error:#}"),
661 "could not probe the filesystem under a mount source"
662 );
663 })
664 .ok()?
665 .pop()?;
666 let reason = hel_targets::overlay_unsupported_filesystem(&filesystem)?;
667 Some(format!("{filesystem} ({reason})"))
668 }
669
670 fn fail_new_session_with_cleanup(
671 &mut self,
672 session_id: &str,
673 error: anyhow::Error,
674 executor: &impl CommandExecutor,
675 ) -> Result<anyhow::Error> {
676 let original = format!("{error:#}");
677 let cleanup_error = self
678 .cleanup_new_session_worktree_after_failure(session_id, executor)
679 .err()
680 .map(|cleanup_error| format!("{cleanup_error:#}"));
681 if let Some(cleanup_error) = &cleanup_error {
682 tracing::warn!(
683 session_id,
684 error = %cleanup_error,
685 "new-session worktree rollback reported a cleanup failure"
686 );
687 }
688 let failure = apply_failed_new_session_rollback(
689 &mut self.state,
690 session_id,
691 &original,
692 cleanup_error,
693 );
694 self.persist_session_state(session_id)?;
695 Ok(failure)
696 }
697
698 pub fn register_session_with_resources(
699 &mut self,
700 profile_id: &str,
701 bundle_id: &str,
702 target_id: &str,
703 title: impl Into<String>,
704 options: SessionLaunchOptions,
705 ) -> Result<String> {
706 let SessionLaunchOptions {
707 initial_prompt,
708 workspace_id,
709 additional_mounts,
710 allow_dirty_local: _,
711 resource_allocation,
712 project_directory,
713 session_title_override,
714 } = options;
715 let session_title_override = match session_title_override {
716 Some(title) => {
717 Some(normalize_session_title(&title).context("session name cannot be empty")?)
718 }
719 None => None,
720 };
721 let profile = self
722 .config
723 .profiles
724 .get(profile_id)
725 .with_context(|| format!("unknown profile {profile_id:?}"))?;
726 ensure!(profile.enabled, "profile {profile_id:?} is disabled");
727 let template = self
728 .config
729 .targets
730 .get(target_id)
731 .with_context(|| format!("unknown target template {target_id:?}"))?;
732 if project_directory.is_some() != is_bare_project_target(template) {
733 bail!("raw project directories require a bare target, and bare targets require one");
734 }
735 if let Some(path) = &project_directory
736 && (!path.is_absolute()
737 || path
738 .components()
739 .any(|part| part == std::path::Component::ParentDir))
740 {
741 bail!("bare project directory must be an absolute safe path");
742 }
743 let bundle = project_directory
744 .is_none()
745 .then(|| self.config.bundles.get(bundle_id))
746 .flatten();
747 if project_directory.is_none() && bundle.is_none() {
748 bail!("unknown bundle {bundle_id:?}");
749 }
750 if matches!(
751 profile.kind,
752 hel::hel_config::HarnessKind::Deepseek | hel::hel_config::HarnessKind::Muse
753 ) && (!additional_mounts.is_empty()
754 || bundle.is_some_and(|bundle| bundle.repositories.len() > 1))
755 {
756 bail!(
757 "{} ACP supports one workspace root; use a single-repository bundle without attached directories",
758 profile.kind.display_name()
759 );
760 }
761 if let Some(bundle) = bundle {
762 for repository in &bundle.repositories {
763 hel::hel_remote_git::resolve_repository(
764 repository,
765 &hel_targets::CancellableProcessExecutor::with_timeout(
766 std::time::Duration::from_secs(15),
767 ),
768 )
769 .with_context(|| format!("repository {:?}", repository.id))?;
770 }
771 }
772 validate_resource_allocation(template, resource_allocation.as_ref())?;
773 let selected_container_size =
774 selected_host_container_size(template, resource_allocation.as_ref());
775 if !additional_mounts.is_empty() && mount_history_host(template).is_none() {
776 bail!("attached resources are unsupported for this target");
777 }
778 hel_targets::validate_additional_mounts(&additional_mounts)?;
779 let id = new_session_id()?;
780 let now = now();
781 let record = SessionRecord {
782 archived: false,
783 container_cpus: None,
784 container_memory: None,
785 id: id.clone(),
786 workspace_id,
787 title: title.into(),
788 harness_kind: profile.kind,
789 last_profile: profile_id.to_string(),
790 bundle_id: bundle_id.to_string(),
791 project_directory,
792 managed_worktree: None,
793 target_template_id: target_id.to_string(),
794 resource_allocation,
795 additional_mounts: additional_mounts.clone(),
796 state: SessionState::Provisioning,
797 target: None,
798 native_session_id: None,
799 acp_session_title: None,
800 session_title_override,
801 created_at: now.clone(),
802 updated_at: now,
803 viewed_through_event_ordinal: 0,
804 draft_input: initial_prompt.unwrap_or_default(),
805 last_error: None,
806 last_checkpoint_error: None,
807 checkpoint: None,
808 };
809 if let Some((host, size)) = selected_container_size.as_ref() {
813 hel::hel_database::save_session_with_container_size(&record, host, *size)?;
814 } else {
815 hel::hel_database::save_session(&record)?;
816 }
817 self.state.sessions.insert(id.clone(), record);
818 if let Some((host, size)) = selected_container_size {
819 self.state.remember_container_size(&host, size);
820 }
821 if let Some(host) = mount_history_host(template) {
822 match hel::hel_database::remember_mount_sources(host, &additional_mounts) {
826 Ok(()) => self.state.remember_mount_sources(host, &additional_mounts),
827 Err(error) => tracing::warn!(
828 session_id = id,
829 error = format!("{error:#}"),
830 "could not remember the attached resource directories for later suggestions"
831 ),
832 }
833 }
834 Ok(id)
835 }
836
837 pub fn rename_session(&mut self, session_id: &str, title: &str) -> Result<String> {
838 let title = normalize_session_title(title).context("session name cannot be empty")?;
839 ensure!(
840 self.state.sessions.contains_key(session_id),
841 "unknown session {session_id}"
842 );
843 let updated_at = now();
844 hel::hel_database::set_session_title_override(session_id, &title, &updated_at)?;
845 let record = self
846 .state
847 .sessions
848 .get_mut(session_id)
849 .expect("session was checked before updating its title");
850 record.session_title_override = Some(title.clone());
851 record.updated_at = updated_at;
852 Ok(title)
853 }
854
855 pub fn rename_profile_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
856 hel::hel_config::validate_id("profile", new_id)?;
857 if old_id == new_id {
858 ensure!(
859 self.config.profiles.contains_key(old_id),
860 "unknown profile {old_id:?}"
861 );
862 return Ok(());
863 }
864 let journal = ConfigRenameJournal {
865 kind: ConfigRenameKind::Profile,
866 old_id: old_id.to_owned(),
867 new_id: new_id.to_owned(),
868 };
869 write_config_rename_journal(&journal)?;
870 let (config, ()) = match HelConfig::update(|config| {
871 ensure!(
872 config.profiles.contains_key(old_id),
873 "unknown profile {old_id:?}"
874 );
875 ensure!(
876 !config.profiles.contains_key(new_id),
877 "profile {new_id:?} already exists"
878 );
879 let profile = config
880 .profiles
881 .remove(old_id)
882 .expect("profile was checked in the transaction");
883 config.profiles.insert(new_id.to_owned(), profile);
884 if config.startup.profile.as_deref() == Some(old_id) {
885 config.startup.profile = Some(new_id.to_owned());
886 }
887 Ok(())
888 }) {
889 Ok(result) => result,
890 Err(error) => {
891 remove_config_rename_journal()
892 .context("remove profile rename journal after config save failed")?;
893 return Err(error).context("save renamed profile configuration");
894 }
895 };
896 self.config = config;
897 hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
898 if let Err(error) = hel::hel_database::rename_profile_references(old_id, new_id) {
899 let restore = HelConfig::update(|config| {
900 let profile = config
901 .profiles
902 .remove(new_id)
903 .with_context(|| format!("renamed profile {new_id:?} is missing"))?;
904 ensure!(
905 !config.profiles.contains_key(old_id),
906 "cannot restore profile rename: both {old_id:?} and {new_id:?} exist"
907 );
908 config.profiles.insert(old_id.to_owned(), profile);
909 if config.startup.profile.as_deref() == Some(new_id) {
910 config.startup.profile = Some(old_id.to_owned());
911 }
912 Ok(())
913 });
914 let restored = match restore {
915 Ok((config, ())) => config,
916 Err(restore_error) => {
917 return Err(error).context(format!(
918 "rename profile references; additionally failed to restore config: {restore_error:#}"
919 ));
920 }
921 };
922 self.config = restored;
923 if let Err(restore_error) = remove_config_rename_journal() {
924 return Err(error).context(format!(
925 "rename profile references; additionally failed to remove rename journal: {restore_error:#}"
926 ));
927 }
928 return Err(error).context("rename profile references");
929 }
930 for session in self.state.sessions.values_mut() {
931 if session.last_profile == old_id {
932 session.last_profile = new_id.to_owned();
933 }
934 }
935 remove_config_rename_journal()?;
936 Ok(())
937 }
938
939 pub fn rename_target_id(&mut self, old_id: &str, new_id: &str) -> Result<()> {
940 hel::hel_config::validate_id("target template", new_id)?;
941 if old_id == new_id {
942 ensure!(
943 self.config.targets.contains_key(old_id),
944 "unknown target {old_id:?}"
945 );
946 return Ok(());
947 }
948 let journal = ConfigRenameJournal {
949 kind: ConfigRenameKind::Target,
950 old_id: old_id.to_owned(),
951 new_id: new_id.to_owned(),
952 };
953 write_config_rename_journal(&journal)?;
954 let (config, ()) = match HelConfig::update(|config| {
955 ensure!(
956 config.targets.contains_key(old_id),
957 "unknown target {old_id:?}"
958 );
959 ensure!(
960 !config.targets.contains_key(new_id),
961 "target {new_id:?} already exists"
962 );
963 let target = config
964 .targets
965 .remove(old_id)
966 .expect("target was checked in the transaction");
967 config.targets.insert(new_id.to_owned(), target);
968 if config.startup.target.as_deref() == Some(old_id) {
969 config.startup.target = Some(new_id.to_owned());
970 }
971 Ok(())
972 }) {
973 Ok(result) => result,
974 Err(error) => {
975 remove_config_rename_journal()
976 .context("remove target rename journal after config save failed")?;
977 return Err(error).context("save renamed target configuration");
978 }
979 };
980 self.config = config;
981 hel::hel_test_hooks::reach_test_hook("config_replacement_before_reference_migration")?;
982 if let Err(error) = hel::hel_database::rename_target_references(old_id, new_id) {
983 let restore = HelConfig::update(|config| {
984 let target = config
985 .targets
986 .remove(new_id)
987 .with_context(|| format!("renamed target {new_id:?} is missing"))?;
988 ensure!(
989 !config.targets.contains_key(old_id),
990 "cannot restore target rename: both {old_id:?} and {new_id:?} exist"
991 );
992 config.targets.insert(old_id.to_owned(), target);
993 if config.startup.target.as_deref() == Some(new_id) {
994 config.startup.target = Some(old_id.to_owned());
995 }
996 Ok(())
997 });
998 let restored = match restore {
999 Ok((config, ())) => config,
1000 Err(restore_error) => {
1001 return Err(error).context(format!(
1002 "rename target references; additionally failed to restore config: {restore_error:#}"
1003 ));
1004 }
1005 };
1006 self.config = restored;
1007 if let Err(restore_error) = remove_config_rename_journal() {
1008 return Err(error).context(format!(
1009 "rename target references; additionally failed to remove rename journal: {restore_error:#}"
1010 ));
1011 }
1012 return Err(error).context("rename target references");
1013 }
1014 for session in self.state.sessions.values_mut() {
1015 if session.target_template_id == old_id {
1016 session.target_template_id = new_id.to_owned();
1017 }
1018 }
1019 remove_config_rename_journal()?;
1020 Ok(())
1021 }
1022
1023 pub fn recover_config_id_rename() -> Result<bool> {
1027 let path = config_rename_journal_path();
1028 let body = match fs::read(&path) {
1029 Ok(body) => body,
1030 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1031 Err(error) => return Err(error).context(format!("read {}", path.display())),
1032 };
1033 let journal: ConfigRenameJournal =
1034 serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
1035 match journal.kind {
1036 ConfigRenameKind::Profile => {
1037 HelConfig::update(|config| {
1038 finish_config_map_rename(
1039 &mut config.profiles,
1040 &journal.old_id,
1041 &journal.new_id,
1042 "profile",
1043 )?;
1044 Ok(())
1045 })?;
1046 hel::hel_database::rename_profile_references(&journal.old_id, &journal.new_id)?;
1047 }
1048 ConfigRenameKind::Target => {
1049 HelConfig::update(|config| {
1050 finish_config_map_rename(
1051 &mut config.targets,
1052 &journal.old_id,
1053 &journal.new_id,
1054 "target",
1055 )?;
1056 Ok(())
1057 })?;
1058 hel::hel_database::rename_target_references(&journal.old_id, &journal.new_id)?;
1059 }
1060 }
1061 remove_config_rename_journal()?;
1062 Ok(true)
1063 }
1064
1065 pub fn update_session_container_settings(
1069 &mut self,
1070 session_id: &str,
1071 cpus: Option<String>,
1072 memory: Option<String>,
1073 additional_mounts: Vec<hel_targets::AdditionalMount>,
1074 mount_history: Vec<std::path::PathBuf>,
1075 ) -> Result<()> {
1076 ensure!(
1077 self.state.sessions.contains_key(session_id),
1078 "unknown session {session_id}"
1079 );
1080 let cpus = cpus.filter(|value| !value.trim().is_empty());
1081 let memory = memory.filter(|value| !value.trim().is_empty());
1082 let updated_at = now();
1083 hel::hel_database::set_session_container_settings(
1084 session_id,
1085 cpus.as_deref(),
1086 memory.as_deref(),
1087 &additional_mounts,
1088 &updated_at,
1089 )?;
1090 if let Some(host) = self
1091 .config
1092 .targets
1093 .get(
1094 &self.state.sessions[session_id]
1095 .target_template_id
1096 .to_owned(),
1097 )
1098 .and_then(hel::hel_config::mount_history_host)
1099 {
1100 let host = host.to_owned();
1101 hel::hel_database::replace_mount_history(&host, &mount_history)?;
1104 hel::hel_database::remember_mount_sources(&host, &additional_mounts)?;
1105 self.state.mount_history.insert(host.clone(), mount_history);
1106 self.state.remember_mount_sources(&host, &additional_mounts);
1107 }
1108 let record = self
1109 .state
1110 .sessions
1111 .get_mut(session_id)
1112 .expect("session was checked before updating its container settings");
1113 record.container_cpus = cpus;
1114 record.container_memory = memory;
1115 record.additional_mounts = additional_mounts;
1116 record.updated_at = updated_at;
1117 Ok(())
1118 }
1119}
1120
1121fn config_rename_journal_path() -> PathBuf {
1122 data_dir().join(CONFIG_RENAME_JOURNAL)
1123}
1124
1125fn write_config_rename_journal(journal: &ConfigRenameJournal) -> Result<()> {
1126 let path = config_rename_journal_path();
1127 let body = serde_json::to_vec(journal).context("serialize config rename journal")?;
1128 atomic_write(&path, &body).with_context(|| format!("write {}", path.display()))
1129}
1130
1131fn remove_config_rename_journal() -> Result<()> {
1132 let path = config_rename_journal_path();
1133 match fs::remove_file(&path) {
1134 Ok(()) => Ok(()),
1135 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1136 Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1137 }
1138}
1139
1140fn finish_config_map_rename<T>(
1141 entries: &mut BTreeMap<String, T>,
1142 old_id: &str,
1143 new_id: &str,
1144 kind: &str,
1145) -> Result<()> {
1146 if let Some(entry) = entries.remove(old_id) {
1147 ensure!(
1148 !entries.contains_key(new_id),
1149 "cannot recover {kind} rename: both {old_id:?} and {new_id:?} exist"
1150 );
1151 entries.insert(new_id.to_owned(), entry);
1152 } else {
1153 ensure!(
1154 entries.contains_key(new_id),
1155 "cannot recover {kind} rename: neither {old_id:?} nor {new_id:?} exists"
1156 );
1157 }
1158 Ok(())
1159}
1160
1161fn target_kind(locator: &hel_targets::TargetLocator) -> &'static str {
1162 match locator {
1163 hel_targets::TargetLocator::LocalBare { .. } => "local-bare",
1164 hel_targets::TargetLocator::LocalPodman { .. } => "local-podman",
1165 hel_targets::TargetLocator::LocalDocker { .. } => "local-docker",
1166 hel_targets::TargetLocator::AppleContainer { .. } => "apple-container",
1167 hel_targets::TargetLocator::AwsEc2 { .. } => "aws-ec2",
1168 hel_targets::TargetLocator::SshBare { .. } => "ssh-bare",
1169 hel_targets::TargetLocator::SshPodman { .. } => "ssh-podman",
1170 hel_targets::TargetLocator::SshDocker { .. } => "ssh-docker",
1171 }
1172}
1173
1174fn target_profile_home(
1175 locator: &hel_targets::TargetLocator,
1176 session_id: &str,
1177 profile: &hel::hel_config::HarnessProfile,
1178) -> String {
1179 let home = match locator {
1180 hel_targets::TargetLocator::LocalBare { .. } => profile.home.to_string_lossy().into_owned(),
1181 hel_targets::TargetLocator::LocalPodman { .. }
1182 | hel_targets::TargetLocator::LocalDocker { .. }
1183 | hel_targets::TargetLocator::AppleContainer { .. }
1184 | hel_targets::TargetLocator::SshPodman { .. }
1185 | hel_targets::TargetLocator::SshDocker { .. } => {
1186 format!("/var/lib/hel/profiles/{session_id}")
1187 }
1188 hel_targets::TargetLocator::AwsEc2 { .. } | hel_targets::TargetLocator::SshBare { .. } => {
1189 format!(".local/share/hel/profiles/{session_id}")
1190 }
1191 };
1192 if profile.kind == hel::hel_config::HarnessKind::Muse {
1193 let root = if matches!(locator, hel_targets::TargetLocator::LocalBare { .. }) {
1194 hel::hel_config::data_dir()
1195 .join("profiles")
1196 .join(session_id)
1197 } else {
1198 PathBuf::from(home)
1199 };
1200 root.join("muse").to_string_lossy().into_owned()
1201 } else {
1202 home
1203 }
1204}
1205
1206pub fn resolve_target_input_path(
1208 target: &TargetTemplate,
1209 path: &Path,
1210 executor: &impl CommandExecutor,
1211) -> Result<PathBuf> {
1212 if !hel::hel_path_input::needs_home(path)? {
1213 return Ok(path.to_path_buf());
1214 }
1215 match target {
1216 TargetTemplate::SshBare { ssh, .. }
1217 | TargetTemplate::SshPodman { ssh, .. }
1218 | TargetTemplate::SshDocker { ssh, .. } => {
1219 let mut ssh = ssh.clone();
1220 ssh.identity_file = ssh
1221 .identity_file
1222 .as_deref()
1223 .map(hel::hel_path_input::expand_local)
1224 .transpose()?;
1225 let command =
1226 ssh_command_spec(&backend_ssh(&ssh), ["sh", "-c", "printf '%s' \"$HOME\""])
1227 .purpose("resolve remote home directory");
1228 let output = executor.execute(&command)?;
1229 anyhow::ensure!(
1230 output.status == 0,
1231 "Could not resolve remote home: {}",
1232 String::from_utf8_lossy(&output.stderr).trim()
1233 );
1234 let home =
1235 String::from_utf8(output.stdout).context("Remote home is not valid UTF-8")?;
1236 hel::hel_path_input::expand_home(path, Some(Path::new(&home)))
1237 }
1238 _ => hel::hel_path_input::expand_local(path),
1239 }
1240}
1241
1242pub(crate) fn backend_ssh(ssh: &SshConnection) -> SshTarget {
1243 let destination = match &ssh.user {
1244 Some(user) => format!("{user}@{}", ssh.host),
1245 None => ssh.host.clone(),
1246 };
1247 SshTarget {
1248 destination,
1249 ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
1250 }
1251}
1252
1253fn ssh_command_spec(
1254 ssh: &SshTarget,
1255 args: impl IntoIterator<Item = impl AsRef<str>>,
1256) -> CommandSpec {
1257 let remote = args
1258 .into_iter()
1259 .map(|arg| arg.as_ref().to_string())
1260 .collect::<Vec<_>>();
1261 let mut command_args = ssh.ssh_args.clone();
1262 command_args.push(ssh.destination.clone());
1263 command_args.push(hel_targets::join_remote_command(&remote));
1264 CommandSpec::new("ssh", command_args)
1265}
1266
1267fn scp_command_spec(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
1268 let mut args = ssh.ssh_args.clone();
1269 if recursive {
1270 args.push("-r".into());
1271 }
1272 args.push(source.to_string_lossy().into_owned());
1273 args.push(format!("{}:{remote}", ssh.destination));
1274 CommandSpec::new("scp", args)
1275}
1276
1277fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
1278 let mut result = vec![
1284 "-o".into(),
1285 "BatchMode=yes".into(),
1286 "-o".into(),
1287 "StrictHostKeyChecking=accept-new".into(),
1288 "-o".into(),
1289 "ConnectTimeout=15".into(),
1290 ];
1291 result.extend(args.iter().cloned());
1292 if let Some(identity) = identity {
1293 result.push("-i".into());
1294 result.push(identity.to_string_lossy().into_owned());
1295 }
1296 result
1297}
1298
1299fn execute_checked(executor: &impl CommandExecutor, command: CommandSpec) -> Result<CommandOutput> {
1300 let output = executor.execute(&command)?;
1301 if output.status != 0 {
1302 let detail = command_error_detail(&output.stderr);
1303 if detail.is_empty() {
1304 bail!("{} failed with status {}", command.purpose, output.status);
1305 }
1306 bail!("{detail}");
1307 }
1308 Ok(output)
1309}
1310
1311fn command_error_detail(stderr: &[u8]) -> String {
1312 let reported = String::from_utf8_lossy(stderr);
1313 let reported = reported.trim();
1314 let detail = reported
1315 .rsplit_once("\nCaused by:\n")
1316 .map_or(reported, |(_, causes)| causes);
1317 let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
1318 detail
1319 .lines()
1320 .map(|line| line.strip_prefix(" ").unwrap_or(line))
1321 .collect::<Vec<_>>()
1322 .join("\n")
1323 .trim()
1324 .to_owned()
1325}
1326
1327fn now() -> String {
1328 Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1329}
1330
1331fn restore_session_after_persistence_failure(
1332 state: &mut HelState,
1333 session_id: &str,
1334 previous: &SessionRecord,
1335 primary: anyhow::Error,
1336 persist: impl FnOnce(&SessionRecord) -> Result<()>,
1337) -> anyhow::Error {
1338 state
1339 .sessions
1340 .insert(session_id.to_owned(), previous.clone());
1341 let restored = state
1342 .sessions
1343 .get(session_id)
1344 .expect("restored session record disappeared");
1345 match persist(restored) {
1346 Ok(()) => primary,
1347 Err(error) => primary.context(format!(
1348 "restored prior session state in memory, but failed to persist the rollback: {error:#}"
1349 )),
1350 }
1351}
1352
1353fn persist_session_record_transition_or_restore(
1354 state: &mut HelState,
1355 session_id: &str,
1356 previous: &SessionRecord,
1357 context: &'static str,
1358 persist: &impl Fn(&SessionRecord) -> Result<()>,
1359) -> Result<()> {
1360 let result = persist(
1361 state
1362 .sessions
1363 .get(session_id)
1364 .expect("checkpoint session disappeared before persistence"),
1365 );
1366 match result {
1367 Ok(()) => Ok(()),
1368 Err(error) => Err(restore_session_after_persistence_failure(
1369 state,
1370 session_id,
1371 previous,
1372 error.context(context),
1373 persist,
1374 )),
1375 }
1376}
1377
1378#[cfg(test)]
1379mod tests {
1380 use std::collections::BTreeMap;
1381 use std::path::Path;
1382
1383 use hel::hel_config::{
1384 ContainerTemplate as ConfigContainer, HarnessKind, HarnessProfile, HelConfig,
1385 ProjectBundle, ProjectRepository, TargetTemplate,
1386 };
1387 use hel::hel_state::HelState;
1388 use hel::hel_targets::ProcessExecutor;
1389
1390 use super::*;
1391
1392 fn registration_config() -> HelConfig {
1395 let mut config = HelConfig::default();
1396 config.profiles.insert(
1397 "codex".into(),
1398 HarnessProfile {
1399 enabled: true,
1400 kind: HarnessKind::Codex,
1401 home: PathBuf::from("/home/dev/.codex"),
1402 environment: BTreeMap::new(),
1403 context_window_bytes: None,
1404 },
1405 );
1406 config.bundles.insert(
1407 "project".into(),
1408 ProjectBundle {
1409 primary_repo: "project".into(),
1410 repositories: vec![ProjectRepository {
1411 id: "project".into(),
1412 github: Some("owner/project".into()),
1413 local: None,
1414 destination: PathBuf::from("project"),
1415 git_ref: None,
1416 }],
1417 },
1418 );
1419 config.targets.insert(
1420 "podman".into(),
1421 TargetTemplate::LocalPodman {
1422 container: ConfigContainer {
1423 image: "example.invalid/hel-test:latest".into(),
1424 pull_policy: Default::default(),
1425 platform: None,
1426 cpus: None,
1427 memory: None,
1428 environment: BTreeMap::new(),
1429 workspace_storage: Default::default(),
1430 },
1431 },
1432 );
1433 config
1434 }
1435
1436 #[test]
1437 fn remote_completion_preserves_home_shorthand_and_trailing_separator() {
1438 struct CompletionExecutor;
1439 impl CommandExecutor for CompletionExecutor {
1440 fn execute(&self, command: &CommandSpec) -> Result<hel_targets::CommandOutput> {
1441 let script = command.args.last().unwrap();
1442 let stdout = if script.contains("$HOME") {
1443 b"/remote".to_vec()
1444 } else {
1445 assert!(script.contains("'/remote/cache/'"), "{script}");
1446 b"/remote/cache/alpha/\n/remote/cache/alpine/\n".to_vec()
1447 };
1448 Ok(hel_targets::CommandOutput {
1449 status: 0,
1450 stdout,
1451 stderr: Vec::new(),
1452 })
1453 }
1454 }
1455 let target: TargetTemplate =
1456 serde_json::from_str(r#"{"kind":"ssh-podman","host":"builder","image":"test"}"#)
1457 .unwrap();
1458 let mut config = HelConfig::default();
1459 config.targets.insert("remote".into(), target);
1460 let controller = Controller {
1461 config,
1462 state: HelState::default(),
1463 };
1464 let candidates = controller
1465 .complete_mount_source("remote", "~/cache/", &CompletionExecutor)
1466 .unwrap();
1467 assert_eq!(candidates, ["~/cache/alpha/", "~/cache/alpine/"]);
1468 assert_eq!(
1469 hel_targets::path_completion("~/cache/", &candidates).as_deref(),
1470 Some("~/cache/alp")
1471 );
1472 }
1473
1474 #[test]
1475 fn remote_path_resolution_uses_login_home_without_evaluating_suffix() {
1476 struct HomeExecutor {
1477 status: i32,
1478 home: &'static str,
1479 }
1480 impl CommandExecutor for HomeExecutor {
1481 fn execute(&self, command: &CommandSpec) -> Result<hel_targets::CommandOutput> {
1482 assert_eq!(command.program, "ssh");
1483 let script = command.args.last().unwrap();
1484 assert!(!script.contains("touch"));
1485 assert!(script.contains("$HOME"));
1486 Ok(hel_targets::CommandOutput {
1487 status: self.status,
1488 stdout: self.home.as_bytes().to_vec(),
1489 stderr: b"home lookup failed".to_vec(),
1490 })
1491 }
1492 }
1493 let target: TargetTemplate = serde_json::from_str(
1494 r#"{"kind":"ssh-bare","host":"builder","permissions":"guardian"}"#,
1495 )
1496 .unwrap();
1497 let path = Path::new("~/資料/$(touch nope)");
1498 assert_eq!(
1499 resolve_target_input_path(
1500 &target,
1501 path,
1502 &HomeExecutor {
1503 status: 0,
1504 home: "/remote user"
1505 }
1506 )
1507 .unwrap(),
1508 Path::new("/remote user/資料/$(touch nope)")
1509 );
1510 assert!(
1511 resolve_target_input_path(
1512 &target,
1513 path,
1514 &HomeExecutor {
1515 status: 1,
1516 home: "/remote"
1517 }
1518 )
1519 .unwrap_err()
1520 .to_string()
1521 .contains("home lookup failed")
1522 );
1523 assert!(
1524 resolve_target_input_path(
1525 &target,
1526 path,
1527 &HomeExecutor {
1528 status: 0,
1529 home: ""
1530 }
1531 )
1532 .is_err()
1533 );
1534 assert!(
1535 resolve_target_input_path(
1536 &target,
1537 path,
1538 &HomeExecutor {
1539 status: 0,
1540 home: "relative"
1541 }
1542 )
1543 .is_err()
1544 );
1545 }
1546
1547 #[test]
1548 fn bundle_creation_combines_sources_with_first_primary_and_stable_collisions() {
1549 let mut config = HelConfig::default();
1550 let sources = vec!["example/app".into(), "other/app".into()];
1551
1552 let bundle_id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1553 let bundle = &config.bundles[&bundle_id];
1554 assert_eq!(bundle_id, "app");
1555 assert_eq!(bundle.primary_repo, "app");
1556 assert_eq!(
1557 bundle
1558 .repositories
1559 .iter()
1560 .map(|repository| repository.id.as_str())
1561 .collect::<Vec<_>>(),
1562 ["app", "app-2"]
1563 );
1564 assert_eq!(
1565 bundle
1566 .repositories
1567 .iter()
1568 .map(|repository| repository.destination.to_string_lossy().into_owned())
1569 .collect::<Vec<_>>(),
1570 ["app".to_owned(), "app-2".to_owned()]
1571 );
1572 assert_eq!(
1573 bundle.repositories[0].github.as_deref(),
1574 Some("example/app")
1575 );
1576 assert_eq!(bundle.repositories[1].github.as_deref(), Some("other/app"));
1577 }
1578
1579 #[test]
1580 fn bundle_creation_combines_local_and_github_sources_and_rejects_local_aliases() {
1581 let directory = tempfile::tempdir().unwrap();
1582 let root = directory.path().join("app");
1583 let output = hel::hel_subprocess::run_capturing_stdout(
1584 std::process::Command::new("git").arg("init").arg(&root),
1585 )
1586 .unwrap();
1587 assert!(output.status.success(), "{output:?}");
1588 let nested = root.join("nested");
1589 fs::create_dir(&nested).unwrap();
1590 let mut config = HelConfig::default();
1591 let sources = vec![root.to_str().unwrap().into(), "example/shared".into()];
1592 let id = create_bundle_from_sources_in_config(&mut config, &sources).unwrap();
1593 let bundle = &config.bundles[&id];
1594 assert_eq!(
1595 bundle.primary().unwrap().local,
1596 Some(root.canonicalize().unwrap())
1597 );
1598 assert_eq!(
1599 bundle.repositories[1].github.as_deref(),
1600 Some("example/shared")
1601 );
1602 let before = config.clone();
1603 let aliases = vec![
1604 root.to_str().unwrap().into(),
1605 nested.to_str().unwrap().into(),
1606 ];
1607 let error = create_bundle_from_sources_in_config(&mut config, &aliases).unwrap_err();
1608 assert!(
1609 error.to_string().contains("duplicate repository source"),
1610 "{error:#}"
1611 );
1612 assert_eq!(config, before);
1613 }
1614
1615 #[test]
1616 fn bundle_creation_rejects_duplicate_normalized_sources_atomically() {
1617 let mut config = HelConfig::default();
1618 let before = config.clone();
1619 let sources = vec![
1620 "example/app".into(),
1621 "https://github.com/EXAMPLE/APP.git".into(),
1622 ];
1623
1624 let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1625 assert!(error.to_string().contains("duplicate repository source"));
1626 assert_eq!(config, before);
1627 }
1628
1629 #[test]
1630 fn bundle_creation_validates_every_source_before_mutating_config() {
1631 let mut config = HelConfig::default();
1632 let before = config.clone();
1633 let invalid_directory = tempfile::tempdir().unwrap();
1634 let sources = vec![
1635 "example/app".into(),
1636 invalid_directory.path().to_string_lossy().into_owned(),
1637 ];
1638
1639 let error = create_bundle_from_sources_in_config(&mut config, &sources).unwrap_err();
1640 assert!(error.to_string().contains("not a Git repository"));
1641 assert_eq!(config, before);
1642 }
1643
1644 #[test]
1645 fn bundle_creation_reuses_an_exact_source_set_and_rejects_obsolete_pins() {
1646 let mut config = HelConfig::default();
1647 config.bundles.insert(
1648 "all".into(),
1649 ProjectBundle {
1650 primary_repo: "app".into(),
1651 repositories: vec![
1652 ProjectRepository {
1653 id: "app".into(),
1654 github: Some("example/app".into()),
1655 local: None,
1656 destination: "app".into(),
1657 git_ref: None,
1658 },
1659 ProjectRepository {
1660 id: "shared".into(),
1661 github: Some("example/shared".into()),
1662 local: None,
1663 destination: "shared".into(),
1664 git_ref: None,
1665 },
1666 ],
1667 },
1668 );
1669
1670 let one_source = vec!["example/app".into()];
1671 let created = create_bundle_from_sources_in_config(&mut config, &one_source).unwrap();
1672 assert_eq!(created, "app");
1673 assert_eq!(config.bundles[&created].repositories.len(), 1);
1674 assert_eq!(
1675 create_bundle_from_sources_in_config(&mut config, &one_source).unwrap(),
1676 "app"
1677 );
1678
1679 let exact_sources = vec!["example/app".into(), "example/shared".into()];
1680 assert_eq!(
1681 create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap(),
1682 "all"
1683 );
1684 assert_eq!(config.bundles.len(), 2);
1685 config.bundles.get_mut("all").unwrap().repositories[0].git_ref = Some("release".into());
1686 let before = config.clone();
1687 let error = create_bundle_from_sources_in_config(&mut config, &exact_sources).unwrap_err();
1688 assert!(format!("{error:#}").contains("git_ref is no longer supported"));
1689 assert_eq!(config, before);
1690 }
1691
1692 fn launch_options(additional_mounts: Vec<AdditionalMount>) -> SessionLaunchOptions {
1693 SessionLaunchOptions {
1694 initial_prompt: None,
1695 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1696 additional_mounts,
1697 allow_dirty_local: false,
1698 resource_allocation: None,
1699 project_directory: None,
1700 session_title_override: None,
1701 }
1702 }
1703
1704 #[test]
1705 fn registration_rejects_a_disabled_profile_before_persisting() {
1706 let mut config = registration_config();
1707 config.profiles.get_mut("codex").unwrap().enabled = false;
1708 let mut controller = Controller {
1709 config,
1710 state: HelState::default(),
1711 };
1712
1713 let error = controller
1714 .register_session_with_resources(
1715 "codex",
1716 "project",
1717 "podman",
1718 "disabled",
1719 launch_options(Vec::new()),
1720 )
1721 .unwrap_err();
1722
1723 assert!(error.to_string().contains("disabled"));
1724 assert!(controller.state.sessions.is_empty());
1725 }
1726
1727 #[test]
1728 fn deepseek_registration_rejects_more_than_one_workspace_root_before_persisting() {
1729 let mut config = registration_config();
1730 config.profiles.get_mut("codex").unwrap().kind = HarnessKind::Deepseek;
1731 let second = config.bundles["project"].repositories[0].clone();
1732 config
1733 .bundles
1734 .get_mut("project")
1735 .unwrap()
1736 .repositories
1737 .push(hel::hel_config::ProjectRepository {
1738 id: "second".into(),
1739 destination: "second".into(),
1740 ..second
1741 });
1742 let mut controller = Controller {
1743 config,
1744 state: HelState::default(),
1745 };
1746
1747 let error = controller
1748 .register_session_with_resources(
1749 "codex",
1750 "project",
1751 "podman",
1752 "unsupported",
1753 launch_options(Vec::new()),
1754 )
1755 .unwrap_err();
1756
1757 assert!(error.to_string().contains("one workspace root"));
1758 assert!(controller.state.sessions.is_empty());
1759 }
1760
1761 fn run_registration_child(marker: &str, test: &str, data_directory: &Path) {
1764 let output = std::process::Command::new(std::env::current_exe().unwrap())
1765 .args([
1766 "--exact",
1767 &format!("hel_controller::tests::{test}"),
1768 "--nocapture",
1769 ])
1770 .env(marker, "1")
1771 .env("MJ_DATA_DIR", data_directory)
1772 .env("MJ_CONFIG_DIR", data_directory)
1773 .output()
1774 .unwrap();
1775 assert!(
1776 output.status.success(),
1777 "isolated {test} failed\nstdout:\n{}\nstderr:\n{}",
1778 String::from_utf8_lossy(&output.stdout),
1779 String::from_utf8_lossy(&output.stderr)
1780 );
1781 }
1782
1783 #[test]
1784 fn registration_saves_the_initial_task_before_provisioning() {
1785 const MARKER: &str = "MJ_TEST_INITIAL_TASK_CHILD";
1786 if std::env::var_os(MARKER).is_none() {
1787 let directory = tempfile::tempdir().unwrap();
1788 run_registration_child(
1789 MARKER,
1790 "registration_saves_the_initial_task_before_provisioning",
1791 directory.path(),
1792 );
1793 return;
1794 }
1795 let _writer = hel::hel_database::install_isolated_test_writer();
1796 let mut controller = Controller {
1797 config: registration_config(),
1798 state: HelState::default(),
1799 };
1800 let prompt = format!(
1801 "Initial task\n{}\n\tPreserve indentation and λ",
1802 "x".repeat(70_000)
1803 );
1804 let mut options = launch_options(Vec::new());
1805 options.initial_prompt = Some(prompt.clone());
1806 let id = controller
1807 .register_session_with_resources("codex", "project", "podman", "fresh task", options)
1808 .unwrap();
1809 let saved = hel::hel_database::load_state().unwrap();
1810 assert_eq!(saved.sessions[&id].draft_input, prompt);
1811 assert_eq!(saved.sessions[&id].state, SessionState::Provisioning);
1812 hel::hel_database::set_session_draft_input(&id, "a newer draft").unwrap();
1813 hel::hel_database::clear_session_draft_input_if_matches(&id, &prompt).unwrap();
1814 assert_eq!(
1815 hel::hel_database::load_state().unwrap().sessions[&id].draft_input,
1816 "a newer draft"
1817 );
1818 hel::hel_database::clear_session_draft_input_if_matches(&id, "a newer draft").unwrap();
1819 assert!(
1820 hel::hel_database::load_state().unwrap().sessions[&id]
1821 .draft_input
1822 .is_empty()
1823 );
1824 }
1825
1826 const UNPERSISTABLE_SESSION_CHILD: &str = "MJ_TEST_UNPERSISTABLE_SESSION_CHILD";
1827
1828 const CONFIG_ID_RENAME_CHILD: &str = "MJ_TEST_CONFIG_ID_RENAME_CHILD";
1829
1830 #[test]
1831 fn configuration_id_rename_rewrites_durable_session_references() {
1832 if std::env::var_os(CONFIG_ID_RENAME_CHILD).is_none() {
1833 let directory = tempfile::tempdir().unwrap();
1834 run_registration_child(
1835 CONFIG_ID_RENAME_CHILD,
1836 "configuration_id_rename_rewrites_durable_session_references",
1837 directory.path(),
1838 );
1839 return;
1840 }
1841 let _writer = hel::hel_database::install_isolated_test_writer();
1843
1844 let mut controller = Controller {
1845 config: registration_config(),
1846 state: HelState::default(),
1847 };
1848 controller.config.startup.profile = Some("codex".into());
1849 controller.config.startup.target = Some("podman".into());
1850 controller.config.save().unwrap();
1851 let session_id = controller
1852 .register_session_with_resources(
1853 "codex",
1854 "project",
1855 "podman",
1856 "rename references",
1857 launch_options(Vec::new()),
1858 )
1859 .unwrap();
1860
1861 controller
1862 .rename_profile_id("codex", "codex-renamed")
1863 .unwrap();
1864 controller
1865 .rename_target_id("podman", "podman-renamed")
1866 .unwrap();
1867
1868 let loaded = Controller::load().unwrap();
1869 let session = &loaded.state.sessions[&session_id];
1870 assert_eq!(session.last_profile, "codex-renamed");
1871 assert_eq!(session.target_template_id, "podman-renamed");
1872 assert!(loaded.config.profiles.contains_key("codex-renamed"));
1873 assert!(loaded.config.targets.contains_key("podman-renamed"));
1874 assert_eq!(
1875 loaded.config.startup.profile.as_deref(),
1876 Some("codex-renamed")
1877 );
1878 assert_eq!(
1879 loaded.config.startup.target.as_deref(),
1880 Some("podman-renamed")
1881 );
1882 assert!(!config_rename_journal_path().exists());
1883 }
1884
1885 #[test]
1886 fn a_session_the_database_rejects_is_never_left_in_memory() {
1887 if std::env::var_os(UNPERSISTABLE_SESSION_CHILD).is_none() {
1888 let directory = tempfile::tempdir().unwrap();
1889 run_registration_child(
1890 UNPERSISTABLE_SESSION_CHILD,
1891 "a_session_the_database_rejects_is_never_left_in_memory",
1892 directory.path(),
1893 );
1894 return;
1895 }
1896 let _writer = hel::hel_database::install_isolated_test_writer();
1898
1899 let mut controller = Controller {
1900 config: registration_config(),
1901 state: HelState::default(),
1902 };
1903 controller
1909 .register_session_with_resources(
1910 "codex",
1911 "project",
1912 "podman",
1913 "first",
1914 launch_options(Vec::new()),
1915 )
1916 .expect("a healthy store registers a session");
1917 rusqlite::Connection::open(hel::hel_database::database_path())
1918 .unwrap()
1919 .execute_batch("DROP TABLE sessions")
1920 .unwrap();
1921
1922 let error = controller
1923 .register_session_with_resources(
1924 "codex",
1925 "project",
1926 "podman",
1927 "unpersistable",
1928 launch_options(Vec::new()),
1929 )
1930 .expect_err("a store that rejects the write cannot register a session");
1931 assert!(
1932 format!("{error:#}").contains("sessions"),
1933 "unexpected error: {error:#}"
1934 );
1935 assert_eq!(
1936 controller.state.sessions.len(),
1937 1,
1938 "a session the database never accepted stayed in controller memory"
1939 );
1940 assert!(
1941 controller
1942 .state
1943 .sessions
1944 .values()
1945 .all(|session| session.title != "unpersistable"),
1946 "the rejected session is the one that stayed"
1947 );
1948 }
1949
1950 const MOUNT_HISTORY_FAILURE_CHILD: &str = "MJ_TEST_MOUNT_HISTORY_FAILURE_CHILD";
1951
1952 const CONTAINER_SIZE_HISTORY_CHILD: &str = "MJ_TEST_CONTAINER_SIZE_HISTORY_CHILD";
1953
1954 #[test]
1955 fn registration_remembers_launch_size_but_session_overrides_do_not_replace_it() {
1956 if std::env::var_os(CONTAINER_SIZE_HISTORY_CHILD).is_none() {
1957 let directory = tempfile::tempdir().unwrap();
1958 run_registration_child(
1959 CONTAINER_SIZE_HISTORY_CHILD,
1960 "registration_remembers_launch_size_but_session_overrides_do_not_replace_it",
1961 directory.path(),
1962 );
1963 return;
1964 }
1965 let _writer = hel::hel_database::install_isolated_test_writer();
1967
1968 let mut controller = Controller {
1969 config: registration_config(),
1970 state: HelState::default(),
1971 };
1972 let mut options = launch_options(Vec::new());
1973 options.resource_allocation = Some(SessionResourceAllocation::Container {
1974 cpus: 12,
1975 memory_bytes: 48 * 1024 * 1024 * 1024,
1976 });
1977 let id = controller
1978 .register_session_with_resources("codex", "project", "podman", "sized", options)
1979 .unwrap();
1980 let expected = HostContainerSize {
1981 cpus: 12,
1982 memory_bytes: 48 * 1024 * 1024 * 1024,
1983 };
1984 assert_eq!(controller.state.container_sizes["local"], expected);
1985 assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1986
1987 controller
1988 .update_session_container_settings(
1989 &id,
1990 Some("2".into()),
1991 Some("4g".into()),
1992 Vec::new(),
1993 Vec::new(),
1994 )
1995 .unwrap();
1996 assert_eq!(HelState::load().unwrap().container_sizes["local"], expected);
1997 }
1998
1999 #[test]
2000 fn a_failed_mount_history_write_does_not_fail_the_registered_session() {
2001 if std::env::var_os(MOUNT_HISTORY_FAILURE_CHILD).is_none() {
2002 let directory = tempfile::tempdir().unwrap();
2003 run_registration_child(
2004 MOUNT_HISTORY_FAILURE_CHILD,
2005 "a_failed_mount_history_write_does_not_fail_the_registered_session",
2006 directory.path(),
2007 );
2008 return;
2009 }
2010 let _writer = hel::hel_database::install_isolated_test_writer();
2012
2013 let mut controller = Controller {
2014 config: registration_config(),
2015 state: HelState::default(),
2016 };
2017 controller
2019 .register_session_with_resources(
2020 "codex",
2021 "project",
2022 "podman",
2023 "first",
2024 launch_options(Vec::new()),
2025 )
2026 .expect("a healthy store registers a session");
2027 let database = hel::hel_database::database_path();
2028 rusqlite::Connection::open(&database)
2029 .unwrap()
2030 .execute_batch("DROP TABLE mount_history")
2031 .unwrap();
2032
2033 let id = controller
2034 .register_session_with_resources(
2035 "codex",
2036 "project",
2037 "podman",
2038 "attached",
2039 launch_options(vec![AdditionalMount {
2040 source: PathBuf::from("/host/models"),
2041 destination: PathBuf::from("/mnt/models"),
2042 read_only: false,
2043 }]),
2044 )
2045 .expect("a suggestion list that cannot be written must not fail a registration");
2046
2047 let stored: i64 = rusqlite::Connection::open(&database)
2048 .unwrap()
2049 .query_row(
2050 "SELECT count(*) FROM sessions WHERE session_id = ?1",
2051 [&id],
2052 |row| row.get(0),
2053 )
2054 .unwrap();
2055 assert_eq!(stored, 1, "the registered session was not committed");
2056 assert!(
2057 controller.state.mount_history.is_empty(),
2058 "controller memory remembered mount sources the database never stored"
2059 );
2060 }
2061
2062 #[test]
2063 fn command_errors_report_the_root_cause_without_worker_wrappers() {
2064 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";
2065
2066 assert_eq!(
2067 command_error_detail(stderr),
2068 "checkpoint base b41dc78 is absent from configured source\nrepository may have moved"
2069 );
2070 }
2071
2072 #[test]
2073 fn controller_store_lock_excludes_a_second_process_owner() {
2074 let directory = tempfile::tempdir().unwrap();
2075 let first = ControllerStoreGuard::acquire_at(directory.path()).unwrap();
2076 run_controller_lock_probe(directory.path(), true);
2077 drop(first);
2078 run_controller_lock_probe(directory.path(), false);
2079 }
2080 fn run_controller_lock_probe(directory: &Path, expect_locked: bool) {
2081 let output = std::process::Command::new(std::env::current_exe().unwrap())
2082 .args([
2083 "--exact",
2084 "hel_controller::tests::controller_store_lock_subprocess_probe",
2085 "--nocapture",
2086 ])
2087 .env("MJ_CONTROLLER_LOCK_PROBE", directory)
2088 .env(
2089 "MJ_CONTROLLER_LOCK_EXPECTED",
2090 if expect_locked { "locked" } else { "available" },
2091 )
2092 .output()
2093 .unwrap();
2094 assert!(
2095 output.status.success(),
2096 "controller lock subprocess failed:\nstdout:\n{}\nstderr:\n{}",
2097 String::from_utf8_lossy(&output.stdout),
2098 String::from_utf8_lossy(&output.stderr)
2099 );
2100 }
2101 #[test]
2102 fn controller_store_lock_subprocess_probe() {
2103 let Some(directory) = std::env::var_os("MJ_CONTROLLER_LOCK_PROBE") else {
2104 return;
2105 };
2106 let expected = std::env::var("MJ_CONTROLLER_LOCK_EXPECTED").unwrap();
2107 let acquired = ControllerStoreGuard::acquire_at(Path::new(&directory));
2108 match expected.as_str() {
2109 "locked" => {
2110 let error = acquired.expect_err("a second process acquired the controller store");
2111 assert!(error.to_string().contains("another Mjolnir controller"));
2112 }
2113 "available" => {
2114 acquired.expect("released controller store stayed locked");
2115 }
2116 value => panic!("unexpected lock probe expectation {value:?}"),
2117 }
2118 }
2119 #[test]
2120 fn local_mount_source_must_be_an_existing_directory() {
2121 let directory = tempfile::tempdir().unwrap();
2122 let file = directory.path().join("file");
2123 std::fs::write(&file, "not a directory").unwrap();
2124 let mut config = HelConfig::default();
2125 config.targets.insert(
2126 "local".into(),
2127 TargetTemplate::LocalPodman {
2128 container: ConfigContainer {
2129 image: "ubuntu:24.04".into(),
2130 pull_policy: Default::default(),
2131 platform: None,
2132 cpus: None,
2133 memory: None,
2134 environment: BTreeMap::new(),
2135 workspace_storage: Default::default(),
2136 },
2137 },
2138 );
2139 let controller = Controller {
2140 config,
2141 state: HelState::default(),
2142 };
2143
2144 assert!(
2145 controller
2146 .validate_mount_source("local", directory.path(), &ProcessExecutor)
2147 .is_ok()
2148 );
2149 for invalid in [file, directory.path().join("missing")] {
2150 let error = controller
2151 .validate_mount_source("local", &invalid, &ProcessExecutor)
2152 .unwrap_err();
2153 assert!(
2154 error
2155 .to_string()
2156 .contains("does not exist or is not a directory")
2157 );
2158 }
2159 }
2160}