Skip to main content

mj_client/
daemon.rs

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