1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6
7use crate::session_manager::StandaloneSession;
8use mj_core::config::{AwsAddressSource, SshConnection, TargetTemplate};
9use mj_core::state::{SessionRecord, SessionState, TargetLocator, normalize_session_title};
10
11use crate::targets::{self, CommandExecutor, CommandOutput, CommandSpec, SshTarget};
12use mj_core::worker_launch::WorkerOwnership;
13
14use super::backend::{ContainerOverrides, backend_locator, backend_target};
15use super::readiness::wait_for_native_session;
16use super::{Controller, backend_ssh, now, ssh_args_with_identity};
17
18pub use mj_core::state::{RecoveryCandidate, RecoveryScan};
19
20impl Controller {
21 pub fn scan_orphan_workers(&self, executor: &impl CommandExecutor) -> RecoveryScan {
25 let mut scan = RecoveryScan::default();
26 for (target_id, template) in &self.config.targets {
27 match scan_target_workers(target_id, template, executor) {
28 Ok(candidates) => {
29 scan.candidates
30 .extend(candidates.into_iter().filter(|candidate| {
31 !self.state.sessions.contains_key(&candidate.session_id)
32 }))
33 }
34 Err(error) => scan.warnings.push(format!("target {target_id}: {error:#}")),
35 }
36 }
37 scan.candidates.sort_by(|left, right| {
38 (&left.session_id, &left.target_template_id)
39 .cmp(&(&right.session_id, &right.target_template_id))
40 });
41 scan.candidates.dedup_by(|left, right| {
42 left.session_id == right.session_id
43 && left.target_template_id == right.target_template_id
44 });
45 scan
46 }
47
48 pub async fn adopt_orphan_worker(
49 &mut self,
50 session_id: &str,
51 target_id: &str,
52 profile_override: Option<&str>,
53 bundle_override: Option<&str>,
54 executor: &impl CommandExecutor,
55 ) -> Result<()> {
56 let (record, newly_adopted) = match self.state.sessions.get(session_id).cloned() {
57 Some(existing) if adoption_unfinished(&existing, target_id) => {
61 for (flag, requested, adopted) in [
62 ("profile", profile_override, existing.last_profile.as_str()),
63 ("bundle", bundle_override, existing.bundle_id.as_str()),
64 ] {
65 if let Some(requested) = requested
66 && requested != adopted
67 {
68 bail!(
69 "session {session_id} was already adopted with {flag} {adopted:?}; retry without --{flag}"
70 );
71 }
72 }
73 (existing, false)
74 }
75 Some(_) => bail!("session {session_id} is already tracked"),
76 None => {
77 let candidate = self
78 .scan_orphan_workers(executor)
79 .candidates
80 .into_iter()
81 .find(|candidate| {
82 candidate.session_id == session_id
83 && candidate.target_template_id == target_id
84 })
85 .with_context(|| {
86 format!("no managed orphan {session_id} was found on target {target_id:?}")
87 })?;
88 let profile_id = profile_override
89 .map(str::to_owned)
90 .or_else(|| {
91 candidate
92 .ownership
93 .as_ref()
94 .map(|marker| marker.profile_id.clone())
95 })
96 .context("orphan has no ownership marker; pass --profile")?;
97 let bundle_id = bundle_override
98 .map(str::to_owned)
99 .or_else(|| {
100 candidate
101 .ownership
102 .as_ref()
103 .map(|marker| marker.bundle_id.clone())
104 })
105 .context("orphan has no ownership marker; pass --bundle")?;
106 let profile = self
107 .config
108 .profiles
109 .get(&profile_id)
110 .with_context(|| format!("unknown profile {profile_id:?}"))?;
111 self.config
112 .bundles
113 .get(&bundle_id)
114 .with_context(|| format!("unknown bundle {bundle_id:?}"))?;
115 let workspace_id = resolve_recovery_workspace_id(
116 candidate
117 .ownership
118 .as_ref()
119 .map(|ownership| ownership.workspace_id.as_str())
120 .unwrap_or(mj_core::workspace::DEFAULT_WORKSPACE_ID),
121 )?;
122 let record = adopted_session_record(
123 session_id,
124 target_id,
125 profile_id,
126 profile.kind,
127 bundle_id,
128 workspace_id,
129 candidate.locator,
130 );
131 (record, true)
132 }
133 };
134 let locator = record
135 .target
136 .as_ref()
137 .context("adopted session has no target locator")?;
138 let backend = backend_locator(locator, &record, &self.config)?;
139 let spec = targets::reconnect_plan(&backend, session_id)?
140 .commands
141 .into_iter()
142 .next()
143 .context("reconnect plan is empty")?;
144 if newly_adopted {
145 crate::database::save_session(&record)?;
150 self.state.sessions.insert(session_id.to_owned(), record);
151 }
152 match self.complete_adoption(session_id, &spec, executor).await {
153 Ok(()) => Ok(()),
154 Err(error) => Err(self.record_adoption_failure(session_id, error)),
158 }
159 }
160
161 async fn complete_adoption(
163 &mut self,
164 session_id: &str,
165 spec: &CommandSpec,
166 executor: &impl CommandExecutor,
167 ) -> Result<()> {
168 let mut relay = StandaloneSession::connect_command(spec, session_id)
169 .await
170 .context("orphan relay did not complete the v1 handshake")?;
171 let native_session_id = wait_for_native_session(&mut relay, executor).await?;
172 self.mark_worker_connected(session_id, Some(native_session_id))?;
173 if let Some(title) = relay
174 .snapshot()
175 .materialized
176 .session_title
177 .as_deref()
178 .and_then(normalize_session_title)
179 {
180 crate::database::set_session_acp_title(session_id, Some(&title))?;
181 self.state
182 .sessions
183 .get_mut(session_id)
184 .expect("adopted session disappeared while saving its ACP title")
185 .acp_session_title = Some(title);
186 }
187 Ok(())
188 }
189
190 fn record_adoption_failure(&mut self, session_id: &str, error: anyhow::Error) -> anyhow::Error {
195 let Some(record) = self.state.sessions.get_mut(session_id) else {
196 return error;
197 };
198 record.updated_at = now();
199 record.last_error = Some(format!("orphan adoption failed: {error:#}"));
200 match self.persist_session_state(session_id) {
201 Ok(()) => error,
202 Err(persist_error) => error.context(format!(
203 "recorded the adoption failure in memory, but failed to persist it: {persist_error:#}"
204 )),
205 }
206 }
207
208 pub fn destroy_orphan_worker(
209 &self,
210 session_id: &str,
211 target_id: &str,
212 confirmation: &str,
213 executor: &impl CommandExecutor,
214 ) -> Result<()> {
215 if confirmation != session_id {
216 bail!("refusing destructive recovery: --confirm must exactly match the session ID");
217 }
218 let candidate = self
219 .scan_orphan_workers(executor)
220 .candidates
221 .into_iter()
222 .find(|candidate| {
223 candidate.session_id == session_id && candidate.target_template_id == target_id
224 })
225 .with_context(|| {
226 format!("no managed orphan {session_id} was found on target {target_id:?}")
227 })?;
228 let template = self.config.targets.get(target_id).unwrap();
229 let backend = recovery_backend_locator(template, &candidate.locator, session_id)?;
230 targets::close_plan(&backend, session_id)?
231 .execute(executor)
232 .map(|_| ())
233 }
234}
235
236fn resolve_recovery_workspace_id(marked_workspace_id: &str) -> Result<String> {
240 if marked_workspace_id == mj_core::workspace::DEFAULT_WORKSPACE_ID {
241 return Ok(marked_workspace_id.to_owned());
242 }
243 if crate::database::list_workspaces()?
244 .into_iter()
245 .any(|workspace| workspace.id == marked_workspace_id)
246 {
247 return Ok(marked_workspace_id.to_owned());
248 }
249 Ok(crate::database::create_or_get_workspace("Recovered")?.id)
250}
251
252fn adopted_session_record(
254 session_id: &str,
255 target_id: &str,
256 profile_id: String,
257 harness_kind: mj_core::config::HarnessKind,
258 bundle_id: String,
259 workspace_id: String,
260 locator: TargetLocator,
261) -> SessionRecord {
262 let now = now();
263 SessionRecord {
264 mjolnir_subagents: None,
265 create_managed_worktree: None,
266 workspace_id,
267 archived: false,
268 container_cpus: None,
269 container_memory: None,
270 id: session_id.to_owned(),
271 title: format!("Recovered {}", &session_id[..session_id.len().min(8)]),
272 harness_kind,
273 last_profile: profile_id,
274 bundle_id,
275 project_directory: None,
276 managed_worktree: None,
277 target_template_id: target_id.to_owned(),
278 resource_allocation: None,
279 additional_mounts: Vec::new(),
280 state: SessionState::Disconnected,
281 target: Some(locator),
282 native_session_id: None,
283 acp_session_title: None,
284 session_title_override: None,
285 created_at: now.clone(),
286 updated_at: now,
287 viewed_through_event_ordinal: 0,
288 draft_input: String::new(),
289 last_error: None,
290 last_checkpoint_error: None,
291 checkpoint: None,
292 }
293}
294
295fn adoption_unfinished(record: &SessionRecord, target_id: &str) -> bool {
300 record.state == SessionState::Disconnected
301 && record.native_session_id.is_none()
302 && record.target_template_id == target_id
303 && record.target.is_some()
304}
305
306fn scan_target_workers(
307 target_id: &str,
308 template: &TargetTemplate,
309 executor: &impl CommandExecutor,
310) -> Result<Vec<RecoveryCandidate>> {
311 let mut candidates = match template {
312 TargetTemplate::LocalBare => Vec::new(),
315 TargetTemplate::LocalPodman { .. } => scan_container_engine(
316 target_id,
317 template,
318 "podman",
319 vec![
320 "ps".into(),
321 "--all".into(),
322 "--filter".into(),
323 format!("label={}=true", targets::MANAGED_LABEL),
324 "--format".into(),
325 "json".into(),
326 ],
327 executor,
328 )?,
329 TargetTemplate::LocalDocker { .. } => scan_container_engine(
330 target_id,
331 template,
332 "docker",
333 vec![
334 "ps".into(),
335 "--all".into(),
336 "--filter".into(),
337 format!("label={}=true", targets::MANAGED_LABEL),
338 "--format".into(),
339 "json".into(),
340 ],
341 executor,
342 )?,
343 TargetTemplate::AppleContainer { .. } => scan_container_engine(
344 target_id,
345 template,
346 "container",
347 vec![
348 "list".into(),
349 "--all".into(),
350 "--format".into(),
351 "json".into(),
352 ],
353 executor,
354 )?,
355 TargetTemplate::SshPodman { ssh, .. } => {
356 let remote = targets::join_remote_command(&[
357 "podman".into(),
358 "ps".into(),
359 "--all".into(),
360 "--filter".into(),
361 format!("label={}=true", targets::MANAGED_LABEL),
362 "--format".into(),
363 "json".into(),
364 ]);
365 let output = execute_scan(
366 executor,
367 ssh_spec(ssh, [remote]),
368 "scan remote Podman workers",
369 )?;
370 candidates_from_container_json(target_id, template, &output.stdout)?
371 }
372 TargetTemplate::SshDocker { ssh, .. } => {
373 let remote = targets::join_remote_command(&[
374 "docker".into(),
375 "ps".into(),
376 "--all".into(),
377 "--filter".into(),
378 format!("label={}=true", targets::MANAGED_LABEL),
379 "--format".into(),
380 "json".into(),
381 ]);
382 let output = execute_scan(
383 executor,
384 ssh_spec(ssh, [remote]),
385 "scan remote Docker workers",
386 )?;
387 candidates_from_container_json(target_id, template, &output.stdout)?
388 }
389 TargetTemplate::AwsEc2 {
390 aws_profile,
391 region,
392 address_source,
393 ..
394 } => {
395 let profile = aws_profile.clone().unwrap_or_else(|| "default".into());
396 let output = execute_scan(
397 executor,
398 CommandSpec::new(
399 "aws",
400 [
401 "--profile".into(),
402 profile,
403 "--region".into(),
404 region.clone(),
405 "ec2".into(),
406 "describe-instances".into(),
407 "--filters".into(),
408 format!("Name=tag:{},Values=true", targets::MANAGED_TAG),
409 "Name=instance-state-name,Values=pending,running,stopping,stopped".into(),
410 "--output".into(),
411 "json".into(),
412 ],
413 )
414 .purpose("scan managed EC2 workers"),
415 "scan managed EC2 workers",
416 )?;
417 candidates_from_aws_json(target_id, address_source.clone(), &output.stdout)?
418 }
419 TargetTemplate::SshBare { ssh, .. } => {
420 let output = execute_scan(
421 executor,
422 ssh_spec(
423 ssh,
424 [targets::join_remote_command(&[
425 "find".into(),
426 ".local/share/hel/workers".into(),
427 "-mindepth".into(),
428 "2".into(),
429 "-maxdepth".into(),
430 "2".into(),
431 "-name".into(),
432 "ownership.json".into(),
433 "-print".into(),
434 ])],
435 ),
436 "scan bare SSH worker markers",
437 )?;
438 output
439 .stdout
440 .split(|byte| *byte == b'\n')
441 .filter_map(|line| {
442 let path = match std::str::from_utf8(line) {
443 Ok(path) => path.trim(),
444 Err(error) => {
445 tracing::debug!(%error, "recovery scan skipped a non-UTF-8 worker marker path");
446 return None;
447 }
448 };
449 let Some(session_id) = Path::new(path)
450 .parent()
451 .and_then(|parent| parent.file_name())
452 .and_then(|name| name.to_str())
453 else {
454 tracing::debug!(path, "recovery scan skipped a malformed worker marker path");
455 return None;
456 };
457 if let Err(error) = targets::resource_name(session_id) {
458 tracing::debug!(session_id, %error, "recovery scan skipped an invalid session id");
459 return None;
460 }
461 let backend = match backend_target(template, None, ContainerOverrides::default()) {
462 Ok(backend) => backend,
463 Err(error) => {
464 tracing::debug!(session_id, %error, "recovery scan could not construct the target backend");
465 return None;
466 }
467 };
468 let workspace = match targets::workspace_for(&backend, session_id) {
469 Ok(workspace) => workspace,
470 Err(error) => {
471 tracing::debug!(session_id, %error, "recovery scan could not derive the target workspace");
472 return None;
473 }
474 };
475 Some(RecoveryCandidate {
476 session_id: session_id.to_owned(),
477 target_template_id: target_id.to_owned(),
478 locator: TargetLocator::SshBare {
479 host: ssh.host.clone(),
480 workspace: PathBuf::from(workspace),
481 worker_id: None,
482 },
483 ownership: None,
484 })
485 })
486 .collect()
487 }
488 };
489 for candidate in &mut candidates {
490 candidate.ownership = read_recovery_ownership(template, candidate, executor);
491 }
492 Ok(candidates)
493}
494
495fn scan_container_engine(
496 target_id: &str,
497 template: &TargetTemplate,
498 engine: &str,
499 args: Vec<String>,
500 executor: &impl CommandExecutor,
501) -> Result<Vec<RecoveryCandidate>> {
502 let output = execute_scan(
503 executor,
504 CommandSpec::new(engine, args).purpose("scan managed container workers"),
505 "scan managed container workers",
506 )?;
507 candidates_from_container_json(target_id, template, &output.stdout)
508}
509
510fn candidates_from_container_json(
511 target_id: &str,
512 template: &TargetTemplate,
513 stdout: &[u8],
514) -> Result<Vec<RecoveryCandidate>> {
515 let sessions = managed_sessions_from_container_json(stdout)?;
516 Ok(sessions
517 .into_iter()
518 .filter_map(|session_id| {
519 let generated = match targets::resource_name(&session_id) {
520 Ok(generated) => generated,
521 Err(error) => {
522 tracing::debug!(%session_id, %error, "recovery scan skipped an invalid managed session id");
523 return None;
524 }
525 };
526 let locator = match template {
527 TargetTemplate::LocalPodman { .. } => TargetLocator::LocalPodman {
528 container_id: generated,
529 workspace_storage: Default::default(),
530 },
531 TargetTemplate::LocalDocker { .. } => TargetLocator::LocalDocker {
532 container_id: generated,
533 },
534 TargetTemplate::AppleContainer { .. } => TargetLocator::AppleContainer {
535 container_id: generated,
536 },
537 TargetTemplate::SshPodman { ssh, .. } => TargetLocator::SshPodman {
538 host: ssh.host.clone(),
539 container_id: generated,
540 workspace_storage: Default::default(),
541 },
542 TargetTemplate::SshDocker { ssh, .. } => TargetLocator::SshDocker {
543 host: ssh.host.clone(),
544 container_id: generated,
545 },
546 _ => return None,
547 };
548 Some(RecoveryCandidate {
549 session_id,
550 target_template_id: target_id.to_owned(),
551 locator,
552 ownership: None,
553 })
554 })
555 .collect())
556}
557
558pub(super) fn managed_sessions_from_container_json(stdout: &[u8]) -> Result<Vec<String>> {
559 let values = serde_json::Deserializer::from_slice(stdout)
560 .into_iter::<serde_json::Value>()
561 .collect::<std::result::Result<Vec<_>, _>>()
562 .context("parse container list JSON")?;
563 let mut sessions = Vec::new();
564 for value in &values {
565 collect_managed_sessions(value, &mut sessions);
566 }
567 sessions.sort();
568 sessions.dedup();
569 Ok(sessions)
570}
571
572pub(super) fn collect_managed_sessions(value: &serde_json::Value, sessions: &mut Vec<String>) {
573 match value {
574 serde_json::Value::Array(values) => {
575 for value in values {
576 collect_managed_sessions(value, sessions);
577 }
578 }
579 serde_json::Value::Object(object) => {
580 for label_key in ["Labels", "labels"] {
581 if let Some(labels) = object.get(label_key) {
582 let managed = label_value(labels, targets::MANAGED_LABEL)
583 .is_some_and(|value| value == "true");
584 if managed && let Some(session) = label_value(labels, targets::SESSION_LABEL) {
585 sessions.push(session);
586 }
587 }
588 }
589 for value in object.values() {
590 collect_managed_sessions(value, sessions);
591 }
592 }
593 _ => {}
594 }
595}
596
597fn label_value(labels: &serde_json::Value, key: &str) -> Option<String> {
598 match labels {
599 serde_json::Value::Object(object) => object.get(key)?.as_str().map(str::to_owned),
600 serde_json::Value::String(text) => text
601 .split(',')
602 .find_map(|label| {
603 label
604 .trim()
605 .split_once('=')
606 .filter(|(name, _)| *name == key)
607 })
608 .map(|(_, value)| value.to_owned()),
609 _ => None,
610 }
611}
612
613fn candidates_from_aws_json(
614 target_id: &str,
615 address_source: AwsAddressSource,
616 stdout: &[u8],
617) -> Result<Vec<RecoveryCandidate>> {
618 let value: serde_json::Value =
619 serde_json::from_slice(stdout).context("parse AWS instance JSON")?;
620 let mut result = Vec::new();
621 let reservations = value
622 .get("Reservations")
623 .and_then(serde_json::Value::as_array)
624 .cloned()
625 .unwrap_or_default();
626 for instance in reservations.iter().flat_map(|reservation| {
627 reservation
628 .get("Instances")
629 .and_then(serde_json::Value::as_array)
630 .into_iter()
631 .flatten()
632 }) {
633 let tags = instance
634 .get("Tags")
635 .and_then(serde_json::Value::as_array)
636 .cloned()
637 .unwrap_or_default();
638 let tag = |key: &str| {
639 tags.iter()
640 .find(|tag| tag.get("Key").and_then(serde_json::Value::as_str) == Some(key))
641 .and_then(|tag| tag.get("Value"))
642 .and_then(serde_json::Value::as_str)
643 };
644 if tag(targets::MANAGED_TAG) != Some("true") {
645 continue;
646 }
647 let Some(session_id) = tag(targets::SESSION_TAG).map(str::to_owned) else {
648 continue;
649 };
650 targets::resource_name(&session_id)?;
651 let instance_id = instance
652 .get("InstanceId")
653 .and_then(serde_json::Value::as_str)
654 .context("managed EC2 instance omitted InstanceId")?
655 .to_owned();
656 let field = match address_source {
657 AwsAddressSource::PublicDns => "PublicDnsName",
658 AwsAddressSource::PublicIp => "PublicIpAddress",
659 AwsAddressSource::PrivateDns => "PrivateDnsName",
660 AwsAddressSource::PrivateIp => "PrivateIpAddress",
661 };
662 let address = instance
663 .get(field)
664 .and_then(serde_json::Value::as_str)
665 .filter(|value| !value.is_empty())
666 .map(str::to_owned);
667 result.push(RecoveryCandidate {
668 session_id,
669 target_template_id: target_id.to_owned(),
670 locator: TargetLocator::AwsEc2 {
671 instance_id,
672 address,
673 },
674 ownership: None,
675 });
676 }
677 Ok(result)
678}
679
680fn execute_scan(
681 executor: &impl CommandExecutor,
682 command: CommandSpec,
683 operation: &str,
684) -> Result<CommandOutput> {
685 let output = executor.execute(&command)?;
686 if output.status != 0 {
687 bail!(
688 "{operation} failed with status {}: {}",
689 output.status,
690 String::from_utf8_lossy(&output.stderr).trim()
691 );
692 }
693 Ok(output)
694}
695
696fn ssh_spec(ssh: &SshConnection, remote: impl IntoIterator<Item = String>) -> CommandSpec {
697 let backend = backend_ssh(ssh);
698 let mut args = backend.ssh_args;
699 args.push(backend.destination.clone());
700 args.extend(remote);
701 CommandSpec::new("ssh", args).ssh_destination(backend.destination)
702}
703
704fn read_recovery_ownership(
705 template: &TargetTemplate,
706 candidate: &RecoveryCandidate,
707 executor: &impl CommandExecutor,
708) -> Option<WorkerOwnership> {
709 let backend =
710 match recovery_backend_locator(template, &candidate.locator, &candidate.session_id) {
711 Ok(backend) => backend,
712 Err(error) => {
713 tracing::debug!(
714 session_id = %candidate.session_id,
715 %error,
716 "could not construct a recovery ownership probe"
717 );
718 return None;
719 }
720 };
721 let root = match targets::worker_root(&backend, &candidate.session_id) {
722 Ok(root) => root,
723 Err(error) => {
724 tracing::debug!(
725 session_id = %candidate.session_id,
726 %error,
727 "could not derive a recovery worker root"
728 );
729 return None;
730 }
731 };
732 let command = match targets::command_on_locator(
733 &backend,
734 &candidate.session_id,
735 vec!["cat".into(), format!("{root}/ownership.json")],
736 "read worker ownership marker",
737 ) {
738 Ok(command) => command,
739 Err(error) => {
740 tracing::debug!(
741 session_id = %candidate.session_id,
742 %error,
743 "could not construct a recovery ownership command"
744 );
745 return None;
746 }
747 };
748 let output = match executor.execute(&command) {
749 Ok(output) => output,
750 Err(error) => {
751 tracing::debug!(
752 session_id = %candidate.session_id,
753 %error,
754 "could not read a recovery worker ownership marker"
755 );
756 return None;
757 }
758 };
759 if output.status != 0 {
760 tracing::debug!(
761 session_id = %candidate.session_id,
762 status = output.status,
763 "recovery worker ownership probe returned a failure"
764 );
765 return None;
766 }
767 let marker: WorkerOwnership = match serde_json::from_slice(&output.stdout) {
768 Ok(marker) => marker,
769 Err(error) => {
770 tracing::debug!(
771 session_id = %candidate.session_id,
772 %error,
773 "recovery worker ownership marker was not valid JSON"
774 );
775 return None;
776 }
777 };
778 if !(1..=WorkerOwnership::VERSION).contains(&marker.version)
779 || marker.session_id != candidate.session_id
780 || marker.target_template_id != candidate.target_template_id
781 {
782 tracing::debug!(
783 session_id = %candidate.session_id,
784 marker_session_id = %marker.session_id,
785 marker_target_template_id = %marker.target_template_id,
786 "recovery worker ownership marker did not match the candidate"
787 );
788 return None;
789 }
790 Some(marker)
791}
792
793fn recovery_backend_locator(
794 template: &TargetTemplate,
795 locator: &TargetLocator,
796 session_id: &str,
797) -> Result<targets::TargetLocator> {
798 Ok(match (template, locator) {
799 (TargetTemplate::LocalBare, TargetLocator::LocalBare { worker_root }) => {
800 targets::TargetLocator::LocalBare {
801 worker_root: worker_root.to_string_lossy().into_owned(),
802 }
803 }
804 (TargetTemplate::LocalPodman { .. }, TargetLocator::LocalPodman { container_id, .. }) => {
805 targets::TargetLocator::LocalPodman {
806 container_id: container_id.clone(),
807 workspace_storage: Default::default(),
808 }
809 }
810 (TargetTemplate::LocalDocker { .. }, TargetLocator::LocalDocker { container_id }) => {
811 targets::TargetLocator::LocalDocker {
812 container_id: container_id.clone(),
813 }
814 }
815 (TargetTemplate::AppleContainer { .. }, TargetLocator::AppleContainer { container_id }) => {
816 targets::TargetLocator::AppleContainer {
817 container_id: container_id.clone(),
818 }
819 }
820 (TargetTemplate::SshPodman { ssh, .. }, TargetLocator::SshPodman { container_id, .. }) => {
821 targets::TargetLocator::SshPodman {
822 ssh: backend_ssh(ssh),
823 container_id: container_id.clone(),
824 workspace_storage: Default::default(),
825 }
826 }
827 (
828 TargetTemplate::SshDocker { ssh, .. },
829 TargetLocator::SshDocker { host, container_id },
830 ) => {
831 if host != &ssh.host {
832 bail!("recovery SSH Docker host does not match target template")
833 }
834 targets::TargetLocator::SshDocker {
835 ssh: backend_ssh(ssh),
836 container_id: container_id.clone(),
837 }
838 }
839 (TargetTemplate::SshBare { ssh, .. }, TargetLocator::SshBare { workspace, .. }) => {
840 targets::TargetLocator::SshBare {
841 ssh: backend_ssh(ssh),
842 workspace: workspace.to_string_lossy().into_owned(),
843 worker_id: None,
844 }
845 }
846 (
847 TargetTemplate::AwsEc2 {
848 aws_profile,
849 region,
850 ssh_user,
851 identity_file,
852 ssh_args,
853 ..
854 },
855 TargetLocator::AwsEc2 {
856 instance_id,
857 address,
858 },
859 ) => targets::TargetLocator::AwsEc2 {
860 profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
861 region: region.clone(),
862 instance_id: instance_id.clone(),
863 ssh: SshTarget {
864 destination: format!(
865 "{ssh_user}@{}",
866 address.as_deref().unwrap_or("unavailable.invalid")
867 ),
868 ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
869 },
870 workspace: format!(".local/share/hel/workspaces/{session_id}"),
871 },
872 _ => bail!("recovery target locator does not match target template"),
873 })
874}
875
876#[cfg(test)]
877mod tests {
878 use std::collections::BTreeMap;
879
880 use mj_core::config::{
881 AwsAddressSource, Config, ContainerTemplate as ConfigContainer, HarnessKind, TargetTemplate,
882 };
883 use mj_core::state::{State, TargetLocator};
884
885 use crate::targets::ProcessExecutor;
886
887 use super::*;
888
889 const FAILED_ADOPTION_CHILD: &str = "MJ_TEST_FAILED_ADOPTION_CHILD";
890
891 #[tokio::test]
892 async fn a_failed_adoption_records_the_failure_and_stays_retryable() {
893 if std::env::var_os(FAILED_ADOPTION_CHILD).is_none() {
896 let directory = tempfile::tempdir().unwrap();
897 let output = std::process::Command::new(std::env::current_exe().unwrap())
898 .args([
899 "--exact",
900 "controller::recovery_scan::tests::\
901 a_failed_adoption_records_the_failure_and_stays_retryable",
902 "--nocapture",
903 ])
904 .env(FAILED_ADOPTION_CHILD, "1")
905 .env("MJ_DATA_DIR", directory.path())
906 .output()
907 .unwrap();
908 assert!(
909 output.status.success(),
910 "isolated adoption retry test failed\nstdout:\n{}\nstderr:\n{}",
911 String::from_utf8_lossy(&output.stdout),
912 String::from_utf8_lossy(&output.stderr)
913 );
914 return;
915 }
916 let _writer = crate::database::install_isolated_test_writer();
918
919 let session_id = "0123456789abcdef0123456789abcdef";
920 let workers = tempfile::tempdir().unwrap();
921 let record = adopted_session_record(
924 session_id,
925 "local-bare",
926 "codex".into(),
927 HarnessKind::Codex,
928 "project".into(),
929 mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
930 TargetLocator::LocalBare {
931 worker_root: workers.path().join(session_id),
932 },
933 );
934 assert!(
935 adoption_unfinished(&record, "local-bare"),
936 "the record adoption commits must be the record adoption can retry"
937 );
938 crate::database::save_session(&record).unwrap();
939 let mut config = Config::default();
940 config
941 .targets
942 .insert("local-bare".into(), TargetTemplate::LocalBare);
943 let mut state = State::default();
944 state.sessions.insert(session_id.to_owned(), record);
945 let mut controller = Controller { config, state };
946
947 let failure = controller
948 .adopt_orphan_worker(session_id, "local-bare", None, None, &ProcessExecutor)
949 .await
950 .expect_err("a worker root without a worker cannot complete the handshake");
951 assert!(
952 format!("{failure:#}").contains("orphan relay"),
953 "unexpected failure: {failure:#}"
954 );
955 let recorded = controller.state.sessions[session_id]
956 .last_error
957 .clone()
958 .expect("the failed handshake was recorded on the session");
959 assert!(
960 recorded.contains("orphan adoption failed"),
961 "unexpected recorded failure: {recorded}"
962 );
963 assert_eq!(
964 controller.state.sessions[session_id].state,
965 SessionState::Disconnected
966 );
967 let stored = crate::database::load_state().unwrap();
968 assert_eq!(
969 stored.sessions[session_id].last_error.as_deref(),
970 Some(recorded.as_str()),
971 "the adoption failure was not persisted"
972 );
973
974 let retry = controller
975 .adopt_orphan_worker(session_id, "local-bare", None, None, &ProcessExecutor)
976 .await
977 .expect_err("the worker is still unreachable");
978 let retry = format!("{retry:#}");
979 assert!(
980 retry.contains("orphan relay"),
981 "adoption did not retry the handshake: {retry}"
982 );
983 assert!(
984 !retry.contains("already tracked"),
985 "a session adoption never finished blocked its own retry: {retry}"
986 );
987 }
988
989 const RECOVERY_WORKSPACE_CHILD: &str = "MJ_TEST_RECOVERY_WORKSPACE_CHILD";
990
991 #[tokio::test]
992 async fn orphan_workspace_ids_are_reconciled_before_adoption_persistence() {
993 if std::env::var_os(RECOVERY_WORKSPACE_CHILD).is_none() {
994 let directory = tempfile::tempdir().unwrap();
995 let test = "orphan_workspace_ids_are_reconciled_before_adoption_persistence";
996 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
997 command
998 .args([
999 "--exact",
1000 &format!("controller::recovery_scan::tests::{test}"),
1001 "--nocapture",
1002 ])
1003 .env(RECOVERY_WORKSPACE_CHILD, "1")
1004 .env("MJ_DATA_DIR", directory.path());
1005 let output = mj_core::subprocess::run_with_input(&mut command, &[]).unwrap();
1006 assert!(
1007 output.status.success(),
1008 "isolated recovery workspace test failed\nstdout:\n{}\nstderr:\n{}",
1009 String::from_utf8_lossy(&output.stdout),
1010 String::from_utf8_lossy(&output.stderr)
1011 );
1012 return;
1013 }
1014
1015 let _writer = crate::database::install_isolated_test_writer();
1016 let default =
1017 resolve_recovery_workspace_id(mj_core::workspace::DEFAULT_WORKSPACE_ID).unwrap();
1018 assert_eq!(default, mj_core::workspace::DEFAULT_WORKSPACE_ID);
1019
1020 let known = crate::database::create_or_get_workspace("Known").unwrap();
1021 assert_eq!(resolve_recovery_workspace_id(&known.id).unwrap(), known.id);
1022
1023 let recovered = resolve_recovery_workspace_id("workspace-from-old-controller").unwrap();
1024 let repeated = resolve_recovery_workspace_id("another-old-workspace").unwrap();
1025 assert_eq!(repeated, recovered);
1026 assert_eq!(
1027 crate::database::list_workspaces()
1028 .unwrap()
1029 .iter()
1030 .filter(|workspace| workspace.name == "Recovered")
1031 .count(),
1032 1
1033 );
1034
1035 let session_id = "0123456789abcdef0123456789abcdef";
1036 let workers = tempfile::tempdir().unwrap();
1037 let record = adopted_session_record(
1038 session_id,
1039 "local-bare",
1040 "codex".into(),
1041 HarnessKind::Codex,
1042 "project".into(),
1043 recovered.clone(),
1044 TargetLocator::LocalBare {
1045 worker_root: workers.path().join(session_id),
1046 },
1047 );
1048 crate::database::save_session(&record).unwrap();
1049 assert_eq!(
1050 crate::database::load_state().unwrap().sessions[session_id].workspace_id,
1051 recovered
1052 );
1053
1054 let mut state = State::default();
1058 state.sessions.insert(session_id.to_owned(), record);
1059 let mut config = Config::default();
1060 config
1061 .targets
1062 .insert("local-bare".into(), TargetTemplate::LocalBare);
1063 let mut controller = Controller { config, state };
1064 let failure = controller
1065 .adopt_orphan_worker(session_id, "local-bare", None, None, &ProcessExecutor)
1066 .await
1067 .expect_err("a worker root without a worker cannot complete the handshake");
1068 assert!(
1069 format!("{failure:#}").contains("orphan relay"),
1070 "unexpected failure: {failure:#}"
1071 );
1072 let stored = crate::database::load_state().unwrap();
1073 assert_eq!(stored.sessions[session_id].workspace_id, recovered);
1074 assert!(
1075 stored.sessions[session_id]
1076 .last_error
1077 .as_deref()
1078 .is_some_and(|error| error.contains("orphan adoption failed"))
1079 );
1080 }
1081
1082 #[test]
1083 fn a_session_that_completed_its_handshake_is_not_adoptable_again() {
1084 let mut record = adopted_session_record(
1085 "0123456789abcdef0123456789abcdef",
1086 "local-bare",
1087 "codex".into(),
1088 HarnessKind::Codex,
1089 "project".into(),
1090 mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1091 TargetLocator::LocalBare {
1092 worker_root: std::path::PathBuf::from("/workers/0123456789abcdef0123456789abcdef"),
1093 },
1094 );
1095 record.native_session_id = Some("native-session".into());
1096 assert!(!adoption_unfinished(&record, "local-bare"));
1097
1098 record.native_session_id = None;
1099 assert!(
1100 !adoption_unfinished(&record, "other-target"),
1101 "a record adopted onto another target is not this target's retry"
1102 );
1103 }
1104
1105 #[test]
1106 fn recovery_container_scan_requires_both_managed_and_session_labels() {
1107 let template = TargetTemplate::LocalPodman {
1108 container: ConfigContainer {
1109 image: "ignored".into(),
1110 pull_policy: Default::default(),
1111 platform: None,
1112 cpus: None,
1113 memory: None,
1114 environment: BTreeMap::new(),
1115 workspace_storage: Default::default(),
1116 },
1117 };
1118 let json = serde_json::json!([
1119 {"Labels": {"dev.mj.managed": "true", "dev.mj.session": "0123456789abcdef0123456789abcdef"}},
1120 {"Labels": {"dev.mj.managed": "false", "dev.mj.session": "not-owned"}},
1121 {"configuration": {"labels": "dev.mj.managed=true,dev.mj.session=abcdef0123456789abcdef0123456789"}}
1122 ]);
1123 let candidates = candidates_from_container_json(
1124 "local",
1125 &template,
1126 serde_json::to_string(&json).unwrap().as_bytes(),
1127 )
1128 .unwrap();
1129 assert_eq!(candidates.len(), 2);
1130 assert_eq!(candidates[0].session_id, "0123456789abcdef0123456789abcdef");
1131 }
1132
1133 #[test]
1134 fn recovery_docker_scan_accepts_json_lines_and_builds_a_docker_locator() {
1135 let template = TargetTemplate::LocalDocker {
1136 container: ConfigContainer {
1137 image: "ignored".into(),
1138 pull_policy: Default::default(),
1139 platform: None,
1140 cpus: None,
1141 memory: None,
1142 environment: BTreeMap::new(),
1143 workspace_storage: Default::default(),
1144 },
1145 };
1146 let session = "0123456789abcdef0123456789abcdef";
1147 let output = format!(
1148 "{{\"Labels\":\"dev.mj.managed=true,dev.mj.session={session}\"}}\n{{\"Labels\":\"dev.mj.managed=false,dev.mj.session=ignored\"}}\n"
1149 );
1150
1151 let candidates =
1152 candidates_from_container_json("docker", &template, output.as_bytes()).unwrap();
1153
1154 assert_eq!(candidates.len(), 1);
1155 assert_eq!(candidates[0].session_id, session);
1156 assert!(matches!(
1157 &candidates[0].locator,
1158 TargetLocator::LocalDocker { container_id }
1159 if container_id == &targets::resource_name(session).unwrap()
1160 ));
1161 }
1162
1163 #[test]
1164 fn recovery_aws_scan_uses_exact_tagged_instance_and_address() {
1165 let json = serde_json::json!({"Reservations": [{"Instances": [{
1166 "InstanceId": "i-exact",
1167 "PrivateIpAddress": "10.0.0.7",
1168 "Tags": [
1169 {"Key": "dev.mj.managed", "Value": "true"},
1170 {"Key": "dev.mj.session", "Value": "0123456789abcdef0123456789abcdef"}
1171 ]
1172 }]}]});
1173 let candidates = candidates_from_aws_json(
1174 "aws",
1175 AwsAddressSource::PrivateIp,
1176 serde_json::to_string(&json).unwrap().as_bytes(),
1177 )
1178 .unwrap();
1179 assert!(matches!(
1180 &candidates[0].locator,
1181 TargetLocator::AwsEc2 { instance_id, address }
1182 if instance_id == "i-exact" && address.as_deref() == Some("10.0.0.7")
1183 ));
1184 }
1185}