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