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