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