1use crate::review::RuntimeReviewView;
3use crate::session::{ManagedSessionView, ViewError};
4use anyhow::{Context, Result, bail, ensure};
5use mj_core::config::{Config, data_dir};
6use mj_core::credentials::CredentialSyncSignal;
7use mj_core::elicitation::ElicitationResponse;
8use mj_core::relay::{RelayCommand, RelayOperationalState};
9use mj_core::review::driver::Resolution;
10use mj_core::state::*;
11use mj_core::targets::{AdditionalMount, ProvisionStage};
12use mj_core::workspace::WorkspaceRecord;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::fs;
16use std::net::SocketAddr;
17use std::path::PathBuf;
18use std::time::{Duration, Instant};
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::TcpStream;
21pub fn metadata_path() -> PathBuf {
22 data_dir().join("daemon.json")
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct DaemonMetadata {
28 pub protocol_version: u32,
29 pub pid: u32,
30 pub address: SocketAddr,
31 pub token: String,
32 pub started_at: String,
33 pub build_version: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct WorkspaceListing {
39 pub workspace: WorkspaceRecord,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct SessionPreview {
45 pub id: String,
46 pub title: String,
47 pub project: String,
48 pub harness: String,
49 pub state: String,
50 pub active: bool,
51 pub updated_at: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct WorkspaceSnapshot {
57 pub workspace: WorkspaceRecord,
58 pub sessions: Vec<SessionPreview>,
59 pub drafts: Vec<DraftPreview>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct RuntimeSessionView {
65 pub session_id: String,
66 pub projection_ordinal: u64,
67 pub projection_digest: String,
68 pub operational: Option<RelayOperationalState>,
69 pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
70 pub connected: bool,
71 pub error: Option<ViewError>,
72}
73
74impl RuntimeSessionView {
75 pub fn from_managed(session_id: String, view: ManagedSessionView) -> Self {
76 let (projection_ordinal, projection_digest, operational, signal) =
77 view.snapshot
78 .map_or((0, String::new(), None, None), |snapshot| {
79 (
80 snapshot.materialized.applied_event_ordinal,
81 snapshot.materialized.applied_event_digest,
82 Some(snapshot.operational),
83 snapshot.latest_credential_sync_signal,
84 )
85 });
86 Self {
87 session_id,
88 projection_ordinal,
89 projection_digest,
90 operational,
91 latest_credential_sync_signal: signal,
92 connected: view.connected,
93 error: view.error,
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct RuntimeNotice {
106 pub id: u64,
107 pub session_id: String,
108 pub text: String,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct RuntimeSnapshot {
114 #[serde(default)]
115 pub workspace_names: BTreeMap<String, String>,
116 #[serde(default)]
117 pub moves: Vec<mj_core::state::MoveOperation>,
118 pub revision: u64,
119 pub config: Config,
120 pub records: Vec<SessionRecord>,
121 pub sessions: Vec<RuntimeSessionView>,
122 pub lifecycles: Vec<RuntimeLifecycleView>,
123 #[serde(default)]
125 pub reviews: Vec<RuntimeReviewView>,
126 #[serde(default)]
128 pub notices: Vec<RuntimeNotice>,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum RuntimeLifecycleKind {
134 Create,
135 Close,
136 Resume,
137 Move,
138 ForceStop,
139 DestroyStopped,
140 ForceDestroy,
141 Cleanup,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct RuntimeLifecycleView {
147 pub operation_id: String,
148 pub cancellable: bool,
149 pub session_id: String,
150 pub kind: RuntimeLifecycleKind,
151 pub started_at_epoch_seconds: u64,
152 pub active_stages: Vec<(ProvisionStage, u64)>,
153 pub resume_destination: Option<(String, String)>,
154 pub notice: Option<String>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct ResumeSessionRequest {
160 pub session_id: String,
161 pub workspace_id: String,
162 pub profile_id: String,
163 pub target_template_id: String,
164 pub additional_mounts: Option<Vec<AdditionalMount>>,
165 pub resource_allocation: Option<SessionResourceAllocation>,
166 pub discard_queue: bool,
167 pub repository_preflight: Option<ResumeRepositorySourceReceipt>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct CreateSessionRequest {
173 #[serde(default)]
174 pub create_managed_worktree: Option<bool>,
175 #[serde(default)]
176 pub initial_prompt: Option<String>,
177 pub workspace_id: String,
178 pub profile_id: String,
179 pub bundle_id: String,
180 pub project_directory: Option<PathBuf>,
181 pub target_template_id: String,
182 pub additional_mounts: Vec<AdditionalMount>,
183 pub allow_dirty_local: bool,
184 pub resource_allocation: Option<SessionResourceAllocation>,
185 pub title: String,
186 pub session_title_override: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(deny_unknown_fields)]
191pub struct RegisteredSession {
192 pub session: SessionRecord,
193 pub remembered_container_size: Option<(String, HostContainerSize)>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct DraftPreview {
199 pub id: String,
200 pub session_id: Option<String>,
201 pub source: String,
202 pub owner_pid: Option<u32>,
203 pub saved_at: String,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(rename_all = "snake_case", tag = "action", content = "arguments")]
214pub enum DaemonAction {
215 Ping,
216 Status,
217 WebViewerAccess,
218 RecoverWebViewer(crate::web::WebViewerRecovery),
219 InspectWebListener,
220 ListWorkspaces,
221 CreateWorkspace {
222 name: String,
223 },
224 RenameWorkspace {
225 workspace_id: String,
226 name: String,
227 },
228 TouchWorkspace {
229 workspace_id: String,
230 },
231 DeleteWorkspace {
232 workspace_id: String,
233 },
234 Attach {
235 client_id: String,
236 pid: u32,
237 },
238 Detach {
239 client_id: String,
240 },
241 PersistReadReceipt {
242 client_id: String,
243 workspace_id: String,
244 session_id: String,
245 through: u64,
246 },
247 PersistDetachedSessionState {
248 client_id: String,
249 workspace_id: String,
250 session_id: String,
251 through: u64,
252 owner_pid: u32,
253 draft: mj_core::storage::DetachedSessionDraft,
254 },
255 SaveActiveReview {
256 session_id: String,
257 review: mj_core::storage::StoredReview,
258 },
259 ClearActiveReview {
260 session_id: String,
261 },
262 RememberReviewerSelection {
263 workspace_id: String,
264 selection: mj_core::second_opinion::ReviewerSelection,
265 },
266 SaveWorkspacePaneSizes {
267 workspace_id: String,
268 sizes: mj_core::workspace::PaneSizes,
269 },
270 PersistImportedSession {
271 session: Box<SessionRecord>,
272 },
273 SetSessionTitle {
274 session_id: String,
275 title: String,
276 },
277 SetSessionContainerSettings {
278 session_id: String,
279 cpus: Option<String>,
280 memory: Option<String>,
281 mounts: Vec<AdditionalMount>,
282 mount_history: Vec<PathBuf>,
283 },
284 SetSessionAcpTitle {
285 session_id: String,
286 title: Option<String>,
287 },
288 MarkSessionTargetMissing {
289 session_id: String,
290 detail: String,
291 updated_at: String,
292 },
293 CheckpointSession {
294 session_id: String,
295 },
296 ScanRecovery,
297 AdoptRecovery {
298 session_id: String,
299 target_id: String,
300 profile: Option<String>,
301 bundle: Option<String>,
302 },
303 DestroyRecovery {
304 session_id: String,
305 target_id: String,
306 confirmation: String,
307 },
308 Snapshot {
309 workspace_id: String,
310 },
311 RuntimeSnapshot {
312 workspace_id: String,
313 after_revision: u64,
314 #[serde(default)]
315 all_workspaces: bool,
316 },
317 RenameProfile {
318 old_id: String,
319 new_id: String,
320 },
321 RenameTarget {
322 old_id: String,
323 new_id: String,
324 },
325 SubmitSessionCommand {
326 #[serde(default)]
327 inherited_draft: Option<String>,
328 session_id: String,
329 command_id: String,
330 command: RelayCommand,
331 },
332 SyncSession {
333 session_id: String,
334 },
335 RespondElicitation {
336 session_id: String,
337 elicitation_id: String,
338 response: ElicitationResponse,
339 },
340 StopBackgroundTask {
341 session_id: String,
342 background_task_id: String,
343 },
344 ReviewerAction {
348 session_id: String,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
352 role: Option<String>,
353 action: crate::session::ReviewerAction,
354 },
355 StartTurnReview {
357 session_id: String,
358 },
359 ResolveTurnReview {
361 session_id: String,
362 resolution: Resolution,
363 },
364 CloseSession {
365 session_id: String,
366 },
367 StartCreateSession(CreateSessionRequest),
368 WaitCreateSession {
369 session_id: String,
370 },
371 ResumeSession(ResumeSessionRequest),
372 PrepareMoveSession(MoveSelection),
373 MoveSession(MoveSessionRequest),
374 ForceStopSession {
375 session_id: String,
376 },
377 DestroyStoppedSession {
378 session_id: String,
379 },
380 ForceDestroySession {
381 session_id: String,
382 },
383 ForceDeleteWorkspace {
384 workspace_id: String,
385 },
386 CancelLifecycle {
387 session_id: String,
388 },
389 RecoverDraft {
390 draft_id: String,
391 },
392 Stop,
393}
394
395#[derive(Debug, Serialize, Deserialize)]
396#[serde(deny_unknown_fields)]
397pub struct RequestEnvelope {
398 pub protocol_version: u32,
399 pub request_id: u64,
400 pub token: String,
401 pub action: DaemonAction,
402}
403
404#[derive(Debug, Serialize, Deserialize)]
405#[serde(deny_unknown_fields)]
406pub struct ResponseEnvelope {
407 pub protocol_version: u32,
408 pub request_id: u64,
409 pub result: std::result::Result<DaemonReply, String>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(rename_all = "snake_case", tag = "reply", content = "value")]
414pub enum DaemonReply {
415 Pong,
416 Status(DaemonStatus),
417 WebViewerAccess(crate::web::WebViewerAccess),
418 WebListeners(Vec<crate::web::WebListenerProcess>),
419 Workspaces(Vec<WorkspaceListing>),
420 Workspace(WorkspaceRecord),
421 Snapshot(WorkspaceSnapshot),
422 RuntimeSnapshot(Box<RuntimeSnapshot>),
423 RegisteredSession(Box<RegisteredSession>),
424 MovePreparation(Box<MovePreparation>),
425 MoveOutcome(MoveOutcome),
426 Ordinal(u64),
427 Text(String),
428 OptionalSessionState(Option<SessionState>),
429 Checkpoint(mj_core::state::CheckpointMetadata),
430 RecoveryScan(mj_core::state::RecoveryScan),
431 Reviewer(Box<crate::session::ReviewerOutcome>),
432 Done,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
436#[serde(deny_unknown_fields)]
437pub struct DaemonStatus {
438 pub pid: u32,
439 pub started_at: String,
440 pub build_version: String,
441 pub attached_clients: usize,
442 pub phone_status: WebViewerStatus,
443}
444
445#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case", tag = "state")]
447pub enum WebViewerStatus {
448 Disabled,
449 Starting,
450 Ready {
451 viewer_url: String,
452 viewer_code: String,
453 qr_login_url: Option<String>,
454 fallback_reason: Option<String>,
455 },
456 Stopped,
457 Error {
458 message: String,
459 },
460}
461
462impl std::fmt::Debug for WebViewerStatus {
463 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464 match self {
465 Self::Ready {
466 viewer_url,
467 viewer_code,
468 fallback_reason,
469 ..
470 } => formatter
471 .debug_struct("Ready")
472 .field("viewer_url", viewer_url)
473 .field("viewer_code", viewer_code)
474 .field("qr_login_url", &"[redacted]")
475 .field("fallback_reason", fallback_reason)
476 .finish(),
477 Self::Disabled => formatter.write_str("Disabled"),
478 Self::Starting => formatter.write_str("Starting"),
479 Self::Stopped => formatter.write_str("Stopped"),
480 Self::Error { message } => formatter.debug_tuple("Error").field(message).finish(),
481 }
482 }
483}
484
485impl std::fmt::Display for WebViewerStatus {
486 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487 match self {
488 Self::Disabled => formatter.write_str("disabled"),
489 Self::Starting => formatter.write_str("starting"),
490 Self::Stopped => formatter.write_str("stopped unexpectedly"),
491 Self::Error { message } => write!(formatter, "error: {message}"),
492 Self::Ready {
493 viewer_url,
494 viewer_code,
495 fallback_reason,
496 ..
497 } => {
498 write!(formatter, "{viewer_url}; viewer code {viewer_code}")?;
499 if let Some(reason) = fallback_reason {
500 write!(
501 formatter,
502 "; local only because Tailscale HTTPS is unavailable: {reason}"
503 )?;
504 }
505 Ok(())
506 }
507 }
508 }
509}
510
511#[cfg(unix)]
526pub fn process_is_zombie(pid: u32) -> bool {
527 let pid = sysinfo::Pid::from_u32(pid);
528 let mut system = sysinfo::System::new();
529 system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
530 system
531 .process(pid)
532 .is_some_and(|process| process.status() == sysinfo::ProcessStatus::Zombie)
533}
534
535pub async fn wait_for_exit(pid: u32) -> Result<()> {
541 let deadline = Instant::now() + STOP_TIMEOUT;
542 while daemon_process_is_alive(pid) {
543 ensure!(Instant::now() < deadline, "process {pid} is still running");
544 tokio::time::sleep(RETRY_DELAY).await;
545 }
546 Ok(())
547}
548
549pub fn daemon_process_is_alive(pid: u32) -> bool {
555 #[cfg(unix)]
556 {
557 if pid == 0 {
558 return false;
559 }
560 let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
561 return false;
562 };
563 let mut status = 0;
564 let waited = unsafe { libc::waitpid(raw_pid, &mut status, libc::WNOHANG) };
567 if waited == raw_pid {
568 return false;
569 }
570 if waited == 0 {
571 return true;
572 }
573 let wait_error = std::io::Error::last_os_error();
574 if wait_error.raw_os_error() != Some(libc::ECHILD) {
575 return true;
576 }
577
578 #[cfg(target_os = "macos")]
579 return owned_daemon_group_is_alive(raw_pid);
580
581 #[cfg(not(target_os = "macos"))]
582 process_is_alive(pid)
583 }
584 #[cfg(not(unix))]
585 process_is_alive(pid)
586}
587
588#[cfg(target_os = "macos")]
589pub fn owned_daemon_group_is_alive(pid: libc::pid_t) -> bool {
590 if unsafe { libc::kill(-pid, 0) } == 0 {
598 return true;
599 }
600 let error = std::io::Error::last_os_error();
601 !matches!(error.raw_os_error(), Some(libc::ESRCH) | Some(libc::EPERM))
602}
603
604pub fn process_is_alive(pid: u32) -> bool {
605 #[cfg(unix)]
606 {
607 if pid == 0 {
608 return false;
609 }
610 let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
611 return false;
612 };
613 let result = unsafe { libc::kill(raw_pid, 0) };
616 let exists =
617 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
618 exists && !process_is_zombie(pid)
619 }
620 #[cfg(not(unix))]
621 {
622 let _ = pid;
623 true
624 }
625}
626
627pub fn read_metadata() -> Result<DaemonMetadata> {
628 let metadata = read_metadata_any()?;
629 ensure!(
630 metadata.protocol_version == PROTOCOL_VERSION,
631 "daemon protocol {} is incompatible with client protocol {}",
632 metadata.protocol_version,
633 PROTOCOL_VERSION
634 );
635 Ok(metadata)
636}
637
638pub fn read_metadata_any() -> Result<DaemonMetadata> {
639 let path = metadata_path();
640 let body = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
641 let metadata: DaemonMetadata =
642 serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
643 Ok(metadata)
644}
645
646pub async fn write_frame<T: Serialize>(stream: &mut TcpStream, value: &T) -> Result<()> {
647 let body = serde_json::to_vec(value)?;
648 ensure!(body.len() <= MAX_FRAME_BYTES, "daemon frame is too large");
649 stream.write_u32(body.len() as u32).await?;
650 stream.write_all(&body).await?;
651 stream.flush().await?;
652 Ok(())
653}
654
655pub async fn read_frame<T: for<'de> Deserialize<'de>>(stream: &mut TcpStream) -> Result<T> {
656 let length = stream.read_u32().await? as usize;
657 ensure!(
658 length <= MAX_FRAME_BYTES,
659 "daemon frame exceeds {MAX_FRAME_BYTES} bytes"
660 );
661 let mut body = vec![0_u8; length];
662 stream.read_exact(&mut body).await?;
663 serde_json::from_slice(&body).context("decode daemon frame")
664}
665
666pub struct DaemonClient {
667 metadata: DaemonMetadata,
668 stream: TcpStream,
669 next_request_id: u64,
670}
671
672impl DaemonClient {
673 pub async fn connect(metadata: DaemonMetadata) -> Result<Self> {
674 let stream =
675 tokio::time::timeout(Duration::from_secs(1), TcpStream::connect(metadata.address))
676 .await
677 .context("time out connecting to Mjolnir daemon")??;
678 Ok(Self {
679 metadata,
680 stream,
681 next_request_id: 1,
682 })
683 }
684
685 pub async fn request(&mut self, action: DaemonAction) -> Result<DaemonReply> {
689 let protocol_version = self.metadata.protocol_version;
690 let request_id = self.next_request_id;
691 self.next_request_id += 1;
692 write_frame(
693 &mut self.stream,
694 &RequestEnvelope {
695 protocol_version,
696 request_id,
697 token: self.metadata.token.clone(),
698 action,
699 },
700 )
701 .await?;
702 let response: ResponseEnvelope = read_frame(&mut self.stream).await?;
703 ensure!(
704 response.protocol_version == protocol_version,
705 "daemon changed protocol"
706 );
707 ensure!(
708 response.request_id == request_id,
709 "daemon crossed request IDs"
710 );
711 response.result.map_err(anyhow::Error::msg)
712 }
713
714 pub async fn status(&mut self) -> Result<DaemonStatus> {
715 match self.request(DaemonAction::Status).await? {
716 DaemonReply::Status(status) => Ok(status),
717 reply => bail!("unexpected daemon status reply {reply:?}"),
718 }
719 }
720
721 pub async fn web_access(&mut self) -> Result<crate::web::WebViewerAccess> {
722 match self.request(DaemonAction::WebViewerAccess).await? {
723 DaemonReply::WebViewerAccess(access) => Ok(access),
724 reply => bail!("unexpected web viewer reply {reply:?}"),
725 }
726 }
727
728 pub async fn recover_web_viewer(
729 &mut self,
730 action: crate::web::WebViewerRecovery,
731 ) -> Result<()> {
732 match self.request(DaemonAction::RecoverWebViewer(action)).await? {
733 DaemonReply::Done => Ok(()),
734 reply => bail!("unexpected web viewer recovery reply {reply:?}"),
735 }
736 }
737
738 pub async fn inspect_web_listener(&mut self) -> Result<Vec<crate::web::WebListenerProcess>> {
739 match self.request(DaemonAction::InspectWebListener).await? {
740 DaemonReply::WebListeners(processes) => Ok(processes),
741 reply => bail!("unexpected listener inspection reply {reply:?}"),
742 }
743 }
744
745 pub async fn list_workspaces(&mut self) -> Result<Vec<WorkspaceListing>> {
746 match self.request(DaemonAction::ListWorkspaces).await? {
747 DaemonReply::Workspaces(workspaces) => Ok(workspaces),
748 reply => bail!("unexpected daemon workspace reply {reply:?}"),
749 }
750 }
751
752 pub async fn rename_profile(&mut self, old_id: String, new_id: String) -> Result<()> {
753 match self
754 .request(DaemonAction::RenameProfile { old_id, new_id })
755 .await?
756 {
757 DaemonReply::Done => Ok(()),
758 reply => bail!("unexpected rename-profile reply {reply:?}"),
759 }
760 }
761
762 pub async fn rename_target(&mut self, old_id: String, new_id: String) -> Result<()> {
763 match self
764 .request(DaemonAction::RenameTarget { old_id, new_id })
765 .await?
766 {
767 DaemonReply::Done => Ok(()),
768 reply => bail!("unexpected rename-target reply {reply:?}"),
769 }
770 }
771
772 pub async fn create_workspace(&mut self, name: String) -> Result<WorkspaceRecord> {
773 match self.request(DaemonAction::CreateWorkspace { name }).await? {
774 DaemonReply::Workspace(workspace) => Ok(workspace),
775 reply => bail!("unexpected create-workspace reply {reply:?}"),
776 }
777 }
778
779 pub async fn rename_workspace(&mut self, workspace_id: String, name: String) -> Result<()> {
780 match self
781 .request(DaemonAction::RenameWorkspace { workspace_id, name })
782 .await?
783 {
784 DaemonReply::Done => Ok(()),
785 reply => bail!("unexpected rename-workspace reply {reply:?}"),
786 }
787 }
788
789 pub async fn touch_workspace(&mut self, workspace_id: String) -> Result<()> {
790 match self
791 .request(DaemonAction::TouchWorkspace { workspace_id })
792 .await?
793 {
794 DaemonReply::Done => Ok(()),
795 reply => bail!("unexpected touch-workspace reply {reply:?}"),
796 }
797 }
798
799 pub async fn delete_workspace(&mut self, workspace_id: String) -> Result<()> {
800 match self
801 .request(DaemonAction::DeleteWorkspace { workspace_id })
802 .await?
803 {
804 DaemonReply::Done => Ok(()),
805 reply => bail!("unexpected delete-workspace reply {reply:?}"),
806 }
807 }
808
809 pub async fn attach(&mut self, client_id: String, pid: u32) -> Result<()> {
810 match self
811 .request(DaemonAction::Attach { client_id, pid })
812 .await?
813 {
814 DaemonReply::Done => Ok(()),
815 reply => bail!("unexpected attach reply {reply:?}"),
816 }
817 }
818
819 pub async fn detach(&mut self, client_id: String) -> Result<()> {
820 match self.request(DaemonAction::Detach { client_id }).await? {
821 DaemonReply::Done => Ok(()),
822 reply => bail!("unexpected detach reply {reply:?}"),
823 }
824 }
825
826 pub async fn persist_read_receipt(
827 &mut self,
828 client_id: String,
829 workspace_id: String,
830 session_id: String,
831 through: u64,
832 ) -> Result<u64> {
833 match self
834 .request(DaemonAction::PersistReadReceipt {
835 client_id,
836 workspace_id,
837 session_id,
838 through,
839 })
840 .await?
841 {
842 DaemonReply::Ordinal(ordinal) => Ok(ordinal),
843 reply => bail!("unexpected read-receipt reply {reply:?}"),
844 }
845 }
846
847 pub async fn persist_detached_session_state(
848 &mut self,
849 client_id: String,
850 workspace_id: String,
851 session_id: String,
852 through: u64,
853 owner_pid: u32,
854 draft: mj_core::storage::DetachedSessionDraft,
855 ) -> Result<()> {
856 match self
857 .request(DaemonAction::PersistDetachedSessionState {
858 client_id,
859 workspace_id,
860 session_id,
861 through,
862 owner_pid,
863 draft,
864 })
865 .await?
866 {
867 DaemonReply::Done => Ok(()),
868 reply => bail!("unexpected detached-session-state reply {reply:?}"),
869 }
870 }
871
872 pub async fn save_active_review(
873 &mut self,
874 session_id: String,
875 review: mj_core::storage::StoredReview,
876 ) -> Result<()> {
877 match self
878 .request(DaemonAction::SaveActiveReview { session_id, review })
879 .await?
880 {
881 DaemonReply::Done => Ok(()),
882 reply => bail!("unexpected save-review reply {reply:?}"),
883 }
884 }
885
886 pub async fn clear_active_review(&mut self, session_id: String) -> Result<()> {
887 match self
888 .request(DaemonAction::ClearActiveReview { session_id })
889 .await?
890 {
891 DaemonReply::Done => Ok(()),
892 reply => bail!("unexpected clear-review reply {reply:?}"),
893 }
894 }
895
896 pub async fn remember_reviewer_selection(
897 &mut self,
898 workspace_id: String,
899 selection: mj_core::second_opinion::ReviewerSelection,
900 ) -> Result<()> {
901 match self
902 .request(DaemonAction::RememberReviewerSelection {
903 workspace_id,
904 selection,
905 })
906 .await?
907 {
908 DaemonReply::Done => Ok(()),
909 reply => bail!("unexpected reviewer-selection reply {reply:?}"),
910 }
911 }
912
913 pub async fn save_workspace_pane_sizes(
914 &mut self,
915 workspace_id: String,
916 sizes: mj_core::workspace::PaneSizes,
917 ) -> Result<()> {
918 match self
919 .request(DaemonAction::SaveWorkspacePaneSizes {
920 workspace_id,
921 sizes,
922 })
923 .await?
924 {
925 DaemonReply::Done => Ok(()),
926 reply => bail!("unexpected pane-size save reply {reply:?}"),
927 }
928 }
929
930 pub async fn persist_imported_session(&mut self, session: SessionRecord) -> Result<()> {
931 match self
932 .request(DaemonAction::PersistImportedSession {
933 session: Box::new(session),
934 })
935 .await?
936 {
937 DaemonReply::Done => Ok(()),
938 reply => bail!("unexpected imported-session reply {reply:?}"),
939 }
940 }
941
942 pub async fn set_session_title(&mut self, session_id: String, title: String) -> Result<String> {
943 match self
944 .request(DaemonAction::SetSessionTitle { session_id, title })
945 .await?
946 {
947 DaemonReply::Text(title) => Ok(title),
948 reply => bail!("unexpected session-title reply {reply:?}"),
949 }
950 }
951
952 pub async fn set_session_container_settings(
953 &mut self,
954 session_id: String,
955 cpus: Option<String>,
956 memory: Option<String>,
957 mounts: Vec<AdditionalMount>,
958 mount_history: Vec<PathBuf>,
959 ) -> Result<()> {
960 match self
961 .request(DaemonAction::SetSessionContainerSettings {
962 session_id,
963 cpus,
964 memory,
965 mounts,
966 mount_history,
967 })
968 .await?
969 {
970 DaemonReply::Done => Ok(()),
971 reply => bail!("unexpected container-settings reply {reply:?}"),
972 }
973 }
974
975 pub async fn set_session_acp_title(
976 &mut self,
977 session_id: String,
978 title: Option<String>,
979 ) -> Result<()> {
980 match self
981 .request(DaemonAction::SetSessionAcpTitle { session_id, title })
982 .await?
983 {
984 DaemonReply::Done => Ok(()),
985 reply => bail!("unexpected ACP-title reply {reply:?}"),
986 }
987 }
988
989 pub async fn mark_session_target_missing(
990 &mut self,
991 session_id: String,
992 detail: String,
993 updated_at: String,
994 ) -> Result<Option<SessionState>> {
995 match self
996 .request(DaemonAction::MarkSessionTargetMissing {
997 session_id,
998 detail,
999 updated_at,
1000 })
1001 .await?
1002 {
1003 DaemonReply::OptionalSessionState(state) => Ok(state),
1004 reply => bail!("unexpected target-missing reply {reply:?}"),
1005 }
1006 }
1007
1008 pub async fn checkpoint_session(
1009 &mut self,
1010 session_id: String,
1011 ) -> Result<mj_core::state::CheckpointMetadata> {
1012 match self
1013 .request(DaemonAction::CheckpointSession { session_id })
1014 .await?
1015 {
1016 DaemonReply::Checkpoint(checkpoint) => Ok(checkpoint),
1017 reply => bail!("unexpected checkpoint reply {reply:?}"),
1018 }
1019 }
1020
1021 pub async fn scan_recovery(&mut self) -> Result<mj_core::state::RecoveryScan> {
1022 match self.request(DaemonAction::ScanRecovery).await? {
1023 DaemonReply::RecoveryScan(scan) => Ok(scan),
1024 reply => bail!("unexpected recovery-scan reply {reply:?}"),
1025 }
1026 }
1027
1028 pub async fn adopt_recovery(
1029 &mut self,
1030 session_id: String,
1031 target_id: String,
1032 profile: Option<String>,
1033 bundle: Option<String>,
1034 ) -> Result<()> {
1035 match self
1036 .request(DaemonAction::AdoptRecovery {
1037 session_id,
1038 target_id,
1039 profile,
1040 bundle,
1041 })
1042 .await?
1043 {
1044 DaemonReply::Done => Ok(()),
1045 reply => bail!("unexpected recovery-adopt reply {reply:?}"),
1046 }
1047 }
1048
1049 pub async fn destroy_recovery(
1050 &mut self,
1051 session_id: String,
1052 target_id: String,
1053 confirmation: String,
1054 ) -> Result<()> {
1055 match self
1056 .request(DaemonAction::DestroyRecovery {
1057 session_id,
1058 target_id,
1059 confirmation,
1060 })
1061 .await?
1062 {
1063 DaemonReply::Done => Ok(()),
1064 reply => bail!("unexpected recovery-destroy reply {reply:?}"),
1065 }
1066 }
1067
1068 pub async fn snapshot(&mut self, workspace_id: String) -> Result<WorkspaceSnapshot> {
1069 match self
1070 .request(DaemonAction::Snapshot { workspace_id })
1071 .await?
1072 {
1073 DaemonReply::Snapshot(snapshot) => Ok(snapshot),
1074 reply => bail!("unexpected snapshot reply {reply:?}"),
1075 }
1076 }
1077
1078 pub async fn runtime_snapshot(
1079 &mut self,
1080 workspace_id: String,
1081 after_revision: u64,
1082 all_workspaces: bool,
1083 ) -> Result<RuntimeSnapshot> {
1084 match self
1085 .request(DaemonAction::RuntimeSnapshot {
1086 workspace_id,
1087 after_revision,
1088 all_workspaces,
1089 })
1090 .await?
1091 {
1092 DaemonReply::RuntimeSnapshot(snapshot) => Ok(*snapshot),
1093 reply => bail!("unexpected runtime snapshot reply {reply:?}"),
1094 }
1095 }
1096
1097 pub async fn submit_session_command(
1098 &mut self,
1099 session_id: String,
1100 command_id: String,
1101 command: RelayCommand,
1102 inherited_draft: Option<String>,
1103 ) -> Result<u64> {
1104 match self
1105 .request(DaemonAction::SubmitSessionCommand {
1106 inherited_draft,
1107 session_id,
1108 command_id,
1109 command,
1110 })
1111 .await?
1112 {
1113 DaemonReply::Ordinal(ordinal) => Ok(ordinal),
1114 reply => bail!("unexpected session command reply {reply:?}"),
1115 }
1116 }
1117
1118 pub async fn start_turn_review(&mut self, session_id: String) -> Result<()> {
1124 match self
1125 .request(DaemonAction::StartTurnReview { session_id })
1126 .await?
1127 {
1128 DaemonReply::Done => Ok(()),
1129 reply => bail!("unexpected turn-review reply {reply:?}"),
1130 }
1131 }
1132
1133 pub async fn resolve_turn_review(
1134 &mut self,
1135 session_id: String,
1136 resolution: Resolution,
1137 ) -> Result<()> {
1138 match self
1139 .request(DaemonAction::ResolveTurnReview {
1140 session_id,
1141 resolution,
1142 })
1143 .await?
1144 {
1145 DaemonReply::Done => Ok(()),
1146 reply => bail!("unexpected turn-review resolution reply {reply:?}"),
1147 }
1148 }
1149
1150 pub async fn reviewer_action(
1151 &mut self,
1152 session_id: String,
1153 role: Option<String>,
1154 action: crate::session::ReviewerAction,
1155 ) -> Result<crate::session::ReviewerOutcome> {
1156 match self
1157 .request(DaemonAction::ReviewerAction {
1158 session_id,
1159 role,
1160 action,
1161 })
1162 .await?
1163 {
1164 DaemonReply::Reviewer(outcome) => Ok(*outcome),
1165 reply => bail!("unexpected reviewer reply {reply:?}"),
1166 }
1167 }
1168
1169 pub async fn sync_session(&mut self, session_id: String) -> Result<()> {
1170 match self
1171 .request(DaemonAction::SyncSession { session_id })
1172 .await?
1173 {
1174 DaemonReply::Done => Ok(()),
1175 reply => bail!("unexpected session sync reply {reply:?}"),
1176 }
1177 }
1178
1179 pub async fn respond_elicitation(
1180 &mut self,
1181 session_id: String,
1182 elicitation_id: String,
1183 response: ElicitationResponse,
1184 ) -> Result<()> {
1185 match self
1186 .request(DaemonAction::RespondElicitation {
1187 session_id,
1188 elicitation_id,
1189 response,
1190 })
1191 .await?
1192 {
1193 DaemonReply::Done => Ok(()),
1194 reply => bail!("unexpected elicitation reply {reply:?}"),
1195 }
1196 }
1197
1198 pub async fn stop_background_task(
1199 &mut self,
1200 session_id: String,
1201 background_task_id: String,
1202 ) -> Result<()> {
1203 match self
1204 .request(DaemonAction::StopBackgroundTask {
1205 session_id,
1206 background_task_id,
1207 })
1208 .await?
1209 {
1210 DaemonReply::Done => Ok(()),
1211 reply => bail!("unexpected background task stop reply {reply:?}"),
1212 }
1213 }
1214
1215 pub async fn close_session(&mut self, session_id: String) -> Result<()> {
1216 match self
1217 .request(DaemonAction::CloseSession { session_id })
1218 .await?
1219 {
1220 DaemonReply::Done => Ok(()),
1221 reply => bail!("unexpected close-session reply {reply:?}"),
1222 }
1223 }
1224
1225 pub async fn start_create_session(
1226 &mut self,
1227 request: CreateSessionRequest,
1228 ) -> Result<RegisteredSession> {
1229 match self
1230 .request(DaemonAction::StartCreateSession(request))
1231 .await?
1232 {
1233 DaemonReply::RegisteredSession(registered) => Ok(*registered),
1234 reply => bail!("unexpected start-create reply {reply:?}"),
1235 }
1236 }
1237
1238 pub async fn wait_create_session(&mut self, session_id: String) -> Result<()> {
1239 match self
1240 .request(DaemonAction::WaitCreateSession { session_id })
1241 .await?
1242 {
1243 DaemonReply::Done => Ok(()),
1244 reply => bail!("unexpected wait-create reply {reply:?}"),
1245 }
1246 }
1247
1248 pub async fn resume_session(&mut self, request: ResumeSessionRequest) -> Result<()> {
1249 match self.request(DaemonAction::ResumeSession(request)).await? {
1250 DaemonReply::Done => Ok(()),
1251 reply => bail!("unexpected resume-session reply {reply:?}"),
1252 }
1253 }
1254
1255 pub async fn force_stop_session(&mut self, session_id: String) -> Result<()> {
1256 match self
1257 .request(DaemonAction::ForceStopSession { session_id })
1258 .await?
1259 {
1260 DaemonReply::Done => Ok(()),
1261 reply => bail!("unexpected force-stop reply {reply:?}"),
1262 }
1263 }
1264
1265 pub async fn destroy_stopped_session(&mut self, session_id: String) -> Result<()> {
1266 match self
1267 .request(DaemonAction::DestroyStoppedSession { session_id })
1268 .await?
1269 {
1270 DaemonReply::Done => Ok(()),
1271 reply => bail!("unexpected destroy-stopped reply {reply:?}"),
1272 }
1273 }
1274
1275 pub async fn force_destroy_session(&mut self, session_id: String) -> Result<()> {
1276 match self
1277 .request(DaemonAction::ForceDestroySession { session_id })
1278 .await?
1279 {
1280 DaemonReply::Done => Ok(()),
1281 reply => bail!("unexpected force-destroy reply {reply:?}"),
1282 }
1283 }
1284
1285 pub async fn force_delete_workspace(&mut self, workspace_id: String) -> Result<()> {
1286 match self
1287 .request(DaemonAction::ForceDeleteWorkspace { workspace_id })
1288 .await?
1289 {
1290 DaemonReply::Done => Ok(()),
1291 reply => bail!("unexpected force-delete-workspace reply {reply:?}"),
1292 }
1293 }
1294
1295 pub async fn cancel_lifecycle(&mut self, session_id: String) -> Result<()> {
1296 match self
1297 .request(DaemonAction::CancelLifecycle { session_id })
1298 .await?
1299 {
1300 DaemonReply::Done => Ok(()),
1301 reply => bail!("unexpected cancel-lifecycle reply {reply:?}"),
1302 }
1303 }
1304
1305 pub async fn recover_draft(&mut self, draft_id: String) -> Result<()> {
1306 match self
1307 .request(DaemonAction::RecoverDraft { draft_id })
1308 .await?
1309 {
1310 DaemonReply::Done => Ok(()),
1311 reply => bail!("unexpected recover-draft reply {reply:?}"),
1312 }
1313 }
1314
1315 pub async fn stop(&mut self) -> Result<()> {
1316 match self.request(DaemonAction::Stop).await? {
1317 DaemonReply::Done => Ok(()),
1318 reply => bail!("unexpected stop reply {reply:?}"),
1319 }
1320 }
1321}
1322
1323pub async fn connect_existing() -> Result<DaemonClient> {
1324 let metadata = tokio::task::spawn_blocking(read_metadata)
1325 .await
1326 .context("read daemon metadata task failed")??;
1327 DaemonClient::connect(metadata).await
1328}
1329
1330pub struct ManagementClient {
1334 inner: DaemonClient,
1335}
1336
1337impl ManagementClient {
1338 pub fn new(inner: DaemonClient) -> Self {
1339 Self { inner }
1340 }
1341 pub fn protocol_version(&self) -> u32 {
1342 self.inner.metadata.protocol_version
1343 }
1344
1345 pub async fn status(&mut self) -> Result<DaemonStatus> {
1346 self.inner.status().await
1347 }
1348
1349 pub async fn stop(&mut self) -> Result<()> {
1350 tokio::time::timeout(STOP_TIMEOUT, self.inner.stop())
1351 .await
1352 .context("Mjolnir daemon did not acknowledge the stop before the deadline")?
1353 }
1354
1355 pub async fn stop_and_wait(mut self) -> Result<()> {
1357 let pid = self.inner.metadata.pid;
1358 self.stop().await?;
1359 wait_for_exit(pid).await.with_context(|| {
1360 format!(
1361 "Mjolnir daemon {pid} accepted the stop but was still running after {}s",
1362 STOP_TIMEOUT.as_secs()
1363 )
1364 })
1365 }
1366}
1367
1368pub async fn connect_management() -> Result<ManagementClient> {
1369 Ok(ManagementClient {
1370 inner: DaemonClient::connect(read_metadata_any()?).await?,
1371 })
1372}
1373
1374pub fn ensure_supported_daemon_protocol(version: u32) -> Result<()> {
1375 ensure!(
1376 version <= PROTOCOL_VERSION,
1377 "the daemon uses a newer protocol ({version}) than this client ({PROTOCOL_VERSION}); restart this client with the updated mj binary"
1378 );
1379 Ok(())
1380}
1381pub const PROTOCOL_VERSION: u32 = 19;
1382pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
1383pub const STOP_TIMEOUT: Duration = Duration::from_secs(30);
1393pub const RETRY_DELAY: Duration = Duration::from_millis(40);
1394impl DaemonClient {
1395 pub async fn prepare_move_session(
1396 &mut self,
1397 selection: MoveSelection,
1398 ) -> Result<MovePreparation> {
1399 match self
1400 .request(DaemonAction::PrepareMoveSession(selection))
1401 .await?
1402 {
1403 DaemonReply::MovePreparation(preparation) => Ok(*preparation),
1404 _ => bail!("daemon returned an unexpected move preparation reply"),
1405 }
1406 }
1407
1408 pub async fn move_session(&mut self, request: MoveSessionRequest) -> Result<MoveOutcome> {
1409 match self.request(DaemonAction::MoveSession(request)).await? {
1410 DaemonReply::MoveOutcome(outcome) => Ok(outcome),
1411 _ => bail!("daemon returned an unexpected move reply"),
1412 }
1413 }
1414}