1use std::collections::HashSet;
2#[cfg(unix)]
3use std::os::unix::process::CommandExt;
4use std::process::{Command, Stdio};
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize, de::DeserializeOwned};
8use serde_json::{Value, json};
9
10use mermaid_runtime::{
11 ApprovalRecord, ApprovalReplayResult, CheckpointManifest, CheckpointRecord, CompactionRecord,
12 MessageRecord, NewProcess, NewProviderProbe, PairingTokenRecord, PluginInstallRecord,
13 ProcessRecord, ProcessStatus, ProviderProbeRecord, RuntimeStore, SessionRecord, TaskRecord,
14 TaskStatus, TaskTimelineEvent, ToolRunRecord, approve_and_replay, deny_approval,
15 request_daemon_json, restore_checkpoint,
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum RuntimeClientSource {
21 Daemon,
22 Local,
23}
24
25impl RuntimeClientSource {
26 #[must_use]
27 pub fn as_str(self) -> &'static str {
28 match self {
29 Self::Daemon => "daemon",
30 Self::Local => "local",
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
36enum RuntimeClientMode {
37 PreferDaemon,
38 DaemonOnly,
39 LocalOnly,
40}
41
42#[derive(Debug, Clone)]
43pub struct RuntimeClient {
44 mode: RuntimeClientMode,
45 auth_token: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct RuntimeRead<T> {
50 pub source: RuntimeClientSource,
51 pub value: T,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct RuntimeHealth {
56 pub ok: bool,
57 pub service: String,
58 pub database: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct RuntimeSnapshot {
63 pub ok: bool,
64 pub database: String,
65 pub sessions: Vec<SessionRecord>,
66 pub tasks: Vec<TaskRecord>,
67 pub tool_runs: Vec<ToolRunRecord>,
68 pub processes: Vec<ProcessRecord>,
69 pub approvals: Vec<ApprovalRecord>,
70 pub checkpoints: Vec<CheckpointRecord>,
71 pub compactions: Vec<CompactionRecord>,
72 pub plugins: Vec<PluginInstallRecord>,
73 pub provider_probes: Vec<ProviderProbeRecord>,
74 pub pairings: Vec<PairingTokenRecord>,
75 pub safety: mermaid_domain::SafetyConfig,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct RuntimeDashboardCounts {
80 pub pending_approvals: usize,
81 pub running_tasks: usize,
82 pub waiting_tasks: usize,
83 pub blocked_tasks: usize,
84 pub ready_processes: usize,
85 pub recent_checkpoints: usize,
86 pub installed_plugins: usize,
87 pub archived_approvals: usize,
88 pub archived_checkpoints: usize,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct RuntimeDashboard {
93 pub ok: bool,
94 pub health: RuntimeHealth,
95 pub safety: mermaid_domain::SafetyConfig,
96 pub counts: RuntimeDashboardCounts,
97 pub sessions: Vec<SessionRecord>,
98 pub tasks: Vec<TaskRecord>,
99 pub tool_runs: Vec<ToolRunRecord>,
100 pub processes: Vec<ProcessRecord>,
101 pub approvals: Vec<ApprovalRecord>,
102 pub checkpoints: Vec<CheckpointRecord>,
103 pub compactions: Vec<CompactionRecord>,
104 pub plugins: Vec<PluginInstallRecord>,
105 pub provider_probes: Vec<ProviderProbeRecord>,
106 pub pairings: Vec<PairingTokenRecord>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct RuntimeDiagnostics {
111 #[serde(flatten)]
112 pub snapshot: RuntimeSnapshot,
113 pub mode: String,
114 pub hygiene: RuntimeDiagnosticsHygiene,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct RuntimeDiagnosticsHygiene {
119 pub preview: Value,
120 pub visible: RuntimeDiagnosticsVisible,
121 pub archived: RuntimeDiagnosticsArchived,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct RuntimeDiagnosticsVisible {
126 pub approvals: usize,
127 pub checkpoints: usize,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct RuntimeDiagnosticsArchived {
132 pub approvals: usize,
133 pub checkpoints: usize,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct RuntimeHygienePreview {
138 pub ok: bool,
139 pub reason: String,
140 pub approvals: Vec<ApprovalRecord>,
141 pub checkpoints: Vec<CheckpointRecord>,
142 pub counts: RuntimeHygieneCounts,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct RuntimeHygieneCounts {
147 pub approvals: usize,
148 pub checkpoints: usize,
149 pub total: usize,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct RuntimeHygieneArchive {
154 pub ok: bool,
155 pub reason: String,
156 pub archived: RuntimeHygieneCounts,
157 pub matched: RuntimeHygieneCounts,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct RuntimeTaskDetail {
162 pub ok: bool,
163 pub task: TaskRecord,
164 pub events: Vec<TaskTimelineEvent>,
165 pub session: Option<SessionRecord>,
166 pub messages: Vec<MessageRecord>,
167 pub approvals: Vec<ApprovalRecord>,
168 pub checkpoints: Vec<CheckpointRecord>,
169 pub processes: Vec<ProcessRecord>,
170 pub tool_runs: Vec<ToolRunRecord>,
171 pub compactions: Vec<CompactionRecord>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct RuntimeApprovalDetail {
176 pub ok: bool,
177 pub approval: ApprovalRecord,
178 pub task: Option<TaskRecord>,
179 pub checkpoint: Option<CheckpointRecord>,
180 pub pending_action: Value,
181 pub args: Value,
182 pub changed_files: Value,
183 pub affected_paths: Vec<String>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct RuntimeCheckpointDetail {
188 pub ok: bool,
189 pub checkpoint: CheckpointRecord,
190 pub task: Option<TaskRecord>,
191 pub approval: Option<ApprovalRecord>,
192 pub pending_action: Value,
193 pub changed_files: Value,
194 pub affected_paths: Vec<String>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct RuntimeProcessLog {
199 pub ok: bool,
200 pub content: String,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct RuntimeProcessOpen {
205 pub ok: bool,
206 pub target: String,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct RuntimePorts {
211 pub ok: bool,
212 pub ports: String,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct RuntimeApprovalDecision {
217 pub ok: bool,
218 pub approval: Option<ApprovalRecord>,
219 pub replayed: bool,
220 pub summary: String,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct RuntimeCheckpointRestore {
225 pub ok: bool,
226 pub checkpoint: CheckpointManifest,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
230struct RuntimeItems<T> {
231 ok: bool,
232 items: Vec<T>,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct RuntimeOne<T> {
237 pub ok: bool,
238 pub item: T,
239}
240
241pub struct RuntimeService {
242 store: RuntimeStore,
243}
244
245impl RuntimeClient {
246 #[must_use]
247 pub fn auto() -> Self {
248 Self {
249 mode: RuntimeClientMode::PreferDaemon,
250 auth_token: None,
251 }
252 }
253
254 #[must_use]
255 pub fn daemon() -> Self {
256 Self {
257 mode: RuntimeClientMode::DaemonOnly,
258 auth_token: None,
259 }
260 }
261
262 pub fn daemon_with_token(token: impl Into<String>) -> Self {
263 Self {
264 mode: RuntimeClientMode::DaemonOnly,
265 auth_token: Some(token.into()),
266 }
267 }
268
269 #[must_use]
270 pub fn local() -> Self {
271 Self {
272 mode: RuntimeClientMode::LocalOnly,
273 auth_token: None,
274 }
275 }
276
277 pub fn health(&self) -> Result<RuntimeRead<RuntimeHealth>> {
284 self.read(
285 crate::runtime_client::DaemonRequest::Health.to_wire(),
286 |service| service.health(),
287 )
288 }
289
290 pub fn snapshot(&self) -> Result<RuntimeRead<RuntimeSnapshot>> {
297 self.read(
298 crate::runtime_client::DaemonRequest::Snapshot.to_wire(),
299 |service| service.snapshot(),
300 )
301 }
302
303 pub fn dashboard(&self) -> Result<RuntimeRead<RuntimeDashboard>> {
310 self.read(
311 crate::runtime_client::DaemonRequest::RuntimeDashboard.to_wire(),
312 |service| service.dashboard(),
313 )
314 }
315
316 pub fn diagnostics(&self) -> Result<RuntimeRead<RuntimeDiagnostics>> {
323 self.read(
324 crate::runtime_client::DaemonRequest::RuntimeDiagnostics.to_wire(),
325 |service| service.diagnostics(),
326 )
327 }
328
329 pub fn hygiene_preview(&self) -> Result<RuntimeRead<RuntimeHygienePreview>> {
336 self.read(
337 crate::runtime_client::DaemonRequest::RuntimeHygienePreview.to_wire(),
338 |service| service.hygiene_preview(),
339 )
340 }
341
342 pub fn hygiene_archive(&self) -> Result<RuntimeRead<RuntimeHygieneArchive>> {
349 self.read_inner(
350 crate::runtime_client::DaemonRequest::RuntimeHygieneArchive.to_wire(),
351 true,
352 |service| service.hygiene_archive(),
353 )
354 }
355
356 pub fn task_detail(&self, id: &str) -> Result<RuntimeRead<RuntimeTaskDetail>> {
363 self.read(
364 crate::runtime_client::DaemonRequest::RuntimeTaskDetail { id: id.to_string() }
365 .to_wire(),
366 |service| service.task_detail(id),
367 )
368 }
369
370 pub fn approval_detail(&self, id: &str) -> Result<RuntimeRead<RuntimeApprovalDetail>> {
377 self.read(
378 crate::runtime_client::DaemonRequest::RuntimeApprovalDetail { id: id.to_string() }
379 .to_wire(),
380 |service| service.approval_detail(id),
381 )
382 }
383
384 pub fn checkpoint_detail(&self, id: &str) -> Result<RuntimeRead<RuntimeCheckpointDetail>> {
391 self.read(
392 crate::runtime_client::DaemonRequest::RuntimeCheckpointDetail { id: id.to_string() }
393 .to_wire(),
394 |service| service.checkpoint_detail(id),
395 )
396 }
397
398 pub fn list_tasks(&self, limit: usize) -> Result<RuntimeRead<Vec<TaskRecord>>> {
405 self.list(
406 crate::runtime_client::DaemonRequest::RuntimeTasks {
407 limit: Some(limit as u64),
408 }
409 .to_wire(),
410 |service| service.list_tasks(limit),
411 )
412 }
413
414 pub fn list_processes(&self, limit: usize) -> Result<RuntimeRead<Vec<ProcessRecord>>> {
421 self.list(
422 crate::runtime_client::DaemonRequest::RuntimeProcesses {
423 limit: Some(limit as u64),
424 }
425 .to_wire(),
426 |service| service.list_processes(limit),
427 )
428 }
429
430 pub fn list_approvals(&self) -> Result<RuntimeRead<Vec<ApprovalRecord>>> {
437 self.list(
438 crate::runtime_client::DaemonRequest::RuntimeApprovals.to_wire(),
439 |service| service.list_approvals(),
440 )
441 }
442
443 pub fn list_tool_runs(&self, limit: usize) -> Result<RuntimeRead<Vec<ToolRunRecord>>> {
450 self.list(
451 crate::runtime_client::DaemonRequest::RuntimeToolRuns {
452 limit: Some(limit as u64),
453 }
454 .to_wire(),
455 |service| service.list_tool_runs(limit),
456 )
457 }
458
459 pub fn list_checkpoints(&self, limit: usize) -> Result<RuntimeRead<Vec<CheckpointRecord>>> {
466 self.list(
467 crate::runtime_client::DaemonRequest::RuntimeCheckpoints {
468 limit: Some(limit as u64),
469 }
470 .to_wire(),
471 |service| service.list_checkpoints(limit),
472 )
473 }
474
475 pub fn list_plugins(&self) -> Result<RuntimeRead<Vec<PluginInstallRecord>>> {
482 self.list(
483 crate::runtime_client::DaemonRequest::RuntimePlugins.to_wire(),
484 |service| service.list_plugins(),
485 )
486 }
487
488 pub fn process_log(&self, id: &str, tail_bytes: Option<u64>) -> Result<RuntimeProcessLog> {
495 self.action(
496 crate::runtime_client::DaemonRequest::Logs {
497 id: id.to_string(),
498 tail_bytes,
499 }
500 .to_wire(),
501 |service| service.process_log(id, tail_bytes),
502 )
503 }
504
505 pub fn stop_process(&self, id: &str) -> Result<RuntimeOne<ProcessRecord>> {
512 self.action_authed_non_idempotent(
514 crate::runtime_client::DaemonRequest::StopProcess { id: id.to_string() }.to_wire(),
515 |service| {
516 service
517 .stop_process(id)
518 .map(|item| RuntimeOne { ok: true, item })
519 },
520 )
521 }
522
523 pub fn restart_process(&self, id: &str) -> Result<RuntimeOne<ProcessRecord>> {
530 self.action_authed_non_idempotent(
533 crate::runtime_client::DaemonRequest::RestartProcess { id: id.to_string() }.to_wire(),
534 |service| {
535 service
536 .restart_process(id)
537 .map(|item| RuntimeOne { ok: true, item })
538 },
539 )
540 }
541
542 pub fn open_process(&self, id: &str) -> Result<RuntimeProcessOpen> {
549 self.action_authed(
550 crate::runtime_client::DaemonRequest::OpenProcess { id: id.to_string() }.to_wire(),
551 |service| service.open_process(id),
552 )
553 }
554
555 pub fn ports(&self) -> Result<RuntimePorts> {
562 self.action(
563 crate::runtime_client::DaemonRequest::Ports.to_wire(),
564 |service| service.ports(),
565 )
566 }
567
568 pub fn approve(&self, id: &str) -> Result<RuntimeApprovalDecision> {
575 self.action_authed_non_idempotent(
578 crate::runtime_client::DaemonRequest::Approve { id: id.to_string() }.to_wire(),
579 |_service| RuntimeService::approval_decision(approve_and_replay(id)?),
580 )
581 }
582
583 pub fn deny(&self, id: &str) -> Result<RuntimeApprovalDecision> {
590 self.action_authed(
591 crate::runtime_client::DaemonRequest::Deny { id: id.to_string() }.to_wire(),
592 |_service| RuntimeService::approval_decision(deny_approval(id)?),
593 )
594 }
595
596 pub fn restore_checkpoint(&self, id: &str) -> Result<RuntimeCheckpointRestore> {
603 self.action_authed_non_idempotent(
606 crate::runtime_client::DaemonRequest::RestoreCheckpoint { id: id.to_string() }
607 .to_wire(),
608 |_service| {
609 Ok(RuntimeCheckpointRestore {
610 ok: true,
611 checkpoint: restore_checkpoint(id)?,
612 })
613 },
614 )
615 }
616
617 pub fn set_plugin_enabled(&self, id: &str, enabled: bool) -> Result<()> {
624 self.action_authed::<Value, _>(
625 crate::runtime_client::DaemonRequest::SetPluginEnabled {
626 id: id.to_string(),
627 enabled,
628 }
629 .to_wire(),
630 |service| {
631 service.set_plugin_enabled(id, enabled)?;
632 Ok(json!({"ok": true}))
633 },
634 )?;
635 Ok(())
636 }
637
638 pub fn model_info(&self, model: &str) -> Result<Value> {
645 self.action(
646 crate::runtime_client::DaemonRequest::ModelInfo {
647 model: model.to_string(),
648 }
649 .to_wire(),
650 |service| Ok(json!({"ok": true, "model": service.model_info(model)})),
651 )
652 }
653
654 pub fn set_safety_mode(&self, mode: &str) -> Result<Value> {
661 self.action_authed(
662 crate::runtime_client::DaemonRequest::SetSafetyMode {
663 mode: mode.to_string(),
664 }
665 .to_wire(),
666 |service| {
667 let safety = service.set_safety_mode(mode)?;
668 Ok(json!({"ok": true, "safety": safety}))
669 },
670 )
671 }
672
673 fn list<T, F>(&self, body: Value, local: F) -> Result<RuntimeRead<Vec<T>>>
674 where
675 T: DeserializeOwned,
676 F: FnOnce(&RuntimeService) -> Result<Vec<T>>,
677 {
678 self.read(body, |service| {
679 local(service).map(|items| RuntimeItems { ok: true, items })
680 })
681 .map(|read| RuntimeRead {
682 source: read.source,
683 value: read.value.items,
684 })
685 }
686
687 fn read<T, F>(&self, body: Value, local: F) -> Result<RuntimeRead<T>>
688 where
689 T: DeserializeOwned,
690 F: FnOnce(&RuntimeService) -> Result<T>,
691 {
692 self.read_inner(body, true, local)
700 }
701
702 fn action<T, F>(&self, body: Value, local: F) -> Result<T>
703 where
704 T: DeserializeOwned,
705 F: FnOnce(&RuntimeService) -> Result<T>,
706 {
707 self.action_inner(body, false, true, local)
708 }
709
710 fn action_authed<T, F>(&self, body: Value, local: F) -> Result<T>
711 where
712 T: DeserializeOwned,
713 F: FnOnce(&RuntimeService) -> Result<T>,
714 {
715 self.action_inner(body, true, true, local)
716 }
717
718 fn action_authed_non_idempotent<T, F>(&self, body: Value, local: F) -> Result<T>
725 where
726 T: DeserializeOwned,
727 F: FnOnce(&RuntimeService) -> Result<T>,
728 {
729 self.action_inner(body, true, false, local)
730 }
731
732 fn read_inner<T, F>(&self, body: Value, authed: bool, local: F) -> Result<RuntimeRead<T>>
733 where
734 T: DeserializeOwned,
735 F: FnOnce(&RuntimeService) -> Result<T>,
736 {
737 match self.mode {
738 RuntimeClientMode::LocalOnly => RuntimeService::open_default()
739 .and_then(|service| local(&service))
740 .map(|value| RuntimeRead {
741 source: RuntimeClientSource::Local,
742 value,
743 }),
744 RuntimeClientMode::DaemonOnly => {
745 self.request_daemon(body, authed).map(|value| RuntimeRead {
746 source: RuntimeClientSource::Daemon,
747 value,
748 })
749 },
750 RuntimeClientMode::PreferDaemon => match self.request_daemon(body, authed) {
751 Ok(value) => Ok(RuntimeRead {
752 source: RuntimeClientSource::Daemon,
753 value,
754 }),
755 Err(_) => RuntimeService::open_default()
756 .and_then(|service| local(&service))
757 .map(|value| RuntimeRead {
758 source: RuntimeClientSource::Local,
759 value,
760 }),
761 },
762 }
763 }
764
765 fn action_inner<T, F>(&self, body: Value, authed: bool, idempotent: bool, local: F) -> Result<T>
766 where
767 T: DeserializeOwned,
768 F: FnOnce(&RuntimeService) -> Result<T>,
769 {
770 match self.mode {
771 RuntimeClientMode::LocalOnly => {
772 RuntimeService::open_default().and_then(|service| local(&service))
773 },
774 RuntimeClientMode::DaemonOnly => self.request_daemon(body, authed),
775 RuntimeClientMode::PreferDaemon => match self.request_daemon(body, authed) {
776 Ok(value) => Ok(value),
777 Err(err) => {
778 if idempotent || daemon_failure_is_pre_send(&err) {
787 RuntimeService::open_default().and_then(|service| local(&service))
788 } else {
789 Err(err.context(
790 "daemon request failed after the action may have already run; \
791 not re-running it locally to avoid a duplicate side effect — \
792 retry once the daemon is reachable",
793 ))
794 }
795 },
796 },
797 }
798 }
799
800 fn request_daemon<T: DeserializeOwned>(&self, mut body: Value, authed: bool) -> Result<T> {
801 if authed && let Some(token) = self.auth_token.as_ref() {
802 body["auth"] = json!({ "token": token });
803 }
804 let value = request_daemon_json(body)?;
805 serde_json::from_value(value).context("daemon response had unexpected shape")
806 }
807}
808
809const MAX_SESSION_MESSAGES: usize = 5_000;
813
814fn transcript_rows(session: &SessionRecord) -> Vec<MessageRecord> {
833 let Ok(manager) = crate::session::ConversationManager::new(&session.project_path) else {
834 return Vec::new();
835 };
836 let Ok(conversation) = manager.load_conversation(&session.id) else {
837 return Vec::new();
838 };
839 let messages = conversation.messages();
840 let start = messages.len().saturating_sub(MAX_SESSION_MESSAGES);
841 messages[start..]
842 .iter()
843 .enumerate()
844 .map(|(index, message)| MessageRecord {
845 id: i64::try_from(index).unwrap_or(i64::MAX),
848 session_id: session.id.clone(),
849 role: format!("{:?}", message.role).to_lowercase(),
850 content_json: serde_json::to_string(message).unwrap_or_default(),
851 created_at: message.timestamp.to_rfc3339(),
852 })
853 .collect()
854}
855
856impl RuntimeService {
857 pub fn open_default() -> Result<Self> {
861 Ok(Self {
862 store: RuntimeStore::open_default()?,
863 })
864 }
865
866 pub fn from_store(store: RuntimeStore) -> Self {
867 Self { store }
868 }
869
870 pub fn session_messages(
878 &self,
879 id: &str,
880 ) -> Result<(Option<SessionRecord>, Vec<MessageRecord>)> {
881 let session = self.store.sessions().get(id)?;
882 let messages = session.as_ref().map_or_else(Vec::new, transcript_rows);
883 Ok((session, messages))
884 }
885
886 pub fn health(&self) -> Result<RuntimeHealth> {
890 Ok(RuntimeHealth {
891 ok: true,
892 service: "mermaidd".to_string(),
893 database: self.store.path().display().to_string(),
894 })
895 }
896
897 pub fn snapshot(&self) -> Result<RuntimeSnapshot> {
901 Ok(RuntimeSnapshot {
902 ok: true,
903 database: self.store.path().display().to_string(),
904 sessions: self.store.sessions().list(100)?,
905 tasks: self.store.tasks().list(100)?,
906 tool_runs: self.store.tool_runs().list(100)?,
907 processes: self.store.processes().list(100)?,
908 approvals: self.store.approvals().list_all(100)?,
909 checkpoints: self.store.checkpoints().list_all(100)?,
910 compactions: self.store.compactions().list(100)?,
911 plugins: self.store.plugins().list()?,
912 provider_probes: self.store.provider_probes().list(None, None)?,
913 pairings: self.store.pairing_tokens().list_redacted()?,
916 safety: crate::app::load_config().unwrap_or_default().safety,
917 })
918 }
919
920 pub fn dashboard(&self) -> Result<RuntimeDashboard> {
924 let sessions = self.store.sessions().list(100)?;
925 let tasks = self.store.tasks().list(200)?;
926 let tool_runs = self.store.tool_runs().list(100)?;
927 let processes = self.store.processes().list(100)?;
928 let approvals = self.store.approvals().list_pending()?;
929 let checkpoints = self.store.checkpoints().list(100)?;
930 let compactions = self.store.compactions().list(100)?;
931 let plugins = self.store.plugins().list()?;
932 let provider_probes = self.store.provider_probes().list(None, None)?;
933 let pairings = self.store.pairing_tokens().list_redacted()?;
934
935 let running_tasks = tasks
936 .iter()
937 .filter(|task| task.status == TaskStatus::Running)
938 .count();
939 let waiting_tasks = tasks
940 .iter()
941 .filter(|task| task.status == TaskStatus::WaitingForApproval)
942 .count();
943 let blocked_tasks = tasks
944 .iter()
945 .filter(|task| matches!(task.status, TaskStatus::Blocked | TaskStatus::Failed))
946 .count();
947 let ready_processes = processes
948 .iter()
949 .filter(|process| process.detected_url.is_some())
950 .count();
951
952 Ok(RuntimeDashboard {
953 ok: true,
954 health: self.health()?,
955 safety: crate::app::load_config().unwrap_or_default().safety,
956 counts: RuntimeDashboardCounts {
957 pending_approvals: approvals.len(),
958 running_tasks,
959 waiting_tasks,
960 blocked_tasks,
961 ready_processes,
962 recent_checkpoints: checkpoints.len(),
963 installed_plugins: plugins.len(),
964 archived_approvals: self.store.approvals().count_archived()?,
965 archived_checkpoints: self.store.checkpoints().count_archived()?,
966 },
967 sessions,
968 tasks,
969 tool_runs,
970 processes,
971 approvals,
972 checkpoints,
973 compactions,
974 plugins,
975 provider_probes,
976 pairings,
977 })
978 }
979
980 pub fn diagnostics(&self) -> Result<RuntimeDiagnostics> {
984 let snapshot = self.snapshot()?;
985 let hygiene = self.hygiene_preview()?;
986 Ok(RuntimeDiagnostics {
987 snapshot,
988 mode: "diagnostics".to_string(),
989 hygiene: RuntimeDiagnosticsHygiene {
990 preview: serde_json::to_value(hygiene)?,
991 visible: RuntimeDiagnosticsVisible {
992 approvals: self.store.approvals().list_pending()?.len(),
993 checkpoints: self.store.checkpoints().list(100)?.len(),
994 },
995 archived: RuntimeDiagnosticsArchived {
996 approvals: self.store.approvals().count_archived()?,
997 checkpoints: self.store.checkpoints().count_archived()?,
998 },
999 },
1000 })
1001 }
1002
1003 pub fn hygiene_preview(&self) -> Result<RuntimeHygienePreview> {
1007 let preview = self.raw_hygiene_preview()?;
1008 Ok(RuntimeHygienePreview {
1009 ok: true,
1010 reason: runtime_hygiene_reason().to_string(),
1011 counts: RuntimeHygieneCounts {
1012 approvals: preview.approvals.len(),
1013 checkpoints: preview.checkpoints.len(),
1014 total: preview.approvals.len() + preview.checkpoints.len(),
1015 },
1016 approvals: preview.approvals,
1017 checkpoints: preview.checkpoints,
1018 })
1019 }
1020
1021 pub fn hygiene_archive(&self) -> Result<RuntimeHygieneArchive> {
1025 let preview = self.raw_hygiene_preview()?;
1026 let approval_ids = preview
1027 .approvals
1028 .iter()
1029 .map(|approval| approval.id.clone())
1030 .collect::<Vec<_>>();
1031 let checkpoint_ids = preview
1032 .checkpoints
1033 .iter()
1034 .map(|checkpoint| checkpoint.id.clone())
1035 .collect::<Vec<_>>();
1036 let reason = runtime_hygiene_reason();
1037 let approvals_archived = self.store.approvals().archive(&approval_ids, reason)?;
1038 let checkpoints_archived = self.store.checkpoints().archive(&checkpoint_ids, reason)?;
1039
1040 Ok(RuntimeHygieneArchive {
1041 ok: true,
1042 reason: reason.to_string(),
1043 archived: RuntimeHygieneCounts {
1044 approvals: approvals_archived,
1045 checkpoints: checkpoints_archived,
1046 total: approvals_archived + checkpoints_archived,
1047 },
1048 matched: RuntimeHygieneCounts {
1049 approvals: approval_ids.len(),
1050 checkpoints: checkpoint_ids.len(),
1051 total: approval_ids.len() + checkpoint_ids.len(),
1052 },
1053 })
1054 }
1055
1056 pub fn task_detail(&self, id: &str) -> Result<RuntimeTaskDetail> {
1060 let task = self
1061 .store
1062 .tasks()
1063 .get(id)?
1064 .with_context(|| format!("task not found: {id}"))?;
1065 let events = self.store.tasks().events(id)?;
1066 let session = match task.conversation_id.as_deref() {
1067 Some(session_id) => self.store.sessions().get(session_id)?,
1068 None => None,
1069 };
1070 let messages = session.as_ref().map_or_else(Vec::new, transcript_rows);
1071 let approvals = self
1072 .store
1073 .approvals()
1074 .list_pending()?
1075 .into_iter()
1076 .filter(|approval| approval.task_id.as_deref() == Some(id))
1077 .collect::<Vec<_>>();
1078 let checkpoints = self
1079 .store
1080 .checkpoints()
1081 .list(200)?
1082 .into_iter()
1083 .filter(|checkpoint| checkpoint.task_id.as_deref() == Some(id))
1084 .collect::<Vec<_>>();
1085 let processes = self
1086 .store
1087 .processes()
1088 .list(200)?
1089 .into_iter()
1090 .filter(|process| process.task_id.as_deref() == Some(id))
1091 .collect::<Vec<_>>();
1092 let tool_runs = self
1093 .store
1094 .tool_runs()
1095 .list(200)?
1096 .into_iter()
1097 .filter(|tool_run| tool_run.task_id.as_deref() == Some(id))
1098 .collect::<Vec<_>>();
1099 let compactions = self
1100 .store
1101 .compactions()
1102 .list(200)?
1103 .into_iter()
1104 .filter(|compaction| compaction.task_id.as_deref() == Some(id))
1105 .collect::<Vec<_>>();
1106
1107 Ok(RuntimeTaskDetail {
1108 ok: true,
1109 task,
1110 events,
1111 session,
1112 messages,
1113 approvals,
1114 checkpoints,
1115 processes,
1116 tool_runs,
1117 compactions,
1118 })
1119 }
1120
1121 pub fn approval_detail(&self, id: &str) -> Result<RuntimeApprovalDetail> {
1125 let approval = self
1126 .store
1127 .approvals()
1128 .get(id)?
1129 .with_context(|| format!("approval not found: {id}"))?;
1130 let checkpoint = match approval.checkpoint_id.as_deref() {
1131 Some(checkpoint_id) => self.store.checkpoints().get(checkpoint_id)?,
1132 None => None,
1133 };
1134 let task = match approval.task_id.as_deref() {
1135 Some(task_id) => self.store.tasks().get(task_id)?,
1136 None => None,
1137 };
1138 let pending_action = parse_optional_json(approval.pending_action_json.as_deref());
1139 let args = parse_optional_json(approval.args_summary.as_deref());
1140 let changed_files = checkpoint
1141 .as_ref()
1142 .map(|checkpoint| parse_optional_json(Some(&checkpoint.changed_files_json)))
1143 .unwrap_or(Value::Null);
1144 let affected_paths = affected_paths_from_json(&pending_action, &changed_files);
1145
1146 Ok(RuntimeApprovalDetail {
1147 ok: true,
1148 approval,
1149 task,
1150 checkpoint,
1151 pending_action,
1152 args,
1153 changed_files,
1154 affected_paths,
1155 })
1156 }
1157
1158 pub fn checkpoint_detail(&self, id: &str) -> Result<RuntimeCheckpointDetail> {
1162 let checkpoint = self
1163 .store
1164 .checkpoints()
1165 .get(id)?
1166 .with_context(|| format!("checkpoint not found: {id}"))?;
1167 let approval = match checkpoint.approval_id.as_deref() {
1168 Some(approval_id) => self.store.approvals().get(approval_id)?,
1169 None => None,
1170 };
1171 let task = match checkpoint.task_id.as_deref() {
1172 Some(task_id) => self.store.tasks().get(task_id)?,
1173 None => None,
1174 };
1175 let pending_action = parse_optional_json(checkpoint.pending_action_json.as_deref());
1176 let changed_files = parse_optional_json(Some(&checkpoint.changed_files_json));
1177 let affected_paths = affected_paths_from_json(&pending_action, &changed_files);
1178
1179 Ok(RuntimeCheckpointDetail {
1180 ok: true,
1181 checkpoint,
1182 task,
1183 approval,
1184 pending_action,
1185 changed_files,
1186 affected_paths,
1187 })
1188 }
1189
1190 pub fn list_tasks(&self, limit: usize) -> Result<Vec<TaskRecord>> {
1194 self.store.tasks().list(limit)
1195 }
1196
1197 pub fn list_processes(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
1201 self.store.processes().list(limit)
1202 }
1203
1204 pub fn list_approvals(&self) -> Result<Vec<ApprovalRecord>> {
1208 self.store.approvals().list_pending()
1209 }
1210
1211 pub fn list_tool_runs(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
1215 self.store.tool_runs().list(limit)
1216 }
1217
1218 pub fn list_checkpoints(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1222 self.store.checkpoints().list(limit)
1223 }
1224
1225 pub fn list_plugins(&self) -> Result<Vec<PluginInstallRecord>> {
1229 self.store.plugins().list()
1230 }
1231
1232 pub fn process_log(&self, id: &str, tail_bytes: Option<u64>) -> Result<RuntimeProcessLog> {
1236 let process = self
1237 .store
1238 .processes()
1239 .get(id)?
1240 .with_context(|| format!("process not found: {id}"))?;
1241 let path = process
1242 .log_path
1243 .with_context(|| format!("process has no log path: {id}"))?;
1244 let tail = tail_bytes.unwrap_or(32 * 1024).min(512 * 1024);
1245 use std::io::{Read, Seek, SeekFrom};
1250 let mut file =
1251 std::fs::File::open(&path).with_context(|| format!("failed to read {path}"))?;
1252 let len = file.metadata().map(|m| m.len()).unwrap_or(0);
1253 let start = len.saturating_sub(tail);
1254 if start > 0 {
1255 file.seek(SeekFrom::Start(start))
1256 .with_context(|| format!("failed to seek {path}"))?;
1257 }
1258 let mut bytes = Vec::new();
1259 file.take(tail)
1260 .read_to_end(&mut bytes)
1261 .with_context(|| format!("failed to read {path}"))?;
1262 Ok(RuntimeProcessLog {
1263 ok: true,
1264 content: String::from_utf8_lossy(&bytes).into_owned(),
1265 })
1266 }
1267
1268 pub fn stop_process(&self, id: &str) -> Result<ProcessRecord> {
1272 let process = self
1273 .store
1274 .processes()
1275 .get(id)?
1276 .with_context(|| format!("process not found: {id}"))?;
1277 mermaid_model::utils::terminate_tree_blocking(
1280 process.pid,
1281 mermaid_model::utils::Grace::Graceful,
1282 );
1283 self.store.processes().upsert(NewProcess {
1284 id: Some(process.id.clone()),
1285 task_id: process.task_id.clone(),
1286 pid: process.pid,
1287 command: process.command.clone(),
1288 cwd: process.cwd.clone(),
1289 log_path: process.log_path.clone(),
1290 detected_url: process.detected_url.clone(),
1291 status: ProcessStatus::Exited,
1292 health: Some("stopped".to_string()),
1293 })
1294 }
1295
1296 pub fn restart_process(&self, id: &str) -> Result<ProcessRecord> {
1300 let process = self
1301 .store
1302 .processes()
1303 .get(id)?
1304 .with_context(|| format!("process not found: {id}"))?;
1305 anyhow::ensure!(
1309 !mermaid_runtime::is_destructive_command(&process.command),
1310 "refusing to restart process {id}: command flagged destructive: {:?}",
1311 process.command
1312 );
1313 mermaid_model::utils::terminate_tree_blocking(
1314 process.pid,
1315 mermaid_model::utils::Grace::Graceful,
1316 );
1317 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1322 while pid_alive(process.pid) {
1323 if std::time::Instant::now() >= deadline {
1324 tracing::warn!(
1325 pid = process.pid,
1326 "restart_process: old pid still alive after 5s; respawning anyway"
1327 );
1328 break;
1329 }
1330 std::thread::sleep(std::time::Duration::from_millis(100));
1331 }
1332 let mut command = Command::new("sh");
1333 command.arg("-c").arg(&process.command);
1334 #[cfg(unix)]
1338 command.process_group(0);
1339 if let Some(cwd) = process.cwd.as_deref() {
1340 command.current_dir(cwd);
1341 }
1342 if let Some(path) = process.log_path.as_deref() {
1343 let file = std::fs::OpenOptions::new()
1344 .create(true)
1345 .append(true)
1346 .open(path)
1347 .with_context(|| format!("failed to open process log {path}"))?;
1348 let stderr = file.try_clone()?;
1349 command
1350 .stdout(Stdio::from(file))
1351 .stderr(Stdio::from(stderr));
1352 }
1353 let child = command.spawn()?;
1354 self.store.processes().upsert(NewProcess {
1355 id: Some(process.id),
1356 task_id: process.task_id,
1357 pid: child.id(),
1358 command: process.command,
1359 cwd: process.cwd,
1360 log_path: process.log_path,
1361 detected_url: process.detected_url,
1362 status: ProcessStatus::Running,
1363 health: Some("restarted".to_string()),
1364 })
1365 }
1366
1367 pub fn open_process(&self, id: &str) -> Result<RuntimeProcessOpen> {
1371 let process = self
1372 .store
1373 .processes()
1374 .get(id)?
1375 .with_context(|| format!("process not found: {id}"))?;
1376 let target = process
1377 .detected_url
1378 .or(process.log_path)
1379 .with_context(|| format!("process has no URL or log path: {id}"))?;
1380 validate_open_target(&target)?;
1381 mermaid_model::utils::open_file(target.clone());
1382 Ok(RuntimeProcessOpen { ok: true, target })
1383 }
1384
1385 pub fn resolve_open_target(&self, target: &str) -> Result<String> {
1389 if let Some(process) = self.store.processes().get(target)?
1390 && let Some(target) = process.detected_url.or(process.log_path)
1391 {
1392 return Ok(target);
1393 }
1394 Ok(target.to_string())
1395 }
1396
1397 pub fn ports(&self) -> Result<RuntimePorts> {
1401 let output = if cfg!(windows) {
1402 Command::new("netstat").arg("-ano").output()?
1403 } else {
1404 Command::new("sh")
1405 .arg("-c")
1406 .arg("ss -ltnp 2>/dev/null || netstat -ltnp 2>/dev/null || true")
1407 .output()?
1408 };
1409 Ok(RuntimePorts {
1410 ok: true,
1411 ports: String::from_utf8_lossy(&output.stdout).into_owned(),
1412 })
1413 }
1414
1415 pub fn set_plugin_enabled(&self, id: &str, enabled: bool) -> Result<()> {
1419 self.store.plugins().set_enabled(id, enabled)?;
1420 mermaid_runtime::write_plugin_lockfile()?;
1421 Ok(())
1422 }
1423
1424 pub fn set_safety_mode(&self, mode: &str) -> Result<mermaid_domain::SafetyConfig> {
1428 let parsed = mermaid_runtime::SafetyMode::parse(mode)
1429 .ok_or_else(|| anyhow::anyhow!("unknown safety mode: {mode}"))?;
1430 crate::app::update_user_config_key(
1433 &["safety", "mode"],
1434 toml::Value::String(parsed.as_str().to_string()),
1435 )?;
1436 Ok(crate::app::load_config()?.safety)
1437 }
1438
1439 pub fn model_info(&self, model: &str) -> Value {
1440 let snapshot = mermaid_domain::ProviderCapabilitySnapshot::from_model_id(model);
1441 for (key, value, confidence) in [
1442 (
1443 "supports_tools",
1444 snapshot.supports_tools.to_string(),
1445 "static",
1446 ),
1447 (
1448 "supports_vision",
1449 snapshot.supports_vision.to_string(),
1450 "static",
1451 ),
1452 ("reasoning", snapshot.reasoning.clone(), "static"),
1453 (
1454 "max_context_tokens",
1455 snapshot
1456 .max_context_tokens
1457 .map(|value| value.to_string())
1458 .unwrap_or_else(|| "unknown".to_string()),
1459 "static",
1460 ),
1461 ] {
1462 let _ = self.store.provider_probes().upsert(NewProviderProbe {
1463 provider: snapshot.provider.clone(),
1464 model_id: snapshot.model.clone(),
1465 capability_key: key.to_string(),
1466 capability_value: value,
1467 confidence: confidence.to_string(),
1468 error: None,
1469 });
1470 }
1471 if let Some(profile) = mermaid_model::models::lookup_provider(&snapshot.provider) {
1472 record_static_provider_probes(
1473 &self.store,
1474 profile,
1475 &snapshot.provider,
1476 &snapshot.model,
1477 );
1478 }
1479 json!({
1480 "id": model,
1481 "provider": snapshot.provider,
1482 "model": snapshot.model,
1483 "supports_tools": snapshot.supports_tools,
1484 "supports_vision": snapshot.supports_vision,
1485 "reasoning": snapshot.reasoning,
1486 "max_context_tokens": snapshot.max_context_tokens,
1487 })
1488 }
1489
1490 pub fn approval_decision(result: ApprovalReplayResult) -> Result<RuntimeApprovalDecision> {
1494 Ok(RuntimeApprovalDecision {
1495 ok: true,
1496 approval: result.approval,
1497 replayed: result.replayed,
1498 summary: result.summary,
1499 })
1500 }
1501
1502 fn raw_hygiene_preview(&self) -> Result<RawRuntimeHygienePreview> {
1503 let checkpoints = self
1504 .store
1505 .checkpoints()
1506 .list_all(10_000)?
1507 .into_iter()
1508 .filter(|checkpoint| checkpoint.archived_at.is_none())
1509 .collect::<Vec<_>>();
1510 let test_checkpoint_ids = checkpoints
1511 .iter()
1512 .filter(|checkpoint| is_runtime_hygiene_checkpoint(checkpoint))
1513 .map(|checkpoint| checkpoint.id.clone())
1514 .collect::<HashSet<_>>();
1515 let approvals = self
1516 .store
1517 .approvals()
1518 .list_all(10_000)?
1519 .into_iter()
1520 .filter(|approval| approval.archived_at.is_none())
1521 .filter(|approval| is_runtime_hygiene_approval(approval, &test_checkpoint_ids))
1522 .collect::<Vec<_>>();
1523 let checkpoints = checkpoints
1524 .into_iter()
1525 .filter(is_runtime_hygiene_checkpoint)
1526 .collect::<Vec<_>>();
1527
1528 Ok(RawRuntimeHygienePreview {
1529 approvals,
1530 checkpoints,
1531 })
1532 }
1533}
1534
1535#[derive(Debug)]
1536struct RawRuntimeHygienePreview {
1537 approvals: Vec<ApprovalRecord>,
1538 checkpoints: Vec<CheckpointRecord>,
1539}
1540
1541#[must_use]
1542pub fn runtime_hygiene_reason() -> &'static str {
1543 "runtime hygiene: test/dev artifact"
1544}
1545
1546pub fn record_static_provider_probes(
1551 store: &RuntimeStore,
1552 profile: &mermaid_model::models::ProviderProfile,
1553 provider: &str,
1554 model_id: &str,
1555) {
1556 for (key, value) in [
1557 (
1558 "max_output_tokens_param",
1559 format!("{:?}", profile.max_tokens_param),
1560 ),
1561 (
1562 "parallel_tool_calls",
1563 (!profile.disable_parallel_tool_calls_for.contains(&model_id)).to_string(),
1564 ),
1565 (
1566 "reasoning_parameter_shape",
1567 format!("{:?}", profile.reasoning_strategy),
1568 ),
1569 (
1570 "streaming_usage_available",
1571 "provider_dependent".to_string(),
1572 ),
1573 ("token_usage_field_shape", "openai_compatible".to_string()),
1574 ] {
1575 let _ = store.provider_probes().upsert(NewProviderProbe {
1576 provider: provider.to_string(),
1577 model_id: model_id.to_string(),
1578 capability_key: key.to_string(),
1579 capability_value: value,
1580 confidence: "static".to_string(),
1581 error: None,
1582 });
1583 }
1584}
1585
1586#[must_use]
1587pub fn parse_optional_json(raw: Option<&str>) -> Value {
1588 raw.and_then(|value| serde_json::from_str(value).ok())
1589 .unwrap_or(Value::Null)
1590}
1591
1592#[must_use]
1593pub fn affected_paths_from_json(pending_action: &Value, changed_files: &Value) -> Vec<String> {
1594 let mut paths = Vec::new();
1595 collect_path_like_strings(changed_files, &mut paths);
1596 collect_path_like_strings(pending_action, &mut paths);
1597 paths.sort();
1598 paths.dedup();
1599 paths.truncate(25);
1600 paths
1601}
1602
1603fn collect_path_like_strings(value: &Value, paths: &mut Vec<String>) {
1604 match value {
1605 Value::String(value) if looks_path_like(value) => {
1606 paths.push(value.clone());
1607 },
1608 Value::Array(items) => {
1609 for item in items {
1610 collect_path_like_strings(item, paths);
1611 }
1612 },
1613 Value::Object(object) => {
1614 for (key, value) in object {
1615 match value {
1616 Value::String(text)
1617 if matches!(
1618 key.as_str(),
1619 "path"
1620 | "file"
1621 | "file_path"
1622 | "target"
1623 | "project_path"
1624 | "snapshot_path"
1625 | "cwd"
1626 ) =>
1627 {
1628 if !text.trim().is_empty() {
1629 paths.push(text.clone());
1630 }
1631 },
1632 other => collect_path_like_strings(other, paths),
1633 }
1634 }
1635 },
1636 _ => {},
1637 }
1638}
1639
1640fn looks_path_like(value: &str) -> bool {
1641 let trimmed = value.trim();
1642 trimmed.starts_with('/')
1643 || trimmed.starts_with("./")
1644 || trimmed.starts_with("../")
1645 || trimmed.contains(std::path::MAIN_SEPARATOR)
1646}
1647
1648fn is_runtime_hygiene_checkpoint(checkpoint: &CheckpointRecord) -> bool {
1649 checkpoint.project_path.starts_with("/tmp/mermaid_")
1650}
1651
1652fn is_runtime_hygiene_approval(
1653 approval: &ApprovalRecord,
1654 test_checkpoint_ids: &HashSet<String>,
1655) -> bool {
1656 let is_restore_replay = approval.risk_classification == "restored_action"
1657 && approval.proposed_action.starts_with("restore replay:");
1658 if !is_restore_replay {
1659 return false;
1660 }
1661 match approval.checkpoint_id.as_deref() {
1662 Some(checkpoint_id) => test_checkpoint_ids.contains(checkpoint_id),
1663 None => true,
1664 }
1665}
1666
1667fn daemon_failure_is_pre_send(err: &anyhow::Error) -> bool {
1684 #[cfg(not(unix))]
1685 {
1686 let _ = err;
1687 true
1688 }
1689 #[cfg(unix)]
1690 {
1691 match err.downcast_ref::<std::io::Error>() {
1692 Some(io) => matches!(
1693 io.kind(),
1694 std::io::ErrorKind::NotFound
1695 | std::io::ErrorKind::ConnectionRefused
1696 | std::io::ErrorKind::PermissionDenied
1697 ),
1698 None => false,
1699 }
1700 }
1701}
1702
1703fn pid_alive(pid: u32) -> bool {
1706 if cfg!(windows) {
1707 Command::new("tasklist")
1708 .args(["/FI", &format!("PID eq {pid}"), "/NH"])
1709 .output()
1710 .map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
1711 .unwrap_or(false)
1712 } else {
1713 Command::new("kill")
1714 .args(["-0", &pid.to_string()])
1715 .status()
1716 .map(|s| s.success())
1717 .unwrap_or(false)
1718 }
1719}
1720
1721pub(crate) fn validate_open_target(target: &str) -> Result<()> {
1725 if target.contains("://") {
1728 let url = reqwest::Url::parse(target)
1729 .with_context(|| format!("refusing to open unparseable URL target: {target:?}"))?;
1730 anyhow::ensure!(
1731 matches!(url.scheme(), "http" | "https"),
1732 "refusing to open non-http(s) URL target: {target:?}"
1733 );
1734 return Ok(());
1737 }
1738 let meta = std::fs::metadata(target)
1742 .with_context(|| format!("refusing to open missing target: {target:?}"))?;
1743 anyhow::ensure!(
1744 meta.is_file(),
1745 "refusing to open non-regular-file target: {target:?}"
1746 );
1747 Ok(())
1748}
1749
1750#[cfg(test)]
1751mod tests {
1752 use super::*;
1753 use std::path::PathBuf;
1754
1755 fn temp_db(name: &str) -> PathBuf {
1756 let dir = std::env::temp_dir().join(format!("mermaid_runtime_client_{name}"));
1757 let _ = std::fs::remove_dir_all(&dir);
1758 std::fs::create_dir_all(&dir).expect("create temp dir");
1759 dir.join("runtime.sqlite3")
1760 }
1761
1762 #[test]
1763 fn session_messages_come_from_the_conversation_file() {
1764 let path = temp_db("session_from_disk");
1769 let project = path.parent().expect("temp dir").join("project");
1770 std::fs::create_dir_all(&project).expect("project dir");
1771
1772 let manager = crate::session::ConversationManager::new(&project).expect("manager");
1773 let mut conversation = mermaid_domain::ConversationHistory::new(
1774 project.display().to_string(),
1775 "ollama/test".to_string(),
1776 chrono::Local::now(),
1777 );
1778 let seeded = vec![
1781 mermaid_model::models::ChatMessage::user("what shipped?"),
1782 mermaid_model::models::ChatMessage::assistant("the event log"),
1783 ];
1784 conversation.add_messages(&seeded, chrono::Local::now());
1785 manager.save_conversation(&conversation).expect("save");
1786
1787 let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1788 service
1789 .store
1790 .sessions()
1791 .upsert(mermaid_runtime::NewSession {
1792 id: Some(conversation.id.clone()),
1793 project_path: project.display().to_string(),
1794 model_id: "ollama/test".to_string(),
1795 title: Some(conversation.title.clone()),
1796 conversation_path: None,
1797 total_tokens: Some(42),
1798 })
1799 .expect("index row");
1800
1801 let (session, messages) = service
1802 .session_messages(&conversation.id)
1803 .expect("session_messages");
1804 assert!(session.is_some());
1805 assert_eq!(messages.len(), 2, "transcript must come off disk");
1806 assert_eq!(messages[0].role, "user");
1807 assert!(messages[0].content_json.contains("what shipped?"));
1808 assert_eq!(messages[1].role, "assistant");
1809
1810 let (missing, none) = service
1812 .session_messages("20990101_000000_999")
1813 .expect("unknown id is Ok");
1814 assert!(missing.is_none() && none.is_empty());
1815 let _ = std::fs::remove_dir_all(path.parent().expect("temp dir"));
1816 }
1817
1818 #[test]
1819 fn a_huge_transcript_is_capped_at_the_tail() {
1820 let path = temp_db("session_cap");
1824 let project = path.parent().expect("temp dir").join("project");
1825 std::fs::create_dir_all(&project).expect("project dir");
1826 let manager = crate::session::ConversationManager::new(&project).expect("manager");
1827 let mut conversation = mermaid_domain::ConversationHistory::new(
1828 project.display().to_string(),
1829 "ollama/test".to_string(),
1830 chrono::Local::now(),
1831 );
1832 let over = MAX_SESSION_MESSAGES + 10;
1833 let seeded: Vec<_> = (0..over)
1834 .map(|i| mermaid_model::models::ChatMessage::user(format!("m{i}")))
1835 .collect();
1836 conversation.add_messages(&seeded, chrono::Local::now());
1837 manager.save_conversation(&conversation).expect("save");
1838
1839 let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1840 service
1841 .store
1842 .sessions()
1843 .upsert(mermaid_runtime::NewSession {
1844 id: Some(conversation.id.clone()),
1845 project_path: project.display().to_string(),
1846 model_id: "ollama/test".to_string(),
1847 title: None,
1848 conversation_path: None,
1849 total_tokens: None,
1850 })
1851 .expect("index row");
1852
1853 let (_, messages) = service
1854 .session_messages(&conversation.id)
1855 .expect("session_messages");
1856 assert_eq!(messages.len(), MAX_SESSION_MESSAGES, "capped at the max");
1857 assert!(
1858 messages[0].content_json.contains("\"m10\""),
1859 "the cap keeps the TAIL, so the first 10 are the ones dropped: {}",
1860 messages[0].content_json
1861 );
1862 assert!(
1863 messages.windows(2).all(|w| w[0].id < w[1].id),
1864 "chronological order preserved across the capped tail"
1865 );
1866 let _ = std::fs::remove_dir_all(path.parent().expect("temp dir"));
1867 }
1868
1869 #[test]
1870 fn pid_alive_detects_live_and_dead() {
1871 assert!(pid_alive(std::process::id()));
1873 #[cfg(unix)]
1875 let mut child = Command::new("true").spawn().expect("spawn true");
1876 #[cfg(windows)]
1877 let mut child = Command::new("cmd")
1878 .args(["/C", "exit 0"])
1879 .spawn()
1880 .expect("spawn exit");
1881 let pid = child.id();
1882 let _ = child.wait();
1883 std::thread::sleep(std::time::Duration::from_millis(250));
1884 assert!(!pid_alive(pid));
1885 }
1886
1887 #[cfg(unix)]
1888 #[test]
1889 fn daemon_failure_pre_send_only_for_connect_kinds() {
1890 use anyhow::Context as _;
1893 use std::io::{Error as IoError, ErrorKind};
1894
1895 let refused: anyhow::Error = Err::<(), _>(IoError::from(ErrorKind::ConnectionRefused))
1898 .with_context(|| "failed to connect to /run/mermaidd.sock".to_string())
1899 .unwrap_err();
1900 assert!(daemon_failure_is_pre_send(&refused));
1901 let missing: anyhow::Error = Err::<(), _>(IoError::from(ErrorKind::NotFound))
1902 .with_context(|| "failed to connect to /run/mermaidd.sock".to_string())
1903 .unwrap_err();
1904 assert!(daemon_failure_is_pre_send(&missing));
1905
1906 assert!(!daemon_failure_is_pre_send(&anyhow::anyhow!(
1909 "daemon returned invalid JSON"
1910 )));
1911 let broken = anyhow::Error::from(IoError::from(ErrorKind::BrokenPipe));
1913 assert!(!daemon_failure_is_pre_send(&broken));
1914 assert!(!daemon_failure_is_pre_send(&anyhow::anyhow!(
1916 "stop_process failed"
1917 )));
1918 }
1919
1920 #[test]
1921 fn validate_open_target_allows_http_rejects_file_and_js() {
1922 assert!(super::validate_open_target("http://localhost:3000").is_ok());
1923 assert!(super::validate_open_target("https://localhost:8443/app").is_ok());
1924 assert!(super::validate_open_target("file:///etc/passwd").is_err());
1925 assert!(super::validate_open_target("javascript:alert(1)").is_err());
1926 assert!(super::validate_open_target("data:text/html,<x>").is_err());
1927 assert!(super::validate_open_target("/no/such/path/xyz.log").is_err());
1928 }
1929
1930 #[test]
1931 fn restart_process_refuses_destructive_command() {
1932 let path = temp_db("restart_destructive");
1933 let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1934 let rec = service
1935 .store
1936 .processes()
1937 .upsert(NewProcess {
1938 id: Some("p-destruct".to_string()),
1939 task_id: None,
1940 pid: 0,
1941 command: "rm -rf /".to_string(),
1942 cwd: None,
1943 log_path: None,
1944 detected_url: None,
1945 status: ProcessStatus::Running,
1946 health: None,
1947 })
1948 .expect("seed process");
1949 let err = service.restart_process(&rec.id).unwrap_err();
1951 assert!(err.to_string().contains("destructive"), "{err}");
1952 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1953 }
1954
1955 #[test]
1956 fn open_process_rejects_file_url_target() {
1957 let path = temp_db("open_file_url");
1958 let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1959 let rec = service
1960 .store
1961 .processes()
1962 .upsert(NewProcess {
1963 id: Some("p-open".to_string()),
1964 task_id: None,
1965 pid: 0,
1966 command: "true".to_string(),
1967 cwd: None,
1968 log_path: None,
1969 detected_url: Some("file:///etc/passwd".to_string()),
1970 status: ProcessStatus::Running,
1971 health: None,
1972 })
1973 .expect("seed process");
1974 assert!(service.open_process(&rec.id).is_err());
1976 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1977 }
1978
1979 #[test]
1980 fn local_client_lists_tasks_from_store() {
1981 let path = temp_db("tasks");
1982 let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1983 let task = service
1984 .store
1985 .tasks()
1986 .create(mermaid_runtime::NewTask::new(
1987 "contract task",
1988 "/tmp/mermaid_runtime_client",
1989 "openai/test",
1990 ))
1991 .expect("task");
1992 let tasks = service.list_tasks(10).expect("list");
1993 assert_eq!(
1994 tasks.first().map(|item| item.id.as_str()),
1995 Some(task.id.as_str())
1996 );
1997 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1998 }
1999
2000 #[test]
2001 fn hygiene_preview_matches_test_artifacts_and_archive_is_idempotent() {
2002 let path = temp_db("hygiene");
2003 let store = RuntimeStore::open(&path).expect("open store");
2004 let checkpoint = store
2005 .checkpoints()
2006 .create(mermaid_runtime::NewCheckpoint {
2007 id: Some("checkpoint-test".to_string()),
2008 task_id: None,
2009 project_path: "/tmp/mermaid_checkpoint_test".to_string(),
2010 snapshot_path: "/data/checkpoints/checkpoint-test".to_string(),
2011 changed_files_json: "[]".to_string(),
2012 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2013 approval_id: None,
2014 session_id: None,
2015 message_index: None,
2016 })
2017 .expect("create checkpoint");
2018 let approval = store
2019 .approvals()
2020 .create(mermaid_runtime::NewApproval {
2021 task_id: None,
2022 proposed_action: "restore replay: write_file".to_string(),
2023 risk_classification: "restored_action".to_string(),
2024 policy_decision: "ask".to_string(),
2025 args_summary: None,
2026 checkpoint_id: Some(checkpoint.id.clone()),
2027 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2028 })
2029 .expect("create approval");
2030 store
2031 .checkpoints()
2032 .set_approval(&checkpoint.id, &approval.id)
2033 .expect("link approval");
2034
2035 let service = RuntimeService::from_store(store);
2036 let preview = service.hygiene_preview().expect("preview");
2037 assert_eq!(preview.counts.approvals, 1);
2038 assert_eq!(preview.counts.checkpoints, 1);
2039 let archived = service.hygiene_archive().expect("archive");
2040 assert_eq!(archived.archived.total, 2);
2041 let archived_again = service.hygiene_archive().expect("archive again");
2042 assert_eq!(archived_again.archived.total, 0);
2043 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2044 }
2045}