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