1use crate::executable::describe_running_daemon_and_client_builds;
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::fs;
15use std::net::SocketAddr;
16use std::path::PathBuf;
17use std::time::{Duration, Instant};
18use tokio::io::{AsyncReadExt, AsyncWriteExt};
19use tokio::net::TcpStream;
20pub fn metadata_path() -> PathBuf {
21 data_dir().join("daemon.json")
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct DaemonMetadata {
27 pub protocol_version: u32,
28 pub pid: u32,
29 pub address: SocketAddr,
30 pub token: String,
31 pub started_at: String,
32 pub build_version: String,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct WorkspaceListing {
38 pub workspace: WorkspaceRecord,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct SessionPreview {
44 pub id: String,
45 pub title: String,
46 pub project: String,
47 pub harness: String,
48 pub state: String,
49 pub active: bool,
50 pub updated_at: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct WikiRow {
61 pub id: String,
62 pub tool: String,
63 pub project: String,
64 pub title: String,
65 pub started: Option<String>,
66 pub msgs: i64,
67 pub preview: Option<String>,
68 pub archived: bool,
70 pub native_id: Option<String>,
73 pub snippet: Option<String>,
75 pub hel_session_id: Option<String>,
77 #[serde(default)]
82 pub target: Option<String>,
83 #[serde(default)]
86 pub profile: Option<String>,
87 #[serde(default)]
91 pub harness: Option<String>,
92}
93
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum WikiIndexState {
98 Ready,
100 #[default]
103 Indexing,
104 VersionMismatch,
108}
109
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct WikiStatus {
114 pub state: WikiIndexState,
115 pub topping_up: bool,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum SessionTextMatchKind {
125 User,
126 Agent,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct SessionTextMatch {
133 pub session_id: String,
134 pub kind: SessionTextMatchKind,
135}
136
137#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct WikiSearchPage {
141 pub rows: Vec<WikiRow>,
142 pub status: WikiStatus,
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct WikiHitBlock {
150 pub role: String,
152 pub text: String,
155 pub hits: Vec<(usize, usize)>,
158 pub omitted_before: usize,
161 pub truncated: bool,
163}
164
165#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct WikiHitTranscript {
169 pub blocks: Vec<WikiHitBlock>,
170 pub omitted_after: usize,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum WikiSessionStatus {
178 Mine,
180 Archived,
183 Native,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct WikiSessionInfo {
194 pub wiki_id: String,
196 pub tool: String,
198 pub path: PathBuf,
200 pub status: WikiSessionStatus,
201 pub mjolnir_session_id: Option<String>,
203 pub profile_id: Option<String>,
205 pub target_template_id: Option<String>,
207 pub harness: Option<mj_core::config::HarnessKind>,
210 pub title: String,
211 pub project: String,
212 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
217 pub nothing_to_restore: bool,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct WikiRestoreRequest {
224 pub wiki_id: String,
226 pub workspace_id: String,
227 pub profile_id: String,
228 pub target_template_id: String,
229 #[serde(default)]
232 pub project_directory: Option<PathBuf>,
233 #[serde(default)]
234 pub additional_mounts: Vec<AdditionalMount>,
235 #[serde(default)]
236 pub resource_allocation: Option<SessionResourceAllocation>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct WorkspaceSnapshot {
242 pub workspace: WorkspaceRecord,
243 pub sessions: Vec<SessionPreview>,
244 pub drafts: Vec<DraftPreview>,
245}
246
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct RuntimeSessionView {
250 pub session_id: String,
251 pub projection_ordinal: u64,
252 pub projection_digest: String,
253 pub operational: Option<RelayOperationalState>,
254 pub latest_credential_sync_signal: Option<CredentialSyncSignal>,
255 pub connected: bool,
256 pub error: Option<ViewError>,
257}
258
259impl RuntimeSessionView {
260 pub fn from_managed(session_id: String, view: ManagedSessionView) -> Self {
261 let (projection_ordinal, projection_digest, operational, signal) =
262 view.snapshot
263 .map_or((0, String::new(), None, None), |snapshot| {
264 (
265 snapshot.materialized.applied_event_ordinal,
266 snapshot.materialized.applied_event_digest,
267 Some(snapshot.operational),
268 snapshot.latest_credential_sync_signal,
269 )
270 });
271 Self {
272 session_id,
273 projection_ordinal,
274 projection_digest,
275 operational,
276 latest_credential_sync_signal: signal,
277 connected: view.connected,
278 error: view.error,
279 }
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct RuntimeNotice {
291 pub id: u64,
292 pub session_id: String,
293 pub text: String,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "snake_case")]
298pub enum RuntimeLifecycleKind {
299 Create,
300 Suspend,
301 Resume,
302 Move,
303 ForceStop,
304 DestroyStopped,
305 ForceDestroy,
306 StopSubagent,
310 Cleanup,
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
314#[serde(deny_unknown_fields)]
315pub struct RuntimeLifecycleView {
316 pub operation_id: String,
317 pub cancellable: bool,
318 pub session_id: String,
319 pub kind: RuntimeLifecycleKind,
320 pub started_at_epoch_seconds: u64,
321 pub active_stages: Vec<(ProvisionStage, u64)>,
322 pub resume_destination: Option<(String, String)>,
323 pub notice: Option<String>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct ResumeSessionRequest {
329 pub session_id: String,
330 pub workspace_id: String,
331 pub profile_id: String,
332 pub target_template_id: String,
333 pub additional_mounts: Option<Vec<AdditionalMount>>,
334 pub resource_allocation: Option<SessionResourceAllocation>,
335 pub discard_queue: bool,
336 pub repository_preflight: Option<ResumeRepositorySourceReceipt>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(deny_unknown_fields)]
341pub struct CreateSessionRequest {
342 #[serde(default)]
343 pub create_managed_worktree: Option<bool>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub at: Option<String>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub branch: Option<String>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub base: Option<String>,
356 #[serde(
358 default,
359 alias = "mjolnir_subagents",
360 deserialize_with = "mj_core::subagent::deserialize_optional_policy"
361 )]
362 pub subagents: Option<mj_core::subagent::SubagentPolicy>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub review: Option<mj_core::config::SessionReview>,
366 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
369 pub no_project_memory: bool,
370 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
373 pub no_mailbox: bool,
374 #[serde(default)]
375 pub initial_prompt: Option<String>,
376 pub workspace_id: String,
377 pub profile_id: String,
378 pub bundle_id: String,
379 pub project_directory: Option<PathBuf>,
380 pub target_template_id: String,
381 pub additional_mounts: Vec<AdditionalMount>,
382 pub resource_allocation: Option<SessionResourceAllocation>,
383 pub title: String,
384 pub session_title_override: Option<String>,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389pub struct RegisteredSession {
390 pub session: SessionRecord,
391 pub remembered_container_size: Option<(String, HostContainerSize)>,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395#[serde(deny_unknown_fields)]
396pub struct DraftPreview {
397 pub id: String,
398 pub session_id: Option<String>,
399 pub source: String,
400 pub owner_pid: Option<u32>,
401 pub saved_at: String,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(rename_all = "snake_case", tag = "action", content = "arguments")]
414pub enum DaemonAction {
415 NativeAgentHistory {
416 owner: String,
417 child: String,
418 before: Option<(u64, String)>,
419 },
420 Ping,
421 PrepareUpgrade,
424 UpgradeBlockers,
433 Status,
434 WebViewerAccess,
435 RecoverWebViewer(crate::web::WebViewerRecovery),
436 InspectWebListener,
437 ListWorkspaces,
438 CreateWorkspace {
439 name: String,
440 },
441 RenameWorkspace {
442 workspace_id: String,
443 name: String,
444 },
445 TouchWorkspace {
446 workspace_id: String,
447 },
448 CloseWorkspace {
449 workspace_id: String,
450 },
451 CancelWorkspaceClose {
452 workspace_id: String,
453 },
454 DeleteWorkspace {
455 workspace_id: String,
456 },
457 Attach {
458 client_id: String,
459 pid: u32,
460 },
461 Detach {
462 client_id: String,
463 },
464 PersistReadReceipt {
465 client_id: String,
466 workspace_id: String,
467 session_id: String,
468 through: u64,
469 },
470 PersistDetachedSessionState {
471 client_id: String,
472 workspace_id: String,
473 session_id: String,
474 through: u64,
475 owner_pid: u32,
476 draft: mj_core::storage::DetachedSessionDraft,
477 },
478 SaveActiveReview {
479 session_id: String,
480 review: mj_core::storage::StoredReview,
481 },
482 ClearActiveReview {
483 session_id: String,
484 },
485 RecordAbandonedPrompt {
486 session_id: String,
487 bundle_id: String,
488 text: String,
489 },
490 SaveWorkspacePaneSizes {
491 workspace_id: String,
492 sizes: mj_core::workspace::PaneSizes,
493 },
494 SaveWorkspaceLayout {
495 workspace_id: String,
496 layout: mj_core::workspace::ConversationLayout,
497 },
498 PersistImportedSession {
499 session: Box<SessionRecord>,
500 },
501 SetSessionTitle {
502 session_id: String,
503 title: String,
504 },
505 SetSessionWorkspace {
506 session_id: String,
507 workspace_id: String,
508 },
509 SetSessionContainerSettings {
510 session_id: String,
511 cpus: Option<String>,
512 memory: Option<String>,
513 mounts: Vec<AdditionalMount>,
514 mount_history: Vec<PathBuf>,
515 },
516 SetSessionAcpTitle {
517 session_id: String,
518 title: Option<String>,
519 },
520 MarkSessionTargetMissing {
521 session_id: String,
522 detail: String,
523 updated_at: String,
524 },
525 CheckpointSession {
526 session_id: String,
527 },
528 ProjectCatalog {
531 refresh: bool,
532 retry: bool,
533 },
534 CreateProject {
535 sources: Vec<String>,
536 },
537 WikiSearch {
538 query: String,
539 limit: usize,
540 },
541 SessionTextSearch {
544 query: String,
545 },
546 WikiBrief {
548 wiki_id: String,
549 max_chars: usize,
550 },
551 WikiHits {
553 wiki_id: String,
554 query: String,
555 context_messages: usize,
556 per_message_chars: usize,
557 },
558 WikiSession {
560 wiki_id: String,
561 },
562 WikiRestore(WikiRestoreRequest),
564 ScanRecovery {
565 all_instances: bool,
566 },
567 AdoptRecovery {
568 session_id: String,
569 target_id: String,
570 profile: Option<String>,
571 bundle: Option<String>,
572 all_instances: bool,
573 },
574 DestroyRecovery {
575 session_id: String,
576 target_id: String,
577 confirmation: String,
578 all_instances: bool,
579 },
580 Snapshot {
581 workspace_id: String,
582 },
583 RuntimeChanges {
584 cursor: Option<crate::runtime_feed::RuntimeCursor>,
585 wait: bool,
586 },
587 SessionTail {
590 session_id: String,
591 cursor: crate::runtime_feed::RuntimeCursor,
592 },
593 ResumeCandidates,
596 SessionRecord {
599 session_id: String,
600 },
601 GoStartupSession {
605 workspace_id: String,
606 last_session_id: Option<String>,
607 },
608 RefreshQuota,
612 SubagentOptions {
614 profile: String,
615 model: Option<String>,
616 #[serde(default, skip_serializing_if = "Option::is_none")]
618 config: Option<Box<Config>>,
619 },
620 WarmProfileCapabilities {
623 config: Box<Config>,
624 },
625 RenameProfile {
626 old_id: String,
627 new_id: String,
628 },
629 RenameTarget {
630 old_id: String,
631 new_id: String,
632 },
633 SubmitSessionCommand {
634 #[serde(default)]
635 inherited_draft: Option<String>,
636 session_id: String,
637 command_id: String,
638 command: RelayCommand,
639 },
640 QueueStartupPrompt {
645 session_id: String,
646 text: String,
647 #[serde(default)]
651 inherited_draft: Option<String>,
652 },
653 WithdrawStartupPrompt {
659 session_id: String,
660 text: String,
661 },
662 SyncSession {
663 session_id: String,
664 },
665 RespondElicitation {
666 session_id: String,
667 elicitation_id: String,
668 response: ElicitationResponse,
669 },
670 StopBackgroundTask {
671 session_id: String,
672 background_task_id: String,
673 },
674 ReviewerAction {
678 session_id: String,
679 #[serde(default, skip_serializing_if = "Option::is_none")]
682 role: Option<String>,
683 action: crate::session::ReviewerAction,
684 },
685 StartTurnReview {
687 session_id: String,
688 },
689 ResolveTurnReview {
691 session_id: String,
692 resolution: Resolution,
693 },
694 SuspendSession {
695 session_id: String,
696 #[serde(default)]
697 acknowledge_unpublished_work: bool,
698 },
699 RestartSession {
700 session_id: String,
701 },
702 StartCreateSession(CreateSessionRequest),
703 WaitCreateSession {
704 session_id: String,
705 },
706 ResumeSession(ResumeSessionRequest),
707 PrepareMoveSession(MoveSelection),
708 MoveSession(MoveSessionRequest),
709 MoveSources {
710 session_id: String,
711 cleanup_operation_id: Option<String>,
712 },
713 DiscardSinceCheckpoint {
714 session_id: String,
715 checkpoint: mj_core::state::CheckpointMetadata,
716 },
717 DestroyStoppedSession {
718 session_id: String,
719 delete_branch: bool,
723 },
724 ForceDestroySession {
725 session_id: String,
726 delete_branch: bool,
728 },
729 CancelLifecycle {
730 session_id: String,
731 },
732 RecoverDraft {
733 draft_id: String,
734 },
735 Stop,
736}
737
738#[derive(Debug, Serialize, Deserialize)]
739#[serde(deny_unknown_fields)]
740pub struct RequestEnvelope {
741 pub protocol_version: u32,
742 pub request_id: u64,
743 pub token: String,
744 pub action: DaemonAction,
745}
746
747#[derive(Debug)]
751pub struct DaemonRefusal(pub String);
752
753impl std::fmt::Display for DaemonRefusal {
754 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
755 f.write_str(&self.0)
756 }
757}
758
759impl std::error::Error for DaemonRefusal {}
760
761impl DaemonRefusal {
762 #[must_use]
765 pub fn delivery_unconfirmed(&self) -> bool {
766 self.0
767 .starts_with(&crate::session::DeliveryUnconfirmed.to_string())
768 }
769}
770
771#[derive(Debug, Serialize, Deserialize)]
772#[serde(deny_unknown_fields)]
773pub struct ResponseEnvelope {
774 pub protocol_version: u32,
775 pub request_id: u64,
776 pub result: std::result::Result<DaemonReply, String>,
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize)]
780#[serde(rename_all = "snake_case", tag = "reply", content = "value")]
781pub enum DaemonReply {
782 NativeAgentHistory(mj_core::native_agent::NativeAgentHistoryPage),
783 Pong,
784 UpgradePending,
785 UpgradeBlockers(Vec<String>),
788 Status(DaemonStatus),
789 WebViewerAccess(crate::web::WebViewerAccess),
790 WebListeners(Vec<crate::web::WebListenerProcess>),
791 SubagentOptions(mj_core::subagent::SubagentOptions),
792 Workspaces(Vec<WorkspaceListing>),
793 Workspace(WorkspaceRecord),
794 Snapshot(WorkspaceSnapshot),
795 RuntimeChanges(Box<crate::runtime_feed::RuntimeFrame>),
796 SessionTail(Box<crate::runtime_feed::SessionTailReply>),
797 ResumeCandidates(Box<ResumeCandidates>),
798 GoStartupSession(Option<Box<SessionRecord>>),
799 SessionRecord(Option<Box<SessionRecord>>),
800 ReplyChunk {
803 bytes: Vec<u8>,
804 finished: bool,
805 },
806 RegisteredSession(Box<RegisteredSession>),
807 MovePreparation(Box<MovePreparation>),
808 MoveOutcome(MoveOutcome),
809 MoveSources(Vec<mj_core::move_workspace::RetainedMoveSource>),
810 Ordinal(u64),
811 Text(String),
812 OptionalSessionState(Option<SessionState>),
813 Checkpoint(mj_core::state::CheckpointMetadata),
814 RecoveryScan(mj_core::state::RecoveryScan),
815 WikiRows(WikiSearchPage),
816 ProjectCatalog(mj_core::project_catalog::ProjectCatalogView),
817 ProjectCreated(mj_core::project_catalog::SavedProject),
818 SessionTextMatches(Vec<SessionTextMatch>),
819 WikiHits(Option<WikiHitTranscript>),
820 WikiSession(Option<Box<WikiSessionInfo>>),
821 Reviewer(Box<crate::session::ReviewerOutcome>),
822 PromptWithdrawn(bool),
824 Done,
825}
826
827impl DaemonReply {
828 pub fn is_chunked(&self) -> bool {
831 matches!(
832 self,
833 Self::RuntimeChanges(_) | Self::SessionTail(_) | Self::ResumeCandidates(_)
834 )
835 }
836}
837
838#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
840#[serde(deny_unknown_fields)]
841pub struct ResumeCandidates {
842 pub candidates: Vec<ResumeCandidate>,
844 pub moves: Vec<MoveOperation>,
846 pub adopted_native_sessions: Vec<(mj_core::config::HarnessKind, String)>,
849 pub local_checkout_roots: Vec<PathBuf>,
852}
853
854#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
859#[serde(deny_unknown_fields)]
860pub struct ResumeCandidate {
861 pub session_id: String,
862 pub state: SessionState,
863 pub last_profile: String,
864 pub title: String,
866 pub origin: String,
868 pub project: String,
869 pub has_checkpoint: bool,
870 pub last_activity_ms: Option<i64>,
873 pub publication: Option<PublicationState>,
874 pub checkpoint_archive: Option<PathBuf>,
877 pub worktree_checkout: bool,
879}
880
881impl ResumeCandidate {
882 pub const TITLE_CHARS: usize = 200;
884
885 pub fn of(record: &SessionRecord, config: &Config) -> Self {
888 let timestamp_ms = |timestamp: &str| {
889 chrono::DateTime::parse_from_rfc3339(timestamp)
890 .ok()
891 .map(|parsed| parsed.timestamp_millis())
892 };
893 Self {
894 session_id: record.id.clone(),
895 state: record.state,
896 last_profile: record.last_profile.clone(),
897 title: record
898 .listed_title()
899 .chars()
900 .take(Self::TITLE_CHARS)
901 .collect(),
902 origin: record.project_target(config, &record.target_template_id),
903 project: record.project_name(config),
904 has_checkpoint: record.checkpoint.is_some(),
905 last_activity_ms: record
906 .checkpoint
907 .as_ref()
908 .and_then(|checkpoint| timestamp_ms(&checkpoint.created_at))
909 .or_else(|| timestamp_ms(&record.updated_at)),
910 publication: record.publication_state(),
911 checkpoint_archive: record
912 .checkpoint
913 .as_ref()
914 .filter(|_| record.state == SessionState::Stopped)
915 .map(|checkpoint| checkpoint.archive_path.clone()),
916 worktree_checkout: record
917 .checkout()
918 .managed_worktree()
919 .is_some_and(|owned| owned.kind == ManagedCheckoutKind::Worktree),
920 }
921 }
922}
923
924#[derive(Debug, Clone, Serialize, Deserialize)]
925#[serde(deny_unknown_fields)]
926pub struct DaemonStatus {
927 pub pid: u32,
928 pub started_at: String,
929 pub build_version: String,
930 pub attached_clients: usize,
931 pub phone_status: WebViewerStatus,
932}
933
934#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
935#[serde(rename_all = "snake_case", tag = "state")]
936pub enum WebViewerStatus {
937 Disabled,
938 Starting,
939 Ready {
940 viewer_url: String,
941 viewer_code: String,
942 qr_login_url: Option<String>,
943 fallback_reason: Option<String>,
944 },
945 Stopped,
946 Error {
947 message: String,
948 },
949}
950
951impl std::fmt::Debug for WebViewerStatus {
952 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
953 match self {
954 Self::Ready {
955 viewer_url,
956 viewer_code,
957 fallback_reason,
958 ..
959 } => formatter
960 .debug_struct("Ready")
961 .field("viewer_url", viewer_url)
962 .field("viewer_code", viewer_code)
963 .field("qr_login_url", &"[redacted]")
964 .field("fallback_reason", fallback_reason)
965 .finish(),
966 Self::Disabled => formatter.write_str("Disabled"),
967 Self::Starting => formatter.write_str("Starting"),
968 Self::Stopped => formatter.write_str("Stopped"),
969 Self::Error { message } => formatter.debug_tuple("Error").field(message).finish(),
970 }
971 }
972}
973
974impl std::fmt::Display for WebViewerStatus {
975 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
976 match self {
977 Self::Disabled => formatter.write_str("disabled"),
978 Self::Starting => formatter.write_str("starting"),
979 Self::Stopped => formatter.write_str("stopped unexpectedly"),
980 Self::Error { message } => write!(formatter, "error: {message}"),
981 Self::Ready {
982 viewer_url,
983 viewer_code,
984 fallback_reason,
985 ..
986 } => {
987 write!(formatter, "{viewer_url}; viewer code {viewer_code}")?;
988 if let Some(reason) = fallback_reason {
989 write!(
990 formatter,
991 "; local only because Tailscale HTTPS is unavailable: {reason}"
992 )?;
993 }
994 Ok(())
995 }
996 }
997 }
998}
999
1000#[cfg(target_os = "linux")]
1019pub fn process_is_zombie(pid: u32) -> bool {
1020 let Ok(stat) = std::fs::read(format!("/proc/{pid}/stat")) else {
1021 return false;
1022 };
1023 stat.iter()
1026 .rposition(|byte| *byte == b')')
1027 .and_then(|end| stat.get(end + 2))
1028 .is_some_and(|state| *state == b'Z')
1029}
1030
1031#[cfg(target_os = "macos")]
1037pub fn process_is_zombie(pid: u32) -> bool {
1038 let Ok(raw_pid) = libc::c_int::try_from(pid) else {
1039 return false;
1040 };
1041 let size = std::mem::size_of::<libc::proc_bsdinfo>();
1042 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
1044 let written = unsafe {
1046 libc::proc_pidinfo(
1047 raw_pid,
1048 libc::PROC_PIDTBSDINFO,
1049 1,
1050 (&mut info as *mut libc::proc_bsdinfo).cast(),
1051 size as libc::c_int,
1052 )
1053 };
1054 usize::try_from(written).is_ok_and(|written| written == size) && info.pbi_status == libc::SZOMB
1055}
1056
1057#[cfg(all(unix, not(target_os = "linux"), not(target_os = "macos")))]
1058pub fn process_is_zombie(pid: u32) -> bool {
1059 let pid = sysinfo::Pid::from_u32(pid);
1060 let mut system = sysinfo::System::new();
1061 system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
1062 system
1063 .process(pid)
1064 .is_some_and(|process| process.status() == sysinfo::ProcessStatus::Zombie)
1065}
1066
1067pub async fn wait_for_exit(pid: u32) -> Result<()> {
1073 wait_for_exit_within(pid, STOP_TIMEOUT).await
1074}
1075
1076async fn wait_for_exit_within(pid: u32, timeout: Duration) -> Result<()> {
1077 let deadline = Instant::now() + timeout;
1078 while daemon_process_is_alive(pid) {
1079 ensure!(Instant::now() < deadline, "process {pid} is still running");
1080 tokio::time::sleep(RETRY_DELAY).await;
1081 }
1082 Ok(())
1083}
1084
1085pub fn daemon_process_is_alive(pid: u32) -> bool {
1091 #[cfg(unix)]
1092 {
1093 if pid == 0 {
1094 return false;
1095 }
1096 let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
1097 return false;
1098 };
1099 let mut status = 0;
1100 let waited = unsafe { libc::waitpid(raw_pid, &mut status, libc::WNOHANG) };
1103 if waited == raw_pid {
1104 return false;
1105 }
1106 if waited == 0 {
1107 return true;
1108 }
1109 let wait_error = std::io::Error::last_os_error();
1110 if wait_error.raw_os_error() != Some(libc::ECHILD) {
1111 return true;
1112 }
1113
1114 #[cfg(target_os = "macos")]
1115 return owned_daemon_group_is_alive(raw_pid);
1116
1117 #[cfg(not(target_os = "macos"))]
1118 process_is_alive(pid)
1119 }
1120 #[cfg(not(unix))]
1121 process_is_alive(pid)
1122}
1123
1124#[cfg(target_os = "macos")]
1125pub fn owned_daemon_group_is_alive(pid: libc::pid_t) -> bool {
1126 if unsafe { libc::kill(-pid, 0) } == 0 {
1134 return true;
1135 }
1136 let error = std::io::Error::last_os_error();
1137 !matches!(error.raw_os_error(), Some(libc::ESRCH) | Some(libc::EPERM))
1138}
1139
1140pub fn process_is_alive(pid: u32) -> bool {
1141 #[cfg(unix)]
1142 {
1143 if pid == 0 {
1144 return false;
1145 }
1146 let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
1147 return false;
1148 };
1149 let result = unsafe { libc::kill(raw_pid, 0) };
1152 let exists =
1153 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
1154 exists && !process_is_zombie(pid)
1155 }
1156 #[cfg(not(unix))]
1157 {
1158 let process_id = sysinfo::Pid::from_u32(pid);
1159 let mut system = sysinfo::System::new();
1160 system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[process_id]), true);
1161 system.process(process_id).is_some()
1162 }
1163}
1164
1165pub fn read_metadata() -> Result<DaemonMetadata> {
1166 let metadata = read_metadata_any()?;
1167 ensure!(
1168 metadata.protocol_version == PROTOCOL_VERSION,
1169 "daemon protocol {} is incompatible with client protocol {}",
1170 metadata.protocol_version,
1171 PROTOCOL_VERSION
1172 );
1173 Ok(metadata)
1174}
1175
1176#[derive(Debug, Clone, PartialEq, Eq)]
1182pub struct DaemonNotRunning {
1183 pub metadata_path: std::path::PathBuf,
1185}
1186
1187pub const DAEMON_NOT_RUNNING_MESSAGE: &str = "the Mjolnir daemon is not running";
1189
1190impl std::fmt::Display for DaemonNotRunning {
1191 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1192 formatter.write_str(DAEMON_NOT_RUNNING_MESSAGE)
1193 }
1194}
1195
1196impl std::error::Error for DaemonNotRunning {}
1197
1198pub fn daemon_not_running(error: &anyhow::Error) -> Option<&DaemonNotRunning> {
1200 error
1201 .chain()
1202 .find_map(|cause| cause.downcast_ref::<DaemonNotRunning>())
1203}
1204
1205pub fn read_metadata_any() -> Result<DaemonMetadata> {
1206 read_metadata_at(&metadata_path())
1207}
1208
1209fn read_metadata_at(path: &std::path::Path) -> Result<DaemonMetadata> {
1210 let body = match fs::read(path) {
1211 Ok(body) => body,
1212 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1213 return Err(DaemonNotRunning {
1214 metadata_path: path.to_owned(),
1215 }
1216 .into());
1217 }
1218 Err(error) => return Err(error).with_context(|| format!("read {}", path.display())),
1219 };
1220 let metadata: DaemonMetadata =
1221 serde_json::from_slice(&body).with_context(|| format!("parse {}", path.display()))?;
1222 Ok(metadata)
1223}
1224
1225pub async fn write_frame<T: Serialize>(stream: &mut TcpStream, value: &T) -> Result<()> {
1226 let body = serde_json::to_vec(value)?;
1227 write_encoded_frame(stream, &body).await
1228}
1229
1230pub async fn write_encoded_frame(stream: &mut TcpStream, body: &[u8]) -> Result<()> {
1231 ensure!(
1232 body.len() <= MAX_FRAME_BYTES,
1233 "daemon frame is too large: {} bytes exceeds {MAX_FRAME_BYTES}",
1234 body.len()
1235 );
1236 stream.write_u32(body.len() as u32).await?;
1237 stream.write_all(body).await?;
1238 stream.flush().await?;
1239 Ok(())
1240}
1241
1242pub async fn read_frame<T: for<'de> Deserialize<'de>>(stream: &mut TcpStream) -> Result<T> {
1243 let length = stream.read_u32().await? as usize;
1244 ensure!(
1245 length <= MAX_FRAME_BYTES,
1246 "daemon frame exceeds {MAX_FRAME_BYTES} bytes"
1247 );
1248 let mut body = vec![0_u8; length];
1249 stream.read_exact(&mut body).await?;
1250 serde_json::from_slice(&body).context("decode daemon frame")
1251}
1252
1253pub async fn read_response(stream: &mut TcpStream) -> Result<ResponseEnvelope> {
1256 let mut response: ResponseEnvelope = read_frame(stream).await?;
1257 if !matches!(response.result, Ok(DaemonReply::ReplyChunk { .. })) {
1258 return Ok(response);
1259 }
1260 let protocol_version = response.protocol_version;
1261 let request_id = response.request_id;
1262 let mut body = Vec::new();
1263 loop {
1264 ensure!(
1265 response.protocol_version == protocol_version && response.request_id == request_id,
1266 "daemon crossed reply fragment identities"
1267 );
1268 let Ok(DaemonReply::ReplyChunk { bytes, finished }) = response.result else {
1269 bail!("daemon interrupted a chunked reply");
1270 };
1271 ensure!(!bytes.is_empty(), "empty reply fragment");
1272 body.extend(bytes);
1273 if finished {
1274 break;
1275 }
1276 response = read_frame(stream).await?;
1277 }
1278 let reply: DaemonReply = tokio::task::spawn_blocking(move || serde_json::from_slice(&body))
1279 .await
1280 .context("reply decoder task failed")??;
1281 ensure!(
1282 reply.is_chunked(),
1283 "daemon chunked a reply that is never chunked"
1284 );
1285 Ok(ResponseEnvelope {
1286 protocol_version,
1287 request_id,
1288 result: Ok(reply),
1289 })
1290}
1291
1292pub struct DaemonClient {
1293 metadata: DaemonMetadata,
1294 stream: TcpStream,
1295 next_request_id: u64,
1296}
1297
1298impl DaemonClient {
1299 pub async fn connect(metadata: DaemonMetadata) -> Result<Self> {
1300 let stream =
1301 tokio::time::timeout(Duration::from_secs(1), TcpStream::connect(metadata.address))
1302 .await
1303 .context("time out connecting to Mjolnir daemon")??;
1304 Ok(Self {
1305 metadata,
1306 stream,
1307 next_request_id: 1,
1308 })
1309 }
1310
1311 pub fn daemon_pid(&self) -> u32 {
1313 self.metadata.pid
1314 }
1315
1316 pub async fn request(&mut self, action: DaemonAction) -> Result<DaemonReply> {
1320 self.request_with_reconnect(action, || async {
1321 loop {
1322 if let Ok(client) = connect_existing().await {
1323 return Ok(client);
1324 }
1325 tokio::time::sleep(Duration::from_millis(250)).await;
1326 }
1327 })
1328 .await
1329 }
1330
1331 async fn request_with_reconnect<F, Fut>(
1332 &mut self,
1333 action: DaemonAction,
1334 mut reconnect: F,
1335 ) -> Result<DaemonReply>
1336 where
1337 F: FnMut() -> Fut,
1338 Fut: Future<Output = Result<Self>>,
1339 {
1340 loop {
1341 let response = self.request_once(action.clone()).await?;
1342 if matches!(response, DaemonReply::UpgradePending)
1343 && !matches!(action, DaemonAction::PrepareUpgrade)
1344 {
1345 tokio::time::sleep(Duration::from_millis(100)).await;
1348 *self = reconnect().await?;
1349 } else {
1350 return Ok(response);
1351 }
1352 }
1353 }
1354
1355 async fn request_once(&mut self, action: DaemonAction) -> Result<DaemonReply> {
1356 let protocol_version = self.metadata.protocol_version;
1357 let request_id = self.next_request_id;
1358 self.next_request_id += 1;
1359 write_frame(
1360 &mut self.stream,
1361 &RequestEnvelope {
1362 protocol_version,
1363 request_id,
1364 token: self.metadata.token.clone(),
1365 action,
1366 },
1367 )
1368 .await?;
1369 let response = read_response(&mut self.stream).await?;
1370 ensure!(
1371 response.protocol_version == protocol_version,
1372 "daemon changed protocol"
1373 );
1374 ensure!(
1375 response.request_id == request_id,
1376 "daemon crossed request IDs"
1377 );
1378 response
1379 .result
1380 .map_err(|message| anyhow::Error::new(DaemonRefusal(message)))
1381 }
1382
1383 pub async fn status(&mut self) -> Result<DaemonStatus> {
1384 match self.request(DaemonAction::Status).await? {
1385 DaemonReply::Status(status) => Ok(status),
1386 reply => bail!("unexpected daemon status reply {reply:?}"),
1387 }
1388 }
1389
1390 pub async fn web_access(&mut self) -> Result<crate::web::WebViewerAccess> {
1391 match self.request(DaemonAction::WebViewerAccess).await? {
1392 DaemonReply::WebViewerAccess(access) => Ok(access),
1393 reply => bail!("unexpected web viewer reply {reply:?}"),
1394 }
1395 }
1396
1397 pub async fn subagent_options(
1398 &mut self,
1399 profile: String,
1400 model: Option<String>,
1401 config: Option<Config>,
1402 ) -> Result<mj_core::subagent::SubagentOptions> {
1403 match self
1404 .request(DaemonAction::SubagentOptions {
1405 profile,
1406 model,
1407 config: config.map(Box::new),
1408 })
1409 .await?
1410 {
1411 DaemonReply::SubagentOptions(options) => Ok(options),
1412 reply => bail!("unexpected subagent options reply {reply:?}"),
1413 }
1414 }
1415
1416 pub async fn warm_profile_capabilities(&mut self, config: Config) -> Result<()> {
1417 match self
1418 .request(DaemonAction::WarmProfileCapabilities {
1419 config: Box::new(config),
1420 })
1421 .await?
1422 {
1423 DaemonReply::Done => Ok(()),
1424 reply => bail!("unexpected capability hydration reply {reply:?}"),
1425 }
1426 }
1427
1428 pub async fn recover_web_viewer(
1429 &mut self,
1430 action: crate::web::WebViewerRecovery,
1431 ) -> Result<()> {
1432 match self.request(DaemonAction::RecoverWebViewer(action)).await? {
1433 DaemonReply::Done => Ok(()),
1434 reply => bail!("unexpected web viewer recovery reply {reply:?}"),
1435 }
1436 }
1437
1438 pub async fn inspect_web_listener(&mut self) -> Result<Vec<crate::web::WebListenerProcess>> {
1439 match self.request(DaemonAction::InspectWebListener).await? {
1440 DaemonReply::WebListeners(processes) => Ok(processes),
1441 reply => bail!("unexpected listener inspection reply {reply:?}"),
1442 }
1443 }
1444
1445 pub async fn list_workspaces(&mut self) -> Result<Vec<WorkspaceListing>> {
1446 match self.request(DaemonAction::ListWorkspaces).await? {
1447 DaemonReply::Workspaces(workspaces) => Ok(workspaces),
1448 reply => bail!("unexpected daemon workspace reply {reply:?}"),
1449 }
1450 }
1451
1452 pub async fn refresh_quota(&mut self) -> Result<()> {
1455 match self.request(DaemonAction::RefreshQuota).await? {
1456 DaemonReply::Done => Ok(()),
1457 reply => bail!("unexpected refresh-quota reply {reply:?}"),
1458 }
1459 }
1460
1461 pub async fn rename_profile(&mut self, old_id: String, new_id: String) -> Result<()> {
1462 match self
1463 .request(DaemonAction::RenameProfile { old_id, new_id })
1464 .await?
1465 {
1466 DaemonReply::Done => Ok(()),
1467 reply => bail!("unexpected rename-profile reply {reply:?}"),
1468 }
1469 }
1470
1471 pub async fn rename_target(&mut self, old_id: String, new_id: String) -> Result<()> {
1472 match self
1473 .request(DaemonAction::RenameTarget { old_id, new_id })
1474 .await?
1475 {
1476 DaemonReply::Done => Ok(()),
1477 reply => bail!("unexpected rename-target reply {reply:?}"),
1478 }
1479 }
1480
1481 pub async fn create_workspace(&mut self, name: String) -> Result<WorkspaceRecord> {
1482 match self.request(DaemonAction::CreateWorkspace { name }).await? {
1483 DaemonReply::Workspace(workspace) => Ok(workspace),
1484 reply => bail!("unexpected create-workspace reply {reply:?}"),
1485 }
1486 }
1487
1488 pub async fn rename_workspace(&mut self, workspace_id: String, name: String) -> Result<()> {
1489 match self
1490 .request(DaemonAction::RenameWorkspace { workspace_id, name })
1491 .await?
1492 {
1493 DaemonReply::Done => Ok(()),
1494 reply => bail!("unexpected rename-workspace reply {reply:?}"),
1495 }
1496 }
1497
1498 pub async fn touch_workspace(&mut self, workspace_id: String) -> Result<()> {
1499 match self
1500 .request(DaemonAction::TouchWorkspace { workspace_id })
1501 .await?
1502 {
1503 DaemonReply::Done => Ok(()),
1504 reply => bail!("unexpected touch-workspace reply {reply:?}"),
1505 }
1506 }
1507
1508 pub async fn cancel_workspace_close(&mut self, workspace_id: String) -> Result<()> {
1509 match self
1510 .request(DaemonAction::CancelWorkspaceClose { workspace_id })
1511 .await?
1512 {
1513 DaemonReply::Done => Ok(()),
1514 reply => bail!("unexpected cancel workspace close reply: {reply:?}"),
1515 }
1516 }
1517
1518 pub async fn close_workspace(&mut self, workspace_id: String) -> Result<()> {
1519 match self
1520 .request(DaemonAction::CloseWorkspace { workspace_id })
1521 .await?
1522 {
1523 DaemonReply::Done => Ok(()),
1524 reply => bail!("unexpected close workspace reply: {reply:?}"),
1525 }
1526 }
1527
1528 pub async fn delete_workspace(&mut self, workspace_id: String) -> Result<()> {
1529 match self
1530 .request(DaemonAction::DeleteWorkspace { workspace_id })
1531 .await?
1532 {
1533 DaemonReply::Done => Ok(()),
1534 reply => bail!("unexpected delete-workspace reply {reply:?}"),
1535 }
1536 }
1537
1538 pub async fn attach(&mut self, client_id: String, pid: u32) -> Result<()> {
1539 match self
1540 .request(DaemonAction::Attach { client_id, pid })
1541 .await?
1542 {
1543 DaemonReply::Done => Ok(()),
1544 reply => bail!("unexpected attach reply {reply:?}"),
1545 }
1546 }
1547
1548 pub async fn detach(&mut self, client_id: String) -> Result<()> {
1549 match self.request(DaemonAction::Detach { client_id }).await? {
1550 DaemonReply::Done => Ok(()),
1551 reply => bail!("unexpected detach reply {reply:?}"),
1552 }
1553 }
1554
1555 pub async fn persist_read_receipt(
1556 &mut self,
1557 client_id: String,
1558 workspace_id: String,
1559 session_id: String,
1560 through: u64,
1561 ) -> Result<u64> {
1562 match self
1563 .request(DaemonAction::PersistReadReceipt {
1564 client_id,
1565 workspace_id,
1566 session_id,
1567 through,
1568 })
1569 .await?
1570 {
1571 DaemonReply::Ordinal(ordinal) => Ok(ordinal),
1572 reply => bail!("unexpected read-receipt reply {reply:?}"),
1573 }
1574 }
1575
1576 pub async fn persist_detached_session_state(
1577 &mut self,
1578 client_id: String,
1579 workspace_id: String,
1580 session_id: String,
1581 through: u64,
1582 owner_pid: u32,
1583 draft: mj_core::storage::DetachedSessionDraft,
1584 ) -> Result<()> {
1585 match self
1586 .request(DaemonAction::PersistDetachedSessionState {
1587 client_id,
1588 workspace_id,
1589 session_id,
1590 through,
1591 owner_pid,
1592 draft,
1593 })
1594 .await?
1595 {
1596 DaemonReply::Done => Ok(()),
1597 reply => bail!("unexpected detached-session-state reply {reply:?}"),
1598 }
1599 }
1600
1601 pub async fn save_active_review(
1602 &mut self,
1603 session_id: String,
1604 review: mj_core::storage::StoredReview,
1605 ) -> Result<()> {
1606 match self
1607 .request(DaemonAction::SaveActiveReview { session_id, review })
1608 .await?
1609 {
1610 DaemonReply::Done => Ok(()),
1611 reply => bail!("unexpected save-review reply {reply:?}"),
1612 }
1613 }
1614
1615 pub async fn clear_active_review(&mut self, session_id: String) -> Result<()> {
1616 match self
1617 .request(DaemonAction::ClearActiveReview { session_id })
1618 .await?
1619 {
1620 DaemonReply::Done => Ok(()),
1621 reply => bail!("unexpected clear-review reply {reply:?}"),
1622 }
1623 }
1624
1625 pub async fn record_abandoned_prompt(
1627 &mut self,
1628 session_id: String,
1629 bundle_id: String,
1630 text: String,
1631 ) -> Result<()> {
1632 match self
1633 .request(DaemonAction::RecordAbandonedPrompt {
1634 session_id,
1635 bundle_id,
1636 text,
1637 })
1638 .await?
1639 {
1640 DaemonReply::Done => Ok(()),
1641 reply => bail!("unexpected abandoned-prompt reply {reply:?}"),
1642 }
1643 }
1644
1645 pub async fn save_workspace_pane_sizes(
1646 &mut self,
1647 workspace_id: String,
1648 sizes: mj_core::workspace::PaneSizes,
1649 ) -> Result<()> {
1650 match self
1651 .request(DaemonAction::SaveWorkspacePaneSizes {
1652 workspace_id,
1653 sizes,
1654 })
1655 .await?
1656 {
1657 DaemonReply::Done => Ok(()),
1658 reply => bail!("unexpected pane-size save reply {reply:?}"),
1659 }
1660 }
1661
1662 pub async fn save_workspace_layout(
1663 &mut self,
1664 workspace_id: String,
1665 layout: mj_core::workspace::ConversationLayout,
1666 ) -> Result<()> {
1667 match self
1668 .request(DaemonAction::SaveWorkspaceLayout {
1669 workspace_id,
1670 layout,
1671 })
1672 .await?
1673 {
1674 DaemonReply::Done => Ok(()),
1675 reply => bail!("unexpected layout save reply {reply:?}"),
1676 }
1677 }
1678
1679 pub async fn persist_imported_session(&mut self, session: SessionRecord) -> Result<()> {
1680 match self
1681 .request(DaemonAction::PersistImportedSession {
1682 session: Box::new(session),
1683 })
1684 .await?
1685 {
1686 DaemonReply::Done => Ok(()),
1687 reply => bail!("unexpected imported-session reply {reply:?}"),
1688 }
1689 }
1690
1691 pub async fn set_session_title(&mut self, session_id: String, title: String) -> Result<String> {
1692 match self
1693 .request(DaemonAction::SetSessionTitle { session_id, title })
1694 .await?
1695 {
1696 DaemonReply::Text(title) => Ok(title),
1697 reply => bail!("unexpected session-title reply {reply:?}"),
1698 }
1699 }
1700
1701 pub async fn set_session_workspace(
1702 &mut self,
1703 session_id: String,
1704 workspace_id: String,
1705 ) -> Result<()> {
1706 match self
1707 .request(DaemonAction::SetSessionWorkspace {
1708 session_id,
1709 workspace_id,
1710 })
1711 .await?
1712 {
1713 DaemonReply::Done => Ok(()),
1714 reply => bail!("unexpected session-workspace reply {reply:?}"),
1715 }
1716 }
1717
1718 pub async fn set_session_container_settings(
1719 &mut self,
1720 session_id: String,
1721 cpus: Option<String>,
1722 memory: Option<String>,
1723 mounts: Vec<AdditionalMount>,
1724 mount_history: Vec<PathBuf>,
1725 ) -> Result<()> {
1726 match self
1727 .request(DaemonAction::SetSessionContainerSettings {
1728 session_id,
1729 cpus,
1730 memory,
1731 mounts,
1732 mount_history,
1733 })
1734 .await?
1735 {
1736 DaemonReply::Done => Ok(()),
1737 reply => bail!("unexpected container-settings reply {reply:?}"),
1738 }
1739 }
1740
1741 pub async fn set_session_acp_title(
1742 &mut self,
1743 session_id: String,
1744 title: Option<String>,
1745 ) -> Result<()> {
1746 match self
1747 .request(DaemonAction::SetSessionAcpTitle { session_id, title })
1748 .await?
1749 {
1750 DaemonReply::Done => Ok(()),
1751 reply => bail!("unexpected ACP-title reply {reply:?}"),
1752 }
1753 }
1754
1755 pub async fn mark_session_target_missing(
1756 &mut self,
1757 session_id: String,
1758 detail: String,
1759 updated_at: String,
1760 ) -> Result<Option<SessionState>> {
1761 match self
1762 .request(DaemonAction::MarkSessionTargetMissing {
1763 session_id,
1764 detail,
1765 updated_at,
1766 })
1767 .await?
1768 {
1769 DaemonReply::OptionalSessionState(state) => Ok(state),
1770 reply => bail!("unexpected target-missing reply {reply:?}"),
1771 }
1772 }
1773
1774 pub async fn checkpoint_session(
1775 &mut self,
1776 session_id: String,
1777 ) -> Result<mj_core::state::CheckpointMetadata> {
1778 match self
1779 .request(DaemonAction::CheckpointSession { session_id })
1780 .await?
1781 {
1782 DaemonReply::Checkpoint(checkpoint) => Ok(checkpoint),
1783 reply => bail!("unexpected checkpoint reply {reply:?}"),
1784 }
1785 }
1786
1787 pub async fn project_catalog(
1792 &mut self,
1793 refresh: bool,
1794 retry: bool,
1795 ) -> Result<mj_core::project_catalog::ProjectCatalogView> {
1796 match self
1797 .request(DaemonAction::ProjectCatalog { refresh, retry })
1798 .await?
1799 {
1800 DaemonReply::ProjectCatalog(view) => Ok(view),
1801 other => bail!("unexpected project catalog reply: {other:?}"),
1802 }
1803 }
1804
1805 pub async fn create_project(
1806 &mut self,
1807 sources: Vec<String>,
1808 ) -> Result<mj_core::project_catalog::SavedProject> {
1809 match self
1810 .request(DaemonAction::CreateProject { sources })
1811 .await?
1812 {
1813 DaemonReply::ProjectCreated(project) => Ok(project),
1814 other => bail!("unexpected project creation reply: {other:?}"),
1815 }
1816 }
1817
1818 pub async fn wiki_search(&mut self, query: String, limit: usize) -> Result<WikiSearchPage> {
1819 match self
1820 .request(DaemonAction::WikiSearch { query, limit })
1821 .await?
1822 {
1823 DaemonReply::WikiRows(page) => Ok(page),
1824 reply => bail!("unexpected SessionWiki search reply {reply:?}"),
1825 }
1826 }
1827
1828 pub async fn session_text_search(&mut self, query: String) -> Result<Vec<SessionTextMatch>> {
1832 match self
1833 .request(DaemonAction::SessionTextSearch { query })
1834 .await?
1835 {
1836 DaemonReply::SessionTextMatches(matches) => Ok(matches),
1837 reply => bail!("unexpected session text search reply {reply:?}"),
1838 }
1839 }
1840
1841 pub async fn wiki_brief(&mut self, wiki_id: String, max_chars: usize) -> Result<String> {
1843 match self
1844 .request(DaemonAction::WikiBrief { wiki_id, max_chars })
1845 .await?
1846 {
1847 DaemonReply::Text(markdown) => Ok(markdown),
1848 reply => bail!("unexpected SessionWiki brief reply {reply:?}"),
1849 }
1850 }
1851
1852 pub async fn wiki_hits(
1857 &mut self,
1858 wiki_id: String,
1859 query: String,
1860 context_messages: usize,
1861 per_message_chars: usize,
1862 ) -> Result<Option<WikiHitTranscript>> {
1863 match self
1864 .request(DaemonAction::WikiHits {
1865 wiki_id,
1866 query,
1867 context_messages,
1868 per_message_chars,
1869 })
1870 .await?
1871 {
1872 DaemonReply::WikiHits(transcript) => Ok(transcript),
1873 reply => bail!("unexpected SessionWiki hits reply {reply:?}"),
1874 }
1875 }
1876
1877 pub async fn wiki_session(&mut self, wiki_id: String) -> Result<Option<WikiSessionInfo>> {
1880 match self.request(DaemonAction::WikiSession { wiki_id }).await? {
1881 DaemonReply::WikiSession(info) => Ok(info.map(|info| *info)),
1882 reply => bail!("unexpected SessionWiki session reply {reply:?}"),
1883 }
1884 }
1885
1886 pub async fn wiki_restore(&mut self, request: WikiRestoreRequest) -> Result<RegisteredSession> {
1890 match self.request(DaemonAction::WikiRestore(request)).await? {
1891 DaemonReply::RegisteredSession(registered) => Ok(*registered),
1892 reply => bail!("unexpected SessionWiki restore reply {reply:?}"),
1893 }
1894 }
1895
1896 pub async fn scan_recovery(
1897 &mut self,
1898 all_instances: bool,
1899 ) -> Result<mj_core::state::RecoveryScan> {
1900 match self
1901 .request(DaemonAction::ScanRecovery { all_instances })
1902 .await?
1903 {
1904 DaemonReply::RecoveryScan(scan) => Ok(scan),
1905 reply => bail!("unexpected recovery-scan reply {reply:?}"),
1906 }
1907 }
1908
1909 pub async fn adopt_recovery(
1910 &mut self,
1911 session_id: String,
1912 target_id: String,
1913 profile: Option<String>,
1914 bundle: Option<String>,
1915 all_instances: bool,
1916 ) -> Result<()> {
1917 match self
1918 .request(DaemonAction::AdoptRecovery {
1919 session_id,
1920 target_id,
1921 profile,
1922 bundle,
1923 all_instances,
1924 })
1925 .await?
1926 {
1927 DaemonReply::Done => Ok(()),
1928 reply => bail!("unexpected recovery-adopt reply {reply:?}"),
1929 }
1930 }
1931
1932 pub async fn destroy_recovery(
1933 &mut self,
1934 session_id: String,
1935 target_id: String,
1936 confirmation: String,
1937 all_instances: bool,
1938 ) -> Result<()> {
1939 match self
1940 .request(DaemonAction::DestroyRecovery {
1941 session_id,
1942 target_id,
1943 confirmation,
1944 all_instances,
1945 })
1946 .await?
1947 {
1948 DaemonReply::Done => Ok(()),
1949 reply => bail!("unexpected recovery-destroy reply {reply:?}"),
1950 }
1951 }
1952
1953 pub async fn snapshot(&mut self, workspace_id: String) -> Result<WorkspaceSnapshot> {
1954 match self
1955 .request(DaemonAction::Snapshot { workspace_id })
1956 .await?
1957 {
1958 DaemonReply::Snapshot(snapshot) => Ok(snapshot),
1959 reply => bail!("unexpected snapshot reply {reply:?}"),
1960 }
1961 }
1962
1963 pub async fn runtime_changes(
1964 &mut self,
1965 cursor: Option<crate::runtime_feed::RuntimeCursor>,
1966 wait: bool,
1967 ) -> Result<crate::runtime_feed::RuntimeFrame> {
1968 match self
1969 .request(DaemonAction::RuntimeChanges { cursor, wait })
1970 .await?
1971 {
1972 DaemonReply::RuntimeChanges(frame) => Ok(*frame),
1973 reply => bail!("unexpected runtime changes reply {reply:?}"),
1974 }
1975 }
1976
1977 pub async fn session_tail(
1978 &mut self,
1979 session_id: String,
1980 cursor: crate::runtime_feed::RuntimeCursor,
1981 ) -> Result<crate::runtime_feed::SessionTailReply> {
1982 match self
1983 .request(DaemonAction::SessionTail { session_id, cursor })
1984 .await?
1985 {
1986 DaemonReply::SessionTail(reply) => Ok(*reply),
1987 reply => bail!("unexpected session tail reply {reply:?}"),
1988 }
1989 }
1990
1991 pub async fn resume_candidates(&mut self) -> Result<ResumeCandidates> {
1992 match self.request(DaemonAction::ResumeCandidates).await? {
1993 DaemonReply::ResumeCandidates(candidates) => Ok(*candidates),
1994 reply => bail!("unexpected resume candidates reply {reply:?}"),
1995 }
1996 }
1997
1998 pub async fn session_record(&mut self, session_id: String) -> Result<Option<SessionRecord>> {
1999 match self
2000 .request(DaemonAction::SessionRecord { session_id })
2001 .await?
2002 {
2003 DaemonReply::SessionRecord(record) => Ok(record.map(|record| *record)),
2004 reply => bail!("unexpected session record reply {reply:?}"),
2005 }
2006 }
2007
2008 pub async fn go_startup_session(
2009 &mut self,
2010 workspace_id: String,
2011 last_session_id: Option<String>,
2012 ) -> Result<Option<SessionRecord>> {
2013 match self
2014 .request(DaemonAction::GoStartupSession {
2015 workspace_id,
2016 last_session_id,
2017 })
2018 .await?
2019 {
2020 DaemonReply::GoStartupSession(record) => Ok(record.map(|record| *record)),
2021 reply => bail!("unexpected go startup session reply {reply:?}"),
2022 }
2023 }
2024
2025 pub async fn submit_session_command(
2026 &mut self,
2027 session_id: String,
2028 command_id: String,
2029 command: RelayCommand,
2030 inherited_draft: Option<String>,
2031 ) -> Result<u64> {
2032 match self
2033 .request(DaemonAction::SubmitSessionCommand {
2034 inherited_draft,
2035 session_id,
2036 command_id,
2037 command,
2038 })
2039 .await?
2040 {
2041 DaemonReply::Ordinal(ordinal) => Ok(ordinal),
2042 reply => bail!("unexpected session command reply {reply:?}"),
2043 }
2044 }
2045
2046 pub async fn queue_startup_prompt(
2050 &mut self,
2051 session_id: String,
2052 text: String,
2053 inherited_draft: Option<String>,
2054 ) -> Result<()> {
2055 match self
2056 .request(DaemonAction::QueueStartupPrompt {
2057 session_id,
2058 text,
2059 inherited_draft,
2060 })
2061 .await?
2062 {
2063 DaemonReply::Done => Ok(()),
2064 reply => bail!("unexpected startup prompt reply {reply:?}"),
2065 }
2066 }
2067
2068 pub async fn withdraw_startup_prompt(
2073 &mut self,
2074 session_id: String,
2075 text: String,
2076 ) -> Result<bool> {
2077 match self
2078 .request(DaemonAction::WithdrawStartupPrompt { session_id, text })
2079 .await?
2080 {
2081 DaemonReply::PromptWithdrawn(withdrawn) => Ok(withdrawn),
2082 reply => bail!("unexpected startup prompt withdrawal reply {reply:?}"),
2083 }
2084 }
2085
2086 pub async fn start_turn_review(&mut self, session_id: String) -> Result<()> {
2092 match self
2093 .request(DaemonAction::StartTurnReview { session_id })
2094 .await?
2095 {
2096 DaemonReply::Done => Ok(()),
2097 reply => bail!("unexpected turn-review reply {reply:?}"),
2098 }
2099 }
2100
2101 pub async fn resolve_turn_review(
2102 &mut self,
2103 session_id: String,
2104 resolution: Resolution,
2105 ) -> Result<()> {
2106 match self
2107 .request(DaemonAction::ResolveTurnReview {
2108 session_id,
2109 resolution,
2110 })
2111 .await?
2112 {
2113 DaemonReply::Done => Ok(()),
2114 reply => bail!("unexpected turn-review resolution reply {reply:?}"),
2115 }
2116 }
2117
2118 pub async fn reviewer_action(
2119 &mut self,
2120 session_id: String,
2121 role: Option<String>,
2122 action: crate::session::ReviewerAction,
2123 ) -> Result<crate::session::ReviewerOutcome> {
2124 match self
2125 .request(DaemonAction::ReviewerAction {
2126 session_id,
2127 role,
2128 action,
2129 })
2130 .await?
2131 {
2132 DaemonReply::Reviewer(outcome) => Ok(*outcome),
2133 reply => bail!("unexpected reviewer reply {reply:?}"),
2134 }
2135 }
2136
2137 pub async fn sync_session(&mut self, session_id: String) -> Result<()> {
2138 match self
2139 .request(DaemonAction::SyncSession { session_id })
2140 .await?
2141 {
2142 DaemonReply::Done => Ok(()),
2143 reply => bail!("unexpected session sync reply {reply:?}"),
2144 }
2145 }
2146
2147 pub async fn respond_elicitation(
2148 &mut self,
2149 session_id: String,
2150 elicitation_id: String,
2151 response: ElicitationResponse,
2152 ) -> Result<()> {
2153 match self
2154 .request(DaemonAction::RespondElicitation {
2155 session_id,
2156 elicitation_id,
2157 response,
2158 })
2159 .await?
2160 {
2161 DaemonReply::Done => Ok(()),
2162 reply => bail!("unexpected elicitation reply {reply:?}"),
2163 }
2164 }
2165
2166 pub async fn native_agent_history(
2167 &mut self,
2168 owner: String,
2169 child: String,
2170 before: Option<(u64, String)>,
2171 ) -> Result<mj_core::native_agent::NativeAgentHistoryPage> {
2172 match self
2173 .request(DaemonAction::NativeAgentHistory {
2174 owner,
2175 child,
2176 before,
2177 })
2178 .await?
2179 {
2180 DaemonReply::NativeAgentHistory(page) => Ok(page),
2181 reply => bail!("unexpected native agent history reply {reply:?}"),
2182 }
2183 }
2184
2185 pub async fn stop_background_task(
2186 &mut self,
2187 session_id: String,
2188 background_task_id: String,
2189 ) -> Result<()> {
2190 match self
2191 .request(DaemonAction::StopBackgroundTask {
2192 session_id,
2193 background_task_id,
2194 })
2195 .await?
2196 {
2197 DaemonReply::Done => Ok(()),
2198 reply => bail!("unexpected background task stop reply {reply:?}"),
2199 }
2200 }
2201
2202 pub async fn suspend_session(&mut self, session_id: String) -> Result<()> {
2203 self.suspend_session_with_ack(session_id, false).await
2204 }
2205
2206 pub async fn suspend_session_with_ack(
2207 &mut self,
2208 session_id: String,
2209 acknowledge_unpublished_work: bool,
2210 ) -> Result<()> {
2211 match self
2212 .request(DaemonAction::SuspendSession {
2213 session_id,
2214 acknowledge_unpublished_work,
2215 })
2216 .await?
2217 {
2218 DaemonReply::Done => Ok(()),
2219 reply => bail!("unexpected close-session reply {reply:?}"),
2220 }
2221 }
2222
2223 pub async fn restart_session(&mut self, session_id: String) -> Result<()> {
2224 ensure!(
2225 self.metadata.protocol_version == PROTOCOL_VERSION,
2226 "cannot Restart a session: daemon protocol {} does not match client protocol {}; restart the daemon, then try again",
2227 self.metadata.protocol_version,
2228 PROTOCOL_VERSION
2229 );
2230 match self
2231 .request(DaemonAction::RestartSession { session_id })
2232 .await?
2233 {
2234 DaemonReply::Done => Ok(()),
2235 reply => bail!("unexpected restart-session reply {reply:?}"),
2236 }
2237 }
2238
2239 pub async fn start_create_session(
2240 &mut self,
2241 request: CreateSessionRequest,
2242 ) -> Result<RegisteredSession> {
2243 match self
2244 .request(DaemonAction::StartCreateSession(request))
2245 .await?
2246 {
2247 DaemonReply::RegisteredSession(registered) => Ok(*registered),
2248 reply => bail!("unexpected start-create reply {reply:?}"),
2249 }
2250 }
2251
2252 pub async fn wait_create_session(&mut self, session_id: String) -> Result<()> {
2253 match self
2254 .request(DaemonAction::WaitCreateSession { session_id })
2255 .await?
2256 {
2257 DaemonReply::Done => Ok(()),
2258 reply => bail!("unexpected wait-create reply {reply:?}"),
2259 }
2260 }
2261
2262 pub async fn resume_session(&mut self, request: ResumeSessionRequest) -> Result<()> {
2263 match self.request(DaemonAction::ResumeSession(request)).await? {
2264 DaemonReply::Done => Ok(()),
2265 reply => bail!("unexpected resume-session reply {reply:?}"),
2266 }
2267 }
2268
2269 pub async fn discard_since_checkpoint(
2270 &mut self,
2271 session_id: String,
2272 checkpoint: mj_core::state::CheckpointMetadata,
2273 ) -> Result<()> {
2274 match self
2275 .request(DaemonAction::DiscardSinceCheckpoint {
2276 session_id,
2277 checkpoint,
2278 })
2279 .await?
2280 {
2281 DaemonReply::Done => Ok(()),
2282 reply => bail!("unexpected force-stop reply {reply:?}"),
2283 }
2284 }
2285
2286 pub async fn destroy_stopped_session(
2287 &mut self,
2288 session_id: String,
2289 delete_branch: bool,
2290 ) -> Result<()> {
2291 match self
2292 .request(DaemonAction::DestroyStoppedSession {
2293 session_id,
2294 delete_branch,
2295 })
2296 .await?
2297 {
2298 DaemonReply::Done => Ok(()),
2299 reply => bail!("unexpected destroy-stopped reply {reply:?}"),
2300 }
2301 }
2302
2303 pub async fn force_destroy_session(
2304 &mut self,
2305 session_id: String,
2306 delete_branch: bool,
2307 ) -> Result<()> {
2308 match self
2309 .request(DaemonAction::ForceDestroySession {
2310 session_id,
2311 delete_branch,
2312 })
2313 .await?
2314 {
2315 DaemonReply::Done => Ok(()),
2316 reply => bail!("unexpected force-destroy reply {reply:?}"),
2317 }
2318 }
2319
2320 pub async fn cancel_lifecycle(&mut self, session_id: String) -> Result<()> {
2321 match self
2322 .request(DaemonAction::CancelLifecycle { session_id })
2323 .await?
2324 {
2325 DaemonReply::Done => Ok(()),
2326 reply => bail!("unexpected cancel-lifecycle reply {reply:?}"),
2327 }
2328 }
2329
2330 pub async fn recover_draft(&mut self, draft_id: String) -> Result<()> {
2331 match self
2332 .request(DaemonAction::RecoverDraft { draft_id })
2333 .await?
2334 {
2335 DaemonReply::Done => Ok(()),
2336 reply => bail!("unexpected recover-draft reply {reply:?}"),
2337 }
2338 }
2339
2340 pub async fn stop(&mut self) -> Result<()> {
2341 match self.request(DaemonAction::Stop).await? {
2342 DaemonReply::Done => Ok(()),
2343 reply => bail!("unexpected stop reply {reply:?}"),
2344 }
2345 }
2346}
2347
2348pub async fn connect_existing() -> Result<DaemonClient> {
2349 let metadata = tokio::task::spawn_blocking(read_metadata)
2350 .await
2351 .context("read daemon metadata task failed")??;
2352 DaemonClient::connect(metadata).await
2353}
2354
2355pub struct ManagementClient {
2359 inner: DaemonClient,
2360}
2361
2362impl ManagementClient {
2363 pub fn new(inner: DaemonClient) -> Self {
2364 Self { inner }
2365 }
2366 pub fn protocol_version(&self) -> u32 {
2367 self.inner.metadata.protocol_version
2368 }
2369
2370 pub async fn status(&mut self) -> Result<DaemonStatus> {
2371 self.inner.status().await
2372 }
2373
2374 pub async fn stop(&mut self) -> Result<()> {
2375 tokio::time::timeout(STOP_TIMEOUT, self.inner.stop())
2376 .await
2377 .context("Mjolnir daemon did not acknowledge the stop before the deadline")?
2378 }
2379
2380 pub async fn stop_and_wait(mut self) -> Result<()> {
2382 let pid = self.inner.metadata.pid;
2383 self.stop().await?;
2384 wait_for_exit_within(pid, STOP_DRAIN_TIMEOUT)
2385 .await
2386 .with_context(|| {
2387 format!(
2388 "Mjolnir daemon {pid} accepted the stop but was still running after {}s",
2389 STOP_DRAIN_TIMEOUT.as_secs()
2390 )
2391 })
2392 }
2393}
2394
2395pub async fn connect_management() -> Result<ManagementClient> {
2396 Ok(ManagementClient {
2397 inner: DaemonClient::connect(read_metadata_any()?).await?,
2398 })
2399}
2400
2401pub fn ensure_supported_daemon_protocol(metadata: &DaemonMetadata) -> Result<()> {
2409 ensure!(
2410 metadata.protocol_version <= PROTOCOL_VERSION,
2411 "{}",
2412 unsupported_daemon_protocol_message(
2413 metadata.protocol_version,
2414 &describe_running_daemon_and_client_builds(metadata.pid, &metadata.build_version),
2415 )
2416 );
2417 Ok(())
2418}
2419
2420fn unsupported_daemon_protocol_message(daemon_protocol: u32, builds: &str) -> String {
2423 format!(
2424 "the daemon uses a newer protocol ({daemon_protocol}) than this client ({PROTOCOL_VERSION}). {builds}. \
2425 Put the daemon's directory first on PATH, or reinstall this client from that build."
2426 )
2427}
2428pub const PROTOCOL_VERSION: u32 = 56;
2433pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
2434pub const STOP_TIMEOUT: Duration = Duration::from_secs(30);
2444pub const STOP_DRAIN_TIMEOUT: Duration = Duration::from_secs(90);
2447pub const RETRY_DELAY: Duration = Duration::from_millis(40);
2448impl DaemonClient {
2449 pub async fn prepare_move_session(
2450 &mut self,
2451 selection: MoveSelection,
2452 ) -> Result<MovePreparation> {
2453 match self
2454 .request(DaemonAction::PrepareMoveSession(selection))
2455 .await?
2456 {
2457 DaemonReply::MovePreparation(preparation) => Ok(*preparation),
2458 _ => bail!("daemon returned an unexpected move preparation reply"),
2459 }
2460 }
2461
2462 pub async fn move_sources(
2463 &mut self,
2464 session_id: String,
2465 cleanup_operation_id: Option<String>,
2466 ) -> Result<Vec<mj_core::move_workspace::RetainedMoveSource>> {
2467 match self
2468 .request(DaemonAction::MoveSources {
2469 session_id,
2470 cleanup_operation_id,
2471 })
2472 .await?
2473 {
2474 DaemonReply::MoveSources(sources) => Ok(sources),
2475 reply => bail!("unexpected Move sources reply: {reply:?}"),
2476 }
2477 }
2478
2479 pub async fn move_session(&mut self, request: MoveSessionRequest) -> Result<MoveOutcome> {
2480 match self.request(DaemonAction::MoveSession(request)).await? {
2481 DaemonReply::MoveOutcome(outcome) => Ok(outcome),
2482 _ => bail!("daemon returned an unexpected move reply"),
2483 }
2484 }
2485}
2486
2487#[cfg(test)]
2488mod tests {
2489 use super::*;
2490 use crate::executable::{BuildDescription, describe_daemon_and_client_builds};
2491 use std::path::Path;
2492
2493 #[cfg(unix)]
2495 #[test]
2496 fn an_exited_unreaped_process_is_a_zombie_and_not_alive() {
2497 use std::time::{Duration, Instant};
2501
2502 let mut running = std::process::Command::new("sleep")
2503 .arg("30")
2504 .spawn()
2505 .unwrap();
2506 assert!(process_is_alive(running.id()));
2507 assert!(!process_is_zombie(running.id()));
2508 running.kill().unwrap();
2509 running.wait().unwrap();
2510
2511 let mut exited = std::process::Command::new("sh")
2512 .args(["-c", "exit 0"])
2513 .spawn()
2514 .unwrap();
2515 let pid = exited.id();
2516 let deadline = Instant::now() + Duration::from_secs(10);
2517 while !process_is_zombie(pid) {
2518 assert!(
2519 Instant::now() < deadline,
2520 "an exited, unreaped child was never reported as a zombie"
2521 );
2522 std::thread::sleep(Duration::from_millis(20));
2523 }
2524 assert!(!process_is_alive(pid));
2525 exited.wait().unwrap();
2526 }
2527
2528 #[test]
2530 fn a_missing_endpoint_file_reads_as_a_stopped_daemon() {
2531 let directory = tempfile::tempdir().unwrap();
2532 let path = directory.path().join("daemon.json");
2533 let error = read_metadata_at(&path).unwrap_err();
2534 let stopped = daemon_not_running(&error).expect("stopped, not an I/O failure");
2535 assert_eq!(stopped.metadata_path, path);
2536 assert_eq!(format!("{error:#}"), "the Mjolnir daemon is not running");
2537
2538 std::fs::write(&path, b"not json").unwrap();
2539 let error = read_metadata_at(&path).unwrap_err();
2540 assert!(daemon_not_running(&error).is_none());
2541 }
2542
2543 #[tokio::test]
2544 async fn restart_session_rejects_an_old_daemon_before_sending_the_action() {
2545 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2546 let metadata = DaemonMetadata {
2547 protocol_version: PROTOCOL_VERSION - 1,
2548 pid: std::process::id(),
2549 address: listener.local_addr().unwrap(),
2550 token: "old-restart-daemon".into(),
2551 started_at: "test".into(),
2552 build_version: env!("CARGO_PKG_VERSION").into(),
2553 };
2554 let server = tokio::spawn(async move {
2555 let (mut stream, _) = listener.accept().await.unwrap();
2556 let mut byte = [0_u8; 1];
2557 assert_eq!(stream.read(&mut byte).await.unwrap(), 0);
2558 });
2559 let mut client = DaemonClient::connect(metadata).await.unwrap();
2560
2561 let error = client
2562 .restart_session("session-1".into())
2563 .await
2564 .unwrap_err();
2565 let message = format!("{error:#}");
2566 assert!(message.contains(&format!("daemon protocol {}", PROTOCOL_VERSION - 1)));
2567 assert!(message.contains(&format!("client protocol {PROTOCOL_VERSION}")));
2568 assert!(message.contains("restart the daemon"));
2569 drop(client);
2570 server.await.unwrap();
2571 }
2572
2573 #[tokio::test]
2574 async fn abandoned_prompt_history_uses_the_daemon_protocol_action() {
2575 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2576 let metadata = DaemonMetadata {
2577 protocol_version: PROTOCOL_VERSION,
2578 pid: std::process::id(),
2579 address: listener.local_addr().unwrap(),
2580 token: "abandoned-prompt-test".into(),
2581 started_at: "test".into(),
2582 build_version: env!("CARGO_PKG_VERSION").into(),
2583 };
2584 let server = tokio::spawn(async move {
2585 let (mut stream, _) = listener.accept().await.unwrap();
2586 let request: RequestEnvelope = read_frame(&mut stream).await.unwrap();
2587 assert_eq!(request.protocol_version, PROTOCOL_VERSION);
2588 assert_eq!(request.token, "abandoned-prompt-test");
2589 assert_eq!(
2590 serde_json::to_value(request.action).unwrap(),
2591 serde_json::json!({
2592 "action": "record_abandoned_prompt",
2593 "arguments": {
2594 "session_id": "session-1",
2595 "bundle_id": "project-1",
2596 "text": "unfinished draft"
2597 }
2598 })
2599 );
2600 write_frame(
2601 &mut stream,
2602 &ResponseEnvelope {
2603 protocol_version: request.protocol_version,
2604 request_id: request.request_id,
2605 result: Ok(DaemonReply::Done),
2606 },
2607 )
2608 .await
2609 .unwrap();
2610 });
2611 let mut client = DaemonClient::connect(metadata).await.unwrap();
2612
2613 client
2614 .record_abandoned_prompt(
2615 "session-1".into(),
2616 "project-1".into(),
2617 "unfinished draft".into(),
2618 )
2619 .await
2620 .unwrap();
2621 server.await.unwrap();
2622 }
2623
2624 #[tokio::test]
2626 async fn subagent_discovery_uses_the_daemon_and_preserves_choices_and_failures() {
2627 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2628 let metadata = DaemonMetadata {
2629 protocol_version: PROTOCOL_VERSION,
2630 pid: std::process::id(),
2631 address: listener.local_addr().unwrap(),
2632 token: "subagent-discovery-test".into(),
2633 started_at: "test".into(),
2634 build_version: env!("CARGO_PKG_VERSION").into(),
2635 };
2636 let options: mj_core::subagent::SubagentOptions =
2637 serde_json::from_value(serde_json::json!({
2638 "models": [{"value": "gpt-6-luna", "name": "Luna", "description": null}],
2639 "efforts": [{"value": "high", "name": "High", "description": null}],
2640 "unavailable": ["another-profile: authentication failed"]
2641 }))
2642 .unwrap();
2643 let expected = serde_json::to_value(&options).unwrap();
2644 let mut draft = Config::default();
2645 draft.profiles.insert(
2646 "codex".into(),
2647 serde_json::from_value(serde_json::json!({
2648 "kind": "codex", "home": "/unsaved/profile",
2649 "subagents": {"mode": "single_model", "model": "gpt-6-luna", "effort": "high"}
2650 }))
2651 .unwrap(),
2652 );
2653 let expected_draft = serde_json::to_value(&draft).unwrap();
2654 let server = tokio::spawn(async move {
2655 let (mut stream, _) = listener.accept().await.unwrap();
2656 for (index, model) in [
2657 None,
2658 Some("gpt-6-luna".to_owned()),
2659 Some("gpt-6-luna".to_owned()),
2660 ]
2661 .into_iter()
2662 .enumerate()
2663 {
2664 let request: RequestEnvelope = read_frame(&mut stream).await.unwrap();
2665 assert_eq!(request.token, "subagent-discovery-test");
2666 let DaemonAction::SubagentOptions {
2667 profile,
2668 model: requested,
2669 config,
2670 } = request.action
2671 else {
2672 panic!("unexpected discovery request")
2673 };
2674 assert_eq!(profile, "codex");
2675 assert_eq!(requested, model);
2676 assert_eq!(
2677 serde_json::to_value(config).unwrap(),
2678 if index == 1 {
2679 expected_draft.clone()
2680 } else {
2681 serde_json::Value::Null
2682 }
2683 );
2684 write_frame(
2685 &mut stream,
2686 &ResponseEnvelope {
2687 protocol_version: request.protocol_version,
2688 request_id: request.request_id,
2689 result: if index != 2 {
2690 Ok(DaemonReply::SubagentOptions(options.clone()))
2691 } else {
2692 Err("profile discovery cancelled".into())
2693 },
2694 },
2695 )
2696 .await
2697 .unwrap();
2698 }
2699 });
2700 let mut client = DaemonClient::connect(metadata).await.unwrap();
2701 let discovered = client
2702 .subagent_options("codex".into(), None, None)
2703 .await
2704 .unwrap();
2705 assert_eq!(serde_json::to_value(discovered).unwrap(), expected);
2706 let discovered = client
2707 .subagent_options("codex".into(), Some("gpt-6-luna".into()), Some(draft))
2708 .await
2709 .unwrap();
2710 assert_eq!(serde_json::to_value(discovered).unwrap(), expected);
2711 let error = client
2712 .subagent_options("codex".into(), Some("gpt-6-luna".into()), None)
2713 .await
2714 .unwrap_err();
2715 assert_eq!(error.to_string(), "profile discovery cancelled");
2716 server.await.unwrap();
2717 }
2718 #[tokio::test]
2719 async fn upgrade_refusal_retries_the_identical_command_but_lost_acknowledgements_do_not() {
2720 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2721 let metadata = DaemonMetadata {
2722 protocol_version: PROTOCOL_VERSION,
2723 pid: std::process::id(),
2724 address: listener.local_addr().unwrap(),
2725 token: "test-token".into(),
2726 started_at: "test".into(),
2727 build_version: env!("CARGO_PKG_VERSION").into(),
2728 };
2729 let action = DaemonAction::SubmitSessionCommand {
2730 inherited_draft: None,
2731 session_id: "test-session".into(),
2732 command_id: "steer-command".into(),
2733 command: RelayCommand::Steer {
2734 active_prompt_id: "active-command".into(),
2735 queued_prompt_id: "queued-command".into(),
2736 },
2737 };
2738 let expected = serde_json::to_value(&action).unwrap();
2739 let server = tokio::spawn(async move {
2740 for reply in [
2741 Some(DaemonReply::UpgradePending),
2742 Some(DaemonReply::Done),
2743 None,
2744 ] {
2745 let (mut stream, _) = listener.accept().await.unwrap();
2746 let request: RequestEnvelope = read_frame(&mut stream).await.unwrap();
2747 assert_eq!(serde_json::to_value(&request.action).unwrap(), expected);
2748 if let Some(reply) = reply {
2749 write_frame(
2750 &mut stream,
2751 &ResponseEnvelope {
2752 protocol_version: request.protocol_version,
2753 request_id: request.request_id,
2754 result: Ok(reply),
2755 },
2756 )
2757 .await
2758 .unwrap();
2759 }
2760 }
2761 });
2762 let mut client = DaemonClient::connect(metadata.clone()).await.unwrap();
2763 let reply = client
2764 .request_with_reconnect(action.clone(), || DaemonClient::connect(metadata.clone()))
2765 .await
2766 .unwrap();
2767 assert!(matches!(reply, DaemonReply::Done));
2768 let mut client = DaemonClient::connect(metadata).await.unwrap();
2769 assert!(
2770 client
2771 .request_with_reconnect(action, || async {
2772 panic!("an ambiguous acknowledgement must not replay a mutation");
2773 })
2774 .await
2775 .is_err()
2776 );
2777 server.await.unwrap();
2778 }
2779
2780 #[test]
2782 fn unsupported_protocol_message_names_both_binaries_and_versions() {
2783 let message = unsupported_daemon_protocol_message(
2784 PROTOCOL_VERSION + 5,
2785 &describe_daemon_and_client_builds(
2786 4242,
2787 BuildDescription {
2788 executable: Some(Path::new("/home/dev/mj/target/release/mj")),
2789 version: "2.14.0",
2790 },
2791 BuildDescription {
2792 executable: Some(Path::new("/home/dev/.cargo/bin/mj")),
2793 version: "2.9.0",
2794 },
2795 ),
2796 );
2797 assert_eq!(
2798 message,
2799 format!(
2800 "the daemon uses a newer protocol ({}) than this client ({PROTOCOL_VERSION}). \
2801 Daemon 4242 runs /home/dev/mj/target/release/mj (version 2.14.0), \
2802 while this client runs /home/dev/.cargo/bin/mj (version 2.9.0). \
2803 Put the daemon's directory first on PATH, or reinstall this client from that build.",
2804 PROTOCOL_VERSION + 5
2805 )
2806 );
2807 }
2808
2809 #[test]
2810 fn unsupported_protocol_message_still_names_the_client_when_the_daemon_file_is_unknown() {
2811 let message = unsupported_daemon_protocol_message(
2812 PROTOCOL_VERSION + 1,
2813 &describe_daemon_and_client_builds(
2814 4242,
2815 BuildDescription {
2816 executable: None,
2817 version: "2.14.0",
2818 },
2819 BuildDescription {
2820 executable: Some(Path::new("/home/dev/.cargo/bin/mj")),
2821 version: "2.9.0",
2822 },
2823 ),
2824 );
2825 assert!(
2826 message.contains("Daemon 4242 runs an unknown file (version 2.14.0)"),
2827 "{message}"
2828 );
2829 assert!(
2830 message.contains("this client runs /home/dev/.cargo/bin/mj (version 2.9.0)"),
2831 "{message}"
2832 );
2833 }
2834}