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/// One session in the user's SessionWiki index, as a control surface shows it.
55///
56/// It is the daemon's own shape rather than SessionWiki's row: it carries the
57/// search snippet that found the row and, for a Mjolnir session this daemon
58/// still holds, the id that resumes it instead of restoring it.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct WikiRow {
62    pub id: String,
63    pub tool: String,
64    pub project: String,
65    pub title: String,
66    pub started: Option<String>,
67    pub msgs: i64,
68    pub preview: Option<String>,
69    /// The tool deleted its own copy and SessionWiki kept the transcript.
70    pub archived: bool,
71    /// The session id the tool that ran it knows it by, when its stored path
72    /// carries one. It is what matches a row against an import scan.
73    pub native_id: Option<String>,
74    /// The matching text, when this row came from a search.
75    pub snippet: Option<String>,
76    /// The live Mjolnir session this row describes, when this daemon has it.
77    pub hel_session_id: Option<String>,
78}
79
80/// How far along the daemon's SessionWiki index is when a search answers.
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum WikiIndexState {
84    /// The index has completed one full build; its answers are complete.
85    Ready,
86    /// The first full build has not finished yet, so a search can miss
87    /// sessions that exist. This is the state a fresh index starts in.
88    #[default]
89    Indexing,
90    /// The index file on disk was written by a different SessionWiki schema
91    /// version. Mjolnir will not open it, because opening it would drop and
92    /// rebuild the user's whole cache.
93    VersionMismatch,
94}
95
96/// What a search says about the index it answered from.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct WikiStatus {
100    pub state: WikiIndexState,
101    /// A sync is running now, so repeating the query may return more.
102    pub topping_up: bool,
103}
104
105/// One page of search results with the state of the index behind them.
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct WikiSearchPage {
109    pub rows: Vec<WikiRow>,
110    pub status: WikiStatus,
111}
112
113/// One message of an indexed transcript, reduced to what a search preview
114/// shows: the text around the query's matches, with the matches located in it.
115#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct WikiHitBlock {
118    /// `user`, `assistant` or `tool`.
119    pub role: String,
120    /// The message text, redacted, and windowed to the caller's per-message
121    /// budget when the message is longer than that.
122    pub text: String,
123    /// Byte ranges of the matches inside `text`, on character boundaries, in
124    /// order. A context message has none.
125    pub hits: Vec<(usize, usize)>,
126    /// Messages between the previous block and this one that no group covered.
127    /// Non-zero only on the first block of a group.
128    pub omitted_before: usize,
129    /// `text` is a window of the message rather than the whole of it.
130    pub truncated: bool,
131}
132
133/// The matching passages of one indexed transcript.
134#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct WikiHitTranscript {
137    pub blocks: Vec<WikiHitBlock>,
138    /// Messages after the last block that no group covered.
139    pub omitted_after: usize,
140}
141
142/// Start a new session carrying a compacted hand-off from an archived one.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct WikiRestoreRequest {
146    /// The SessionWiki session id to restore from.
147    pub wiki_id: String,
148    pub workspace_id: String,
149    pub profile_id: String,
150    pub target_template_id: String,
151    /// Where the new session opens. None takes the project the archived
152    /// session ran in, when that directory still exists.
153    #[serde(default)]
154    pub project_directory: Option<PathBuf>,
155    #[serde(default)]
156    pub additional_mounts: Vec<AdditionalMount>,
157    #[serde(default)]
158    pub resource_allocation: Option<SessionResourceAllocation>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct WorkspaceSnapshot {
164    pub workspace: WorkspaceRecord,
165    pub sessions: Vec<SessionPreview>,
166    pub drafts: Vec<DraftPreview>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct RuntimeSessionView {
172    pub session_id: String,
173    pub projection_ordinal: u64,
174    pub projection_digest: String,
175    pub operational: Option<RelayOperationalState>,
176    pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
177    pub connected: bool,
178    pub error: Option<ViewError>,
179}
180
181impl RuntimeSessionView {
182    pub fn from_managed(session_id: String, view: ManagedSessionView) -> Self {
183        let (projection_ordinal, projection_digest, operational, signal) =
184            view.snapshot
185                .map_or((0, String::new(), None, None), |snapshot| {
186                    (
187                        snapshot.materialized.applied_event_ordinal,
188                        snapshot.materialized.applied_event_digest,
189                        Some(snapshot.operational),
190                        snapshot.latest_credential_sync_signal,
191                    )
192                });
193        Self {
194            session_id,
195            projection_ordinal,
196            projection_digest,
197            operational,
198            latest_credential_sync_signal: signal,
199            connected: view.connected,
200            error: view.error,
201        }
202    }
203}
204
205/// Something the daemon did on its own that a surface should report once.
206///
207/// Background work has no lifecycle entry to hang a message on, so notices
208/// travel with the snapshot and carry an id: a surface reports the ones newer
209/// than the last it saw and nothing else, however often it polls.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct RuntimeNotice {
213    pub id: u64,
214    pub session_id: String,
215    pub text: String,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(deny_unknown_fields)]
220pub struct RuntimeSnapshot {
221    #[serde(default)]
222    pub workspace_names: BTreeMap<String, String>,
223    #[serde(default)]
224    pub moves: Vec<mj_core::state::MoveOperation>,
225    pub revision: u64,
226    pub config: Config,
227    pub records: Vec<SessionRecord>,
228    pub sessions: Vec<RuntimeSessionView>,
229    pub lifecycles: Vec<RuntimeLifecycleView>,
230    /// Reviews the daemon is running, so every surface renders the same one.
231    #[serde(default)]
232    pub reviews: Vec<RuntimeReviewView>,
233    /// Recent background events for this workspace's sessions, oldest first.
234    #[serde(default)]
235    pub notices: Vec<RuntimeNotice>,
236    /// Parent/child relations for the sessions in `records`, so a surface can
237    /// keep a daemon-created child out of the real workspace without a full
238    /// state reload.
239    #[serde(default)]
240    pub subagents: Vec<mj_core::subagent::SubagentRecord>,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "snake_case")]
245pub enum RuntimeLifecycleKind {
246    Create,
247    Close,
248    Resume,
249    Move,
250    ForceStop,
251    DestroyStopped,
252    ForceDestroy,
253    Cleanup,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[serde(deny_unknown_fields)]
258pub struct RuntimeLifecycleView {
259    pub operation_id: String,
260    pub cancellable: bool,
261    pub session_id: String,
262    pub kind: RuntimeLifecycleKind,
263    pub started_at_epoch_seconds: u64,
264    pub active_stages: Vec<(ProvisionStage, u64)>,
265    pub resume_destination: Option<(String, String)>,
266    pub notice: Option<String>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct ResumeSessionRequest {
272    pub session_id: String,
273    pub workspace_id: String,
274    pub profile_id: String,
275    pub target_template_id: String,
276    pub additional_mounts: Option<Vec<AdditionalMount>>,
277    pub resource_allocation: Option<SessionResourceAllocation>,
278    pub discard_queue: bool,
279    pub repository_preflight: Option<ResumeRepositorySourceReceipt>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284pub struct CreateSessionRequest {
285    #[serde(default)]
286    pub create_managed_worktree: Option<bool>,
287    /// None follows the global `[subagents] enabled` setting at launch time.
288    #[serde(default)]
289    pub mjolnir_subagents: Option<bool>,
290    #[serde(default)]
291    pub initial_prompt: Option<String>,
292    pub workspace_id: String,
293    pub profile_id: String,
294    pub bundle_id: String,
295    pub project_directory: Option<PathBuf>,
296    pub target_template_id: String,
297    pub additional_mounts: Vec<AdditionalMount>,
298    pub resource_allocation: Option<SessionResourceAllocation>,
299    pub title: String,
300    pub session_title_override: Option<String>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct RegisteredSession {
306    pub session: SessionRecord,
307    pub remembered_container_size: Option<(String, HostContainerSize)>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct DraftPreview {
313    pub id: String,
314    pub session_id: Option<String>,
315    pub source: String,
316    pub owner_pid: Option<u32>,
317    pub saved_at: String,
318}
319
320/// `Ping`, `Status`, and `Stop` form the frozen management subset: their wire
321/// encoding — together with `RequestEnvelope`, `ResponseEnvelope`,
322/// `DaemonStatus`, and `WebViewerStatus` — must never change shape, because
323/// clients and daemons of *any* protocol version rely on them to identify,
324/// stop, and replace each other. Every other action may change freely behind a
325/// `PROTOCOL_VERSION` bump.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(rename_all = "snake_case", tag = "action", content = "arguments")]
328pub enum DaemonAction {
329    Ping,
330    Status,
331    WebViewerAccess,
332    RecoverWebViewer(crate::web::WebViewerRecovery),
333    InspectWebListener,
334    ListWorkspaces,
335    CreateWorkspace {
336        name: String,
337    },
338    RenameWorkspace {
339        workspace_id: String,
340        name: String,
341    },
342    TouchWorkspace {
343        workspace_id: String,
344    },
345    DeleteWorkspace {
346        workspace_id: String,
347    },
348    Attach {
349        client_id: String,
350        pid: u32,
351    },
352    Detach {
353        client_id: String,
354    },
355    PersistReadReceipt {
356        client_id: String,
357        workspace_id: String,
358        session_id: String,
359        through: u64,
360    },
361    PersistDetachedSessionState {
362        client_id: String,
363        workspace_id: String,
364        session_id: String,
365        through: u64,
366        owner_pid: u32,
367        draft: mj_core::storage::DetachedSessionDraft,
368    },
369    SaveActiveReview {
370        session_id: String,
371        review: mj_core::storage::StoredReview,
372    },
373    ClearActiveReview {
374        session_id: String,
375    },
376    RememberReviewerSelection {
377        workspace_id: String,
378        selection: mj_core::second_opinion::ReviewerSelection,
379    },
380    SaveWorkspacePaneSizes {
381        workspace_id: String,
382        sizes: mj_core::workspace::PaneSizes,
383    },
384    PersistImportedSession {
385        session: Box<SessionRecord>,
386    },
387    SetSessionTitle {
388        session_id: String,
389        title: String,
390    },
391    SetSessionContainerSettings {
392        session_id: String,
393        cpus: Option<String>,
394        memory: Option<String>,
395        mounts: Vec<AdditionalMount>,
396        mount_history: Vec<PathBuf>,
397    },
398    SetSessionAcpTitle {
399        session_id: String,
400        title: Option<String>,
401    },
402    MarkSessionTargetMissing {
403        session_id: String,
404        detail: String,
405        updated_at: String,
406    },
407    CheckpointSession {
408        session_id: String,
409    },
410    /// Search the user's SessionWiki index. An empty query lists the most
411    /// recent sessions.
412    WikiSearch {
413        query: String,
414        limit: usize,
415    },
416    /// The markdown briefing for one indexed session.
417    WikiBrief {
418        wiki_id: String,
419        max_chars: usize,
420    },
421    /// The passages of one indexed session that match a query, with context.
422    WikiHits {
423        wiki_id: String,
424        query: String,
425        context_messages: usize,
426        per_message_chars: usize,
427    },
428    /// Start a new session from an archived one's transcript.
429    WikiRestore(WikiRestoreRequest),
430    ScanRecovery {
431        all_instances: bool,
432    },
433    AdoptRecovery {
434        session_id: String,
435        target_id: String,
436        profile: Option<String>,
437        bundle: Option<String>,
438        all_instances: bool,
439    },
440    DestroyRecovery {
441        session_id: String,
442        target_id: String,
443        confirmation: String,
444        all_instances: bool,
445    },
446    Snapshot {
447        workspace_id: String,
448    },
449    RuntimeSnapshot {
450        workspace_id: String,
451        after_revision: u64,
452        #[serde(default)]
453        all_workspaces: bool,
454    },
455    RenameProfile {
456        old_id: String,
457        new_id: String,
458    },
459    RenameTarget {
460        old_id: String,
461        new_id: String,
462    },
463    SubmitSessionCommand {
464        #[serde(default)]
465        inherited_draft: Option<String>,
466        session_id: String,
467        command_id: String,
468        command: RelayCommand,
469    },
470    /// Deliver a prompt typed while a session was still starting, once the
471    /// daemon sees that session's harness become ready. The daemon owns the
472    /// wait, so the prompt arrives whether or not this client is still
473    /// running or still showing that session.
474    QueueStartupPrompt {
475        session_id: String,
476        text: String,
477        /// The saved draft text this prompt was typed from, if the client
478        /// also persisted it. Cleared after a successful submit so the
479        /// delivered prompt does not reappear as a draft.
480        #[serde(default)]
481        inherited_draft: Option<String>,
482    },
483    SyncSession {
484        session_id: String,
485    },
486    RespondElicitation {
487        session_id: String,
488        elicitation_id: String,
489        response: ElicitationResponse,
490    },
491    StopBackgroundTask {
492        session_id: String,
493        background_task_id: String,
494    },
495    /// Drive a session's second-opinion reviewer. The reviewer is a sidecar of
496    /// the session's worker, so it travels the session's own relay rather than
497    /// becoming a session of its own here.
498    ReviewerAction {
499        session_id: String,
500        /// Which reviewing role the action drives; absent means the default
501        /// one, which is what plan review uses.
502        #[serde(default, skip_serializing_if = "Option::is_none")]
503        role: Option<String>,
504        action: crate::session::ReviewerAction,
505    },
506    /// Review the turn this session just finished, on a surface's request.
507    StartTurnReview {
508        session_id: String,
509    },
510    /// Forward, dismiss, or cancel the open review.
511    ResolveTurnReview {
512        session_id: String,
513        resolution: Resolution,
514    },
515    CloseSession {
516        session_id: String,
517    },
518    StartCreateSession(CreateSessionRequest),
519    WaitCreateSession {
520        session_id: String,
521    },
522    ResumeSession(ResumeSessionRequest),
523    PrepareMoveSession(MoveSelection),
524    MoveSession(MoveSessionRequest),
525    ForceStopSession {
526        session_id: String,
527    },
528    DestroyStoppedSession {
529        session_id: String,
530        /// Whether to delete the session's managed git branch as well. The
531        /// branch can hold work the user still wants, so destroying keeps it
532        /// unless the request asks for the deletion.
533        delete_branch: bool,
534    },
535    ForceDestroySession {
536        session_id: String,
537        /// See [`DaemonAction::DestroyStoppedSession`].
538        delete_branch: bool,
539    },
540    ForceDeleteWorkspace {
541        workspace_id: String,
542    },
543    CancelLifecycle {
544        session_id: String,
545    },
546    RecoverDraft {
547        draft_id: String,
548    },
549    Stop,
550}
551
552#[derive(Debug, Serialize, Deserialize)]
553#[serde(deny_unknown_fields)]
554pub struct RequestEnvelope {
555    pub protocol_version: u32,
556    pub request_id: u64,
557    pub token: String,
558    pub action: DaemonAction,
559}
560
561#[derive(Debug, Serialize, Deserialize)]
562#[serde(deny_unknown_fields)]
563pub struct ResponseEnvelope {
564    pub protocol_version: u32,
565    pub request_id: u64,
566    pub result: std::result::Result<DaemonReply, String>,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
570#[serde(rename_all = "snake_case", tag = "reply", content = "value")]
571pub enum DaemonReply {
572    Pong,
573    Status(DaemonStatus),
574    WebViewerAccess(crate::web::WebViewerAccess),
575    WebListeners(Vec<crate::web::WebListenerProcess>),
576    Workspaces(Vec<WorkspaceListing>),
577    Workspace(WorkspaceRecord),
578    Snapshot(WorkspaceSnapshot),
579    RuntimeSnapshot(Box<RuntimeSnapshot>),
580    RegisteredSession(Box<RegisteredSession>),
581    MovePreparation(Box<MovePreparation>),
582    MoveOutcome(MoveOutcome),
583    Ordinal(u64),
584    Text(String),
585    OptionalSessionState(Option<SessionState>),
586    Checkpoint(mj_core::state::CheckpointMetadata),
587    RecoveryScan(mj_core::state::RecoveryScan),
588    WikiRows(WikiSearchPage),
589    WikiHits(Option<WikiHitTranscript>),
590    Reviewer(Box<crate::session::ReviewerOutcome>),
591    Done,
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
595#[serde(deny_unknown_fields)]
596pub struct DaemonStatus {
597    pub pid: u32,
598    pub started_at: String,
599    pub build_version: String,
600    pub attached_clients: usize,
601    pub phone_status: WebViewerStatus,
602}
603
604#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
605#[serde(rename_all = "snake_case", tag = "state")]
606pub enum WebViewerStatus {
607    Disabled,
608    Starting,
609    Ready {
610        viewer_url: String,
611        viewer_code: String,
612        qr_login_url: Option<String>,
613        fallback_reason: Option<String>,
614    },
615    Stopped,
616    Error {
617        message: String,
618    },
619}
620
621impl std::fmt::Debug for WebViewerStatus {
622    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
623        match self {
624            Self::Ready {
625                viewer_url,
626                viewer_code,
627                fallback_reason,
628                ..
629            } => formatter
630                .debug_struct("Ready")
631                .field("viewer_url", viewer_url)
632                .field("viewer_code", viewer_code)
633                .field("qr_login_url", &"[redacted]")
634                .field("fallback_reason", fallback_reason)
635                .finish(),
636            Self::Disabled => formatter.write_str("Disabled"),
637            Self::Starting => formatter.write_str("Starting"),
638            Self::Stopped => formatter.write_str("Stopped"),
639            Self::Error { message } => formatter.debug_tuple("Error").field(message).finish(),
640        }
641    }
642}
643
644impl std::fmt::Display for WebViewerStatus {
645    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646        match self {
647            Self::Disabled => formatter.write_str("disabled"),
648            Self::Starting => formatter.write_str("starting"),
649            Self::Stopped => formatter.write_str("stopped unexpectedly"),
650            Self::Error { message } => write!(formatter, "error: {message}"),
651            Self::Ready {
652                viewer_url,
653                viewer_code,
654                fallback_reason,
655                ..
656            } => {
657                write!(formatter, "{viewer_url}; viewer code {viewer_code}")?;
658                if let Some(reason) = fallback_reason {
659                    write!(
660                        formatter,
661                        "; local only because Tailscale HTTPS is unavailable: {reason}"
662                    )?;
663                }
664                Ok(())
665            }
666        }
667    }
668}
669
670/// Whether a non-child process has exited but not yet been reaped.
671///
672/// A zombie still answers `kill(pid, 0)`, because its process-table entry
673/// survives until its parent waits for it — so an existence probe alone calls
674/// it alive forever and anything waiting for it to leave waits forever. That is
675/// exactly the shape of `mj daemon restart` refusing to restart a daemon that
676/// had already stopped: `spawn_detached` used to leave the daemon a child of a
677/// long-lived Mjolnir process that never reaped it. It now double-forks, so the
678/// daemon is init's to reap, but any other unreaped child of this process would
679/// look the same, and the check stays cheap.
680///
681/// Treating a zombie as gone is also safe in the direction that matters: a
682/// zombie's PID cannot be reused until it is reaped, so nothing else can be
683/// occupying that number while this returns true.
684#[cfg(unix)]
685pub fn process_is_zombie(pid: u32) -> bool {
686    let pid = sysinfo::Pid::from_u32(pid);
687    let mut system = sysinfo::System::new();
688    system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
689    system
690        .process(pid)
691        .is_some_and(|process| process.status() == sysinfo::ProcessStatus::Zombie)
692}
693
694/// Wait for a process to leave, within [`STOP_TIMEOUT`].
695///
696/// The error says the process was still running rather than that it "did not
697/// stop": a daemon that is still winding down has not refused, and the two
698/// read very differently to somebody deciding whether to reach for a kill.
699pub async fn wait_for_exit(pid: u32) -> Result<()> {
700    let deadline = Instant::now() + STOP_TIMEOUT;
701    while daemon_process_is_alive(pid) {
702        ensure!(Instant::now() < deadline, "process {pid} is still running");
703        tokio::time::sleep(RETRY_DELAY).await;
704    }
705    Ok(())
706}
707
708/// Whether a daemon that Mjolnir launched in its own process group is alive.
709///
710/// Reaping is deliberately confined to this daemon-specific path. Attachment
711/// PIDs are merely observations and may alias unrelated children owned by this
712/// process, so their liveness probe below must never call `waitpid`.
713pub fn daemon_process_is_alive(pid: u32) -> bool {
714    #[cfg(unix)]
715    {
716        if pid == 0 {
717            return false;
718        }
719        let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
720            return false;
721        };
722        let mut status = 0;
723        // SAFETY: `status` is writable for the call and WNOHANG never blocks.
724        // A non-child fails with ECHILD without changing any process state.
725        let waited = unsafe { libc::waitpid(raw_pid, &mut status, libc::WNOHANG) };
726        if waited == raw_pid {
727            return false;
728        }
729        if waited == 0 {
730            return true;
731        }
732        let wait_error = std::io::Error::last_os_error();
733        if wait_error.raw_os_error() != Some(libc::ECHILD) {
734            return true;
735        }
736
737        #[cfg(target_os = "macos")]
738        return owned_daemon_group_is_alive(raw_pid);
739
740        #[cfg(not(target_os = "macos"))]
741        process_is_alive(pid)
742    }
743    #[cfg(not(unix))]
744    process_is_alive(pid)
745}
746
747#[cfg(target_os = "macos")]
748pub fn owned_daemon_group_is_alive(pid: libc::pid_t) -> bool {
749    // `spawn_detached` makes the daemon a process-group leader. Darwin
750    // excludes zombies from group signal probes: ESRCH means the group is gone
751    // and EPERM means only exiting members remain. The latter is safe here
752    // because this is a group we created for our own same-user child, not an
753    // arbitrary process group.
754    // SAFETY: signal 0 is only an existence probe, and the negative PID targets
755    // the daemon-owned group rather than another process.
756    if unsafe { libc::kill(-pid, 0) } == 0 {
757        return true;
758    }
759    let error = std::io::Error::last_os_error();
760    !matches!(error.raw_os_error(), Some(libc::ESRCH) | Some(libc::EPERM))
761}
762
763pub fn process_is_alive(pid: u32) -> bool {
764    #[cfg(unix)]
765    {
766        if pid == 0 {
767            return false;
768        }
769        let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
770            return false;
771        };
772        // SAFETY: kill(pid, 0) sends no signal and is the standard existence
773        // probe. EPERM still means the process exists.
774        let result = unsafe { libc::kill(raw_pid, 0) };
775        let exists =
776            result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
777        exists && !process_is_zombie(pid)
778    }
779    #[cfg(not(unix))]
780    {
781        let _ = pid;
782        true
783    }
784}
785
786pub fn read_metadata() -> Result<DaemonMetadata> {
787    let metadata = read_metadata_any()?;
788    ensure!(
789        metadata.protocol_version == PROTOCOL_VERSION,
790        "daemon protocol {} is incompatible with client protocol {}",
791        metadata.protocol_version,
792        PROTOCOL_VERSION
793    );
794    Ok(metadata)
795}
796
797pub fn read_metadata_any() -> Result<DaemonMetadata> {
798    let path = metadata_path();
799    let body = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
800    let metadata: DaemonMetadata =
801        serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
802    Ok(metadata)
803}
804
805pub async fn write_frame<T: Serialize>(stream: &mut TcpStream, value: &T) -> Result<()> {
806    let body = serde_json::to_vec(value)?;
807    ensure!(body.len() <= MAX_FRAME_BYTES, "daemon frame is too large");
808    stream.write_u32(body.len() as u32).await?;
809    stream.write_all(&body).await?;
810    stream.flush().await?;
811    Ok(())
812}
813
814pub async fn read_frame<T: for<'de> Deserialize<'de>>(stream: &mut TcpStream) -> Result<T> {
815    let length = stream.read_u32().await? as usize;
816    ensure!(
817        length <= MAX_FRAME_BYTES,
818        "daemon frame exceeds {MAX_FRAME_BYTES} bytes"
819    );
820    let mut body = vec![0_u8; length];
821    stream.read_exact(&mut body).await?;
822    serde_json::from_slice(&body).context("decode daemon frame")
823}
824
825pub struct DaemonClient {
826    metadata: DaemonMetadata,
827    stream: TcpStream,
828    next_request_id: u64,
829}
830
831impl DaemonClient {
832    pub async fn connect(metadata: DaemonMetadata) -> Result<Self> {
833        let stream =
834            tokio::time::timeout(Duration::from_secs(1), TcpStream::connect(metadata.address))
835                .await
836                .context("time out connecting to Mjolnir daemon")??;
837        Ok(Self {
838            metadata,
839            stream,
840            next_request_id: 1,
841        })
842    }
843
844    /// Speak the daemon's advertised dialect, not this build's: management
845    /// requests must reach daemons of any protocol version, and the frozen
846    /// subset encodes identically across all of them.
847    pub async fn request(&mut self, action: DaemonAction) -> Result<DaemonReply> {
848        let protocol_version = self.metadata.protocol_version;
849        let request_id = self.next_request_id;
850        self.next_request_id += 1;
851        write_frame(
852            &mut self.stream,
853            &RequestEnvelope {
854                protocol_version,
855                request_id,
856                token: self.metadata.token.clone(),
857                action,
858            },
859        )
860        .await?;
861        let response: ResponseEnvelope = read_frame(&mut self.stream).await?;
862        ensure!(
863            response.protocol_version == protocol_version,
864            "daemon changed protocol"
865        );
866        ensure!(
867            response.request_id == request_id,
868            "daemon crossed request IDs"
869        );
870        response.result.map_err(anyhow::Error::msg)
871    }
872
873    pub async fn status(&mut self) -> Result<DaemonStatus> {
874        match self.request(DaemonAction::Status).await? {
875            DaemonReply::Status(status) => Ok(status),
876            reply => bail!("unexpected daemon status reply {reply:?}"),
877        }
878    }
879
880    pub async fn web_access(&mut self) -> Result<crate::web::WebViewerAccess> {
881        match self.request(DaemonAction::WebViewerAccess).await? {
882            DaemonReply::WebViewerAccess(access) => Ok(access),
883            reply => bail!("unexpected web viewer reply {reply:?}"),
884        }
885    }
886
887    pub async fn recover_web_viewer(
888        &mut self,
889        action: crate::web::WebViewerRecovery,
890    ) -> Result<()> {
891        match self.request(DaemonAction::RecoverWebViewer(action)).await? {
892            DaemonReply::Done => Ok(()),
893            reply => bail!("unexpected web viewer recovery reply {reply:?}"),
894        }
895    }
896
897    pub async fn inspect_web_listener(&mut self) -> Result<Vec<crate::web::WebListenerProcess>> {
898        match self.request(DaemonAction::InspectWebListener).await? {
899            DaemonReply::WebListeners(processes) => Ok(processes),
900            reply => bail!("unexpected listener inspection reply {reply:?}"),
901        }
902    }
903
904    pub async fn list_workspaces(&mut self) -> Result<Vec<WorkspaceListing>> {
905        match self.request(DaemonAction::ListWorkspaces).await? {
906            DaemonReply::Workspaces(workspaces) => Ok(workspaces),
907            reply => bail!("unexpected daemon workspace reply {reply:?}"),
908        }
909    }
910
911    pub async fn rename_profile(&mut self, old_id: String, new_id: String) -> Result<()> {
912        match self
913            .request(DaemonAction::RenameProfile { old_id, new_id })
914            .await?
915        {
916            DaemonReply::Done => Ok(()),
917            reply => bail!("unexpected rename-profile reply {reply:?}"),
918        }
919    }
920
921    pub async fn rename_target(&mut self, old_id: String, new_id: String) -> Result<()> {
922        match self
923            .request(DaemonAction::RenameTarget { old_id, new_id })
924            .await?
925        {
926            DaemonReply::Done => Ok(()),
927            reply => bail!("unexpected rename-target reply {reply:?}"),
928        }
929    }
930
931    pub async fn create_workspace(&mut self, name: String) -> Result<WorkspaceRecord> {
932        match self.request(DaemonAction::CreateWorkspace { name }).await? {
933            DaemonReply::Workspace(workspace) => Ok(workspace),
934            reply => bail!("unexpected create-workspace reply {reply:?}"),
935        }
936    }
937
938    pub async fn rename_workspace(&mut self, workspace_id: String, name: String) -> Result<()> {
939        match self
940            .request(DaemonAction::RenameWorkspace { workspace_id, name })
941            .await?
942        {
943            DaemonReply::Done => Ok(()),
944            reply => bail!("unexpected rename-workspace reply {reply:?}"),
945        }
946    }
947
948    pub async fn touch_workspace(&mut self, workspace_id: String) -> Result<()> {
949        match self
950            .request(DaemonAction::TouchWorkspace { workspace_id })
951            .await?
952        {
953            DaemonReply::Done => Ok(()),
954            reply => bail!("unexpected touch-workspace reply {reply:?}"),
955        }
956    }
957
958    pub async fn delete_workspace(&mut self, workspace_id: String) -> Result<()> {
959        match self
960            .request(DaemonAction::DeleteWorkspace { workspace_id })
961            .await?
962        {
963            DaemonReply::Done => Ok(()),
964            reply => bail!("unexpected delete-workspace reply {reply:?}"),
965        }
966    }
967
968    pub async fn attach(&mut self, client_id: String, pid: u32) -> Result<()> {
969        match self
970            .request(DaemonAction::Attach { client_id, pid })
971            .await?
972        {
973            DaemonReply::Done => Ok(()),
974            reply => bail!("unexpected attach reply {reply:?}"),
975        }
976    }
977
978    pub async fn detach(&mut self, client_id: String) -> Result<()> {
979        match self.request(DaemonAction::Detach { client_id }).await? {
980            DaemonReply::Done => Ok(()),
981            reply => bail!("unexpected detach reply {reply:?}"),
982        }
983    }
984
985    pub async fn persist_read_receipt(
986        &mut self,
987        client_id: String,
988        workspace_id: String,
989        session_id: String,
990        through: u64,
991    ) -> Result<u64> {
992        match self
993            .request(DaemonAction::PersistReadReceipt {
994                client_id,
995                workspace_id,
996                session_id,
997                through,
998            })
999            .await?
1000        {
1001            DaemonReply::Ordinal(ordinal) => Ok(ordinal),
1002            reply => bail!("unexpected read-receipt reply {reply:?}"),
1003        }
1004    }
1005
1006    pub async fn persist_detached_session_state(
1007        &mut self,
1008        client_id: String,
1009        workspace_id: String,
1010        session_id: String,
1011        through: u64,
1012        owner_pid: u32,
1013        draft: mj_core::storage::DetachedSessionDraft,
1014    ) -> Result<()> {
1015        match self
1016            .request(DaemonAction::PersistDetachedSessionState {
1017                client_id,
1018                workspace_id,
1019                session_id,
1020                through,
1021                owner_pid,
1022                draft,
1023            })
1024            .await?
1025        {
1026            DaemonReply::Done => Ok(()),
1027            reply => bail!("unexpected detached-session-state reply {reply:?}"),
1028        }
1029    }
1030
1031    pub async fn save_active_review(
1032        &mut self,
1033        session_id: String,
1034        review: mj_core::storage::StoredReview,
1035    ) -> Result<()> {
1036        match self
1037            .request(DaemonAction::SaveActiveReview { session_id, review })
1038            .await?
1039        {
1040            DaemonReply::Done => Ok(()),
1041            reply => bail!("unexpected save-review reply {reply:?}"),
1042        }
1043    }
1044
1045    pub async fn clear_active_review(&mut self, session_id: String) -> Result<()> {
1046        match self
1047            .request(DaemonAction::ClearActiveReview { session_id })
1048            .await?
1049        {
1050            DaemonReply::Done => Ok(()),
1051            reply => bail!("unexpected clear-review reply {reply:?}"),
1052        }
1053    }
1054
1055    pub async fn remember_reviewer_selection(
1056        &mut self,
1057        workspace_id: String,
1058        selection: mj_core::second_opinion::ReviewerSelection,
1059    ) -> Result<()> {
1060        match self
1061            .request(DaemonAction::RememberReviewerSelection {
1062                workspace_id,
1063                selection,
1064            })
1065            .await?
1066        {
1067            DaemonReply::Done => Ok(()),
1068            reply => bail!("unexpected reviewer-selection reply {reply:?}"),
1069        }
1070    }
1071
1072    pub async fn save_workspace_pane_sizes(
1073        &mut self,
1074        workspace_id: String,
1075        sizes: mj_core::workspace::PaneSizes,
1076    ) -> Result<()> {
1077        match self
1078            .request(DaemonAction::SaveWorkspacePaneSizes {
1079                workspace_id,
1080                sizes,
1081            })
1082            .await?
1083        {
1084            DaemonReply::Done => Ok(()),
1085            reply => bail!("unexpected pane-size save reply {reply:?}"),
1086        }
1087    }
1088
1089    pub async fn persist_imported_session(&mut self, session: SessionRecord) -> Result<()> {
1090        match self
1091            .request(DaemonAction::PersistImportedSession {
1092                session: Box::new(session),
1093            })
1094            .await?
1095        {
1096            DaemonReply::Done => Ok(()),
1097            reply => bail!("unexpected imported-session reply {reply:?}"),
1098        }
1099    }
1100
1101    pub async fn set_session_title(&mut self, session_id: String, title: String) -> Result<String> {
1102        match self
1103            .request(DaemonAction::SetSessionTitle { session_id, title })
1104            .await?
1105        {
1106            DaemonReply::Text(title) => Ok(title),
1107            reply => bail!("unexpected session-title reply {reply:?}"),
1108        }
1109    }
1110
1111    pub async fn set_session_container_settings(
1112        &mut self,
1113        session_id: String,
1114        cpus: Option<String>,
1115        memory: Option<String>,
1116        mounts: Vec<AdditionalMount>,
1117        mount_history: Vec<PathBuf>,
1118    ) -> Result<()> {
1119        match self
1120            .request(DaemonAction::SetSessionContainerSettings {
1121                session_id,
1122                cpus,
1123                memory,
1124                mounts,
1125                mount_history,
1126            })
1127            .await?
1128        {
1129            DaemonReply::Done => Ok(()),
1130            reply => bail!("unexpected container-settings reply {reply:?}"),
1131        }
1132    }
1133
1134    pub async fn set_session_acp_title(
1135        &mut self,
1136        session_id: String,
1137        title: Option<String>,
1138    ) -> Result<()> {
1139        match self
1140            .request(DaemonAction::SetSessionAcpTitle { session_id, title })
1141            .await?
1142        {
1143            DaemonReply::Done => Ok(()),
1144            reply => bail!("unexpected ACP-title reply {reply:?}"),
1145        }
1146    }
1147
1148    pub async fn mark_session_target_missing(
1149        &mut self,
1150        session_id: String,
1151        detail: String,
1152        updated_at: String,
1153    ) -> Result<Option<SessionState>> {
1154        match self
1155            .request(DaemonAction::MarkSessionTargetMissing {
1156                session_id,
1157                detail,
1158                updated_at,
1159            })
1160            .await?
1161        {
1162            DaemonReply::OptionalSessionState(state) => Ok(state),
1163            reply => bail!("unexpected target-missing reply {reply:?}"),
1164        }
1165    }
1166
1167    pub async fn checkpoint_session(
1168        &mut self,
1169        session_id: String,
1170    ) -> Result<mj_core::state::CheckpointMetadata> {
1171        match self
1172            .request(DaemonAction::CheckpointSession { session_id })
1173            .await?
1174        {
1175            DaemonReply::Checkpoint(checkpoint) => Ok(checkpoint),
1176            reply => bail!("unexpected checkpoint reply {reply:?}"),
1177        }
1178    }
1179
1180    /// Search the user's SessionWiki index, newest first when the query is
1181    /// empty and best match first otherwise. The reply carries the state of
1182    /// the index as well as the rows, so a caller can say the first build is
1183    /// still running.
1184    pub async fn wiki_search(&mut self, query: String, limit: usize) -> Result<WikiSearchPage> {
1185        match self
1186            .request(DaemonAction::WikiSearch { query, limit })
1187            .await?
1188        {
1189            DaemonReply::WikiRows(page) => Ok(page),
1190            reply => bail!("unexpected SessionWiki search reply {reply:?}"),
1191        }
1192    }
1193
1194    /// The markdown briefing for one indexed session.
1195    pub async fn wiki_brief(&mut self, wiki_id: String, max_chars: usize) -> Result<String> {
1196        match self
1197            .request(DaemonAction::WikiBrief { wiki_id, max_chars })
1198            .await?
1199        {
1200            DaemonReply::Text(markdown) => Ok(markdown),
1201            reply => bail!("unexpected SessionWiki brief reply {reply:?}"),
1202        }
1203    }
1204
1205    /// The passages of one indexed session that match a query, each matching
1206    /// message with `context_messages` neighbours on either side and its text
1207    /// capped at `per_message_chars`. `None` when the index holds no session
1208    /// with that id.
1209    pub async fn wiki_hits(
1210        &mut self,
1211        wiki_id: String,
1212        query: String,
1213        context_messages: usize,
1214        per_message_chars: usize,
1215    ) -> Result<Option<WikiHitTranscript>> {
1216        match self
1217            .request(DaemonAction::WikiHits {
1218                wiki_id,
1219                query,
1220                context_messages,
1221                per_message_chars,
1222            })
1223            .await?
1224        {
1225            DaemonReply::WikiHits(transcript) => Ok(transcript),
1226            reply => bail!("unexpected SessionWiki hits reply {reply:?}"),
1227        }
1228    }
1229
1230    /// Start a new session carrying a hand-off compacted from an archived one.
1231    /// It answers like any other session start: the record exists and is
1232    /// provisioning, and the hand-off follows once the harness is ready.
1233    pub async fn wiki_restore(&mut self, request: WikiRestoreRequest) -> Result<RegisteredSession> {
1234        match self.request(DaemonAction::WikiRestore(request)).await? {
1235            DaemonReply::RegisteredSession(registered) => Ok(*registered),
1236            reply => bail!("unexpected SessionWiki restore reply {reply:?}"),
1237        }
1238    }
1239
1240    pub async fn scan_recovery(
1241        &mut self,
1242        all_instances: bool,
1243    ) -> Result<mj_core::state::RecoveryScan> {
1244        match self
1245            .request(DaemonAction::ScanRecovery { all_instances })
1246            .await?
1247        {
1248            DaemonReply::RecoveryScan(scan) => Ok(scan),
1249            reply => bail!("unexpected recovery-scan reply {reply:?}"),
1250        }
1251    }
1252
1253    pub async fn adopt_recovery(
1254        &mut self,
1255        session_id: String,
1256        target_id: String,
1257        profile: Option<String>,
1258        bundle: Option<String>,
1259        all_instances: bool,
1260    ) -> Result<()> {
1261        match self
1262            .request(DaemonAction::AdoptRecovery {
1263                session_id,
1264                target_id,
1265                profile,
1266                bundle,
1267                all_instances,
1268            })
1269            .await?
1270        {
1271            DaemonReply::Done => Ok(()),
1272            reply => bail!("unexpected recovery-adopt reply {reply:?}"),
1273        }
1274    }
1275
1276    pub async fn destroy_recovery(
1277        &mut self,
1278        session_id: String,
1279        target_id: String,
1280        confirmation: String,
1281        all_instances: bool,
1282    ) -> Result<()> {
1283        match self
1284            .request(DaemonAction::DestroyRecovery {
1285                session_id,
1286                target_id,
1287                confirmation,
1288                all_instances,
1289            })
1290            .await?
1291        {
1292            DaemonReply::Done => Ok(()),
1293            reply => bail!("unexpected recovery-destroy reply {reply:?}"),
1294        }
1295    }
1296
1297    pub async fn snapshot(&mut self, workspace_id: String) -> Result<WorkspaceSnapshot> {
1298        match self
1299            .request(DaemonAction::Snapshot { workspace_id })
1300            .await?
1301        {
1302            DaemonReply::Snapshot(snapshot) => Ok(snapshot),
1303            reply => bail!("unexpected snapshot reply {reply:?}"),
1304        }
1305    }
1306
1307    pub async fn runtime_snapshot(
1308        &mut self,
1309        workspace_id: String,
1310        after_revision: u64,
1311        all_workspaces: bool,
1312    ) -> Result<RuntimeSnapshot> {
1313        match self
1314            .request(DaemonAction::RuntimeSnapshot {
1315                workspace_id,
1316                after_revision,
1317                all_workspaces,
1318            })
1319            .await?
1320        {
1321            DaemonReply::RuntimeSnapshot(snapshot) => Ok(*snapshot),
1322            reply => bail!("unexpected runtime snapshot reply {reply:?}"),
1323        }
1324    }
1325
1326    pub async fn submit_session_command(
1327        &mut self,
1328        session_id: String,
1329        command_id: String,
1330        command: RelayCommand,
1331        inherited_draft: Option<String>,
1332    ) -> Result<u64> {
1333        match self
1334            .request(DaemonAction::SubmitSessionCommand {
1335                inherited_draft,
1336                session_id,
1337                command_id,
1338                command,
1339            })
1340            .await?
1341        {
1342            DaemonReply::Ordinal(ordinal) => Ok(ordinal),
1343            reply => bail!("unexpected session command reply {reply:?}"),
1344        }
1345    }
1346
1347    /// Hand the daemon a prompt for a session that is still starting. The
1348    /// daemon replies as soon as the prompt is queued, not when it is
1349    /// delivered; delivery failures come back as a session notice.
1350    pub async fn queue_startup_prompt(
1351        &mut self,
1352        session_id: String,
1353        text: String,
1354        inherited_draft: Option<String>,
1355    ) -> Result<()> {
1356        match self
1357            .request(DaemonAction::QueueStartupPrompt {
1358                session_id,
1359                text,
1360                inherited_draft,
1361            })
1362            .await?
1363        {
1364            DaemonReply::Done => Ok(()),
1365            reply => bail!("unexpected startup prompt reply {reply:?}"),
1366        }
1367    }
1368
1369    /// Ask the daemon to review the turn this session just finished.
1370    ///
1371    /// The refusal is a sentence for a person -- "prompts are queued", "set
1372    /// [review] profile in config.toml" -- so it travels as text rather than
1373    /// as a code every surface would have to translate.
1374    pub async fn start_turn_review(&mut self, session_id: String) -> Result<()> {
1375        match self
1376            .request(DaemonAction::StartTurnReview { session_id })
1377            .await?
1378        {
1379            DaemonReply::Done => Ok(()),
1380            reply => bail!("unexpected turn-review reply {reply:?}"),
1381        }
1382    }
1383
1384    pub async fn resolve_turn_review(
1385        &mut self,
1386        session_id: String,
1387        resolution: Resolution,
1388    ) -> Result<()> {
1389        match self
1390            .request(DaemonAction::ResolveTurnReview {
1391                session_id,
1392                resolution,
1393            })
1394            .await?
1395        {
1396            DaemonReply::Done => Ok(()),
1397            reply => bail!("unexpected turn-review resolution reply {reply:?}"),
1398        }
1399    }
1400
1401    pub async fn reviewer_action(
1402        &mut self,
1403        session_id: String,
1404        role: Option<String>,
1405        action: crate::session::ReviewerAction,
1406    ) -> Result<crate::session::ReviewerOutcome> {
1407        match self
1408            .request(DaemonAction::ReviewerAction {
1409                session_id,
1410                role,
1411                action,
1412            })
1413            .await?
1414        {
1415            DaemonReply::Reviewer(outcome) => Ok(*outcome),
1416            reply => bail!("unexpected reviewer reply {reply:?}"),
1417        }
1418    }
1419
1420    pub async fn sync_session(&mut self, session_id: String) -> Result<()> {
1421        match self
1422            .request(DaemonAction::SyncSession { session_id })
1423            .await?
1424        {
1425            DaemonReply::Done => Ok(()),
1426            reply => bail!("unexpected session sync reply {reply:?}"),
1427        }
1428    }
1429
1430    pub async fn respond_elicitation(
1431        &mut self,
1432        session_id: String,
1433        elicitation_id: String,
1434        response: ElicitationResponse,
1435    ) -> Result<()> {
1436        match self
1437            .request(DaemonAction::RespondElicitation {
1438                session_id,
1439                elicitation_id,
1440                response,
1441            })
1442            .await?
1443        {
1444            DaemonReply::Done => Ok(()),
1445            reply => bail!("unexpected elicitation reply {reply:?}"),
1446        }
1447    }
1448
1449    pub async fn stop_background_task(
1450        &mut self,
1451        session_id: String,
1452        background_task_id: String,
1453    ) -> Result<()> {
1454        match self
1455            .request(DaemonAction::StopBackgroundTask {
1456                session_id,
1457                background_task_id,
1458            })
1459            .await?
1460        {
1461            DaemonReply::Done => Ok(()),
1462            reply => bail!("unexpected background task stop reply {reply:?}"),
1463        }
1464    }
1465
1466    pub async fn close_session(&mut self, session_id: String) -> Result<()> {
1467        match self
1468            .request(DaemonAction::CloseSession { session_id })
1469            .await?
1470        {
1471            DaemonReply::Done => Ok(()),
1472            reply => bail!("unexpected close-session reply {reply:?}"),
1473        }
1474    }
1475
1476    pub async fn start_create_session(
1477        &mut self,
1478        request: CreateSessionRequest,
1479    ) -> Result<RegisteredSession> {
1480        match self
1481            .request(DaemonAction::StartCreateSession(request))
1482            .await?
1483        {
1484            DaemonReply::RegisteredSession(registered) => Ok(*registered),
1485            reply => bail!("unexpected start-create reply {reply:?}"),
1486        }
1487    }
1488
1489    pub async fn wait_create_session(&mut self, session_id: String) -> Result<()> {
1490        match self
1491            .request(DaemonAction::WaitCreateSession { session_id })
1492            .await?
1493        {
1494            DaemonReply::Done => Ok(()),
1495            reply => bail!("unexpected wait-create reply {reply:?}"),
1496        }
1497    }
1498
1499    pub async fn resume_session(&mut self, request: ResumeSessionRequest) -> Result<()> {
1500        match self.request(DaemonAction::ResumeSession(request)).await? {
1501            DaemonReply::Done => Ok(()),
1502            reply => bail!("unexpected resume-session reply {reply:?}"),
1503        }
1504    }
1505
1506    pub async fn force_stop_session(&mut self, session_id: String) -> Result<()> {
1507        match self
1508            .request(DaemonAction::ForceStopSession { session_id })
1509            .await?
1510        {
1511            DaemonReply::Done => Ok(()),
1512            reply => bail!("unexpected force-stop reply {reply:?}"),
1513        }
1514    }
1515
1516    pub async fn destroy_stopped_session(
1517        &mut self,
1518        session_id: String,
1519        delete_branch: bool,
1520    ) -> Result<()> {
1521        match self
1522            .request(DaemonAction::DestroyStoppedSession {
1523                session_id,
1524                delete_branch,
1525            })
1526            .await?
1527        {
1528            DaemonReply::Done => Ok(()),
1529            reply => bail!("unexpected destroy-stopped reply {reply:?}"),
1530        }
1531    }
1532
1533    pub async fn force_destroy_session(
1534        &mut self,
1535        session_id: String,
1536        delete_branch: bool,
1537    ) -> Result<()> {
1538        match self
1539            .request(DaemonAction::ForceDestroySession {
1540                session_id,
1541                delete_branch,
1542            })
1543            .await?
1544        {
1545            DaemonReply::Done => Ok(()),
1546            reply => bail!("unexpected force-destroy reply {reply:?}"),
1547        }
1548    }
1549
1550    pub async fn force_delete_workspace(&mut self, workspace_id: String) -> Result<()> {
1551        match self
1552            .request(DaemonAction::ForceDeleteWorkspace { workspace_id })
1553            .await?
1554        {
1555            DaemonReply::Done => Ok(()),
1556            reply => bail!("unexpected force-delete-workspace reply {reply:?}"),
1557        }
1558    }
1559
1560    pub async fn cancel_lifecycle(&mut self, session_id: String) -> Result<()> {
1561        match self
1562            .request(DaemonAction::CancelLifecycle { session_id })
1563            .await?
1564        {
1565            DaemonReply::Done => Ok(()),
1566            reply => bail!("unexpected cancel-lifecycle reply {reply:?}"),
1567        }
1568    }
1569
1570    pub async fn recover_draft(&mut self, draft_id: String) -> Result<()> {
1571        match self
1572            .request(DaemonAction::RecoverDraft { draft_id })
1573            .await?
1574        {
1575            DaemonReply::Done => Ok(()),
1576            reply => bail!("unexpected recover-draft reply {reply:?}"),
1577        }
1578    }
1579
1580    pub async fn stop(&mut self) -> Result<()> {
1581        match self.request(DaemonAction::Stop).await? {
1582            DaemonReply::Done => Ok(()),
1583            reply => bail!("unexpected stop reply {reply:?}"),
1584        }
1585    }
1586}
1587
1588pub async fn connect_existing() -> Result<DaemonClient> {
1589    let metadata = tokio::task::spawn_blocking(read_metadata)
1590        .await
1591        .context("read daemon metadata task failed")??;
1592    DaemonClient::connect(metadata).await
1593}
1594
1595/// A handle to whatever daemon the metadata file advertises, regardless of its
1596/// protocol version. It only exposes the frozen management subset (`Ping`,
1597/// `Status`, `Stop`), which encodes identically in every protocol version.
1598pub struct ManagementClient {
1599    inner: DaemonClient,
1600}
1601
1602impl ManagementClient {
1603    pub fn new(inner: DaemonClient) -> Self {
1604        Self { inner }
1605    }
1606    pub fn protocol_version(&self) -> u32 {
1607        self.inner.metadata.protocol_version
1608    }
1609
1610    pub async fn status(&mut self) -> Result<DaemonStatus> {
1611        self.inner.status().await
1612    }
1613
1614    pub async fn stop(&mut self) -> Result<()> {
1615        tokio::time::timeout(STOP_TIMEOUT, self.inner.stop())
1616            .await
1617            .context("Mjolnir daemon did not acknowledge the stop before the deadline")?
1618    }
1619
1620    /// Ask the daemon to stop and wait for its process to actually exit.
1621    pub async fn stop_and_wait(mut self) -> Result<()> {
1622        let pid = self.inner.metadata.pid;
1623        self.stop().await?;
1624        wait_for_exit(pid).await.with_context(|| {
1625            format!(
1626                "Mjolnir daemon {pid} accepted the stop but was still running after {}s",
1627                STOP_TIMEOUT.as_secs()
1628            )
1629        })
1630    }
1631}
1632
1633pub async fn connect_management() -> Result<ManagementClient> {
1634    Ok(ManagementClient {
1635        inner: DaemonClient::connect(read_metadata_any()?).await?,
1636    })
1637}
1638
1639pub fn ensure_supported_daemon_protocol(version: u32) -> Result<()> {
1640    ensure!(
1641        version <= PROTOCOL_VERSION,
1642        "the daemon uses a newer protocol ({version}) than this client ({PROTOCOL_VERSION}); restart this client with the updated mj binary"
1643    );
1644    Ok(())
1645}
1646pub const PROTOCOL_VERSION: u32 = 25;
1647pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
1648/// How long a daemon is given to exit after it accepts a stop.
1649///
1650/// Stopping cancels a token and returns immediately; the daemon then unwinds
1651/// its session manager, its phone server and its pollers. That is normally
1652/// fast, but a daemon whose database has been migrated out from under it fails
1653/// every read while it winds down and has been observed taking over five
1654/// seconds — which the previous five-second bound missed by a fraction,
1655/// reporting a stop that had in fact worked as `did not stop` and aborting the
1656/// restart that depended on it.
1657pub const STOP_TIMEOUT: Duration = Duration::from_secs(30);
1658pub const RETRY_DELAY: Duration = Duration::from_millis(40);
1659impl DaemonClient {
1660    pub async fn prepare_move_session(
1661        &mut self,
1662        selection: MoveSelection,
1663    ) -> Result<MovePreparation> {
1664        match self
1665            .request(DaemonAction::PrepareMoveSession(selection))
1666            .await?
1667        {
1668            DaemonReply::MovePreparation(preparation) => Ok(*preparation),
1669            _ => bail!("daemon returned an unexpected move preparation reply"),
1670        }
1671    }
1672
1673    pub async fn move_session(&mut self, request: MoveSessionRequest) -> Result<MoveOutcome> {
1674        match self.request(DaemonAction::MoveSession(request)).await? {
1675            DaemonReply::MoveOutcome(outcome) => Ok(outcome),
1676            _ => bail!("daemon returned an unexpected move reply"),
1677        }
1678    }
1679}