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
809impl RuntimeService {
810 pub fn open_default() -> Result<Self> {
814 Ok(Self {
815 store: RuntimeStore::open_default()?,
816 })
817 }
818
819 pub fn from_store(store: RuntimeStore) -> Self {
820 Self { store }
821 }
822
823 pub fn health(&self) -> Result<RuntimeHealth> {
827 Ok(RuntimeHealth {
828 ok: true,
829 service: "mermaidd".to_string(),
830 database: self.store.path().display().to_string(),
831 })
832 }
833
834 pub fn snapshot(&self) -> Result<RuntimeSnapshot> {
838 Ok(RuntimeSnapshot {
839 ok: true,
840 database: self.store.path().display().to_string(),
841 sessions: self.store.sessions().list(100)?,
842 tasks: self.store.tasks().list(100)?,
843 tool_runs: self.store.tool_runs().list(100)?,
844 processes: self.store.processes().list(100)?,
845 approvals: self.store.approvals().list_all(100)?,
846 checkpoints: self.store.checkpoints().list_all(100)?,
847 compactions: self.store.compactions().list(100)?,
848 plugins: self.store.plugins().list()?,
849 provider_probes: self.store.provider_probes().list(None, None)?,
850 pairings: self.store.pairing_tokens().list_redacted()?,
853 safety: crate::app::load_config().unwrap_or_default().safety,
854 })
855 }
856
857 pub fn dashboard(&self) -> Result<RuntimeDashboard> {
861 let sessions = self.store.sessions().list(100)?;
862 let tasks = self.store.tasks().list(200)?;
863 let tool_runs = self.store.tool_runs().list(100)?;
864 let processes = self.store.processes().list(100)?;
865 let approvals = self.store.approvals().list_pending()?;
866 let checkpoints = self.store.checkpoints().list(100)?;
867 let compactions = self.store.compactions().list(100)?;
868 let plugins = self.store.plugins().list()?;
869 let provider_probes = self.store.provider_probes().list(None, None)?;
870 let pairings = self.store.pairing_tokens().list_redacted()?;
871
872 let running_tasks = tasks
873 .iter()
874 .filter(|task| task.status == TaskStatus::Running)
875 .count();
876 let waiting_tasks = tasks
877 .iter()
878 .filter(|task| task.status == TaskStatus::WaitingForApproval)
879 .count();
880 let blocked_tasks = tasks
881 .iter()
882 .filter(|task| matches!(task.status, TaskStatus::Blocked | TaskStatus::Failed))
883 .count();
884 let ready_processes = processes
885 .iter()
886 .filter(|process| process.detected_url.is_some())
887 .count();
888
889 Ok(RuntimeDashboard {
890 ok: true,
891 health: self.health()?,
892 safety: crate::app::load_config().unwrap_or_default().safety,
893 counts: RuntimeDashboardCounts {
894 pending_approvals: approvals.len(),
895 running_tasks,
896 waiting_tasks,
897 blocked_tasks,
898 ready_processes,
899 recent_checkpoints: checkpoints.len(),
900 installed_plugins: plugins.len(),
901 archived_approvals: self.store.approvals().count_archived()?,
902 archived_checkpoints: self.store.checkpoints().count_archived()?,
903 },
904 sessions,
905 tasks,
906 tool_runs,
907 processes,
908 approvals,
909 checkpoints,
910 compactions,
911 plugins,
912 provider_probes,
913 pairings,
914 })
915 }
916
917 pub fn diagnostics(&self) -> Result<RuntimeDiagnostics> {
921 let snapshot = self.snapshot()?;
922 let hygiene = self.hygiene_preview()?;
923 Ok(RuntimeDiagnostics {
924 snapshot,
925 mode: "diagnostics".to_string(),
926 hygiene: RuntimeDiagnosticsHygiene {
927 preview: serde_json::to_value(hygiene)?,
928 visible: RuntimeDiagnosticsVisible {
929 approvals: self.store.approvals().list_pending()?.len(),
930 checkpoints: self.store.checkpoints().list(100)?.len(),
931 },
932 archived: RuntimeDiagnosticsArchived {
933 approvals: self.store.approvals().count_archived()?,
934 checkpoints: self.store.checkpoints().count_archived()?,
935 },
936 },
937 })
938 }
939
940 pub fn hygiene_preview(&self) -> Result<RuntimeHygienePreview> {
944 let preview = self.raw_hygiene_preview()?;
945 Ok(RuntimeHygienePreview {
946 ok: true,
947 reason: runtime_hygiene_reason().to_string(),
948 counts: RuntimeHygieneCounts {
949 approvals: preview.approvals.len(),
950 checkpoints: preview.checkpoints.len(),
951 total: preview.approvals.len() + preview.checkpoints.len(),
952 },
953 approvals: preview.approvals,
954 checkpoints: preview.checkpoints,
955 })
956 }
957
958 pub fn hygiene_archive(&self) -> Result<RuntimeHygieneArchive> {
962 let preview = self.raw_hygiene_preview()?;
963 let approval_ids = preview
964 .approvals
965 .iter()
966 .map(|approval| approval.id.clone())
967 .collect::<Vec<_>>();
968 let checkpoint_ids = preview
969 .checkpoints
970 .iter()
971 .map(|checkpoint| checkpoint.id.clone())
972 .collect::<Vec<_>>();
973 let reason = runtime_hygiene_reason();
974 let approvals_archived = self.store.approvals().archive(&approval_ids, reason)?;
975 let checkpoints_archived = self.store.checkpoints().archive(&checkpoint_ids, reason)?;
976
977 Ok(RuntimeHygieneArchive {
978 ok: true,
979 reason: reason.to_string(),
980 archived: RuntimeHygieneCounts {
981 approvals: approvals_archived,
982 checkpoints: checkpoints_archived,
983 total: approvals_archived + checkpoints_archived,
984 },
985 matched: RuntimeHygieneCounts {
986 approvals: approval_ids.len(),
987 checkpoints: checkpoint_ids.len(),
988 total: approval_ids.len() + checkpoint_ids.len(),
989 },
990 })
991 }
992
993 pub fn task_detail(&self, id: &str) -> Result<RuntimeTaskDetail> {
997 let task = self
998 .store
999 .tasks()
1000 .get(id)?
1001 .with_context(|| format!("task not found: {id}"))?;
1002 let events = self.store.tasks().events(id)?;
1003 let session = match task.conversation_id.as_deref() {
1004 Some(session_id) => self.store.sessions().get(session_id)?,
1005 None => None,
1006 };
1007 let messages = match task.conversation_id.as_deref() {
1008 Some(session_id) => self.store.messages().list_for_session(session_id)?,
1009 None => Vec::new(),
1010 };
1011 let approvals = self
1012 .store
1013 .approvals()
1014 .list_pending()?
1015 .into_iter()
1016 .filter(|approval| approval.task_id.as_deref() == Some(id))
1017 .collect::<Vec<_>>();
1018 let checkpoints = self
1019 .store
1020 .checkpoints()
1021 .list(200)?
1022 .into_iter()
1023 .filter(|checkpoint| checkpoint.task_id.as_deref() == Some(id))
1024 .collect::<Vec<_>>();
1025 let processes = self
1026 .store
1027 .processes()
1028 .list(200)?
1029 .into_iter()
1030 .filter(|process| process.task_id.as_deref() == Some(id))
1031 .collect::<Vec<_>>();
1032 let tool_runs = self
1033 .store
1034 .tool_runs()
1035 .list(200)?
1036 .into_iter()
1037 .filter(|tool_run| tool_run.task_id.as_deref() == Some(id))
1038 .collect::<Vec<_>>();
1039 let compactions = self
1040 .store
1041 .compactions()
1042 .list(200)?
1043 .into_iter()
1044 .filter(|compaction| compaction.task_id.as_deref() == Some(id))
1045 .collect::<Vec<_>>();
1046
1047 Ok(RuntimeTaskDetail {
1048 ok: true,
1049 task,
1050 events,
1051 session,
1052 messages,
1053 approvals,
1054 checkpoints,
1055 processes,
1056 tool_runs,
1057 compactions,
1058 })
1059 }
1060
1061 pub fn approval_detail(&self, id: &str) -> Result<RuntimeApprovalDetail> {
1065 let approval = self
1066 .store
1067 .approvals()
1068 .get(id)?
1069 .with_context(|| format!("approval not found: {id}"))?;
1070 let checkpoint = match approval.checkpoint_id.as_deref() {
1071 Some(checkpoint_id) => self.store.checkpoints().get(checkpoint_id)?,
1072 None => None,
1073 };
1074 let task = match approval.task_id.as_deref() {
1075 Some(task_id) => self.store.tasks().get(task_id)?,
1076 None => None,
1077 };
1078 let pending_action = parse_optional_json(approval.pending_action_json.as_deref());
1079 let args = parse_optional_json(approval.args_summary.as_deref());
1080 let changed_files = checkpoint
1081 .as_ref()
1082 .map(|checkpoint| parse_optional_json(Some(&checkpoint.changed_files_json)))
1083 .unwrap_or(Value::Null);
1084 let affected_paths = affected_paths_from_json(&pending_action, &changed_files);
1085
1086 Ok(RuntimeApprovalDetail {
1087 ok: true,
1088 approval,
1089 task,
1090 checkpoint,
1091 pending_action,
1092 args,
1093 changed_files,
1094 affected_paths,
1095 })
1096 }
1097
1098 pub fn checkpoint_detail(&self, id: &str) -> Result<RuntimeCheckpointDetail> {
1102 let checkpoint = self
1103 .store
1104 .checkpoints()
1105 .get(id)?
1106 .with_context(|| format!("checkpoint not found: {id}"))?;
1107 let approval = match checkpoint.approval_id.as_deref() {
1108 Some(approval_id) => self.store.approvals().get(approval_id)?,
1109 None => None,
1110 };
1111 let task = match checkpoint.task_id.as_deref() {
1112 Some(task_id) => self.store.tasks().get(task_id)?,
1113 None => None,
1114 };
1115 let pending_action = parse_optional_json(checkpoint.pending_action_json.as_deref());
1116 let changed_files = parse_optional_json(Some(&checkpoint.changed_files_json));
1117 let affected_paths = affected_paths_from_json(&pending_action, &changed_files);
1118
1119 Ok(RuntimeCheckpointDetail {
1120 ok: true,
1121 checkpoint,
1122 task,
1123 approval,
1124 pending_action,
1125 changed_files,
1126 affected_paths,
1127 })
1128 }
1129
1130 pub fn list_tasks(&self, limit: usize) -> Result<Vec<TaskRecord>> {
1134 self.store.tasks().list(limit)
1135 }
1136
1137 pub fn list_processes(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
1141 self.store.processes().list(limit)
1142 }
1143
1144 pub fn list_approvals(&self) -> Result<Vec<ApprovalRecord>> {
1148 self.store.approvals().list_pending()
1149 }
1150
1151 pub fn list_tool_runs(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
1155 self.store.tool_runs().list(limit)
1156 }
1157
1158 pub fn list_checkpoints(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1162 self.store.checkpoints().list(limit)
1163 }
1164
1165 pub fn list_plugins(&self) -> Result<Vec<PluginInstallRecord>> {
1169 self.store.plugins().list()
1170 }
1171
1172 pub fn process_log(&self, id: &str, tail_bytes: Option<u64>) -> Result<RuntimeProcessLog> {
1176 let process = self
1177 .store
1178 .processes()
1179 .get(id)?
1180 .with_context(|| format!("process not found: {id}"))?;
1181 let path = process
1182 .log_path
1183 .with_context(|| format!("process has no log path: {id}"))?;
1184 let tail = tail_bytes.unwrap_or(32 * 1024).min(512 * 1024);
1185 use std::io::{Read, Seek, SeekFrom};
1190 let mut file =
1191 std::fs::File::open(&path).with_context(|| format!("failed to read {path}"))?;
1192 let len = file.metadata().map(|m| m.len()).unwrap_or(0);
1193 let start = len.saturating_sub(tail);
1194 if start > 0 {
1195 file.seek(SeekFrom::Start(start))
1196 .with_context(|| format!("failed to seek {path}"))?;
1197 }
1198 let mut bytes = Vec::new();
1199 file.take(tail)
1200 .read_to_end(&mut bytes)
1201 .with_context(|| format!("failed to read {path}"))?;
1202 Ok(RuntimeProcessLog {
1203 ok: true,
1204 content: String::from_utf8_lossy(&bytes).into_owned(),
1205 })
1206 }
1207
1208 pub fn stop_process(&self, id: &str) -> Result<ProcessRecord> {
1212 let process = self
1213 .store
1214 .processes()
1215 .get(id)?
1216 .with_context(|| format!("process not found: {id}"))?;
1217 mermaid_model::utils::terminate_tree_blocking(
1220 process.pid,
1221 mermaid_model::utils::Grace::Graceful,
1222 );
1223 self.store.processes().upsert(NewProcess {
1224 id: Some(process.id.clone()),
1225 task_id: process.task_id.clone(),
1226 pid: process.pid,
1227 command: process.command.clone(),
1228 cwd: process.cwd.clone(),
1229 log_path: process.log_path.clone(),
1230 detected_url: process.detected_url.clone(),
1231 status: ProcessStatus::Exited,
1232 health: Some("stopped".to_string()),
1233 })
1234 }
1235
1236 pub fn restart_process(&self, id: &str) -> Result<ProcessRecord> {
1240 let process = self
1241 .store
1242 .processes()
1243 .get(id)?
1244 .with_context(|| format!("process not found: {id}"))?;
1245 anyhow::ensure!(
1249 !mermaid_runtime::is_destructive_command(&process.command),
1250 "refusing to restart process {id}: command flagged destructive: {:?}",
1251 process.command
1252 );
1253 mermaid_model::utils::terminate_tree_blocking(
1254 process.pid,
1255 mermaid_model::utils::Grace::Graceful,
1256 );
1257 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1262 while pid_alive(process.pid) {
1263 if std::time::Instant::now() >= deadline {
1264 tracing::warn!(
1265 pid = process.pid,
1266 "restart_process: old pid still alive after 5s; respawning anyway"
1267 );
1268 break;
1269 }
1270 std::thread::sleep(std::time::Duration::from_millis(100));
1271 }
1272 let mut command = Command::new("sh");
1273 command.arg("-c").arg(&process.command);
1274 #[cfg(unix)]
1278 command.process_group(0);
1279 if let Some(cwd) = process.cwd.as_deref() {
1280 command.current_dir(cwd);
1281 }
1282 if let Some(path) = process.log_path.as_deref() {
1283 let file = std::fs::OpenOptions::new()
1284 .create(true)
1285 .append(true)
1286 .open(path)
1287 .with_context(|| format!("failed to open process log {path}"))?;
1288 let stderr = file.try_clone()?;
1289 command
1290 .stdout(Stdio::from(file))
1291 .stderr(Stdio::from(stderr));
1292 }
1293 let child = command.spawn()?;
1294 self.store.processes().upsert(NewProcess {
1295 id: Some(process.id),
1296 task_id: process.task_id,
1297 pid: child.id(),
1298 command: process.command,
1299 cwd: process.cwd,
1300 log_path: process.log_path,
1301 detected_url: process.detected_url,
1302 status: ProcessStatus::Running,
1303 health: Some("restarted".to_string()),
1304 })
1305 }
1306
1307 pub fn open_process(&self, id: &str) -> Result<RuntimeProcessOpen> {
1311 let process = self
1312 .store
1313 .processes()
1314 .get(id)?
1315 .with_context(|| format!("process not found: {id}"))?;
1316 let target = process
1317 .detected_url
1318 .or(process.log_path)
1319 .with_context(|| format!("process has no URL or log path: {id}"))?;
1320 validate_open_target(&target)?;
1321 mermaid_model::utils::open_file(target.clone());
1322 Ok(RuntimeProcessOpen { ok: true, target })
1323 }
1324
1325 pub fn resolve_open_target(&self, target: &str) -> Result<String> {
1329 if let Some(process) = self.store.processes().get(target)?
1330 && let Some(target) = process.detected_url.or(process.log_path)
1331 {
1332 return Ok(target);
1333 }
1334 Ok(target.to_string())
1335 }
1336
1337 pub fn ports(&self) -> Result<RuntimePorts> {
1341 let output = if cfg!(windows) {
1342 Command::new("netstat").arg("-ano").output()?
1343 } else {
1344 Command::new("sh")
1345 .arg("-c")
1346 .arg("ss -ltnp 2>/dev/null || netstat -ltnp 2>/dev/null || true")
1347 .output()?
1348 };
1349 Ok(RuntimePorts {
1350 ok: true,
1351 ports: String::from_utf8_lossy(&output.stdout).into_owned(),
1352 })
1353 }
1354
1355 pub fn set_plugin_enabled(&self, id: &str, enabled: bool) -> Result<()> {
1359 self.store.plugins().set_enabled(id, enabled)?;
1360 mermaid_runtime::write_plugin_lockfile()?;
1361 Ok(())
1362 }
1363
1364 pub fn set_safety_mode(&self, mode: &str) -> Result<mermaid_domain::SafetyConfig> {
1368 let parsed = mermaid_runtime::SafetyMode::parse(mode)
1369 .ok_or_else(|| anyhow::anyhow!("unknown safety mode: {mode}"))?;
1370 crate::app::update_user_config_key(
1373 &["safety", "mode"],
1374 toml::Value::String(parsed.as_str().to_string()),
1375 )?;
1376 Ok(crate::app::load_config()?.safety)
1377 }
1378
1379 pub fn model_info(&self, model: &str) -> Value {
1380 let snapshot = mermaid_domain::ProviderCapabilitySnapshot::from_model_id(model);
1381 for (key, value, confidence) in [
1382 (
1383 "supports_tools",
1384 snapshot.supports_tools.to_string(),
1385 "static",
1386 ),
1387 (
1388 "supports_vision",
1389 snapshot.supports_vision.to_string(),
1390 "static",
1391 ),
1392 ("reasoning", snapshot.reasoning.clone(), "static"),
1393 (
1394 "max_context_tokens",
1395 snapshot
1396 .max_context_tokens
1397 .map(|value| value.to_string())
1398 .unwrap_or_else(|| "unknown".to_string()),
1399 "static",
1400 ),
1401 ] {
1402 let _ = self.store.provider_probes().upsert(NewProviderProbe {
1403 provider: snapshot.provider.clone(),
1404 model_id: snapshot.model.clone(),
1405 capability_key: key.to_string(),
1406 capability_value: value,
1407 confidence: confidence.to_string(),
1408 error: None,
1409 });
1410 }
1411 if let Some(profile) = mermaid_model::models::lookup_provider(&snapshot.provider) {
1412 record_static_provider_probes(
1413 &self.store,
1414 profile,
1415 &snapshot.provider,
1416 &snapshot.model,
1417 );
1418 }
1419 json!({
1420 "id": model,
1421 "provider": snapshot.provider,
1422 "model": snapshot.model,
1423 "supports_tools": snapshot.supports_tools,
1424 "supports_vision": snapshot.supports_vision,
1425 "reasoning": snapshot.reasoning,
1426 "max_context_tokens": snapshot.max_context_tokens,
1427 })
1428 }
1429
1430 pub fn approval_decision(result: ApprovalReplayResult) -> Result<RuntimeApprovalDecision> {
1434 Ok(RuntimeApprovalDecision {
1435 ok: true,
1436 approval: result.approval,
1437 replayed: result.replayed,
1438 summary: result.summary,
1439 })
1440 }
1441
1442 fn raw_hygiene_preview(&self) -> Result<RawRuntimeHygienePreview> {
1443 let checkpoints = self
1444 .store
1445 .checkpoints()
1446 .list_all(10_000)?
1447 .into_iter()
1448 .filter(|checkpoint| checkpoint.archived_at.is_none())
1449 .collect::<Vec<_>>();
1450 let test_checkpoint_ids = checkpoints
1451 .iter()
1452 .filter(|checkpoint| is_runtime_hygiene_checkpoint(checkpoint))
1453 .map(|checkpoint| checkpoint.id.clone())
1454 .collect::<HashSet<_>>();
1455 let approvals = self
1456 .store
1457 .approvals()
1458 .list_all(10_000)?
1459 .into_iter()
1460 .filter(|approval| approval.archived_at.is_none())
1461 .filter(|approval| is_runtime_hygiene_approval(approval, &test_checkpoint_ids))
1462 .collect::<Vec<_>>();
1463 let checkpoints = checkpoints
1464 .into_iter()
1465 .filter(is_runtime_hygiene_checkpoint)
1466 .collect::<Vec<_>>();
1467
1468 Ok(RawRuntimeHygienePreview {
1469 approvals,
1470 checkpoints,
1471 })
1472 }
1473}
1474
1475#[derive(Debug)]
1476struct RawRuntimeHygienePreview {
1477 approvals: Vec<ApprovalRecord>,
1478 checkpoints: Vec<CheckpointRecord>,
1479}
1480
1481#[must_use]
1482pub fn runtime_hygiene_reason() -> &'static str {
1483 "runtime hygiene: test/dev artifact"
1484}
1485
1486pub fn record_static_provider_probes(
1491 store: &RuntimeStore,
1492 profile: &mermaid_model::models::ProviderProfile,
1493 provider: &str,
1494 model_id: &str,
1495) {
1496 for (key, value) in [
1497 (
1498 "max_output_tokens_param",
1499 format!("{:?}", profile.max_tokens_param),
1500 ),
1501 (
1502 "parallel_tool_calls",
1503 (!profile.disable_parallel_tool_calls_for.contains(&model_id)).to_string(),
1504 ),
1505 (
1506 "reasoning_parameter_shape",
1507 format!("{:?}", profile.reasoning_strategy),
1508 ),
1509 (
1510 "streaming_usage_available",
1511 "provider_dependent".to_string(),
1512 ),
1513 ("token_usage_field_shape", "openai_compatible".to_string()),
1514 ] {
1515 let _ = store.provider_probes().upsert(NewProviderProbe {
1516 provider: provider.to_string(),
1517 model_id: model_id.to_string(),
1518 capability_key: key.to_string(),
1519 capability_value: value,
1520 confidence: "static".to_string(),
1521 error: None,
1522 });
1523 }
1524}
1525
1526#[must_use]
1527pub fn parse_optional_json(raw: Option<&str>) -> Value {
1528 raw.and_then(|value| serde_json::from_str(value).ok())
1529 .unwrap_or(Value::Null)
1530}
1531
1532#[must_use]
1533pub fn affected_paths_from_json(pending_action: &Value, changed_files: &Value) -> Vec<String> {
1534 let mut paths = Vec::new();
1535 collect_path_like_strings(changed_files, &mut paths);
1536 collect_path_like_strings(pending_action, &mut paths);
1537 paths.sort();
1538 paths.dedup();
1539 paths.truncate(25);
1540 paths
1541}
1542
1543fn collect_path_like_strings(value: &Value, paths: &mut Vec<String>) {
1544 match value {
1545 Value::String(value) if looks_path_like(value) => {
1546 paths.push(value.clone());
1547 },
1548 Value::Array(items) => {
1549 for item in items {
1550 collect_path_like_strings(item, paths);
1551 }
1552 },
1553 Value::Object(object) => {
1554 for (key, value) in object {
1555 match value {
1556 Value::String(text)
1557 if matches!(
1558 key.as_str(),
1559 "path"
1560 | "file"
1561 | "file_path"
1562 | "target"
1563 | "project_path"
1564 | "snapshot_path"
1565 | "cwd"
1566 ) =>
1567 {
1568 if !text.trim().is_empty() {
1569 paths.push(text.clone());
1570 }
1571 },
1572 other => collect_path_like_strings(other, paths),
1573 }
1574 }
1575 },
1576 _ => {},
1577 }
1578}
1579
1580fn looks_path_like(value: &str) -> bool {
1581 let trimmed = value.trim();
1582 trimmed.starts_with('/')
1583 || trimmed.starts_with("./")
1584 || trimmed.starts_with("../")
1585 || trimmed.contains(std::path::MAIN_SEPARATOR)
1586}
1587
1588fn is_runtime_hygiene_checkpoint(checkpoint: &CheckpointRecord) -> bool {
1589 checkpoint.project_path.starts_with("/tmp/mermaid_")
1590}
1591
1592fn is_runtime_hygiene_approval(
1593 approval: &ApprovalRecord,
1594 test_checkpoint_ids: &HashSet<String>,
1595) -> bool {
1596 let is_restore_replay = approval.risk_classification == "restored_action"
1597 && approval.proposed_action.starts_with("restore replay:");
1598 if !is_restore_replay {
1599 return false;
1600 }
1601 match approval.checkpoint_id.as_deref() {
1602 Some(checkpoint_id) => test_checkpoint_ids.contains(checkpoint_id),
1603 None => true,
1604 }
1605}
1606
1607fn daemon_failure_is_pre_send(err: &anyhow::Error) -> bool {
1624 #[cfg(not(unix))]
1625 {
1626 let _ = err;
1627 true
1628 }
1629 #[cfg(unix)]
1630 {
1631 match err.downcast_ref::<std::io::Error>() {
1632 Some(io) => matches!(
1633 io.kind(),
1634 std::io::ErrorKind::NotFound
1635 | std::io::ErrorKind::ConnectionRefused
1636 | std::io::ErrorKind::PermissionDenied
1637 ),
1638 None => false,
1639 }
1640 }
1641}
1642
1643fn pid_alive(pid: u32) -> bool {
1646 if cfg!(windows) {
1647 Command::new("tasklist")
1648 .args(["/FI", &format!("PID eq {pid}"), "/NH"])
1649 .output()
1650 .map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
1651 .unwrap_or(false)
1652 } else {
1653 Command::new("kill")
1654 .args(["-0", &pid.to_string()])
1655 .status()
1656 .map(|s| s.success())
1657 .unwrap_or(false)
1658 }
1659}
1660
1661pub(crate) fn validate_open_target(target: &str) -> Result<()> {
1665 if target.contains("://") {
1668 let url = reqwest::Url::parse(target)
1669 .with_context(|| format!("refusing to open unparseable URL target: {target:?}"))?;
1670 anyhow::ensure!(
1671 matches!(url.scheme(), "http" | "https"),
1672 "refusing to open non-http(s) URL target: {target:?}"
1673 );
1674 return Ok(());
1677 }
1678 let meta = std::fs::metadata(target)
1682 .with_context(|| format!("refusing to open missing target: {target:?}"))?;
1683 anyhow::ensure!(
1684 meta.is_file(),
1685 "refusing to open non-regular-file target: {target:?}"
1686 );
1687 Ok(())
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692 use super::*;
1693 use std::path::PathBuf;
1694
1695 fn temp_db(name: &str) -> PathBuf {
1696 let dir = std::env::temp_dir().join(format!("mermaid_runtime_client_{name}"));
1697 let _ = std::fs::remove_dir_all(&dir);
1698 std::fs::create_dir_all(&dir).expect("create temp dir");
1699 dir.join("runtime.sqlite3")
1700 }
1701
1702 #[test]
1703 fn pid_alive_detects_live_and_dead() {
1704 assert!(pid_alive(std::process::id()));
1706 #[cfg(unix)]
1708 let mut child = Command::new("true").spawn().expect("spawn true");
1709 #[cfg(windows)]
1710 let mut child = Command::new("cmd")
1711 .args(["/C", "exit 0"])
1712 .spawn()
1713 .expect("spawn exit");
1714 let pid = child.id();
1715 let _ = child.wait();
1716 std::thread::sleep(std::time::Duration::from_millis(250));
1717 assert!(!pid_alive(pid));
1718 }
1719
1720 #[cfg(unix)]
1721 #[test]
1722 fn daemon_failure_pre_send_only_for_connect_kinds() {
1723 use anyhow::Context as _;
1726 use std::io::{Error as IoError, ErrorKind};
1727
1728 let refused: anyhow::Error = Err::<(), _>(IoError::from(ErrorKind::ConnectionRefused))
1731 .with_context(|| "failed to connect to /run/mermaidd.sock".to_string())
1732 .unwrap_err();
1733 assert!(daemon_failure_is_pre_send(&refused));
1734 let missing: anyhow::Error = Err::<(), _>(IoError::from(ErrorKind::NotFound))
1735 .with_context(|| "failed to connect to /run/mermaidd.sock".to_string())
1736 .unwrap_err();
1737 assert!(daemon_failure_is_pre_send(&missing));
1738
1739 assert!(!daemon_failure_is_pre_send(&anyhow::anyhow!(
1742 "daemon returned invalid JSON"
1743 )));
1744 let broken = anyhow::Error::from(IoError::from(ErrorKind::BrokenPipe));
1746 assert!(!daemon_failure_is_pre_send(&broken));
1747 assert!(!daemon_failure_is_pre_send(&anyhow::anyhow!(
1749 "stop_process failed"
1750 )));
1751 }
1752
1753 #[test]
1754 fn validate_open_target_allows_http_rejects_file_and_js() {
1755 assert!(super::validate_open_target("http://localhost:3000").is_ok());
1756 assert!(super::validate_open_target("https://localhost:8443/app").is_ok());
1757 assert!(super::validate_open_target("file:///etc/passwd").is_err());
1758 assert!(super::validate_open_target("javascript:alert(1)").is_err());
1759 assert!(super::validate_open_target("data:text/html,<x>").is_err());
1760 assert!(super::validate_open_target("/no/such/path/xyz.log").is_err());
1761 }
1762
1763 #[test]
1764 fn restart_process_refuses_destructive_command() {
1765 let path = temp_db("restart_destructive");
1766 let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1767 let rec = service
1768 .store
1769 .processes()
1770 .upsert(NewProcess {
1771 id: Some("p-destruct".to_string()),
1772 task_id: None,
1773 pid: 0,
1774 command: "rm -rf /".to_string(),
1775 cwd: None,
1776 log_path: None,
1777 detected_url: None,
1778 status: ProcessStatus::Running,
1779 health: None,
1780 })
1781 .expect("seed process");
1782 let err = service.restart_process(&rec.id).unwrap_err();
1784 assert!(err.to_string().contains("destructive"), "{err}");
1785 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1786 }
1787
1788 #[test]
1789 fn open_process_rejects_file_url_target() {
1790 let path = temp_db("open_file_url");
1791 let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1792 let rec = service
1793 .store
1794 .processes()
1795 .upsert(NewProcess {
1796 id: Some("p-open".to_string()),
1797 task_id: None,
1798 pid: 0,
1799 command: "true".to_string(),
1800 cwd: None,
1801 log_path: None,
1802 detected_url: Some("file:///etc/passwd".to_string()),
1803 status: ProcessStatus::Running,
1804 health: None,
1805 })
1806 .expect("seed process");
1807 assert!(service.open_process(&rec.id).is_err());
1809 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1810 }
1811
1812 #[test]
1813 fn local_client_lists_tasks_from_store() {
1814 let path = temp_db("tasks");
1815 let service = RuntimeService::from_store(RuntimeStore::open(&path).expect("open store"));
1816 let task = service
1817 .store
1818 .tasks()
1819 .create(mermaid_runtime::NewTask::new(
1820 "contract task",
1821 "/tmp/mermaid_runtime_client",
1822 "openai/test",
1823 ))
1824 .expect("task");
1825 let tasks = service.list_tasks(10).expect("list");
1826 assert_eq!(
1827 tasks.first().map(|item| item.id.as_str()),
1828 Some(task.id.as_str())
1829 );
1830 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1831 }
1832
1833 #[test]
1834 fn hygiene_preview_matches_test_artifacts_and_archive_is_idempotent() {
1835 let path = temp_db("hygiene");
1836 let store = RuntimeStore::open(&path).expect("open store");
1837 let checkpoint = store
1838 .checkpoints()
1839 .create(mermaid_runtime::NewCheckpoint {
1840 id: Some("checkpoint-test".to_string()),
1841 task_id: None,
1842 project_path: "/tmp/mermaid_checkpoint_test".to_string(),
1843 snapshot_path: "/data/checkpoints/checkpoint-test".to_string(),
1844 changed_files_json: "[]".to_string(),
1845 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1846 approval_id: None,
1847 session_id: None,
1848 message_index: None,
1849 })
1850 .expect("create checkpoint");
1851 let approval = store
1852 .approvals()
1853 .create(mermaid_runtime::NewApproval {
1854 task_id: None,
1855 proposed_action: "restore replay: write_file".to_string(),
1856 risk_classification: "restored_action".to_string(),
1857 policy_decision: "ask".to_string(),
1858 args_summary: None,
1859 checkpoint_id: Some(checkpoint.id.clone()),
1860 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1861 })
1862 .expect("create approval");
1863 store
1864 .checkpoints()
1865 .set_approval(&checkpoint.id, &approval.id)
1866 .expect("link approval");
1867
1868 let service = RuntimeService::from_store(store);
1869 let preview = service.hygiene_preview().expect("preview");
1870 assert_eq!(preview.counts.approvals, 1);
1871 assert_eq!(preview.counts.checkpoints, 1);
1872 let archived = service.hygiene_archive().expect("archive");
1873 assert_eq!(archived.archived.total, 2);
1874 let archived_again = service.hygiene_archive().expect("archive again");
1875 assert_eq!(archived_again.archived.total, 0);
1876 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1877 }
1878}