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