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