Skip to main content

mermaid_cli/runtime_client/
client.rs

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    /// # Errors
278    ///
279    /// Errors if the daemon rejects the request or answers with an unexpected
280    /// shape, and -- when the call falls back to the local database -- if that read
281    /// fails. A daemon that is simply not running is not an error: the client falls
282    /// back.
283    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    /// # Errors
291    ///
292    /// Errors if the daemon rejects the request or answers with an unexpected
293    /// shape, and -- when the call falls back to the local database -- if that read
294    /// fails. A daemon that is simply not running is not an error: the client falls
295    /// back.
296    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    /// # Errors
304    ///
305    /// Errors if the daemon rejects the request or answers with an unexpected
306    /// shape, and -- when the call falls back to the local database -- if that read
307    /// fails. A daemon that is simply not running is not an error: the client falls
308    /// back.
309    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    /// # Errors
317    ///
318    /// Errors if the daemon rejects the request or answers with an unexpected
319    /// shape, and -- when the call falls back to the local database -- if that read
320    /// fails. A daemon that is simply not running is not an error: the client falls
321    /// back.
322    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    /// # Errors
330    ///
331    /// Errors if the daemon rejects the request or answers with an unexpected
332    /// shape, and -- when the call falls back to the local database -- if that read
333    /// fails. A daemon that is simply not running is not an error: the client falls
334    /// back.
335    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    /// # Errors
343    ///
344    /// Errors if the daemon rejects the action or answers with an unexpected shape,
345    /// and -- when the call falls back to acting on the local database -- if that
346    /// fails. A daemon that is simply not running is not an error: the client falls
347    /// back.
348    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    /// # Errors
357    ///
358    /// Errors if the daemon rejects the request or answers with an unexpected
359    /// shape, and -- when the call falls back to the local database -- if that read
360    /// fails. A daemon that is simply not running is not an error: the client falls
361    /// back.
362    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    /// # Errors
371    ///
372    /// Errors if the daemon rejects the request or answers with an unexpected
373    /// shape, and -- when the call falls back to the local database -- if that read
374    /// fails. A daemon that is simply not running is not an error: the client falls
375    /// back.
376    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    /// # Errors
385    ///
386    /// Errors if the daemon rejects the request or answers with an unexpected
387    /// shape, and -- when the call falls back to the local database -- if that read
388    /// fails. A daemon that is simply not running is not an error: the client falls
389    /// back.
390    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    /// # Errors
399    ///
400    /// Errors if the daemon rejects the action or answers with an unexpected shape,
401    /// and -- when the call falls back to acting on the local database -- if that
402    /// fails. A daemon that is simply not running is not an error: the client falls
403    /// back.
404    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    /// # Errors
415    ///
416    /// Errors if the daemon rejects the action or answers with an unexpected shape,
417    /// and -- when the call falls back to acting on the local database -- if that
418    /// fails. A daemon that is simply not running is not an error: the client falls
419    /// back.
420    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    /// # Errors
431    ///
432    /// Errors if the daemon rejects the action or answers with an unexpected shape,
433    /// and -- when the call falls back to acting on the local database -- if that
434    /// fails. A daemon that is simply not running is not an error: the client falls
435    /// back.
436    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    /// # Errors
444    ///
445    /// Errors if the daemon rejects the action or answers with an unexpected shape,
446    /// and -- when the call falls back to acting on the local database -- if that
447    /// fails. A daemon that is simply not running is not an error: the client falls
448    /// back.
449    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    /// # Errors
460    ///
461    /// Errors if the daemon rejects the action or answers with an unexpected shape,
462    /// and -- when the call falls back to acting on the local database -- if that
463    /// fails. A daemon that is simply not running is not an error: the client falls
464    /// back.
465    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    /// # Errors
476    ///
477    /// Errors if the daemon rejects the action or answers with an unexpected shape,
478    /// and -- when the call falls back to acting on the local database -- if that
479    /// fails. A daemon that is simply not running is not an error: the client falls
480    /// back.
481    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    /// # Errors
489    ///
490    /// Errors if the daemon rejects the action or answers with an unexpected shape,
491    /// and -- when the call falls back to acting on the local database -- if that
492    /// fails. A daemon that is simply not running is not an error: the client falls
493    /// back.
494    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    /// # Errors
506    ///
507    /// Errors if the daemon rejects the action or answers with an unexpected shape,
508    /// and -- when the call falls back to acting on the local database -- if that
509    /// fails. A daemon that is simply not running is not an error: the client falls
510    /// back.
511    pub fn stop_process(&self, id: &str) -> Result<RuntimeOne<ProcessRecord>> {
512        // Non-idempotent: `terminate_tree` must not fire twice (RC-G/F25).
513        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    /// # Errors
524    ///
525    /// Errors if the daemon rejects the action or answers with an unexpected shape,
526    /// and -- when the call falls back to acting on the local database -- if that
527    /// fails. A daemon that is simply not running is not an error: the client falls
528    /// back.
529    pub fn restart_process(&self, id: &str) -> Result<RuntimeOne<ProcessRecord>> {
530        // Non-idempotent: kills then respawns — a duplicate run double-signals
531        // (possibly a since-reused PID) and can spawn two servers (RC-G/F25).
532        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    /// # Errors
543    ///
544    /// Errors if the daemon rejects the action or answers with an unexpected shape,
545    /// and -- when the call falls back to acting on the local database -- if that
546    /// fails. A daemon that is simply not running is not an error: the client falls
547    /// back.
548    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    /// # Errors
556    ///
557    /// Errors if the daemon rejects the action or answers with an unexpected shape,
558    /// and -- when the call falls back to acting on the local database -- if that
559    /// fails. A daemon that is simply not running is not an error: the client falls
560    /// back.
561    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    /// # Errors
569    ///
570    /// Errors if the daemon rejects the action or answers with an unexpected shape,
571    /// and -- when the call falls back to acting on the local database -- if that
572    /// fails. A daemon that is simply not running is not an error: the client falls
573    /// back.
574    pub fn approve(&self, id: &str) -> Result<RuntimeApprovalDecision> {
575        // Non-idempotent: replays the approved action, so a duplicate run could
576        // execute that side effect twice (RC-G/F25).
577        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    /// # Errors
584    ///
585    /// Errors if the daemon rejects the action or answers with an unexpected shape,
586    /// and -- when the call falls back to acting on the local database -- if that
587    /// fails. A daemon that is simply not running is not an error: the client falls
588    /// back.
589    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    /// # Errors
597    ///
598    /// Errors if the daemon rejects the action or answers with an unexpected shape,
599    /// and -- when the call falls back to acting on the local database -- if that
600    /// fails. A daemon that is simply not running is not an error: the client falls
601    /// back.
602    pub fn restore_checkpoint(&self, id: &str) -> Result<RuntimeCheckpointRestore> {
603        // Non-idempotent: rewrites working-tree files from the snapshot — a
604        // duplicate run could clobber edits made between the two runs (RC-G/F25).
605        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    /// # Errors
618    ///
619    /// Errors if the daemon rejects the action or answers with an unexpected shape,
620    /// and -- when the call falls back to acting on the local database -- if that
621    /// fails. A daemon that is simply not running is not an error: the client falls
622    /// back.
623    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    /// # Errors
639    ///
640    /// Errors if the daemon rejects the action or answers with an unexpected shape,
641    /// and -- when the call falls back to acting on the local database -- if that
642    /// fails. A daemon that is simply not running is not an error: the client falls
643    /// back.
644    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    /// # Errors
655    ///
656    /// Errors if the daemon rejects the action or answers with an unexpected shape,
657    /// and -- when the call falls back to acting on the local database -- if that
658    /// fails. A daemon that is simply not running is not an error: the client falls
659    /// back.
660    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        // #21: attach the pairing token when the client has one. Reads are now
693        // gated server-side (`command_requires_auth`), so a `daemon_with_token`
694        // client must send its token to read off the socket; a tokenless
695        // `auto()` client sends nothing and `PreferDaemon` falls back to a local
696        // DB read on the daemon's auth rejection. `request_daemon` only attaches
697        // the token if one is present, so tokenless clients are unchanged, and
698        // ungated commands (`health`/`ports`) are served regardless.
699        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    /// Like [`Self::action_authed`], but for a NON-idempotent side effect
719    /// (`stop`/`restart`/`approve`/`restore`). RC-G/F25: under `PreferDaemon` a
720    /// local fallback *re-runs* the action, which is only safe when the daemon
721    /// never received the request (a pre-send connection failure). On a
722    /// post-send or ambiguous failure the daemon MAY have already executed it,
723    /// so we surface an error instead of risking a duplicate side effect.
724    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                    // RC-G/F25: an idempotent or read-only action falls back to
779                    // a local run freely. A non-idempotent one falls back ONLY
780                    // when the failure is a pre-send connection failure (the
781                    // request never reached the daemon); otherwise the daemon
782                    // may have already executed it and re-running locally would
783                    // double-fire the side effect (e.g. a second
784                    // `terminate_tree` on a since-reused PID). Conservative
785                    // default: treat any non-connect failure as "may have run".
786                    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    /// # Errors
811    ///
812    /// Errors if the runtime database cannot be opened, created, or migrated.
813    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    /// # Errors
824    ///
825    /// Errors if a store query fails.
826    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    /// # Errors
835    ///
836    /// Errors if a store query fails.
837    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            // Redact token hashes — this snapshot is served over the local
851            // socket to same-UID processes without auth.
852            pairings: self.store.pairing_tokens().list_redacted()?,
853            safety: crate::app::load_config().unwrap_or_default().safety,
854        })
855    }
856
857    /// # Errors
858    ///
859    /// Errors if a store query fails.
860    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    /// # Errors
918    ///
919    /// Errors if a store query fails.
920    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    /// # Errors
941    ///
942    /// Errors if a store query fails.
943    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    /// # Errors
959    ///
960    /// Errors if a store query fails.
961    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    /// # Errors
994    ///
995    /// Errors if no task has that id, or if a store query fails.
996    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    /// # Errors
1062    ///
1063    /// Errors if no approval has that id, or if a store query fails.
1064    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    /// # Errors
1099    ///
1100    /// Errors if no checkpoint has that id, or if a store query fails.
1101    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    /// # Errors
1131    ///
1132    /// Errors if a store query fails.
1133    pub fn list_tasks(&self, limit: usize) -> Result<Vec<TaskRecord>> {
1134        self.store.tasks().list(limit)
1135    }
1136
1137    /// # Errors
1138    ///
1139    /// Errors if a store query fails.
1140    pub fn list_processes(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
1141        self.store.processes().list(limit)
1142    }
1143
1144    /// # Errors
1145    ///
1146    /// Errors if a store query fails.
1147    pub fn list_approvals(&self) -> Result<Vec<ApprovalRecord>> {
1148        self.store.approvals().list_pending()
1149    }
1150
1151    /// # Errors
1152    ///
1153    /// Errors if a store query fails.
1154    pub fn list_tool_runs(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
1155        self.store.tool_runs().list(limit)
1156    }
1157
1158    /// # Errors
1159    ///
1160    /// Errors if a store query fails.
1161    pub fn list_checkpoints(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1162        self.store.checkpoints().list(limit)
1163    }
1164
1165    /// # Errors
1166    ///
1167    /// Errors if a store query fails.
1168    pub fn list_plugins(&self) -> Result<Vec<PluginInstallRecord>> {
1169        self.store.plugins().list()
1170    }
1171
1172    /// # Errors
1173    ///
1174    /// Errors if no record has that id, or if a store query fails.
1175    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        // Seek to the tail rather than reading the whole file: a long-running
1186        // dev server can produce a multi-GB log, and we only ever return the
1187        // last `tail` bytes. `std::fs::read` would have pinned the whole file
1188        // in RAM first (#41).
1189        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    /// # Errors
1209    ///
1210    /// Errors if no record has that id, or if a store query fails.
1211    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        // Best-effort group kill (SIGTERM → grace → SIGKILL) so workers the dev
1218        // server forked die with it; then mark the row stopped regardless.
1219        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    /// # Errors
1237    ///
1238    /// Errors if no record has that id, or if a store query fails.
1239    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        // #63: the command comes from a `processes` row; a tampered DB could swap
1246        // in a destructive command. Refuse to respawn it (mirrors exec.rs's
1247        // execute_command pre-check) before killing or spawning anything.
1248        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        // Wait (bounded) for the old PID to actually exit before respawning —
1258        // the kill above is fire-and-forget (async signal / taskkill), so an
1259        // immediate respawn can race the old process for its listening port.
1260        // Poll until it's gone, then fail-open after 5s.
1261        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        // Lead a new process group so `/stop`/`/restart` can group-kill the
1275        // whole tree (the dev server plus any worker it forks), matching the
1276        // foreground exec path. Windows kills the tree by pid via taskkill /T.
1277        #[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    /// # Errors
1308    ///
1309    /// Errors if no record has that id, or if a store query fails.
1310    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    /// # Errors
1326    ///
1327    /// Errors if a store query fails.
1328    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    /// # Errors
1338    ///
1339    /// Errors if a store query fails.
1340    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    /// # Errors
1356    ///
1357    /// Errors if a store query fails.
1358    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    /// # Errors
1365    ///
1366    /// Errors if a store query fails.
1367    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        // Rewrite only `safety.mode` in the user file (never the whole merged
1371        // config), then report back the resulting user-scope safety section.
1372        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    /// # Errors
1431    ///
1432    /// Errors if a store query fails.
1433    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
1486/// Record the statically-known capability probes a provider profile implies.
1487/// Both `mermaid providers probe` and the runtime client's model view persist
1488/// this same table, so it lives here once — adding a capability in one place
1489/// can no longer silently miss the other.
1490pub 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
1607/// Whether a daemon-request failure happened *before* the request reached the
1608/// daemon — i.e. the Unix-socket `connect()` itself failed (daemon not running,
1609/// socket missing, or connection refused), so the action was never delivered
1610/// and is safe to re-run locally (RC-G/F25).
1611///
1612/// `request_daemon_text` wraps ONLY the `UnixStream::connect` error (with a
1613/// "failed to connect to …" context) and propagates post-connect write/read I/O
1614/// failures bare; a lost reply from a daemon that crashed *after* executing the
1615/// action surfaces as a JSON-parse error, never an I/O error. The connect-phase
1616/// `io::ErrorKind`s below (`NotFound`/`ConnectionRefused`/`PermissionDenied`)
1617/// are therefore unreachable from a post-send failure on this transport, so
1618/// matching them is a precise "never reached the daemon" signal. Everything else
1619/// — post-connect write/read I/O errors, empty/invalid-JSON replies, or a
1620/// daemon-level `ok:false` — is treated conservatively as "the action may have
1621/// already run". On platforms without Unix-socket IPC the transport bails before
1622/// connecting, so the request provably never reached a daemon → always pre-send.
1623fn 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
1643/// Best-effort synchronous liveness check for a pid. The runtime client is sync,
1644/// so it can't reuse the async `process_running` in `providers::tool::exec`.
1645fn 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
1661/// Validate a process "open" target before handing it to the OS opener (#63 —
1662/// defense-in-depth for a tampered local `processes` row). The target is either
1663/// a detected URL (a local dev server) or a log-file path mermaid wrote.
1664pub(crate) fn validate_open_target(target: &str) -> Result<()> {
1665    // Gate on the `://` authority so a bare path or Windows drive (`C:\…`, which
1666    // `Url::parse` would read as scheme "c") isn't misclassified as a URL.
1667    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        // Dev-server URLs are loopback by design, so we deliberately do NOT apply
1675        // an SSRF host-class block here — the scheme allowlist is the control.
1676        return Ok(());
1677    }
1678    // Not a URL → a filesystem path (the process log). Open only an existing
1679    // regular file; this also rejects opaque `javascript:`/`data:` URIs, which
1680    // have no `://` and so fail the regular-file check.
1681    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        // Our own process is alive.
1705        assert!(pid_alive(std::process::id()));
1706        // A reaped child is dead (#6 — the restart wait polls this).
1707        #[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        // F25: distinguish "never reached the daemon" (safe to re-run locally)
1724        // from "may have already run" (must not re-run a non-idempotent action).
1725        use anyhow::Context as _;
1726        use std::io::{Error as IoError, ErrorKind};
1727
1728        // Connect-phase failures — the request never left the socket. These are
1729        // wrapped exactly as `request_daemon_text` wraps its connect error.
1730        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        // A lost reply from a daemon that crashed AFTER executing surfaces as a
1740        // JSON-parse error — ambiguous, may have run.
1741        assert!(!daemon_failure_is_pre_send(&anyhow::anyhow!(
1742            "daemon returned invalid JSON"
1743        )));
1744        // A post-connect write failure (broken pipe) is ambiguous — may have run.
1745        let broken = anyhow::Error::from(IoError::from(ErrorKind::BrokenPipe));
1746        assert!(!daemon_failure_is_pre_send(&broken));
1747        // A daemon-level `ok:false` error definitely reached the daemon.
1748        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        // Refused before kill/spawn (pid 0 is never signaled).
1783        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        // Errors before open_file — nothing is launched.
1808        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}